@platformos/platformos-check-common 0.0.13 → 0.0.16

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.
@@ -12,6 +12,8 @@ import {
12
12
  GraphQLMarkup,
13
13
  GraphQLInlineMarkup,
14
14
  HashAssignMarkup,
15
+ JsonHashLiteral,
16
+ JsonArrayLiteral,
15
17
  } from '@platformos/liquid-html-parser';
16
18
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
17
19
  import { isError } from '../../utils';
@@ -85,8 +87,10 @@ export const UnknownProperty: LiquidCheckDefinition = {
85
87
  if (isLiquidTagAssign(node)) {
86
88
  const markup = node.markup;
87
89
 
88
- // Close any previous shape for this variable (reassignment)
89
- closeShapeRange(markup.name, node.position.start);
90
+ // Close any previous shape for this variable (reassignment).
91
+ // Use the value expression's end so the RHS can still see the old shape
92
+ // (e.g. {% assign c = c.d %} — c.d must resolve against the previous shape of c).
93
+ closeShapeRange(markup.name, markup.value.position.end);
90
94
 
91
95
  const hasParseJsonFilter =
92
96
  markup.value.filters &&
@@ -127,6 +131,19 @@ export const UnknownProperty: LiquidCheckDefinition = {
127
131
  }
128
132
  }
129
133
 
134
+ // {% assign x = {a: 5, b: {c: 1}} %} or {% assign x = [1, 2, 3] %}
135
+ const exprType = markup.value.expression.type;
136
+ if (exprType === NodeTypes.JsonHashLiteral || exprType === NodeTypes.JsonArrayLiteral) {
137
+ const shape = inferShapeFromLiteralNode(markup.value.expression);
138
+ if (shape) {
139
+ variableShapes.push({
140
+ name: markup.name,
141
+ shape,
142
+ range: [node.position.end],
143
+ });
144
+ }
145
+ }
146
+
130
147
  // {% assign x = y | dig: "key1" | dig: "key2" %}
131
148
  // Follow the dig path through the source variable's known shape.
132
149
  const digFilters =
@@ -540,3 +557,57 @@ function isGraphQLInlineMarkup(
540
557
  function isLiquidString(expr: LiquidString | LiquidVariableLookup): expr is LiquidString {
541
558
  return expr.type === NodeTypes.String;
542
559
  }
560
+
561
+ /**
562
+ * Infer a PropertyShape from a JsonHashLiteral or JsonArrayLiteral AST node.
563
+ */
564
+ function inferShapeFromLiteralNode(
565
+ node: JsonHashLiteral | JsonArrayLiteral,
566
+ ): PropertyShape | undefined {
567
+ if (node.type === NodeTypes.JsonArrayLiteral) {
568
+ let itemShape: PropertyShape | undefined;
569
+ for (const element of node.elements) {
570
+ const elShape = inferShapeFromExpressionNode(element);
571
+ if (elShape) {
572
+ itemShape = itemShape ? itemShape : elShape;
573
+ }
574
+ }
575
+ return { kind: 'array', itemShape };
576
+ }
577
+
578
+ if (node.type === NodeTypes.JsonHashLiteral) {
579
+ const properties = new Map<string, PropertyShape>();
580
+ for (const entry of node.entries) {
581
+ // Keys are VariableLookup nodes where the name is the key string
582
+ if (entry.key.type === NodeTypes.VariableLookup && entry.key.name) {
583
+ const valueShape = inferShapeFromExpressionNode(entry.value);
584
+ properties.set(entry.key.name, valueShape ?? { kind: 'primitive' });
585
+ }
586
+ }
587
+ return { kind: 'object', properties };
588
+ }
589
+
590
+ return undefined;
591
+ }
592
+
593
+ /**
594
+ * Infer a PropertyShape from a Liquid expression or variable node.
595
+ */
596
+ function inferShapeFromExpressionNode(node: LiquidExpression | LiquidVariable): PropertyShape {
597
+ if (node.type === NodeTypes.JsonHashLiteral) {
598
+ return inferShapeFromLiteralNode(node as JsonHashLiteral) ?? { kind: 'primitive' };
599
+ }
600
+ if (node.type === NodeTypes.JsonArrayLiteral) {
601
+ return inferShapeFromLiteralNode(node as JsonArrayLiteral) ?? { kind: 'primitive' };
602
+ }
603
+ if (node.type === NodeTypes.String) {
604
+ return { kind: 'primitive', primitiveType: 'string' };
605
+ }
606
+ if (node.type === NodeTypes.Number) {
607
+ return { kind: 'primitive', primitiveType: 'number' };
608
+ }
609
+ if (node.type === NodeTypes.LiquidLiteral) {
610
+ return { kind: 'primitive' };
611
+ }
612
+ return { kind: 'primitive' };
613
+ }
@@ -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
+ });