Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/theme-check-config-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@shopify/theme-check-node': minor
---

Add `ThemeCheckConfigError` so configuration-resolution failures are catchable by type.

`loadConfig` now wraps any failure while reading, parsing, or validating a Theme Check
configuration in a `ThemeCheckConfigError`. The error attaches the `configPath` and preserves
the underlying error (filesystem error, YAML parse error, validation error, etc.) on `cause`,
so callers can recognize a configuration problem with a single `instanceof` check — or the
stable `code` (`'THEME_CHECK_CONFIG_ERROR'`) — instead of matching error messages, while the
original error's details remain available on `cause`.
48 changes: 48 additions & 0 deletions packages/theme-check-node/src/config/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Thrown by `loadConfig` when a Theme Check configuration cannot be resolved.
*
* Any failure while reading, parsing, or validating the configuration is
* surfaced as a `ThemeCheckConfigError`, so callers can recognize a
* configuration problem with a single `instanceof` check (or the stable
* `code`) instead of matching error messages. The underlying error — a
* filesystem error, a YAML parse error, a validation error, etc. — is
* preserved on `cause`, so its `code`, line/column, and message remain
* available to callers that want the detail.
*/
export class ThemeCheckConfigError extends Error {
/**
* Stable discriminator for recognizing this error across module/realm
* boundaries where `instanceof` can be unreliable (duplicate package copies).
*/
readonly code = 'THEME_CHECK_CONFIG_ERROR';
/** The config path or modern identifier that failed to load, when known. */
readonly configPath?: string;

constructor(message: string, options: { cause?: unknown; configPath?: string } = {}) {
super(message, { cause: options.cause });
this.name = 'ThemeCheckConfigError';
this.configPath = options.configPath;
}
}

/**
* Wraps any error thrown while resolving a configuration in a
* `ThemeCheckConfigError`, attaching the config path and preserving the
* original error on `cause`. The original message is surfaced inline so the
* default output stays useful without unwrapping `cause`.
*/
export function toThemeCheckConfigError(
error: unknown,
configPath?: string,
): ThemeCheckConfigError {
if (error instanceof ThemeCheckConfigError) return error;

const errno = error instanceof Error ? (error as NodeJS.ErrnoException) : undefined;
const location = errno?.path ?? configPath;
const detail = errno?.message ?? String(error);

return new ThemeCheckConfigError(
`Failed to load Theme Check configuration${location ? ` from ${location}` : ''}: ${detail}`,
{ cause: error, configPath: location },
);
}
1 change: 1 addition & 0 deletions packages/theme-check-node/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { loadConfig } from './load-config';
export { findConfigPath } from './find-config-path';
export { ThemeCheckConfigError } from './errors';
29 changes: 29 additions & 0 deletions packages/theme-check-node/src/config/load-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, it, expect, afterEach, beforeEach, assert, vi } from 'vitest';
import { loadConfig } from './load-config';
import { ThemeCheckConfigError } from './errors';
import {
allChecks,
CheckDefinition,
Expand Down Expand Up @@ -252,6 +253,34 @@ SyntaxError:
assert(config.rootUri.startsWith('file://'));
});

describe('when the configuration cannot be resolved', () => {
it('wraps a missing config file in a ThemeCheckConfigError, preserving the original error', async () => {
const missingPath = path.join(tempDir, 'does-not-exist.yml');
const error = await loadConfig(missingPath, tempDir).catch((e) => e);
expect(error).to.be.an.instanceof(ThemeCheckConfigError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to add an expectation for the actual code since that'll be the contract for consumers.

expect(error.code).to.equal('THEME_CHECK_CONFIG_ERROR');

expect(error.code).to.equal('THEME_CHECK_CONFIG_ERROR');
expect(error.configPath).to.equal(missingPath);
// The original error is preserved for callers that want the detail.
expect((error.cause as NodeJS.ErrnoException)?.code).to.equal('ENOENT');
});

it('wraps a config path that is a directory in a ThemeCheckConfigError', async () => {
const error = await loadConfig(tempDir, tempDir).catch((e) => e);
expect(error).to.be.an.instanceof(ThemeCheckConfigError);
expect((error.cause as NodeJS.ErrnoException)?.code).to.equal('EISDIR');
});

it('wraps an invalid check setting shape (config description parse error) in a ThemeCheckConfigError', async () => {
const configPath = await createMockConfigFile(
tempDir,
`extends: nothing\nUnusedAssign: true`,
);
const error = await loadConfig(configPath, tempDir).catch((e) => e);
expect(error).to.be.an.instanceof(ThemeCheckConfigError);
expect(error.cause).to.be.an.instanceof(Error);
});
});

function check(code: string) {
return allChecks.find(isCheck(code));
}
Expand Down
33 changes: 21 additions & 12 deletions packages/theme-check-node/src/config/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadConfigDescription } from './load-config-description';
import { resolveConfig } from './resolve';
import { ModernIdentifier } from './types';
import { validateConfig } from './validation';
import { toThemeCheckConfigError } from './errors';
import fs from 'fs/promises';

