@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,436 @@
1
+ import { expect, describe, it } from 'vitest';
2
+ import { PartialCallArguments } from '.';
3
+ import { check } from '../../test';
4
+
5
+ describe('Module: PartialCallArguments', () => {
6
+ // ─── @doc-based validation ───────────────────────────────────────────────
7
+
8
+ it('should use doc tag as complete param list when present', async () => {
9
+ const file = `
10
+ {% doc %}
11
+ @param {Number} variable - param with description
12
+ @param {Number} variable2 - param with description
13
+ {% enddoc %}
14
+
15
+ {% assign a = 5 | plus: variable | plus: variable2 %}
16
+ {{ a }}
17
+ `;
18
+ const file2 = `
19
+ {% function a = 'commands/call/fileToCall', variable: 2, variable2: 12 %}
20
+ {{ a }}
21
+ `;
22
+ const files = {
23
+ 'app/lib/commands/call/fileToCall.liquid': file,
24
+ 'app/lib/caller.liquid': file2,
25
+ };
26
+
27
+ const offenses = await check(files, [PartialCallArguments]);
28
+
29
+ expect(offenses).to.have.length(0);
30
+ });
31
+
32
+ it('should report missing required doc params', async () => {
33
+ const file = `
34
+ {% doc %}
35
+ @param {Number} variable - param with description
36
+ @param {Number} variable2 - param with description
37
+ {% enddoc %}
38
+
39
+ {% assign a = 5 | plus: variable | plus: variable2 %}
40
+ {{ a }}
41
+ `;
42
+ const file2 = `
43
+ {% function a = 'commands/call/fileToCall', variable: 2 %}
44
+ {{ a }}
45
+ `;
46
+ const files = {
47
+ 'app/lib/commands/call/fileToCall.liquid': file,
48
+ 'app/lib/caller.liquid': file2,
49
+ };
50
+
51
+ const offenses = await check(files, [PartialCallArguments]);
52
+
53
+ expect(offenses).to.have.length(1);
54
+ expect(offenses).to.containOffense(
55
+ 'Required parameter variable2 must be passed to function call',
56
+ );
57
+ });
58
+
59
+ it('should report unknown params not in doc', async () => {
60
+ const file = `
61
+ {% doc %}
62
+ @param {Number} variable - param with description
63
+ {% enddoc %}
64
+
65
+ {% assign a = 5 | plus: variable %}
66
+ {{ a }}
67
+ `;
68
+ const file2 = `
69
+ {% function a = 'commands/call/fileToCall', variable: 2, extra: 12 %}
70
+ {{ a }}
71
+ `;
72
+ const files = {
73
+ 'app/lib/commands/call/fileToCall.liquid': file,
74
+ 'app/lib/caller.liquid': file2,
75
+ };
76
+
77
+ const offenses = await check(files, [PartialCallArguments]);
78
+
79
+ expect(offenses).to.have.length(1);
80
+ expect(offenses).to.containOffense('Unknown parameter extra passed to function call');
81
+ });
82
+
83
+ it('should allow doc-optional params without requiring them', async () => {
84
+ const file = `
85
+ {% doc %}
86
+ @param {String} a - required
87
+ @param {String} [b] - optional
88
+ {% enddoc %}
89
+ {{ a }}{{ b }}
90
+ `;
91
+ const file2 = `
92
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
93
+ {{ res }}
94
+ `;
95
+ const files = {
96
+ 'app/lib/commands/call/fileToCall.liquid': file,
97
+ 'app/lib/caller.liquid': file2,
98
+ };
99
+
100
+ const offenses = await check(files, [PartialCallArguments]);
101
+
102
+ expect(offenses).to.have.length(0);
103
+ });
104
+
105
+ it('should allow passing doc-optional params without reporting unknown', async () => {
106
+ const file = `
107
+ {% doc %}
108
+ @param {String} a - required
109
+ @param {String} [b] - optional
110
+ {% enddoc %}
111
+ {{ a }}{{ b }}
112
+ `;
113
+ const file2 = `
114
+ {% function res = 'commands/call/fileToCall', a: 'hello', b: 'world' %}
115
+ {{ res }}
116
+ `;
117
+ const files = {
118
+ 'app/lib/commands/call/fileToCall.liquid': file,
119
+ 'app/lib/caller.liquid': file2,
120
+ };
121
+
122
+ const offenses = await check(files, [PartialCallArguments]);
123
+
124
+ expect(offenses).to.have.length(0);
125
+ });
126
+
127
+ it('should not require doc params that are not used in source', async () => {
128
+ const file = `
129
+ {% doc %}
130
+ @param {String} a - required param
131
+ @param {String} unused - required but not used in source
132
+ {% enddoc %}
133
+ {{ a }}
134
+ `;
135
+ const file2 = `
136
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
137
+ {{ res }}
138
+ `;
139
+ const files = {
140
+ 'app/lib/commands/call/fileToCall.liquid': file,
141
+ 'app/lib/caller.liquid': file2,
142
+ };
143
+
144
+ const offenses = await check(files, [PartialCallArguments]);
145
+
146
+ expect(offenses).to.have.length(0);
147
+ });
148
+
149
+ it('should still require a doc-required param even when implementation uses | default', async () => {
150
+ // The @doc annotation is the public API contract; internal fallbacks do not change it.
151
+ const file = `
152
+ {% doc %}
153
+ @param {String} message - required by contract
154
+ {% enddoc %}
155
+ {% assign message = message | default: 'fallback' %}
156
+ {{ message }}
157
+ `;
158
+ const file2 = `
159
+ {% function res = 'commands/call/fileToCall' %}
160
+ {{ res }}
161
+ `;
162
+ const files = {
163
+ 'app/lib/commands/call/fileToCall.liquid': file,
164
+ 'app/lib/caller.liquid': file2,
165
+ };
166
+
167
+ const offenses = await check(files, [PartialCallArguments]);
168
+
169
+ expect(offenses).to.have.length(1);
170
+ expect(offenses).to.containOffense(
171
+ 'Required parameter message must be passed to function call',
172
+ );
173
+ });
174
+
175
+ // ─── Inferred validation (no @doc) ───────────────────────────────────────
176
+
177
+ it('should infer required params from undefined variables when no doc', async () => {
178
+ const file = `
179
+ {% assign b = a %}
180
+ {{ b }}
181
+ `;
182
+ const file2 = `
183
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
184
+ {{ res }}
185
+ `;
186
+ const files = {
187
+ 'app/lib/commands/call/fileToCall.liquid': file,
188
+ 'app/lib/caller.liquid': file2,
189
+ };
190
+
191
+ const offenses = await check(files, [PartialCallArguments]);
192
+
193
+ expect(offenses).to.have.length(0);
194
+ });
195
+
196
+ it('should report missing inferred params when no doc', async () => {
197
+ const file = `
198
+ {% assign b = a %}
199
+ {{ b }}
200
+ `;
201
+ const file2 = `
202
+ {% function res = 'commands/call/fileToCall' %}
203
+ {{ res }}
204
+ `;
205
+ const files = {
206
+ 'app/lib/commands/call/fileToCall.liquid': file,
207
+ 'app/lib/caller.liquid': file2,
208
+ };
209
+
210
+ const offenses = await check(files, [PartialCallArguments]);
211
+
212
+ expect(offenses).to.have.length(1);
213
+ expect(offenses).to.containOffense('Required parameter a must be passed to function call');
214
+ });
215
+
216
+ it('should report unknown params when passing args not in inferred set', async () => {
217
+ const file = `
218
+ {% assign b = a %}
219
+ {{ b }}
220
+ `;
221
+ const file2 = `
222
+ {% function res = 'commands/call/fileToCall', a: 'hello', extra: 'world' %}
223
+ {{ res }}
224
+ `;
225
+ const files = {
226
+ 'app/lib/commands/call/fileToCall.liquid': file,
227
+ 'app/lib/caller.liquid': file2,
228
+ };
229
+
230
+ const offenses = await check(files, [PartialCallArguments]);
231
+
232
+ expect(offenses).to.have.length(1);
233
+ expect(offenses).to.containOffense('Unknown parameter extra passed to function call');
234
+ });
235
+
236
+ it('should not include global objects like context in inferred params', async () => {
237
+ const file = `
238
+ {{ context.session }}
239
+ {{ a }}
240
+ `;
241
+ const file2 = `
242
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
243
+ {{ res }}
244
+ `;
245
+ const files = {
246
+ 'app/lib/commands/call/fileToCall.liquid': file,
247
+ 'app/lib/caller.liquid': file2,
248
+ };
249
+
250
+ const offenses = await check(files, [PartialCallArguments]);
251
+
252
+ expect(offenses).to.have.length(0);
253
+ });
254
+
255
+ it('should work with render tags too', async () => {
256
+ const file = `{{ a }}`;
257
+ const file2 = `{% render 'fileToRender' %}`;
258
+ const files = {
259
+ 'app/views/partials/fileToRender.liquid': file,
260
+ 'app/views/pages/caller.liquid': file2,
261
+ };
262
+
263
+ const offenses = await check(files, [PartialCallArguments]);
264
+
265
+ expect(offenses).to.have.length(1);
266
+ expect(offenses).to.containOffense('Required parameter a must be passed to render call');
267
+ });
268
+
269
+ it('should skip validation when no doc and no undefined vars', async () => {
270
+ const file = `
271
+ {% assign a = 5 %}
272
+ {{ a }}
273
+ `;
274
+ const file2 = `
275
+ {% function res = 'commands/call/fileToCall', extra: 'hello' %}
276
+ {{ res }}
277
+ `;
278
+ const files = {
279
+ 'app/lib/commands/call/fileToCall.liquid': file,
280
+ 'app/lib/caller.liquid': file2,
281
+ };
282
+
283
+ const offenses = await check(files, [PartialCallArguments]);
284
+
285
+ expect(offenses).to.have.length(0);
286
+ });
287
+
288
+ // ─── | default — inferred optional params ────────────────────────────────
289
+
290
+ it('should treat assign x = x | default: val as optional (no error when omitted)', async () => {
291
+ const file = `
292
+ {% assign message = message | default: null %}
293
+ {% assign required_param = required_param %}
294
+ {{ message }}{{ required_param }}
295
+ `;
296
+ const file2 = `
297
+ {% function res = 'commands/call/fileToCall', required_param: 'hello' %}
298
+ {{ res }}
299
+ `;
300
+ const files = {
301
+ 'app/lib/commands/call/fileToCall.liquid': file,
302
+ 'app/lib/caller.liquid': file2,
303
+ };
304
+
305
+ const offenses = await check(files, [PartialCallArguments]);
306
+
307
+ expect(offenses).to.have.length(0);
308
+ });
309
+
310
+ it('should allow passing inferred optional params without reporting unknown', async () => {
311
+ const file = `
312
+ {% assign message = message | default: null %}
313
+ {% assign required_param = required_param %}
314
+ {{ message }}{{ required_param }}
315
+ `;
316
+ const file2 = `
317
+ {% function res = 'commands/call/fileToCall', required_param: 'hello', message: 'hi' %}
318
+ {{ res }}
319
+ `;
320
+ const files = {
321
+ 'app/lib/commands/call/fileToCall.liquid': file,
322
+ 'app/lib/caller.liquid': file2,
323
+ };
324
+
325
+ const offenses = await check(files, [PartialCallArguments]);
326
+
327
+ expect(offenses).to.have.length(0);
328
+ });
329
+
330
+ it('should treat inline output {{ x | default: val }} as optional', async () => {
331
+ const file = `{{ message | default: 'fallback' }}`;
332
+ const file2 = `
333
+ {% function res = 'commands/call/fileToCall' %}
334
+ {{ res }}
335
+ `;
336
+ const files = {
337
+ 'app/lib/commands/call/fileToCall.liquid': file,
338
+ 'app/lib/caller.liquid': file2,
339
+ };
340
+
341
+ const offenses = await check(files, [PartialCallArguments]);
342
+
343
+ expect(offenses).to.have.length(0);
344
+ });
345
+
346
+ it('should treat assign y = x | default: val as x optional (different lhs/rhs)', async () => {
347
+ const file = `{% assign label = title | default: 'Untitled' %}{{ label }}`;
348
+ const file2 = `
349
+ {% function res = 'commands/call/fileToCall' %}
350
+ {{ res }}
351
+ `;
352
+ const files = {
353
+ 'app/lib/commands/call/fileToCall.liquid': file,
354
+ 'app/lib/caller.liquid': file2,
355
+ };
356
+
357
+ const offenses = await check(files, [PartialCallArguments]);
358
+
359
+ expect(offenses).to.have.length(0);
360
+ });
361
+
362
+ it('should still report unknown when caller passes arg not in optional or required set', async () => {
363
+ const file = `
364
+ {% assign message = message | default: null %}
365
+ {{ message }}
366
+ `;
367
+ const file2 = `
368
+ {% function res = 'commands/call/fileToCall', unknown_param: 'oops' %}
369
+ {{ res }}
370
+ `;
371
+ const files = {
372
+ 'app/lib/commands/call/fileToCall.liquid': file,
373
+ 'app/lib/caller.liquid': file2,
374
+ };
375
+
376
+ const offenses = await check(files, [PartialCallArguments]);
377
+
378
+ expect(offenses).to.have.length(1);
379
+ expect(offenses).to.containOffense('Unknown parameter unknown_param passed to function call');
380
+ });
381
+
382
+ it('should handle the real register_error pattern: required + optional params', async () => {
383
+ const file = `
384
+ {% liquid
385
+ assign key = key | default: null
386
+ assign message = message | default: null
387
+ assign errors = contract.errors
388
+ assign field_errors = errors[field_name] | default: blank
389
+ assign field_errors << message
390
+ assign errors[field_name] = field_errors
391
+ assign contract.valid = false
392
+ return contract
393
+ %}
394
+ `;
395
+ const file2 = `
396
+ {% function c = 'helpers/register_error', contract: c, field_name: field_name, key: key %}
397
+ `;
398
+ const files = {
399
+ 'app/lib/helpers/register_error.liquid': file,
400
+ 'app/lib/caller.liquid': file2,
401
+ };
402
+
403
+ const offenses = await check(files, [PartialCallArguments]);
404
+
405
+ expect(offenses).to.have.length(0);
406
+ });
407
+
408
+ it('should still require required params even when optional ones are present', async () => {
409
+ const file = `
410
+ {% liquid
411
+ assign key = key | default: null
412
+ assign errors = contract.errors
413
+ assign field_errors = errors[field_name] | default: blank
414
+ return contract
415
+ %}
416
+ `;
417
+ // omits both required: contract and field_name
418
+ const file2 = `
419
+ {% function c = 'helpers/register_error', key: 'some_key' %}
420
+ `;
421
+ const files = {
422
+ 'app/lib/helpers/register_error.liquid': file,
423
+ 'app/lib/caller.liquid': file2,
424
+ };
425
+
426
+ const offenses = await check(files, [PartialCallArguments]);
427
+
428
+ expect(offenses).to.have.length(2);
429
+ expect(offenses).to.containOffense(
430
+ 'Required parameter contract must be passed to function call',
431
+ );
432
+ expect(offenses).to.containOffense(
433
+ 'Required parameter field_name must be passed to function call',
434
+ );
435
+ });
436
+ });
@@ -5,15 +5,16 @@ import { LiquidNamedArgument, Position } from '@platformos/liquid-html-parser';
5
5
  import { relative } from '../../path';
