From c7f1fa07a86759721c5bb4cd3d1e66cf43947159 Mon Sep 17 00:00:00 2001 From: Adam Barefoot Date: Wed, 10 Jun 2026 21:22:19 -0400 Subject: [PATCH 1/4] Improve JSON schema error messages --- packages/app/src/cli/models/app/loader.ts | 14 ++++- .../app/src/cli/services/validate.test.ts | 22 +++++++ .../src/public/node/json-schema.test.ts | 57 +++++++++++++++++++ .../cli-kit/src/public/node/json-schema.ts | 8 ++- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 206aa1d9471..e0fac11ab57 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -73,11 +73,21 @@ export interface ConfigurationError { code?: string } +function tomlObjectArrayHint(error: ConfigurationError): string | undefined { + if (!error.file.endsWith('.toml')) return undefined + if (error.message !== 'Expected object, received array') return undefined + + return 'Use a single TOML table instead of an array of tables. [table] defines one table; [[table]] defines an array of tables.' +} + export function formatConfigurationError(error: ConfigurationError): string { + const hint = tomlObjectArrayHint(error) + const message = hint ? `${error.message}. ${hint}` : error.message + if (error.path?.length) { - return `[${error.path.join('.')}]: ${error.message}` + return `[${error.path.join('.')}]: ${message}` } - return error.message + return message } type ConfigurationResult = {data: T; errors?: never} | {data?: never; errors: ConfigurationError[]} diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index f22f543286f..61d23135648 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -37,6 +37,28 @@ describe('formatConfigurationError', () => { '[access.admin]: Required', ) }) + + test('adds a TOML table hint for object array type mismatches', () => { + expect( + formatConfigurationError({ + file: 'shopify.app.toml', + path: ['events', '1', 'metrics'], + message: 'Expected object, received array', + }), + ).toBe( + '[events.1.metrics]: Expected object, received array. Use a single TOML table instead of an array of tables. [table] defines one table; [[table]] defines an array of tables.', + ) + }) + + test('does not add a TOML table hint for non-TOML files', () => { + expect( + formatConfigurationError({ + file: 'config.json', + path: ['events', '1', 'metrics'], + message: 'Expected object, received array', + }), + ).toBe('[events.1.metrics]: Expected object, received array') + }) }) describe('validateApp', () => { diff --git a/packages/cli-kit/src/public/node/json-schema.test.ts b/packages/cli-kit/src/public/node/json-schema.test.ts index 46583d30050..e3ec2bddb01 100644 --- a/packages/cli-kit/src/public/node/json-schema.test.ts +++ b/packages/cli-kit/src/public/node/json-schema.test.ts @@ -263,6 +263,63 @@ describe('jsonSchemaValidate', () => { expect(schemaParsed.errors, `Converting ${JSON.stringify(schemaParsed.rawErrors)}`).toEqual(zodErrors) }) + test('reports arrays distinctly when an object is expected', () => { + const subject = { + events: [ + {}, + { + metrics: [], + }, + ], + } + const contract = { + type: 'object', + properties: { + events: { + type: 'array', + items: { + type: 'object', + properties: { + metrics: {type: 'object'}, + }, + }, + }, + }, + } + + const schemaParsed = jsonSchemaValidate(subject, contract, 'strip') + + expect(schemaParsed.state).toBe('error') + expect(schemaParsed.errors).toEqual([ + { + path: ['events', '1', 'metrics'], + message: 'Expected object, received array', + }, + ]) + }) + + test('reports null distinctly when an object is expected', () => { + const subject = { + foo: null, + } + const contract = { + type: 'object', + properties: { + foo: {type: 'object'}, + }, + } + + const schemaParsed = jsonSchemaValidate(subject, contract, 'strip') + + expect(schemaParsed.state).toBe('error') + expect(schemaParsed.errors).toEqual([ + { + path: ['foo'], + message: 'Expected object, received null', + }, + ]) + }) + test('ignores custom x-taplo directive', () => { const subject = { foo: 'bar', diff --git a/packages/cli-kit/src/public/node/json-schema.ts b/packages/cli-kit/src/public/node/json-schema.ts index e5cb6b2ec9f..3d425d5e1ce 100644 --- a/packages/cli-kit/src/public/node/json-schema.ts +++ b/packages/cli-kit/src/public/node/json-schema.ts @@ -112,6 +112,12 @@ export function jsonSchemaValidate( * @param schema - The JSON schema to validated against. * @returns The errors in a zod-like format. */ +function getJsonSchemaValueType(value: unknown): string { + if (Array.isArray(value)) return 'array' + if (value === null) return 'null' + return typeof value +} + function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: SchemaObject) { // This reduces the number of errors by simplifying errors coming from different branches of a union const errors = simplifyUnionErrors(rawErrors, subject, schema) @@ -129,7 +135,7 @@ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: ? error.params.type.join(', ') : (error.params.type as string) const actualType = getPathValue(subject, path.join('.')) - return {path, message: `Expected ${expectedType}, received ${typeof actualType}`} + return {path, message: `Expected ${expectedType}, received ${getJsonSchemaValueType(actualType)}`} } if (error.keyword === 'anyOf' || error.keyword === 'oneOf') { From 08eb0ad66be12443a829239241af49f436dce89a Mon Sep 17 00:00:00 2001 From: Adam Barefoot Date: Wed, 10 Jun 2026 21:38:24 -0400 Subject: [PATCH 2/4] Add changeset --- .changeset/mighty-hotels-tickle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mighty-hotels-tickle.md diff --git a/.changeset/mighty-hotels-tickle.md b/.changeset/mighty-hotels-tickle.md new file mode 100644 index 00000000000..cabf99de88f --- /dev/null +++ b/.changeset/mighty-hotels-tickle.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Improve JSON Schema validation error messages for arrays. From 38045b62b9f25fb2de6a3689a3f3dca39effbda4 Mon Sep 17 00:00:00 2001 From: Barefoot0 <144734551+Barefoot0@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:46:20 -0400 Subject: [PATCH 3/4] Apply changes from code browser Apply changes from code browser --- packages/cli-kit/src/public/node/json-schema.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli-kit/src/public/node/json-schema.ts b/packages/cli-kit/src/public/node/json-schema.ts index 3d425d5e1ce..19a51115454 100644 --- a/packages/cli-kit/src/public/node/json-schema.ts +++ b/packages/cli-kit/src/public/node/json-schema.ts @@ -105,12 +105,10 @@ export function jsonSchemaValidate( } /** - * Converts errors from Ajv into a zod-like format. + * Get a more precise type name for JSON Schema error messages. * - * @param rawErrors - JSON Schema errors taken directly from Ajv. - * @param subject - The object being validated. - * @param schema - The JSON schema to validated against. - * @returns The errors in a zod-like format. + * @param value - The value to get the type of. + * @returns A string representing the type (e.g., 'array', 'null', 'object'). */ function getJsonSchemaValueType(value: unknown): string { if (Array.isArray(value)) return 'array' @@ -118,6 +116,14 @@ function getJsonSchemaValueType(value: unknown): string { return typeof value } +/** + * Converts errors from Ajv into a zod-like format. + * + * @param rawErrors - JSON Schema errors taken directly from Ajv. + * @param subject - The object being validated. + * @param schema - The JSON schema to validated against. + * @returns The errors in a zod-like format. + */ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: SchemaObject) { // This reduces the number of errors by simplifying errors coming from different branches of a union const errors = simplifyUnionErrors(rawErrors, subject, schema) From e942bae4be4d917d60c8e51d8fc177486fc73b91 Mon Sep 17 00:00:00 2001 From: Adam Barefoot Date: Fri, 12 Jun 2026 11:35:24 -0400 Subject: [PATCH 4/4] Handle top level JSON schema type errors --- .../src/public/node/json-schema.test.ts | 24 +++++++++++++++++++ .../cli-kit/src/public/node/json-schema.ts | 10 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/cli-kit/src/public/node/json-schema.test.ts b/packages/cli-kit/src/public/node/json-schema.test.ts index e3ec2bddb01..dd3a2eee4de 100644 --- a/packages/cli-kit/src/public/node/json-schema.test.ts +++ b/packages/cli-kit/src/public/node/json-schema.test.ts @@ -320,6 +320,30 @@ describe('jsonSchemaValidate', () => { ]) }) + test('reports top-level arrays distinctly when an object is expected', () => { + const schemaParsed = jsonSchemaValidate([], {type: 'object'}, 'strip') + + expect(schemaParsed.state).toBe('error') + expect(schemaParsed.errors).toEqual([ + { + path: [], + message: 'Expected object, received array', + }, + ]) + }) + + test('reports top-level null distinctly when an object is expected', () => { + const schemaParsed = jsonSchemaValidate(null as unknown as object, {type: 'object'}, 'strip') + + expect(schemaParsed.state).toBe('error') + expect(schemaParsed.errors).toEqual([ + { + path: [], + message: 'Expected object, received null', + }, + ]) + }) + test('ignores custom x-taplo directive', () => { const subject = { foo: 'bar', diff --git a/packages/cli-kit/src/public/node/json-schema.ts b/packages/cli-kit/src/public/node/json-schema.ts index 19a51115454..ea8be968410 100644 --- a/packages/cli-kit/src/public/node/json-schema.ts +++ b/packages/cli-kit/src/public/node/json-schema.ts @@ -116,6 +116,10 @@ function getJsonSchemaValueType(value: unknown): string { return typeof value } +function getJsonSchemaErrorValue(subject: object, path: string[]): unknown { + return path.length === 0 ? subject : getPathValue(subject, path) +} + /** * Converts errors from Ajv into a zod-like format. * @@ -140,7 +144,7 @@ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: const expectedType = Array.isArray(error.params.type) ? error.params.type.join(', ') : (error.params.type as string) - const actualType = getPathValue(subject, path.join('.')) + const actualType = getJsonSchemaErrorValue(subject, path) return {path, message: `Expected ${expectedType}, received ${getJsonSchemaValueType(actualType)}`} } @@ -150,7 +154,7 @@ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: if (error.params.allowedValues) { const allowedValues = error.params.allowedValues as string[] - const actualValue = getPathValue(subject, path.join('.')) + const actualValue = getJsonSchemaErrorValue(subject, path) return { path, message: `Invalid enum value. Expected ${allowedValues @@ -162,7 +166,7 @@ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema: if (error.params.comparison) { const comparison = error.params.comparison as string const limit = error.params.limit - const actualValue = getPathValue(subject, path.join('.')) + const actualValue = getJsonSchemaErrorValue(subject, path) let comparisonText = comparison switch (comparison) {