|
37 | 37 | * per-package attribution exists: the composed stack flattens every |
38 | 38 | * collection to the top level, and a flattened array cannot say which |
39 | 39 | * package each item came from. |
| 40 | + * 4. **`plugins` / `devPlugins` are envelope keys** (#15219, maintainer ruling |
| 41 | + * A for both keys, 2026-09-04): runtime assembly instructions, not metadata. |
| 42 | + * They stay `concat` for in-memory composition and stay at the top level, |
| 43 | + * and a body that carries one is REFUSED at the manifest's strict close — |
| 44 | + * a plugin inside `packages[i].manifest` is inert JSON no loader could |
| 45 | + * construct, so the alternative to refusal was a reader registering garbage. |
40 | 46 | */ |
41 | 47 |
|
42 | 48 | import { describe, it, expect } from 'vitest'; |
@@ -69,6 +75,8 @@ const ARTIFACT_ENVELOPE_KEYS = [ |
69 | 75 | 'i18n', // one artifact, one supported-locale declaration |
70 | 76 | 'runtimeModule', // written by the compiler, per ARTIFACT |
71 | 77 | 'onEnable', // one bundle, one lifecycle hook (AppPlugin invokes a single one) |
| 78 | + 'plugins', // runtime assembly instructions a host hands to `kernel.use()` — not metadata (#15219 ruling A) |
| 79 | + 'devPlugins', // the `os dev` load list — the same class as `plugins` (#15219 ruling A) |
72 | 80 | ].sort(); |
73 | 81 |
|
74 | 82 | const shapeKeys = (schema: unknown): string[] => |
@@ -260,3 +268,95 @@ describe("ADR-0130 D4 — `manifest: 'preserve'` assembles each input stack", () |
260 | 268 | expect(objectNames(parsed[0].manifest.objects)).toEqual(['crm_order']); |
261 | 269 | }); |
262 | 270 | }); |
| 271 | + |
| 272 | +// ─── 4. `plugins` / `devPlugins` are envelope keys (#15219) ────────── |
| 273 | + |
| 274 | +describe('#15219 A — `plugins` / `devPlugins` are envelope keys: top level only, never inside `packages[]`', () => { |
| 275 | + /** What a host hands to `kernel.use()` — a live instance, not metadata. */ |
| 276 | + const livePlugin = { name: 'plugin.example', init: () => undefined }; |
| 277 | + |
| 278 | + /** The unrecognized-keys issue a strict close raises, or undefined. */ |
| 279 | + const unrecognizedKeys = (verdict: ReturnType<typeof AssembledPackageBodySchema.safeParse>) => { |
| 280 | + if (verdict.success) return undefined; |
| 281 | + const issue = verdict.error.issues.find((i) => i.code === 'unrecognized_keys'); |
| 282 | + return issue |
| 283 | + ? { path: issue.path.map(String), keys: (issue as unknown as { keys: string[] }).keys } |
| 284 | + : undefined; |
| 285 | + }; |
| 286 | + |
| 287 | + it('both keys are absent from the body key set while the stack schema still declares both', () => { |
| 288 | + const stackKeys = shapeKeys(ObjectStackDefinitionSchema); |
| 289 | + const bodyKeys = shapeKeys(AssembledPackageBodySchema); |
| 290 | + |
| 291 | + // The stack half first: an exclusion is only an exclusion if the key is |
| 292 | + // still there to be excluded — a key that vanished from the stack schema |
| 293 | + // would satisfy the body assertions below for the wrong reason. |
| 294 | + expect(stackKeys).toContain('plugins'); |
| 295 | + expect(stackKeys).toContain('devPlugins'); |
| 296 | + |
| 297 | + expect(bodyKeys).not.toContain('plugins'); |
| 298 | + expect(bodyKeys).not.toContain('devPlugins'); |
| 299 | + }); |
| 300 | + |
| 301 | + it('a body carrying `plugins` is refused at the strict close, naming the key', () => { |
| 302 | + // Issue code + path + the key, never "it threw": the door is |
| 303 | + // `ManifestSchema`'s strict close carried through `.extend()`, and this is |
| 304 | + // the same instrument the `somethingUndeclared` pin above reads. |
| 305 | + const verdict = AssembledPackageBodySchema.safeParse({ ...coreManifest, plugins: [livePlugin] }); |
| 306 | + expect(verdict.success).toBe(false); |
| 307 | + expect(unrecognizedKeys(verdict)).toEqual({ path: [], keys: ['plugins'] }); |
| 308 | + }); |
| 309 | + |
| 310 | + it('a body carrying `devPlugins` is refused the same way — serialisable or not, it is a load instruction', () => { |
| 311 | + const verdict = AssembledPackageBodySchema.safeParse({ ...coreManifest, devPlugins: ['@example/dev-tools'] }); |
| 312 | + expect(verdict.success).toBe(false); |
| 313 | + expect(unrecognizedKeys(verdict)).toEqual({ path: [], keys: ['devPlugins'] }); |
| 314 | + }); |
| 315 | + |
| 316 | + it("through the artifact wrapper the refusal is located at `manifest` — the load gate's seam", () => { |
| 317 | + // `artifact-packages.ts` parses every `packages[]` entry with |
| 318 | + // `ArtifactPackageSchema`; this is the path its refusal message quotes. |
| 319 | + const verdict = ArtifactPackageSchema.safeParse({ manifest: { ...coreManifest, plugins: [livePlugin] } }); |
| 320 | + expect(verdict.success).toBe(false); |
| 321 | + expect(unrecognizedKeys(verdict)).toEqual({ path: ['manifest'], keys: ['plugins'] }); |
| 322 | + }); |
| 323 | + |
| 324 | + it('composition keeps both at the top level (concat, in stack order) and out of every package body', () => { |
| 325 | + const core = defineStack({ |
| 326 | + manifest: coreManifest, |
| 327 | + objects: [accountObject], |
| 328 | + plugins: [livePlugin], |
| 329 | + devPlugins: ['@example/dev-core'], |
| 330 | + }); |
| 331 | + const orders = defineStack({ |
| 332 | + manifest: ordersManifest, |
| 333 | + objects: [orderObject], |
| 334 | + plugins: [{ name: 'plugin.orders' }], |
| 335 | + devPlugins: ['@example/dev-orders'], |
| 336 | + }); |
| 337 | + |
| 338 | + const composed = composeStacks([core, orders], { manifest: 'preserve' }); |
| 339 | + |
| 340 | + // Live stacks still concatenate their plugins to the top level — the |
| 341 | + // `concat` disposition is untouched by the envelope exclusion. |
| 342 | + expect((composed.plugins as { name: string }[]).map((p) => p.name)).toEqual(['plugin.example', 'plugin.orders']); |
| 343 | + expect(composed.devPlugins).toEqual(['@example/dev-core', '@example/dev-orders']); |
| 344 | + |
| 345 | + // …and no package body carries either: the assembler reads the derived |
| 346 | + // body shape, so the exclusion reaches composition without a second list. |
| 347 | + const entries = (composed as { packages?: { manifest: Record<string, unknown> }[] }).packages ?? []; |
| 348 | + expect(entries).toHaveLength(2); |
| 349 | + for (const entry of entries) { |
| 350 | + expect(entry.manifest, `${entry.manifest.id} carries plugins`).not.toHaveProperty('plugins'); |
| 351 | + expect(entry.manifest, `${entry.manifest.id} carries devPlugins`).not.toHaveProperty('devPlugins'); |
| 352 | + } |
| 353 | + |
| 354 | + // SEAM 2/3 for a plugin-carrying host: the composed artifact parses, and |
| 355 | + // the top-level plugins survive the parse. |
| 356 | + const parsed = ObjectStackDefinitionSchema.safeParse(composed); |
| 357 | + expect(parsed.success).toBe(true); |
| 358 | + if (!parsed.success) return; |
| 359 | + expect(parsed.data.plugins).toHaveLength(2); |
| 360 | + expect(parsed.data.devPlugins).toEqual(['@example/dev-core', '@example/dev-orders']); |
| 361 | + }); |
| 362 | +}); |
0 commit comments