@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.
- package/CHANGELOG.md +16 -0
- package/dist/checks/index.d.ts +1 -1
- package/dist/checks/index.js +6 -2
- package/dist/checks/index.js.map +1 -1
- package/dist/checks/json-literal-quote-style/index.d.ts +2 -0
- package/dist/checks/json-literal-quote-style/index.js +42 -0
- package/dist/checks/json-literal-quote-style/index.js.map +1 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.d.ts +32 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js +93 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js.map +1 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.d.ts +17 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js +37 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js.map +1 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js +1 -1
- package/dist/checks/liquid-html-syntax-error/index.js +25 -0
- package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
- package/dist/checks/partial-call-arguments/extract-undefined-variables.d.ts +14 -0
- package/dist/checks/partial-call-arguments/extract-undefined-variables.js +234 -0
- package/dist/checks/partial-call-arguments/extract-undefined-variables.js.map +1 -0
- package/dist/checks/partial-call-arguments/index.d.ts +2 -0
- package/dist/checks/partial-call-arguments/index.js +117 -0
- package/dist/checks/partial-call-arguments/index.js.map +1 -0
- package/dist/checks/unused-assign/index.js +17 -0
- package/dist/checks/unused-assign/index.js.map +1 -1
- package/dist/checks/valid-frontmatter/index.d.ts +2 -0
- package/dist/checks/valid-frontmatter/index.js +279 -0
- package/dist/checks/valid-frontmatter/index.js.map +1 -0
- package/dist/frontmatter/index.d.ts +1 -59
- package/dist/frontmatter/index.js +6 -298
- package/dist/frontmatter/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/checks/index.ts +6 -2
- package/src/checks/json-literal-quote-style/index.spec.ts +129 -0
- package/src/checks/json-literal-quote-style/index.ts +45 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.spec.ts +471 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.ts +97 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.spec.ts +104 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.ts +39 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +86 -2
- package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +1 -1
- package/src/checks/liquid-html-syntax-error/index.ts +30 -0
- package/src/checks/partial-call-arguments/extract-undefined-variables.spec.ts +218 -0
- package/src/checks/{metadata-params → partial-call-arguments}/extract-undefined-variables.ts +31 -6
- package/src/checks/partial-call-arguments/index.spec.ts +436 -0
- package/src/checks/{metadata-params → partial-call-arguments}/index.ts +18 -11
- package/src/checks/undefined-object/index.spec.ts +101 -0
- package/src/checks/unused-assign/index.spec.ts +48 -0
- package/src/checks/unused-assign/index.ts +15 -0
- package/src/checks/valid-frontmatter/index.spec.ts +666 -0
- package/src/checks/valid-frontmatter/index.ts +344 -0
- package/src/frontmatter/index.ts +9 -344
- package/src/checks/metadata-params/extract-undefined-variables.spec.ts +0 -115
- 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
|
+
});
|