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