diff --git a/package.json b/package.json index 83a08da..70ffe71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "promptl-ai", - "version": "0.6.5", + "version": "0.6.6", "author": "Latitude Data", "license": "MIT", "description": "Compiler for PromptL, the prompt language", @@ -20,7 +20,9 @@ } } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "dev": "rollup -c -w", "build": "rollup -c", diff --git a/src/compiler/scan.ts b/src/compiler/scan.ts index 6ce88c8..e904a39 100644 --- a/src/compiler/scan.ts +++ b/src/compiler/scan.ts @@ -32,6 +32,7 @@ import { ScopeContext } from './scope' import { Document, ReferencePromptFn } from './types' import { findYAMLItemPosition, + getMostSpecificError, isChainStepTag, isContentTag, isMessageTag, @@ -314,11 +315,11 @@ export class Scan { } catch (err) { if (isZodError(err)) { err.errors.forEach((error) => { - const issue = error.message + const { message, path } = getMostSpecificError(error) const range = findYAMLItemPosition( parsedYaml.contents as YAMLItem, - error.path, + path, ) const errorStart = range @@ -328,7 +329,7 @@ export class Scan { ? node.start! + CONFIG_START_OFFSET + range[1] + 1 : node.end! - this.baseNodeError(errors.invalidConfig(issue), node, { + this.baseNodeError(errors.invalidConfig(message), node, { start: errorStart, end: errorEnd, }) diff --git a/src/compiler/utils.test.ts b/src/compiler/utils.test.ts new file mode 100644 index 0000000..33286c3 --- /dev/null +++ b/src/compiler/utils.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod' +import { getMostSpecificError } from './utils' + +function makeZodError(issues: ZodIssue[]): ZodError { + // @ts-ignore + return new ZodError(issues) +} + +describe('getMostSpecificError', () => { + it('returns the message and path for a simple error', () => { + const error = makeZodError([ + { + code: ZodIssueCode.invalid_type, + expected: 'string', + received: 'number', + path: ['foo'], + message: 'Expected string', + }, + ]) + const result = getMostSpecificError(error.issues[0]!) + expect(result.message).toMatch('Expected type') + expect(result.path).toEqual(['foo']) + }) + + it('returns the most specific (deepest) error in a nested structure', () => { + const unionError = makeZodError([ + { + code: ZodIssueCode.invalid_union, + unionErrors: [ + makeZodError([ + { + code: ZodIssueCode.invalid_type, + expected: 'string', + received: 'number', + path: ['foo', 'bar'], + message: 'Expected string', + }, + ]), + makeZodError([ + { + code: ZodIssueCode.invalid_type, + expected: 'number', + received: 'string', + path: ['foo'], + message: 'Expected number', + }, + ]), + ], + path: ['foo'], + message: 'Invalid union', + }, + ]) + const result = getMostSpecificError(unionError.issues[0]!) + expect(result.path).toEqual(['foo', 'bar']) + expect(result.message).toMatch('Expected type') + }) + + it('returns the error message and empty path if no issues', () => { + const error = makeZodError([ + { + code: ZodIssueCode.custom, + path: [], + message: 'Custom error', + }, + ]) + const result = getMostSpecificError(error.issues[0]!) + expect(result.message).toMatch('Custom error') + expect(result.path).toEqual([]) + }) + + it('handles errors with multiple paths and picks the deepest', () => { + const error = makeZodError([ + { + code: ZodIssueCode.invalid_type, + expected: 'string', + received: 'number', + path: ['a'], + message: 'Expected string', + }, + { + code: ZodIssueCode.invalid_type, + expected: 'number', + received: 'string', + path: ['a', 'b', 'c'], + message: 'Expected number', + }, + ]) + const result = getMostSpecificError(error.issues[1]!) // The deepest path is at index 1 + expect(result.path).toEqual(['a', 'b', 'c']) + expect(result.message).toMatch('Expected type') + }) + + it('handles ZodError thrown by zod schema', () => { + const schema = z.object({ foo: z.string() }) + let error: ZodError | undefined + try { + schema.parse({ foo: 123 }) + } catch (e) { + error = e as ZodError + } + expect(error).toBeDefined() + expect(error!.issues.length).toBeGreaterThan(0) + const result = getMostSpecificError(error!.issues[0]!) + expect(result.path).toEqual(['foo']) + expect(result.message).toMatch('Expected type') + }) +}) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index b0f7f42..caf15d6 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -9,7 +9,7 @@ import { } from '$promptl/parser/interfaces' import { ContentTypeTagName, MessageRole } from '$promptl/types' import { Scalar, Node as YAMLItem, YAMLMap, YAMLSeq } from 'yaml' -import { ZodError } from 'zod' +import { ZodError, ZodIssue, ZodIssueCode } from 'zod' export function isIterable(obj: unknown): obj is Iterable { return (obj as Iterable)?.[Symbol.iterator] !== undefined @@ -109,3 +109,139 @@ export function isZodError(error: unknown): error is ZodError { return false } + +function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] { + switch (issue.code) { + 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 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 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] + } + + default: + // Any other issue code is considered a “leaf” (no deeper nested ZodError) + return [issue] + } +} + +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}\`.` + } + + 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}\`.` + } + + 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: (string | number)[] +} { + const allIssues = collectAllLeafIssues(error) + + 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, + } +}