diff --git a/.changeset/undefined-object-block-schema-variables.md b/.changeset/undefined-object-block-schema-variables.md
new file mode 100644
index 000000000..ee2a762e6
--- /dev/null
+++ b/.changeset/undefined-object-block-schema-variables.md
@@ -0,0 +1,5 @@
+---
+'@shopify/theme-check-common': patch
+---
+
+Stop `UndefinedObject` from reporting schema settings used as bare variables in theme blocks. A block file can now use `{{ heading }}` for a `heading` setting without repeating it as a LiquidDoc `@param`.
diff --git a/packages/theme-check-common/src/checks/undefined-object/index.spec.ts b/packages/theme-check-common/src/checks/undefined-object/index.spec.ts
index dbec713f3..0f2ea6ebc 100644
--- a/packages/theme-check-common/src/checks/undefined-object/index.spec.ts
+++ b/packages/theme-check-common/src/checks/undefined-object/index.spec.ts
@@ -466,6 +466,148 @@ describe('Module: UndefinedObject', () => {
expect(offenses).toMatchObject([{ message: "Unknown object 'content' used." }]);
});
+ describe('block parameters in a theme block file', () => {
+ it('does not report a schema setting used as a bare variable', async () => {
+ const sourceCode = `
+
{{ heading }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toEqual([]);
+ });
+
+ it('does not report a schema setting and a LiquidDoc-only parameter in the same block', async () => {
+ const sourceCode = `
+ {% doc %}
+ @param {string} [eyebrow]
+ {% enddoc %}
+ {{ eyebrow }}
+ {{ heading }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toEqual([]);
+ });
+
+ it('does not report a name declared by both schema and LiquidDoc', async () => {
+ const sourceCode = `
+ {% doc %}
+ @param {string} heading
+ {% enddoc %}
+ {{ heading }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toEqual([]);
+ });
+
+ it('does not report content alongside schema and LiquidDoc variables', async () => {
+ const sourceCode = `
+ {% doc %}
+ @param {string} [eyebrow]
+ {% enddoc %}
+ {{ eyebrow }}
+ {{ heading }}
+ {{ content }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toEqual([]);
+ });
+
+ it('reports an unknown root alongside schema and LiquidDoc variables', async () => {
+ const sourceCode = `
+ {% doc %}
+ @param {string} [eyebrow]
+ {% enddoc %}
+ {{ eyebrow }}
+ {{ heading }}
+ {{ subtitle }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toMatchObject([{ message: "Unknown object 'subtitle' used." }]);
+ });
+
+ it('reports names from non-input schema entries and setting types', async () => {
+ const sourceCode = `
+
+ {{ layout_heading }}
+ {{ header }}
+ {{ color_background }}
+
+ ${schema([
+ { type: 'header', id: 'layout_heading', content: 'Layout' },
+ { type: 'color_background', id: 'background', label: 'Background' },
+ ])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toMatchObject([
+ { message: "Unknown object 'layout_heading' used." },
+ { message: "Unknown object 'header' used." },
+ { message: "Unknown object 'color_background' used." },
+ ]);
+ });
+
+ it('keeps LiquidDoc parameters and content defined when the schema is invalid', async () => {
+ const sourceCode = `
+ {% doc %}
+ @param {string} [eyebrow]
+ {% enddoc %}
+ {{ eyebrow }}
+ {{ content }}
+ {% schema %}
+ { "name": "Card", "settings": [
+ {% endschema %}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid');
+
+ expect(offenses).toEqual([]);
+ });
+
+ it('reports schema settings used as bare variables in a section file', async () => {
+ const sourceCode = `
+ {{ heading }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'sections/hero.liquid');
+
+ expect(offenses).toMatchObject([{ message: "Unknown object 'heading' used." }]);
+ });
+
+ it('reports schema settings used as bare variables in an app block', async () => {
+ const sourceCode = `
+ {{ heading }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])}
+ `;
+
+ const offenses = await runLiquidCheck(
+ UndefinedObject,
+ sourceCode,
+ 'blocks/card.liquid',
+ {},
+ undefined,
+ 'app',
+ );
+
+ expect(offenses).toMatchObject([{ message: "Unknown object 'heading' used." }]);
+ });
+ });
+
it('should not report an offense when a self defined variable is defined with a @param tag', async () => {
const sourceCode = `
{% doc %}
@@ -519,3 +661,7 @@ describe('Module: UndefinedObject', () => {
expect(offenses).to.be.empty;
});
});
+
+function schema(settings: object[]): string {
+ return `{% schema %}${JSON.stringify({ name: 'Card', settings })}{% endschema %}`;
+}
diff --git a/packages/theme-check-common/src/checks/undefined-object/index.ts b/packages/theme-check-common/src/checks/undefined-object/index.ts
index e894a8180..4c04c3cc3 100644
--- a/packages/theme-check-common/src/checks/undefined-object/index.ts
+++ b/packages/theme-check-common/src/checks/undefined-object/index.ts
@@ -14,7 +14,16 @@ import {
Position,
} from '@shopify/liquid-html-parser';
import { BLOCK_CONTENT_PARAMETER } from '../../block-parameters';
-import { LiquidCheckDefinition, Mode, Severity, SourceCodeType, ThemeDocset } from '../../types';
+import * as path from '../../path';
+import { isBlock } from '../../to-schema';
+import {
+ Context,
+ LiquidCheckDefinition,
+ Mode,
+ Severity,
+ SourceCodeType,
+ ThemeDocset,
+} from '../../types';
import { isError, last } from '../../utils';
import { hasLiquidDoc } from '../../liquid-doc/liquidDoc';
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
@@ -150,6 +159,7 @@ export const UndefinedObject: LiquidCheckDefinition = {
const objects = await globalObjects(themeDocset, relativePath, context.mode);
objects.forEach((obj) => fileScopedVariables.add(obj.name));
+ (await themeBlockParameterNames(context)).forEach((name) => fileScopedVariables.add(name));
variables.forEach((variable) => {
if (!variable.name) return;
@@ -194,6 +204,20 @@ function builtInVariables(relativePath: string): string[] {
return relativePath.startsWith('blocks/') ? [BLOCK_CONTENT_PARAMETER] : [];
}
+/**
+ * A theme block's schema settings and LiquidDoc parameters are also plain
+ * variables in the block file. Returns no names when the block's parameters
+ * cannot be resolved, such as when its schema is invalid.
+ */
+async function themeBlockParameterNames(
+ context: Context,
+): Promise {
+ if (!isBlock(context.file.uri)) return [];
+
+ const parameters = await context.getBlockParameters(path.basename(context.file.uri, '.liquid'));
+ return [...(parameters?.keys() ?? [])];
+}
+
const BLOCK_CONTEXTUAL_OBJECTS = ['app', 'section', 'recommendations', 'block'];
function getContextualObjects(relativePath: string, mode: Mode = 'theme'): string[] {