From 788b6e2fa2c10a1824e70f7548bd120acb286e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:29:46 +0000 Subject: [PATCH 1/2] wip(spec,core): PluginSchema requires staticPath/slug for ui; Plugin derives from PluginDefinition Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .changeset/plugin-schema-ui-required-keys.md | 27 ++++ .../src/plugin-contract-enforcement.test.ts | 101 +++++++++++-- packages/core/src/plugin-loader.ts | 9 ++ .../core/src/plugin-type-closed-set.test.ts | 14 +- packages/core/src/types.ts | 74 +++++---- .../src/ui-plugin-auto-discovery.pin.test.ts | 74 +++++---- .../src/plugin-type-closed-set.pin.test.ts | 7 + .../src/dispatcher-error-vocabulary.ts | 25 ++++ .../kernel/plugin-ui-required-keys.test.ts | 140 ++++++++++++++++++ packages/spec/src/kernel/plugin.zod.ts | 67 +++++++++ 10 files changed, 466 insertions(+), 72 deletions(-) create mode 100644 .changeset/plugin-schema-ui-required-keys.md create mode 100644 packages/spec/src/kernel/plugin-ui-required-keys.test.ts diff --git a/.changeset/plugin-schema-ui-required-keys.md b/.changeset/plugin-schema-ui-required-keys.md new file mode 100644 index 0000000000..aec5e24dbc --- /dev/null +++ b/.changeset/plugin-schema-ui-required-keys.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +`PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them. + +**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06). + +**Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change. + +**What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else: + +``` +PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared +plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` +plugin must declare `staticPath` — the absolute path of the static assets it +serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets. +``` + +**The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored. + +**`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface. + +**Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add. + + diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts index ac93b56cba..b7b1776de0 100644 --- a/packages/core/src/plugin-contract-enforcement.test.ts +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest'; import { ObjectKernel } from './kernel.js'; import { PluginLoader } from './plugin-loader.js'; import { ObjectLogger } from './logger.js'; +import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel'; import type { Plugin, PluginContext } from './types.js'; /** A kernel that registers plugins and installs no process signal handlers. */ @@ -48,17 +49,21 @@ function stored(kernel: ObjectKernel, name: string): Record | u } /** - * A plugin object with an arbitrary extra surface. The keys under test - * (`type`, `slug`, `homepage`, `id`) are declared by `PluginSchema` and NOT by - * the `Plugin` interface, which is one reason the repo contained no producer of - * them — so the fixture states the extra surface rather than casting it away. + * A plugin object under test. The keys under test (`type`, `slug`, `homepage`, + * `id`, `staticPath`) used to be declared by `PluginSchema` and NOT by the + * `Plugin` interface — one reason the repo contained no producer of them, and + * why this alias once had to widen `Plugin` to spell them. Since #16334 + * `Plugin` inherits every `PluginSchema` key through `PluginDefinition`, so a + * plain `Plugin` states the whole surface; the alias survives as the name. */ -type Fixture = Plugin & { - id?: string; - slug?: string; - homepage?: string; - staticPath?: string; -}; +type Fixture = Plugin; + +/** + * A `type: 'ui'` fixture owes `staticPath` and `slug` (#16334), so every `ui` + * fixture below carries both unless the case is ABOUT one of them. Nothing at + * `kernel.use()` reads the path off disk — the loader validates the object. + */ +const UI_STATIC_PATH = '/srv/os-fixture/ui/dist'; /** * The refusal `promise` produced, or a loud failure if it produced none. @@ -93,6 +98,10 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, // The value #15638 MEASURED as accepted, stored verbatim and mounting // routes. It is not a member of `CORE_PLUGIN_TYPES`. type: 'ui-plugin' as unknown as Plugin['type'], + // Both `ui` keys declared (#16334), so the calibration twin below + // differs from this fixture in `type` and nothing else. + staticPath: UI_STATIC_PATH, + slug: 'legacy-ui', }); await expect(kernel.use(legacy)).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/); @@ -111,7 +120,12 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638, it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => { const kernel = makeKernel(); - const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' }); + const modern = fixture({ + name: '@os-fixture/modern-ui', + type: 'ui', + staticPath: UI_STATIC_PATH, + slug: 'modern-ui', + }); await expect(kernel.use(modern)).resolves.toBe(kernel); expect(stored(kernel, '@os-fixture/modern-ui')?.type).toBe('ui'); @@ -208,7 +222,7 @@ describe('C — ⭐ a CLASS-BASED plugin still loads, prototype chain intact', ( describe('D — the other two refusals the changeset states', () => { it('refuses an invalid `slug`', async () => { const kernel = makeKernel(); - const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' }); + const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'Not A Slug' }); const err = await refusal(kernel.use(bad)); expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); @@ -217,7 +231,7 @@ describe('D — the other two refusals the changeset states', () => { it('CALIBRATION — the same fixture with a legal slug loads', async () => { const kernel = makeKernel(); - const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' }); + const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'not-a-slug' }); await expect(kernel.use(good)).resolves.toBe(kernel); }); @@ -239,6 +253,67 @@ describe('D — the other two refusals the changeset states', () => { }); }); +describe('F — a `ui` plugin owes `staticPath` and `slug`, refused at kernel.use() (#16334)', () => { + /** + * The spec half of #16049: `PluginSchema` describes both keys as + * `(Required for type="ui")` and, since #16334, refuses a `ui` plugin + * missing either — one issue per missing key, `path` naming the key, + * `PLUGIN_UI_REQUIRED_KEY_MISSING` at the head of the issue message. These + * pins measure that the boot path SURFACES that code unchanged: the loader + * re-emits the first issue's `path` and `message`, so the spec's code rides + * inside `PLUGIN_CONTRACT_VIOLATION`'s envelope. Group B's untyped and + * `standard` fixtures, which declare neither key and load, are the scope + * control: only `type: 'ui'` owes them. + */ + it('refuses a `ui` plugin with no `staticPath`, naming the key and the spec code', async () => { + const kernel = makeKernel(); + const bad = fixture({ name: '@os-fixture/ui-no-static-path', type: 'ui', slug: 'ui-no-static-path' }); + + const err = await refusal(kernel.use(bad)); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'staticPath'"); + expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING); + expect(stored(kernel, '@os-fixture/ui-no-static-path')).toBeUndefined(); + }); + + it('refuses a `ui` plugin with no `slug`, naming the key and the spec code', async () => { + const kernel = makeKernel(); + const bad = fixture({ name: '@os-fixture/ui-no-slug', type: 'ui', staticPath: UI_STATIC_PATH }); + + const err = await refusal(kernel.use(bad)); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'slug'"); + expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING); + }); + + it('CALIBRATION — the same `ui` fixture with both keys loads, stored verbatim', async () => { + const kernel = makeKernel(); + const good = fixture({ name: '@os-fixture/ui-complete', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'ui-complete' }); + + await expect(kernel.use(good)).resolves.toBe(kernel); + const entry = stored(kernel, '@os-fixture/ui-complete'); + expect(entry).toBe(good); + expect(entry?.staticPath).toBe(UI_STATIC_PATH); + expect(entry?.slug).toBe('ui-complete'); + }); + + it('SCOPE — a `standard` plugin declaring neither key still loads', async () => { + const kernel = makeKernel(); + const plain = fixture({ name: '@os-fixture/standard-keyless', type: 'standard' }); + + await expect(kernel.use(plain)).resolves.toBe(kernel); + }); + + it('the two keys are members of `Plugin` itself — inherited from PluginDefinition, not restated', () => { + // Compile-time half of the derivation (#16334): before it `staticPath` + // and `slug` were not members of `Plugin`, and every fixture in this + // file needed a widening alias to spell them. A plain `Plugin` now does. + const declared: Plugin = { name: 'x', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'x', init() {} }; + expect(declared.slug).toBe('x'); + expect(declared.staticPath).toBe(UI_STATIC_PATH); + }); +}); + describe('E — `version` is DELIBERATELY not enforced from the schema', () => { /** * `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 46b2e5cee5..e5aef258ef 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -464,6 +464,15 @@ export class PluginLoader { * 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 — diff --git a/packages/core/src/plugin-type-closed-set.test.ts b/packages/core/src/plugin-type-closed-set.test.ts index ef059cb5c7..f7bfcb9b0e 100644 --- a/packages/core/src/plugin-type-closed-set.test.ts +++ b/packages/core/src/plugin-type-closed-set.test.ts @@ -57,9 +57,21 @@ describe('Plugin.type closed set — runtime parity with the spec enum (#13925)' expect(CORE_PLUGIN_TYPES).toHaveLength(7); }); + /** + * The minimal spec-legal object per member. `ui` alone owes more than its + * `type`: `staticPath` and `slug` are required for it since #16334 + * (`plugin-ui-required-keys.test.ts` in spec pins that), so a bare + * `{ type: 'ui' }` is refused at `['staticPath']` / `['slug']` — a reading + * about those two keys, not about the enum this file pins. Every other + * member is legal with its `type` alone, which the bare `{ type }` states. + */ + function minimalLegal(type: PluginType): Record { + return type === 'ui' ? { type, staticPath: '/srv/ui/dist', slug: 'ui' } : { type }; + } + it('every union member parses through PluginSchema', () => { for (const type of UNION_MEMBERS) { - const result = PluginSchema.safeParse({ type }); + const result = PluginSchema.safeParse(minimalLegal(type)); expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true); } }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 83546bcb74..6d58273ee3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,7 +2,7 @@ import { ObjectKernel } from './kernel.js'; import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts'; -import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel'; +import type { CORE_PLUGIN_TYPES, PluginDefinition } from '@objectstack/spec/kernel'; /** * PluginContext - Runtime context available to plugins @@ -106,41 +106,57 @@ export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number]; /** * Plugin Interface - * + * * All ObjectStack plugins must implement this interface. + * + * ## Two halves, one contract (#16334) + * + * **The metadata half is inherited, not restated.** Every key `PluginSchema` + * declares (`@objectstack/spec`, `kernel/plugin.zod.ts`) — `id`, `type`, + * `staticPath`, `slug`, `default`, `version`, `description`, `author`, + * `homepage` — arrives here through `PluginDefinition` + * (`z.input`), so the keys the compiler accepts on a + * plugin object and the keys `kernel.use()` validates + * (`PluginLoader.validatePluginContract`, #16049) are ONE declaration. Before + * this the interface spelled `type` and `version` itself and declared neither + * `staticPath` nor `slug`, so an in-repo `ui` plugin could not carry the two + * keys the schema requires of it without widening its own type — two shapes + * for one contract, free to drift. + * + * **The runtime half is declared here and only here**: `name`, the ADR-0116 + * ordering declarations, and the `init` / `start` / `destroy` lifecycle. The + * spec's schema describes what a plugin OBJECT may say about itself, never + * what it does. + * + * ### `type` + * + * The inherited `type` is a {@link PluginType} — the closed set the spec + * declares (`'standard'` plus `CORE_PLUGIN_TYPES`); `packages/rest`'s + * `plugin-type-closed-set.pin.test.ts` pins that the inherited key and the + * 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 + * `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. + * + * ⚠️ This comment used to say a bad `type` was refused "at parse". It was + * measured false (#16049, from #15638): `PluginSchema` had no runtime caller, + * kernel plugin objects were never parsed, and a `type` outside the set was + * accepted and stored verbatim. The refusal described here is the one that + * now exists, on the boot path, and the compiler's arm is the second half + * rather than the only one — `kernel.use(plugin as any)` is a shipped + * in-repo pattern, and externally authored plugins never meet this compiler + * at all. */ -export interface Plugin { +export interface Plugin extends PluginDefinition { /** * Unique plugin name (e.g., 'com.objectstack.engine.objectql') */ name: string; - /** - * Plugin version - */ - version?: string; - - /** - * Plugin type categorisation for runtime behaviour — a {@link PluginType}, - * the closed set the spec declares. The enumeration lives on that type - * (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside - * it no longer type-checks, and since #16049 `kernel.use()` REFUSES it at - * boot — `PluginLoader.validatePluginContract` runs `PluginSchema` over - * every plugin object and raises `PLUGIN_CONTRACT_VIOLATION` naming the - * plugin and the first violated key. - * - * ⚠️ This sentence used to say the value was refused "at parse". It was - * measured false (#16049, from #15638): `PluginSchema` had no runtime - * caller, kernel plugin objects were never parsed, and a `type` outside the - * set was accepted and stored verbatim. The refusal this comment describes - * is the one that now exists, on the boot path, and the compiler's arm is - * the second half rather than the only one — `kernel.use(plugin as any)` is - * a shipped in-repo pattern, and externally authored plugins never meet - * this compiler at all. - * @default 'standard' - */ - type?: PluginType; - /** * List of other plugin names that this plugin depends on. * The kernel ensures these plugins are initialized before this one. 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 c77a94a7d1..cd4bea6aa8 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 @@ -84,17 +84,26 @@ afterAll(() => { /** * The two keys the block reads are declared on `PluginSchema` - * (`packages/spec/src/kernel/plugin.zod.ts`) but NOT on the `Plugin` interface - * the kernel's `use()` accepts (`packages/core/src/types.ts`) — which is one - * reason the repo contained no producer of either. The fixture states the extra - * surface explicitly instead of casting it away, so a future change to `Plugin` - * that adopts these keys does not silently pass this file by. + * (`packages/spec/src/kernel/plugin.zod.ts`) and, since #16334, inherited by + * the `Plugin` interface through `PluginDefinition` — the change this alias + * was written to notice, and it did: `Plugin` now carries `staticPath`, `slug` + * and `default` itself, so the alias survives only as the fixture's name. */ -type UiPluginFixture = Plugin & { - staticPath?: string; - slug?: string; - default?: boolean; -}; +type UiPluginFixture = Plugin; + +/** + * The refusal `promise` produced, or a loud failure if it produced none — the + * shape core's `plugin-contract-enforcement.test.ts` uses, so a case whose + * input STOPPED being refused reports "it loaded" instead of a property miss. + */ +async function refusal(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e as Error; + } + throw new Error('expected kernel.use() to refuse the plugin, but it loaded'); +} function makeFixture(overrides: Partial & { name: string }): UiPluginFixture { return { @@ -238,12 +247,18 @@ describe('UI plugin auto-discovery (#16050)', () => { ]); }); - it('derives the slug from the last path segment of the plugin name when none is declared', async () => { - const { routes } = await observe(makeFixture({ name: '@os-fixture/console' })); - - // `plugin.slug || plugin.name.split('/').pop()` — the documented - // `@org/console -> console` derivation. - expect(routes).toEqual(['/console', '/console', '/console/*', '/console/*']); + it('a `ui` plugin declaring no `slug` is refused at kernel.use() before the block can derive one (#16334)', async () => { + // `plugin.slug || plugin.name.split('/').pop()` — the block's documented + // `@org/console -> console` derivation — is UNREACHABLE through the + // kernel since #16334: `PluginSchema` requires `slug` for `type: 'ui'` + // and `kernel.use()` runs the schema (#16049), so the object never + // reaches `kernel.plugins`. Pinned as the refusal, with the spec's + // stable code surfacing inside the loader's envelope. The fallback + // expression itself is dead code now, awaiting its own card. + const err = await refusal(boot(makeFixture({ name: '@os-fixture/console' }))); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'slug'"); + expect(err.message).toContain('PLUGIN_UI_REQUIRED_KEY_MISSING'); }); }); @@ -296,19 +311,20 @@ describe('UI plugin auto-discovery (#16050)', () => { expect(routes).toEqual([]); }); - it('a `ui` type with no staticPath mounts nothing', async () => { - const { routes } = await observe( - makeFixture({ - name: '@os-fixture/console-no-assets', - staticPath: undefined, - slug: 'console-fixture', - }), - ); - - // The other conjunct of the same guard (`&& plugin.staticPath`), so a - // change that keeps the type check but drops the assets check cannot - // sit green. - expect(routes).toEqual([]); + it('a `ui` plugin declaring no `staticPath` is refused at kernel.use() before the block runs (#16334)', async () => { + // The other conjunct of the same guard (`&& plugin.staticPath`) is + // likewise unreachable through the kernel: `staticPath` is required + // for `type: 'ui'` since #16334, so a `ui` plugin without assets is a + // boot refusal, not a silent non-mount. The `NON_UI_TYPES` cases + // above remain the proof that this harness CAN produce `[]`. + const err = await refusal(boot(makeFixture({ + name: '@os-fixture/console-no-assets', + staticPath: undefined, + slug: 'console-fixture', + }))); + expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'staticPath'"); + expect(err.message).toContain('PLUGIN_UI_REQUIRED_KEY_MISSING'); }); }); diff --git a/packages/rest/src/plugin-type-closed-set.pin.test.ts b/packages/rest/src/plugin-type-closed-set.pin.test.ts index 8315856ac8..beaeb3d690 100644 --- a/packages/rest/src/plugin-type-closed-set.pin.test.ts +++ b/packages/rest/src/plugin-type-closed-set.pin.test.ts @@ -89,5 +89,12 @@ describe('Plugin.type closed set — published-surface pins (#13925)', () => { // unassignable to it. const complete: Equal, never> = true; expect(complete).toBe(true); + + // #16334: `Plugin.type` is INHERITED from `PluginDefinition` now, not + // spelled on the interface — so "the interface's key IS this union" has + // to be pinned, or the inherited key and the exported alias could drift + // apart with every directive above still green. + const inherited: Equal, PluginType> = true; + expect(inherited).toBe(true); }); }); diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 86ac4ee76f..89a6a0100a 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -800,6 +800,31 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ + '`message`. If a transport ever ANSWERS with this fact, the verdict becomes ' + 'pending-registration and the code belongs in the ledger batch.', }, + // [#16334] The spec's own code for the one CONDITIONAL requirement + // `PluginSchema` carries — `type: 'ui'` owes `staticPath` and `slug` — + // stamped by the schema's `superRefine` onto the zod ISSUE (`params.code`, + // and at the head of `message`), never onto a thrown error. The scanner + // sees it as an `objlitconst` site because the literal sits inside + // `ctx.addIssue({ …, params: { code } })` in a `.zod.ts` file. + { + code: 'PLUGIN_UI_REQUIRED_KEY_MISSING', + file: 'packages/spec/src/kernel/plugin.zod.ts', + shape: 'objlitconst', + door: 'none', + verdict: 'boot-refusal', + why: + 'Stamped on a zod ISSUE, not on a thrown error: `PluginSchema`\'s `superRefine` adds one ' + + '`custom` issue per missing key when a `type: \'ui\'` plugin omits `staticPath` or `slug`, ' + + 'with this code on `params.code` and at the head of `message`. MEASURED reachability: the ' + + 'only runtime caller of `PluginSchema` is `PluginLoader.validatePluginContract` ' + + '(`packages/core/src/plugin-loader.ts`, the `PLUGIN_CONTRACT_VIOLATION` row above), which ' + + 'reads the first issue\'s `path` and `message` and re-raises them inside the ' + + '`PLUGIN_CONTRACT_VIOLATION` envelope at `kernel.use()` — before bootstrap, and therefore ' + + 'before any HTTP boundary exists. So this code reaches a reader only as a substring of that ' + + 'boot refusal\'s message; no door answers with it and `error.code` never carries it. Same ' + + 'class and same reasoning as the row above. If a door ever answers with it, the verdict ' + + 'becomes pending-registration and it belongs in the ledger batch.', + }, // [ADR-0130 D4] The artifact load path's three wrapper refusals, added with the // N-package load path itself. The pre-HTTP reasoning is the one the rows above // cite; what is specific to these three is the second half recorded in each `why` diff --git a/packages/spec/src/kernel/plugin-ui-required-keys.test.ts b/packages/spec/src/kernel/plugin-ui-required-keys.test.ts new file mode 100644 index 0000000000..0fd779f498 --- /dev/null +++ b/packages/spec/src/kernel/plugin-ui-required-keys.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `PluginSchema` makes `(Required for type="ui")` true (#16334). + * + * WHY THIS FILE EXISTS. `staticPath` and `slug` were described as required for + * `type: 'ui'` and declared `.optional()`, with nothing behind the prose. Once + * `kernel.use()` ran the schema on the boot path (#16049) that became a promise + * the runtime visibly did not keep. These pins read the schema DIRECTLY — this + * file imports `./plugin.zod` from source, so no build sits between the + * assertion and the declaration — one group per direction: + * + * A refusal: a `ui` plugin missing either key fails `safeParse`, one + * issue per missing key, `path` naming the key, the stable code + * at the head of `message` and on `params.code`; + * B calibration: the SAME `ui` fixture with both keys declared parses, and a + * present-but-invalid value is still judged by its own key's + * declaration, never re-judged here; + * C scope: every other declared type, and a plugin declaring no type, + * parses with neither key — the refinement is scoped to + * `type === 'ui'` and to ABSENCE. + * + * NEGATIVE CONTROL. Against the schema this branch was cut from (90e7e6de1, + * the `PluginSchema` with no `superRefine`) every case in group A fails and + * every case in B and C passes — measured by running this file with that + * `plugin.zod.ts` restored, recorded in the PR. That asymmetry is what makes A + * a pin and B/C the calibration, rather than a file that would pass either way. + * + * The boot-path half — that `kernel.use()` SURFACES the code inside its + * `PLUGIN_CONTRACT_VIOLATION` envelope — is `packages/core`'s + * `plugin-contract-enforcement.test.ts`, group F. + */ + +import { describe, expect, it } from 'vitest'; +import { + CORE_PLUGIN_TYPES, + PLUGIN_UI_REQUIRED_KEY_MISSING, + PluginSchema, + type PluginDefinition, +} from './plugin.zod'; + +/** A `ui` plugin carrying both required keys — the calibration fixture. */ +const UI_COMPLETE: PluginDefinition = { + type: 'ui', + staticPath: '/srv/acme-console/dist', + slug: 'console', +}; + +/** The two keys `type: 'ui'` owes — the whole set, pinned one case per key. */ +const UI_REQUIRED_KEYS = ['staticPath', 'slug'] as const; + +/** `fixture` with `key` removed — an ABSENT key, not one set to `undefined`. */ +function without(fixture: PluginDefinition, key: keyof PluginDefinition): PluginDefinition { + const copy: PluginDefinition = { ...fixture }; + delete copy[key]; + return copy; +} + +/** `[code, path]` per issue — the shape core's closed-set pin reads too. */ +function issueShapes(input: unknown): Array<[string, string]> { + const result = PluginSchema.safeParse(input); + if (result.success) return []; + return result.error.issues.map((i): [string, string] => [i.code, i.path.join('.')]); +} + +describe('A — a `ui` plugin without `staticPath` or `slug` fails PluginSchema.safeParse (#16334)', () => { + it('missing both: refused with one issue per key, in declaration order, path naming the key', () => { + const keyless = without(without(UI_COMPLETE, 'staticPath'), 'slug'); + expect(keyless).toEqual({ type: 'ui' }); + + const result = PluginSchema.safeParse(keyless); + expect(result.success).toBe(false); + expect(issueShapes(keyless)).toEqual([ + ['custom', 'staticPath'], + ['custom', 'slug'], + ]); + }); + + it.each(UI_REQUIRED_KEYS)('missing only `%s`: exactly that key is refused', (key) => { + expect(issueShapes(without(UI_COMPLETE, key))).toEqual([['custom', key]]); + }); + + it('the refusal carries the stable code: at the head of `message` and on `params.code`', () => { + const result = PluginSchema.safeParse(without(UI_COMPLETE, 'staticPath')); + expect(result.success).toBe(false); + if (result.success) return; + + const [issue] = result.error.issues; + // The message head is the channel the boot path surfaces today: + // `PluginLoader.validatePluginContract` re-emits `issue.message`, and + // `ObjectKernel.use()` keeps only the message (#16049). + expect(issue.message.startsWith(`${PLUGIN_UI_REQUIRED_KEY_MISSING}: `)).toBe(true); + expect(issue.message).toContain('`staticPath`'); + // The structured channel, for a reader that wants the code as a field. + expect((issue as unknown as { params?: Record }).params).toEqual({ + code: PLUGIN_UI_REQUIRED_KEY_MISSING, + key: 'staticPath', + }); + }); + + it('an explicit `undefined` is absence: `slug: undefined` is refused like an omitted `slug`', () => { + expect(issueShapes({ ...UI_COMPLETE, slug: undefined })).toEqual([['custom', 'slug']]); + }); +}); + +describe('B — CALIBRATION: the same `ui` fixture with both keys parses', () => { + it('parses; the refinement adds nothing to a complete `ui` plugin', () => { + expect(PluginSchema.safeParse(UI_COMPLETE).success).toBe(true); + expect(issueShapes(UI_COMPLETE)).toEqual([]); + }); + + it('a present-but-invalid `slug` is the slug regex\'s own refusal, not this one', () => { + // The requirement added by #16334 is about ABSENCE. A present value keeps + // exactly the issue its own declaration produces — one issue, at `slug`, + // and not the `custom` code this file pins. + const shapes = issueShapes({ ...UI_COMPLETE, slug: 'Not A Slug' }); + expect(shapes).toHaveLength(1); + expect(shapes[0][1]).toBe('slug'); + expect(shapes[0][0]).not.toBe('custom'); + }); +}); + +describe('C — SCOPE: only `type: \'ui\'` owes the two keys', () => { + const NON_UI = ['standard', ...CORE_PLUGIN_TYPES].filter((t) => t !== 'ui'); + + it.each(NON_UI)('a `%s` plugin parses with neither key', (type) => { + expect(issueShapes({ type })).toEqual([]); + }); + + it('a plugin declaring no `type` parses with neither key (`.default(\'standard\')`)', () => { + expect(issueShapes({})).toEqual([]); + }); + + it('positive control on the scope loop: the complement excludes `ui` and covers every other member', () => { + expect(NON_UI).not.toContain('ui'); + expect(CORE_PLUGIN_TYPES).toContain('ui'); + // 'standard' plus the seven `CORE_PLUGIN_TYPES`, minus 'ui'. + expect(NON_UI).toHaveLength(CORE_PLUGIN_TYPES.length); + }); +}); diff --git a/packages/spec/src/kernel/plugin.zod.ts b/packages/spec/src/kernel/plugin.zod.ts index a81da57b8b..5e581efeaf 100644 --- a/packages/spec/src/kernel/plugin.zod.ts +++ b/packages/spec/src/kernel/plugin.zod.ts @@ -116,6 +116,52 @@ export function isConsumerInstallable(type: string | undefined): boolean { return type != null && (CONSUMER_INSTALLABLE_TYPES as readonly string[]).includes(type); } +/** + * The stable code a `PluginSchema` refusal carries when a `type: 'ui'` plugin + * omits a key the `ui` type requires (#16334). + * + * `staticPath` and `slug` are described below as `(Required for type="ui")` + * and were declared `.optional()` with nothing behind the prose. Once + * `kernel.use()` ran the schema on the boot path (#16049) that prose became a + * promise the runtime visibly did not keep. The `superRefine` on + * `PluginSchema` makes it true: a `type: 'ui'` plugin missing either key is + * refused with one issue per missing key, `path` naming the key. + * + * Where the code is readable — MEASURED on this tree, not assumed: + * + * - At the HEAD of the issue's `message`. The one runtime caller of + * `PluginSchema` is `PluginLoader.validatePluginContract` + * (`packages/core/src/plugin-loader.ts`, #16049), which surfaces the first + * issue's `path` and `message` and reads nothing else, and + * `ObjectKernel.use()` re-wraps that into a fresh `Error` carrying only the + * message. So the code reaches the boot log verbatim today, with no loader + * change: + * + * PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the + * declared plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: … + * + * - On the issue's `params.code` (zod's slot for custom-issue metadata), with + * `params.key` naming the missing key — for a reader that wants the code as + * a field rather than a message prefix. No reader does today; whether the + * loader should stamp it onto `err.code` is the boot path's seam (#16049), + * not this one. + * + * Spelled the ADR-0112 way and deliberately NOT wire vocabulary: it is raised + * at authoring / `kernel.use()`, before any HTTP boundary exists — + * `door: 'none'` / `boot-refusal` in + * `packages/runtime/src/dispatcher-error-vocabulary.ts`, beside + * `PLUGIN_CONTRACT_VIOLATION`, the envelope it rides. + */ +export const PLUGIN_UI_REQUIRED_KEY_MISSING = 'PLUGIN_UI_REQUIRED_KEY_MISSING'; + +/** + * The keys `type: 'ui'` requires — exactly the two whose `.describe()` says + * `(Required for type="ui")`. Module-private on purpose: the published symbol + * is the refusal code above; the key set itself is pinned by + * `plugin-ui-required-keys.test.ts`, one case per key. + */ +const PLUGIN_UI_REQUIRED_KEYS = ['staticPath', 'slug'] as const; + export const PluginSchema = lazySchema(() => z.object({ id: z.string().min(1).optional().describe('Unique Plugin ID (e.g. com.example.crm)'), type: z.enum([ @@ -131,6 +177,27 @@ export const PluginSchema = lazySchema(() => z.object({ description: z.string().optional(), author: z.string().optional(), homepage: z.string().url().optional(), +}).superRefine((plugin, ctx) => { + // #16334 — the `(Required for type="ui")` prose on `staticPath` / `slug`, + // enforced. Scoped to `type === 'ui'` exactly: every other type, and a + // plugin declaring no `type` (`.default('standard')`), owes neither key. + // Absence only — a PRESENT value is judged by its own declaration above + // (`slug` keeps its regex, `staticPath` stays any string), never re-judged. + if (plugin.type !== 'ui') return; + for (const key of PLUGIN_UI_REQUIRED_KEYS) { + if (plugin[key] !== undefined) continue; + ctx.addIssue({ + code: 'custom', + path: [key], + message: + `${PLUGIN_UI_REQUIRED_KEY_MISSING}: a \`type: 'ui'\` plugin must declare \`${key}\` — ` + + (key === 'staticPath' + ? 'the absolute path of the static assets it serves.' + : 'the URL path segment it is mounted under.') + + " Declare it, or drop `type: 'ui'` if this plugin serves no assets.", + params: { code: PLUGIN_UI_REQUIRED_KEY_MISSING, key }, + }); + } })); export type PluginDefinition = z.input; From 8dc4a33040aeff2b2e173f318225f284af7a9a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:57:17 +0000 Subject: [PATCH 2/2] wip(spec): regenerate api-surface and export-origins shards for PLUGIN_UI_REQUIRED_KEY_MISSING Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- packages/spec/api-surface/kernel.json | 1 + packages/spec/export-origins/kernel.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 42af1484bc..9ad8e331a2 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -225,6 +225,7 @@ "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", "PLATFORM_PLUGIN_WIRED_RUNTIMES (const)", + "PLUGIN_UI_REQUIRED_KEY_MISSING (const)", "PROTOCOL_MAJOR (const)", "PROTOCOL_VERSION (const)", "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index efd1dae954..ebe9e91088 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -224,6 +224,7 @@ "PLATFORM_CAPABILITY_PROVIDERS": "src/kernel/platform-capabilities.ts#PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS": "src/kernel/platform-capabilities.ts#PLATFORM_CAPABILITY_TOKENS (const)", "PLATFORM_PLUGIN_WIRED_RUNTIMES": "src/kernel/platform-capabilities.ts#PLATFORM_PLUGIN_WIRED_RUNTIMES (const)", + "PLUGIN_UI_REQUIRED_KEY_MISSING": "src/kernel/plugin.zod.ts#PLUGIN_UI_REQUIRED_KEY_MISSING (const)", "PROTOCOL_MAJOR": "src/kernel/protocol-version.ts#PROTOCOL_MAJOR (const)", "PROTOCOL_VERSION": "src/kernel/protocol-version.ts#PROTOCOL_VERSION (const)", "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS": "src/kernel/public-auth-features.ts#PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)",