|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// RUNTIME parity pin for the closed `Plugin.type` set (#13925). |
| 4 | +// |
| 5 | +// `Plugin.type` in `./types.ts` is a `PluginType` DERIVED from the spec's |
| 6 | +// `CORE_PLUGIN_TYPES` constant (`'standard' | (typeof CORE_PLUGIN_TYPES)[number]`), |
| 7 | +// and `PluginSchema.type` in `@objectstack/spec` is declared as |
| 8 | +// `z.enum(['standard', ...CORE_PLUGIN_TYPES])`. Both sides read the same |
| 9 | +// constant, so the one way they can still drift is the Zod enum's literal |
| 10 | +// prefix changing shape (a member added to the enum but not to the constant, |
| 11 | +// or `'standard'` renamed) — which is exactly what the first case below reads |
| 12 | +// off the schema at runtime, member by member and in declared order. |
| 13 | +// |
| 14 | +// The COMPILE-TIME half — a non-member literal or a `string`-typed value no |
| 15 | +// longer type-checks against the PUBLISHED `Plugin.type` — lives in |
| 16 | +// `packages/rest/src/plugin-type-closed-set.pin.test.ts`, deliberately NOT |
| 17 | +// here: `@objectstack/core` has no `typecheck` script (type-check DEBT ledger |
| 18 | +// entry), so a `@ts-expect-error` in this package is a phantom pin no tsc |
| 19 | +// program a `typecheck` script runs would ever evaluate — |
| 20 | +// `check:type-check-coverage` refuses exactly that. The rest package's |
| 21 | +// `tsconfig.test.json` program is compiled by its `typecheck` script and reads |
| 22 | +// core's BUILT `.d.ts`, so the pin over there guards the published contract. |
| 23 | + |
| 24 | +import { describe, it, expect } from 'vitest'; |
| 25 | +import { CORE_PLUGIN_TYPES, PluginSchema } from '@objectstack/spec/kernel'; |
| 26 | +import type { PluginType } from './types.js'; |
| 27 | + |
| 28 | +/** |
| 29 | + * The TypeScript union's members, spelled by the same derivation `PluginType` |
| 30 | + * uses. `satisfies` makes each entry a member of the union; the schema |
| 31 | + * comparison below makes the list COMPLETE against the Zod enum. |
| 32 | + */ |
| 33 | +const UNION_MEMBERS = ['standard', ...CORE_PLUGIN_TYPES] as const satisfies readonly PluginType[]; |
| 34 | + |
| 35 | +/** |
| 36 | + * Walks the wrapper chain `PluginSchema.shape.type` carries |
| 37 | + * (`optional` → `default` → `enum`, measured at 9c7d9d4b3) down to the enum's |
| 38 | + * declared options. Throws rather than returning `[]` when no enum is found, |
| 39 | + * so a re-shaped key cannot read as "zero members, all equal". |
| 40 | + */ |
| 41 | +function zodEnumOptions(schema: unknown): readonly string[] { |
| 42 | + let node = schema as { options?: readonly string[]; def?: { innerType?: unknown } } | undefined; |
| 43 | + while (node) { |
| 44 | + if (Array.isArray(node.options)) return node.options; |
| 45 | + node = node.def?.innerType as typeof node; |
| 46 | + } |
| 47 | + throw new Error('PluginSchema.shape.type carries no z.enum in its wrapper chain'); |
| 48 | +} |
| 49 | + |
| 50 | +describe('Plugin.type closed set — runtime parity with the spec enum (#13925)', () => { |
| 51 | + it('the Zod enum enumerates exactly the TypeScript union, in declared order', () => { |
| 52 | + const options = zodEnumOptions(PluginSchema.shape.type); |
| 53 | + expect(options).toEqual([...UNION_MEMBERS]); |
| 54 | + // Positive control on the instrument: the list is populated and the |
| 55 | + // spec constant is the seven-member set the union is derived from. |
| 56 | + expect(options).toHaveLength(8); |
| 57 | + expect(CORE_PLUGIN_TYPES).toHaveLength(7); |
| 58 | + }); |
| 59 | + |
| 60 | + it('every union member parses through PluginSchema', () => { |
| 61 | + for (const type of UNION_MEMBERS) { |
| 62 | + const result = PluginSchema.safeParse({ type }); |
| 63 | + expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true); |
| 64 | + } |
| 65 | + }); |
| 66 | + |
| 67 | + it('a non-member is refused by PluginSchema with invalid_value at ["type"]', () => { |
| 68 | + // `'plugin'` / `'module'` are PACKAGE manifest types (ManifestSchema.type), |
| 69 | + // never plugin types; `'ui-plugin'` is the spelling a stale describe() |
| 70 | + // string still uses; the casing variant guards against a lax comparator. |
| 71 | + for (const type of ['bogus', 'ui-plugin', 'plugin', 'module', 'Standard']) { |
| 72 | + const result = PluginSchema.safeParse({ type }); |
| 73 | + expect(result.success, `PluginSchema accepted non-member '${type}'`).toBe(false); |
| 74 | + if (!result.success) { |
| 75 | + expect(result.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([ |
| 76 | + ['invalid_value', 'type'], |
| 77 | + ]); |
| 78 | + } |
| 79 | + } |
| 80 | + }); |
| 81 | +}); |
0 commit comments