6
6
  import { extractUndefinedVariables } from './extract-undefined-variables';
7
7
 
8
- export const MetadataParamsCheck: LiquidCheckDefinition = {
8
+ export const PartialCallArguments: LiquidCheckDefinition = {
9
9
  meta: {
10
- code: 'MetadataParamsCheck',
11
- name: 'Metadata Params Check',
10
+ code: 'PartialCallArguments',
11
+ aliases: ['MetadataParamsCheck'],
12
+ name: 'Partial Call Arguments',
12
13
  docs: {
13
14
  description:
14
- 'Ensures that parameters referenced in the document exist in the doc tag or are inferred from undefined variables.',
15
+ 'Ensures that all required arguments are passed at render/function call sites, and that no unknown arguments are passed. Required vs optional is determined from the {% doc %} block when present, or inferred from undefined variables in the partial source otherwise. Variables used with | default are treated as optional.',
15
16
  recommended: true,
16
- url: undefined,
17
+ url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/partial-call-arguments',
17
18
  },
18
19
  type: SourceCodeType.LiquidHtml,
19
20
  severity: Severity.ERROR,
@@ -61,14 +62,17 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
61
62
  }
62
63
  }
63
64
  }
64
- const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
65
+ const { required: undefinedRequiredVars, optional: undefinedOptionalVars } =
66
+ extractUndefinedVariables(source, globalObjectNames);
67
+ const undefinedVars = [...undefinedRequiredVars, ...undefinedOptionalVars];
65
68
  const docRequiredNames = docDef.liquidDoc.parameters
