@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,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,10 @@
|
|
|
1
1
|
import { Severity, SourceCodeType, LiquidCheckDefinition, Problem } from '../../types';
|
|
2
2
|
import { getOffset, isError } from '../../utils';
|
|
3
3
|
import { detectMultipleAssignValues } from './checks/MultipleAssignValues';
|
|
4
|
+
import {
|
|
5
|
+
detectInvalidAssignSyntax,
|
|
6
|
+
detectInvalidAssignFallback,
|
|
7
|
+
} from './checks/InvalidAssignSyntax';
|
|
4
8
|
import { detectInvalidBooleanExpressions } from './checks/InvalidBooleanExpressions';
|
|
5
9
|
import { detectInvalidEchoValue } from './checks/InvalidEchoValue';
|
|
6
10
|
import { detectInvalidConditionalNode } from './checks/InvalidConditionalNode';
|
|
@@ -11,6 +15,7 @@ import { detectInvalidFilterName } from './checks/InvalidFilterName';
|
|
|
11
15
|
import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
|
|
12
16
|
import { detectUnknownTag } from './checks/UnknownTag';
|
|
13
17
|
import { detectInvalidTagSyntax } from './checks/InvalidTagSyntax';
|
|
18
|
+
import { detectInvalidOutputPush } from './checks/InvalidOutputPush';
|
|
14
19
|
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
|
|
15
20
|
|
|
16
21
|
type LineColPosition = {
|
|
@@ -75,9 +80,18 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
// Push-operator misuse in `{% echo … << … %}` — dedicated message.
|
|
84
|
+
// Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
|
|
85
|
+
const outputPushProblem = detectInvalidOutputPush(node);
|
|
86
|
+
if (outputPushProblem) {
|
|
87
|
+
context.report(outputPushProblem);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
// Run specific sub-checks first — they provide better error messages and autofixes.
|
|
79
92
|
const problems = [
|
|
80
93
|
detectMultipleAssignValues(node),
|
|
94
|
+
detectInvalidAssignSyntax(node),
|
|
81
95
|
detectInvalidEchoValue(node),
|
|
82
96
|
detectInvalidLoopRange(node),
|
|
83
97
|
detectInvalidLoopArguments(node, tags),
|
|
@@ -112,6 +126,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
112
126
|
if (pipeProblems.length > 0) {
|
|
113
127
|
pipeProblems.forEach((pipeProblem) => context.report(pipeProblem));
|
|
114
128
|
}
|
|
129
|
+
|
|
130
|
+
// Last-chance check for assign tags whose tolerant parse fell back to
|
|
131
|
+
// string markup (e.g. stray `}` before `%}`). Gated on "nothing else
|
|
132
|
+
// reported on this tag" to avoid double-flagging.
|
|
133
|
+
if (problems.length + filterProblems.length + pipeProblems.length === 0) {
|
|
134
|
+
const fallback = detectInvalidAssignFallback(node);
|
|
135
|
+
if (fallback) context.report(fallback);
|
|
136
|
+
}
|
|
115
137
|
},
|
|
116
138
|
|
|
117
139
|
async LiquidBranch(node, ancestors) {
|
|
@@ -129,6 +151,14 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
129
151
|
async LiquidVariableOutput(node, ancestors) {
|
|
130
152
|
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
131
153
|
|
|
154
|
+
// Push-operator misuse in `{{ … << … }}` — dedicated message.
|
|
155
|
+
// Short-circuit so InvalidEchoValue doesn't duplicate with a generic message.
|
|
156
|
+
const outputPushProblem = detectInvalidOutputPush(node);
|
|
157
|
+
if (outputPushProblem) {
|
|
158
|
+
context.report(outputPushProblem);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
|
|
133
163
|
if (filterProblems.length > 0) {
|
|
134
164
|
filterProblems.forEach((problem) => context.report(problem));
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractUndefinedVariables } from './extract-undefined-variables';
|
|
3
|
+
|
|
4
|
+
describe('extractUndefinedVariables', () => {
|
|
5
|
+
it('should return variables used but not defined', () => {
|
|
6
|
+
const source = `{% liquid
|
|
7
|
+
assign b = a
|
|
8
|
+
%}
|
|
9
|
+
{{ b }}`;
|
|
10
|
+
const result = extractUndefinedVariables(source);
|
|
11
|
+
expect(result.required).to.deep.equal(['a']);
|
|
12
|
+
expect(result.optional).to.deep.equal([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should not include assigned variables', () => {
|
|
16
|
+
const source = `{% assign x = 1 %}{{ x }}`;
|
|
17
|
+
const result = extractUndefinedVariables(source);
|
|
18
|
+
expect(result.required).to.deep.equal([]);
|
|
19
|
+
expect(result.optional).to.deep.equal([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should not include captured variables', () => {
|
|
23
|
+
const source = `{% capture x %}hello{% endcapture %}{{ x }}`;
|
|
24
|
+
const result = extractUndefinedVariables(source);
|
|
25
|
+
expect(result.required).to.deep.equal([]);
|
|
26
|
+
expect(result.optional).to.deep.equal([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should not include for loop variables', () => {
|
|
30
|
+
const source = `{% for item in items %}{{ item }}{% endfor %}`;
|
|
31
|
+
const result = extractUndefinedVariables(source);
|
|
32
|
+
expect(result.required).to.deep.equal(['items']);
|
|
33
|
+
expect(result.optional).to.deep.equal([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should not include forloop variable', () => {
|
|
37
|
+
const source = `{% for item in items %}{{ forloop.index }}{% endfor %}`;
|
|
38
|
+
const result = extractUndefinedVariables(source);
|
|
39
|
+
expect(result.required).to.deep.equal(['items']);
|
|
40
|
+
expect(result.optional).to.deep.equal([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle function result variables', () => {
|
|
44
|
+
const source = `{% function res = 'my_partial' %}{{ res }}`;
|
|
45
|
+
const result = extractUndefinedVariables(source);
|
|
46
|
+
expect(result.required).to.deep.equal([]);
|
|
47
|
+
expect(result.optional).to.deep.equal([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle graphql result variables', () => {
|
|
51
|
+
const source = `{% graphql res = 'my_query' %}{{ res }}`;
|
|
52
|
+
const result = extractUndefinedVariables(source);
|
|
53
|
+
expect(result.required).to.deep.equal([]);
|
|
54
|
+
expect(result.optional).to.deep.equal([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should handle inline graphql result variables', () => {
|
|
58
|
+
const source = `{% graphql res %}{ users { id } }{% endgraphql %}{{ res }}`;
|
|
59
|
+
const result = extractUndefinedVariables(source);
|
|
60
|
+
expect(result.required).to.deep.equal([]);
|
|
61
|
+
expect(result.optional).to.deep.equal([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should handle parse_json result variables', () => {
|
|
65
|
+
const source = `{% parse_json data %}{"a":1}{% endparse_json %}{{ data }}`;
|
|
66
|
+
const result = extractUndefinedVariables(source);
|
|
67
|
+
expect(result.required).to.deep.equal([]);
|
|
68
|
+
expect(result.optional).to.deep.equal([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should not include global objects', () => {
|
|
72
|
+
const source = `{{ context.session }}`;
|
|
73
|
+
const result = extractUndefinedVariables(source, [
|
|
74
|
+
'context',
|
|
75
|
+
'null',
|
|
76
|
+
'true',
|
|
77
|
+
'false',
|
|
78
|
+
'blank',
|
|
79
|
+
'empty',
|
|
80
|
+
]);
|
|
81
|
+
expect(result.required).to.deep.equal([]);
|
|
82
|
+
expect(result.optional).to.deep.equal([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should deduplicate results', () => {
|
|
86
|
+
const source = `{{ a }}{{ a }}`;
|
|
87
|
+
const result = extractUndefinedVariables(source);
|
|
88
|
+
expect(result.required).to.deep.equal(['a']);
|
|
89
|
+
expect(result.optional).to.deep.equal([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should return empty arrays if source fails to parse', () => {
|
|
93
|
+
const source = `{% invalid unclosed`;
|
|
94
|
+
const result = extractUndefinedVariables(source);
|
|
95
|
+
expect(result.required).to.deep.equal([]);
|
|
96
|
+
expect(result.optional).to.deep.equal([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle increment/decrement as definitions', () => {
|
|
100
|
+
const source = `{% increment counter %}{{ counter }}`;
|
|
101
|
+
const result = extractUndefinedVariables(source);
|
|
102
|
+
expect(result.required).to.deep.equal([]);
|
|
103
|
+
expect(result.optional).to.deep.equal([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle background file-based result variables', () => {
|
|
107
|
+
const source = `{% background my_job = 'some_partial' %}{{ my_job }}`;
|
|
108
|
+
const result = extractUndefinedVariables(source);
|
|
109
|
+
expect(result.required).to.deep.equal([]);
|
|
110
|
+
expect(result.optional).to.deep.equal([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle inline background tag without job_id', () => {
|
|
114
|
+
const source = `{% background source_name: 'my_task' %}echo "hello"{% endbackground %}{{ my_job }}`;
|
|
115
|
+
const result = extractUndefinedVariables(source);
|
|
116
|
+
expect(result.required).to.deep.equal(['my_job']);
|
|
117
|
+
expect(result.optional).to.deep.equal([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should not include doc param names', () => {
|
|
121
|
+
const source = `
|
|
122
|
+
{% doc %}
|
|
123
|
+
@param {String} name - a name
|
|
124
|
+
{% enddoc %}
|
|
125
|
+
{{ name }}
|
|
126
|
+
`;
|
|
127
|
+
const result = extractUndefinedVariables(source);
|
|
128
|
+
expect(result.required).to.deep.equal(['name']);
|
|
129
|
+
expect(result.optional).to.deep.equal([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// | default filter detection
|
|
133
|
+
|
|
134
|
+
it('should treat assign x = x | default: val as optional', () => {
|
|
135
|
+
const source = `{% assign message = message | default: null %}{{ message }}`;
|
|
136
|
+
const result = extractUndefinedVariables(source);
|
|
137
|
+
expect(result.required).to.deep.equal([]);
|
|
138
|
+
expect(result.optional).to.deep.equal(['message']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should treat inline output with | default as optional', () => {
|
|
142
|
+
const source = `{{ message | default: 'fallback' }}`;
|
|
143
|
+
const result = extractUndefinedVariables(source);
|
|
144
|
+
expect(result.required).to.deep.equal([]);
|
|
145
|
+
expect(result.optional).to.deep.equal(['message']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should treat only the defaulted variable as optional, not others', () => {
|
|
149
|
+
const source = `
|
|
150
|
+
{% liquid
|
|
151
|
+
assign message = message | default: null
|
|
152
|
+
assign name = name
|
|
153
|
+
%}
|
|
154
|
+
{{ message }}{{ name }}
|
|
155
|
+
`;
|
|
156
|
+
const result = extractUndefinedVariables(source);
|
|
157
|
+
expect(result.required).to.deep.equal(['name']);
|
|
158
|
+
expect(result.optional).to.deep.equal(['message']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should treat x as optional when assign y = x | default: val (different lhs/rhs)', () => {
|
|
162
|
+
// x is the optional input; y is the local alias defined by the assign
|
|
163
|
+
const source = `{% assign label = title | default: 'Untitled' %}{{ label }}`;
|
|
164
|
+
const result = extractUndefinedVariables(source);
|
|
165
|
+
expect(result.required).to.deep.equal([]);
|
|
166
|
+
expect(result.optional).to.deep.equal(['title']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should treat variable as optional when default filter is not first in the chain', () => {
|
|
170
|
+
const source = `{% assign x = x | strip | default: '' %}{{ x }}`;
|
|
171
|
+
const result = extractUndefinedVariables(source);
|
|
172
|
+
expect(result.required).to.deep.equal([]);
|
|
173
|
+
expect(result.optional).to.deep.equal(['x']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should not classify a variable as optional when default is applied to a literal, not the variable', () => {
|
|
177
|
+
// default is applied to the result of upcase, not directly to the VariableLookup
|
|
178
|
+
// The parent of `x` lookup here is LiquidVariable; default is still in filters — optional
|
|
179
|
+
// This is the same as any other filter chain: x | upcase | default: '' → x is optional
|
|
180
|
+
const source = `{% assign x = x | upcase | default: '' %}{{ x }}`;
|
|
181
|
+
const result = extractUndefinedVariables(source);
|
|
182
|
+
expect(result.required).to.deep.equal([]);
|
|
183
|
+
expect(result.optional).to.deep.equal(['x']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should not treat a variable as optional when it has no default filter', () => {
|
|
187
|
+
const source = `{% assign x = x | upcase %}{{ x }}`;
|
|
188
|
+
const result = extractUndefinedVariables(source);
|
|
189
|
+
expect(result.required).to.deep.equal(['x']);
|
|
190
|
+
expect(result.optional).to.deep.equal([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should not classify a defined variable as optional even when | default is used later', () => {
|
|
194
|
+
// x is defined by the assign, so the later {{ x | default: '' }} does not make x an input
|
|
195
|
+
const source = `{% assign x = 'hello' %}{{ x | default: 'fallback' }}`;
|
|
196
|
+
const result = extractUndefinedVariables(source);
|
|
197
|
+
expect(result.required).to.deep.equal([]);
|
|
198
|
+
expect(result.optional).to.deep.equal([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle multiple optional params from the real register_error pattern', () => {
|
|
202
|
+
const source = `
|
|
203
|
+
{% liquid
|
|
204
|
+
assign key = key | default: null
|
|
205
|
+
assign message = message | default: null
|
|
206
|
+
assign errors = contract.errors
|
|
207
|
+
assign field_errors = errors[field_name] | default: blank
|
|
208
|
+
assign field_errors << message
|
|
209
|
+
assign errors[field_name] = field_errors
|
|
210
|
+
assign contract.valid = false
|
|
211
|
+
return contract
|
|
212
|
+
%}
|
|
213
|
+
`;
|
|
214
|
+
const result = extractUndefinedVariables(source);
|
|
215
|
+
expect(result.required).to.deep.equal(['contract', 'field_name']);
|
|
216
|
+
expect(result.optional).to.deep.equal(['key', 'message']);
|
|
217
|
+
});
|
|
218
|
+
});
|
package/src/checks/{metadata-params → partial-call-arguments}/extract-undefined-variables.ts
RENAMED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
LiquidTagIncrement,
|
|
9
9
|
LiquidTagTablerow,
|
|
10
10
|
LiquidVariableLookup,
|
|
11
|
+
LiquidVariable,
|
|
11
12
|
NamedTags,
|
|
12
13
|
NodeTypes,
|
|
13
14
|
Position,
|
|
@@ -25,7 +26,10 @@ type Scope = { start?: number; end?: number };
|
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Parses a Liquid source string and returns a deduplicated list of variable names
|
|
28
|
-
* that are used but never defined. Returns [] on parse errors.
|
|
29
|
+
* that are used but never defined. Returns `{ required: [], optional: [] }` on parse errors.
|
|
30
|
+
*
|
|
31
|
+
* Variables used exclusively with `| default` filter (e.g. `assign x = x | default: val`)
|
|
32
|
+
* are returned in `optional` — the partial handles the missing-argument case itself.
|
|
29
33
|
*
|
|
30
34
|
* This mirrors the variable tracking logic from the UndefinedObject check but
|
|
31
35
|
* packaged as a standalone synchronous function.
|
|
@@ -33,17 +37,18 @@ type Scope = { start?: number; end?: number };
|
|
|
33
37
|
export function extractUndefinedVariables(
|
|
34
38
|
source: string,
|
|
35
39
|
globalObjectNames: string[] = [],
|
|
36
|
-
): string[] {
|
|
40
|
+
): { required: string[]; optional: string[] } {
|
|
37
41
|
let ast;
|
|
38
42
|
try {
|
|
39
43
|
ast = toLiquidHtmlAST(source);
|
|
40
44
|
} catch {
|
|
41
|
-
return [];
|
|
45
|
+
return { required: [], optional: [] };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const scopedVariables: Map<string, Scope[]> = new Map();
|
|
45
49
|
const fileScopedVariables: Set<string> = new Set(globalObjectNames);
|
|
46
50
|
const variables: LiquidVariableLookup[] = [];
|
|
51
|
+
const variablesWithDefault: Set<string> = new Set();
|
|
47
52
|
|
|
48
53
|
function indexVariableScope(variableName: string | null, scope: Scope) {
|
|
49
54
|
if (!variableName) return;
|
|
@@ -166,13 +171,25 @@ export function extractUndefinedVariables(
|
|
|
166
171
|
if (isHashAssignMarkup(parent) && parent.target === node) return;
|
|
167
172
|
|
|
168
173
|
variables.push(node);
|
|
174
|
+
|
|
175
|
+
// Detect `x | default: ...` — the variable is the expression of a LiquidVariable
|
|
176
|
+
// that has a `default` filter, meaning the partial handles the missing case itself.
|
|
177
|
+
if (
|
|
178
|
+
node.name &&
|
|
179
|
+
isLiquidVariable(parent) &&
|
|
180
|
+
parent.expression === node &&
|
|
181
|
+
parent.filters.some((f) => f.name === 'default')
|
|
182
|
+
) {
|
|
183
|
+
variablesWithDefault.add(node.name);
|
|
184
|
+
}
|
|
169
185
|
}
|
|
170
186
|
|
|
171
187
|
walk(ast, []);
|
|
172
188
|
|
|
173
189
|
// Determine undefined variables
|
|
174
190
|
const seen = new Set<string>();
|
|
175
|
-
const
|
|
191
|
+
const required: string[] = [];
|
|
192
|
+
const optional: string[] = [];
|
|
176
193
|
|
|
177
194
|
for (const variable of variables) {
|
|
178
195
|
if (!variable.name) continue;
|
|
@@ -187,11 +204,15 @@ export function extractUndefinedVariables(
|
|
|
187
204
|
|
|
188
205
|
if (!isVariableDefined) {
|
|
189
206
|
seen.add(variable.name);
|
|
190
|
-
|
|
207
|
+
if (variablesWithDefault.has(variable.name)) {
|
|
208
|
+
optional.push(variable.name);
|
|
209
|
+
} else {
|
|
210
|
+
required.push(variable.name);
|
|
211
|
+
}
|
|
191
212
|
}
|
|
192
213
|
}
|
|
193
214
|
|
|
194
|
-
return
|
|
215
|
+
return { required, optional };
|
|
195
216
|
}
|
|
196
217
|
|
|
197
218
|
function isNode(x: any): x is LiquidHtmlNode {
|
|
@@ -284,3 +305,7 @@ function isLiquidBranchCatch(
|
|
|
284
305
|
): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } {
|
|
285
306
|
return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch;
|
|
286
307
|
}
|
|
308
|
+
|
|
309
|
+
function isLiquidVariable(node?: LiquidHtmlNode): node is LiquidVariable {
|
|
310
|
+
return node?.type === NodeTypes.LiquidVariable;
|
|
311
|
+
}
|