From e5e9279a27848dca9c3251e4340e37bf4841ab0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Sat, 3 Oct 2026 16:00:27 +0300 Subject: [PATCH] Report unused LiquidDoc caller parameters --- .changeset/quiet-snakes-report.md | 5 ++ .../src/checks/unused-doc-param/index.spec.ts | 66 +++++++++++++++++++ .../src/checks/unused-doc-param/index.ts | 65 +++++++++++++++++- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-snakes-report.md diff --git a/.changeset/quiet-snakes-report.md b/.changeset/quiet-snakes-report.md new file mode 100644 index 000000000..8449be20d --- /dev/null +++ b/.changeset/quiet-snakes-report.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': minor +--- + +Report LiquidDoc parameters that are used in a snippet but never passed by its callers. diff --git a/packages/theme-check-common/src/checks/unused-doc-param/index.spec.ts b/packages/theme-check-common/src/checks/unused-doc-param/index.spec.ts index dc94f2dba..9992f0cd6 100644 --- a/packages/theme-check-common/src/checks/unused-doc-param/index.spec.ts +++ b/packages/theme-check-common/src/checks/unused-doc-param/index.spec.ts @@ -38,6 +38,72 @@ describe('Module: UnusedDocParam', () => { expect(offenses[0]!.suggest![0].message).to.equal("Remove unused parameter 'param2'"); }); + it('should report a used parameter that is never passed to the snippet', async () => { + const sourceCode = ` + {% doc %} + @param {string} [style] - Example style + {% enddoc %} + + {{ style }} + `; + + const offenses = await runLiquidCheck( + UnusedDocParam, + sourceCode, + 'snippets/card.liquid', + { + async getReferences() { + return [ + { + source: { uri: 'file:///templates/product.liquid' }, + target: { uri: 'file:///snippets/card.liquid' }, + type: 'direct', + }, + ]; + }, + }, + { 'templates/product.liquid': "{% render 'card' %}" }, + ); + + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal("The parameter 'style' is never passed to this snippet."); + }); + + it('should not report a used parameter that is passed to the snippet', async () => { + const sourceCode = ` + {% doc %} + @param {string} [style] - Example style + {% enddoc %} + + {{ style }} + `; + + const offenses = await runLiquidCheck( + UnusedDocParam, + sourceCode, + 'snippets/card.liquid', + { + async getReferences() { + return [ + { + source: { uri: 'file:///templates/product.liquid' }, + target: { uri: 'file:///snippets/card.liquid' }, + type: 'direct', + }, + ]; + }, + }, + { + 'templates/product.liquid': ` + {% render 'card' %} + {% render 'card', style: 'default' %} + `, + }, + ); + + expect(offenses).to.be.empty; + }); + it('should apply suggestion when a variable is defined but not used', async () => { const sourceCode = ` {% doc %} diff --git a/packages/theme-check-common/src/checks/unused-doc-param/index.ts b/packages/theme-check-common/src/checks/unused-doc-param/index.ts index 28270cf46..c3b2de074 100644 --- a/packages/theme-check-common/src/checks/unused-doc-param/index.ts +++ b/packages/theme-check-common/src/checks/unused-doc-param/index.ts @@ -1,6 +1,15 @@ -import { LiquidDocParamNode, NodeTypes } from '@shopify/liquid-html-parser'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { + isLiquidHtmlNode, + LiquidDocParamNode, + NodeTypes, + RenderMarkup, +} from '@shopify/liquid-html-parser'; +import { Context, LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { isLoopScopedVariable } from '../utils'; +import { getSnippetName } from '../../liquid-doc/arguments'; +import { toSourceCode } from '../../to-source-code'; +import { isSnippet } from '../../to-schema'; +import { visit } from '../../visitor'; export const UnusedDocParam: LiquidCheckDefinition = { meta: { @@ -8,7 +17,7 @@ export const UnusedDocParam: LiquidCheckDefinition = { name: 'Prevent unused doc parameters', docs: { description: - 'This check exists to ensure any parameters defined in the `doc` tag are used within the snippet.', + 'This check ensures parameters defined in the `doc` tag are used within the snippet and passed by its callers.', recommended: true, url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/unused-doc-param', }, @@ -38,6 +47,10 @@ export const UnusedDocParam: LiquidCheckDefinition = { }, async onCodePathEnd() { + if (definedLiquidDocParams.size === 0) return; + + const providedParams = await getProvidedParams(context); + for (const [variable, node] of definedLiquidDocParams.entries()) { if (!usedVariables.has(variable)) { context.report({ @@ -51,9 +64,55 @@ export const UnusedDocParam: LiquidCheckDefinition = { }, ], }); + } else if (providedParams && !providedParams.has(variable)) { + context.report({ + message: `The parameter '${variable}' is never passed to this snippet.`, + startIndex: node.position.start, + endIndex: node.position.end, + }); } } }, }; }, }; + +async function getProvidedParams(context: Context) { + const { getReferences, fs, file, toRelativePath } = context; + if (!getReferences || !isSnippet(file.uri)) return; + + const snippetName = toRelativePath(file.uri) + .replace(/^snippets\//, '') + .replace(/\.liquid$/, ''); + let references; + try { + references = await getReferences(file.uri); + } catch { + return; + } + const sourceUris = new Set( + references.filter((reference) => reference.type === 'direct').map(({ source }) => source.uri), + ); + const providedParams = new Set(); + + for (const sourceUri of sourceUris) { + let source: string; + try { + source = await fs.readFile(sourceUri); + } catch { + return; + } + + const sourceCode = toSourceCode(sourceUri, source); + if (sourceCode.type !== SourceCodeType.LiquidHtml || !isLiquidHtmlNode(sourceCode.ast)) return; + + visit(sourceCode.ast, { + RenderMarkup(node: RenderMarkup) { + if (getSnippetName(node) !== snippetName) return; + node.args.forEach(({ name }) => providedParams.add(name)); + }, + }); + } + + return providedParams; +}