@platformos/platformos-check-common 0.0.17 → 0.0.19

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 (54) hide show
  1. package/CHANGELOG.md +16 -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 +32 -0
  9. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js +93 -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 +25 -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/unused-assign/index.js +17 -0
  24. package/dist/checks/unused-assign/index.js.map +1 -1
  25. package/dist/checks/valid-frontmatter/index.d.ts +2 -0
  26. package/dist/checks/valid-frontmatter/index.js +279 -0
  27. package/dist/checks/valid-frontmatter/index.js.map +1 -0
  28. package/dist/frontmatter/index.d.ts +1 -59
  29. package/dist/frontmatter/index.js +6 -298
  30. package/dist/frontmatter/index.js.map +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +2 -2
  33. package/src/checks/index.ts +6 -2
  34. package/src/checks/json-literal-quote-style/index.spec.ts +129 -0
  35. package/src/checks/json-literal-quote-style/index.ts +45 -0
  36. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.spec.ts +471 -0
  37. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.ts +97 -0
  38. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.spec.ts +104 -0
  39. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.ts +39 -0
  40. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +86 -2
  41. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +1 -1
  42. package/src/checks/liquid-html-syntax-error/index.ts +30 -0
  43. package/src/checks/partial-call-arguments/extract-undefined-variables.spec.ts +218 -0
  44. package/src/checks/{metadata-params → partial-call-arguments}/extract-undefined-variables.ts +31 -6
  45. package/src/checks/partial-call-arguments/index.spec.ts +436 -0
  46. package/src/checks/{metadata-params → partial-call-arguments}/index.ts +18 -11
  47. package/src/checks/undefined-object/index.spec.ts +101 -0
  48. package/src/checks/unused-assign/index.spec.ts +48 -0
  49. package/src/checks/unused-assign/index.ts +15 -0
  50. package/src/checks/valid-frontmatter/index.spec.ts +666 -0
  51. package/src/checks/valid-frontmatter/index.ts +344 -0
  52. package/src/frontmatter/index.ts +9 -344
  53. package/src/checks/metadata-params/extract-undefined-variables.spec.ts +0 -115
  54. package/src/checks/metadata-params/index.spec.ts +0 -257
@@ -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,10 @@
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 {
5
+ detectInvalidAssignSyntax,
6
+ detectInvalidAssignFallback,
7
+ } from './checks/InvalidAssignSyntax';
4
8
  import { detectInvalidBooleanExpressions } from './checks/InvalidBooleanExpressions';
5
9
  import { detectInvalidEchoValue } from './checks/InvalidEchoValue';
6
10
  import { detectInvalidConditionalNode } from './checks/InvalidConditionalNode';
@@ -11,6 +15,7 @@ import { detectInvalidFilterName } from './checks/InvalidFilterName';
11
15
  import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
12
16
  import { detectUnknownTag } from './checks/UnknownTag';
13
17
  import { detectInvalidTagSyntax } from './checks/InvalidTagSyntax';
18
+ import { detectInvalidOutputPush } from './checks/InvalidOutputPush';
14
19
  import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
15
20
 
16
21
  type LineColPosition = {
@@ -75,9 +80,18 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
75
80
  return;
76
81
  }
77
82
 
83
+ // Push-operator misuse in `{% echo … << … %}` — dedicated message.
84
+ // Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
85
+ const outputPushProblem = detectInvalidOutputPush(node);
86
+ if (outputPushProblem) {
87
+ context.report(outputPushProblem);
88
+ return;
89
+ }
90
+
78
91
  // Run specific sub-checks first — they provide better error messages and autofixes.
79
92
  const problems = [
80
93
  detectMultipleAssignValues(node),
94
+ detectInvalidAssignSyntax(node),
81
95
  detectInvalidEchoValue(node),
82
96
  detectInvalidLoopRange(node),
83
97
  detectInvalidLoopArguments(node, tags),
@@ -112,6 +126,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
112
126
  if (pipeProblems.length > 0) {
113
127
  pipeProblems.forEach((pipeProblem) => context.report(pipeProblem));
114
128
  }
129
+
130
+ // Last-chance check for assign tags whose tolerant parse fell back to
131
+ // string markup (e.g. stray `}` before `%}`). Gated on "nothing else
132
+ // reported on this tag" to avoid double-flagging.
133
+ if (problems.length + filterProblems.length + pipeProblems.length === 0) {
134
+ const fallback = detectInvalidAssignFallback(node);
135
+ if (fallback) context.report(fallback);
136
+ }
115
137
  },
