Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"locate-character": "^3.0.0",
"openai": "^4.98.0",
"yaml": "^2.4.5",
"zod": "^3.23.8"
"zod": "^4.1.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/compiler/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,8 @@ export class Scan {
this.configSchema?.parse(parsedObj)
} catch (err) {
if (isZodError(err)) {
err.errors.forEach((error) => {
const { message, path } = getMostSpecificError(error)
err.issues.forEach((issue) => {
const { message, path } = getMostSpecificError(issue)

const range = findYAMLItemPosition(
parsedYaml.contents as YAMLItem,
Expand Down
39 changes: 20 additions & 19 deletions src/compiler/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { describe, it, expect } from 'vitest'
import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod'
import { ZodError, core, z } from 'zod'
import { getMostSpecificError } from './utils'

type ZodIssue = core.$ZodIssue

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,
code: 'invalid_type',
expected: 'string',
received: 'number',
input: 'number',
path: ['foo'],
message: 'Expected string',
},
Expand All @@ -26,26 +27,26 @@ describe('getMostSpecificError', () => {
it('returns the most specific (deepest) error in a nested structure', () => {
const unionError = makeZodError([
{
code: ZodIssueCode.invalid_union,
unionErrors: [
makeZodError([
code: 'invalid_union',
errors: [
[
{
code: ZodIssueCode.invalid_type,
code: 'invalid_type',
expected: 'string',
received: 'number',
input: 'number',
path: ['foo', 'bar'],
message: 'Expected string',
},
]),
makeZodError([
],
[
{
code: ZodIssueCode.invalid_type,
code: 'invalid_type',
expected: 'number',
received: 'string',
input: 'string',
path: ['foo'],
message: 'Expected number',
},
]),
],
],
path: ['foo'],
message: 'Invalid union',
Expand All @@ -59,7 +60,7 @@ describe('getMostSpecificError', () => {
it('returns the error message and empty path if no issues', () => {
const error = makeZodError([
{
code: ZodIssueCode.custom,
code: 'custom',
path: [],
message: 'Custom error',
},
Expand All @@ -72,16 +73,16 @@ describe('getMostSpecificError', () => {
it('handles errors with multiple paths and picks the deepest', () => {
const error = makeZodError([
{
code: ZodIssueCode.invalid_type,
code: 'invalid_type',
expected: 'string',
received: 'number',
input: 'number',
path: ['a'],
message: 'Expected string',
},
{
code: ZodIssueCode.invalid_type,
code: 'invalid_type',
expected: 'number',
received: 'string',
input: 'string',
path: ['a', 'b', 'c'],
message: 'Expected number',
},
Expand Down
179 changes: 57 additions & 122 deletions src/compiler/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ZodError, core } from 'zod'
import { TAG_NAMES } from '$promptl/constants'
import {
ChainStepTag,
Expand All @@ -9,7 +10,8 @@ import {
} from '$promptl/parser/interfaces'
import { ContentTypeTagName, MessageRole } from '$promptl/types'
import { Scalar, Node as YAMLItem, YAMLMap, YAMLSeq } from 'yaml'
import { ZodError, ZodIssue, ZodIssueCode } from 'zod'

type ZodIssue = core.$ZodIssue

export function isIterable(obj: unknown): obj is Iterable<unknown> {
return (obj as Iterable<unknown>)?.[Symbol.iterator] !== undefined
Expand Down Expand Up @@ -76,7 +78,7 @@ export function tagAttributeIsLiteral(tag: ElementTag, name: string): boolean {
type YAMLItemRange = [number, number] | undefined
export function findYAMLItemPosition(
parent: YAMLItem,
path: (string | number)[],
path: PropertyKey[],
): YAMLItemRange {
const parentRange: YAMLItemRange = parent?.range
? [parent.range[0], parent.range[1]]
Expand All @@ -101,145 +103,78 @@ export function findYAMLItemPosition(
}

export function isZodError(error: unknown): error is ZodError {
if (!(error instanceof Error)) return false

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
return error instanceof ZodError
}

function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] {
if (issue.code === 'invalid_union') {
issue.errors
return issue.errors.flatMap((issues) =>
issues.flatMap(collectAllLeafIssues),
)
}
return [issue]
}

function getZodIssueMessage(issue: ZodIssue): string {
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 '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_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 '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_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 'unrecognized_keys':
return `Unrecognized keys: ${issue.keys.join(', ')}.`

default:
// Any other issue code is considered a “leaf” (no deeper nested ZodError)
return [issue]
}
}
case 'invalid_union':
return `Invalid union: ${issue.message}`

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 'too_small':
return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}`

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}\`.`
}
case 'too_big':
return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}`

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.'}`
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 'invalid_key':
return `Invalid key: ${issue.message || 'Key validation failed.'}`

case 'invalid_format':
return `Invalid format: ${issue.message || `Expected format ${issue.format}.`}`

case 'invalid_element':
return `Invalid element: ${issue.message || 'Element validation failed.'}`

default:
// The types are exhaustive, but we don't want to miss any new ones
return 'Unknown validation error.'
}
// 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)[]
path: PropertyKey[]
} {
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
}
}

const mostSpecific = allIssues.reduce(
(acc, cur) => (cur.path.length > acc.path.length ? cur : acc),
allIssues[0]!,
)
return {
message: getZodIssueMessage(mostSpecific),
path: mostSpecific.path,
Expand Down