Skip to content

Commit 2a3decc

Browse files
huangyiireneclaude
andauthored
fix(spec,core): PluginSchema requires staticPath/slug for type "ui", and Plugin derives its metadata keys from PluginDefinition (#16334) (#16598)
* wip(spec,core): PluginSchema requires staticPath/slug for ui; Plugin derives from PluginDefinition Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * wip(spec): regenerate api-surface and export-origins shards for PLUGIN_UI_REQUIRED_KEY_MISSING Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc7c11d commit 2a3decc

12 files changed

Lines changed: 468 additions & 72 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
`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.
7+
8+
**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).
9+
10+
**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.
11+
12+
**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:
13+
14+
```
15+
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
16+
plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'`
17+
plugin must declare `staticPath` — the absolute path of the static assets it
18+
serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets.
19+
```
20+
21+
**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.
22+
23+
**`@objectstack/core``Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input<typeof PluginSchema>`), 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.
24+
25+
**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.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing on plugin OBJECTS, which are never stored metadata: `PluginSchema` gains a refinement and one exported constant; no metadata key, object definition or stored representation is added, removed or renamed, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The channel that reaches an affected plugin author is the refusal itself, which names the missing key at `kernel.use()`; which value that key should carry is authoring intent no ledger entry can decide. -->

packages/core/src/plugin-contract-enforcement.test.ts

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest';
3434
import { ObjectKernel } from './kernel.js';
3535
import { PluginLoader } from './plugin-loader.js';
3636
import { ObjectLogger } from './logger.js';
37+
import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel';
3738
import type { Plugin, PluginContext } from './types.js';
3839

3940
/** A kernel that registers plugins and installs no process signal handlers. */
@@ -48,17 +49,21 @@ function stored(kernel: ObjectKernel, name: string): Record<string, unknown> | u
4849
}
4950

5051
/**
51-
* A plugin object with an arbitrary extra surface. The keys under test
52-
* (`type`, `slug`, `homepage`, `id`) are declared by `PluginSchema` and NOT by
53-
* the `Plugin` interface, which is one reason the repo contained no producer of
54-
* them — so the fixture states the extra surface rather than casting it away.
52+
* A plugin object under test. The keys under test (`type`, `slug`, `homepage`,
53+
* `id`, `staticPath`) used to be declared by `PluginSchema` and NOT by the
54+
* `Plugin` interface — one reason the repo contained no producer of them, and
55+
* why this alias once had to widen `Plugin` to spell them. Since #16334
56+
* `Plugin` inherits every `PluginSchema` key through `PluginDefinition`, so a
57+
* plain `Plugin` states the whole surface; the alias survives as the name.
5558
*/
56-
type Fixture = Plugin & {
57-
id?: string;
58-
slug?: string;
59-
homepage?: string;
60-
staticPath?: string;
61-
};
59+
type Fixture = Plugin;
60+
61+
/**
62+
* A `type: 'ui'` fixture owes `staticPath` and `slug` (#16334), so every `ui`
63+
* fixture below carries both unless the case is ABOUT one of them. Nothing at
64+
* `kernel.use()` reads the path off disk — the loader validates the object.
65+
*/
66+
const UI_STATIC_PATH = '/srv/os-fixture/ui/dist';
6267

6368
/**
6469
* 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,
9398
// The value #15638 MEASURED as accepted, stored verbatim and mounting
9499
// routes. It is not a member of `CORE_PLUGIN_TYPES`.
95100
type: 'ui-plugin' as unknown as Plugin['type'],
101+
// Both `ui` keys declared (#16334), so the calibration twin below
102+
// differs from this fixture in `type` and nothing else.
103+
staticPath: UI_STATIC_PATH,
104+
slug: 'legacy-ui',
96105
});
97106

98107
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,
111120

112121
it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => {
113122
const kernel = makeKernel();
114-
const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' });
123+
const modern = fixture({
124+
name: '@os-fixture/modern-ui',
125+
type: 'ui',
126+
staticPath: UI_STATIC_PATH,
127+
slug: 'modern-ui',
128+
});
115129

116130
await expect(kernel.use(modern)).resolves.toBe(kernel);
117131
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', (
208222
describe('D — the other two refusals the changeset states', () => {
209223
it('refuses an invalid `slug`', async () => {
210224
const kernel = makeKernel();
211-
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' });
225+
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'Not A Slug' });
212226

213227
const err = await refusal(kernel.use(bad));
214228
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
@@ -217,7 +231,7 @@ describe('D — the other two refusals the changeset states', () => {
217231

218232
it('CALIBRATION — the same fixture with a legal slug loads', async () => {
219233
const kernel = makeKernel();
220-
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' });
234+
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'not-a-slug' });
221235

222236
await expect(kernel.use(good)).resolves.toBe(kernel);
223237
});
@@ -239,6 +253,67 @@ describe('D — the other two refusals the changeset states', () => {
239253
});
240254
});
241255

256+
describe('F — a `ui` plugin owes `staticPath` and `slug`, refused at kernel.use() (#16334)', () => {
257+
/**
258+
* The spec half of #16049: `PluginSchema` describes both keys as
259+
* `(Required for type="ui")` and, since #16334, refuses a `ui` plugin
260+
* missing either — one issue per missing key, `path` naming the key,
261+
* `PLUGIN_UI_REQUIRED_KEY_MISSING` at the head of the issue message. These
262+
* pins measure that the boot path SURFACES that code unchanged: the loader
263+
* re-emits the first issue's `path` and `message`, so the spec's code rides
264+
* inside `PLUGIN_CONTRACT_VIOLATION`'s envelope. Group B's untyped and
265+
* `standard` fixtures, which declare neither key and load, are the scope
266+
* control: only `type: 'ui'` owes them.
267+
*/
268+
it('refuses a `ui` plugin with no `staticPath`, naming the key and the spec code', async () => {
269+
const kernel = makeKernel();
270+
const bad = fixture({ name: '@os-fixture/ui-no-static-path', type: 'ui', slug: 'ui-no-static-path' });
271+
272+
const err = await refusal(kernel.use(bad));
273+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
274+
expect(err.message).toContain("at 'staticPath'");
275+
expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING);
276+
expect(stored(kernel, '@os-fixture/ui-no-static-path')).toBeUndefined();
277+
});
278+
279+
it('refuses a `ui` plugin with no `slug`, naming the key and the spec code', async () => {
280+
const kernel = makeKernel();
281+
const bad = fixture({ name: '@os-fixture/ui-no-slug', type: 'ui', staticPath: UI_STATIC_PATH });
282+
283+
const err = await refusal(kernel.use(bad));
284+
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
285+
expect(err.message).toContain("at 'slug'");
286+
expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING);
287+
});
288+
289+
it('CALIBRATION — the same `ui` fixture with both keys loads, stored verbatim', async () => {
290+
const kernel = makeKernel();
291+
const good = fixture({ name: '@os-fixture/ui-complete', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'ui-complete' });
292+
293+
await expect(kernel.use(good)).resolves.toBe(kernel);
294+
const entry = stored(kernel, '@os-fixture/ui-complete');
295+
expect(entry).toBe(good);
296+
expect(entry?.staticPath).toBe(UI_STATIC_PATH);
297+
expect(entry?.slug).toBe('ui-complete');
298+
});
299+
300+
it('SCOPE — a `standard` plugin declaring neither key still loads', async () => {
301+
const kernel = makeKernel();
302+
const plain = fixture({ name: '@os-fixture/standard-keyless', type: 'standard' });
303+
304+
await expect(kernel.use(plain)).resolves.toBe(kernel);
305+
});
306+
307+
it('the two keys are members of `Plugin` itself — inherited from PluginDefinition, not restated', () => {
308+
// Compile-time half of the derivation (#16334): before it `staticPath`
309+
// and `slug` were not members of `Plugin`, and every fixture in this
310+
// file needed a widening alias to spell them. A plain `Plugin` now does.
311+
const declared: Plugin = { name: 'x', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'x', init() {} };
312+
expect(declared.slug).toBe('x');
313+
expect(declared.staticPath).toBe(UI_STATIC_PATH);
314+
});
315+
});
316+
242317
describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
243318
/**
244319
* `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease

packages/core/src/plugin-loader.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,15 @@ export class PluginLoader {
464464
* All eight are `.optional()`, which admits absence and `undefined` but
465465
* never an explicit `null` — so `null` on any of the eight is refused too.
466466
*
467+
* Since #16334 the schema carries ONE conditional requirement on top of
468+
* the eight: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema`
469+
* refuses a `ui` plugin missing either with `PLUGIN_UI_REQUIRED_KEY_MISSING`
470+
* at the head of the issue message (`packages/spec/src/kernel/plugin.zod.ts`).
471+
* That refusal rides this method's envelope unchanged — reported as
472+
* `at 'staticPath'` / `at 'slug'` with the spec's code inside the message —
473+
* because this method surfaces `path` and `message` and reads nothing
474+
* else. `plugin-contract-enforcement.test.ts` group F pins the surfacing.
475+
*
467476
* ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to
468477
* consumers as `CHANGELOG.md` and is what an upgrading author greps after
469478
* the refusal, so a shorter enumeration there does not merely omit keys —

packages/core/src/plugin-type-closed-set.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,21 @@ describe('Plugin.type closed set — runtime parity with the spec enum (#13925)'
5757
expect(CORE_PLUGIN_TYPES).toHaveLength(7);
5858
});
5959

60+
/**
61+
* The minimal spec-legal object per member. `ui` alone owes more than its
62+
* `type`: `staticPath` and `slug` are required for it since #16334
63+
* (`plugin-ui-required-keys.test.ts` in spec pins that), so a bare
64+
* `{ type: 'ui' }` is refused at `['staticPath']` / `['slug']` — a reading
65+
* about those two keys, not about the enum this file pins. Every other
66+
* member is legal with its `type` alone, which the bare `{ type }` states.
67+
*/
68+
function minimalLegal(type: PluginType): Record<string, unknown> {
69+
return type === 'ui' ? { type, staticPath: '/srv/ui/dist', slug: 'ui' } : { type };
70+
}
71+
6072
it('every union member parses through PluginSchema', () => {
6173
for (const type of UNION_MEMBERS) {
62-
const result = PluginSchema.safeParse({ type });
74+
const result = PluginSchema.safeParse(minimalLegal(type));
6375
expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true);
6476
}
6577
});

packages/core/src/types.ts

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { ObjectKernel } from './kernel.js';
44
import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts';
5-
import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';
5+
import type { CORE_PLUGIN_TYPES, PluginDefinition } from '@objectstack/spec/kernel';
66

77
/**
88
* PluginContext - Runtime context available to plugins
@@ -106,41 +106,57 @@ export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];
106106

107107
/**
108108
* Plugin Interface
109-
*
109+
*
110110
* All ObjectStack plugins must implement this interface.
111+
*
112+
* ## Two halves, one contract (#16334)
113+
*
114+
* **The metadata half is inherited, not restated.** Every key `PluginSchema`
115+
* declares (`@objectstack/spec`, `kernel/plugin.zod.ts`) — `id`, `type`,
116+
* `staticPath`, `slug`, `default`, `version`, `description`, `author`,
117+
* `homepage` — arrives here through `PluginDefinition`
118+
* (`z.input<typeof PluginSchema>`), so the keys the compiler accepts on a
119+
* plugin object and the keys `kernel.use()` validates
120+
* (`PluginLoader.validatePluginContract`, #16049) are ONE declaration. Before
121+
* this the interface spelled `type` and `version` itself and declared neither
122+
* `staticPath` nor `slug`, so an in-repo `ui` plugin could not carry the two
123+
* keys the schema requires of it without widening its own type — two shapes
124+
* for one contract, free to drift.
125+
*
126+
* **The runtime half is declared here and only here**: `name`, the ADR-0116
127+
* ordering declarations, and the `init` / `start` / `destroy` lifecycle. The
128+
* spec's schema describes what a plugin OBJECT may say about itself, never
129+
* what it does.
130+
*
131+
* ### `type`
132+
*
133+
* The inherited `type` is a {@link PluginType} — the closed set the spec
134+
* declares (`'standard'` plus `CORE_PLUGIN_TYPES`); `packages/rest`'s
135+
* `plugin-type-closed-set.pin.test.ts` pins that the inherited key and the
136+
* exported alias are the same union. Absent means `'standard'` at the schema
137+
* (`.default('standard')`), and the loader never writes that default back
138+
* onto the object. A value outside the set no longer type-checks, and since
139+
* #16049 `kernel.use()` REFUSES it at boot — `PluginLoader.validatePluginContract`
140+
* runs `PluginSchema` over every plugin object and raises
141+
* `PLUGIN_CONTRACT_VIOLATION` naming the plugin and the first violated key.
142+
* `type: 'ui'` additionally owes `staticPath` and `slug` (#16334,
143+
* `PLUGIN_UI_REQUIRED_KEY_MISSING`), refused on the same path.
144+
*
145+
* ⚠️ This comment used to say a bad `type` was refused "at parse". It was
146+
* measured false (#16049, from #15638): `PluginSchema` had no runtime caller,
147+
* kernel plugin objects were never parsed, and a `type` outside the set was
148+
* accepted and stored verbatim. The refusal described here is the one that
149+
* now exists, on the boot path, and the compiler's arm is the second half
150+
* rather than the only one — `kernel.use(plugin as any)` is a shipped
151+
* in-repo pattern, and externally authored plugins never meet this compiler
152+
* at all.
111153
*/
112-
export interface Plugin {
154+
export interface Plugin extends PluginDefinition {
113155
/**
114156
* Unique plugin name (e.g., 'com.objectstack.engine.objectql')
115157
*/
116158
name: string;
117159

118-
/**
119-
* Plugin version
120-
*/
121-
version?: string;
122-
123-
/**
124-
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
125-
* the closed set the spec declares. The enumeration lives on that type
126-
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
127-
* it no longer type-checks, and since #16049 `kernel.use()` REFUSES it at
128-
* boot — `PluginLoader.validatePluginContract` runs `PluginSchema` over
129-
* every plugin object and raises `PLUGIN_CONTRACT_VIOLATION` naming the
130-
* plugin and the first violated key.
131-
*
132-
* ⚠️ This sentence used to say the value was refused "at parse". It was
133-
* measured false (#16049, from #15638): `PluginSchema` had no runtime
134-
* caller, kernel plugin objects were never parsed, and a `type` outside the
135-
* set was accepted and stored verbatim. The refusal this comment describes
136-
* is the one that now exists, on the boot path, and the compiler's arm is
137-
* the second half rather than the only one — `kernel.use(plugin as any)` is
138-
* a shipped in-repo pattern, and externally authored plugins never meet
139-
* this compiler at all.
140-
* @default 'standard'
141-
*/
142-
type?: PluginType;
143-
144160
/**
145161
* List of other plugin names that this plugin depends on.
146162
* The kernel ensures these plugins are initialized before this one.

0 commit comments

Comments
 (0)