|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { MetadataPluginConfigSchema, MetadataPluginManifestSchema } from './metadata-plugin.zod'; |
| 5 | + |
| 6 | +// ─── [#8586] `MetadataPluginConfig.additionalTypes` is REMOVED ──────────────── |
| 7 | +// |
| 8 | +// ADR-0049 enforce-or-remove, maintainer ruling 2026-08-14, ruled REMOVE. The |
| 9 | +// key was declared, authorable, and documented on four docs pages as THE way a |
| 10 | +// plugin registers a custom metadata type — and read by NOTHING: the only |
| 11 | +// production writer of the manager's type registry is |
| 12 | +// `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/ |
| 13 | +// plugin.ts`), called exactly once, and it replaces the array outright. |
| 14 | +// Measured on the real `MetadataManager`: declared count == live count |
| 15 | +// (27 == 27). The same silence trap as #4212's `onInstall`, one level down: |
| 16 | +// write it per the docs, get no error, nothing happens. |
| 17 | +// |
| 18 | +// Route: `retiredKey()` tombstone, NOT plain deletion. |
| 19 | +// `MetadataPluginConfigSchema` is not `.strict()`, so deleting the key would |
| 20 | +// make zod strip it in silence — replacing an inert declaration with an |
| 21 | +// invisible one (the #3726 / #3733 shape, ADR-0104). The tombstone is audible |
| 22 | +// in two channels: `tsc` (the key's input type is `never`) and the parse below. |
| 23 | +// |
| 24 | +// ⚠️ On the assertion set (the #4914 precedent, same reasoning): the dispatch |
| 25 | +// asked for the unknown-key refusal shape, but that shape belongs to `.strict()` |
| 26 | +// schemas — a `retiredKey()` tombstone raises `invalid_type` from its |
| 27 | +// `z.never()`, with the prescription as the message (`shared/retired-key.ts`; |
| 28 | +// `alias-integrity.test.ts` records the same fact). And the ADR-0112 `code` + |
| 29 | +// `status` envelope belongs to the API error surface — a schema refusal raises |
| 30 | +// a `ZodError` whose issues carry `code` and `path` but no `status`. So these |
| 31 | +// pins assert the strongest set this surface really has: refusal, the issue |
| 32 | +// `code`, the `path` naming WHICH key was refused, and the prescription text |
| 33 | +// (#5240: where the wording is the contract, pin the wording). |
| 34 | +describe('[#8586] MetadataPluginConfig.additionalTypes retirement', () => { |
| 35 | + /** A config that is valid except for whatever the individual test adds. */ |
| 36 | + const baseConfig = { storage: {} } as const; |
| 37 | + |
| 38 | + it('REJECTS an authored `additionalTypes`, naming the key and carrying the fix', () => { |
| 39 | + const result = MetadataPluginConfigSchema.safeParse({ |
| 40 | + ...baseConfig, |
| 41 | + additionalTypes: [{ |
| 42 | + type: 'chart', |
| 43 | + label: 'Chart', |
| 44 | + filePatterns: ['**/*.chart.ts'], |
| 45 | + domain: 'ui', |
| 46 | + }], |
| 47 | + }); |
| 48 | + |
| 49 | + expect(result.success).toBe(false); |
| 50 | + if (result.success) return; // narrowing; the assertion above already failed |
| 51 | + |
| 52 | + const issue = result.error.issues.find((i) => i.path[0] === 'additionalTypes'); |
| 53 | + expect(issue, 'the refusal must name `additionalTypes`').toBeDefined(); |
| 54 | + // The machine-readable half of the envelope this surface actually has. |
| 55 | + expect(issue!.code).toBe('invalid_type'); |
| 56 | + expect(issue!.path).toEqual(['additionalTypes']); |
| 57 | + // The prescription itself — this string IS the migration doc for whoever |
| 58 | + // hits it, so it is contract, not commentary. |
| 59 | + expect(issue!.message).toMatch(/`config\.additionalTypes`.*removed.*17.*#8586/s); |
| 60 | + expect(issue!.message).toMatch(/Delete the key/s); |
| 61 | + // The live mechanism must be named: how a kind ACTUALLY enters the set. |
| 62 | + expect(issue!.message).toMatch(/registering an ITEM/s); |
| 63 | + expect(issue!.message).toMatch(/registerMetadataTypeSchema/s); |
| 64 | + }); |
| 65 | + |
| 66 | + it('REJECTS it through the manifest embed too (`config.additionalTypes`)', () => { |
| 67 | + const result = MetadataPluginManifestSchema.safeParse({ |
| 68 | + id: 'com.objectstack.metadata', |
| 69 | + name: 'ObjectStack Metadata Service', |
| 70 | + version: '1.0.0', |
| 71 | + type: 'standard', |
| 72 | + capabilities: {}, |
| 73 | + config: { ...baseConfig, additionalTypes: [] }, |
| 74 | + }); |
| 75 | + |
| 76 | + expect(result.success).toBe(false); |
| 77 | + if (result.success) return; |
| 78 | + const issue = result.error.issues.find( |
| 79 | + (i) => i.path[0] === 'config' && i.path[1] === 'additionalTypes', |
| 80 | + ); |
| 81 | + expect(issue, 'the refusal must surface at config.additionalTypes').toBeDefined(); |
| 82 | + expect(issue!.code).toBe('invalid_type'); |
| 83 | + }); |
| 84 | + |
| 85 | + it('parses cleanly once the key is deleted, and grows no `additionalTypes` property', () => { |
| 86 | + const parsed = MetadataPluginConfigSchema.parse({ ...baseConfig }); |
| 87 | + expect(parsed.enableEvents).toBe(true); // control: defaults still apply |
| 88 | + // The non-strict strip path: absence must stay absence. If the tombstone |
| 89 | + // were ever replaced by a plain deletion, an authored `additionalTypes` |
| 90 | + // would be stripped here in silence — this pin plus the rejections above |
| 91 | + // are what make that regression loud. |
| 92 | + expect(parsed).not.toHaveProperty('additionalTypes'); |
| 93 | + }); |
| 94 | +}); |
0 commit comments