/**
Expand All @@ -23,19 +24,27 @@ export async function loadConfig(
root: AbsolutePath,
): Promise<Config> {
if (!root) throw new Error('loadConfig cannot be called without a root argument');
let defaultChecks = 'theme-check:recommended';

if (!configPath) {
const files = await fs.readdir(root);
// *.extension.toml implies that we're already in the appropriate extensions
// directory. *.app.toml implies that we're inside the root of an app.
if (files.some((file) => file.endsWith('.extension.toml') || file.endsWith('.app.toml'))) {
defaultChecks = 'theme-check:theme-app-extension';
// Config resolution is a single, well-defined operation: any failure while
// reading, parsing, or validating the configuration is a configuration
// problem, so we surface it as a typed `ThemeCheckConfigError`.
try {
let defaultChecks = 'theme-check:recommended';

if (!configPath) {
const files = await fs.readdir(root);
// *.extension.toml implies that we're already in the appropriate extensions
// directory. *.app.toml implies that we're inside the root of an app.
if (files.some((file) => file.endsWith('.extension.toml') || file.endsWith('.app.toml'))) {
defaultChecks = 'theme-check:theme-app-extension';
}
}
}

const configDescription = await resolveConfig(configPath ?? defaultChecks, true);
const config = await loadConfigDescription(configDescription, root);
validateConfig(config);
return config;
const configDescription = await resolveConfig(configPath ?? defaultChecks, true);
const config = await loadConfigDescription(configDescription, root);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(NEEDS VALIDATION)

🐛 Bug: Configured require entries appear to be delegated to loadThirdPartyChecks, which catches require() failures, logs them, and returns no checks. That means a user-authored config such as require: './missing-checks.js' can still load successfully instead of producing the new ThemeCheckConfigError. In the related CLI PR, bad config is expected to be catchable and surfaced as a friendly config failure, so explicit config require failures should probably be strict even if auto-discovered third-party packages remain best-effort.

Suggestion: Thread a strict/non-strict distinction through the loading path: explicit configDescription.require entries should throw when they cannot be loaded so this catch can wrap them, while auto-discovered third-party check packages can preserve the existing log-and-ignore behavior. Add a regression test for a missing relative require path that asserts ThemeCheckConfigError, code, configPath, and cause.

validateConfig(config);
return config;
} catch (error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this one was worth flagging, too. I think this is a reasonable approach but it might be good to see if we can narrow it in any kind of way.


📐 Design: The catch wraps the entire resolution chain and converts every thrown value into a ThemeCheckConfigError. In the related CLI PR, that type is treated as an invalid-configuration condition and anything else remains reportable as a bug. If a future refactor introduces a genuine internal TypeError or other logic error in this path, it would be shown to users as bad config instead of surfacing as a reportable crash. This may be an intentional boundary, but it is worth making explicit because the consumer treats the type as authoritative for bug-vs-config triage.

Suggestion: Consider whether clearly internal precondition or logic errors should pass through unwrapped while expected failure shapes such as filesystem errors, YAML parse errors, and validation errors are wrapped. If any failure in the load path should be considered a config problem, document that in the class JSDoc or catch comment so the consumer-facing boundary is recorded.

throw toThemeCheckConfigError(error, typeof configPath === 'string' ? configPath : undefined);
}
}
1 change: 1 addition & 0 deletions packages/theme-check-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const asyncGlob = promisify(glob);
export * from '@shopify/theme-check-common';
export * from './config/types';
export { NodeFileSystem };
export { ThemeCheckConfigError } from './config';

export const loadConfig: typeof resolveConfig = async (configPath, root) => {
configPath ??= await findConfigPath(root);
Expand Down
Loading