@platformos/platformos-check-common 0.0.11 → 0.0.12
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 +8 -0
- package/CLAUDE.md +150 -0
- package/dist/AugmentedPlatformOSDocset.js +1 -0
- package/dist/AugmentedPlatformOSDocset.js.map +1 -1
- package/dist/checks/deprecated-filter/index.js +15 -0
- package/dist/checks/deprecated-filter/index.js.map +1 -1
- package/dist/checks/duplicate-content-for-arguments/index.js +1 -1
- package/dist/checks/duplicate-content-for-arguments/index.js.map +1 -1
- package/dist/checks/graphql/index.d.ts +1 -0
- package/dist/checks/graphql/index.js +20 -7
- package/dist/checks/graphql/index.js.map +1 -1
- package/dist/checks/invalid-hash-assign-target/index.js +4 -3
- package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
- package/dist/checks/missing-content-for-arguments/index.js +1 -1
- package/dist/checks/missing-content-for-arguments/index.js.map +1 -1
- package/dist/checks/pagination-size/index.js +1 -1
- package/dist/checks/pagination-size/index.js.map +1 -1
- package/dist/checks/undefined-object/index.js +14 -13
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/checks/unknown-property/index.js +75 -10
- package/dist/checks/unknown-property/index.js.map +1 -1
- package/dist/checks/unknown-property/property-shape.js +14 -1
- package/dist/checks/unknown-property/property-shape.js.map +1 -1
- package/dist/checks/unrecognized-content-for-arguments/index.js +1 -1
- package/dist/checks/unrecognized-content-for-arguments/index.js.map +1 -1
- package/dist/checks/unused-assign/index.js +23 -1
- package/dist/checks/unused-assign/index.js.map +1 -1
- package/dist/checks/valid-content-for-argument-types/index.js +1 -1
- package/dist/checks/valid-content-for-argument-types/index.js.map +1 -1
- package/dist/checks/variable-name/index.js +4 -0
- package/dist/checks/variable-name/index.js.map +1 -1
- package/dist/doc-generator/DocBlockGenerator.d.ts +16 -0
- package/dist/doc-generator/DocBlockGenerator.js +464 -0
- package/dist/doc-generator/DocBlockGenerator.js.map +1 -0
- package/dist/doc-generator/index.d.ts +1 -0
- package/dist/doc-generator/index.js +6 -0
- package/dist/doc-generator/index.js.map +1 -0
- package/dist/frontmatter/index.d.ts +59 -0
- package/dist/frontmatter/index.js +301 -0
- package/dist/frontmatter/index.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/arguments.js +5 -0
- package/dist/liquid-doc/arguments.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.js +3 -1
- package/dist/path.js.map +1 -1
- package/dist/to-schema.d.ts +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/block.js.map +1 -1
- package/package.json +2 -2
- package/src/AugmentedPlatformOSDocset.ts +1 -0
- package/src/checks/deprecated-filter/index.spec.ts +41 -1
- package/src/checks/deprecated-filter/index.ts +17 -0
- package/src/checks/graphql/index.spec.ts +173 -0
- package/src/checks/graphql/index.ts +21 -10
- package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
- package/src/checks/invalid-hash-assign-target/index.ts +6 -4
- package/src/checks/undefined-object/index.spec.ts +123 -19
- package/src/checks/undefined-object/index.ts +16 -18
- package/src/checks/unknown-property/index.spec.ts +133 -0
- package/src/checks/unknown-property/index.ts +84 -10
- package/src/checks/unknown-property/property-shape.ts +15 -1
- package/src/checks/unused-assign/index.spec.ts +74 -0
- package/src/checks/unused-assign/index.ts +26 -1
- package/src/checks/variable-name/index.spec.ts +9 -0
- package/src/checks/variable-name/index.ts +5 -0
- package/src/frontmatter/index.ts +344 -0
- package/src/index.ts +3 -0
- package/src/liquid-doc/arguments.ts +3 -0
- package/src/path.ts +2 -0
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
LiquidTagGraphQL,
|
|
19
19
|
LiquidTagParseJson,
|
|
20
20
|
LiquidTagBackground,
|
|
21
|
-
|
|
21
|
+
BackgroundMarkup,
|
|
22
22
|
} from '@platformos/liquid-html-parser';
|
|
23
23
|
import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types';
|
|
24
24
|
import { isError, last } from '../../utils';
|
|
@@ -108,9 +108,13 @@ export const UndefinedObject: LiquidCheckDefinition = {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
if (node.name === 'function') {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
const fnName = (node.markup as FunctionMarkup).name;
|
|
112
|
+
// Only register simple variable names (not hash/array mutations like hash['key'])
|
|
113
|
+
if (fnName.lookups.length === 0 && fnName.name !== null) {
|
|
114
|
+
indexVariableScope(fnName.name, {
|
|
115
|
+
start: node.position.end,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
if (node.name === 'layout') {
|
|
@@ -148,8 +152,8 @@ export const UndefinedObject: LiquidCheckDefinition = {
|
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
if (isLiquidTagBackground(node)) {
|
|
151
|
-
indexVariableScope(node.markup.jobId
|
|
152
|
-
start: node.
|
|
155
|
+
indexVariableScope(node.markup.jobId, {
|
|
156
|
+
start: node.position.end,
|
|
153
157
|
});
|
|
154
158
|
}
|
|
155
159
|
},
|
|
@@ -160,9 +164,8 @@ export const UndefinedObject: LiquidCheckDefinition = {
|
|
|
160
164
|
const parent = last(ancestors);
|
|
161
165
|
if (isLiquidTag(parent) && isLiquidTagCapture(parent)) return;
|
|
162
166
|
if (isLiquidTag(parent) && isLiquidTagParseJson(parent)) return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (isBackgroundInlineMarkup(parent) && parent.jobId === node) return;
|
|
167
|
+
// Skip the result variable of function tags (it's a definition, not a usage)
|
|
168
|
+
if (isFunctionMarkup(parent) && parent.name === node) return;
|
|
166
169
|
|
|
167
170
|
variables.push(node);
|
|
168
171
|
},
|
|
@@ -299,19 +302,14 @@ function isLiquidTagDecrement(node: LiquidTag): node is LiquidTagDecrement {
|
|
|
299
302
|
|
|
300
303
|
function isLiquidTagBackground(
|
|
301
304
|
node: LiquidTag,
|
|
302
|
-
): node is LiquidTagBackground & { markup:
|
|
305
|
+
): node is LiquidTagBackground & { markup: BackgroundMarkup } {
|
|
303
306
|
return (
|
|
304
307
|
node.name === NamedTags.background &&
|
|
305
308
|
typeof node.markup !== 'string' &&
|
|
306
|
-
node.markup.type === NodeTypes.
|
|
309
|
+
node.markup.type === NodeTypes.BackgroundMarkup
|
|
307
310
|
);
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
function
|
|
311
|
-
return
|
|
312
|
-
typeof node === 'object' &&
|
|
313
|
-
node !== null &&
|
|
314
|
-
'type' in node &&
|
|
315
|
-
node.type === NodeTypes.BackgroundInlineMarkup
|
|
316
|
-
);
|
|
313
|
+
function isFunctionMarkup(node?: LiquidHtmlNode): node is FunctionMarkup {
|
|
314
|
+
return node?.type === NodeTypes.FunctionMarkup;
|
|
317
315
|
}
|
|
@@ -212,6 +212,139 @@ query {
|
|
|
212
212
|
// This is expected behavior without schema - we can't know users is an array
|
|
213
213
|
expect(offenses).toHaveLength(1);
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
it('should not report an offense for result.errors (GraphQL responses always have errors)', async () => {
|
|
217
|
+
const sourceCode = `{% graphql r %}
|
|
218
|
+
query {
|
|
219
|
+
user {
|
|
220
|
+
id
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
{% endgraphql %}
|
|
224
|
+
{% if r.errors %}
|
|
225
|
+
{{ r.errors }}
|
|
226
|
+
{% endif %}`;
|
|
227
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
228
|
+
expect(offenses).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should not report file-based graphql result.errors', async () => {
|
|
232
|
+
const sourceCode = `{% graphql r = 'my_query' %}
|
|
233
|
+
{% if r.errors %}{{ r.errors }}{% endif %}`;
|
|
234
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
235
|
+
expect(offenses).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('graphql errors field', () => {
|
|
240
|
+
it('should not report r.errors on graphql results (protocol-level field)', async () => {
|
|
241
|
+
const sourceCode = `{% graphql r %}
|
|
242
|
+
query {
|
|
243
|
+
user {
|
|
244
|
+
id
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
{% endgraphql %}
|
|
248
|
+
{% if r.errors %}error{% endif %}`;
|
|
249
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
250
|
+
expect(offenses).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should not report errors on mutation results without errors in selection set', async () => {
|
|
254
|
+
const sourceCode = `{% graphql r %}
|
|
255
|
+
mutation ($id: ID!) {
|
|
256
|
+
user: user_delete(id: $id) {
|
|
257
|
+
id
|
|
258
|
+
email
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
{% endgraphql %}
|
|
262
|
+
{% unless r.errors %}ok{% endunless %}`;
|
|
263
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
264
|
+
expect(offenses).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should still report genuinely unknown properties on graphql results', async () => {
|
|
268
|
+
const sourceCode = `{% graphql r %}
|
|
269
|
+
query {
|
|
270
|
+
user {
|
|
271
|
+
id
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
{% endgraphql %}
|
|
275
|
+
{{ r.user.bogus }}`;
|
|
276
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
277
|
+
expect(offenses).toHaveLength(1);
|
|
278
|
+
expect(offenses[0].message).toContain("Unknown property 'bogus'");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('dig filter shape tracking', () => {
|
|
283
|
+
it('should infer shape after dig on a parse_json variable', async () => {
|
|
284
|
+
const sourceCode = `{% assign data = '{"user": {"name": "John", "age": 30}}' | parse_json %}
|
|
285
|
+
{% assign user = data | dig: "user" %}
|
|
286
|
+
{{ user.name }}
|
|
287
|
+
{{ user.missing }}`;
|
|
288
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
289
|
+
expect(offenses).toHaveLength(1);
|
|
290
|
+
expect(offenses[0].message).toContain("Unknown property 'missing'");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should infer array shape after dig and allow .size', async () => {
|
|
294
|
+
const sourceCode = `{% assign data = '{"results": [{"id": 1}, {"id": 2}]}' | parse_json %}
|
|
295
|
+
{% assign items = data | dig: "results" %}
|
|
296
|
+
{{ items.size }}
|
|
297
|
+
{{ items.first.id }}`;
|
|
298
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
299
|
+
expect(offenses).toHaveLength(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should infer shape after multiple dig filters', async () => {
|
|
303
|
+
const sourceCode = `{% assign data = '{"a": {"b": {"c": 1}}}' | parse_json %}
|
|
304
|
+
{% assign val = data | dig: "a" | dig: "b" %}
|
|
305
|
+
{{ val.c }}
|
|
306
|
+
{{ val.missing }}`;
|
|
307
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
308
|
+
expect(offenses).toHaveLength(1);
|
|
309
|
+
expect(offenses[0].message).toContain("Unknown property 'missing'");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should not track dig when source has no known shape', async () => {
|
|
313
|
+
const sourceCode = `{% assign val = dynamic_var | dig: "key" %}
|
|
314
|
+
{{ val.anything }}`;
|
|
315
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
316
|
+
expect(offenses).toHaveLength(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should infer shape after dig on inline graphql result', async () => {
|
|
320
|
+
const sourceCode = `{% graphql result | dig: 'user' %}
|
|
321
|
+
query {
|
|
322
|
+
user {
|
|
323
|
+
id
|
|
324
|
+
email
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
{% endgraphql %}
|
|
328
|
+
{{ result.id }}
|
|
329
|
+
{{ result.email }}
|
|
330
|
+
{{ result.missing }}`;
|
|
331
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
332
|
+
expect(offenses).toHaveLength(1);
|
|
333
|
+
expect(offenses[0].message).toContain("Unknown property 'missing'");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should allow full shape when no dig filter on inline graphql', async () => {
|
|
337
|
+
const sourceCode = `{% graphql result %}
|
|
338
|
+
query {
|
|
339
|
+
user {
|
|
340
|
+
id
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
{% endgraphql %}
|
|
344
|
+
{{ result.user.id }}`;
|
|
345
|
+
const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
|
|
346
|
+
expect(offenses).toHaveLength(0);
|
|
347
|
+
});
|
|
215
348
|
});
|
|
216
349
|
|
|
217
350
|
describe('error message formatting', () => {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
LiquidVariableLookup,
|
|
5
5
|
LiquidVariable,
|
|
6
6
|
LiquidExpression,
|
|
7
|
+
LiquidFilter,
|
|
7
8
|
LiquidString,
|
|
8
9
|
NodeTypes,
|
|
9
10
|
NamedTags,
|
|
@@ -125,6 +126,50 @@ export const UnknownProperty: LiquidCheckDefinition = {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
// {% assign x = y | dig: "key1" | dig: "key2" %}
|
|
131
|
+
// Follow the dig path through the source variable's known shape.
|
|
132
|
+
const digFilters =
|
|
133
|
+
markup.value.filters?.filter((f: { name: string }) => f.name === 'dig') ?? [];
|
|
134
|
+
if (digFilters.length > 0 && markup.value.expression.type === NodeTypes.VariableLookup) {
|
|
135
|
+
const expr = markup.value.expression as LiquidVariableLookup;
|
|
136
|
+
if (expr.name) {
|
|
137
|
+
const digPath: string[] = [];
|
|
138
|
+
let validDigPath = true;
|
|
139
|
+
for (const digFilter of digFilters) {
|
|
140
|
+
const arg = digFilter.args?.[0];
|
|
141
|
+
if (arg?.type === NodeTypes.String) {
|
|
142
|
+
digPath.push(arg.value);
|
|
143
|
+
} else {
|
|
144
|
+
validDigPath = false;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (validDigPath && digPath.length > 0) {
|
|
150
|
+
// Combine any lookups on the source expression (e.g. y.a | dig: "b")
|
|
151
|
+
// with the dig path to get the full navigation path.
|
|
152
|
+
const sourceLookupPath = buildLookupPath(expr.lookups);
|
|
153
|
+
const fullPath = sourceLookupPath ? [...sourceLookupPath, ...digPath] : digPath;
|
|
154
|
+
|
|
155
|
+
const sourceIdx = findLastApplicableShapeIndex(
|
|
156
|
+
expr.name,
|
|
157
|
+
node.position.start,
|
|
158
|
+
variableShapes,
|
|
159
|
+
);
|
|
160
|
+
if (sourceIdx !== -1) {
|
|
161
|
+
const result = lookupPropertyPath(variableShapes[sourceIdx].shape, fullPath);
|
|
162
|
+
if (result.shape && !result.error) {
|
|
163
|
+
variableShapes.push({
|
|
164
|
+
name: markup.name,
|
|
165
|
+
shape: result.shape,
|
|
166
|
+
range: [node.position.end],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
// {% parse_json x %}{"a": 5}{% endparse_json %}
|
|
@@ -162,11 +207,14 @@ export const UnknownProperty: LiquidCheckDefinition = {
|
|
|
162
207
|
const schema = await getGraphQLSchema();
|
|
163
208
|
const shape = inferShapeFromGraphQL(content, schema);
|
|
164
209
|
if (shape) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
const resultShape = applyDigFilters(shape, markup.filters);
|
|
211
|
+
if (resultShape) {
|
|
212
|
+
variableShapes.push({
|
|
213
|
+
name: markup.name,
|
|
214
|
+
shape: resultShape,
|
|
215
|
+
range: [node.position.end],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
170
218
|
}
|
|
171
219
|
} catch {
|
|
172
220
|
// File read error - skip
|
|
@@ -186,11 +234,14 @@ export const UnknownProperty: LiquidCheckDefinition = {
|
|
|
186
234
|
const schema = await getGraphQLSchema();
|
|
187
235
|
const shape = inferShapeFromGraphQL(textContent, schema);
|
|
188
236
|
if (shape) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
237
|
+
const resultShape = applyDigFilters(shape, markup.filters);
|
|
238
|
+
if (resultShape) {
|
|
239
|
+
variableShapes.push({
|
|
240
|
+
name: markup.name,
|
|
241
|
+
shape: resultShape,
|
|
242
|
+
range: [node.blockEndPosition?.end ?? node.position.end],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
194
245
|
}
|
|
195
246
|
}
|
|
196
247
|
}
|
|
@@ -361,6 +412,29 @@ function buildLookupPath(lookups: LiquidExpression[]): string[] | undefined {
|
|
|
361
412
|
return path;
|
|
362
413
|
}
|
|
363
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Navigate a shape using the `dig` filters from a tag's result filters.
|
|
417
|
+
* Returns the navigated shape, or the original shape if no dig filters are present.
|
|
418
|
+
* Returns null if the dig path is dynamic or navigates to an unknown property.
|
|
419
|
+
*/
|
|
420
|
+
function applyDigFilters(shape: PropertyShape, filters: LiquidFilter[]): PropertyShape | null {
|
|
421
|
+
const digFilters = filters.filter((f) => f.name === 'dig');
|
|
422
|
+
if (digFilters.length === 0) return shape;
|
|
423
|
+
|
|
424
|
+
const digPath: string[] = [];
|
|
425
|
+
for (const filter of digFilters) {
|
|
426
|
+
const arg = filter.args?.[0];
|
|
427
|
+
if (arg?.type === NodeTypes.String) {
|
|
428
|
+
digPath.push(arg.value);
|
|
429
|
+
} else {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const result = lookupPropertyPath(shape, digPath);
|
|
435
|
+
return result.error || !result.shape ? null : result.shape;
|
|
436
|
+
}
|
|
437
|
+
|
|
364
438
|
/**
|
|
365
439
|
* Extract the lookup path from a hash_assign target.
|
|
366
440
|
* For {% hash_assign a['key1']['key2'] = value %}, returns ['key1', 'key2']
|
|
@@ -216,7 +216,21 @@ export function inferShapeFromGraphQL(
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
const shape = selectionSetToShape(definition.selectionSet, rootType);
|
|
220
|
+
|
|
221
|
+
// platformOS always exposes a top-level 'errors' array on graphql results
|
|
222
|
+
// (GraphQL protocol-level errors), regardless of what's in the selection set.
|
|
223
|
+
const properties = new Map(shape.properties);
|
|
224
|
+
if (!properties.has('errors')) {
|
|
225
|
+
properties.set('errors', {
|
|
226
|
+
kind: 'array',
|
|
227
|
+
itemShape: {
|
|
228
|
+
kind: 'object',
|
|
229
|
+
properties: new Map([['message', { kind: 'primitive', primitiveType: 'string' }]]),
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { kind: 'object', properties };
|
|
220
234
|
}
|
|
221
235
|
}
|
|
222
236
|
return undefined;
|
|
@@ -127,4 +127,78 @@ describe('Module: UnusedAssign', () => {
|
|
|
127
127
|
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
128
128
|
expect(offenses).to.have.lengthOf(1);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
it('should not report an offense for hash mutation via assign with lookup target', async () => {
|
|
132
|
+
const sourceCode = `
|
|
133
|
+
{% assign errors = {} %}
|
|
134
|
+
{% assign errors[field] = 'value' %}
|
|
135
|
+
{{ errors }}
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
139
|
+
expect(offenses).to.have.lengthOf(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should not track assign with lookup as a new variable definition', async () => {
|
|
143
|
+
const sourceCode = `
|
|
144
|
+
{% assign errors[field_name] = field_errors %}
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
148
|
+
expect(offenses).to.have.lengthOf(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should not report when a reference alias is mutated via subscript', async () => {
|
|
152
|
+
// assign errors = contract.errors creates a reference; mutations on errors
|
|
153
|
+
// have side effects on the original, so they count as "using" errors.
|
|
154
|
+
const sourceCode = `
|
|
155
|
+
{% assign errors = contract.errors %}
|
|
156
|
+
{% assign errors[field_name] = field_errors %}
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
160
|
+
expect(offenses).to.have.lengthOf(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should not report when a reference alias is mutated via dot notation', async () => {
|
|
164
|
+
const sourceCode = `
|
|
165
|
+
{% assign data = response.data %}
|
|
166
|
+
{% assign data.status = 'ok' %}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
170
|
+
expect(offenses).to.have.lengthOf(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should not report when a reference alias is mutated via array push', async () => {
|
|
174
|
+
const sourceCode = `
|
|
175
|
+
{% assign items = cart.items %}
|
|
176
|
+
{% assign items << new_item %}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
180
|
+
expect(offenses).to.have.lengthOf(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should still report when a fresh literal hash is mutated but never used', async () => {
|
|
184
|
+
const sourceCode = `
|
|
185
|
+
{% assign x = {} %}
|
|
186
|
+
{% assign x['key'] = 'value' %}
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
190
|
+
expect(offenses).to.have.lengthOf(1);
|
|
191
|
+
expect(offenses[0].message).to.equal("The variable 'x' is assigned but not used");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should still report when a fresh literal array is pushed to but never used', async () => {
|
|
195
|
+
const sourceCode = `
|
|
196
|
+
{% assign arr = [] %}
|
|
197
|
+
{% assign arr << item %}
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
const offenses = await runLiquidCheck(UnusedAssign, sourceCode);
|
|
201
|
+
expect(offenses).to.have.lengthOf(1);
|
|
202
|
+
expect(offenses[0].message).to.equal("The variable 'arr' is assigned but not used");
|
|
203
|
+
});
|
|
130
204
|
});
|
|
@@ -26,6 +26,10 @@ export const UnusedAssign: LiquidCheckDefinition = {
|
|
|
26
26
|
|
|
27
27
|
create(context) {
|
|
28
28
|
const assignedVariables: Map<string, LiquidTagAssign | LiquidTagCapture> = new Map();
|
|
29
|
+
// Variables assigned from a pure variable lookup (no filters, no literals).
|
|
30
|
+
// e.g. `assign errors = contract.errors` — mutations on `errors` have side
|
|
31
|
+
// effects on the original, so they count as "using" the variable.
|
|
32
|
+
const referenceAssignedVariables: Set<string> = new Set();
|
|
29
33
|
const usedVariables: Set<string> = new Set();
|
|
30
34
|
|
|
31
35
|
function checkVariableUsage(node: any) {
|
|
@@ -38,7 +42,28 @@ export const UnusedAssign: LiquidCheckDefinition = {
|
|
|
38
42
|
async LiquidTag(node, ancestors) {
|
|
39
43
|
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
40
44
|
if (isLiquidTagAssign(node)) {
|
|
41
|
-
|
|
45
|
+
if (node.markup.lookups.length === 0 && node.markup.operator === '=') {
|
|
46
|
+
// Simple assignment: register as a new variable
|
|
47
|
+
assignedVariables.set(node.markup.name, node);
|
|
48
|
+
// Track pure reference assignments (VariableLookup with no filters)
|
|
49
|
+
if (
|
|
50
|
+
node.markup.value.type === NodeTypes.LiquidVariable &&
|
|
51
|
+
node.markup.value.expression.type === NodeTypes.VariableLookup &&
|
|
52
|
+
node.markup.value.filters.length === 0
|
|
53
|
+
) {
|
|
54
|
+
referenceAssignedVariables.add(node.markup.name);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// Hash/array mutation: assign x[key]=val, assign x.key=val, assign x<<val
|
|
58
|
+
// Counts as "using" x only when x is external (not locally assigned here)
|
|
59
|
+
// or was assigned as a reference alias to another variable.
|
|
60
|
+
if (
|
|
61
|
+
!assignedVariables.has(node.markup.name) ||
|
|
62
|
+
referenceAssignedVariables.has(node.markup.name)
|
|
63
|
+
) {
|
|
64
|
+
usedVariables.add(node.markup.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
42
67
|
} else if (isLiquidTagCapture(node) && node.markup.name) {
|
|
43
68
|
assignedVariables.set(node.markup.name, node);
|
|
44
69
|
}
|
|
@@ -43,6 +43,15 @@ describe('Module: VariableName', () => {
|
|
|
43
43
|
expect(suggestions).to.include(expectedFixedCode);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it('should not report an error for variables starting with underscore', async () => {
|
|
47
|
+
const varNames = [`_`, `_errors`, `_temp_var`, `_myPrivateVar`];
|
|
48
|
+
for (const varName of varNames) {
|
|
49
|
+
const sourceCode = `{% assign ${varName} = "value" %}`;
|
|
50
|
+
const offenses = await runLiquidCheck(VariableName, sourceCode);
|
|
51
|
+
expect(offenses).to.be.empty;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
46
55
|
// It's impossible to make an idempotent rule that works for all cases. We
|
|
47
56
|
// have to accept whatever spacing the user has input as valid.
|
|
48
57
|
it('should not complain about numbers inside variable names', async () => {
|
|
@@ -65,6 +65,11 @@ export const VariableName: LiquidCheckDefinition<typeof schema> = {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Variables starting with _ are valid (used as throwaway/private variables)
|
|
69
|
+
if (node.markup.name.startsWith('_')) {
|
|
70
|
+
return { valid: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
const formatter = formatTypes[context.settings.format as FormatTypes];
|
|
69
74
|
const suggestion = formatter(node.markup.name);
|
|
70
75
|
|