From f6983727cb0d21bbc7fb7ca25940f8e50c50538e Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:15:11 +0200 Subject: [PATCH 1/9] Add typed JSON output to theme check --- packages/cli/oclif.manifest.json | 17 ++- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/check-json.test.ts | 116 ++++++++++++++++++ .../theme/src/cli/commands/theme/check.ts | 84 +++++-------- packages/theme/src/cli/services/check.ts | 38 +++--- .../theme/src/cli/services/check/result.ts | 51 ++++++++ .../theme/src/cli/services/check/types.ts | 34 +++++ 7 files changed, 265 insertions(+), 76 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/check-json.test.ts create mode 100644 packages/theme/src/cli/services/check/result.ts create mode 100644 packages/theme/src/cli/services/check/types.ts diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ddd0960d909..0693b75909e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10360,7 +10360,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Calls and runs \"Theme Check\" (https://shopify.dev/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. \"Learn more about the checks that Theme Check runs.\" (https://shopify.dev/docs/themes/tools/theme-check/checks)", + "description": "Calls and runs \"Theme Check\" (https://shopify.dev/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. \"Learn more about the checks that Theme Check runs.\" (https://shopify.dev/docs/themes/tools/theme-check/checks)\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeCheckResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeCheckEnvironment/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeCheckEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeCheckResult\",\n \"definitions\": {\n \"ThemeCheckOffense\": {\n \"type\": \"object\",\n \"properties\": {\n \"check\": {\n \"type\": \"string\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\n \"error\",\n \"warning\",\n \"info\"\n ]\n },\n \"start_row\": {\n \"type\": \"number\"\n },\n \"start_column\": {\n \"type\": \"number\"\n },\n \"end_row\": {\n \"type\": \"number\"\n },\n \"end_column\": {\n \"type\": \"number\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"check\",\n \"severity\",\n \"start_row\",\n \"start_column\",\n \"end_row\",\n \"end_column\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeCheckFile\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\"\n },\n \"offenses\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeCheckOffense\"\n }\n },\n \"errorCount\": {\n \"type\": \"number\"\n },\n \"warningCount\": {\n \"type\": \"number\"\n },\n \"infoCount\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"path\",\n \"offenses\",\n \"errorCount\",\n \"warningCount\",\n \"infoCount\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeCheckEnvironment\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeCheckFile\"\n }\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Calls and runs [Theme Check](https://shopify.dev/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. [Learn more about the checks that Theme Check runs.](https://shopify.dev/docs/themes/tools/theme-check/checks)", "enableJsonFlag": false, "flags": { @@ -10426,6 +10426,21 @@ "required": false, "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "exclusive": [ + "init", + "version", + "print", + "list" + ], + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index ec02be93dfb..d228dea1c5f 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -25,7 +25,6 @@ const commandExceptions = [ 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts', - 'packages/theme/src/cli/commands/theme/check.ts', 'packages/theme/src/cli/commands/theme/init.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', 'packages/theme/src/cli/commands/theme/package.ts', diff --git a/packages/theme/src/cli/commands/theme/check-json.test.ts b/packages/theme/src/cli/commands/theme/check-json.test.ts new file mode 100644 index 00000000000..135a20643f7 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/check-json.test.ts @@ -0,0 +1,116 @@ +import Check from './check.js' +import {checkTheme, formatOffensesJson, sortOffenses} from '../../services/check.js' +import {themeCheckJsonOutputSchema} from '../../services/check/types.js' +import {encodeThemeCheckResult} from '../../services/check/result.js' +import {expect, test, vi} from 'vitest' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {themeCheckRun, Severity, SourceCodeType, path as pathUtils} from '@shopify/theme-check-node' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' + +vi.mock('@shopify/theme-check-node', async (importOriginal) => ({ + ...(await importOriginal()), + themeCheckRun: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/environments') + +const offense = { + type: SourceCodeType.LiquidHtml, + check: 'ExampleCheck', + severity: Severity.ERROR, + uri: 'file:///theme/templates/index.liquid', + start: {index: 0, line: 0, character: 0}, + end: {index: 1, line: 0, character: 1}, + message: 'Example offense', +} + +test('exposes the contract and preserves compact JSON, environment omission and counts', async () => { + expect(Check.jsonOutputSchema).toBe(themeCheckJsonOutputSchema) + vi.mocked(themeCheckRun).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) + const {result} = await checkTheme('/theme') + expect(result).toEqual(formatOffensesJson(sortOffenses([offense]))) + expect(encodeThemeCheckResult(result)).toBe( + JSON.stringify([ + { + path: pathUtils.fsPath(offense.uri), + offenses: [ + { + check: 'ExampleCheck', + severity: 'error', + start_row: 0, + start_column: 0, + end_row: 0, + end_column: 1, + message: 'Example offense', + }, + ], + errorCount: 1, + warningCount: 0, + infoCount: 0, + }, + ]), + ) + expect(encodeThemeCheckResult([])).toBe('[]') + expect(() => themeCheckJsonOutputSchema.validate([{...result[0], errorCount: '1'}])).toThrow() + expect(() => + themeCheckJsonOutputSchema.validate([{...result[0], offenses: [{...result[0]!.offenses[0], severity: 'fatal'}]}]), + ).toThrow() +}) + +test.each(['--json', '--output=json'])('writes compact JSON and preserves unsuccessful exit with %s', async (flag) => { + await inTemporaryDirectory(async (directory) => { + const {default: StreamCheck} = await import('./check.js') + const {themeCheckRun: check} = await import('@shopify/theme-check-node') + const {Config} = await import('@oclif/core') + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(check).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new StreamCheck([`--path=${directory}`, flag], config).run() + expect(stdout()).toBe(`${JSON.stringify(formatOffensesJson(sortOffenses([offense])))}\n`) + expect(stderr()).toBe('') + }) + expect(exit).toHaveBeenCalledWith(1) + }) +}) + +test.each(['init', 'version', 'print', 'list'])('rejects --json with --%s before execution', async (mode) => { + const {Config} = await import('@oclif/core') + const config = new Config({root: __dirname}) + await config.load() + await expect(new Check(['--json', `--${mode}`], config).run()).rejects.toThrow() + expect(themeCheckRun).not.toHaveBeenCalled() +}) + +test.each(['success', 'partial failure', 'total failure'])( + 'collects multiple environments into one document on %s', + async (mode) => { + await inTemporaryDirectory(async (directory) => { + const {default: StreamCheck} = await import('./check.js') + const {themeCheckRun: check} = await import('@shopify/theme-check-node') + const {Config} = await import('@oclif/core') + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(check).mockResolvedValue({offenses: [], theme: [], config: {} as never}) + if (mode === 'partial failure') vi.mocked(check).mockRejectedValueOnce(new Error('Check failed')) + if (mode === 'total failure') vi.mocked(check).mockRejectedValue(new Error('Check failed')) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + vi.mocked(loadEnvironment).mockResolvedValue({path: directory}) + const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const argv = ['--environment=first', '--environment=second', '--json'] + await runWithCommandEventsForCommand(argv, () => new StreamCheck(argv, config).run()) + let names = ['first', 'second'] + if (mode === 'partial failure') names = ['second'] + if (mode === 'total failure') names = [] + expect(JSON.parse(stdout())).toEqual({ + environments: names.map((environment) => ({environment, result: []})), + }) + if (mode.includes('failure')) expect(stderr()).toContain('Check failed') + }) + expect(exit).not.toHaveBeenCalled() + }) + }, +) diff --git a/packages/theme/src/cli/commands/theme/check.ts b/packages/theme/src/cli/commands/theme/check.ts index 5921f6ba370..fb9a56a9bb1 100644 --- a/packages/theme/src/cli/commands/theme/check.ts +++ b/packages/theme/src/cli/commands/theme/check.ts @@ -1,24 +1,20 @@ import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import { - formatOffensesJson, - formatSummary, + checkTheme, initConfig, outputActiveChecks, outputActiveConfig, performAutoFixes, - renderOffensesText, - sortOffenses, - isExtendedWriteStream, handleExit, - withThemeCheckConfigErrorHandling, type FailLevel, } from '../../services/check.js' +import {renderThemeCheckResult, encodeThemeCheckResult} from '../../services/check/result.js' +import {themeCheckJsonOutputSchema} from '../../services/check/types.js' import {themeFlags} from '../../flags.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' -import {outputResult, outputDebug} from '@shopify/cli-kit/node/output' -import {renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' -import {themeCheckRun, LegacyIdentifiers} from '@shopify/theme-check-node' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {LegacyIdentifiers} from '@shopify/theme-check-node' import {findPathUp} from '@shopify/cli-kit/node/fs' import {moduleDirectory, joinPath} from '@shopify/cli-kit/node/path' import {getPackageVersion} from '@shopify/cli-kit/node/node-package-manager' @@ -27,6 +23,10 @@ import {AdminSession} from '@shopify/cli-kit/node/session' type CheckFlags = InferredFlags export default class Check extends ThemeCommand { + static get jsonOutputSchema() { + return themeCheckJsonOutputSchema + } + static summary = 'Validate the theme.' static descriptionWithMarkdown = `Calls and runs [Theme Check](https://shopify.dev/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. [Learn more about the checks that Theme Check runs.](https://shopify.dev/docs/themes/tools/theme-check/checks)` @@ -35,6 +35,8 @@ export default class Check extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, + json: Flags.boolean({...jsonFlag.json, env: 'SHOPIFY_FLAG_JSON', exclusive: ['init', 'version', 'print', 'list']}), path: themeFlags.path, 'auto-correct': Flags.boolean({ char: 'a', @@ -92,7 +94,7 @@ export default class Check extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = ['path'] - async command(flags: CheckFlags, _session: AdminSession, multiEnvironment: boolean): Promise { + async command(flags: CheckFlags, _session: AdminSession, multiEnvironment: boolean) { // Its not clear to typescript that path will always be defined const path = flags.path const environment = flags.environment?.[0] @@ -139,7 +141,12 @@ export default class Check extends ThemeCommand { return } - const {offenses, theme} = await runThemeCheck(path, flags.output, config, environment) + const output = await checkTheme(path, config, environment) + const {offenses, theme} = output + const json = flags.json || flags.output === 'json' + if (!multiEnvironment || !json) { + renderThemeCheckResult(output, json ? 'json' : flags.output, path, environment) + } if (flags['auto-correct']) { await performAutoFixes(theme, offenses) @@ -148,50 +155,23 @@ export default class Check extends ThemeCommand { if (!multiEnvironment) { return handleExit(offenses, flags['fail-level'] as FailLevel) } + if (json) return output.result } -} - -export async function runThemeCheck(path: string, outputFormat: string, config?: string, environment?: string) { - const {offenses, theme} = await withThemeCheckConfigErrorHandling(config, () => - themeCheckRun(path, config, (message) => { - if (process.env.SHOPIFY_TMP_FLAG_DEBUG) { - outputDebug(message) - } - }), - ) - const offensesByFile = sortOffenses(offenses) - - if (outputFormat === 'text') { - renderOffensesText(offensesByFile, path, theme, environment) - - // Use renderSuccess when theres no offenses - const render = offenses.length ? renderInfo : renderSuccess - - render({ - headline: environment ? `[${environment}] Theme Check Summary.` : 'Theme Check Summary.', - body: formatSummary(offenses, offensesByFile, theme), - }) + protected collectsEnvironmentResults(flags: Partial): boolean { + return ( + !flags.init && !flags.version && !flags.print && !flags.list && (Boolean(flags.json) || flags.output === 'json') + ) } - if (outputFormat === 'json') { - /** - * Workaround: - * Force stdout to be blocking so that the JSON output is not broken when piped to another process. - * ie: ` | jq .` - * It turns out that console.log is technically asynchronous, and when we call process.exit(), - * node doesn't wait on all the output being sent to stdout and instead closes the process immediately - * - * https://github.com/pnp/cli-microsoft365/issues/1266#issuecomment-727254264 - * - */ - const stdout = process.stdout - if (isExtendedWriteStream(stdout)) { - stdout._handle.setBlocking(true) - } - - outputResult(JSON.stringify(formatOffensesJson(offensesByFile, environment))) + protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { + outputResult(encodeThemeCheckResult(themeCheckJsonOutputSchema.validate({environments}))) } +} - return {offenses, theme} +// Compatibility adapter for theme push, which still presents check results as text. +export async function runThemeCheck(path: string, outputFormat: string, config?: string, environment?: string) { + const result = await checkTheme(path, config, environment) + renderThemeCheckResult(result, outputFormat, path, environment) + return {offenses: result.offenses, theme: result.theme} } diff --git a/packages/theme/src/cli/services/check.ts b/packages/theme/src/cli/services/check.ts index 154ce4079da..38dbe9b186d 100644 --- a/packages/theme/src/cli/services/check.ts +++ b/packages/theme/src/cli/services/check.ts @@ -1,10 +1,12 @@ +import {ThemeCheckResult} from './check/types.js' import {fileExists, writeFile} from '@shopify/cli-kit/node/fs' -import {outputResult, outputInfo, outputSuccess} from '@shopify/cli-kit/node/output' +import {outputResult, outputInfo, outputSuccess, outputDebug} from '@shopify/cli-kit/node/output' import {joinPath, resolvePath} from '@shopify/cli-kit/node/path' import {renderInfo} from '@shopify/cli-kit/node/ui' import {uniq} from '@shopify/cli-kit/common/array' import {AbortError} from '@shopify/cli-kit/node/error' import { + themeCheckRun, Severity, applyFixToString, autofix, @@ -21,24 +23,6 @@ type OffenseMap = Record /** The contents of every theme file, keyed by its normalized uri. */ type ThemeSourcesByUri = Map -interface TransformedOffense { - check: string - severity: string - start_row: number - start_column: number - end_row: number - end_column: number - message: string -} - -interface TransformedOffenseMap { - path: string - offenses: TransformedOffense[] - errorCount: number - warningCount: number - infoCount: number -} - type SeverityCounts = Partial<{ [Severity.ERROR]: number [Severity.WARNING]: number @@ -58,7 +42,7 @@ function isMissingThemeCheckConfigFile(error: unknown, configPath: string) { ) } -export async function withThemeCheckConfigErrorHandling( +async function withThemeCheckConfigErrorHandling( configPath: string | undefined, operation: () => Promise, ): Promise { @@ -87,7 +71,7 @@ function failLevelToSeverity(failLevel: FailLevel): Severity | undefined { } } -function severityToLabel(severity: Severity) { +function severityToLabel(severity: Severity): 'error' | 'warning' | 'info' { switch (severity) { case Severity.ERROR: return 'error' @@ -248,7 +232,7 @@ export function renderOffensesText( }) } -export function formatOffensesJson(offensesByFile: OffenseMap, environment?: string): TransformedOffenseMap[] { +export function formatOffensesJson(offensesByFile: OffenseMap, environment?: string): ThemeCheckResult { return Object.entries(offensesByFile).map(([path, offenses]) => { const transformedOffenses = offenses.map((offense: Offense) => { return { @@ -416,3 +400,13 @@ export function isExtendedWriteStream(stream: NodeJS.WriteStream): stream is Ext typeof (stream as HandleWithSetBlocking)._handle.setBlocking === 'function' ) } + +export async function checkTheme(path: string, config?: string, environment?: string) { + const {offenses, theme} = await withThemeCheckConfigErrorHandling(config, () => + themeCheckRun(path, config, (message) => { + if (process.env.SHOPIFY_TMP_FLAG_DEBUG) outputDebug(message) + }), + ) + const result = formatOffensesJson(sortOffenses(offenses), environment) + return {result, offenses, theme} +} diff --git a/packages/theme/src/cli/services/check/result.ts b/packages/theme/src/cli/services/check/result.ts new file mode 100644 index 00000000000..33a569ee96f --- /dev/null +++ b/packages/theme/src/cli/services/check/result.ts @@ -0,0 +1,51 @@ +import {themeCheckJsonOutputSchema, type ThemeCheckResult} from './types.js' +import {formatSummary, renderOffensesText, sortOffenses, isExtendedWriteStream} from '../check.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' +import {type Offense, type Theme} from '@shopify/theme-check-node' +import {type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' + +export function encodeThemeCheckResult(result: InferJsonOutputSchema): string { + // Theme Check's established JSON format is compact, unlike the shared encoder. + return JSON.stringify(JSON.parse(themeCheckJsonOutputSchema.encode(result))) +} + +export function renderThemeCheckResult( + {result, offenses, theme}: {result: ThemeCheckResult; offenses: Offense[]; theme: Theme}, + outputFormat: string, + path: string, + environment?: string, +): void { + const offensesByFile = sortOffenses(offenses) + + if (outputFormat === 'text') { + renderOffensesText(offensesByFile, path, theme, environment) + + // Use renderSuccess when theres no offenses + const render = offenses.length ? renderInfo : renderSuccess + + render({ + headline: environment ? `[${environment}] Theme Check Summary.` : 'Theme Check Summary.', + body: formatSummary(offenses, offensesByFile, theme), + }) + } + + if (outputFormat === 'json') { + /** + * Workaround: + * Force stdout to be blocking so that the JSON output is not broken when piped to another process. + * ie: ` | jq .` + * It turns out that console.log is technically asynchronous, and when we call process.exit(), + * node doesn't wait on all the output being sent to stdout and instead closes the process immediately + * + * https://github.com/pnp/cli-microsoft365/issues/1266#issuecomment-727254264 + * + */ + const stdout = process.stdout + if (isExtendedWriteStream(stdout)) { + stdout._handle.setBlocking(true) + } + + outputResult(encodeThemeCheckResult(result)) + } +} diff --git a/packages/theme/src/cli/services/check/types.ts b/packages/theme/src/cli/services/check/types.ts new file mode 100644 index 00000000000..cb645dd2664 --- /dev/null +++ b/packages/theme/src/cli/services/check/types.ts @@ -0,0 +1,34 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeCheckOffenseSchema = zod.object({ + check: zod.string(), + severity: zod.enum(['error', 'warning', 'info']), + start_row: zod.number(), + start_column: zod.number(), + end_row: zod.number(), + end_column: zod.number(), + message: zod.string(), +}) +const ThemeCheckFileSchema = zod.object({ + environment: zod.string().optional(), + path: zod.string(), + offenses: zod.array(ThemeCheckOffenseSchema), + errorCount: zod.number(), + warningCount: zod.number(), + infoCount: zod.number(), +}) +const ThemeCheckResultSchema = zod.array(ThemeCheckFileSchema) +const ThemeCheckEnvironmentSchema = zod.object({environment: zod.string(), result: ThemeCheckResultSchema}) + +export const themeCheckJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeCheckResult', + schema: zod.union([ThemeCheckResultSchema, zod.object({environments: zod.array(ThemeCheckEnvironmentSchema)})]), + definitions: { + ThemeCheckOffense: ThemeCheckOffenseSchema, + ThemeCheckFile: ThemeCheckFileSchema, + ThemeCheckEnvironment: ThemeCheckEnvironmentSchema, + }, +}) + +export type ThemeCheckResult = zod.infer From 848dc0a8617317187cb15a4cd173ce2ce2c185ca Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:08:44 +0200 Subject: [PATCH 2/9] Normalize theme check JSON issues and failure handling --- .changeset/theme-check-json-contract.md | 5 + .../src/public/node/error/index.test.ts | 10 + .../cli-kit/src/public/node/error/index.ts | 3 +- .../src/cli/commands/theme/check-json.test.ts | 178 +++++++++++------- .../theme/src/cli/commands/theme/check.ts | 31 ++- packages/theme/src/cli/services/check.ts | 11 +- .../theme/src/cli/services/check/result.ts | 9 +- .../theme/src/cli/services/check/types.ts | 95 +++++++--- 8 files changed, 230 insertions(+), 112 deletions(-) create mode 100644 .changeset/theme-check-json-contract.md diff --git a/.changeset/theme-check-json-contract.md b/.changeset/theme-check-json-contract.md new file mode 100644 index 00000000000..4782eab7bab --- /dev/null +++ b/.changeset/theme-check-json-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Return `valid` and flat `issues` with camelCase zero-based positions from Theme Check JSON output, retaining every requested environment and failing blocking validations. diff --git a/packages/cli-kit/src/public/node/error/index.test.ts b/packages/cli-kit/src/public/node/error/index.test.ts index 9958fe57271..142ceba9bf3 100644 --- a/packages/cli-kit/src/public/node/error/index.test.ts +++ b/packages/cli-kit/src/public/node/error/index.test.ts @@ -9,6 +9,7 @@ import { import {jsonOutputEnabled} from '../environment.js' import {renderFatalError} from '../ui.js' import {mockAndCaptureOutput} from '../testing/output.js' +import {runWithCommandEvents} from '../command-events.js' import {ClientError} from 'graphql-request' import {beforeEach, describe, expect, test, vi} from 'vitest' @@ -90,6 +91,15 @@ describe('handler', () => { expect(renderFatalError).not.toHaveBeenCalled() }) + test('renders JSON when the command selects JSON output without a global flag', async () => { + const output = mockAndCaptureOutput() + + await runWithCommandEvents({outputMode: 'json', sink: () => {}}, () => handler(new AbortError('Expected failure'))) + + expect(JSON.parse(output.info())).toStrictEqual({error: {type: 'abort', message: 'Expected failure'}}) + expect(renderFatalError).not.toHaveBeenCalled() + }) + test('keeps JSON silent aborts silent', async () => { vi.mocked(jsonOutputEnabled).mockReturnValue(true) diff --git a/packages/cli-kit/src/public/node/error/index.ts b/packages/cli-kit/src/public/node/error/index.ts index 2b608baddb7..b136f1ba440 100644 --- a/packages/cli-kit/src/public/node/error/index.ts +++ b/packages/cli-kit/src/public/node/error/index.ts @@ -150,7 +150,8 @@ export async function handler(error: unknown): Promise { } const {jsonOutputEnabled} = await import('../environment.js') - if (jsonOutputEnabled()) { + const {commandEventOutputMode} = await import('../../../private/node/command-event-context.js') + if (jsonOutputEnabled() || commandEventOutputMode() === 'json') { const {renderFatalErrorAsJson} = await import('../../../private/node/json-error.js') renderFatalErrorAsJson(fatal) await flushStdout() diff --git a/packages/theme/src/cli/commands/theme/check-json.test.ts b/packages/theme/src/cli/commands/theme/check-json.test.ts index 135a20643f7..8085baa4ad7 100644 --- a/packages/theme/src/cli/commands/theme/check-json.test.ts +++ b/packages/theme/src/cli/commands/theme/check-json.test.ts @@ -6,12 +6,12 @@ import {expect, test, vi} from 'vitest' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {themeCheckRun, Severity, SourceCodeType, path as pathUtils} from '@shopify/theme-check-node' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' vi.mock('@shopify/theme-check-node', async (importOriginal) => ({ ...(await importOriginal()), themeCheckRun: vi.fn(), })) - vi.mock('@shopify/cli-kit/node/environments') const offense = { @@ -23,94 +23,140 @@ const offense = { end: {index: 1, line: 0, character: 1}, message: 'Example offense', } +const emptyResult = {valid: true, issues: [], errorCount: 0, warningCount: 0, infoCount: 0} +const invalidResult = { + valid: false, + issues: [ + { + filePath: pathUtils.fsPath(offense.uri), + check: 'ExampleCheck', + severity: 'error', + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 1, + message: 'Example offense', + }, + ], + errorCount: 1, + warningCount: 0, + infoCount: 0, +} + +async function configuration() { + const {Config} = await import('@oclif/core') + const config = new Config({root: __dirname}) + await config.load() + return config +} -test('exposes the contract and preserves compact JSON, environment omission and counts', async () => { +test('projects the service model to flat issues with strict zero-based positions and counts', async () => { expect(Check.jsonOutputSchema).toBe(themeCheckJsonOutputSchema) vi.mocked(themeCheckRun).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) const {result} = await checkTheme('/theme') expect(result).toEqual(formatOffensesJson(sortOffenses([offense]))) - expect(encodeThemeCheckResult(result)).toBe( - JSON.stringify([ - { - path: pathUtils.fsPath(offense.uri), - offenses: [ - { - check: 'ExampleCheck', - severity: 'error', - start_row: 0, - start_column: 0, - end_row: 0, - end_column: 1, - message: 'Example offense', - }, - ], - errorCount: 1, - warningCount: 0, - infoCount: 0, - }, - ]), - ) - expect(encodeThemeCheckResult([])).toBe('[]') - expect(() => themeCheckJsonOutputSchema.validate([{...result[0], errorCount: '1'}])).toThrow() + expect(JSON.parse(encodeThemeCheckResult(result))).toEqual(invalidResult) + expect(JSON.parse(encodeThemeCheckResult([]))).toEqual(emptyResult) + expect(() => themeCheckJsonOutputSchema.validate({...invalidResult, errorCount: '1'})).toThrow() + expect(() => themeCheckJsonOutputSchema.validate({...invalidResult, extra: true})).toThrow() expect(() => - themeCheckJsonOutputSchema.validate([{...result[0], offenses: [{...result[0]!.offenses[0], severity: 'fatal'}]}]), + themeCheckJsonOutputSchema.validate({...invalidResult, issues: [{...invalidResult.issues[0], startRow: -1}]}), + ).toThrow() + expect(() => + themeCheckJsonOutputSchema.validate({ + ...invalidResult, + issues: [{...invalidResult.issues[0], filePath: 'index.liquid'}], + }), ).toThrow() }) -test.each(['--json', '--output=json'])('writes compact JSON and preserves unsuccessful exit with %s', async (flag) => { +test.each(['--json', '--output=json'])('writes one validation result and exits nonzero for %s', async (flag) => { await inTemporaryDirectory(async (directory) => { - const {default: StreamCheck} = await import('./check.js') - const {themeCheckRun: check} = await import('@shopify/theme-check-node') - const {Config} = await import('@oclif/core') - const config = new Config({root: __dirname}) - await config.load() - vi.mocked(check).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) + const config = await configuration() + vi.mocked(themeCheckRun).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await new StreamCheck([`--path=${directory}`, flag], config).run() - expect(stdout()).toBe(`${JSON.stringify(formatOffensesJson(sortOffenses([offense])))}\n`) - expect(stderr()).toBe('') - }) - expect(exit).toHaveBeenCalledWith(1) + const previousExitCode = process.exitCode + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Check([`--path=${directory}`, flag], config).run() + expect(JSON.parse(stdout())).toEqual(invalidResult) + expect(stderr()).toBe('') + }) + expect(process.exitCode).toBe(1) + expect(exit).not.toHaveBeenCalled() + } finally { + process.exitCode = previousExitCode + } }) }) test.each(['init', 'version', 'print', 'list'])('rejects --json with --%s before execution', async (mode) => { - const {Config} = await import('@oclif/core') - const config = new Config({root: __dirname}) - await config.load() - await expect(new Check(['--json', `--${mode}`], config).run()).rejects.toThrow() + await expect(new Check(['--json', `--${mode}`], await configuration()).run()).rejects.toThrow() expect(themeCheckRun).not.toHaveBeenCalled() }) -test.each(['success', 'partial failure', 'total failure'])( - 'collects multiple environments into one document on %s', +test.each(['success', 'single environment', 'partial failure', 'total failure', 'blocking validation'])( + 'returns every requested environment in one document on %s', async (mode) => { await inTemporaryDirectory(async (directory) => { - const {default: StreamCheck} = await import('./check.js') - const {themeCheckRun: check} = await import('@shopify/theme-check-node') - const {Config} = await import('@oclif/core') - const config = new Config({root: __dirname}) - await config.load() - vi.mocked(check).mockResolvedValue({offenses: [], theme: [], config: {} as never}) - if (mode === 'partial failure') vi.mocked(check).mockRejectedValueOnce(new Error('Check failed')) - if (mode === 'total failure') vi.mocked(check).mockRejectedValue(new Error('Check failed')) - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const config = await configuration() + vi.mocked(themeCheckRun).mockResolvedValue({offenses: [], theme: [], config: {} as never}) + if (mode === 'partial failure') vi.mocked(themeCheckRun).mockRejectedValueOnce(new Error('Check failed')) + if (mode === 'total failure') { + vi.mocked(themeCheckRun).mockImplementation(async () => { + throw new Error('Check failed') + }) + } + if (mode === 'blocking validation') + vi.mocked(themeCheckRun).mockResolvedValueOnce({offenses: [offense], theme: [], config: {} as never}) const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') vi.mocked(loadEnvironment).mockResolvedValue({path: directory}) - const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events') - await withCapturedStandardStreams(async ({stdout, stderr}) => { - const argv = ['--environment=first', '--environment=second', '--json'] - await runWithCommandEventsForCommand(argv, () => new StreamCheck(argv, config).run()) - let names = ['first', 'second'] - if (mode === 'partial failure') names = ['second'] - if (mode === 'total failure') names = [] - expect(JSON.parse(stdout())).toEqual({ - environments: names.map((environment) => ({environment, result: []})), + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const argv = [ + '--environment=first', + ...(mode === 'single environment' ? [] : ['--environment=second']), + '--json', + ] + await runWithCommandEventsForCommand(argv, () => new Check(argv, config).run()) + const first = mode.includes('failure') + ? {environment: 'first', error: expect.objectContaining({message: 'Check failed'})} + : {environment: 'first', result: mode === 'blocking validation' ? invalidResult : emptyResult} + const second = + mode === 'total failure' + ? {environment: 'second', error: expect.objectContaining({message: 'Check failed'})} + : {environment: 'second', result: emptyResult} + expect(JSON.parse(stdout())).toEqual({ + environments: mode === 'single environment' ? [first] : [first, second], + }) + expect(process.exitCode).toBe(mode.includes('failure') || mode === 'blocking validation' ? 1 : 0) + if (mode.includes('failure')) expect(stderr()).toContain('Check failed') }) - if (mode.includes('failure')) expect(stderr()).toContain('Check failed') - }) - expect(exit).not.toHaveBeenCalled() + } finally { + // Each test restores the process state after its asynchronous command has completed. + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } }) }, ) + +class LifecycleCheck extends Check { + execute(): Promise { + return this._run() + } +} + +test('uses the standard JSON error envelope for incompatible legacy JSON output flags', async () => { + const config = await configuration() + vi.spyOn(Check.prototype as unknown as {init(): Promise}, 'init').mockResolvedValue(undefined) + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + await withCapturedStandardStreams(async ({stdout}) => { + await new LifecycleCheck(['--output=json', '--version'], config).execute() + expect(JSON.parse(stdout())).toEqual({error: expect.objectContaining({type: 'abort', message: expect.any(String)})}) + }) + expect(process.exit).toHaveBeenCalledWith(2) + expect(themeCheckRun).not.toHaveBeenCalled() +}) diff --git a/packages/theme/src/cli/commands/theme/check.ts b/packages/theme/src/cli/commands/theme/check.ts index fb9a56a9bb1..7b07a66036d 100644 --- a/packages/theme/src/cli/commands/theme/check.ts +++ b/packages/theme/src/cli/commands/theme/check.ts @@ -6,6 +6,7 @@ import { outputActiveConfig, performAutoFixes, handleExit, + themeCheckHasBlockingIssues, type FailLevel, } from '../../services/check.js' import {renderThemeCheckResult, encodeThemeCheckResult} from '../../services/check/result.js' @@ -20,6 +21,7 @@ import {moduleDirectory, joinPath} from '@shopify/cli-kit/node/path' import {getPackageVersion} from '@shopify/cli-kit/node/node-package-manager' import {InferredFlags} from '@oclif/core/interfaces' import {AdminSession} from '@shopify/cli-kit/node/session' +import type {ThemeEnvironmentResult} from '../../services/json-output/schema.js' type CheckFlags = InferredFlags export default class Check extends ThemeCommand { @@ -144,18 +146,35 @@ export default class Check extends ThemeCommand { const output = await checkTheme(path, config, environment) const {offenses, theme} = output const json = flags.json || flags.output === 'json' + const valid = !themeCheckHasBlockingIssues(offenses, flags['fail-level'] as FailLevel) if (!multiEnvironment || !json) { - renderThemeCheckResult(output, json ? 'json' : flags.output, path, environment) + renderThemeCheckResult({...output, valid}, json ? 'json' : flags.output, path, environment) } if (flags['auto-correct']) { await performAutoFixes(theme, offenses) } - if (!multiEnvironment) { - return handleExit(offenses, flags['fail-level'] as FailLevel) + if (json) { + if (!valid) process.exitCode = 1 + return {files: output.result, valid} } - if (json) return output.result + if (!multiEnvironment) return handleExit(offenses, flags['fail-level'] as FailLevel) + } + + protected async _run(): Promise { + const separatorIndex = this.argv.indexOf('--') + const argv = separatorIndex < 0 ? this.argv : this.argv.slice(0, separatorIndex) + const outputIndex = argv.findLastIndex((arg) => /^(?:--output(?:=|$)|-o)/.test(arg)) + const output = + outputIndex < 0 ? process.env.SHOPIFY_FLAG_OUTPUT : argv[outputIndex]!.replace(/^(?:--output=|-o=?)/, '') + const legacyJson = + output === 'json' || ((output === '--output' || output === '') && argv[outputIndex + 1] === 'json') + if (legacyJson && !argv.includes('--json')) { + // The legacy format flag must also select CLI Kit's JSON error and side-event context. + this.argv = [...argv, '--json', ...(separatorIndex < 0 ? [] : this.argv.slice(separatorIndex))] + } + return super._run() } protected collectsEnvironmentResults(flags: Partial): boolean { @@ -164,8 +183,8 @@ export default class Check extends ThemeCommand { ) } - protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { - outputResult(encodeThemeCheckResult(themeCheckJsonOutputSchema.validate({environments}))) + protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { + outputResult(encodeThemeCheckResult({environments})) } } diff --git a/packages/theme/src/cli/services/check.ts b/packages/theme/src/cli/services/check.ts index 38dbe9b186d..dda9e9b1819 100644 --- a/packages/theme/src/cli/services/check.ts +++ b/packages/theme/src/cli/services/check.ts @@ -263,13 +263,12 @@ export function formatOffensesJson(offensesByFile: OffenseMap, environment?: str * Handles the process exit based on the offenses and fail level. */ export function handleExit(offenses: Offense[], failLevel: FailLevel) { - // If there is no fail level set, exit with 0 - if (!failLevel) process.exit(0) - - const failSeverity = failLevelToSeverity(failLevel) - const shouldFail = failSeverity !== undefined && offenses.some((offense) => offense.severity <= failSeverity) + process.exit(themeCheckHasBlockingIssues(offenses, failLevel) ? 1 : 0) +} - process.exit(shouldFail ? 1 : 0) +export function themeCheckHasBlockingIssues(offenses: Offense[], failLevel: FailLevel): boolean { + const failSeverity = failLevel ? failLevelToSeverity(failLevel) : undefined + return failSeverity !== undefined && offenses.some((offense) => offense.severity <= failSeverity) } /** diff --git a/packages/theme/src/cli/services/check/result.ts b/packages/theme/src/cli/services/check/result.ts index 33a569ee96f..420dd25284f 100644 --- a/packages/theme/src/cli/services/check/result.ts +++ b/packages/theme/src/cli/services/check/result.ts @@ -3,15 +3,14 @@ import {formatSummary, renderOffensesText, sortOffenses, isExtendedWriteStream} import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' import {type Offense, type Theme} from '@shopify/theme-check-node' -import {type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -export function encodeThemeCheckResult(result: InferJsonOutputSchema): string { - // Theme Check's established JSON format is compact, unlike the shared encoder. +export function encodeThemeCheckResult(result: unknown): string { + // Keep the legacy output option compact while encoding the agreed object contract. return JSON.stringify(JSON.parse(themeCheckJsonOutputSchema.encode(result))) } export function renderThemeCheckResult( - {result, offenses, theme}: {result: ThemeCheckResult; offenses: Offense[]; theme: Theme}, + {result, offenses, theme, valid}: {result: ThemeCheckResult; offenses: Offense[]; theme: Theme; valid?: boolean}, outputFormat: string, path: string, environment?: string, @@ -46,6 +45,6 @@ export function renderThemeCheckResult( stdout._handle.setBlocking(true) } - outputResult(encodeThemeCheckResult(result)) + outputResult(encodeThemeCheckResult(valid === undefined ? result : {files: result, valid})) } } diff --git a/packages/theme/src/cli/services/check/types.ts b/packages/theme/src/cli/services/check/types.ts index cb645dd2664..3b1ebe964b4 100644 --- a/packages/theme/src/cli/services/check/types.ts +++ b/packages/theme/src/cli/services/check/types.ts @@ -1,34 +1,73 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' +import {isAbsolutePath, resolvePath} from '@shopify/cli-kit/node/path' -const ThemeCheckOffenseSchema = zod.object({ - check: zod.string(), - severity: zod.enum(['error', 'warning', 'info']), - start_row: zod.number(), - start_column: zod.number(), - end_row: zod.number(), - end_column: zod.number(), - message: zod.string(), -}) -const ThemeCheckFileSchema = zod.object({ - environment: zod.string().optional(), - path: zod.string(), - offenses: zod.array(ThemeCheckOffenseSchema), - errorCount: zod.number(), - warningCount: zod.number(), - infoCount: zod.number(), -}) -const ThemeCheckResultSchema = zod.array(ThemeCheckFileSchema) -const ThemeCheckEnvironmentSchema = zod.object({environment: zod.string(), result: ThemeCheckResultSchema}) +const ThemeCheckIssueSchema = zod + .object({ + filePath: zod.string().refine(isAbsolutePath).describe('The absolute native path of the file.'), + check: zod.string(), + severity: zod.enum(['error', 'warning', 'info']), + startRow: zod.number().int().nonnegative().describe('The zero-based starting line.'), + startColumn: zod.number().int().nonnegative().describe('The zero-based starting column.'), + endRow: zod.number().int().nonnegative().describe('The zero-based ending line.'), + endColumn: zod.number().int().nonnegative().describe('The zero-based ending column.'), + message: zod.string(), + }) + .strict() + +const ThemeCheckResultSchema = zod + .object({ + valid: zod.boolean().describe('Whether the validation passes the selected blocking policy.'), + issues: zod.array(ThemeCheckIssueSchema), + errorCount: zod.number().int().nonnegative(), + warningCount: zod.number().int().nonnegative(), + infoCount: zod.number().int().nonnegative(), + }) + .strict() -export const themeCheckJsonOutputSchema = defineJsonOutputSchema({ +/** The Theme Check service model used by text rendering and compatibility callers. */ +export type ThemeCheckResult = { + environment?: string + path: string + offenses: { + check: string + severity: 'error' | 'warning' | 'info' + start_row: number + start_column: number + end_row: number + end_column: number + message: string + }[] + errorCount: number + warningCount: number + infoCount: number +}[] + +export const themeCheckJsonOutputSchema = defineThemeJsonOutputSchema({ name: 'ThemeCheckResult', - schema: zod.union([ThemeCheckResultSchema, zod.object({environments: zod.array(ThemeCheckEnvironmentSchema)})]), - definitions: { - ThemeCheckOffense: ThemeCheckOffenseSchema, - ThemeCheckFile: ThemeCheckFileSchema, - ThemeCheckEnvironment: ThemeCheckEnvironmentSchema, + schema: ThemeCheckResultSchema, + definitions: {ThemeCheckIssue: ThemeCheckIssueSchema}, + project: (value) => { + const {files, valid} = Array.isArray(value) + ? {files: value as ThemeCheckResult, valid: undefined} + : (value as {files: ThemeCheckResult; valid: boolean}) + return { + valid: valid ?? files.every((file) => file.errorCount === 0), + issues: files.flatMap((file) => + file.offenses.map((offense) => ({ + filePath: resolvePath(file.path), + check: offense.check, + severity: offense.severity, + startRow: offense.start_row, + startColumn: offense.start_column, + endRow: offense.end_row, + endColumn: offense.end_column, + message: offense.message, + })), + ), + errorCount: files.reduce((count, file) => count + file.errorCount, 0), + warningCount: files.reduce((count, file) => count + file.warningCount, 0), + infoCount: files.reduce((count, file) => count + file.infoCount, 0), + } }, }) - -export type ThemeCheckResult = zod.infer From 55d7a5a20d6ff1822e68b2d45893d68d23dee6b4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:14:37 +0200 Subject: [PATCH 3/9] Wait for theme corrections before emitting JSON result --- .../src/cli/commands/theme/check-json.test.ts | 15 +++++++++++++++ packages/theme/src/cli/commands/theme/check.ts | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/check-json.test.ts b/packages/theme/src/cli/commands/theme/check-json.test.ts index 8085baa4ad7..8a06f685f0e 100644 --- a/packages/theme/src/cli/commands/theme/check-json.test.ts +++ b/packages/theme/src/cli/commands/theme/check-json.test.ts @@ -160,3 +160,18 @@ test('uses the standard JSON error envelope for incompatible legacy JSON output expect(process.exit).toHaveBeenCalledWith(2) expect(themeCheckRun).not.toHaveBeenCalled() }) + +test('does not write a validation result before a requested correction fails', async () => { + const checkService = await import('../../services/check.js') + vi.spyOn(checkService, 'performAutoFixes').mockRejectedValue(new Error('Unable to write corrected file')) + vi.mocked(themeCheckRun).mockResolvedValue({offenses: [offense], theme: [], config: {} as never}) + await inTemporaryDirectory(async (directory) => { + const config = await configuration() + await withCapturedStandardStreams(async ({stdout}) => { + await expect(new Check([`--path=${directory}`, '--json', '--auto-correct'], config).run()).rejects.toThrow( + 'Unable to write corrected file', + ) + expect(stdout()).toBe('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/check.ts b/packages/theme/src/cli/commands/theme/check.ts index 7b07a66036d..fc511cf78cc 100644 --- a/packages/theme/src/cli/commands/theme/check.ts +++ b/packages/theme/src/cli/commands/theme/check.ts @@ -147,14 +147,14 @@ export default class Check extends ThemeCommand { const {offenses, theme} = output const json = flags.json || flags.output === 'json' const valid = !themeCheckHasBlockingIssues(offenses, flags['fail-level'] as FailLevel) - if (!multiEnvironment || !json) { - renderThemeCheckResult({...output, valid}, json ? 'json' : flags.output, path, environment) - } + if (!json) renderThemeCheckResult({...output, valid}, flags.output, path, environment) if (flags['auto-correct']) { await performAutoFixes(theme, offenses) } + if (json && !multiEnvironment) renderThemeCheckResult({...output, valid}, 'json', path, environment) + if (json) { if (!valid) process.exitCode = 1 return {files: output.result, valid} From b53783368a6ce90ea048a5641e98e5b699ea4e41 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:43 +0200 Subject: [PATCH 4/9] Refresh theme check JSON schema documentation --- packages/cli/README.md | 311 ++++++++++++++++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 310 insertions(+), 3 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index b7997a1d742..49204405f2d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10297,8 +10297,8 @@ Validate the theme. ``` USAGE $ shopify theme check [--auth-alias ] [-a] [-C ] [-e ...] [--fail-level - crash|error|suggestion|style|warning|info] [--init] [--json-schema] [--list] [--no-color] [--no-input] [-o - text|json] [--path ] [--print] [--verbose] [-v] + crash|error|suggestion|style|warning|info] [-j | --init | -v | --print | --list] [--json-schema] [--no-color] + [--no-input] [-o text|json] [--path ] [--verbose] FLAGS -C, --config= @@ -10316,6 +10316,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -o, --output=