From 4e85d335ba5260a87be2019697296fe30f8d7814 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 17:17:17 -0700 Subject: [PATCH] fix(cli): include allowed values in oneOf const validation errors --- .../__tests__/lib/validate-type-spec.test.ts | 84 +++++++++++++++++++ apps/cli/src/lib/operation-args.ts | 21 ++++- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/__tests__/lib/validate-type-spec.test.ts diff --git a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts new file mode 100644 index 0000000000..2d7161109c --- /dev/null +++ b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; +import { validateValueAgainstTypeSpec } from '../../lib/operation-args'; +import { CliError } from '../../lib/errors'; +import type { CliTypeSpec } from '../../cli/types'; + +describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => { + const schema: CliTypeSpec = { + oneOf: [ + { const: 'headerRow' }, + { const: 'totalRow' }, + { const: 'firstColumn' }, + { const: 'lastColumn' }, + { const: 'bandedRows' }, + { const: 'bandedColumns' }, + ], + }; + + test('accepts a valid const value', () => { + expect(() => validateValueAgainstTypeSpec('headerRow', schema, 'flag')).not.toThrow(); + expect(() => validateValueAgainstTypeSpec('bandedColumns', schema, 'flag')).not.toThrow(); + }); + + test('rejects an invalid value and lists all allowed values', () => { + try { + validateValueAgainstTypeSpec('lastRow', schema, 'tables set-style-option:flag'); + throw new Error('Expected CliError to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + const cliError = error as CliError; + expect(cliError.code).toBe('VALIDATION_ERROR'); + expect(cliError.message).toBe( + 'tables set-style-option:flag must be one of: headerRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.', + ); + } + }); + + test('preserves per-variant errors in details', () => { + try { + validateValueAgainstTypeSpec('invalid', schema, 'flag'); + throw new Error('Expected CliError to be thrown'); + } catch (error) { + const cliError = error as CliError; + const details = cliError.details as { errors: string[] }; + expect(details.errors).toBeArrayOfSize(6); + } + }); +}); + +describe('validateValueAgainstTypeSpec – oneOf with mixed schemas', () => { + const mixedSchema: CliTypeSpec = { + oneOf: [{ const: 'block' }, { type: 'object', properties: { kind: { const: 'inline' } }, required: ['kind'] }], + }; + + test('falls back to generic message when variants are not all const', () => { + try { + validateValueAgainstTypeSpec('nope', mixedSchema, 'target'); + throw new Error('Expected CliError to be thrown'); + } catch (error) { + const cliError = error as CliError; + expect(cliError.message).toBe('target must match one of the allowed schema variants.'); + } + }); +}); + +describe('validateValueAgainstTypeSpec – enum branch', () => { + const enumSchema: CliTypeSpec = { + type: 'string', + enum: ['direct', 'tracked'], + } as CliTypeSpec & { enum: string[] }; + + test('accepts a valid enum value', () => { + expect(() => validateValueAgainstTypeSpec('direct', enumSchema, 'changeMode')).not.toThrow(); + }); + + test('rejects an invalid enum value with allowed list', () => { + try { + validateValueAgainstTypeSpec('bogus', enumSchema, 'changeMode'); + throw new Error('Expected CliError to be thrown'); + } catch (error) { + const cliError = error as CliError; + expect(cliError.message).toBe('changeMode must be one of: direct, tracked.'); + } + }); +}); diff --git a/apps/cli/src/lib/operation-args.ts b/apps/cli/src/lib/operation-args.ts index 41740f2843..b7f89825dc 100644 --- a/apps/cli/src/lib/operation-args.ts +++ b/apps/cli/src/lib/operation-args.ts @@ -100,6 +100,19 @@ function acceptsLegacyTextAddressTarget( return docApiId === 'replace' || docApiId === 'delete' || docApiId?.startsWith('format.') === true; } +/** + * If every variant in a `oneOf` is a `{ const: X }`, return the values as strings. + * Returns an empty array when the pattern doesn't hold (mixed / nested schemas). + */ +function extractConstValues(variants: CliTypeSpec[]): string[] { + const values: string[] = []; + for (const variant of variants) { + if (!('const' in variant)) return []; + values.push(String(variant.const)); + } + return values; +} + export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec, path: string): void { if ('const' in schema) { if (value !== schema.const) { @@ -119,7 +132,13 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec errors.push(error instanceof Error ? error.message : String(error)); } } - throw new CliError('VALIDATION_ERROR', `${path} must match one of the allowed schema variants.`, { errors }); + + const allowedValues = extractConstValues(variants); + const message = + allowedValues.length > 0 + ? `${path} must be one of: ${allowedValues.join(', ')}.` + : `${path} must match one of the allowed schema variants.`; + throw new CliError('VALIDATION_ERROR', message, { errors }); } if (schema.type === 'json') return;