Skip to content
Draft
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
149 changes: 148 additions & 1 deletion packages/app/src/cli/models/app/error-parsing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {parseHumanReadableError} from './error-parsing.js'
import {parseHumanReadableError, parseStructuredErrors} from './error-parsing.js'
import {describe, expect, test} from 'vitest'

describe('parseHumanReadableError', () => {
Expand Down Expand Up @@ -235,3 +235,150 @@ describe('parseHumanReadableError', () => {
expect(result).not.toContain('Union validation failed')
})
})

describe('parseStructuredErrors', () => {
test('preserves regular issues with file path and path string', () => {
const issues = [
{
path: ['name'],
message: 'Required field is missing',
code: 'invalid_type',
},
{
path: ['version'],
message: 'Must be a valid semver string',
code: 'custom',
},
]

const result = parseStructuredErrors(issues, '/tmp/shopify.app.toml')

expect(result).toEqual([
{
filePath: '/tmp/shopify.app.toml',
path: ['name'],
pathString: 'name',
message: 'Required field is missing',
code: 'invalid_type',
},
{
filePath: '/tmp/shopify.app.toml',
path: ['version'],
pathString: 'version',
message: 'Must be a valid semver string',
code: 'custom',
},
])
})

test('uses the best matching union variant for structured issues', () => {
const issues = [
{
code: 'invalid_union',
unionErrors: [
{
issues: [
{
code: 'invalid_type',
path: ['web', 'roles'],
message: 'Expected array, received number',
},
{
code: 'invalid_type',
path: ['web', 'commands', 'build'],
message: 'Required',
},
],
name: 'ZodError',
},
{
issues: [
{
code: 'invalid_literal',
path: ['web', 'type'],
message: "Invalid literal value, expected 'frontend'",
},
],
name: 'ZodError',
},
],
path: ['web'],
message: 'Invalid input',
},
]

const result = parseStructuredErrors(issues, '/tmp/shopify.web.toml')

expect(result).toEqual([
{
filePath: '/tmp/shopify.web.toml',
path: ['web', 'roles'],
pathString: 'web.roles',
message: 'Expected array, received number',
code: 'invalid_type',
},
{
filePath: '/tmp/shopify.web.toml',
path: ['web', 'commands', 'build'],
pathString: 'web.commands.build',
message: 'Required',
code: 'invalid_type',
},
])
})

test('falls back to recovered nested union issues before returning a synthetic root issue', () => {
const unionIssues = Array.from({length: 51}, (_, index) => ({
code: 'custom',
path: ['variants', index],
message: `Invalid variant ${index + 1}`,
}))

const issues = [
{
code: 'invalid_union',
unionErrors: [{issues: unionIssues, name: 'ZodError'}],
path: ['root'],
message: 'Invalid input',
},
]

const result = parseStructuredErrors(issues, '/tmp/shopify.app.toml')

expect(result).toEqual(
unionIssues.map((issue, index) => ({
filePath: '/tmp/shopify.app.toml',
path: ['variants', index],
pathString: `variants.${index}`,
message: issue.message,
code: 'custom',
})),
)
})

test('falls back to a synthetic root issue when union variants expose no nested issues', () => {
const issues = [
{
code: 'invalid_union',
unionErrors: [
{issues: [], name: 'ZodError'},
{issues: [], name: 'ZodError'},
],
path: ['root'],
message: 'Invalid input',
},
]

const result = parseStructuredErrors(issues, '/tmp/shopify.app.toml')

expect(result).toEqual([
{
filePath: '/tmp/shopify.app.toml',
path: ['root'],
pathString: 'root',
message: "Configuration doesn't match any expected format",
code: 'invalid_union',
},
])
})
})
84 changes: 81 additions & 3 deletions packages/app/src/cli/models/app/error-parsing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type {OutputMessage} from '@shopify/cli-kit/node/output'

interface UnionErrorIssue {
path?: (string | number)[]
message: string
code?: string
}

interface UnionError {
issues?: {path?: (string | number)[]; message: string}[]
issues?: UnionErrorIssue[]
name: string
}

Expand All @@ -10,6 +18,20 @@ interface ExtendedZodIssue {
unionErrors?: UnionError[]
}

export interface AppValidationIssue {
filePath: string
path: (string | number)[]
pathString: string
message: string
code?: string
}

export interface AppValidationFileIssues {
filePath: string
message: OutputMessage
issues: AppValidationIssue[]
}

/**
* Finds the best matching variant from a union error by scoring each variant
* based on how close it is to the user's likely intent.
Expand Down Expand Up @@ -56,13 +78,69 @@ function findBestMatchingVariant(unionErrors: UnionError[]): UnionError | null {
return bestVariant
}

function getIssuePath(path?: (string | number)[]) {
return path ?? []
}

function getIssuePathString(path?: (string | number)[]) {
const resolvedPath = getIssuePath(path)
return resolvedPath.length > 0 ? resolvedPath.map(String).join('.') : 'root'
}

/**
* Formats an issue into a human-readable error line
*/
function formatErrorLine(issue: {path?: (string | number)[]; message?: string}, indent = '') {
const path = issue.path && issue.path.length > 0 ? issue.path.map(String).join('.') : 'root'
const pathString = getIssuePathString(issue.path)
const message = issue.message ?? 'Unknown error'
return `${indent}• [${path}]: ${message}\n`
return `${indent}• [${pathString}]: ${message}\n`
}

function toStructuredIssue(
filePath: string,
issue: Pick<ExtendedZodIssue, 'path' | 'message' | 'code'>,
) {
return {
filePath,
path: getIssuePath(issue.path),
pathString: getIssuePathString(issue.path),
message: issue.message ?? 'Unknown error',
code: issue.code,
}
}

export function parseStructuredErrors(issues: ExtendedZodIssue[], filePath: string): AppValidationIssue[] {
return issues.flatMap((issue) => {
if (issue.code === 'invalid_union' && issue.unionErrors) {
// Intentionally mirror the current human-readable union selection heuristic
// so structured/internal issues stay aligned with existing CLI behavior.
// If we change this heuristic later, text and structured output should move together.
const bestVariant = findBestMatchingVariant(issue.unionErrors)

if (bestVariant?.issues?.length) {
return bestVariant.issues.map((nestedIssue) => toStructuredIssue(filePath, nestedIssue))
}

const fallbackIssues = issue.unionErrors.flatMap((unionError) => unionError.issues ?? [])
if (fallbackIssues.length > 0) {
// Preserve any concrete nested issues we were able to recover before
// falling back to a synthetic root issue. This structured path is still
// internal, and retaining leaf issues is more actionable than erasing
// them behind a generic union failure.
return fallbackIssues.map((nestedIssue) => toStructuredIssue(filePath, nestedIssue))
}

return [
toStructuredIssue(filePath, {
path: issue.path,
message: "Configuration doesn't match any expected format",
code: issue.code,
}),
]
}

return [toStructuredIssue(filePath, issue)]
})
}

export function parseHumanReadableError(issues: ExtendedZodIssue[]) {
Expand Down
Loading
Loading