@platformos/platformos-check-common 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/checks/index.js +4 -0
  3. package/dist/checks/index.js.map +1 -1
  4. package/dist/checks/missing-content-for-layout/index.d.ts +2 -0
  5. package/dist/checks/missing-content-for-layout/index.js +89 -0
  6. package/dist/checks/missing-content-for-layout/index.js.map +1 -0
  7. package/dist/checks/reserved-variable-name/index.d.ts +2 -0
  8. package/dist/checks/reserved-variable-name/index.js +82 -0
  9. package/dist/checks/reserved-variable-name/index.js.map +1 -0
  10. package/dist/checks/unused-assign/index.js +4 -0
  11. package/dist/checks/unused-assign/index.js.map +1 -1
  12. package/dist/checks/utils.d.ts +7 -0
  13. package/dist/checks/utils.js +8 -0
  14. package/dist/checks/utils.js.map +1 -1
  15. package/dist/find-root.js +15 -2
  16. package/dist/find-root.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +3 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/liquid-doc/utils.d.ts +1 -1
  21. package/dist/to-source-code.d.ts +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/dist/types.d.ts +10 -0
  24. package/dist/types.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/checks/index.ts +4 -0
  27. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +23 -0
  28. package/src/checks/missing-content-for-layout/index.spec.ts +84 -0
  29. package/src/checks/missing-content-for-layout/index.ts +94 -0
  30. package/src/checks/missing-page/index.spec.ts +94 -0
  31. package/src/checks/reserved-variable-name/index.spec.ts +142 -0
  32. package/src/checks/reserved-variable-name/index.ts +85 -0
  33. package/src/checks/unused-assign/index.spec.ts +13 -0
  34. package/src/checks/unused-assign/index.ts +4 -1
  35. package/src/checks/utils.ts +11 -0
  36. package/src/find-root.ts +17 -2
  37. package/src/index.ts +2 -0
  38. package/src/types.ts +19 -0
package/dist/types.d.ts CHANGED
@@ -231,10 +231,20 @@ export type Translations = {
231
231
  * target: 'file:///app/views/partials/child.liquid'
232
232
  * }
233
233
  */
234
+ /**
235
+ * The semantic Liquid construct that created a {@link Reference} edge.
236
+ *
237
+ * Optional/additive: older producers may omit it. Lets consumers distinguish a
238
+ * `{% render %}` edge from an `{% include %}` / `{% function %}` / `{% graphql %}`
239
+ * / `{% background %}` / asset / layout-association edge without re-parsing.
240
+ */
241
+ export type ReferenceKind = 'render' | 'include' | 'function' | 'background' | 'graphql' | 'asset' | 'layout';
234
242
  export type Reference = {
235
243
  source: Location;
236
244
  target: Location;
237
245
  type: 'direct' | 'indirect';
246
+ /** Which Liquid construct produced this edge. Optional for backwards compatibility. */
247
+ kind?: ReferenceKind;
238
248
  };
239
249
  export type Range = [start: number, end: number];
