From 7ad016d68b9d6128968a3b131bf29dfaf057ea4d Mon Sep 17 00:00:00 2001 From: andresgutgon Date: Thu, 2 Oct 2025 11:19:10 +0200 Subject: [PATCH 1/2] Revert "Upgrade to from Zod v3 to Zod v4 (#41)" This reverts commit a8bc5bca3669786592d05fd78561d2e61afbb673. --- package.json | 2 +- pnpm-lock.yaml | 16 ++-- src/compiler/scan.ts | 4 +- src/compiler/utils.test.ts | 39 ++++---- src/compiler/utils.ts | 179 +++++++++++++++++++++++++------------ 5 files changed, 152 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index 058e307..62eda4d 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "locate-character": "^3.0.0", "openai": "^4.98.0", "yaml": "^2.4.5", - "zod": "^4.1.8" + "zod": "^3.23.8" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5640d0b..a790190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,13 +22,13 @@ importers: version: 3.0.0 openai: specifier: ^4.98.0 - version: 4.98.0(zod@4.1.11) + version: 4.98.0(zod@3.23.8) yaml: specifier: ^2.4.5 version: 2.6.1 zod: - specifier: ^4.1.8 - version: 4.1.11 + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -1570,8 +1570,8 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} - zod@4.1.11: - resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} snapshots: @@ -2598,7 +2598,7 @@ snapshots: dependencies: mimic-fn: 4.0.0 - openai@4.98.0(zod@4.1.11): + openai@4.98.0(zod@3.23.8): dependencies: '@types/node': 18.19.100 '@types/node-fetch': 2.6.12 @@ -2608,7 +2608,7 @@ snapshots: formdata-node: 4.4.1 node-fetch: 2.7.0 optionalDependencies: - zod: 4.1.11 + zod: 3.23.8 transitivePeerDependencies: - encoding @@ -2912,4 +2912,4 @@ snapshots: yocto-queue@1.1.1: {} - zod@4.1.11: {} + zod@3.23.8: {} diff --git a/src/compiler/scan.ts b/src/compiler/scan.ts index e2b673d..bb94a5e 100644 --- a/src/compiler/scan.ts +++ b/src/compiler/scan.ts @@ -319,8 +319,8 @@ export class Scan { this.configSchema?.parse(parsedObj) } catch (err) { if (isZodError(err)) { - err.issues.forEach((issue) => { - const { message, path } = getMostSpecificError(issue) + err.errors.forEach((error) => { + const { message, path } = getMostSpecificError(error) const range = findYAMLItemPosition( parsedYaml.contents as YAMLItem, diff --git a/src/compiler/utils.test.ts b/src/compiler/utils.test.ts index 8fa96f0..33286c3 100644 --- a/src/compiler/utils.test.ts +++ b/src/compiler/utils.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect } from 'vitest' -import { ZodError, core, z } from 'zod' +import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod' import { getMostSpecificError } from './utils' -type ZodIssue = core.$ZodIssue - function makeZodError(issues: ZodIssue[]): ZodError { + // @ts-ignore return new ZodError(issues) } @@ -12,9 +11,9 @@ describe('getMostSpecificError', () => { it('returns the message and path for a simple error', () => { const error = makeZodError([ { - code: 'invalid_type', + code: ZodIssueCode.invalid_type, expected: 'string', - input: 'number', + received: 'number', path: ['foo'], message: 'Expected string', }, @@ -27,26 +26,26 @@ describe('getMostSpecificError', () => { it('returns the most specific (deepest) error in a nested structure', () => { const unionError = makeZodError([ { - code: 'invalid_union', - errors: [ - [ + code: ZodIssueCode.invalid_union, + unionErrors: [ + makeZodError([ { - code: 'invalid_type', + code: ZodIssueCode.invalid_type, expected: 'string', - input: 'number', + received: 'number', path: ['foo', 'bar'], message: 'Expected string', }, - ], - [ + ]), + makeZodError([ { - code: 'invalid_type', + code: ZodIssueCode.invalid_type, expected: 'number', - input: 'string', + received: 'string', path: ['foo'], message: 'Expected number', }, - ], + ]), ], path: ['foo'], message: 'Invalid union', @@ -60,7 +59,7 @@ describe('getMostSpecificError', () => { it('returns the error message and empty path if no issues', () => { const error = makeZodError([ { - code: 'custom', + code: ZodIssueCode.custom, path: [], message: 'Custom error', }, @@ -73,16 +72,16 @@ describe('getMostSpecificError', () => { it('handles errors with multiple paths and picks the deepest', () => { const error = makeZodError([ { - code: 'invalid_type', + code: ZodIssueCode.invalid_type, expected: 'string', - input: 'number', + received: 'number', path: ['a'], message: 'Expected string', }, { - code: 'invalid_type', + code: ZodIssueCode.invalid_type, expected: 'number', - input: 'string', + received: 'string', path: ['a', 'b', 'c'], message: 'Expected number', }, diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 5c6529c..caf15d6 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -1,4 +1,3 @@ -import { ZodError, core } from 'zod' import { TAG_NAMES } from '$promptl/constants' import { ChainStepTag, @@ -10,8 +9,7 @@ import { } from '$promptl/parser/interfaces' import { ContentTypeTagName, MessageRole } from '$promptl/types' import { Scalar, Node as YAMLItem, YAMLMap, YAMLSeq } from 'yaml' - -type ZodIssue = core.$ZodIssue +import { ZodError, ZodIssue, ZodIssueCode } from 'zod' export function isIterable(obj: unknown): obj is Iterable { return (obj as Iterable)?.[Symbol.iterator] !== undefined @@ -78,7 +76,7 @@ export function tagAttributeIsLiteral(tag: ElementTag, name: string): boolean { type YAMLItemRange = [number, number] | undefined export function findYAMLItemPosition( parent: YAMLItem, - path: PropertyKey[], + path: (string | number)[], ): YAMLItemRange { const parentRange: YAMLItemRange = parent?.range ? [parent.range[0], parent.range[1]] @@ -103,78 +101,145 @@ export function findYAMLItemPosition( } export function isZodError(error: unknown): error is ZodError { - return error instanceof ZodError -} + if (!(error instanceof Error)) return false -function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] { - if (issue.code === 'invalid_union') { - issue.errors - return issue.errors.flatMap((issues) => - issues.flatMap(collectAllLeafIssues), - ) - } - return [issue] + if (error instanceof ZodError) return true + if (error.constructor.name === 'ZodError') return true + if ('issues' in error && error.issues instanceof Array) return true + + return false } -function getZodIssueMessage(issue: ZodIssue): string { +function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] { switch (issue.code) { - case 'invalid_type': { - const attr = issue.path.at(-1) - return typeof attr === 'string' - ? `Expected type \`${issue.expected}\` for attribute "${attr}", but received \`${issue.input}\`.` - : `Expected type \`${issue.expected}\`, but received \`${issue.input}\`.` + case ZodIssueCode.invalid_union: { + // invalid_union.issue.unionErrors is ZodError[] + const unionErrs: ZodError[] = (issue as any).unionErrors ?? [] + return unionErrs.flatMap((nestedZodError) => + nestedZodError.issues.flatMap((nestedIssue) => + collectAllLeafIssues(nestedIssue), + ), + ) } - case 'invalid_value': { - const attr = issue.path.at(-1) - const literalValues = issue.values.join(', ') - return typeof attr === 'string' - ? `Expected literal \`${literalValues}\` for attribute "${attr}", but received \`${issue.input}\`.` - : `Expected literal \`${literalValues}\`, but received \`${issue.input}\`.` + case ZodIssueCode.invalid_arguments: { + // invalid_arguments.issue.argumentsError is ZodError + const argsErr: ZodError | undefined = (issue as any).argumentsError + if (argsErr) { + return argsErr.issues.flatMap((nestedIssue) => + collectAllLeafIssues(nestedIssue), + ) + } + return [issue] } - case 'unrecognized_keys': - return `Unrecognized keys: ${issue.keys.join(', ')}.` - - case 'invalid_union': - return `Invalid union: ${issue.message}` - - case 'too_small': - return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}` - - case 'too_big': - return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}` - - case 'not_multiple_of': - return `Value is not a multiple of ${issue.divisor}: ${issue.message || 'Value does not meet multiple of condition.'}` - - case 'custom': - return `Custom validation error: ${issue.message || 'No additional message provided.'}` + case ZodIssueCode.invalid_return_type: { + // invalid_return_type.issue.returnTypeError is ZodError + const retErr: ZodError | undefined = (issue as any).returnTypeError + if (retErr) { + return retErr.issues.flatMap((nestedIssue) => + collectAllLeafIssues(nestedIssue), + ) + } + return [issue] + } - case 'invalid_key': - return `Invalid key: ${issue.message || 'Key validation failed.'}` + default: + // Any other issue code is considered a “leaf” (no deeper nested ZodError) + return [issue] + } +} - case 'invalid_format': - return `Invalid format: ${issue.message || `Expected format ${issue.format}.`}` +function getZodIssueMessage(issue: ZodIssue): string { + if (issue.code === ZodIssueCode.invalid_type) { + const attribute = issue.path[issue.path.length - 1] + if (typeof attribute === 'string') { + return `Expected type \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.` + } - case 'invalid_element': - return `Invalid element: ${issue.message || 'Element validation failed.'}` + return `Expected type \`${issue.expected}\`, but received \`${issue.received}\`.` + } + if (issue.code === ZodIssueCode.invalid_literal) { + const attribute = issue.path[issue.path.length - 1] + if (typeof attribute === 'string') { + return `Expected literal \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.` + } - default: - // The types are exhaustive, but we don't want to miss any new ones - return 'Unknown validation error.' + return `Expected literal \`${issue.expected}\`, but received \`${issue.received}\`.` } + if (issue.code === ZodIssueCode.unrecognized_keys) { + return `Unrecognized keys: ${issue.keys.join(', ')}.` + } + if (issue.code === ZodIssueCode.invalid_union) { + return `Invalid union: ${issue.unionErrors + .map((err) => err.message) + .join(', ')}` + } + if (issue.code === ZodIssueCode.invalid_union_discriminator) { + return `Invalid union discriminator. Expected one of: ${issue.options.join( + ', ', + )}.` + } + if (issue.code === ZodIssueCode.invalid_enum_value) { + return `Invalid enum value: ${issue.received}. Expected one of: ${issue.options.join( + ', ', + )}.` + } + if (issue.code === ZodIssueCode.invalid_arguments) { + return `Invalid arguments: ${issue.argumentsError.issues + .map((err) => err.message) + .join(', ')}` + } + if (issue.code === ZodIssueCode.invalid_return_type) { + return `Invalid return type: ${issue.returnTypeError.issues + .map((err) => err.message) + .join(', ')}` + } + if (issue.code === ZodIssueCode.invalid_date) { + return `Invalid date: ${issue.message || 'Invalid date format.'}` + } + if (issue.code === ZodIssueCode.invalid_string) { + return `Invalid string: ${issue.message || 'String does not match expected format.'}` + } + if (issue.code === ZodIssueCode.too_small) { + return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}` + } + if (issue.code === ZodIssueCode.too_big) { + return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}` + } + if (issue.code === ZodIssueCode.invalid_intersection_types) { + return `Invalid intersection types: ${issue.message || 'Types do not match.'}` + } + if (issue.code === ZodIssueCode.not_multiple_of) { + return `Value is not a multiple of ${issue.multipleOf}: ${issue.message || 'Value does not meet multiple of condition.'}` + } + if (issue.code === ZodIssueCode.not_finite) { + return `Value is not finite: ${issue.message || 'Value must be a finite number.'}` + } + if (issue.code === ZodIssueCode.custom) { + return `Custom validation error: ${issue.message || 'No additional message provided.'}` + } + // For any other issue code, return the message directly + return (issue as ZodIssue).message || 'Unknown validation error.' } export function getMostSpecificError(error: ZodIssue): { message: string - path: PropertyKey[] + path: (string | number)[] } { const allIssues = collectAllLeafIssues(error) - const mostSpecific = allIssues.reduce( - (acc, cur) => (cur.path.length > acc.path.length ? cur : acc), - allIssues[0]!, - ) + + if (allIssues.length === 0) { + return { message: error.message, path: [] } + } + + let mostSpecific = allIssues[0]! + for (const issue of allIssues) { + if (issue.path.length > mostSpecific.path.length) { + mostSpecific = issue + } + } + return { message: getZodIssueMessage(mostSpecific), path: mostSpecific.path, From 56a95b3ef9c9cf07040717798869293337b2ee7f Mon Sep 17 00:00:00 2001 From: andresgutgon Date: Thu, 2 Oct 2025 11:20:58 +0200 Subject: [PATCH 2/2] Loose config schema type --- src/compiler/scan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/scan.ts b/src/compiler/scan.ts index bb94a5e..05ea5b8 100644 --- a/src/compiler/scan.ts +++ b/src/compiler/scan.ts @@ -57,7 +57,7 @@ export class Scan { private fullPath: string private withParameters?: string[] private requireConfig: boolean - private configSchema?: z.ZodType + private configSchema?: z.ZodTypeAny private builtins: Record any> private config?: Config