diff --git a/.changeset/max-lines.md b/.changeset/max-lines.md
new file mode 100644
index 000000000..89b6cecc5
--- /dev/null
+++ b/.changeset/max-lines.md
@@ -0,0 +1,5 @@
+---
+'@shopify/theme-check-common': minor
+---
+
+Add `MaxLines` check that enforces a maximum number of lines per Liquid file, with configurable options to skip blank lines, comments, schema blocks, and doc blocks.
diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts
index be0370c41..7bb1cae7e 100644
--- a/packages/theme-check-common/src/checks/index.ts
+++ b/packages/theme-check-common/src/checks/index.ts
@@ -26,6 +26,7 @@ import { JSONSyntaxError } from './json-syntax-error';
import { LiquidFreeSettings } from './liquid-free-settings';
import { LiquidHTMLSyntaxError } from './liquid-html-syntax-error';
import { MatchingTranslations } from './matching-translations';
+import { MaxLines } from './max-lines';
import { MissingAsset } from './missing-asset';
import { MissingContentForArguments } from './missing-content-for-arguments';
import { MissingRenderSnippetArguments } from './missing-render-snippet-arguments';
@@ -96,6 +97,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
LiquidFreeSettings,
LiquidHTMLSyntaxError,
MatchingTranslations,
+ MaxLines,
MissingAsset,
MissingContentForArguments,
MissingRenderSnippetArguments,
diff --git a/packages/theme-check-common/src/checks/max-lines/index.spec.ts b/packages/theme-check-common/src/checks/max-lines/index.spec.ts
new file mode 100644
index 000000000..2aa72ecf2
--- /dev/null
+++ b/packages/theme-check-common/src/checks/max-lines/index.spec.ts
@@ -0,0 +1,302 @@
+import { expect, describe, it } from 'vitest';
+import { runLiquidCheck, check } from '../../test';
+import { MaxLines } from './index';
+
+const sectionFile = 'sections/my-section.liquid';
+
+describe('Module: MaxLines', () => {
+ it('does not report when file is within the default limit', async () => {
+ const source = Array(300).fill('
').join('\n');
+ const offenses = await runLiquidCheck(MaxLines, source, sectionFile);
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('reports when file exceeds the default limit', async () => {
+ const source = Array(301).fill('').join('\n');
+ const offenses = await runLiquidCheck(MaxLines, source, sectionFile);
+ expect(offenses).toHaveLength(1);
+ expect(offenses.at(0)?.message).toMatch(/301.*300/);
+ });
+
+ it('reports with a custom max', async () => {
+ const source = Array(6).fill('').join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 5 } },
+ );
+ expect(offenses).toHaveLength(1);
+ expect(offenses.at(0)?.message).toMatch(/6.*5/);
+ });
+
+ it('does not report when file equals the max', async () => {
+ const source = Array(5).fill('').join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 5 } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('reports the offset at the first excess line', async () => {
+ const line1 = 'line1
';
+ const line2 = 'line2
';
+ const line3 = 'line3
';
+ const source = [line1, line2, line3].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(1);
+ expect(offenses.at(0)?.start.index).toBe(line1.length + 1 + line2.length + 1);
+ });
+
+ describe('skipBlankLines', () => {
+ it('counts blank lines by default', async () => {
+ const source = Array(3).fill('').join('\n\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 4 } },
+ );
+ expect(offenses).toHaveLength(1);
+ });
+
+ it('skips blank lines when enabled', async () => {
+ const source = Array(3).fill('').join('\n\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 4, skipBlankLines: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+ });
+
+ describe('skipComments', () => {
+ it('counts liquid comment lines by default', async () => {
+ const source = [
+ '',
+ '{% comment %}',
+ 'This is a comment',
+ '{% endcomment %}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 4 } },
+ );
+ expect(offenses).toHaveLength(1);
+ });
+
+ it('skips liquid comment blocks when enabled', async () => {
+ const source = [
+ '',
+ '{% comment %}',
+ 'This is a comment',
+ '{% endcomment %}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 4, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips liquid comment blocks with dash syntax', async () => {
+ const source = [
+ '',
+ '{%- comment -%}',
+ 'This is a comment',
+ '{%- endcomment -%}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips HTML comment lines when enabled', async () => {
+ const source = ['', '', ''].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips inline liquid comment blocks', async () => {
+ const source = ['', '{% comment %}inline{% endcomment %}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips {% # inline %} comments', async () => {
+ const source = ['', '{% # this is an inline comment %}', ''].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips {%- # inline -%} comments with dash syntax', async () => {
+ const source = ['', '{%- # this is an inline comment -%}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipComments: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('counts {% # inline %} comments when skipComments is false', async () => {
+ const source = ['', '{% # this is an inline comment %}', ''].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(1);
+ });
+ });
+
+ describe('skipSchema', () => {
+ it('skips schema block by default', async () => {
+ const source = ['', '{% schema %}', '{}', '{% endschema %}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('counts schema lines when skipSchema is false', async () => {
+ const source = ['', '{% schema %}', '{}', '{% endschema %}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipSchema: false } },
+ );
+ expect(offenses).toHaveLength(1);
+ });
+
+ it('skips schema block with dash syntax', async () => {
+ const source = [
+ '',
+ '{%- schema -%}',
+ '{}',
+ '{%- endschema -%}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('skips start and end tags as well as inner content', async () => {
+ const source = [
+ '',
+ '{% schema %}',
+ '{ "name": "My section",',
+ ' "settings": [] }',
+ '{% endschema %}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+ });
+
+ describe('skipDoc', () => {
+ it('skips doc block by default', async () => {
+ const source = ['', '{% doc %}', 'Some docs', '{% enddoc %}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2 } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+
+ it('counts doc block lines when skipDoc is false', async () => {
+ const source = ['', '{% doc %}', 'Some docs', '{% enddoc %}', ''].join(
+ '\n',
+ );
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 4, skipDoc: false } },
+ );
+ expect(offenses).toHaveLength(1);
+ });
+
+ it('skips doc block with dash syntax', async () => {
+ const source = [
+ '',
+ '{%- doc -%}',
+ 'Some docs',
+ '{%- enddoc -%}',
+ '',
+ ].join('\n');
+ const offenses = await check(
+ { [sectionFile]: source },
+ [MaxLines],
+ {},
+ { MaxLines: { enabled: true, max: 2, skipDoc: true } },
+ );
+ expect(offenses).toHaveLength(0);
+ });
+ });
+});
diff --git a/packages/theme-check-common/src/checks/max-lines/index.ts b/packages/theme-check-common/src/checks/max-lines/index.ts
new file mode 100644
index 000000000..e9cbd3b77
--- /dev/null
+++ b/packages/theme-check-common/src/checks/max-lines/index.ts
@@ -0,0 +1,94 @@
+import { LiquidCheckDefinition, SchemaProp, Severity, SourceCodeType } from '../../types';
+
+const schema = {
+ max: SchemaProp.number(300),
+ skipBlankLines: SchemaProp.boolean(false),
+ skipComments: SchemaProp.boolean(false),
+ skipSchema: SchemaProp.boolean(true),
+ skipDoc: SchemaProp.boolean(true),
+};
+
+export const MaxLines: LiquidCheckDefinition = {
+ meta: {
+ code: 'MaxLines',
+ name: 'Max Lines',
+ docs: {
+ description:
+ 'Enforce a maximum number of lines per file to keep files focused and maintainable.',
+ recommended: false,
+ },
+ type: SourceCodeType.LiquidHtml,
+ severity: Severity.WARNING,
+ schema,
+ targets: [],
+ },
+
+ create(context) {
+ const skipRanges: Array<[number, number]> = [];
+
+ return {
+ async LiquidRawTag(node) {
+ const { skipSchema, skipComments, skipDoc } = context.settings;
+ if (
+ (skipSchema && node.name === 'schema') ||
+ (skipComments && node.name === 'comment') ||
+ (skipDoc && node.name === 'doc')
+ ) {
+ skipRanges.push([node.position.start, node.position.end]);
+ }
+ },
+
+ async LiquidTag(node) {
+ if (context.settings.skipComments && node.name === '#') {
+ skipRanges.push([node.position.start, node.position.end]);
+ }
+ },
+
+ async HtmlComment(node) {
+ if (context.settings.skipComments) {
+ skipRanges.push([node.position.start, node.position.end]);
+ }
+ },
+
+ async onCodePathEnd(file) {
+ const { max, skipBlankLines } = context.settings;
+ const lines = file.source.split('\n');
+
+ const lineStartOffsets: number[] = [];
+ let offset = 0;
+ for (const line of lines) {
+ lineStartOffsets.push(offset);
+ offset += line.length + 1;
+ }
+
+ const countingLineIndices: number[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i] ?? '';
+ if (skipBlankLines && line.trim() === '') continue;
+
+ const lineStart = lineStartOffsets[i] ?? 0;
+ const lineEnd = lineStart + line.length;
+
+ const isSkipped = skipRanges.some(([s, e]) => lineEnd >= s && lineStart <= e);
+
+ if (!isSkipped) countingLineIndices.push(i);
+ }
+
+ if (countingLineIndices.length > max) {
+ const excessLineIndex = countingLineIndices.at(max);
+ const excessLine = excessLineIndex !== undefined ? lines.at(excessLineIndex) : undefined;
+
+ if (excessLineIndex !== undefined && excessLine !== undefined) {
+ const startIndex = lineStartOffsets[excessLineIndex] ?? 0;
+ context.report({
+ message: `File has too many lines (${countingLineIndices.length}). Maximum allowed is ${max}.`,
+ startIndex,
+ endIndex: startIndex + excessLine.length,
+ });
+ }
+ }
+ },
+ };
+ },
+};