From 04110ed67b2ecd7ca2504a3b22d98ee5a6c3dc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Tue, 20 Oct 2026 09:05:21 -0400 Subject: [PATCH] Report configs that extend each other resolveConfig followed `extends` without remembering where it came from, so two configs that extend each other made it recurse forever, and theme-check hung. It now passes down the configs that extend the current one and throws when the current one is among them. A config extended twice from different places, like a shared base, still resolves. Co-Authored-By: Claude Opus 5.5 --- .changeset/cyclic-extends.md | 5 +++++ .../src/config/resolve/resolve-config.spec.ts | 9 +++++++++ .../src/config/resolve/resolve-config.ts | 13 +++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .changeset/cyclic-extends.md diff --git a/.changeset/cyclic-extends.md b/.changeset/cyclic-extends.md new file mode 100644 index 000000000..43f866c6e --- /dev/null +++ b/.changeset/cyclic-extends.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-node': patch +--- + +Report configs that extend each other instead of hanging while resolving them. diff --git a/packages/theme-check-node/src/config/resolve/resolve-config.spec.ts b/packages/theme-check-node/src/config/resolve/resolve-config.spec.ts index 0ebd4f2e2..7b3a59ece 100644 --- a/packages/theme-check-node/src/config/resolve/resolve-config.spec.ts +++ b/packages/theme-check-node/src/config/resolve/resolve-config.spec.ts @@ -125,6 +125,15 @@ SomeCheck: expect(config.context).to.equal('app'); }); + it('rejects configs that extend each other', async () => { + const filePath = await createMockYamlFile('extends: ./base.yml'); + await createMockConfigFile(tempDir, 'extends: ./.theme-check.yml', 'base.yml'); + + await expect(resolveConfig(filePath)).rejects.toThrow( + `Cyclic extends in theme-check config: ${filePath} -> ${path.join(tempDir, 'base.yml')} -> ${filePath}`, + ); + }); + async function createMockYamlFile(content: string): Promise { return createMockConfigFile(tempDir, content); } diff --git a/packages/theme-check-node/src/config/resolve/resolve-config.ts b/packages/theme-check-node/src/config/resolve/resolve-config.ts index a4d3672af..81dfa0129 100644 --- a/packages/theme-check-node/src/config/resolve/resolve-config.ts +++ b/packages/theme-check-node/src/config/resolve/resolve-config.ts @@ -20,18 +20,27 @@ const modernConfigsPath = () => { export async function resolveConfig( configPath: AbsolutePath | ModernIdentifier, isRootConfig: boolean = false, + /** the configs that extend this one, from the root config down */ + extendedBy: AbsolutePath[] = [], ): Promise { if (isModernIdentifier(configPath)) { const modernConfigPath = path.join( modernConfigsPath(), configPath.replace(/^theme-check:/, '') + '.yml', ); - return resolveConfig(modernConfigPath); + return resolveConfig(modernConfigPath, false, extendedBy); + } + + const chain = [...extendedBy, configPath]; + if (extendedBy.includes(configPath)) { + throw new Error(`Cyclic extends in theme-check config: ${chain.join(' -> ')}`); } // TODO: Add support for more file formats. const current = await readYamlConfigDescription(configPath, isRootConfig); - const baseConfigs = await Promise.all(current.extends.map((extend) => resolveConfig(extend))); + const baseConfigs = await Promise.all( + current.extends.map((extend) => resolveConfig(extend, false, chain)), + ); return mergeFragments(baseConfigs, current); }