@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CLAUDE.md +150 -0
  3. package/dist/AugmentedPlatformOSDocset.js +1 -0
  4. package/dist/AugmentedPlatformOSDocset.js.map +1 -1
  5. package/dist/checks/deprecated-filter/index.js +15 -0
  6. package/dist/checks/deprecated-filter/index.js.map +1 -1
  7. package/dist/checks/duplicate-content-for-arguments/index.js +1 -1
  8. package/dist/checks/duplicate-content-for-arguments/index.js.map +1 -1
  9. package/dist/checks/graphql/index.d.ts +1 -0
  10. package/dist/checks/graphql/index.js +20 -7
  11. package/dist/checks/graphql/index.js.map +1 -1
  12. package/dist/checks/invalid-hash-assign-target/index.js +4 -3
  13. package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
  14. package/dist/checks/missing-content-for-arguments/index.js +1 -1
  15. package/dist/checks/missing-content-for-arguments/index.js.map +1 -1
  16. package/dist/checks/pagination-size/index.js +1 -1
  17. package/dist/checks/pagination-size/index.js.map +1 -1
  18. package/dist/checks/undefined-object/index.js +14 -13
  19. package/dist/checks/undefined-object/index.js.map +1 -1
  20. package/dist/checks/unknown-property/index.js +75 -10
  21. package/dist/checks/unknown-property/index.js.map +1 -1
  22. package/dist/checks/unknown-property/property-shape.js +14 -1
  23. package/dist/checks/unknown-property/property-shape.js.map +1 -1
  24. package/dist/checks/unrecognized-content-for-arguments/index.js +1 -1
  25. package/dist/checks/unrecognized-content-for-arguments/index.js.map +1 -1
  26. package/dist/checks/unused-assign/index.js +23 -1
  27. package/dist/checks/unused-assign/index.js.map +1 -1
  28. package/dist/checks/valid-content-for-argument-types/index.js +1 -1
  29. package/dist/checks/valid-content-for-argument-types/index.js.map +1 -1
  30. package/dist/checks/variable-name/index.js +4 -0
  31. package/dist/checks/variable-name/index.js.map +1 -1
  32. package/dist/doc-generator/DocBlockGenerator.d.ts +16 -0
  33. package/dist/doc-generator/DocBlockGenerator.js +464 -0
  34. package/dist/doc-generator/DocBlockGenerator.js.map +1 -0
  35. package/dist/doc-generator/index.d.ts +1 -0
  36. package/dist/doc-generator/index.js +6 -0
  37. package/dist/doc-generator/index.js.map +1 -0
  38. package/dist/frontmatter/index.d.ts +59 -0
  39. package/dist/frontmatter/index.js +301 -0
  40. package/dist/frontmatter/index.js.map +1 -0
  41. package/dist/index.d.ts +2 -1
  42. package/dist/index.js +4 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/liquid-doc/arguments.js +5 -0
  45. package/dist/liquid-doc/arguments.js.map +1 -1
  46. package/dist/path.d.ts +1 -1
  47. package/dist/path.js +3 -1
  48. package/dist/path.js.map +1 -1
  49. package/dist/to-schema.d.ts +1 -1
  50. package/dist/tsconfig.tsbuildinfo +1 -1
  51. package/dist/utils/block.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/AugmentedPlatformOSDocset.ts +1 -0
  54. package/src/checks/deprecated-filter/index.spec.ts +41 -1
  55. package/src/checks/deprecated-filter/index.ts +17 -0
  56. package/src/checks/graphql/index.spec.ts +173 -0
  57. package/src/checks/graphql/index.ts +21 -10
  58. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
  59. package/src/checks/invalid-hash-assign-target/index.ts +6 -4
  60. package/src/checks/undefined-object/index.spec.ts +123 -19
  61. package/src/checks/undefined-object/index.ts +16 -18
  62. package/src/checks/unknown-property/index.spec.ts +133 -0
  63. package/src/checks/unknown-property/index.ts +84 -10
  64. package/src/checks/unknown-property/property-shape.ts +15 -1
  65. package/src/checks/unused-assign/index.spec.ts +74 -0
  66. package/src/checks/unused-assign/index.ts +26 -1
  67. package/src/checks/variable-name/index.spec.ts +9 -0
  68. package/src/checks/variable-name/index.ts +5 -0
  69. package/src/frontmatter/index.ts +344 -0
  70. package/src/index.ts +3 -0
  71. package/src/liquid-doc/arguments.ts +3 -0
  72. package/src/path.ts +2 -0
@@ -18,7 +18,7 @@ import {
18
18
  LiquidTagGraphQL,
19
19
  LiquidTagParseJson,
20
20
  LiquidTagBackground,
21
- BackgroundInlineMarkup,
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
- indexVariableScope((node.markup as FunctionMarkup).name, {
112
- start: node.position.end,
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.name, {
152
- start: node.blockEndPosition?.end,
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
- // Skip the jobId variable in background tag markup - it's being defined, not used
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: BackgroundInlineMarkup } {
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.BackgroundInlineMarkup
309
+ node.markup.type === NodeTypes.BackgroundMarkup
307
310
  );
308
311
  }
309
312
 
310
- function isBackgroundInlineMarkup(node: unknown): node is BackgroundInlineMarkup {
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
- variableShapes.push({
166
- name: markup.name,
167
- shape,
168
- range: [node.position.end],
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
- variableShapes.push({
190
- name: markup.name,
191
- shape,
192
- range: [node.blockEndPosition?.end ?? node.position.end],
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
- return selectionSetToShape(definition.selectionSet, rootType);
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
- assignedVariables.set(node.markup.name, node);
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