66
69
  .filter((p) => p.required)
67
70
  .map((p) => p.name);
68
71
  requiredParams = docRequiredNames.filter((name) => undefinedVars.includes(name));
69
72
  allowedParams = docDef.liquidDoc.parameters.map((p) => p.name);
70
73
  } else {
71
- // No @doc — scan for undefined variables, treat all as required
74
+ // No @doc — infer required vs optional from undefined variables in the source.
75
+ // Variables used with `| default` are treated as optional.
72
76
  const globalObjectNames: string[] = [];
73
77
  if (context.platformosDocset) {
74
78
  const objects = await context.platformosDocset.objects();
@@ -84,11 +88,14 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
84
88
  }
85
89
  }
86
90
 
87
- const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
88
- if (undefinedVars.length === 0) return;
91
+ const { required: requiredVars, optional: optionalVars } = extractUndefinedVariables(
92
+ source,
93
+ globalObjectNames,
94
+ );
95
+ if (requiredVars.length === 0 && optionalVars.length === 0) return;
89
96
 
90
- requiredParams = undefinedVars;
91
- allowedParams = undefinedVars;
97
+ requiredParams = requiredVars;
98
+ allowedParams = [...requiredVars, ...optionalVars];
92
99
  }
93
100
 
94
101
  args
@@ -700,4 +700,105 @@ describe('Module: UndefinedObject', () => {
700
700
  expect(offenses).toHaveLength(1);
701
701
  expect(offenses[0].message).toBe("Unknown object 'error' used.");
702
702
  });
