@platformos/platformos-check-common 0.0.17 → 0.0.18

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 (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/checks/index.d.ts +1 -1
  3. package/dist/checks/index.js +6 -2
  4. package/dist/checks/index.js.map +1 -1
  5. package/dist/checks/json-literal-quote-style/index.d.ts +2 -0
  6. package/dist/checks/json-literal-quote-style/index.js +42 -0
  7. package/dist/checks/json-literal-quote-style/index.js.map +1 -0
  8. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.d.ts +21 -0
  9. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js +60 -0
  10. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js.map +1 -0
  11. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.d.ts +17 -0
  12. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js +37 -0
  13. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js.map +1 -0
  14. package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js +1 -1
  15. package/dist/checks/liquid-html-syntax-error/index.js +17 -0
  16. package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
  17. package/dist/checks/partial-call-arguments/extract-undefined-variables.d.ts +14 -0
  18. package/dist/checks/partial-call-arguments/extract-undefined-variables.js +234 -0
  19. package/dist/checks/partial-call-arguments/extract-undefined-variables.js.map +1 -0
  20. package/dist/checks/partial-call-arguments/index.d.ts +2 -0
  21. package/dist/checks/partial-call-arguments/index.js +117 -0
  22. package/dist/checks/partial-call-arguments/index.js.map +1 -0
  23. package/dist/checks/valid-frontmatter/index.d.ts +2 -0
  24. package/dist/checks/valid-frontmatter/index.js +279 -0
  25. package/dist/checks/valid-frontmatter/index.js.map +1 -0
  26. package/dist/frontmatter/index.d.ts +1 -59
  27. package/dist/frontmatter/index.js +6 -298
  28. package/dist/frontmatter/index.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/src/checks/index.ts +6 -2
  32. package/src/checks/json-literal-quote-style/index.spec.ts +129 -0
  33. package/src/checks/json-literal-quote-style/index.ts +45 -0
  34. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.spec.ts +422 -0
  35. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.ts +63 -0
  36. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.spec.ts +104 -0
  37. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.ts +39 -0
  38. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +86 -2
  39. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +1 -1
  40. package/src/checks/liquid-html-syntax-error/index.ts +19 -0
  41. package/src/checks/partial-call-arguments/extract-undefined-variables.spec.ts +218 -0
  42. package/src/checks/{metadata-params → partial-call-arguments}/extract-undefined-variables.ts +31 -6
  43. package/src/checks/partial-call-arguments/index.spec.ts +436 -0
  44. package/src/checks/{metadata-params → partial-call-arguments}/index.ts +18 -11
  45. package/src/checks/undefined-object/index.spec.ts +101 -0
  46. package/src/checks/valid-frontmatter/index.spec.ts +666 -0
  47. package/src/checks/valid-frontmatter/index.ts +344 -0
  48. package/src/frontmatter/index.ts +9 -344
  49. package/src/checks/metadata-params/extract-undefined-variables.spec.ts +0 -115
  50. package/src/checks/metadata-params/index.spec.ts +0 -257
@@ -0,0 +1,63 @@
1
+ import { LiquidTag } from '@platformos/liquid-html-parser';
2
+ import { Problem, SourceCodeType } from '../../..';
3
+
4
+ /**
5
+ * Detects structurally-invalid `assign` tags that neither `MultipleAssignValues` nor
6
+ * `InvalidPipeSyntax`/`InvalidFilterName` catch:
7
+ *
8
+ * {% assign %} — empty markup
9
+ * {% assign x %} — target only, no operator
10
+ * {% assign x "var" %} — missing `=`
11
+ * {% assign = 'v' %} — missing target
12
+ * {% assign x = %} — empty RHS
13
+ * {% assign 'str' = 'v' %} — target is a literal
14
+ * {% assign x := 'v' %} — operator is not `=`
15
+ *
16
+ * When the strict grammar rule `liquidTagAssignMarkup` fails, the parser falls back to
17
+ * the base case and stores markup as a raw string. That string almost always still
18
+ * contains `=`-plus-RHS with a filter or pipe issue (handled elsewhere). This check
19
+ * targets the cases where the `target = value` skeleton itself is broken, so it
20
+ * complements rather than duplicates the other sub-checks.
21
+ */
22
+ export function detectInvalidAssignSyntax(
23
+ node: LiquidTag,
24
+ ): Problem<SourceCodeType.LiquidHtml> | undefined {
25
+ if (node.name !== 'assign') return;
26
+ if (typeof node.markup !== 'string') return;
27
+
28
+ const markup = node.markup.trim();
29
+
30
+ const eqIndex = markup.indexOf('=');
31
+ const hasEquals = eqIndex !== -1;
32
+ const lhs = hasEquals ? markup.slice(0, eqIndex).trim() : markup;
33
+ const rhs = hasEquals ? markup.slice(eqIndex + 1).trim() : '';
34
+
35
+ const isStructurallyBroken =
36
+ markup === '' || !hasEquals || lhs === '' || rhs === '' || !isValidAssignTarget(lhs);
37
+
38
+ if (!isStructurallyBroken) return;
39
+
40
+ return {
41
+ message: `Invalid syntax for tag 'assign'. Expected syntax: {% assign <var> = <value> %}`,
42
+ startIndex: node.position.start,
43
+ endIndex: node.position.end,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Rejects an LHS that is obviously not an assign target.
49
+ *
50
+ * NOTE: the parser's `variableSegment` rule is stricter than the platformOS runtime —
51
+ * Liquify (see assign_tag_test.rb "allow variable names to start with digit") accepts
52
+ * `23_hours_ago` as a valid name, but our grammar requires `(letter | "_")` at the
53
+ * start and falls back to the base case for digit-starting names. To avoid
54
+ * false-positive lint errors on code that runs fine, this shape check only rejects
55
+ * LHS forms that are never valid: literal delimiters at the start (`'`, `"`, `[`, `{`)
56
+ * and stray operator characters (`:` or a second `=`) that indicate the operator
57
+ * itself is malformed (e.g. `:=`).
58
+ */
59
+ function isValidAssignTarget(lhs: string): boolean {
60
+ if (/^['"[{]/.test(lhs)) return false;
61
+ if (/[:=]/.test(lhs)) return false;
62
+ return true;
63
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runLiquidCheck } from '../../../test';
3
+ import { LiquidHTMLSyntaxError } from '../index';
4
+
5
+ // Runtime-aligned via pos-cli sync against a live staging instance:
6
+ // {{ arr << "el" }} → sync rejects (parse error in output position)
7
+ // {% echo arr << "el" %} → same
8
+ // {% assign arr << "el" %} → sync accepts (only valid position for `<<`)
9
+
10
+ describe('detectInvalidOutputPush', () => {
11
+ describe('{{ }} output position', () => {
12
+ const invalidCases: Array<[string, string]> = [
13
+ ['string value', `{{ arr << "el" }}`],
14
+ ['single-quoted value', `{{ arr << 'el' }}`],
15
+ ['variable value', `{{ arr << other }}`],
16
+ ['with filter chain', `{{ arr << "el" | upcase }}`],
17
+ ['whitespace-trim delimiters', `{{- arr << "el" -}}`],
18
+ ];
19
+
20
+ for (const [label, sourceCode] of invalidCases) {
21
+ it(`should report: ${label} — ${sourceCode}`, async () => {
22
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
23
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
24
+ expect(pushOffenses).toHaveLength(1);
25
+ });
26
+ }
27
+
28
+ it('should not fire on plain output without `<<`', async () => {
29
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ arr }}`);
30
+ expect(offenses).toHaveLength(0);
31
+ });
32
+
33
+ it('should not fire on output with filters', async () => {
34
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ arr | upcase }}`);
35
+ expect(offenses).toHaveLength(0);
36
+ });
37
+
38
+ it('should not fire when `<<` is inside a quoted string', async () => {
39
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ "a << b" }}`);
40
+ expect(offenses).toHaveLength(0);
41
+ });
42
+
43
+ it('should not fire when `<<` is inside a single-quoted string', async () => {
44
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ 'a << b' }}`);
45
+ expect(offenses).toHaveLength(0);
46
+ });
47
+
48
+ it('should not fire when `<<` appears only inside a filter-argument string', async () => {
49
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ a | append: " << " }}`);
50
+ expect(offenses).toHaveLength(0);
51
+ });
52
+
53
+ it('should suppress the generic InvalidEchoValue duplicate', async () => {
54
+ // Without the dedicated check, InvalidEchoValue would also report "Syntax is not supported".
55
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{{ arr << "el" }}`);
56
+ expect(offenses).toHaveLength(1);
57
+ expect(offenses[0].message).toContain("'<<' (push) operator");
58
+ });
59
+ });
60
+
61
+ describe('{% echo %} tag', () => {
62
+ it('should report push in echo tag', async () => {
63
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{% echo arr << "el" %}`);
64
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
65
+ expect(pushOffenses).toHaveLength(1);
66
+ });
67
+
68
+ it('should not fire on valid echo', async () => {
69
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{% echo arr %}`);
70
+ expect(offenses).toHaveLength(0);
71
+ });
72
+ });
73
+
74
+ describe('assign tag — `<<` remains valid here', () => {
75
+ it('should NOT fire on bare push in assign', async () => {
76
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{% assign arr << "el" %}`);
77
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
78
+ expect(pushOffenses).toHaveLength(0);
79
+ });
80
+
81
+ it('should NOT fire on bare push with variable', async () => {
82
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{% assign arr << v %}`);
83
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
84
+ expect(pushOffenses).toHaveLength(0);
85
+ });
86
+
87
+ it('should NOT fire on bare push with filter', async () => {
88
+ const offenses = await runLiquidCheck(
89
+ LiquidHTMLSyntaxError,
90
+ `{% assign arr << "el" | upcase %}`,
91
+ );
92
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
93
+ expect(pushOffenses).toHaveLength(0);
94
+ });
95
+ });
96
+
97
+ describe('other tags', () => {
98
+ it('should not fire for non-echo tags', async () => {
99
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, `{% render 'p' %}`);
100
+ const pushOffenses = offenses.filter((o) => o.message.includes("'<<' (push) operator"));
101
+ expect(pushOffenses).toHaveLength(0);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,39 @@
1
+ import { LiquidTag, LiquidVariableOutput, NodeTypes } from '@platformos/liquid-html-parser';
2
+ import { Problem, SourceCodeType } from '../../..';
3
+
4
+ const PUSH_OPERATOR_MESSAGE =
5
+ "The '<<' (push) operator is only valid inside '{% assign target << value %}'. Remove it or move the expression into an assign tag.";
6
+
7
+ /**
8
+ * Detects misuse of the `<<` (array push) operator inside output positions:
9
+ *
10
+ * {{ arr << "el" }} — invalid
11
+ * {% echo arr << "el" %} — invalid
12
+ *
13
+ * `<<` is only accepted as the top-level operator in an assign tag:
14
+ *
15
+ * {% assign arr << "el" %} — valid (pushes "el" onto arr)
16
+ *
17
+ * Runtime rejects output-position push with a hard syntax error; this check
18
+ * mirrors that with a clearer, actionable message than the generic
19
+ * `InvalidEchoValue` fallback ("Syntax is not supported").
20
+ */
21
+ export function detectInvalidOutputPush(
22
+ node: LiquidTag | LiquidVariableOutput,
23
+ ): Problem<SourceCodeType.LiquidHtml> | undefined {
24
+ if (node.type === NodeTypes.LiquidTag && node.name !== 'echo') return;
25
+
26
+ const markup = node.markup;
27
+ if (typeof markup !== 'string' || !markup) return;
28
+
29
+ // Strip quoted strings so literal `<<` inside a string doesn't trigger the check
30
+ // (e.g. `{{ "a << b" }}` is a harmless string with no push operator).
31
+ const stripped = markup.replace(/'[^']*'|"[^"]*"/g, '');
32
+ if (!/<</.test(stripped)) return;
33
+
34
+ return {
35
+ message: PUSH_OPERATOR_MESSAGE,
36
+ startIndex: node.position.start,
37
+ endIndex: node.position.end,
38
+ };
39
+ }
@@ -57,6 +57,51 @@ describe('Module: InvalidTagSyntax', () => {
57
57
  const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
58
58
  expect(syntaxOffenses).toHaveLength(0);
59
59
  });
60
+
61
+ it('should not report valid function with JSON array argument', async () => {
62
+ const sourceCode = `{% function res = 'path/to/function', items: ["a", "b"] %}`;
63
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
64
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
65
+ expect(syntaxOffenses).toHaveLength(0);
66
+ });
67
+
68
+ it('should not report valid function with JSON hash argument', async () => {
69
+ const sourceCode = `{% function res = 'path/to/function', config: { key: "val" } %}`;
70
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
71
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
72
+ expect(syntaxOffenses).toHaveLength(0);
73
+ });
74
+
75
+ it('should not report valid multi-line function with JSON arguments inside liquid block', async () => {
76
+ const sourceCode = `{% liquid
77
+ function res = 'path/to/function',
78
+ array: [],
79
+ hash: {}
80
+ %}`;
81
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
82
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
83
+ expect(syntaxOffenses).toHaveLength(0);
84
+ });
85
+
86
+ it('should report function with missing partial after =', async () => {
87
+ const sourceCode = `{% function res = %}`;
88
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
89
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
90
+ expect(syntaxOffenses).toHaveLength(1);
91
+ expect(syntaxOffenses[0].message).toContain("Invalid syntax for tag 'function'");
92
+ });
93
+
94
+ it('should not report valid function with nested multi-line hash argument', async () => {
95
+ const sourceCode = `{% liquid
96
+ function res = 'path/to/function',
97
+ config: {
98
+ key: "val"
99
+ }
100
+ %}`;
101
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
102
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
103
+ expect(syntaxOffenses).toHaveLength(0);
104
+ });
60
105
  });
61
106
 
62
107
  describe('graphql tag', () => {
@@ -166,6 +211,43 @@ describe('Module: InvalidTagSyntax', () => {
166
211
  const sourceCode = `{% liquid
167
212
  render 'partial'
168
213
  function res = 'path/to/function'
214
+ %}`;
215
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
216
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
217
+ expect(syntaxOffenses).toHaveLength(0);
218
+ });
219
+
220
+ it('should not report valid multi-line return with JSON hash in liquid block', async () => {
221
+ const sourceCode = `{% liquid
222
+ return {
223
+ "key": val
224
+ }
225
+ %}`;
226
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
227
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
228
+ expect(syntaxOffenses).toHaveLength(0);
229
+ });
230
+
231
+ it('should not report valid multi-line return with JSON array in liquid block', async () => {
232
+ const sourceCode = `{% liquid
233
+ return [
234
+ "a",
235
+ "b"
236
+ ]
237
+ %}`;
238
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
239
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
240
+ expect(syntaxOffenses).toHaveLength(0);
241
+ });
242
+
243
+ it('should not report valid multi-line assign with JSON array followed by render', async () => {
244
+ // assign is excluded from this check (has dedicated sub-checks), but parsing
245
+ // the multi-line liquid block must not corrupt subsequent tags
246
+ const sourceCode = `{% liquid
247
+ assign x = [
248
+ "a"
249
+ ]
250
+ render 'partial'
169
251
  %}`;
170
252
  const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
171
253
  const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
@@ -174,8 +256,10 @@ describe('Module: InvalidTagSyntax', () => {
174
256
  });
175
257
 
176
258
  describe('should NOT fire on tags with dedicated sub-checks', () => {
177
- it('should not fire InvalidTagSyntax on assign (has MultipleAssignValues)', async () => {
178
- const sourceCode = `{% assign x abc %}`;
259
+ it('should not fire InvalidTagSyntax on assign when MultipleAssignValues fires', async () => {
260
+ // MultipleAssignValues reports "Syntax is not supported"; InvalidTagSyntax is suppressed
261
+ // by the `problems.length === 0` guard in the parent pipeline.
262
+ const sourceCode = `{% assign foo = '123' 555 text %}`;
179
263
  const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
180
264
  const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
181
265
  expect(syntaxOffenses).toHaveLength(0);
@@ -12,7 +12,7 @@ const TAGS_WITH_NO_EXPECTED_MARKUP = new Set<string>(TAGS_WITHOUT_MARKUP);
12
12
  * more specific error messages and autofixes. This check should NOT fire on these
13
13
  * to avoid double-reporting or overriding their nuanced decisions.
14
14
  *
15
- * - assign → MultipleAssignValues, InvalidFilterName, InvalidPipeSyntax
15
+ * - assign → MultipleAssignValues, InvalidAssignSyntax, InvalidFilterName, InvalidPipeSyntax
16
16
  * - echo → InvalidEchoValue, InvalidFilterName, InvalidPipeSyntax
17
17
  * - if/elsif/unless → InvalidConditionalNode, InvalidConditionalNodeParenthesis
18
18
  * - for/tablerow → InvalidLoopRange, InvalidLoopArguments
@@ -1,6 +1,7 @@
1
1
  import { Severity, SourceCodeType, LiquidCheckDefinition, Problem } from '../../types';
2
2
  import { getOffset, isError } from '../../utils';
3
3
  import { detectMultipleAssignValues } from './checks/MultipleAssignValues';
4
+ import { detectInvalidAssignSyntax } from './checks/InvalidAssignSyntax';
4
5
  import { detectInvalidBooleanExpressions } from './checks/InvalidBooleanExpressions';
5
6
  import { detectInvalidEchoValue } from './checks/InvalidEchoValue';
6
7
  import { detectInvalidConditionalNode } from './checks/InvalidConditionalNode';
@@ -11,6 +12,7 @@ import { detectInvalidFilterName } from './checks/InvalidFilterName';
11
12
  import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
12
13
  import { detectUnknownTag } from './checks/UnknownTag';
13
14
  import { detectInvalidTagSyntax } from './checks/InvalidTagSyntax';
15
+ import { detectInvalidOutputPush } from './checks/InvalidOutputPush';
14
16
  import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
15
17
 
16
18
  type LineColPosition = {
@@ -75,9 +77,18 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
75
77
  return;
76
78
  }
77
79
 
80
+ // Push-operator misuse in `{% echo … << … %}` — dedicated message.
81
+ // Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
82
+ const outputPushProblem = detectInvalidOutputPush(node);
83
+ if (outputPushProblem) {
84
+ context.report(outputPushProblem);
85
+ return;
86
+ }
87
+
78
88
  // Run specific sub-checks first — they provide better error messages and autofixes.
79
89
  const problems = [
80
90
  detectMultipleAssignValues(node),
91
+ detectInvalidAssignSyntax(node),
81
92
  detectInvalidEchoValue(node),
82
93
  detectInvalidLoopRange(node),
83
94
  detectInvalidLoopArguments(node, tags),
@@ -129,6 +140,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
129
140
  async LiquidVariableOutput(node, ancestors) {
130
141
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
131
142
 
143
+ // Push-operator misuse in `{{ … << … }}` — dedicated message.
144
+ // Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
145
+ const outputPushProblem = detectInvalidOutputPush(node);
146
+ if (outputPushProblem) {
147
+ context.report(outputPushProblem);
148
+ return;
149
+ }
150
+
132
151
  const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
133
152
  if (filterProblems.length > 0) {
134
153
  filterProblems.forEach((problem) => context.report(problem));
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractUndefinedVariables } from './extract-undefined-variables';
3
+
4
+ describe('extractUndefinedVariables', () => {
5
+ it('should return variables used but not defined', () => {
6
+ const source = `{% liquid
7
+ assign b = a
8
+ %}
9
+ {{ b }}`;
10
+ const result = extractUndefinedVariables(source);
11
+ expect(result.required).to.deep.equal(['a']);
12
+ expect(result.optional).to.deep.equal([]);
13
+ });
14
+
15
+ it('should not include assigned variables', () => {
16
+ const source = `{% assign x = 1 %}{{ x }}`;
17
+ const result = extractUndefinedVariables(source);
18
+ expect(result.required).to.deep.equal([]);
19
+ expect(result.optional).to.deep.equal([]);
20
+ });
21
+
22
+ it('should not include captured variables', () => {
23
+ const source = `{% capture x %}hello{% endcapture %}{{ x }}`;
24
+ const result = extractUndefinedVariables(source);
25
+ expect(result.required).to.deep.equal([]);
26
+ expect(result.optional).to.deep.equal([]);
27
+ });
28
+
29
+ it('should not include for loop variables', () => {
30
+ const source = `{% for item in items %}{{ item }}{% endfor %}`;
31
+ const result = extractUndefinedVariables(source);
32
+ expect(result.required).to.deep.equal(['items']);
33
+ expect(result.optional).to.deep.equal([]);
34
+ });
35
+
36
+ it('should not include forloop variable', () => {
37
+ const source = `{% for item in items %}{{ forloop.index }}{% endfor %}`;
38
+ const result = extractUndefinedVariables(source);
39
+ expect(result.required).to.deep.equal(['items']);
40
+ expect(result.optional).to.deep.equal([]);
41
+ });
42
+
43
+ it('should handle function result variables', () => {
44
+ const source = `{% function res = 'my_partial' %}{{ res }}`;
45
+ const result = extractUndefinedVariables(source);
46
+ expect(result.required).to.deep.equal([]);
47
+ expect(result.optional).to.deep.equal([]);
48
+ });
49
+
50
+ it('should handle graphql result variables', () => {
51
+ const source = `{% graphql res = 'my_query' %}{{ res }}`;
52
+ const result = extractUndefinedVariables(source);
53
+ expect(result.required).to.deep.equal([]);
54
+ expect(result.optional).to.deep.equal([]);
55
+ });
56
+
57
+ it('should handle inline graphql result variables', () => {
58
+ const source = `{% graphql res %}{ users { id } }{% endgraphql %}{{ res }}`;
59
+ const result = extractUndefinedVariables(source);
60
+ expect(result.required).to.deep.equal([]);
61
+ expect(result.optional).to.deep.equal([]);
62
+ });
63
+
64
+ it('should handle parse_json result variables', () => {
65
+ const source = `{% parse_json data %}{"a":1}{% endparse_json %}{{ data }}`;
66
+ const result = extractUndefinedVariables(source);
67
+ expect(result.required).to.deep.equal([]);
68
+ expect(result.optional).to.deep.equal([]);
69
+ });
70
+
71
+ it('should not include global objects', () => {
72
+ const source = `{{ context.session }}`;
73
+ const result = extractUndefinedVariables(source, [
74
+ 'context',
75
+ 'null',
76
+ 'true',
77
+ 'false',
78
+ 'blank',
79
+ 'empty',
80
+ ]);
81
+ expect(result.required).to.deep.equal([]);
82
+ expect(result.optional).to.deep.equal([]);
83
+ });
84
+
85
+ it('should deduplicate results', () => {
86
+ const source = `{{ a }}{{ a }}`;
87
+ const result = extractUndefinedVariables(source);
88
+ expect(result.required).to.deep.equal(['a']);
89
+ expect(result.optional).to.deep.equal([]);
90
+ });
91
+
92
+ it('should return empty arrays if source fails to parse', () => {
93
+ const source = `{% invalid unclosed`;
94
+ const result = extractUndefinedVariables(source);
95
+ expect(result.required).to.deep.equal([]);
96
+ expect(result.optional).to.deep.equal([]);
97
+ });
98
+
99
+ it('should handle increment/decrement as definitions', () => {
100
+ const source = `{% increment counter %}{{ counter }}`;
101
+ const result = extractUndefinedVariables(source);
102
+ expect(result.required).to.deep.equal([]);
103
+ expect(result.optional).to.deep.equal([]);
104
+ });
105
+
106
+ it('should handle background file-based result variables', () => {
107
+ const source = `{% background my_job = 'some_partial' %}{{ my_job }}`;
108
+ const result = extractUndefinedVariables(source);
109
+ expect(result.required).to.deep.equal([]);
110
+ expect(result.optional).to.deep.equal([]);
111
+ });
112
+
113
+ it('should handle inline background tag without job_id', () => {
114
+ const source = `{% background source_name: 'my_task' %}echo "hello"{% endbackground %}{{ my_job }}`;
115
+ const result = extractUndefinedVariables(source);
116
+ expect(result.required).to.deep.equal(['my_job']);
117
+ expect(result.optional).to.deep.equal([]);
118
+ });
119
+
120
+ it('should not include doc param names', () => {
121
+ const source = `
122
+ {% doc %}
123
+ @param {String} name - a name
124
+ {% enddoc %}
125
+ {{ name }}
126
+ `;
127
+ const result = extractUndefinedVariables(source);
128
+ expect(result.required).to.deep.equal(['name']);
129
+ expect(result.optional).to.deep.equal([]);
130
+ });
131
+
132
+ // | default filter detection
133
+
134
+ it('should treat assign x = x | default: val as optional', () => {
135
+ const source = `{% assign message = message | default: null %}{{ message }}`;
136
+ const result = extractUndefinedVariables(source);
137
+ expect(result.required).to.deep.equal([]);
138
+ expect(result.optional).to.deep.equal(['message']);
139
+ });
140
+
141
+ it('should treat inline output with | default as optional', () => {
142
+ const source = `{{ message | default: 'fallback' }}`;
143
+ const result = extractUndefinedVariables(source);
144
+ expect(result.required).to.deep.equal([]);
145
+ expect(result.optional).to.deep.equal(['message']);
146
+ });
147
+
148
+ it('should treat only the defaulted variable as optional, not others', () => {
149
+ const source = `
150
+ {% liquid
151
+ assign message = message | default: null
152
+ assign name = name
153
+ %}
154
+ {{ message }}{{ name }}
155
+ `;
156
+ const result = extractUndefinedVariables(source);
157
+ expect(result.required).to.deep.equal(['name']);
158
+ expect(result.optional).to.deep.equal(['message']);
159
+ });
160
+
161
+ it('should treat x as optional when assign y = x | default: val (different lhs/rhs)', () => {
162
+ // x is the optional input; y is the local alias defined by the assign
163
+ const source = `{% assign label = title | default: 'Untitled' %}{{ label }}`;
164
+ const result = extractUndefinedVariables(source);
165
+ expect(result.required).to.deep.equal([]);
166
+ expect(result.optional).to.deep.equal(['title']);
167
+ });
168
+
169
+ it('should treat variable as optional when default filter is not first in the chain', () => {
170
+ const source = `{% assign x = x | strip | default: '' %}{{ x }}`;
171
+ const result = extractUndefinedVariables(source);
172
+ expect(result.required).to.deep.equal([]);
173
+ expect(result.optional).to.deep.equal(['x']);
174
+ });
175
+
176
+ it('should not classify a variable as optional when default is applied to a literal, not the variable', () => {
177
+ // default is applied to the result of upcase, not directly to the VariableLookup
178
+ // The parent of `x` lookup here is LiquidVariable; default is still in filters — optional
179
+ // This is the same as any other filter chain: x | upcase | default: '' → x is optional
180
+ const source = `{% assign x = x | upcase | default: '' %}{{ x }}`;
181
+ const result = extractUndefinedVariables(source);
182
+ expect(result.required).to.deep.equal([]);
183
+ expect(result.optional).to.deep.equal(['x']);
184
+ });
185
+
186
+ it('should not treat a variable as optional when it has no default filter', () => {
187
+ const source = `{% assign x = x | upcase %}{{ x }}`;
188
+ const result = extractUndefinedVariables(source);
189
+ expect(result.required).to.deep.equal(['x']);
190
+ expect(result.optional).to.deep.equal([]);
191
+ });
192
+
193
+ it('should not classify a defined variable as optional even when | default is used later', () => {
194
+ // x is defined by the assign, so the later {{ x | default: '' }} does not make x an input
195
+ const source = `{% assign x = 'hello' %}{{ x | default: 'fallback' }}`;
196
+ const result = extractUndefinedVariables(source);
197
+ expect(result.required).to.deep.equal([]);
198
+ expect(result.optional).to.deep.equal([]);
199
+ });
200
+
201
+ it('should handle multiple optional params from the real register_error pattern', () => {
202
+ const source = `
203
+ {% liquid
204
+ assign key = key | default: null
205
+ assign message = message | default: null
206
+ assign errors = contract.errors
207
+ assign field_errors = errors[field_name] | default: blank
208
+ assign field_errors << message
209
+ assign errors[field_name] = field_errors
210
+ assign contract.valid = false
211
+ return contract
212
+ %}
213
+ `;
214
+ const result = extractUndefinedVariables(source);
215
+ expect(result.required).to.deep.equal(['contract', 'field_name']);
216
+ expect(result.optional).to.deep.equal(['key', 'message']);
217
+ });
218
+ });