Skip to content

Commit 3c48234

Browse files
fix(plugin-hono-server): drop the retired ui-plugin arm from UI auto-discovery (#20086)
Fixes #15638 Clause-②: no ## What This PR deletes the `plugin.type === 'ui-plugin'` disjunct and its "Support legacy 'ui-plugin' and new 'ui' type" comment from the UI auto-discovery block in `HonoServerPlugin.start()` (`packages/plugins/plugin-hono-server/src/hono-plugin.ts:521-522` on `66960564`). The condition is now `plugin.type === 'ui' && plugin.staticPath`. This is item 1 of the maintainer ruling B+ (5557089123), carried out on the director seat's execution terms (5644357902). Item 2, the boot seam refusing the retired spelling, was delivered by #16049's enforcement. This PR reads that refusal and does not re-implement it. There is no hint specific to the retired spelling: the generic closed-set refusal is the message. This PR fills pin C of #16050 (the `it.todo` in `ui-plugin-auto-discovery.pin.test.ts`). #16050 stays open here; the seat handles it at landing. ## Reachability probe (the precondition, run before any edit) A temporary probe (deleted, never committed) ran against the unmodified tree at `66960564`. It drove the real `ObjectKernel.use()` and `LiteKernel.use()`, then the real `HonoServerPlugin.init()` and `start()`, and recorded the route registrations: | input | ObjectKernel | LiteKernel | |---|---|---| | declared `type: 'ui-plugin'`, with `staticPath` and `slug` | refused: `Failed to load plugin: NAME - PLUGIN_CONTRACT_VIOLATION: ... at 'type'`; not stored | refused: the same text, `code: PLUGIN_CONTRACT_VIOLATION`; not stored | | control: `type: 'ui'` | 4 routes (`/probe-console` and `/probe-console/*`, two registrations each) | 4 routes | | control: `type: 'driver'` | `[]` | `[]` | | an admitted `ui` entry flipped to `ui-plugin` before `start()` | 4 routes | 4 routes | Static census on the same tree: - The arm iterates `ctx.getKernel().plugins`, and that map has exactly two writers, both `registerPluginByName`: - from `ObjectKernel.use()` (`kernel.ts:253`), after `loadPlugin`, `validatePluginContract` and `assertPluginContract`; - from `LiteKernel.use()` (`lite-kernel.ts:75`), after `assertPluginContract`. No other `plugins.set(` or `registerPluginByName(` call exists outside tests. - The CLI's config-object path wraps a bundle that has no `init` in `AppPlugin`. `AppPlugin`'s `type = 'app'` is a class field and is never copied from the bundle, and the wrapped object still enters through `kernel.use()`. - `type` values handed to `kernel.use()` in the repo: - Class fields are only `standard`, `server`, `driver`, `objectql` and `app`. - The object-literal `type: 'plugin'` hits are `manifest.register(...)` package manifests, not kernel plugins. - No in-repo production code produces `ui-plugin` as a type value. The three non-test, non-doc mentions are package names in comments. - No in-repo code writes to a registered plugin's `type`. **Reading.** No path admits a DECLARED `ui-plugin` to the map on either kernel. The disjunct was unreachable through either kernel's `use()`, which is the pin file's own reachability standard. The one residual is the last table row. The contract validates at `use()` and stores the object by reference, so an object admitted as `ui` that later rewrites its own `type` did reach the arm. That is a bypass of the contract, not a path through it: the same bypass works for every key the contract checks, and nothing in the repo produces it. The changeset names it, C2 pins it, and it is flagged for the contract reviewer below. ## Clause-② Declared from the delivered diff: `no`. - No file under `packages/spec/src/**` is touched. - No accept or reject verdict moves for any declared value: `ui-plugin` was already refused at `use()` on both kernels before this diff. - The only class whose outcome moves is the post-admission self-rewrite above: mounted before, not mounted after. That object is off-contract by the time `start()` reads it, so I read it as outside the conformance limb. **Reviewer: please confirm or overturn that reading.** No `**BREAKING**` banner and no ADR-0087 marker are carried. ## Pin C These cases are in `packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts`, using the file's own harness: - **C1** reads #16049's refusal. A declared `ui-plugin` fixture, otherwise a complete `ui` fixture, is refused: - by `LiteKernel.use()`, with `code: PLUGIN_CONTRACT_VIOLATION` at `'type'`; - by `ObjectKernel.use()`, with exactly that text behind the loader's `Failed to load plugin: NAME - ` prefix. This is the F1/F2 parity shape. - **C2** runs on both kernels. An admitted `ui` entry flipped to `ui-plugin` before `start()` mounts `[]`. The same case carries a firing control: same kernel, same fixture, no flip, 4 routes. - `observe()` gains an optional `beforeStart` hook that receives the kernel's own admitted entry. C2 is its only caller. - B, E and F0 still pin the modern arm, and all three are green. ## Evidence (at `7363f0f4`) - `pnpm --filter @objectstack/plugin-hono-server test`: 26 files, 314 tests passed. - The pin file alone, verbose reporter: 20 passed, up from 17 plus 1 todo. C1, C2 on ObjectKernel and C2 on LiteKernel are listed by name. - `pnpm --filter @objectstack/plugin-hono-server typecheck`: exit 0, and `check:test-typecheck` reports OK. `tsconfig.test.json --listFilesOnly` includes the pin file. - **Ablation**, on the committed tree through `scripts/ablation-replace.mjs` in wrap mode: - Mutation: restore `(plugin.type === 'ui' || plugin.type === 'ui-plugin') && plugin.staticPath`. The anchor went from 1 hit to 0 and the blob from `201c5866` to `ca844899`. - Result: 2 failed, 18 passed. Both failures are C2 (ObjectKernel and LiteKernel), each with `expected [ '/console-fixture', …(3) ] to deeply equal []`. The failure went the expected way (to red). - Restore proven: the blob is back to `201c5866`, equal to HEAD, and `git diff HEAD` is empty. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 60 commands, identical to the dispatch list. All 60 ran and exit 0. `--ran` answers "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET). After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks), all three exit 0. - The roster families whose roster sits under a touched directory all exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - `pnpm lint`, the full union: exit 0. ## Acceptance notes - Two more parts of the same condition are also unreachable through either kernel's `use()`: the `plugin.slug || plugin.name.split('/').pop()` derivation and the `&& plugin.staticPath` conjunct. That is already recorded on #16721. This ruling does not cover them, so they are left untouched. - The post-admission rewrite residual follows from the documented validation-only design (`packages/core/src/plugin-contract.ts`: the parse output is discarded and the object is stored by reference). It applies to every contract key, not only `type`, and it is not a defect of this card. - The `default`/`isDefault` redirect is still unpinned, as the pin file's header already says. - `packages/core/src/plugin-loader.ts` and `packages/spec/**` are not touched. `CHANGELOG.md` and `package.json` are left to the Version Packages PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 338feda commit 3c48234

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)