1919 * that probe, made permanent — the artifact in the tree that says the block is
2020 * live.
2121 *
22- * WHAT MAKES IT END-TO-END. The fixture plugin is registered through the real
23- * `ObjectKernel.use()` and the real `HonoServerPlugin.init()`/`start()` run
24- * against the context the kernel hands its plugins. Nothing here stubs the
25- * kernel, the plugin, or the branch under test.
22+ * WHAT MAKES IT END-TO-END. The fixture plugin is registered through a real
23+ * kernel's `use()` and the real `HonoServerPlugin.init()`/`start()` run against
24+ * the context that kernel hands its plugins. Nothing here stubs the kernel, the
25+ * plugin, or the branch under test.
26+ *
27+ * ⭐ WHICH KERNEL, AND WHY THAT IS NOW HALF THE FILE (#16599). This repository
28+ * publishes TWO kernels and `@objectstack/core` exports both. They do not agree
29+ * about this block's inputs, and the disagreement is the reason groups B, D and
30+ * F exist in the shape they do:
31+ *
32+ * - `ObjectKernel.use()` runs `PluginLoader.loadPlugin` ->
33+ * `validatePluginContract` -> `PluginSchema.safeParse` on every plugin
34+ * object (#16049, landed as #16363), and since #16334 that schema requires
35+ * `staticPath` AND `slug` for `type: 'ui'`. A `ui` plugin missing either is
36+ * a boot REFUSAL and never reaches `kernel.plugins` at all.
37+ * - `LiteKernel.use()` calls `registerPluginByName` directly and never
38+ * touches `PluginSchema`, `PluginLoader` or any part of that path — #16363
39+ * changed `PluginLoader` only, and `PluginLoader` is reached from
40+ * `ObjectKernel.use()` alone. The same object is stored verbatim, and
41+ * `ObjectKernelBase.createContext()` hands plugins a context whose
42+ * `getKernel()` returns that kernel, whose `plugins` map is exactly what
43+ * this block iterates.
44+ *
45+ * `AGENTS.md`'s Kernel table names `LiteKernel` for "Tests (vitest), serverless,
46+ * edge (Workers)", so this is not a curiosity — it is the second supported way
47+ * to run a UI plugin, and with zero in-repo `type: 'ui'` producers, externally
48+ * authored plugins are the block's only real callers on EITHER kernel.
49+ *
50+ * ⇒ "Reachable" is therefore not a property of a branch here, it is a property
51+ * of a branch PER KERNEL, and this file states both halves rather than one.
2652 *
2753 * WHAT EACH GROUP ACTUALLY OBSERVES — stated because the difference is the whole
28- * point of this file. A, B and D observe ROUTE REGISTRATION: they replace
54+ * point of this file. A, B, D and F observe ROUTE REGISTRATION: they replace
2955 * `rawApp.get` with a recorder, so no handler is ever installed and nothing is
30- * served. That is enough to pin WHICH routes exist and, for D, that none does —
56+ * served. That is enough to pin WHICH routes exist and, for D and F2, that none
57+ * does —
3158 * and it is blind to everything downstream of the route string. E closes that:
3259 * it leaves `rawApp.get` alone, so the real handlers install on the real Hono
3360 * app, and drives `rawApp.request(...)` to pin what actually comes BACK. E is
4774 * would produce pin B's four route registrations whether or not the branch
4875 * works. Pin D is the calibration: the SAME fixture, the SAME on-disk static
4976 * root, one key different, must produce `[]`. Without D, B proves nothing.
77+ *
78+ * Group F carries its own copy of that discipline rather than borrowing D's,
79+ * because it runs on a different kernel: F0 is the firing control showing the
80+ * `LiteKernel` harness CAN mount, so F2's `[]` is caused by the
81+ * `&& plugin.staticPath` conjunct and not by a harness that never mounts under
82+ * that kernel. ⛔ A group that can only ever produce `[]` measures nothing.
5083 */
5184
5285import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest' ;
5386import fs from 'node:fs' ;
5487import os from 'node:os' ;
5588import path from 'node:path' ;
56- import { ObjectKernel } from '@objectstack/core' ;
89+ import { LiteKernel , ObjectKernel } from '@objectstack/core' ;
5790import { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel' ;
5891import type { Plugin , PluginContext } from '@objectstack/core' ;
5992import { HonoServerPlugin } from './hono-plugin' ;
@@ -127,12 +160,22 @@ interface Observation {
127160 * `start()`, and report what the auto-discovery block did.
128161 */
129162interface Booted {
130- kernel : ObjectKernel ;
163+ kernel : KernelUnderTest ;
131164 honoPlugin : HonoServerPlugin ;
132165 ctx : PluginContext ;
133166 rawApp : RawApp ;
134167}
135168
169+ /**
170+ * Either published kernel. Both are exported from `@objectstack/core`, both give
171+ * their plugins a context whose `getKernel()` returns the kernel itself, and both
172+ * keep the loaded plugins in a `plugins` map — the three properties the block
173+ * under test depends on. What they do NOT share is whether `use()` validates:
174+ * see the header. Groups A, B, D and E run on `ObjectKernel`; group F runs on
175+ * `LiteKernel`.
176+ */
177+ type KernelUnderTest = ObjectKernel | LiteKernel ;
178+
136179/** The subset of the raw Hono app these pins touch. */
137180interface RawApp {
138181 get : ( ...args : unknown [ ] ) => unknown ;
@@ -154,11 +197,44 @@ async function boot(fixture: UiPluginFixture): Promise<Booted> {
154197
155198 await kernel . use ( fixture as Plugin ) ;
156199
200+ return attachHono ( kernel ) ;
201+ }
202+
203+ /**
204+ * The `LiteKernel` counterpart of {@link boot} — the kernel `AGENTS.md` names for
205+ * tests, serverless and edge. `LiteKernel.use()` is synchronous and stores the
206+ * object through `registerPluginByName` with no schema in the path, so a fixture
207+ * that {@link boot} REFUSES arrives here intact and the block sees it.
208+ *
209+ * ⚠️ Not merely "less strict": nothing on this path calls `PluginSchema` at all,
210+ * so #16334's `type: 'ui'` requirements and #16363's enforcement are both absent
211+ * here — which is what makes group F a measurement of the branches rather than a
212+ * second copy of group B.
213+ *
214+ * No `gracefulShutdown` option exists on this kernel and it registers no signal
215+ * handlers of its own, so there is nothing to opt out of.
216+ */
217+ async function bootLite ( fixture : UiPluginFixture ) : Promise < Booted > {
218+ const kernel = new LiteKernel ( { logger : { level : 'silent' } } ) ;
219+
220+ kernel . use ( fixture as Plugin ) ;
221+
222+ return attachHono ( kernel ) ;
223+ }
224+
225+ /**
226+ * The half both kernels share: construct the real Hono plugin, take the very
227+ * context object the kernel hands its plugins, and run the real `init()` —
228+ * stopping short of `start()` so each pin can decide whether to watch
229+ * registration or let it happen for real.
230+ */
231+ async function attachHono ( kernel : KernelUnderTest ) : Promise < Booted > {
157232 const honoPlugin = new HonoServerPlugin ( { port : 0 } ) ;
158233
159234 // The very object `bootstrap()` passes to every plugin: `initPluginWithTimeout`
160235 // calls `plugin.init(this.context)` with this context, and its `getKernel()`
161236 // returns this kernel — which is what the block under test reaches through.
237+ // `LiteKernel` builds the same object, in `ObjectKernelBase.createContext()`.
162238 const ctx = ( kernel as unknown as { context : PluginContext } ) . context ;
163239
164240 await honoPlugin . init ( ctx ) ;
@@ -179,8 +255,11 @@ async function boot(fixture: UiPluginFixture): Promise<Booted> {
179255 * ⚠️ Nothing is installed and nothing is served under this helper — that is the
180256 * point of pin E, which does not use it.
181257 */
182- async function observe ( fixture : UiPluginFixture ) : Promise < Observation > {
183- const { kernel, honoPlugin, ctx, rawApp } = await boot ( fixture ) ;
258+ async function observe (
259+ fixture : UiPluginFixture ,
260+ bootOn : ( f : UiPluginFixture ) => Promise < Booted > = boot ,
261+ ) : Promise < Observation > {
262+ const { kernel, honoPlugin, ctx, rawApp } = await bootOn ( fixture ) ;
184263
185264 const routes : string [ ] = [ ] ;
186265 const spy = vi . spyOn ( rawApp , 'get' ) . mockImplementation ( ( ( route : string ) => {
@@ -247,14 +326,19 @@ describe('UI plugin auto-discovery (#16050)', () => {
247326 ] ) ;
248327 } ) ;
249328
250- it ( 'a `ui` plugin declaring no `slug` is refused at kernel .use() before the block can derive one (#16334)' , async ( ) => {
329+ it ( 'a `ui` plugin declaring no `slug` is refused at ObjectKernel .use() before the block can derive one (#16334)' , async ( ) => {
251330 // `plugin.slug || plugin.name.split('/').pop()` — the block's documented
252- // `@org/console -> console` derivation — is UNREACHABLE through the
253- // kernel since #16334: `PluginSchema` requires `slug` for `type: 'ui'`
254- // and `kernel.use()` runs the schema (#16049), so the object never
255- // reaches `kernel.plugins`. Pinned as the refusal, with the spec's
256- // stable code surfacing inside the loader's envelope. The fallback
257- // expression itself is dead code now, awaiting its own card.
331+ // `@org/console -> console` derivation — is unreachable ON THIS KERNEL
332+ // since #16334: `PluginSchema` requires `slug` for `type: 'ui'` and
333+ // `ObjectKernel.use()` runs the schema (#16049, landed as #16363), so
334+ // the object never reaches `kernel.plugins`. Pinned as the refusal,
335+ // with the spec's stable code surfacing inside the loader's envelope.
336+ //
337+ // ⛔ NOT dead code, and this comment used to say it was (#16599). The
338+ // expression is LIVE AND LOAD-BEARING on `LiteKernel`, which never
339+ // calls `PluginSchema` — pin F1 is the measurement, and ablating the
340+ // `||` there moves the mounted route from `/console` to `/undefined`.
341+ // ⇒ The two halves are one fact stated per kernel; read them together.
258342 const err = await refusal ( boot ( makeFixture ( { name : '@os-fixture/console' } ) ) ) ;
259343 expect ( err . message ) . toContain ( 'PLUGIN_CONTRACT_VIOLATION' ) ;
260344 expect ( err . message ) . toContain ( "at 'slug'" ) ;
@@ -267,21 +351,35 @@ describe('UI plugin auto-discovery (#16050)', () => {
267351 *
268352 * `hono-plugin.ts` matches `plugin.type === 'ui' || plugin.type === 'ui-plugin'`,
269353 * and the second disjunct is the subject of #15638: `ui-plugin` is not a
270- * member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value while
271- * the boot path — which never calls `PluginSchema` — accepts it and mounts.
272- * That arm is live, and #15638 decides what it should be. The ruling picks
273- * between two INCOMPATIBLE pins, so writing either one now would pin a guess:
354+ * member of `CORE_PLUGIN_TYPES`, so `PluginSchema` refuses the value.
355+ *
356+ * ⚠️ WHICH KERNEL (#16599). This narration used to say "the boot path — which
357+ * never calls `PluginSchema` — accepts it and mounts", naming no kernel. That
358+ * is true of exactly one of the two, and both were measured:
359+ *
360+ * - `ObjectKernel.use()` REFUSES it since #16363, with
361+ * `PLUGIN_CONTRACT_VIOLATION … at 'type'` naming the closed set.
362+ * - `LiteKernel.use()` still accepts it and the block still mounts `/slug`
363+ * and `/slug/*`, because #16363 changed `PluginLoader` and this kernel
364+ * never reaches `PluginLoader`.
365+ *
366+ * ⇒ #15638's arm is HALF dead — the same shape as the two arms #16599
367+ * measured — and whoever lands it owes both halves rather than one. The
368+ * ruling picks between two INCOMPATIBLE pins, so writing either one now would
369+ * pin a guess:
274370 *
275371 * - if #15638 rules REMOVE, C inverts: a `ui-plugin` fixture must mount
276- * NOTHING, i.e. `routes` equal to `[]`, exactly like pin D;
372+ * NOTHING on `LiteKernel` too, i.e. `routes` equal to `[]`, exactly like
373+ * pin D;
277374 * - if #15638 rules DECLARE/CONVERT (an ADR-0087 conversion entry), C
278375 * becomes: a `ui-plugin` fixture is normalised to `ui`, mounts `/slug`
279376 * and `/slug/*` exactly like pin B, and emits one deprecation warning.
280377 *
281378 * Whoever lands #15638 writes this case in that PR — the harness above takes
282- * it unchanged; only the fixture's `type` and the expectation differ. Until
283- * then the placeholder is the honest state: measured as live on #15638,
284- * unpinned here on purpose.
379+ * it unchanged; only the fixture's `type`, the kernel it boots on
380+ * (`bootLite`, per group F) and the expectation differ. Until then the
381+ * placeholder is the honest state: measured as live on `LiteKernel` and
382+ * refused on `ObjectKernel`, unpinned here on purpose.
285383 */
286384 it . todo ( 'C — the legacy `ui-plugin` arm behaves as #15638 rules that it should' ) ;
287385
@@ -311,12 +409,19 @@ describe('UI plugin auto-discovery (#16050)', () => {
311409 expect ( routes ) . toEqual ( [ ] ) ;
312410 } ) ;
313411
314- it ( 'a `ui` plugin declaring no `staticPath` is refused at kernel .use() before the block runs (#16334)' , async ( ) => {
412+ it ( 'a `ui` plugin declaring no `staticPath` is refused at ObjectKernel .use() before the block runs (#16334)' , async ( ) => {
315413 // The other conjunct of the same guard (`&& plugin.staticPath`) is
316- // likewise unreachable through the kernel: `staticPath` is required
317- // for `type: 'ui'` since #16334, so a `ui` plugin without assets is a
318- // boot refusal, not a silent non-mount. The `NON_UI_TYPES` cases
319- // above remain the proof that this harness CAN produce `[]`.
414+ // likewise unreachable ON THIS KERNEL: `staticPath` is required for
415+ // `type: 'ui'` since #16334, so a `ui` plugin without assets is a boot
416+ // refusal here, not a silent non-mount. The `NON_UI_TYPES` cases above
417+ // remain the proof that this harness CAN produce `[]`.
418+ //
419+ // ⛔ Again NOT dead, and this comment used to imply it (#16599): on
420+ // `LiteKernel` the same object reaches the block and the conjunct is
421+ // what skips it — pin F2. Deleting the conjunct there does not
422+ // "remove dead code", it turns a clean boot into a `TypeError` naming
423+ // `paths[1]`, thrown by `path.resolve(process.cwd(), mount.root)`
424+ // further down `start()` once `undefined` is pushed as a mount root.
320425 const err = await refusal ( boot ( makeFixture ( {
321426 name : '@os-fixture/console-no-assets' ,
322427 staticPath : undefined ,
@@ -408,4 +513,105 @@ describe('UI plugin auto-discovery (#16050)', () => {
408513 expect ( body ) . toBe ( INDEX_HTML ) ;
409514 } ) ;
410515 } ) ;
516+
517+ /**
518+ * F — the SAME two inputs, on `LiteKernel`, where they are not refused.
519+ *
520+ * WHY THIS GROUP EXISTS (#16599). B and D pin that `ObjectKernel.use()`
521+ * REFUSES a `ui` plugin missing `slug` or `staticPath`, and until this group
522+ * existed the file went on to assert — in prose, with no case behind it —
523+ * that the two branches those keys feed were therefore dead. ⛔ That is a
524+ * claim about every entry point, argued from one. It was wrong.
525+ *
526+ * `LiteKernel.use()` never calls `PluginSchema` (see the header), so both
527+ * inputs reach `kernel.plugins` intact and the block runs against them. Both
528+ * are also ordinary type-legal `Plugin` values — nothing here needs a cast to
529+ * construct them, so this is not a torture fixture, it is what an external
530+ * `ui` plugin looks like when its author left an optional key out.
531+ *
532+ * ⭐ WHAT EACH CASE REPLACES. These three pins carry readings that were
533+ * previously produced by ABLATING `hono-plugin.ts` in a throwaway probe —
534+ * deleting the `||` moved F1's route to `/undefined`, and deleting the
535+ * `&& plugin.staticPath` conjunct turned F2's clean boot into a `TypeError`.
536+ * An ablation proves a branch load-bearing ONCE, in a session nobody can
537+ * re-read. These cases are the same two readings, made permanent, so the next
538+ * reader who concludes "dead code" is contradicted by a red test rather than
539+ * by an argument.
540+ *
541+ * ⛔ F is NOT a claim about which kernel is right. Whether `LiteKernel` should
542+ * validate at all is a contract question, carried on its own card and
543+ * deliberately not pre-empted here. This group pins only what the tree does
544+ * today.
545+ */
546+ describe ( 'F — the same inputs on `LiteKernel`, which never calls `PluginSchema` (#16599)' , ( ) => {
547+ it ( 'F0 — the firing control: a fully declared `ui` plugin mounts on this kernel too' , async ( ) => {
548+ const { routes } = await observe (
549+ makeFixture ( { name : '@os-fixture/console' , slug : 'console-fixture' } ) ,
550+ bootLite ,
551+ ) ;
552+
553+ // The calibration F2 depends on, and the reason F2's `[]` is a
554+ // reading rather than a harness that never mounts under this kernel.
555+ // Identical to pin B's expectation, which is the point: the block
556+ // behaves the same on both kernels once the object gets through.
557+ expect ( routes ) . toEqual ( [
558+ '/console-fixture' ,
559+ '/console-fixture' ,
560+ '/console-fixture/*' ,
561+ '/console-fixture/*' ,
562+ ] ) ;
563+ } ) ;
564+
565+ it ( 'F1 — with no `slug`, the fallback derives one from the last path segment of the name' , async ( ) => {
566+ const { stored, routes } = await observe (
567+ makeFixture ( { name : '@os-fixture/console' } ) ,
568+ bootLite ,
569+ ) ;
570+
571+ // The object B could not get past `ObjectKernel.use()` is stored here
572+ // verbatim, `slug` genuinely absent — so the fallback is reached with
573+ // nothing to short-circuit on.
574+ expect ( stored ) . toBeDefined ( ) ;
575+ expect ( stored ?. slug ) . toBeUndefined ( ) ;
576+ expect ( stored ?. staticPath ) . toBe ( STATIC_ROOT ) ;
577+
578+ // `plugin.slug || plugin.name.split('/').pop()` — `@os-fixture/console`
579+ // becomes `console`, exactly the `@org/console -> console` derivation
580+ // the block documents. ⭐ THE NAME IS THE ASSERTION: `console` appears
581+ // in no fixture field, only in the tail of `name`, so this expectation
582+ // cannot be satisfied by anything except the fallback running. Drop the
583+ // `||` and every route below reads `/undefined`.
584+ expect ( routes ) . toEqual ( [
585+ '/console' ,
586+ '/console' ,
587+ '/console/*' ,
588+ '/console/*' ,
589+ ] ) ;
590+ } ) ;
591+
592+ it ( 'F2 — with no `staticPath`, the guard skips the plugin and the boot stays clean' , async ( ) => {
593+ const fixture = makeFixture ( {
594+ name : '@os-fixture/console-no-assets' ,
595+ slug : 'console-fixture' ,
596+ staticPath : undefined ,
597+ } ) ;
598+
599+ // ⛔ The assertion is NOT merely `[]`. `start()` resolving is half of
600+ // it: the `&& plugin.staticPath` conjunct is what keeps an assetless
601+ // `ui` plugin from being pushed onto `mounts` with `root: undefined`,
602+ // which `path.resolve(process.cwd(), mount.root)` further down
603+ // `start()` rejects with a `TypeError` naming `paths[1]`. A guard that
604+ // merely "avoided a pointless mount" would be dead weight; this one is
605+ // the difference between a clean boot and a crashed one.
606+ const observation = await observe ( fixture , bootLite ) ;
607+
608+ expect ( observation . stored ) . toBeDefined ( ) ;
609+ expect ( observation . stored ?. staticPath ) . toBeUndefined ( ) ;
610+
611+ // Same kernel, same harness, same fixture builder as F0 — `staticPath`
612+ // is the only difference, so `[]` is caused by the conjunct and not by
613+ // a harness that never mounts here.
614+ expect ( observation . routes ) . toEqual ( [ ] ) ;
615+ } ) ;
616+ } ) ;
411617} ) ;
0 commit comments