/** * Strict runtime input checking for agent-facing exports. * Unknown keys are rejected so a guessed key never silently changes intent. */ export type FieldSpec = | { type: 'string'; required?: boolean; pattern?: RegExp; maxLength?: number; allowEmpty?: boolean } | { type: 'integer'; required?: boolean; min?: number; max?: number } | { type: 'boolean'; required?: boolean } | { type: 'stringArray'; required?: boolean; maxItems?: number } | { type: 'enum'; values: ReadonlyArray; required?: boolean } | { type: 'enumArray'; values: ReadonlyArray; required?: boolean } | { type: 'object'; required?: boolean } | { type: 'array'; required?: boolean; maxItems?: number } export type Spec = Record export class InputError extends Error { constructor(message: string) { super(message) this.name = 'TodosInputError' } } export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function describeSpec(spec: Spec): string { return Object.entries(spec) .map(([key, field]) => { const type = field.type === 'enum' || field.type === 'enumArray' ? `${field.type === 'enumArray' ? 'array of ' : ''}${field.values.map((v) => JSON.stringify(v)).join(' | ')}` : field.type return `${key}${field.required ? '' : '?'}: ${type}` }) .join(', ') } /** * Parse an export input object against a strict spec. Throws on unknown keys, * missing required keys, or wrong types. Returns only keys that were provided. */ export function parseInput>( raw: unknown, spec: Spec, exportName: string, ): T { const input = raw === undefined || raw === null ? {} : raw if (!isRecord(input)) { throw new InputError(`${exportName}: input must be a plain object. Accepted shape: { ${describeSpec(spec)} }`) } const unknownKeys = Object.keys(input).filter((key) => !(key in spec)) if (unknownKeys.length > 0) { throw new InputError( `${exportName}: unknown input key(s) ${unknownKeys.map((k) => JSON.stringify(k)).join(', ')}. ` + `Accepted shape: { ${describeSpec(spec)} }`, ) } const out: Record = {} for (const [key, field] of Object.entries(spec)) { const value = input[key] if (value === undefined || value === null) { if (field.required) { throw new InputError(`${exportName}: missing required "${key}". Accepted shape: { ${describeSpec(spec)} }`) } continue } out[key] = checkField(exportName, key, field, value) } return out as T } function checkField(exportName: string, key: string, field: FieldSpec, value: unknown): unknown { const fail = (expected: string): never => { throw new InputError(`${exportName}: "${key}" must be ${expected}.`) } switch (field.type) { case 'string': { if (typeof value !== 'string') return fail('a string') const trimmed = (value as string).trim() if (!field.allowEmpty && trimmed.length === 0) return fail('a non-empty string') if (field.maxLength && (value as string).length > field.maxLength) return fail(`at most ${field.maxLength} characters`) if (field.pattern && !field.pattern.test(trimmed)) return fail(`a string matching ${String(field.pattern)}`) return field.allowEmpty ? value : trimmed } case 'integer': { if (typeof value !== 'number' || !Number.isInteger(value)) return fail('an integer') const num = value as number if (field.min !== undefined && num < field.min) return fail(`>= ${field.min}`) if (field.max !== undefined && num > field.max) return fail(`<= ${field.max}`) return num } case 'boolean': { if (typeof value !== 'boolean') return fail('a boolean') return value } case 'stringArray': { if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim().length > 0)) { return fail('an array of non-empty strings') } if (field.maxItems && (value as Array).length > field.maxItems) return fail(`at most ${field.maxItems} items`) return (value as Array).map((item) => item.trim()) } case 'enum': { if (typeof value !== 'string' || !field.values.includes(value)) { return fail(`one of ${field.values.map((v) => JSON.stringify(v)).join(', ')}`) } return value } case 'enumArray': { const list = typeof value === 'string' ? [value] : value if (!Array.isArray(list) || !list.every((item) => typeof item === 'string' && field.values.includes(item))) { return fail(`one of (or an array of) ${field.values.map((v) => JSON.stringify(v)).join(', ')}`) } return list } case 'object': { if (!isRecord(value)) return fail('an object') return value } case 'array': { if (!Array.isArray(value)) return fail('an array') if (field.maxItems && value.length > field.maxItems) return fail(`at most ${field.maxItems} items`) return value } default: return value } }