703
+
704
+ describe('JSON literals in assign tag', () => {
705
+ it('should report undefined bare key AND bare value in a hash literal', async () => {
706
+ // bare keys and bare values are both VariableLookups in platformOS semantics —
707
+ // they are evaluated at runtime, so both are subject to undefined-object checks
708
+ const sourceCode = `
709
+ {% doc %}
710
+ {% enddoc %}
711
+ {% assign hash = { bare_key: bare_val } %}
712
+ `;
713
+
714
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
715
+
716
+ expect(offenses.map((o) => o.message).sort()).toEqual([
717
+ "Unknown object 'bare_key' used.",
718
+ "Unknown object 'bare_val' used.",
719
+ ]);
720
+ });
721
+
722
+ it('should NOT report string keys in a hash literal (they are literals, not variables)', async () => {
723
+ const sourceCode = `
724
+ {% doc %}
725
+ {% enddoc %}
726
+ {% assign hash = { "key": "val" } %}
727
+ `;
728
+
729
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
730
+
731
+ expect(offenses).toHaveLength(0);
732
+ });
733
+
734
+ it('should report undefined bare value under a string key', async () => {
735
+ const sourceCode = `
736
+ {% doc %}
737
+ {% enddoc %}
738
+ {% assign hash = { "key": undefined_var } %}
739
+ `;
740
+
741
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
742
+
743
+ expect(offenses).toHaveLength(1);
744
+ expect(offenses[0].message).toBe("Unknown object 'undefined_var' used.");
745
+ });
746
+
747
+ it('should report undefined elements in an array literal', async () => {
748
+ const sourceCode = `
749
+ {% doc %}
750
+ {% enddoc %}
751
+ {% assign arr = [a, undefined_var] %}
752
+ `;
753
+
754
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
755
+
756
+ expect(offenses.map((o) => o.message).sort()).toEqual([
757
+ "Unknown object 'a' used.",
758
+ "Unknown object 'undefined_var' used.",
759
+ ]);
760
+ });
761
+
762
+ it('should NOT report string elements in an array literal', async () => {
763
+ const sourceCode = `
764
+ {% doc %}
765
+ {% enddoc %}
766
+ {% assign arr = ["a", "b", "c"] %}
767
+ `;
768
+
769
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
770
+
771
+ expect(offenses).toHaveLength(0);
772
+ });
773
+
774
+ it('should NOT report bare keys/values when they refer to defined variables', async () => {
775
+ const sourceCode = `
776
+ {% doc %}
777
+ {% enddoc %}
778
+ {% assign bare_key = "k" %}
779
+ {% assign bare_val = "v" %}
780
+ {% assign hash = { bare_key: bare_val } %}
781
+ `;
782
+
783
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
784
+
785
+ expect(offenses).toHaveLength(0);
786
+ });
787
+
788
+ it('should report undefined bare keys/values in nested hash literals', async () => {
789
+ const sourceCode = `
790
+ {% doc %}
791
+ {% enddoc %}
792
+ {% assign hash = { outer: { inner: value } } %}
793
+ `;
794
+
795
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
796
+
797
+ expect(offenses.map((o) => o.message).sort()).toEqual([
798
+ "Unknown object 'inner' used.",
799
+ "Unknown object 'outer' used.",
800
+ "Unknown object 'value' used.",
801
+ ]);
802
+ });
803
+ });
703
804
  });
