Skip to content

Commit 7363f0f

Browse files
committed
fix(plugin-hono-server): drop the retired ui-plugin arm from UI auto-discovery
The auto-discovery block matched `type === 'ui' || type === 'ui-plugin'` under a "Support legacy" comment. `ui-plugin` is outside the closed plugin-type set and both kernels' `use()` already refuse it through the shared plugin contract, so the disjunct was reachable through neither kernel's `use()`. Delete it and its comment. Pin C in ui-plugin-auto-discovery.pin.test.ts replaces its placeholder: C1 reads the contract's refusal of a declared `ui-plugin` on both kernels (envelope parity), and C2 proves the arm is gone behaviourally: an admitted `ui` entry flipped to `ui-plugin` before start() mounts nothing, with an in-case firing control. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
1 parent 6696056 commit 7363f0f

3 files changed

Lines changed: 112 additions & 44 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
The UI auto-discovery block in `HonoServerPlugin.start()` no longer names the retired plugin type `ui-plugin`: the `plugin.type === 'ui-plugin'` disjunct and its "Support legacy" comment are removed, and the block mounts `type: 'ui'` plugins only (#15638).
6+
7+
Clause-②: no
8+
9+
- **Why nothing a plugin declares moves.** `ui-plugin` is not a member of the closed plugin-type set (`'standard'` plus `CORE_PLUGIN_TYPES`), and `kernel.use()` already refuses it on both published kernels, before the block can see it, with `PLUGIN_CONTRACT_VIOLATION ... at 'type'`. `LiteKernel.use()` throws it as-is; `ObjectKernel.use()` throws it behind its `Failed to load plugin: NAME - ` prefix. The disjunct was reachable through neither kernel's `use()`, so the deletion changes no accept or reject verdict for any declared value, and the refusal is the generic closed-set one. There is no message specific to this spelling.
10+
- **The one object that stops mounting.** The contract validates at `use()` and stores the plugin object by reference, so an object admitted as `ui` that then rewrites its own `type` to `ui-plugin` before `start()` used to be mounted by the removed disjunct. It no longer is.
11+
- **Fix**: declare `type: 'ui'`, with `staticPath` and `slug` (both required for that type).

‎packages/plugins/plugin-hono-server/src/hono-plugin.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,8 +518,7 @@ export class HonoServerPlugin implements Plugin {
518518

519519
for (const plugin of (loadedPlugins as any[])) {
520520
// Check for UI Plugin signature
521-
// Support legacy 'ui-plugin' and new 'ui' type
522-
if ((plugin.type === 'ui' || plugin.type === 'ui-plugin') && plugin.staticPath) {
521+
if (plugin.type === 'ui' && plugin.staticPath) {
523522
// Derive base route from name: @org/console -> console
524523
const slug = plugin.slug || plugin.name.split('/').pop();
525524
const baseRoute = `/${slug}`;

‎packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts‎

Lines changed: 100 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@
5757
* and now that both halves answer the same, it says so per kernel too.
5858
*
5959
* WHAT EACH GROUP ACTUALLY OBSERVES — stated because the difference is the whole
60-
* point of this file. A, B, D and F observe ROUTE REGISTRATION: they replace
60+
* point of this file. A, B, C, D and F observe ROUTE REGISTRATION: they replace
6161
* `rawApp.get` with a recorder, so no handler is ever installed and nothing is
62-
* served. That is enough to pin WHICH routes exist and, for D and F2, that none
63-
* does —
62+
* served. That is enough to pin WHICH routes exist and, for C2, D and F2, that
63+
* none does —
6464
* and it is blind to everything downstream of the route string. E closes that:
6565
* it leaves `rawApp.get` alone, so the real handlers install on the real Hono
6666
* app, and drives `rawApp.request(...)` to pin what actually comes BACK. E is
@@ -176,9 +176,9 @@ interface Booted {
176176
* Either published kernel. Both are exported from `@objectstack/core`, both give
177177
* their plugins a context whose `getKernel()` returns the kernel itself, and both
178178
* keep the loaded plugins in a `plugins` map — the three properties the block
179-
* under test depends on. What they do NOT share is whether `use()` validates:
180-
* see the header. Groups A, B, D and E run on `ObjectKernel`; group F runs on
181-
* `LiteKernel`.
179+
* under test depends on. They used to differ on whether `use()` validates; since
180+
* #16721 both do (see the header). Groups A, B, D and E run on `ObjectKernel`;
181+
* group F runs on `LiteKernel`; group C runs on both.
182182
*/
183183
type KernelUnderTest = ObjectKernel | LiteKernel;
184184

@@ -261,12 +261,27 @@ async function attachHono(kernel: KernelUnderTest): Promise<Booted> {
261261
*
262262
* ⚠️ Nothing is installed and nothing is served under this helper — that is the
263263
* point of pin E, which does not use it.
264+
*
265+
* `beforeStart`, when given, receives the entry `use()` left in the kernel's own
266+
* plugin map — the very object the block will iterate — after `init()` and
267+
* before `start()`. Pin C2 is its only caller.
264268
*/
265269
async function observe(
266270
fixture: UiPluginFixture,
267271
bootOn: (f: UiPluginFixture) => Promise<Booted> = boot,
272+
beforeStart?: (admitted: Record<string, unknown>) => void,
268273
): Promise<Observation> {
269274
const { kernel, honoPlugin, ctx, rawApp } = await bootOn(fixture);
275+
const entry = () => (kernel as unknown as { plugins: Map<string, Record<string, unknown>> })
276+
.plugins.get(fixture.name);
277+
278+
if (beforeStart) {
279+
const admitted = entry();
280+
if (admitted === undefined) {
281+
throw new Error(`expected kernel.use() to admit '${fixture.name}', but kernel.plugins has no entry for it`);
282+
}
283+
beforeStart(admitted);
284+
}
270285

271286
const routes: string[] = [];
272287
const spy = vi.spyOn(rawApp, 'get').mockImplementation(((route: string) => {
@@ -280,10 +295,7 @@ async function observe(
280295
spy.mockRestore();
281296
}
282297

283-
const stored = (kernel as unknown as { plugins: Map<string, Record<string, unknown>> })
284-
.plugins.get(fixture.name);
285-
286-
return { routes, stored };
298+
return { routes, stored: entry() };
287299
}
288300

289301
/**
@@ -359,42 +371,88 @@ describe('UI plugin auto-discovery (#16050)', () => {
359371
});
360372

361373
/**
362-
* C — the legacy `ui-plugin` arm. DELIBERATELY NOT WRITTEN YET.
363-
*
364-
* `hono-plugin.ts` matches `plugin.type === 'ui' || plugin.type === 'ui-plugin'`,
365-
* and the second disjunct is the subject of #15638: `ui-plugin` is not a
366-
* member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value.
367-
*
368-
* ⚠️ WHICH KERNEL (#16599). This narration used to say "the boot path — which
369-
* never calls `PluginSchema` — accepts it and mounts", naming no kernel. That
370-
* is true of exactly one of the two, and both were measured:
374+
* C — the retired `ui-plugin` spelling, as #15638 ruled (B+: remove).
371375
*
372-
* - `ObjectKernel.use()` REFUSES it since #16363, with
373-
* `PLUGIN_CONTRACT_VIOLATION … at 'type'` naming the closed set.
374-
* - `LiteKernel.use()` accepted it until #16721 and the block mounted
375-
* `/slug` and `/slug/*`; since #16721 it runs the same contract and
376-
* refuses it with the same envelope (core's enforcement test, group G).
376+
* The block used to match `plugin.type === 'ui' || plugin.type === 'ui-plugin'`
377+
* under a "Support legacy" comment. `ui-plugin` is not a member of
378+
* `CORE_PLUGIN_TYPES`, and the ruling retires it at once, with no tolerance
379+
* left in any consumer: the disjunct and its comment are deleted. What stands
380+
* in their place is the contract #16049 already runs at `use()`: the generic
381+
* closed-set refusal, with no message of its own for this spelling. Both
382+
* halves are pinned on both published kernels:
377383
*
378-
* ⇒ #15638's arm was HALF dead when this note was first written — the same
379-
* shape as the two arms #16599 measured — and is now reachable through
380-
* NEITHER published kernel's `use()`. That is a reading about the tree, not
381-
* the ruling: #15638 still picks between two INCOMPATIBLE pins, so writing
382-
* either one now would pin a guess:
384+
* - C1: a plugin DECLARING `type: 'ui-plugin'` is refused at `use()`. The
385+
* fixture is otherwise a fully declared `ui` plugin (`staticPath` and
386+
* `slug` both present), so `type` is its only fault. This READS #16049's
387+
* refusal and re-implements nothing. It is also why the deletion moved no
388+
* verdict on a declared value: before it, the same input was already
389+
* refused here and never reached the block, on either kernel.
390+
* - C2: the arm is gone, measured by behaviour rather than by reading the
391+
* source. After the contract, the only way `kernel.plugins` can hold an
392+
* entry typed `ui-plugin` is for an object the contract ADMITTED to
393+
* change its own `type` afterwards: `use()` validates, discards the
394+
* parse, and stores the object by reference (`plugin-contract.ts`). C2
395+
* does exactly that. An admitted `ui` entry is flipped to `ui-plugin`
396+
* before `start()`, and the block mounts nothing. The same flow without
397+
* the flip, in the same case, mounts pin B's four routes, so `[]` comes
398+
* from the guard and not from a harness that stopped mounting. Restoring
399+
* the deleted disjunct turns C2 red on both kernels, with the four routes
400+
* back, and leaves every other case in this file green.
383401
*
384-
* - if #15638 rules REMOVE, C becomes: a `ui-plugin` fixture is refused at
385-
* `use()` on BOTH kernels (true since #16721) and the arm is deleted,
386-
* so nothing can ever mount it;
387-
* - if #15638 rules DECLARE/CONVERT (an ADR-0087 conversion entry), C
388-
* becomes: a `ui-plugin` fixture is normalised to `ui` BEFORE the contract
389-
* runs — on both kernels — mounts `/slug` and `/slug/*` exactly like pin
390-
* B, and emits one deprecation warning.
391-
*
392-
* Whoever lands #15638 writes this case in that PR — the harness above takes
393-
* it unchanged; only the fixture's `type`, the kernel(s) it boots on and the
394-
* expectation differ. Until then the placeholder is the honest state:
395-
* refused on both kernels, unpinned here on purpose.
402+
* The modern `ui` arm stays pinned by B (`ObjectKernel`), E (served) and F0
403+
* (`LiteKernel`).
396404
*/
397-
it.todo('C — the legacy `ui-plugin` arm behaves as #15638 rules that it should');
405+
describe('C — the retired `ui-plugin` spelling: refused at use(), and not honoured by the block (#15638)', () => {
406+
const LEGACY_TYPE = 'ui-plugin';
407+
const KERNELS = [
408+
['ObjectKernel', boot],
409+
['LiteKernel', bootLite],
410+
] as const;
411+
412+
it('C1 — LiteKernel.use() and ObjectKernel.use() refuse a declared `ui-plugin` with one envelope', async () => {
413+
const make = () => makeFixture({
414+
name: '@os-fixture/legacy-console',
415+
type: LEGACY_TYPE as unknown as UiPluginFixture['type'],
416+
slug: 'console-fixture',
417+
});
418+
419+
const lite = await refusal(bootLite(make()));
420+
421+
// #16049's closed-set refusal, located at the key: the stable code on
422+
// the property and at the head of the message, as pins F1/F2 read it.
423+
expect(lite.message).toContain('PLUGIN_CONTRACT_VIOLATION');
424+
expect(lite.message).toContain("at 'type'");
425+
expect((lite as Error & { code?: string }).code).toBe('PLUGIN_CONTRACT_VIOLATION');
426+
427+
// Parity, not resemblance: the same text behind the loader's prefix.
428+
const object = await refusal(boot(make()));
429+
expect(object.message).toBe(`Failed to load plugin: @os-fixture/legacy-console - ${lite.message}`);
430+
});
431+
432+
it.each(KERNELS)('C2 — on %s, an admitted entry turned into `ui-plugin` before start() mounts nothing', async (_kernel, bootOn) => {
433+
const fixture = () => makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' });
434+
435+
// The firing control, same kernel and same fixture, no flip: the block
436+
// mounts here, so the `[]` below is caused by the guard.
437+
const control = await observe(fixture(), bootOn);
438+
expect(control.routes).toEqual([
439+
'/console-fixture',
440+
'/console-fixture',
441+
'/console-fixture/*',
442+
'/console-fixture/*',
443+
]);
444+
445+
const flipped = await observe(fixture(), bootOn, (admitted) => {
446+
admitted.type = LEGACY_TYPE;
447+
});
448+
449+
// The flip landed on the entry the block iterates, and nothing else
450+
// about the entry changed: `staticPath` is still the existing root.
451+
expect(flipped.stored?.type).toBe(LEGACY_TYPE);
452+
expect(flipped.stored?.staticPath).toBe(STATIC_ROOT);
453+
expect(flipped.routes).toEqual([]);
454+
});
455+
});
398456

399457
describe('D — the negative control: the harness can produce an empty result', () => {
400458
// Every declared plugin type EXCEPT `ui`, read off the spec's own closed set

0 commit comments

Comments
 (0)