Skip to content

Add typed ThemeCheckConfigError to theme-check-node#1241

Open
PhilippeCollin wants to merge 1 commit into
mainfrom
add-theme-check-config-error
Open

Add typed ThemeCheckConfigError to theme-check-node#1241
PhilippeCollin wants to merge 1 commit into
mainfrom
add-theme-check-config-error

Conversation

@PhilippeCollin

@PhilippeCollin PhilippeCollin commented Jun 19, 2026

Copy link
Copy Markdown

What

Add an exported ThemeCheckConfigError and make loadConfig throw it for any
configuration-resolution failure — reading, parsing, extends/require
resolution, or validation.

The error preserves the original failure on cause (so the filesystem code,
YAML line/column, and message stay available) and carries the configPath that
failed. It also exposes a stable code of 'THEME_CHECK_CONFIG_ERROR' for
recognition across realm/duplicate-package boundaries where instanceof can be
unreliable.

Why

Today every config failure throws a bare Error (raw fs errors, ad-hoc
new Error(...) from validation, an un-exported UnresolvedAliasError).
Consumers that want to treat "bad config" differently from a real bug have to
match error-message strings, which is brittle and couples them to this package's
wording.

With a single typed class, callers can do if (e instanceof ThemeCheckConfigError)
and read e.cause for the detail. loadConfig is the one choke point all config
loading flows through (getThemeAndConfigthemeCheckRuncheck included),
and the lower-level resolvers aren't exported, so this covers the whole surface.

This follows the common config-loader convention (e.g. Vite, cosmiconfig): wrap
the failure with where it came from, chain the original via cause, and surface
its message — rather than a bespoke error-code taxonomy.

Backwards compatible: ThemeCheckConfigError extends Error, so existing
catch/instanceof Error handling is unaffected.

Consumer

The Shopify CLI uses this to turn a bad theme check configuration into a clear,
user-facing error instead of an unexpected crash: Shopify/cli#7868

@PhilippeCollin PhilippeCollin requested a review from a team as a code owner June 19, 2026 21:35
@PhilippeCollin PhilippeCollin force-pushed the add-theme-check-config-error branch 4 times, most recently from 787b6c4 to d893eec Compare June 19, 2026 22:26
@PhilippeCollin PhilippeCollin marked this pull request as draft June 19, 2026 22:31
@PhilippeCollin PhilippeCollin marked this pull request as ready for review June 19, 2026 22:32

@graygilmore graygilmore left a comment

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.

Love that we're fixing this at the root. A few comments, nothing major. Feel free to push back.

}
}

interface NodeErrnoException extends 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.

Since we're in theme-check-node we can assume we have access to things inside of Node. Instead of a custom interface here we should be able to use NodeJS.ErrnoException:

const errno = error instanceof Error ? (error as NodeJS.ErrnoException) : undefined;

if (error instanceof ThemeCheckConfigError) return error;

const errno = error instanceof Error ? (error as NodeErrnoException) : undefined;
const location = configPath ?? errno?.path;

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.

Pretty sure this bot comment is correct. You could could have a nested file like this:

# /project/.theme-check.yml
extends: ./nested.yml

Where nested.yml is the thing that fails to load but we'd set .theme-check.yml as configPath which could hide the underlying issue. Up to you how to solve this. I think if the consumer (the CLI in our case) inspects cause it'll show the correct issue.


🐛 Bug: When a config extends another config and the nested file fails to load, loadConfig passes the top-level configPath into toThemeCheckConfigError for the whole resolution operation. The helper then prefers that supplied path over a more specific path on the underlying error, such as errno.path, so both ThemeCheckConfigError.configPath and the from ... message can point at .theme-check.yml instead of the missing or malformed extended file. For recursively parsed configs, parse errors may also need the current nested config path attached before they bubble back out.

Suggestion: Prefer the concrete path from the cause before falling back to the entry config path:

Suggested change
const location = configPath ?? errno?.path;
const location = errno?.path ?? configPath;

For parse errors in recursively extended configs, consider wrapping or annotating inside resolveConfig with the current config path so the existing idempotent guard preserves the inner path.

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.cause as NodeJS.ErrnoException)?.code).to.equal('EISDIR');
});

it('wraps an invalid configuration shape in a ThemeCheckConfigError', async () => {

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.

💡 Improvement: The case named around invalid configuration shape uses UnusedAssign: true, which fails in readYamlConfigDescription because the check value is not a plain object. That means it demonstrates wrapping for YAML/shape parsing, but not a later validateConfig failure. The generic wrapper should still catch validation errors, but the advertised validation path is asserted rather than directly exercised.

Suggestion: Either rename this case to reflect that it covers YAML/shape parsing, or add a separate case that reaches validateConfig and throws there, such as a check setting with the wrong type.

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.

const config = await loadConfigDescription(configDescription, root);
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.

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`, and surfaces the original message
inline.

This lets callers recognize a configuration problem with a single `instanceof`
check (or the stable `code`) instead of matching error-message text, while the
original error's details remain available on `cause`.

Assisted-By: devx/e547eed5-2a6b-4b20-972a-c6fe290bdf0c
@PhilippeCollin PhilippeCollin force-pushed the add-theme-check-config-error branch from d893eec to f837dfd Compare June 26, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants