Skip to content

Commit 49611d1

Browse files
authored
Improved console parsing errors (#30)
0.6.6
1 parent f64a4d2 commit 49611d1

File tree

4 files changed

+253
-6
lines changed

4 files changed

+253
-6
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "promptl-ai",
3-
"version": "0.6.5",
3+
"version": "0.6.6",
44
"author": "Latitude Data",
55
"license": "MIT",
66
"description": "Compiler for PromptL, the prompt language",
@@ -20,7 +20,9 @@
2020
}
2121
}
2222
},
23-
"files": ["dist"],
23+
"files": [
24+
"dist"
25+
],
2426
"scripts": {
2527
"dev": "rollup -c -w",
2628
"build": "rollup -c",

src/compiler/scan.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ScopeContext } from './scope'
3232
import { Document, ReferencePromptFn } from './types'
3333
import {
3434
findYAMLItemPosition,
35+
getMostSpecificError,
3536
isChainStepTag,
3637
isContentTag,
3738
isMessageTag,
@@ -314,11 +315,11 @@ export class Scan {
314315
} catch (err) {
315316
if (isZodError(err)) {
316317
err.errors.forEach((error) => {
317-
const issue = error.message
318+
const { message, path } = getMostSpecificError(error)
318319

319320
const range = findYAMLItemPosition(
320321
parsedYaml.contents as YAMLItem,
321-
error.path,
322+
path,
322323
)
323324

324325
const errorStart = range
@@ -328,7 +329,7 @@ export class Scan {
328329
? node.start! + CONFIG_START_OFFSET + range[1] + 1
329330
: node.end!
330331

331-
this.baseNodeError(errors.invalidConfig(issue), node, {
332+
this.baseNodeError(errors.invalidConfig(message), node, {
332333
start: errorStart,
333334
end: errorEnd,
334335
})

src/compiler/utils.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod'
3+
import { getMostSpecificError } from './utils'
4+
5+
function makeZodError(issues: ZodIssue[]): ZodError {
6+
// @ts-ignore
7+
return new ZodError(issues)
8+
}
9+
10+
describe('getMostSpecificError', () => {
11+
it('returns the message and path for a simple error', () => {
12+
const error = makeZodError([
13+
{
14+
code: ZodIssueCode.invalid_type,
15+
expected: 'string',
16+
received: 'number',
17+
path: ['foo'],
18+
message: 'Expected string',
19+
},
20+
])
21+
const result = getMostSpecificError(error.issues[0]!)
22+
expect(result.message).toMatch('Expected type')
23+
expect(result.path).toEqual(['foo'])
24+
})
25+
26+
it('returns the most specific (deepest) error in a nested structure', () => {
27+
const unionError = makeZodError([
28+
{
29+
code: ZodIssueCode.invalid_union,
30+
unionErrors: [
31+
makeZodError([
32+
{
33+
code: ZodIssueCode.invalid_type,
34+
expected: 'string',
35+
received: 'number',
36+
path: ['foo', 'bar'],
37+
message: 'Expected string',
38+
},
39+
]),
40+
makeZodError([
41+
{
42+
code: ZodIssueCode.invalid_type,
43+
expected: 'number',
44+
received: 'string',
45+
path: ['foo'],
46+
message: 'Expected number',
47+
},
48+
]),
49+
],
50+
path: ['foo'],
51+
message: 'Invalid union',
52+
},
53+
])
54+
const result = getMostSpecificError(unionError.issues[0]!)
55+
expect(result.path).toEqual(['foo', 'bar'])
56+
expect(result.message).toMatch('Expected type')
57+
})
58+
59+
it('returns the error message and empty path if no issues', () => {
60+
const error = makeZodError([
61+
{
62+
code: ZodIssueCode.custom,
63+
path: [],
64+
message: 'Custom error',
65+
},
66+
])
67+
const result = getMostSpecificError(error.issues[0]!)
68+
expect(result.message).toMatch('Custom error')
69+
expect(result.path).toEqual([])
70+
})
71+
72+
it('handles errors with multiple paths and picks the deepest', () => {
73+
const error = makeZodError([
74+
{
75+
code: ZodIssueCode.invalid_type,
76+
expected: 'string',
77+
received: 'number',
78+
path: ['a'],
79+
message: 'Expected string',
80+
},
81+
{
82+
code: ZodIssueCode.invalid_type,
83+
expected: 'number',
84+
received: 'string',
85+
path: ['a', 'b', 'c'],
86+
message: 'Expected number',
87+
},
88+
])
89+
const result = getMostSpecificError(error.issues[1]!) // The deepest path is at index 1
90+
expect(result.path).toEqual(['a', 'b', 'c'])
91+
expect(result.message).toMatch('Expected type')
92+
})
93+
94+
it('handles ZodError thrown by zod schema', () => {
95+
const schema = z.object({ foo: z.string() })
96+
let error: ZodError | undefined
97+
try {
98+
schema.parse({ foo: 123 })
99+
} catch (e) {
100+
error = e as ZodError
101+
}
102+
expect(error).toBeDefined()
103+
expect(error!.issues.length).toBeGreaterThan(0)
104+
const result = getMostSpecificError(error!.issues[0]!)
105+
expect(result.path).toEqual(['foo'])
106+
expect(result.message).toMatch('Expected type')
107+
})
108+
})

src/compiler/utils.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '$promptl/parser/interfaces'
1010
import { ContentTypeTagName, MessageRole } from '$promptl/types'
1111
import { Scalar, Node as YAMLItem, YAMLMap, YAMLSeq } from 'yaml'
12-
import { ZodError } from 'zod'
12+
import { ZodError, ZodIssue, ZodIssueCode } from 'zod'
1313

1414
export function isIterable(obj: unknown): obj is Iterable<unknown> {
1515
return (obj as Iterable<unknown>)?.[Symbol.iterator] !== undefined
@@ -109,3 +109,139 @@ export function isZodError(error: unknown): error is ZodError {
109109

110110
return false
111111
}
112+
113+
function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] {
114+
switch (issue.code) {
115+
case ZodIssueCode.invalid_union: {
116+
// invalid_union.issue.unionErrors is ZodError[]
117+
const unionErrs: ZodError[] = (issue as any).unionErrors ?? []
118+
return unionErrs.flatMap((nestedZodError) =>
119+
nestedZodError.issues.flatMap((nestedIssue) =>
120+
collectAllLeafIssues(nestedIssue),
121+
),
122+
)
123+
}
124+
125+
case ZodIssueCode.invalid_arguments: {
126+
// invalid_arguments.issue.argumentsError is ZodError
127+
const argsErr: ZodError | undefined = (issue as any).argumentsError
128+
if (argsErr) {
129+
return argsErr.issues.flatMap((nestedIssue) =>
130+
collectAllLeafIssues(nestedIssue),
131+
)
132+
}
133+
return [issue]
134+
}
135+
136+
case ZodIssueCode.invalid_return_type: {
137+
// invalid_return_type.issue.returnTypeError is ZodError
138+
const retErr: ZodError | undefined = (issue as any).returnTypeError
139+
if (retErr) {
140+
return retErr.issues.flatMap((nestedIssue) =>
141+
collectAllLeafIssues(nestedIssue),
142+
)
143+
}
144+
return [issue]
145+
}
146+
147+
default:
148+
// Any other issue code is considered a “leaf” (no deeper nested ZodError)
149+
return [issue]
150+
}
151+
}
152+
153+
function getZodIssueMessage(issue: ZodIssue): string {
154+
if (issue.code === ZodIssueCode.invalid_type) {
155+
const attribute = issue.path[issue.path.length - 1]
156+
if (typeof attribute === 'string') {
157+
return `Expected type \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.`
158+
}
159+
160+
return `Expected type \`${issue.expected}\`, but received \`${issue.received}\`.`
161+
}
162+
if (issue.code === ZodIssueCode.invalid_literal) {
163+
const attribute = issue.path[issue.path.length - 1]
164+
if (typeof attribute === 'string') {
165+
return `Expected literal \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.`
166+
}
167+
168+
return `Expected literal \`${issue.expected}\`, but received \`${issue.received}\`.`
169+
}
170+
if (issue.code === ZodIssueCode.unrecognized_keys) {
171+
return `Unrecognized keys: ${issue.keys.join(', ')}.`
172+
}
173+
if (issue.code === ZodIssueCode.invalid_union) {
174+
return `Invalid union: ${issue.unionErrors
175+
.map((err) => err.message)
176+
.join(', ')}`
177+
}
178+
if (issue.code === ZodIssueCode.invalid_union_discriminator) {
179+
return `Invalid union discriminator. Expected one of: ${issue.options.join(
180+
', ',
181+
)}.`
182+
}
183+
if (issue.code === ZodIssueCode.invalid_enum_value) {
184+
return `Invalid enum value: ${issue.received}. Expected one of: ${issue.options.join(
185+
', ',
186+
)}.`
187+
}
188+
if (issue.code === ZodIssueCode.invalid_arguments) {
189+
return `Invalid arguments: ${issue.argumentsError.issues
190+
.map((err) => err.message)
191+
.join(', ')}`
192+
}
193+
if (issue.code === ZodIssueCode.invalid_return_type) {
194+
return `Invalid return type: ${issue.returnTypeError.issues
195+
.map((err) => err.message)
196+
.join(', ')}`
197+
}
198+
if (issue.code === ZodIssueCode.invalid_date) {
199+
return `Invalid date: ${issue.message || 'Invalid date format.'}`
200+
}
201+
if (issue.code === ZodIssueCode.invalid_string) {
202+
return `Invalid string: ${issue.message || 'String does not match expected format.'}`
203+
}
204+
if (issue.code === ZodIssueCode.too_small) {
205+
return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}`
206+
}
207+
if (issue.code === ZodIssueCode.too_big) {
208+
return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}`
209+
}
210+
if (issue.code === ZodIssueCode.invalid_intersection_types) {
211+
return `Invalid intersection types: ${issue.message || 'Types do not match.'}`
212+
}
213+
if (issue.code === ZodIssueCode.not_multiple_of) {
214+
return `Value is not a multiple of ${issue.multipleOf}: ${issue.message || 'Value does not meet multiple of condition.'}`
215+
}
216+
if (issue.code === ZodIssueCode.not_finite) {
217+
return `Value is not finite: ${issue.message || 'Value must be a finite number.'}`
218+
}
219+
if (issue.code === ZodIssueCode.custom) {
220+
return `Custom validation error: ${issue.message || 'No additional message provided.'}`
221+
}
222+
// For any other issue code, return the message directly
223+
return (issue as ZodIssue).message || 'Unknown validation error.'
224+
}
225+
226+
export function getMostSpecificError(error: ZodIssue): {
227+
message: string
228+
path: (string | number)[]
229+
} {
230+
const allIssues = collectAllLeafIssues(error)
231+
232+
if (allIssues.length === 0) {
233+
return { message: error.message, path: [] }
234+
}
235+
236+
let mostSpecific = allIssues[0]!
237+
for (const issue of allIssues) {
238+
if (issue.path.length > mostSpecific.path.length) {
239+
mostSpecific = issue
240+
}
241+
}
242+
243+
return {
244+
message: getZodIssueMessage(mostSpecific),
245+
path: mostSpecific.path,
246+
}
247+
}

0 commit comments

Comments
 (0)