240
250
  export type Location = {
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,uEAAkG;AAwEzE,oGAxEa,8BAAmB,OAwEb;AAnD5C,gDAA8B;AAC9B,8DAA4C;AAC5C,iEAA+C;AAExC,MAAM,YAAY,GAAG,CAAC,IAAc,EAAsB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,QAAQ,CAAC;AAA/E,QAAA,YAAY,gBAAmE;AACrF,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,cAAc,GAAG,CAAC,IAAc,EAAwB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,UAAU,CAAC;AAArF,QAAA,cAAc,kBAAuE;AAC3F,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,aAAa,GAAG,CAAC,IAAc,EAAuB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;AAAlF,QAAA,aAAa,iBAAqE;AAkB/F,IAAY,cAKX;AALD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,2CAAyB,CAAA;IACzB,qCAAmB,CAAA;IACnB,+BAAa,CAAA;AACf,CAAC,EALW,cAAc,8BAAd,cAAc,QAKzB;AAkfD,gEAAgE;AAChE,IAAY,QAIX;AAJD,WAAY,QAAQ;IAClB,yCAAS,CAAA;IACT,6CAAW,CAAA;IACX,uCAAQ,CAAA;AACV,CAAC,EAJW,QAAQ,wBAAR,QAAQ,QAInB;AAED,+CAA+C;AAC/C,IAAY,YAGX;AAHD,WAAY,YAAY;IACtB,2BAAW,CAAA;IACX,2CAA2B,CAAA;AAC7B,CAAC,EAHW,YAAY,4BAAZ,YAAY,QAGvB"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,uEAAkG;AAwEzE,oGAxEa,8BAAmB,OAwEb;AAnD5C,gDAA8B;AAC9B,8DAA4C;AAC5C,iEAA+C;AAExC,MAAM,YAAY,GAAG,CAAC,IAAc,EAAsB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,QAAQ,CAAC;AAA/E,QAAA,YAAY,gBAAmE;AACrF,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,cAAc,GAAG,CAAC,IAAc,EAAwB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,UAAU,CAAC;AAArF,QAAA,cAAc,kBAAuE;AAC3F,MAAM,WAAW,GAAG,CAAC,IAAc,EAAqB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;AAA5E,QAAA,WAAW,eAAiE;AAClF,MAAM,aAAa,GAAG,CAAC,IAAc,EAAuB,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;AAAlF,QAAA,aAAa,iBAAqE;AAkB/F,IAAY,cAKX;AALD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,2CAAyB,CAAA;IACzB,qCAAmB,CAAA;IACnB,+BAAa,CAAA;AACf,CAAC,EALW,cAAc,8BAAd,cAAc,QAKzB;AAqgBD,gEAAgE;AAChE,IAAY,QAIX;AAJD,WAAY,QAAQ;IAClB,yCAAS,CAAA;IACT,6CAAW,CAAA;IACX,uCAAQ,CAAA;AACV,CAAC,EAJW,QAAQ,wBAAR,QAAQ,QAInB;AAED,+CAA+C;AAC/C,IAAY,YAGX;AAHD,WAAY,YAAY;IACtB,2BAAW,CAAA;IACX,2CAA2B,CAAA;AAC7B,CAAC,EAHW,YAAY,4BAAZ,YAAY,QAGvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformos/platformos-check-common",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,7 +26,7 @@
26
26
  "type-check": "tsc --noEmit"
27
27
  },
