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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -20,7 +20,9 @@
}
}
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"dev": "rollup -c -w",
"build": "rollup -c",
Expand Down
7 changes: 4 additions & 3 deletions src/compiler/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ScopeContext } from './scope'
import { Document, ReferencePromptFn } from './types'
import {
findYAMLItemPosition,
getMostSpecificError,
isChainStepTag,
isContentTag,
isMessageTag,
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
Expand Down
108 changes: 108 additions & 0 deletions src/compiler/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
138 changes: 137 additions & 1 deletion src/compiler/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
return (obj as Iterable<unknown>)?.[Symbol.iterator] !== undefined
Expand Down Expand Up @@ -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,
}
}