116
138
 
117
139
  async LiquidBranch(node, ancestors) {
@@ -129,6 +151,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
129
151
  async LiquidVariableOutput(node, ancestors) {
130
152
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
131
153
 
154
+ // Push-operator misuse in `{{ … << … }}` — dedicated message.
155
+ // Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
156
+ const outputPushProblem = detectInvalidOutputPush(node);
157
+ if (outputPushProblem) {
158
+ context.report(outputPushProblem);
159
+ return;
160
+ }
161
+
132
162
  const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
133
163
  if (filterProblems.length > 0) {
134
164
  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
+ });
@@ -8,6 +8,7 @@ import {
8
8
  LiquidTagIncrement,
9
9
  LiquidTagTablerow,
10
10
  LiquidVariableLookup,
11
+ LiquidVariable,
11
12
  NamedTags,
12
13
  NodeTypes,
13
14
  Position,
@@ -25,7 +26,10 @@ type Scope = { start?: number; end?: number };
25
26
 
26
27
  /**
27
28
  * Parses a Liquid source string and returns a deduplicated list of variable names
28
- * that are used but never defined. Returns [] on parse errors.
29
+ * that are used but never defined. Returns `{ required: [], optional: [] }` on parse errors.
30
+ *
31
+ * Variables used exclusively with `| default` filter (e.g. `assign x = x | default: val`)
32
+ * are returned in `optional` — the partial handles the missing-argument case itself.
29
33
  *
30
34
  * This mirrors the variable tracking logic from the UndefinedObject check but
31
35
  * packaged as a standalone synchronous function.
@@ -33,17 +37,18 @@ type Scope = { start?: number; end?: number };
33
37
  export function extractUndefinedVariables(
34
38
  source: string,
35
39
  globalObjectNames: string[] = [],
36
- ): string[] {
40
+ ): { required: string[]; optional: string[] } {
37
41
  let ast;
38
42
  try {
39
43
  ast = toLiquidHtmlAST(source);
40
44
  } catch {
41
- return [];
45
+ return { required: [], optional: [] };
42
46
  }
43
47
 
44
48
  const scopedVariables: Map<string, Scope[]> = new Map();
45
49
  const fileScopedVariables: Set<string> = new Set(globalObjectNames);
46
50
  const variables: LiquidVariableLookup[] = [];
51
+ const variablesWithDefault: Set<string> = new Set();
47
52
 
48
53
  function indexVariableScope(variableName: string | null, scope: Scope) {
49
54
  if (!variableName) return;
@@ -166,13 +171,25 @@ export function extractUndefinedVariables(
166
171
  if (isHashAssignMarkup(parent) && parent.target === node) return;
167
172
 
168
173
  variables.push(node);
174
+
175
+ // Detect `x | default: ...` — the variable is the expression of a LiquidVariable
176
+ // that has a `default` filter, meaning the partial handles the missing case itself.
177
+ if (
178
+ node.name &&
179
+ isLiquidVariable(parent) &&
180
+ parent.expression === node &&
181
+ parent.filters.some((f) => f.name === 'default')
182
+ ) {
183
+ variablesWithDefault.add(node.name);
184
+ }
169
185
  }
170
186
 
171
187
  walk(ast, []);
172
188
 
173
189
  // Determine undefined variables
174
190
  const seen = new Set<string>();
175
- const result: string[] = [];
191
+ const required: string[] = [];
192
+ const optional: string[] = [];
176
193
 
177
194
  for (const variable of variables) {
178
195
  if (!variable.name) continue;
@@ -187,11 +204,15 @@ export function extractUndefinedVariables(
187
204
 
188
205
  if (!isVariableDefined) {
189
206
  seen.add(variable.name);
190
- result.push(variable.name);
207
+ if (variablesWithDefault.has(variable.name)) {
208
+ optional.push(variable.name);
209
+ } else {
210
+ required.push(variable.name);
211
+ }
191
212
  }
192
213
  }
193
214
 
194
- return result;
215
+ return { required, optional };
195
216
  }
196
217
 
197
218
  function isNode(x: any): x is LiquidHtmlNode {
@@ -284,3 +305,7 @@ function isLiquidBranchCatch(
284
305
  ): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } {
285
306
  return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch;
286
307
  }
308
+
309
+ function isLiquidVariable(node?: LiquidHtmlNode): node is LiquidVariable {
310
+ return node?.type === NodeTypes.LiquidVariable;
311
+ }