@@ -201,4 +201,52 @@ describe('Module: UnusedAssign', () => {
201
201
  expect(offenses).to.have.lengthOf(1);
202
202
  expect(offenses[0].message).to.equal("The variable 'arr' is assigned but not used");
203
203
  });
204
+
205
+ it('should not report variables used inside a filter array-literal argument', async () => {
206
+ const sourceCode = `
207
+ {% assign k = "key" %}
208
+ {% assign v = "value" %}
209
+ {% assign name = arr | default: [ "hi", k, v] %}
210
+ {{ name }}
211
+ `;
212
+
213
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
214
+ expect(offenses).to.have.lengthOf(0);
215
+ });
216
+
217
+ it('should not report variables used inside a filter hash-literal argument', async () => {
218
+ const sourceCode = `
219
+ {% assign k = 1 %}
220
+ {% assign name = arr | default: { a: k } %}
221
+ {{ name }}
222
+ `;
223
+
224
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
225
+ expect(offenses).to.have.lengthOf(0);
226
+ });
227
+
228
+ it('should not report variables used inside a named-argument value', async () => {
229
+ const sourceCode = `
230
+ {% assign k = 0 %}
231
+ {% assign name = arr | slice: start: k %}
232
+ {{ name }}
233
+ `;
234
+
235
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
236
+ expect(offenses).to.have.lengthOf(0);
237
+ });
238
+
239
+ it('should not report variables referenced in a string-markup fallback assign (parse failure)', async () => {
240
+ // The stray `}` before `%}` causes the tolerant parser to fall back to
241
+ // string markup. The AST contains no VariableLookup nodes for k/v, so
242
+ // without a textual fallback scan the check would wrongly flag them.
243
+ const sourceCode = `
244
+ {% assign k = "key" %}
245
+ {% assign v = "value" %}
246
+ {% assign name = arr | default: [ "hi", k, v] } %}
247
+ `;
248
+
249
+ const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
250
+ expect(offenses).to.have.lengthOf(0);
251
+ });
204
252
  });