28
28
  "dependencies": {
29
- "@platformos/liquid-html-parser": "0.0.17",
29
+ "@platformos/liquid-html-parser": "0.0.18",
30
30
  "graphql": "^16.12.0",
31
31
  "js-yaml": "^4.1.1",
32
32
  "jsonc-parser": "^3.3.1",
@@ -17,6 +17,7 @@ import { MissingAsset } from './missing-asset';
17
17
  import { MissingPartial } from './missing-partial';
18
18
  import { OrphanedPartial } from './orphaned-partial';
19
19
  import { ParserBlockingScript } from './parser-blocking-script';
20
+ import { ReservedVariableName } from './reserved-variable-name';
20
21
  import { TranslationKeyExists } from './translation-key-exists';
21
22
  import { UnclosedHTMLElement } from './unclosed-html-element';
22
23
  import { UndefinedObject } from './undefined-object';
@@ -41,6 +42,7 @@ import { NestedGraphQLQuery } from './nested-graphql-query';
41
42
  import { MissingPage } from './missing-page';
42
43
  import { ValidFrontmatter } from './valid-frontmatter';
43
44
  import { JsonLiteralQuoteStyle } from './json-literal-quote-style';
45
+ import { MissingContentForLayout } from './missing-content-for-layout';
44
46
 
45
47
  export const allChecks: (
46
48
  | LiquidCheckDefinition
@@ -60,6 +62,7 @@ export const allChecks: (
60
62
  MissingPartial,
61
63
  OrphanedPartial,
62
64
  ParserBlockingScript,
65
+ ReservedVariableName,
63
66
  TranslationKeyExists,
64
67
  UnclosedHTMLElement,
65
68
  UndefinedObject,
@@ -83,6 +86,7 @@ export const allChecks: (
83
86
  MissingPage,
84
87
  ValidFrontmatter,
85
88
  JsonLiteralQuoteStyle,
89
+ MissingContentForLayout,
86
90
  ];
87
91
 
88
92
  /**
@@ -194,6 +194,29 @@ describe('Module: InvalidTagSyntax', () => {
194
194
  const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
195
195
  expect(syntaxOffenses).toHaveLength(0);
196
196
  });
197
+
198
+ it('should not report form without arguments', async () => {
199
+ const sourceCode = `{% form %}content{% endform %}`;
200
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
201
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
202
+ expect(syntaxOffenses).toHaveLength(0);
203
+ });
204
+
205
+ it('should not report form with hyphenated keys and missing commas between attributes', async () => {
206
+ // The runtime scans `[\w-]+: value` pairs out of the markup, so commas
207
+ // between attributes are optional and keys may contain hyphens.
208
+ const sourceCode = `{% form html-class: "foo-form", html-submitonce: "true" html-foo-bar: "true" %}content{% endform %}`;
209
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
210
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
211
+ expect(syntaxOffenses).toHaveLength(0);
212
+ });
213
+
214
+ it('should not report form with a positional model name', async () => {
215
+ const sourceCode = `{% form form, method: 'delete' %}content{% endform %}`;
216
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
217
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
218
+ expect(syntaxOffenses).toHaveLength(0);
219
+ });
197
220
  });
198
221
 
199
222
  describe('inside {% liquid %} blocks', () => {
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Severity } from '../../types';
3
+ import { applySuggestions, runLiquidCheck } from '../../test';
4
+ import { MissingContentForLayout } from './index';
5
+
6
+ const LAYOUT = 'app/views/layouts/application.liquid';
7
+ const PAGE = 'app/views/pages/index.liquid';
8
+ const PARTIAL = 'app/views/partials/card.liquid';
9
+
10
+ describe('Module: MissingContentForLayout', () => {
11
+ it('flags a layout that never references content_for_layout', async () => {
12
+ const sourceCode = '<html><body><header>Site</header></body></html>';
13
+
14
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
15
+
16
+ expect(offenses).toHaveLength(1);
17
+ expect(offenses[0].check).toEqual('MissingContentForLayout');
18
+ expect(offenses[0].severity).toEqual(Severity.ERROR);
19
+ });
20
+
21
+ it('does not flag a layout that outputs {{ content_for_layout }}', async () => {
22
+ const sourceCode = '<html><body>{{ content_for_layout }}</body></html>';
23
+
24
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
25
+
26
+ expect(offenses).toEqual([]);
27
+ });
28
+
29
+ it('detects content_for_layout referenced inside a {% liquid %} block via echo', async () => {
30
+ const sourceCode = [
31
+ '<html><body>',
32
+ '{% liquid',
33
+ ' echo content_for_layout',
34
+ '%}',
35
+ '</body></html>',
36
+ ].join('\n');
37
+
38
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
39
+
40
+ expect(offenses).toEqual([]);
41
+ });
42
+
43
+ it('does not flag non-layout files (pages, partials)', async () => {
44
+ const sourceCode = '<html><body><header>Site</header></body></html>';
45
+
46
+ const pageOffenses = await runLiquidCheck(MissingContentForLayout, sourceCode, PAGE);
47
+ const partialOffenses = await runLiquidCheck(MissingContentForLayout, sourceCode, PARTIAL);
48
+
49
+ expect(pageOffenses).toEqual([]);
50
+ expect(partialOffenses).toEqual([]);
51
+ });
52
+
53
+ it('suggests inserting content_for_layout before the closing </body> tag', async () => {
54
+ const sourceCode = '<html><body><header>Site</header></body></html>';
55
+
56
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
57
+ const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
58
+
59
+ expect(suggestions).toEqual([
60
+ '<html><body><header>Site</header>{{ content_for_layout }}\n</body></html>',
61
+ ]);
62
+ });
63
+
64
+ it('suggests inserting before </body> even when <body> has attributes', async () => {
65
+ const sourceCode =
66
+ '<html><body class="theme" data-env="prod"><header>Site</header></body></html>';
67
+
68
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
69
+ const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
70
+
71
+ expect(suggestions).toEqual([
72
+ '<html><body class="theme" data-env="prod"><header>Site</header>{{ content_for_layout }}\n</body></html>',
73
+ ]);
74
+ });
75
+
76
+ it('suggests appending content_for_layout when there is no </body> tag', async () => {
77
+ const sourceCode = '<header>Site</header>';
78
+
79
+ const offenses = await runLiquidCheck(MissingContentForLayout, sourceCode, LAYOUT);
80
+ const suggestions = applySuggestions({ [LAYOUT]: sourceCode }, offenses[0]);
81
+
82
+ expect(suggestions).toEqual(['<header>Site</header>\n{{ content_for_layout }}\n']);
83
+ });
84
+ });
@@ -0,0 +1,94 @@
1
+ import { NodeTypes } from '@platformos/liquid-html-parser';
2
+ import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
3
+ import { isLayout } from '../../path';
4
+
5
+ /**
6
+ * Ensures every layout outputs `{{ content_for_layout }}`.
7
+ *
8
+ * `content_for_layout` is where platformOS injects the rendered page body. A
9
+ * layout that never references it renders its own chrome but drops the page
10
+ * entirely — a correctness defect, not a style issue. (Named slots use
11
+ * `{% yield 'name' %}` separately and do not substitute for it.)
12
+ *
13
+ * The check is scoped to layout files via `getFileType` (re-exported as
14
+ * `isLayout`) so it never fires on pages or partials. Both detection and the
15
+ * suggested fix are AST-based: any reference to the `content_for_layout`
16
+ * variable clears the check (whether emitted with `{{ … }}`, `{% echo … %}`,
17
+ * or inside a `{% liquid %}` block), and the fix is inserted before the
18
+ * `<body>` element's closing tag — using the parsed element's position, never
19
+ * a text scan of the raw source.
20
+ */
21
+
22
+ const CONTENT_FOR_LAYOUT = 'content_for_layout';
23
+
24
+ export const MissingContentForLayout: LiquidCheckDefinition = {
25
+ meta: {
26
+ code: 'MissingContentForLayout',
27
+ name: 'Missing Content For Layout',
28
+ docs: {
29
+ description:
30
+ 'Ensures every layout references `content_for_layout` — without it the rendered page body is never output.',
31
+ recommended: true,
32
+ url: undefined,
33
+ },
34
+ type: SourceCodeType.LiquidHtml,
35
+ severity: Severity.ERROR,
36
+ schema: {},
37
+ targets: [],
38
+ },
39
+
40
+ create(context) {
41
+ // Scope to layout files only; pages/partials/etc. must not be flagged.
42
+ if (!isLayout(context.file.uri)) return {};
43
+
44
+ let referencesContentForLayout = false;
45
+ // Start index of the `</body>` closing tag, captured from the AST so the
46
+ // fix never has to scan the raw source for it.
47
+ let bodyCloseIndex: number | undefined;
48
+
49
+ return {
50
+ async VariableLookup(node) {
51
+ if (node.name === CONTENT_FOR_LAYOUT) {
52
+ referencesContentForLayout = true;
53
+ }
54
+ },
55
+
56
+ async HtmlElement(node) {
57
+ if (bodyCloseIndex !== undefined) return; // first <body> wins
58
+ const tagName = node.name[0];
59
+ if (tagName?.type === NodeTypes.TextNode && tagName.value.toLowerCase() === 'body') {
60
+ bodyCloseIndex = node.blockEndPosition.start;
61
+ }
62
+ },
63
+
64
+ async onCodePathEnd(file) {
65
+ if (referencesContentForLayout) return;
66
+
67
+ // Insert before `</body>` when the layout has one (keeps it inside the
68
+ // body); otherwise append at the end of the document. Both positions
69
+ // come from the AST.
70
+ const hasBody = bodyCloseIndex !== undefined;
71
+ const insertAt = hasBody ? bodyCloseIndex! : file.ast.position.end;
72
+ const insertText = hasBody ? '{{ content_for_layout }}\n' : '\n{{ content_for_layout }}\n';
73
+ const suggestMessage = hasBody
74
+ ? 'Insert `{{ content_for_layout }}` before the closing </body> tag'
75
+ : 'Insert `{{ content_for_layout }}` at the end of the layout';
76
+
77
+ context.report({
78
+ message:
79
+ "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)",
80
+ startIndex: 0,
81
+ endIndex: 0,
82
+ suggest: [
83
+ {
84
+ message: suggestMessage,
85
+ fix: (corrector) => {
86
+ corrector.insert(insertAt, insertText);
87
+ },
88
+ },
89
+ ],
90
+ });
91
+ },
92
+ };
93
+ },
94
+ };
@@ -658,6 +658,100 @@ describe('Module: MissingPage', () => {
658
658
  });
659
659
  });
660
660
 
661
+ describe('rss format link regression (/blog/rss.rss)', () => {
662
+ it('does not report when the rss format page exists', async () => {
663
+ const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
664
+ const offenses = await runLiquidCheck(
665
+ MissingPage,
666
+ sourceCode,
667
+ 'app/views/pages/home.html.liquid',
668
+ {},
669
+ {
670
+ 'app/views/pages/blog/rss.rss.liquid': '<rss></rss>',
671
+ },
672
+ );
673
+ expect(offenses.map((o) => o.message)).toEqual([]);
674
+ });
675
+
676
+ it('does not report when the page declares format rss in frontmatter', async () => {
677
+ const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
678
+ const offenses = await runLiquidCheck(
679
+ MissingPage,
680
+ sourceCode,
681
+ 'app/views/pages/home.html.liquid',
682
+ {},
683
+ {
684
+ 'app/views/pages/blog/rss.liquid': '---\nformat: rss\n---\n<rss></rss>',
685
+ },
686
+ );
687
+ expect(offenses.map((o) => o.message)).toEqual([]);
688
+ });
689
+
690
+ it('reports when no page matches the rss URL', async () => {
691
+ const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
692
+ const offenses = await runLiquidCheck(
693
+ MissingPage,
694
+ sourceCode,
695
+ 'app/views/pages/home.html.liquid',
696
+ {},
697
+ {
698
+ 'app/views/pages/blog/index.html.liquid': '<h1>Blog</h1>',
699
+ },
700
+ );
701
+ expect(offenses.map((o) => o.message)).toEqual([
702
+ "No page found for route '/blog/rss.rss' (GET)",
703
+ ]);
704
+ });
705
+
706
+ it('reports when only an html page exists at the rss URL path', async () => {
707
+ const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
708
+ const offenses = await runLiquidCheck(
709
+ MissingPage,
710
+ sourceCode,
711
+ 'app/views/pages/home.html.liquid',
712
+ {},
713
+ {
714
+ 'app/views/pages/blog/rss.html.liquid': '<h1>Not a feed</h1>',
715
+ },
716
+ );
717
+ expect(offenses.map((o) => o.message)).toEqual([
718
+ "No page found for route '/blog/rss.rss' (GET)",
719
+ ]);
720
+ });
721
+
722
+ it('reports when the module page serves the feed as xml (the platformos-blog scenario)', async () => {
723
+ const sourceCode = '<a href="/blog/rss.rss">RSS</a>';
724
+ const offenses = await runLiquidCheck(
725
+ MissingPage,
726
+ sourceCode,
727
+ 'modules/blog/public/views/partials/layout/footer.liquid',
728
+ {},
729
+ {
730
+ 'modules/blog/public/views/pages/blog/rss.liquid':
731
+ "---\nslug: 'blog/rss'\nformat: xml\n---\n<rss></rss>",
732
+ },
733
+ );
734
+ expect(offenses.map((o) => o.message)).toEqual([
735
+ "No page found for route '/blog/rss.rss' (GET)",
736
+ ]);
737
+ });
738
+
739
+ it('does not report the xml link when the module page serves the feed as xml', async () => {
740
+ const sourceCode = '<a href="/blog/rss.xml">RSS</a>';
741
+ const offenses = await runLiquidCheck(
742
+ MissingPage,
743
+ sourceCode,
744
+ 'modules/blog/public/views/partials/layout/footer.liquid',
745
+ {},
746
+ {
747
+ 'modules/blog/public/views/pages/blog/rss.liquid':
748
+ "---\nslug: 'blog/rss'\nformat: xml\n---\n<rss></rss>",
749
+ },
750
+ );
751
+ expect(offenses.map((o) => o.message)).toEqual([]);
752
+ });
753
+ });
754
+
661
755
  describe('assign variable scoping per position', () => {
662
756
  it('resolves each link against the variable value at that point in the document', async () => {
663
757
  const sourceCode =
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { highlightedOffenses, runLiquidCheck } from '../../test';
3
+ import { ReservedVariableName } from './index';
4
+
5
+ const message = (name: string) =>
6
+ `'${name}' is a reserved Liquid literal and cannot be used as a variable name — reading '${name}' always returns the literal, never the assigned value`;
7
+
8
+ async function expectReservedOffense(sourceCode: string, name: string, highlight = name) {
9
+ const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
10
+
11
+ expect(offenses.map((offense) => offense.message)).toEqual([message(name)]);
12
+ expect(highlightedOffenses(sourceCode, offenses)).toEqual([highlight]);
13
+ }
14
+
15
+ describe('Module: ReservedVariableName', () => {
16
+ it('reports assigning to empty even when the name is referenced later', async () => {
17
+ const sourceCode = [
18
+ '{% liquid',
19
+ " assign empty = '{}' | parse_json",
20
+ " function invalid = 'modules/blog/commands/blog_instances/build', object: empty",
21
+ '%}',
22
+ ].join('\n');
23
+
24
+ await expectReservedOffense(sourceCode, 'empty');
25
+ });
26
+
27
+ it('reports every reserved literal as an assign target', async () => {
28
+ const sourceCode = [
29
+ '{% liquid',
30
+ " assign true = 'a'",
31
+ " assign false = 'a'",
32
+ " assign nil = 'a'",
33
+ " assign null = 'a'",
34
+ " assign empty = 'a'",
35
+ " assign blank = 'a'",
36
+ '%}',
37
+ ].join('\n');
38
+
39
+ const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
40
+
41
+ expect(offenses.map((offense) => offense.message)).toEqual([
42
+ message('true'),
43
+ message('false'),
44
+ message('nil'),
45
+ message('null'),
46
+ message('empty'),
47
+ message('blank'),
48
+ ]);
49
+ expect(highlightedOffenses(sourceCode, offenses)).toEqual([
50
+ 'true',
51
+ 'false',
52
+ 'nil',
53
+ 'null',
54
+ 'empty',
55
+ 'blank',
56
+ ]);
57
+ });
58
+
59
+ it('reports a reserved name as a capture target', async () => {
60
+ await expectReservedOffense('{% capture blank %}hello{% endcapture %}', 'blank');
61
+ });
62
+
63
+ it('reports a reserved name as a function result target', async () => {
64
+ await expectReservedOffense("{% function null = 'lib/queries/find', id: 1 %}", 'null');
65
+ });
66
+
67
+ it('reports a reserved name as a graphql result target', async () => {
68
+ await expectReservedOffense("{% graphql empty = 'my/query' %}", 'empty');
69
+ });
70
+
71
+ it('reports a reserved name as an inline graphql result target', async () => {
72
+ await expectReservedOffense(
73
+ '{% graphql blank %}query { records { id } }{% endgraphql %}',
74
+ 'blank',
75
+ );
76
+ });
77
+
78
+ it('reports a reserved name as a parse_json target', async () => {
79
+ await expectReservedOffense('{% parse_json empty %}{ "a": 1 }{% endparse_json %}', 'empty');
80
+ });
81
+
82
+ it('reports a reserved name as a hash_assign target', async () => {
83
+ await expectReservedOffense(
84
+ "{% hash_assign empty['key'] = 'value' %}",
85
+ 'empty',
86
+ "empty['key']",
87
+ );
88
+ });
89
+
90
+ it('reports a reserved name as a for loop variable', async () => {
91
+ await expectReservedOffense('{% for empty in items %}{{ empty }}{% endfor %}', 'empty');
92
+ });
93
+
94
+ it('reports a reserved name as a background job id', async () => {
95
+ await expectReservedOffense("{% background nil = 'lib/jobs/cleanup' %}", 'nil');
96
+ });
97
+
98
+ it('reports a reserved name as a catch error variable', async () => {
99
+ await expectReservedOffense('{% try %}{% catch empty %}{{ empty }}{% endtry %}', 'empty');
100
+ });
101
+
102
+ it('reports a reserved name as an increment target', async () => {
103
+ await expectReservedOffense('{% increment empty %}', 'empty');
104
+ });
105
+
106
+ it('does not report non-reserved variable names', async () => {
107
+ const sourceCode = [
108
+ '{% liquid',
109
+ " assign my_empty = '{}' | parse_json",
110
+ " function result = 'lib/queries/find', object: my_empty",
111
+ ' echo result',
112
+ '%}',
113
+ ].join('\n');
114
+
115
+ const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
116
+
117
+ expect(offenses).toEqual([]);
118
+ });
119
+
120
+ it('does not report reading reserved literals', async () => {
121
+ const sourceCode = [
122
+ '{% liquid',
123
+ ' if items == empty or name == blank',
124
+ ' echo "nothing"',
125
+ ' endif',
126
+ ' assign flag = true',
127
+ '%}',
128
+ ].join('\n');
129
+
130
+ const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
131
+
132
+ expect(offenses).toEqual([]);
133
+ });
134
+
135
+ it('does not report names that merely contain a reserved word', async () => {
136
+ const sourceCode = "{% assign empty_result = '{}' | parse_json %}{{ empty_result }}";
137
+
138
+ const offenses = await runLiquidCheck(ReservedVariableName, sourceCode);
139
+
140
+ expect(offenses).toEqual([]);
141
+ });
142
+ });
@@ -0,0 +1,85 @@
1
+ import { LiquidVariableLookup, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
2
+ import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
3
+ import { RESERVED_VARIABLE_NAMES } from '../utils';
4
+
5
+ export const ReservedVariableName: LiquidCheckDefinition = {
6
+ meta: {
7
+ code: 'ReservedVariableName',
8
+ name: 'Reserved variable name',
9
+ docs: {
10
+ description:
11
+ "Disallows using reserved Liquid literals ('true', 'false', 'nil', 'null', 'empty', 'blank') as variable names. Reading such a name always returns the built-in literal, never the assigned value.",
12
+ recommended: true,
13
+ url: undefined,
14
+ },
15
+ type: SourceCodeType.LiquidHtml,
16
+ severity: Severity.ERROR,
17
+ schema: {},
18
+ targets: [],
19
+ },
20
+
21
+ create(context) {
22
+ const report = (name: string, startIndex: number, endIndex: number) => {
23
+ context.report({
24
+ message: `'${name}' is a reserved Liquid literal and cannot be used as a variable name — reading '${name}' always returns the literal, never the assigned value`,
25
+ startIndex,
26
+ endIndex,
27
+ });
28
+ };
29
+
30
+ // Targets whose name is a plain string always sit at the start of their markup node
31
+ const checkStringTarget = (name: string, markup: { position: { start: number } }) => {
32
+ if (RESERVED_VARIABLE_NAMES.has(name)) {
33
+ report(name, markup.position.start, markup.position.start + name.length);
34
+ }
35
+ };
36
+
37
+ const checkLookupTarget = (lookup: LiquidVariableLookup | null) => {
38
+ if (lookup?.name && RESERVED_VARIABLE_NAMES.has(lookup.name)) {
39
+ report(lookup.name, lookup.position.start, lookup.position.end);
40
+ }
41
+ };
42
+
43
+ return {
44
+ async LiquidTag(node) {
45
+ if (typeof node.markup === 'string') return;
46
+
47
+ switch (node.name) {
48
+ case NamedTags.assign:
49
+ checkStringTarget(node.markup.name, node.markup);
50
+ break;
51
+ case NamedTags.capture:
52
+ case NamedTags.parse_json:
53
+ case NamedTags.increment:
54
+ case NamedTags.decrement:
55
+ checkLookupTarget(node.markup);
56
+ break;
57
+ case NamedTags.function:
58
+ checkLookupTarget(node.markup.name);
59
+ break;
60
+ case NamedTags.graphql:
61
+ checkStringTarget(node.markup.name, node.markup);
62
+ break;
63
+ case NamedTags.hash_assign:
64
+ checkLookupTarget(node.markup.target);
65
+ break;
66
+ case NamedTags.for:
67
+ case NamedTags.tablerow:
68
+ checkStringTarget(node.markup.variableName, node.markup);
69
+ break;
70
+ case NamedTags.background:
71
+ if (node.markup.type === NodeTypes.BackgroundMarkup) {
72
+ checkStringTarget(node.markup.jobId, node.markup);
73
+ }
74
+ break;
75
+ }
76
+ },
77
+
78
+ async LiquidBranch(node) {
79
+ if (node.name === NamedTags.catch && typeof node.markup !== 'string') {
80
+ checkLookupTarget(node.markup);
81
+ }
82
+ },
83
+ };
84
+ },
85
+ };
@@ -236,6 +236,19 @@ describe('Module: UnusedAssign', () => {
236
236
  expect(offenses).to.have.lengthOf(0);
237
237
  });
238
238
 
239
+ it('should not report assignments to reserved literal names (handled by ReservedVariableName)', async () => {
240
+ const sourceCode = `
241
+ {% liquid
242
+ assign empty = '{}' | parse_json
243
+ function invalid = 'modules/blog/commands/blog_instances/build', object: empty
244
+ echo invalid
245
+ %}
246
+ `;
247
+
248
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
249
+ expect(offenses).toEqual([]);
250
+ });
251
+
239
252
  it('should not report variables referenced in a string-markup fallback assign (parse failure)', async () => {
240
253
  // The stray `}` before `%}` causes the tolerant parser to fall back to
241
254
  // string markup. The AST contains no VariableLookup nodes for k/v, so
@@ -6,7 +6,7 @@ import {
6
6
  NodeTypes,
7
7
  } from '@platformos/liquid-html-parser';
8
8
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
9
- import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
9
+ import { isWithinRawTagThatDoesNotParseItsContents, RESERVED_VARIABLE_NAMES } from '../utils';
10
10
 
11
11
  export const UnusedAssign: LiquidCheckDefinition = {
12
12
  meta: {
@@ -95,6 +95,9 @@ export const UnusedAssign: LiquidCheckDefinition = {
95
95
  }
96
96
 
97
97
  for (const [variable, node] of assignedVariables.entries()) {
98
+ // Reserved literal names are reported by ReservedVariableName instead —
99
+ // "unused" would be misleading when the source visibly references the name.
100
+ if (RESERVED_VARIABLE_NAMES.has(variable)) continue;
98
101
  if (!usedVariables.has(variable) && !variable.startsWith('_')) {
99
102
  context.report({
100
103
  message: `The variable '${variable}' is assigned but not used`,