@platformos/platformos-check-common 0.0.13 → 0.0.17

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.
@@ -347,6 +347,110 @@ query {
347
347
  });
348
348
  });
349
349
 
350
+ describe('JSON literal validation', () => {
351
+ it('should report unknown property on hash literal', async () => {
352
+ const sourceCode = `{% assign a = {x: 5} %}{{ a.b }}`;
353
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
354
+ expect(offenses).toHaveLength(1);
355
+ expect(offenses[0].message).toEqual("Unknown property 'b' on 'a'.");
356
+ });
357
+
358
+ it('should not report for valid property on hash literal', async () => {
359
+ const sourceCode = `{% assign a = {x: 5, y: 10} %}{{ a.x }}{{ a.y }}`;
360
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
361
+ expect(offenses).toHaveLength(0);
362
+ });
363
+
364
+ it('should handle nested hash literals', async () => {
365
+ const sourceCode = `{% assign a = {x: {y: 1}} %}{{ a.x.y }}{{ a.x.z }}`;
366
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
367
+ expect(offenses).toHaveLength(1);
368
+ expect(offenses[0].message).toEqual("Unknown property 'z' on 'a.x'.");
369
+ });
370
+
371
+ it('should report unknown property access on array literal', async () => {
372
+ const sourceCode = `{% assign a = [2, 3] %}{{ a.asd }}`;
373
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
374
+ expect(offenses).toHaveLength(1);
375
+ expect(offenses[0].message).toEqual("Unknown property 'asd' on 'a'.");
376
+ });
377
+
378
+ it('should allow first/last/size on array literals', async () => {
379
+ const sourceCode = `{% assign a = [2, 3] %}{{ a.first }}{{ a.last }}{{ a.size }}`;
380
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
381
+ expect(offenses).toHaveLength(0);
382
+ });
383
+
384
+ it('should allow numeric index on array literals', async () => {
385
+ const sourceCode = `{% assign a = [2, 3] %}{{ a[0] }}{{ a[1] }}`;
386
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
387
+ expect(offenses).toHaveLength(0);
388
+ });
389
+
390
+ it('should report primitive access on array item from hash literal items', async () => {
391
+ const sourceCode = `{% assign a = [{x: 1}, {x: 2}] %}{{ a.first.x }}{{ a.first.y }}`;
392
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
393
+ expect(offenses).toHaveLength(1);
394
+ expect(offenses[0].message).toEqual("Unknown property 'y' on 'a.first'.");
395
+ });
396
+
397
+ it('should report unknown property when assigning from known-shape variable', async () => {
398
+ const sourceCode = `{% assign a = [2, 3] %}{% assign b = a.asd %}`;
399
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
400
+ expect(offenses).toHaveLength(1);
401
+ expect(offenses[0].message).toEqual("Unknown property 'asd' on 'a'.");
402
+ });
403
+
404
+ it('should handle hash literals with quoted string keys', async () => {
405
+ const sourceCode = `{% assign c = { "errors": {}, "valid": true } %}{{ c.valid }}{{ c.errors }}{{ c.missing }}`;
406
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
407
+ expect(offenses).toHaveLength(1);
408
+ expect(offenses[0].message).toEqual("Unknown property 'missing' on 'c'.");
409
+ });
410
+
411
+ it('should handle hash literals with mixed bare and quoted keys', async () => {
412
+ const sourceCode = `{% assign a = {x: 5, "y": 10} %}{{ a.x }}{{ a.y }}{{ a.z }}`;
413
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
414
+ expect(offenses).toHaveLength(1);
415
+ expect(offenses[0].message).toEqual("Unknown property 'z' on 'a'.");
416
+ });
417
+
418
+ it('should handle nested hash literals with quoted keys', async () => {
419
+ const sourceCode = `{% assign a = { "outer": { "inner": 1 } } %}{{ a.outer.inner }}{{ a.outer.missing }}`;
420
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
421
+ expect(offenses).toHaveLength(1);
422
+ expect(offenses[0].message).toEqual("Unknown property 'missing' on 'a.outer'.");
423
+ });
424
+
425
+ it('should report unknown property on hash literal assigned via another variable', async () => {
426
+ const sourceCode = `{% assign a = {a: 5} %}{% assign b = a.b %}`;
427
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
428
+ expect(offenses).toHaveLength(1);
429
+ expect(offenses[0].message).toEqual("Unknown property 'b' on 'a'.");
430
+ });
431
+ });
432
+
433
+ describe('function tag reassignment', () => {
434
+ it('should not report unknown property after function tag reassigns a variable', async () => {
435
+ const sourceCode = `{% assign object = { "valid": true, "id": id, "role": role } %}
436
+ {% function object = 'modules/core/commands/execute', object: object, mutation_name: 'some_mutation' %}
437
+ {% if object.errors == blank %}
438
+ {{ object.valid }}
439
+ {% endif %}`;
440
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
441
+ expect(offenses).toHaveLength(0);
442
+ });
443
+
444
+ it('should still report unknown property before function tag reassignment', async () => {
445
+ const sourceCode = `{% assign object = { "valid": true, "id": "123" } %}
446
+ {{ object.missing }}
447
+ {% function object = 'modules/core/commands/execute', object: object %}`;
448
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
449
+ expect(offenses).toHaveLength(1);
450
+ expect(offenses[0].message).toEqual("Unknown property 'missing' on 'object'.");
451
+ });
452
+ });
453
+
350
454
  describe('error message formatting', () => {
351
455
  it('should include variable name in error message', async () => {
352
456
  const sourceCode = `{% assign myVar = '{"a": 1}' | parse_json %}
@@ -11,7 +11,10 @@ import {
11
11
  TextNode,
12
12
  GraphQLMarkup,
13
13
  GraphQLInlineMarkup,
14
+ FunctionMarkup,
14
15
  HashAssignMarkup,
16
+ JsonHashLiteral,
17
+ JsonArrayLiteral,
15
18
  } from '@platformos/liquid-html-parser';
16
19
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
17
20
  import { isError } from '../../utils';
@@ -85,8 +88,10 @@ export const UnknownProperty: LiquidCheckDefinition = {
85
88
  if (isLiquidTagAssign(node)) {
86
89
  const markup = node.markup;
87
90
 
88
- // Close any previous shape for this variable (reassignment)
89
- closeShapeRange(markup.name, node.position.start);
91
+ // Close any previous shape for this variable (reassignment).
92
+ // Use the value expression's end so the RHS can still see the old shape
93
+ // (e.g. {% assign c = c.d %} — c.d must resolve against the previous shape of c).
94
+ closeShapeRange(markup.name, markup.value.position.end);
90
95
 
91
96
  const hasParseJsonFilter =
92
97
  markup.value.filters &&
@@ -127,6 +132,19 @@ export const UnknownProperty: LiquidCheckDefinition = {
127
132
  }
128
133
  }
129
134
 
135
+ // {% assign x = {a: 5, b: {c: 1}} %} or {% assign x = [1, 2, 3] %}
136
+ const exprType = markup.value.expression.type;
137
+ if (exprType === NodeTypes.JsonHashLiteral || exprType === NodeTypes.JsonArrayLiteral) {
138
+ const shape = inferShapeFromLiteralNode(markup.value.expression);
139
+ if (shape) {
140
+ variableShapes.push({
141
+ name: markup.name,
142
+ shape,
143
+ range: [node.position.end],
144
+ });
145
+ }
146
+ }
147
+
130
148
  // {% assign x = y | dig: "key1" | dig: "key2" %}
131
149
  // Follow the dig path through the source variable's known shape.
132
150
  const digFilters =
@@ -246,6 +264,17 @@ export const UnknownProperty: LiquidCheckDefinition = {
246
264
  }
247
265
  }
248
266
 
267
+ // {% function object = 'partial_name', arg: value %}
268
+ // The function tag reassigns the variable to the return value of the partial,
269
+ // which has an unknown shape. Close any tracked shape so we don't false-positive.
270
+ if (isLiquidTagFunction(node)) {
271
+ const markup = node.markup;
272
+ const variableName = markup.name.name;
273
+ if (variableName) {
274
+ closeShapeRange(variableName, node.position.start);
275
+ }
276
+ }
277
+
249
278
  // {% hash_assign x["key"] = value %} or {% hash_assign x["a"]["b"] = value %}
250
279
  if (isLiquidTagHashAssign(node)) {
251
280
  const markup = node.markup;
@@ -514,6 +543,10 @@ function isLiquidTagGraphQL(
514
543
  return node.name === NamedTags.graphql && typeof node.markup !== 'string';
515
544
  }
516
545
 
546
+ function isLiquidTagFunction(node: LiquidTag): node is LiquidTag & { markup: FunctionMarkup } {
547
+ return node.name === NamedTags.function && typeof node.markup !== 'string';
548
+ }
549
+
517
550
  function isLiquidTagHashAssign(node: LiquidTag): node is LiquidTag & { markup: HashAssignMarkup } {
518
551
  return node.name === NamedTags.hash_assign && typeof node.markup !== 'string';
519
552
  }
@@ -540,3 +573,63 @@ function isGraphQLInlineMarkup(
540
573
  function isLiquidString(expr: LiquidString | LiquidVariableLookup): expr is LiquidString {
541
574
  return expr.type === NodeTypes.String;
542
575
  }
576
+
577
+ /**
578
+ * Infer a PropertyShape from a JsonHashLiteral or JsonArrayLiteral AST node.
579
+ */
580
+ function inferShapeFromLiteralNode(
581
+ node: JsonHashLiteral | JsonArrayLiteral,
582
+ ): PropertyShape | undefined {
583
+ if (node.type === NodeTypes.JsonArrayLiteral) {
584
+ let itemShape: PropertyShape | undefined;
585
+ for (const element of node.elements) {
586
+ const elShape = inferShapeFromExpressionNode(element);
587
+ if (elShape) {
588
+ itemShape = itemShape ? itemShape : elShape;
589
+ }
590
+ }
591
+ return { kind: 'array', itemShape };
592
+ }
593
+
594
+ if (node.type === NodeTypes.JsonHashLiteral) {
595
+ const properties = new Map<string, PropertyShape>();
596
+ for (const entry of node.entries) {
597
+ // Keys can be VariableLookup nodes (bare keys) or String nodes (quoted keys)
598
+ let keyName: string | undefined;
599
+ if (entry.key.type === NodeTypes.VariableLookup && entry.key.name) {
600
+ keyName = entry.key.name;
601
+ } else if (entry.key.type === NodeTypes.String) {
602
+ keyName = entry.key.value;
603
+ }
604
+ if (keyName) {
605
+ const valueShape = inferShapeFromExpressionNode(entry.value);
606
+ properties.set(keyName, valueShape ?? { kind: 'primitive' });
607
+ }
608
+ }
609
+ return { kind: 'object', properties };
610
+ }
611
+
612
+ return undefined;
613
+ }
614
+
615
+ /**
616
+ * Infer a PropertyShape from a Liquid expression or variable node.
617
+ */
618
+ function inferShapeFromExpressionNode(node: LiquidExpression | LiquidVariable): PropertyShape {
619
+ if (node.type === NodeTypes.JsonHashLiteral) {
620
+ return inferShapeFromLiteralNode(node as JsonHashLiteral) ?? { kind: 'primitive' };
621
+ }
622
+ if (node.type === NodeTypes.JsonArrayLiteral) {
623
+ return inferShapeFromLiteralNode(node as JsonArrayLiteral) ?? { kind: 'primitive' };
624
+ }
625
+ if (node.type === NodeTypes.String) {
626
+ return { kind: 'primitive', primitiveType: 'string' };
627
+ }
628
+ if (node.type === NodeTypes.Number) {
629
+ return { kind: 'primitive', primitiveType: 'number' };
630
+ }
631
+ if (node.type === NodeTypes.LiquidLiteral) {
632
+ return { kind: 'primitive' };
633
+ }
634
+ return { kind: 'primitive' };
635
+ }
@@ -194,7 +194,7 @@ ${buildComment('platformos-check-enable')}
194
194
  });
195
195
 
196
196
  it("should disable the parent node's next node if platformos-check is disabled as the last child node", async () => {
197
- const file = `{% liquid
197
+ const file = `{% doc %}{% enddoc %}{% liquid
198
198
  if condition
199
199
  # platformos-check-disable-next-line
200
200
  elsif other_condition
@@ -211,7 +211,7 @@ ${buildComment('platformos-check-enable')}
211
211
  });
212
212
 
213
213
  it('should not disable any checks if platformos-check is disabled at the end', async () => {
214
- const file = `{% liquid
214
+ const file = `{% doc %}{% enddoc %}{% liquid
215
215
  echo hello
216
216
  echo everyone
217
217
  # platformos-check-disable-next-line
@@ -232,7 +232,7 @@ ${buildComment('platformos-check-enable')}
232
232
  });
233
233
 
234
234
  it('should disable the next line if the content is an HTML tag with liquid', async () => {
235
- const file = `{% # platformos-check-disable-next-line %}
235
+ const file = `{% doc %}{% enddoc %}{% # platformos-check-disable-next-line %}
236
236
  <div class="{{ foo }}"></div>
237
237
  <div class="{{ bar }}"></div>`;
238
238
 
@@ -246,7 +246,7 @@ ${buildComment('platformos-check-enable')}
246
246
  });
247
247
 
248
248
  it('should not disable the next line if the specified rule does not exist', async () => {
249
- const file = `{% # platformos-check-disable-next-line FAKE_RULE %}
249
+ const file = `{% doc %}{% enddoc %}{% # platformos-check-disable-next-line FAKE_RULE %}
250
250
  <div class="{{ foo }}"></div>`;
251
251
 
252
252
  const offenses = await check({ 'code.liquid': file }, [UndefinedObject]);
@@ -13,6 +13,8 @@ import {
13
13
  extractUrlPattern,
14
14
  isValuedAttrNode,
15
15
  getAttrName,
16
+ buildVariableMap,
17
+ tryExtractAssignUrl,
16
18
  ValuedAttrNode,
17
19
  } from './url-helpers';
18
20
 
@@ -239,3 +241,146 @@ describe('extractUrlPattern with variableMap', () => {
239
241
  expect(extractUrlPattern(attr, variableMap)).toBe('/about');
240
242
  });
241
243
  });
244
+
245
+ describe('tryExtractAssignUrl', () => {
246
+ function firstChild(source: string): LiquidHtmlNode {
247
+ return toLiquidHtmlAST(source).children[0];
248
+ }
249
+
250
+ it('returns null for a non-assign liquid tag', () => {
251
+ expect(tryExtractAssignUrl(firstChild('{% if true %}{% endif %}'))).toBe(null);
252
+ });
253
+
254
+ it('returns null for an HTML element', () => {
255
+ expect(tryExtractAssignUrl(firstChild('<a href="/about">link</a>'))).toBe(null);
256
+ });
257
+
258
+ it('extracts name and urlPattern from a simple string assign', () => {
259
+ const result = tryExtractAssignUrl(firstChild('{% assign url = "/about" %}'));
260
+ expect(result).toEqual({ name: 'url', urlPattern: '/about' });
261
+ });
262
+
263
+ it('extracts urlPattern from an assign with append filter', () => {
264
+ const result = tryExtractAssignUrl(
265
+ firstChild('{% assign url = "/users/" | append: user.id %}'),
266
+ );
267
+ expect(result).toEqual({ name: 'url', urlPattern: '/users/:_liquid_' });
268
+ });
269
+
270
+ it('returns null when the assign RHS is not a URL pattern (no leading /)', () => {
271
+ expect(tryExtractAssignUrl(firstChild('{% assign url = "about" %}'))).toBe(null);
272
+ });
273
+
274
+ it('returns null when the assign RHS uses an unsupported filter', () => {
275
+ expect(tryExtractAssignUrl(firstChild('{% assign url = "/ABOUT" | downcase %}'))).toBe(null);
276
+ });
277
+
278
+ it('returns null when assigning to a target with lookups (e.g. obj.field = ...)', () => {
279
+ // {% assign hash["key"] = "/about" %} — has lookups, not a plain variable
280
+ const ast = toLiquidHtmlAST('{% assign url = "/about" %}');
281
+ const node = ast.children[0] as LiquidTagAssign;
282
+ // Simulate lookups by checking the real code path: lookups.length > 0 returns null
283
+ const markup = node.markup as AssignMarkup;
284
+ // Normal assign has no lookups — just verify it returns non-null here
285
+ expect(markup.lookups.length).toBe(0);
286
+ expect(tryExtractAssignUrl(node)).not.toBe(null);
287
+ });
288
+ });
289
+
290
+ describe('buildVariableMap', () => {
291
+ function parseChildren(source: string): LiquidHtmlNode[] {
292
+ return toLiquidHtmlAST(source).children;
293
+ }
294
+
295
+ it('collects top-level assigns', () => {
296
+ const map = buildVariableMap(parseChildren('{% assign url = "/about" %}'));
297
+ expect(map.get('url')).toBe('/about');
298
+ });
299
+
300
+ it('collects multiple top-level assigns', () => {
301
+ const map = buildVariableMap(
302
+ parseChildren('{% assign a = "/first" %}{% assign b = "/second" %}'),
303
+ );
304
+ expect(map.get('a')).toBe('/first');
305
+ expect(map.get('b')).toBe('/second');
306
+ });
307
+
308
+ it('later assign overwrites earlier one', () => {
309
+ const map = buildVariableMap(
310
+ parseChildren('{% assign url = "/first" %}{% assign url = "/second" %}'),
311
+ );
312
+ expect(map.get('url')).toBe('/second');
313
+ });
314
+
315
+ it('recurses into {% if %} block children', () => {
316
+ const map = buildVariableMap(
317
+ parseChildren('{% if true %}{% assign url = "/about" %}{% endif %}'),
318
+ );
319
+ expect(map.get('url')).toBe('/about');
320
+ });
321
+
322
+ it('recurses into {% for %} block children', () => {
323
+ const map = buildVariableMap(
324
+ parseChildren('{% for i in list %}{% assign url = "/about" %}{% endfor %}'),
325
+ );
326
+ expect(map.get('url')).toBe('/about');
327
+ });
328
+
329
+ it('recurses into {% liquid %} block markup', () => {
330
+ const map = buildVariableMap(parseChildren('{% liquid\n assign url = "/about"\n%}'));
331
+ expect(map.get('url')).toBe('/about');
332
+ });
333
+
334
+ describe('beforeOffset', () => {
335
+ it('excludes assigns that end after beforeOffset', () => {
336
+ // "{% assign url = "/about" %}" is 27 chars (positions 0-26, end=27)
337
+ const source = '{% assign url = "/about" %}';
338
+ const map = buildVariableMap(parseChildren(source), 26);
339
+ // assign.position.end === 27 > 26, so it should be excluded
340
+ expect(map.has('url')).toBe(false);
341
+ });
342
+
343
+ it('includes assigns that end at or before beforeOffset', () => {
344
+ const source = '{% assign url = "/about" %}';
345
+ // assign ends at 27; beforeOffset=27 means end <= offset → included
346
+ const map = buildVariableMap(parseChildren(source), 27);
347
+ expect(map.get('url')).toBe('/about');
348
+ });
349
+
350
+ it('includes assign and excludes later reassignment based on cursor position', () => {
351
+ // assign1 ends at 27, assign2 ends at 54; cursor between them
352
+ const source = '{% assign url = "/first" %}{% assign url = "/second" %}';
353
+ const map = buildVariableMap(parseChildren(source), 28);
354
+ expect(map.get('url')).toBe('/first');
355
+ });
356
+
357
+ // Regression test for bug where the top-level `continue` skipped recursion into
358
+ // block containers. A block that starts before the cursor but ends after it must
359
+ // still be recursed into so that assigns before the cursor within it are found.
360
+ it('includes assign inside a block that ends after beforeOffset', () => {
361
+ // {% if %}...{% assign url = "/about" %}...<a href>...{% endif %}
362
+ // The if block ends after <a>.position.start, but the assign ends before it.
363
+ const source =
364
+ '{% if true %}{% assign url = "/about" %}<a href="{{ url }}">About</a>{% endif %}';
365
+ const aStart = source.indexOf('<a href');
366
+ const map = buildVariableMap(parseChildren(source), aStart);
367
+ expect(map.get('url')).toBe('/about');
368
+ });
369
+
370
+ it('includes assign inside {% liquid %} block when block ends after beforeOffset', () => {
371
+ const source =
372
+ '{% if true %}{% liquid\n assign url = "/about"\n%}<a href="{{ url }}">About</a>{% endif %}';
373
+ const aStart = source.indexOf('<a href');
374
+ const map = buildVariableMap(parseChildren(source), aStart);
375
+ expect(map.get('url')).toBe('/about');
376
+ });
377
+
378
+ it('excludes assign inside block that starts after beforeOffset', () => {
379
+ const source =
380
+ '<a href="{{ url }}">About</a>{% if true %}{% assign url = "/about" %}{% endif %}';
381
+ const aStart = source.indexOf('<a href');
382
+ const map = buildVariableMap(parseChildren(source), aStart);
383
+ expect(map.has('url')).toBe(false);
384
+ });
385
+ });
386
+ });