@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,471 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runLiquidCheck } from '../../../test';
3
+ import { LiquidHTMLSyntaxError } from '../index';
4
+
5
+ describe('detectInvalidAssignSyntax', () => {
6
+ describe('structurally-broken assign tags', () => {
7
+ const brokenCases: Array<[string, string]> = [
8
+ ['missing `=` with quoted value', `{% assign x "var" %}`],
9
+ ['missing `=` with bare identifier', `{% assign x abc %}`],
10
+ ['missing target', `{% assign = 'val' %}`],
11
+ ['completely empty', `{% assign %}`],
12
+ ['target only, no operator', `{% assign x %}`],
13
+ ['empty RHS after `=`', `{% assign x = %}`],
14
+ ];
15
+
16
+ for (const [label, sourceCode] of brokenCases) {
17
+ it(`should report: ${label} — ${sourceCode}`, async () => {
18
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
19
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
20
+ expect(syntaxOffenses).toHaveLength(1);
21
+ expect(syntaxOffenses[0].message).toContain("Invalid syntax for tag 'assign'");
22
+ });
23
+ }
24
+ });
25
+
26
+ describe('invalid targets', () => {
27
+ // Literal delimiters at the start of the target are never a valid assign target.
28
+ // Digit-starting names (e.g. `23_hours_ago`) are accepted by the platformOS runtime
29
+ // even though our parser's `variableSegment` grammar rule falls back on them —
30
+ // to avoid false positives, we do not flag digit-starting targets here.
31
+ const invalidTargetCases: Array<[string, string]> = [
32
+ ['target is a single-quoted string', `{% assign 'str' = 'v' %}`],
33
+ ['target is a double-quoted string', `{% assign "str" = 'v' %}`],
34
+ ['target is an array literal', `{% assign [] = 'v' %}`],
35
+ ['target is a hash literal', `{% assign {} = 'v' %}`],
36
+ ];
37
+
38
+ for (const [label, sourceCode] of invalidTargetCases) {
39
+ it(`should report: ${label} — ${sourceCode}`, async () => {
40
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
41
+ const syntaxOffenses = offenses.filter((o) =>
42
+ o.message.includes("Invalid syntax for tag 'assign'"),
43
+ );
44
+ expect(syntaxOffenses).toHaveLength(1);
45
+ });
46
+ }
47
+
48
+ it('should NOT flag a valid dotted target (parser accepts it)', async () => {
49
+ const sourceCode = `{% assign foo.bar = 'v' %}`;
50
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
51
+ expect(offenses).toHaveLength(0);
52
+ });
53
+
54
+ it('should NOT flag a valid indexed target', async () => {
55
+ const sourceCode = `{% assign foo[0] = 'v' %}`;
56
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
57
+ expect(offenses).toHaveLength(0);
58
+ });
59
+
60
+ it('should NOT flag a digit-starting target (valid per platformOS runtime)', async () => {
61
+ // Liquify accepts `23_hours_ago` as a valid name; our parser falls back to base
62
+ // case but this check must not over-report vs. runtime.
63
+ const sourceCode = `{% assign 23_hours_ago = 'some value' %}`;
64
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
65
+ const syntaxOffenses = offenses.filter((o) =>
66
+ o.message.includes("Invalid syntax for tag 'assign'"),
67
+ );
68
+ expect(syntaxOffenses).toHaveLength(0);
69
+ });
70
+ });
71
+
72
+ describe('operator variants', () => {
73
+ it('should report `:=` (not a recognized operator) as an assign syntax error', async () => {
74
+ const sourceCode = `{% assign x := 'v' %}`;
75
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
76
+ const syntaxOffenses = offenses.filter((o) =>
77
+ o.message.includes("Invalid syntax for tag 'assign'"),
78
+ );
79
+ expect(syntaxOffenses).toHaveLength(1);
80
+ });
81
+
82
+ it('should have some offense for `==` (MultipleAssignValues handles it)', async () => {
83
+ const sourceCode = `{% assign x == 'v' %}`;
84
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
85
+ expect(offenses.length).toBeGreaterThan(0);
86
+ });
87
+
88
+ it('should have some offense for `=+`', async () => {
89
+ const sourceCode = `{% assign x =+ 'v' %}`;
90
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
91
+ expect(offenses.length).toBeGreaterThan(0);
92
+ });
93
+ });
94
+
95
+ describe('trailing garbage after RHS / filters (fallback)', () => {
96
+ // These cases have a valid `target = value` skeleton and a valid filter chain,
97
+ // but extra non-parseable characters trail the filters. The tolerant parser
98
+ // swallows the entire body as string markup, and none of the other sub-checks
99
+ // (MultipleAssignValues, InvalidFilterName, InvalidPipeSyntax) detects the
100
+ // problem — so the fallback re-parses in strict mode and surfaces it.
101
+ const fallbackCases: Array<[string, string]> = [
102
+ [
103
+ 'stray `}` after filter array argument',
104
+ `{% assign name = arr | default: [ "hi", k, v] } %}`,
105
+ ],
106
+ ['trailing bare word after filter arg', `{% assign x = y | default: "z" trailing %}`],
107
+ ];
108
+
109
+ for (const [label, sourceCode] of fallbackCases) {
110
+ it(`should report: ${label} — ${sourceCode}`, async () => {
111
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
112
+ const syntaxOffenses = offenses.filter((o) =>
113
+ o.message.includes("Invalid syntax for tag 'assign'"),
114
+ );
115
+ expect(syntaxOffenses).toHaveLength(1);
116
+ });
117
+ }
118
+
119
+ // These cases are already flagged by other sub-checks with different messages;
120
+ // the fallback must NOT also fire on them (no double-reporting).
121
+ const alreadyCoveredCases: Array<[string, string]> = [
122
+ [
123
+ 'stray `}` after unary filter (InvalidFilterName catches it)',
124
+ `{% assign x = y | upcase } %}`,
125
+ ],
126
+ [
127
+ 'stray `}` after RHS with no filter (MultipleAssignValues catches it)',
128
+ `{% assign x = "v" } %}`,
129
+ ],
130
+ ];
131
+
132
+ for (const [label, sourceCode] of alreadyCoveredCases) {
133
+ it(`should NOT double-report: ${label} — ${sourceCode}`, async () => {
134
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
135
+ expect(offenses.length).toBeGreaterThan(0);
136
+ const fallbackOffenses = offenses.filter((o) =>
137
+ o.message.includes("Invalid syntax for tag 'assign'"),
138
+ );
139
+ expect(fallbackOffenses).toHaveLength(0);
140
+ });
141
+ }
142
+ });
143
+
144
+ describe('valid syntax — should NOT report', () => {
145
+ const validCases: Array<[string, string]> = [
146
+ // primitives
147
+ ['single-quoted string', `{% assign x = 'str' %}`],
148
+ ['double-quoted string', `{% assign x = "str" %}`],
149
+ ['integer', `{% assign x = 42 %}`],
150
+ ['negative float', `{% assign x = -1.5 %}`],
151
+ ['true', `{% assign x = true %}`],
152
+ ['false', `{% assign x = false %}`],
153
+ ['nil', `{% assign x = nil %}`],
154
+ ['null', `{% assign x = null %}`],
155
+ ['blank', `{% assign x = blank %}`],
156
+ ['empty', `{% assign x = empty %}`],
157
+ ['range', `{% assign x = (1..10) %}`],
158
+
159
+ // lookups
160
+ ['variable', `{% assign x = other %}`],
161
+ ['dot lookup', `{% assign x = other.prop %}`],
162
+ ['string index lookup', `{% assign x = other["prop"] %}`],
163
+ ['variable index lookup', `{% assign x = other[key] %}`],
164
+ ['deep dot chain', `{% assign x = a.b.c.d %}`],
165
+
166
+ // filters
167
+ ['single filter', `{% assign x = y | upcase %}`],
168
+ ['filter with arg', `{% assign x = y | default: 'z' %}`],
169
+ ['chained filters', `{% assign x = y | default: 'z' | upcase %}`],
170
+ ['filter with positional args', `{% assign x = y | append: "a" %}`],
171
+ ['filter with JSON array arg', `{% assign x = y | concat: [1,2] %}`],
172
+ ['filter with JSON hash arg', `{% assign x = y | merge: {a:1} %}`],
173
+ ['chained filter after JSON arg', `{% assign x = y | default: [] | join: "," %}`],
174
+
175
+ // JSON hash
176
+ ['empty hash', `{% assign x = {} %}`],
177
+ ['hash with string key', `{% assign x = { "a": 1 } %}`],
178
+ ['hash with bare key', `{% assign x = { a: 1 } %}`],
179
+ ['hash with multiple string keys', `{% assign x = { "a": 1, "b": 2 } %}`],
180
+ ['hash with mixed keys', `{% assign x = { "a": 1, b: 2 } %}`],
181
+ ['hash with nested hash', `{% assign x = { "a": { "nested": true } } %}`],
182
+ ['hash with nested array', `{% assign x = { "a": [1,2,3] } %}`],
183
+ [
184
+ 'hash with string-interpolated value',
185
+ `{% assign x = { "email": "{{ email | downcase }}" } %}`,
186
+ ],
187
+
188
+ // JSON array
189
+ ['empty array', `{% assign x = [] %}`],
190
+ ['array of numbers', `{% assign x = [1, 2, 3] %}`],
191
+ ['array of strings', `{% assign x = ["a", "b"] %}`],
192
+ ['array of literals', `{% assign x = [true, false, nil] %}`],
193
+ ['array of variables', `{% assign x = [a, b, c] %}`],
194
+ ['nested arrays', `{% assign x = [[1,2],[3,4]] %}`],
195
+ ['array of hashes', `{% assign x = [{"a":1}, {"b":2}] %}`],
196
+
197
+ // push syntax — only the bare form `a << value` is valid; `a = b << c`
198
+ // is a compound operator and is NOT accepted by the platformOS runtime.
199
+ ['bare push with string value', `{% assign my_val << "item" %}`],
200
+ ['bare push with variable value', `{% assign my_val << val %}`],
201
+ ['bare push with filter', `{% assign my_val << val | upcase %}`],
202
+
203
+ // whitespace-strip
204
+ ['both trim', `{%- assign x = 'v' -%}`],
205
+ ['both trim with hash', `{%- assign x = {} -%}`],
206
+ ['left trim with array', `{%- assign x = [] %}`],
207
+ ['right trim only', `{% assign x = 'v' -%}`],
208
+
209
+ // identifiers
210
+ ['underscore prefix', `{% assign _private = 1 %}`],
211
+ ['snake_case', `{% assign snake_case = 1 %}`],
212
+ ['hyphen in identifier', `{% assign my-var = "hello" %}`],
213
+ ['multiple hyphens', `{% assign my-complex-var-name = "test" %}`],
214
+ ['digit in middle', `{% assign x1 = 1 %}`],
215
+ ];
216
+
217
+ for (const [label, sourceCode] of validCases) {
218
+ it(`should not report: ${label} — ${sourceCode}`, async () => {
219
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
220
+ expect(offenses).toHaveLength(0);
221
+ });
222
+ }
223
+ });
224
+
225
+ describe('interaction with other sub-checks', () => {
226
+ it('should not fire when MultipleAssignValues already reports trailing garbage', async () => {
227
+ const sourceCode = `{% assign foo = '123' 555 text %}`;
228
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
229
+ const syntaxOffenses = offenses.filter((o) => o.message.includes('Invalid syntax for tag'));
230
+ expect(syntaxOffenses).toHaveLength(0);
231
+ const supportOffenses = offenses.filter((o) => o.message === 'Syntax is not supported');
232
+ expect(supportOffenses).toHaveLength(1);
233
+ });
234
+
235
+ it('should not fire on assign with invalid filter name', async () => {
236
+ const sourceCode = `{% assign x = "v" | upcase@ %}`;
237
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
238
+ const syntaxOffenses = offenses.filter((o) =>
239
+ o.message.includes("Invalid syntax for tag 'assign'"),
240
+ );
241
+ expect(syntaxOffenses).toHaveLength(0);
242
+ });
243
+
244
+ it('should not fire on assign with pipe syntax issue', async () => {
245
+ const sourceCode = `{% assign x = "v" || upcase %}`;
246
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
247
+ const syntaxOffenses = offenses.filter((o) =>
248
+ o.message.includes("Invalid syntax for tag 'assign'"),
249
+ );
250
+ expect(syntaxOffenses).toHaveLength(0);
251
+ });
252
+
253
+ it('should not fire for non-assign tags', async () => {
254
+ const sourceCode = `{% echo x %}`;
255
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
256
+ const syntaxOffenses = offenses.filter((o) =>
257
+ o.message.includes("Invalid syntax for tag 'assign'"),
258
+ );
259
+ expect(syntaxOffenses).toHaveLength(0);
260
+ });
261
+ });
262
+
263
+ describe('inside {% liquid %} blocks', () => {
264
+ it('should accept a simple assign statement', async () => {
265
+ const sourceCode = `{% liquid
266
+ assign x = 1
267
+ %}`;
268
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
269
+ expect(offenses).toHaveLength(0);
270
+ });
271
+
272
+ it('should accept a multi-line hash literal', async () => {
273
+ const sourceCode = `{% liquid
274
+ assign h = {
275
+ "a": 1,
276
+ "b": 2
277
+ }
278
+ %}`;
279
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
280
+ expect(offenses).toHaveLength(0);
281
+ });
282
+
283
+ it('should accept a multi-line array literal', async () => {
284
+ const sourceCode = `{% liquid
285
+ assign a = [
286
+ "a",
287
+ "b"
288
+ ]
289
+ %}`;
290
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
291
+ expect(offenses).toHaveLength(0);
292
+ });
293
+
294
+ it('should accept nested multi-line hash', async () => {
295
+ const sourceCode = `{% liquid
296
+ assign h = {
297
+ outer: {
298
+ inner: "v"
299
+ }
300
+ }
301
+ %}`;
302
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
303
+ expect(offenses).toHaveLength(0);
304
+ });
305
+
306
+ it('should not let an empty hash swallow the next statement', async () => {
307
+ const sourceCode = `{% liquid
308
+ assign h = {}
309
+ assign a = 'x'
310
+ %}`;
311
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
312
+ expect(offenses).toHaveLength(0);
313
+ });
314
+
315
+ it('should parse subsequent tag cleanly after array', async () => {
316
+ const sourceCode = `{% liquid
317
+ assign a = [1]
318
+ render 'p'
319
+ %}`;
320
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
321
+ expect(offenses).toHaveLength(0);
322
+ });
323
+
324
+ it('should report target-only assign', async () => {
325
+ const sourceCode = `{% liquid
326
+ assign x
327
+ %}`;
328
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
329
+ const syntaxOffenses = offenses.filter((o) =>
330
+ o.message.includes("Invalid syntax for tag 'assign'"),
331
+ );
332
+ expect(syntaxOffenses).toHaveLength(1);
333
+ });
334
+
335
+ it('should report assign with empty RHS', async () => {
336
+ const sourceCode = `{% liquid
337
+ assign x =
338
+ %}`;
339
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
340
+ const syntaxOffenses = offenses.filter((o) =>
341
+ o.message.includes("Invalid syntax for tag 'assign'"),
342
+ );
343
+ expect(syntaxOffenses).toHaveLength(1);
344
+ });
345
+
346
+ it('should report assign without `=`', async () => {
347
+ const sourceCode = `{% liquid
348
+ assign x "v"
349
+ %}`;
350
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
351
+ const syntaxOffenses = offenses.filter((o) =>
352
+ o.message.includes("Invalid syntax for tag 'assign'"),
353
+ );
354
+ expect(syntaxOffenses).toHaveLength(1);
355
+ });
356
+
357
+ it('should report garbage between hash entries as a parse error', async () => {
358
+ const sourceCode = `{% liquid
359
+ assign x = {
360
+ "a": 1
361
+ extra garbage
362
+ }
363
+ %}`;
364
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
365
+ expect(offenses.length).toBeGreaterThan(0);
366
+ });
367
+
368
+ it('should report an unclosed hash before `%}` in tolerant mode', async () => {
369
+ const sourceCode = `{% liquid
370
+ assign x = { "a": 1
371
+ %}`;
372
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
373
+ expect(offenses.length).toBeGreaterThan(0);
374
+ });
375
+ });
376
+
377
+ describe('whitespace-trimming delimiters', () => {
378
+ it('should report with trim delimiters', async () => {
379
+ const sourceCode = `{%- assign x "var" -%}`;
380
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
381
+ const syntaxOffenses = offenses.filter((o) =>
382
+ o.message.includes("Invalid syntax for tag 'assign'"),
383
+ );
384
+ expect(syntaxOffenses).toHaveLength(1);
385
+ });
386
+ });
387
+
388
+ // These assertions mirror scenarios from the platformOS runtime test suite
389
+ // (desksnearme/test/lib/liquify/tags/assign_tag_test.rb) to make sure our
390
+ // lint-level acceptance aligns with runtime acceptance for common patterns.
391
+ describe('runtime-alignment scenarios', () => {
392
+ it('accepts JSON literal as default filter argument (matches Liquify behavior)', async () => {
393
+ const sourceCode = `{% assign my_val = null | default: [] %}`;
394
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
395
+ expect(offenses).toHaveLength(0);
396
+ });
397
+
398
+ it('accepts empty hash as default filter argument', async () => {
399
+ const sourceCode = `{% assign my_hash = null | default: {} %}`;
400
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
401
+ expect(offenses).toHaveLength(0);
402
+ });
403
+
404
+ it('accepts hash_merge with two hash arguments', async () => {
405
+ const sourceCode = `{% assign merged = { "a": 1 } | hash_merge: h2 | hash_merge: h3 %}`;
406
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
407
+ expect(offenses).toHaveLength(0);
408
+ });
409
+
410
+ it('accepts JSON array with mixed primitive types', async () => {
411
+ const sourceCode = `{% assign arr = ["string", 42, true, null, { "nested": "object" }] %}`;
412
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
413
+ expect(offenses).toHaveLength(0);
414
+ });
415
+
416
+ it('accepts JSON hash with escaped double-quote in string value', async () => {
417
+ const sourceCode = `{% assign data = { "quote": "He said \\"hello\\"" } %}`;
418
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
419
+ expect(offenses).toHaveLength(0);
420
+ });
421
+
422
+ it('accepts escaped backslash in double-quoted string', async () => {
423
+ const sourceCode = `{% assign x = "path\\\\to\\\\file" %}`;
424
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
425
+ expect(offenses).toHaveLength(0);
426
+ });
427
+
428
+ it('accepts escaped single-quote in single-quoted string', async () => {
429
+ const sourceCode = `{% assign x = 'can\\'t' %}`;
430
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
431
+ expect(offenses).toHaveLength(0);
432
+ });
433
+
434
+ it('accepts deeply nested JSON structures', async () => {
435
+ const sourceCode = `{% assign data = {
436
+ "level1": {
437
+ "level2": {
438
+ "level3": {
439
+ "level4": {
440
+ "value": "deep",
441
+ "array": [1, 2, 3]
442
+ }
443
+ }
444
+ }
445
+ }
446
+ } %}`;
447
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
448
+ expect(offenses).toHaveLength(0);
449
+ });
450
+
451
+ it('accepts JSON array of hashes then map/join filters', async () => {
452
+ const sourceCode = `{% assign names = users | map: "name" | join: ", " %}`;
453
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
454
+ expect(offenses).toHaveLength(0);
455
+ });
456
+
457
+ it('accepts push syntax — `{% assign v << "x" %}`', async () => {
458
+ const sourceCode = `{% assign my_val << "item" %}`;
459
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
460
+ expect(offenses).toHaveLength(0);
461
+ });
462
+
463
+ it('rejects compound operator — `{% assign a = b << c %}` is invalid', async () => {
464
+ // Per platformOS runtime: an assign uses exactly one of `=` or `<<`, never both.
465
+ // Our parser falls back to base case; check should flag via MAV or IAS.
466
+ const sourceCode = `{% assign a = b << c %}`;
467
+ const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
468
+ expect(offenses.length).toBeGreaterThan(0);
469
+ });
470
+ });
471
+ });
@@ -0,0 +1,97 @@
1
+ import { LiquidTag, toLiquidAST } from '@platformos/liquid-html-parser';
2
+ import { Problem, SourceCodeType } from '../../..';
3
+
4
+ const INVALID_ASSIGN_MESSAGE = `Invalid syntax for tag 'assign'. Expected syntax: {% assign <var> = <value> %}`;
5
+
6
+ /**
7
+ * Detects structurally-invalid `assign` tags that neither `MultipleAssignValues` nor
8
+ * `InvalidPipeSyntax`/`InvalidFilterName` catch:
9
+ *
10
+ * {% assign %} — empty markup
11
+ * {% assign x %} — target only, no operator
12
+ * {% assign x "var" %} — missing `=`
13
+ * {% assign = 'v' %} — missing target
14
+ * {% assign x = %} — empty RHS
15
+ * {% assign 'str' = 'v' %} — target is a literal
16
+ * {% assign x := 'v' %} — operator is not `=`
17
+ *
18
+ * When the strict grammar rule `liquidTagAssignMarkup` fails, the parser falls back to
19
+ * the base case and stores markup as a raw string. That string almost always still
20
+ * contains `=`-plus-RHS with a filter or pipe issue (handled elsewhere). This check
21
+ * targets the cases where the `target = value` skeleton itself is broken, so it
22
+ * complements rather than duplicates the other sub-checks.
23
+ */
24
+ export function detectInvalidAssignSyntax(
25
+ node: LiquidTag,
26
+ ): Problem<SourceCodeType.LiquidHtml> | undefined {
27
+ if (node.name !== 'assign') return;
28
+ if (typeof node.markup !== 'string') return;
29
+
30
+ const markup = node.markup.trim();
31
+
32
+ const eqIndex = markup.indexOf('=');
33
+ const hasEquals = eqIndex !== -1;
34
+ const lhs = hasEquals ? markup.slice(0, eqIndex).trim() : markup;
35
+ const rhs = hasEquals ? markup.slice(eqIndex + 1).trim() : '';
36
+
37
+ const isStructurallyBroken =
38
+ markup === '' || !hasEquals || lhs === '' || rhs === '' || !isValidAssignTarget(lhs);
39
+
40
+ if (!isStructurallyBroken) return;
41
+
42
+ return {
43
+ message: INVALID_ASSIGN_MESSAGE,
44
+ startIndex: node.position.start,
45
+ endIndex: node.position.end,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Fallback for assign tags where the tolerant parser landed in string markup even
51
+ * though the `target = value` skeleton looks fine — meaning the value or filter
52
+ * chain has parse-breaking characters (e.g. a stray `}` before `%}`) that no other
53
+ * dedicated sub-check (MultipleAssignValues, InvalidFilterName, InvalidPipeSyntax)
54
+ * surfaced. Re-parses the tag source in strict mode and reports on failure.
55
+ *
56
+ * Must run ONLY when no other sub-check already reported on this tag, otherwise
57
+ * it double-flags the same problem. The orchestrator enforces that gate.
58
+ */
59
+ export function detectInvalidAssignFallback(
60
+ node: LiquidTag,
61
+ ): Problem<SourceCodeType.LiquidHtml> | undefined {
62
+ if (node.name !== 'assign' || typeof node.markup !== 'string') return;
63
+
64
+ // Digit-starting targets (e.g. `23_hours_ago`) are accepted by the platformOS
65
+ // runtime but rejected by the Ohm grammar's `variableSegment` rule. Skipping
66
+ // them here mirrors the intentional tolerance in isValidAssignTarget above.
67
+ if (/^\s*\d/.test(node.markup)) return;
68
+
69
+ const tagSource = node.source.slice(node.position.start, node.position.end);
70
+ try {
71
+ toLiquidAST(tagSource, { mode: 'strict', allowUnclosedDocumentNode: true });
72
+ } catch {
73
+ return {
74
+ message: INVALID_ASSIGN_MESSAGE,
75
+ startIndex: node.position.start,
76
+ endIndex: node.position.end,
77
+ };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Rejects an LHS that is obviously not an assign target.
83
+ *
84
+ * NOTE: the parser's `variableSegment` rule is stricter than the platformOS runtime —
85
+ * Liquify (see assign_tag_test.rb "allow variable names to start with digit") accepts
86
+ * `23_hours_ago` as a valid name, but our grammar requires `(letter | "_")` at the
87
+ * start and falls back to the base case for digit-starting names. To avoid
88
+ * false-positive lint errors on code that runs fine, this shape check only rejects
89
+ * LHS forms that are never valid: literal delimiters at the start (`'`, `"`, `[`, `{`)
90
+ * and stray operator characters (`:` or a second `=`) that indicate the operator
91
+ * itself is malformed (e.g. `:=`).
92
+ */
93
+ function isValidAssignTarget(lhs: string): boolean {
94
+ if (/^['"[{]/.test(lhs)) return false;
95
+ if (/[:=]/.test(lhs)) return false;
96
+ return true;
97
+ }
@@ -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
+ });