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": "^4.1.8"
"zod": "^3.23.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.

6 changes: 3 additions & 3 deletions src/compiler/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class Scan {
private fullPath: string
private withParameters?: string[]
private requireConfig: boolean
private configSchema?: z.ZodType
private configSchema?: z.ZodTypeAny
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only change in this PR. The rest is downgrading this package to zod v3

private builtins: Record<string, () => any>

private config?: Config
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 19 additions & 20 deletions src/compiler/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
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)
}

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',
},
Expand All @@ -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',
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand Down
179 changes: 122 additions & 57 deletions src/compiler/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ZodError, core } from 'zod'
import { TAG_NAMES } from '$promptl/constants'
import {
ChainStepTag,
Expand All @@ -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<unknown> {
return (obj as Iterable<unknown>)?.[Symbol.iterator] !== undefined
Expand Down Expand Up @@ -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]]
Expand All @@ -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,
Expand Down