@@ -31,6 +31,12 @@ export const UnusedAssign: LiquidCheckDefinition = {
31
31
  // effects on the original, so they count as "using" the variable.
32
32
  const referenceAssignedVariables: Set<string> = new Set();
33
33
  const usedVariables: Set<string> = new Set();
34
+ // Concatenated markup of LiquidTags whose parsing fell back to a raw string
35
+ // (e.g. a typo like `{% assign name = x | default: [...] } %}`). The AST has
36
+ // no VariableLookup nodes to traverse inside them, so onCodePathEnd scans
37
+ // this text for assigned-variable names — avoids compounding the user's
38
+ // syntax error with false "unused" warnings.
39
+ let fallbackText = '';
34
40
 
35
41
  function checkVariableUsage(node: any) {
36
42
  if (node.type === NodeTypes.VariableLookup) {
@@ -66,6 +72,8 @@ export const UnusedAssign: LiquidCheckDefinition = {
66
72
  }
67
73
  } else if (isLiquidTagCapture(node) && node.markup.name) {
68
74
  assignedVariables.set(node.markup.name, node);
75
+ } else if (typeof node.markup === 'string' && node.markup) {
76
+ fallbackText += '\n' + node.markup;
69
77
  }
70
78
  },
71
79
 
@@ -79,6 +87,13 @@ export const UnusedAssign: LiquidCheckDefinition = {
79
87
  },
80
88
 
81
89
  async onCodePathEnd() {
90
+ if (fallbackText) {
91
+ for (const name of assignedVariables.keys()) {
92
+ if (usedVariables.has(name)) continue;
93
+ if (new RegExp(`\\b${name}\\b`).test(fallbackText)) usedVariables.add(name);
94
+ }
95
+ }
96
+
82
97
  for (const [variable, node] of assignedVariables.entries()) {
83
98
  if (!usedVariables.has(variable) && !variable.startsWith('_')) {
84
99
  context.report({