@platformos/platformos-check-common 0.0.11 → 0.0.13

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 (140) hide show
  1. package/CHANGELOG.md +39 -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/circular-render/index.d.ts +2 -0
  6. package/dist/checks/circular-render/index.js +164 -0
  7. package/dist/checks/circular-render/index.js.map +1 -0
  8. package/dist/checks/deprecated-filter/index.js +15 -0
  9. package/dist/checks/deprecated-filter/index.js.map +1 -1
  10. package/dist/checks/duplicate-content-for-arguments/index.js +1 -1
  11. package/dist/checks/duplicate-content-for-arguments/index.js.map +1 -1
  12. package/dist/checks/graphql/index.d.ts +1 -0
  13. package/dist/checks/graphql/index.js +20 -7
  14. package/dist/checks/graphql/index.js.map +1 -1
  15. package/dist/checks/index.d.ts +1 -1
  16. package/dist/checks/index.js +6 -0
  17. package/dist/checks/index.js.map +1 -1
  18. package/dist/checks/invalid-hash-assign-target/index.js +4 -3
  19. package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
  20. package/dist/checks/missing-content-for-arguments/index.js +1 -1
  21. package/dist/checks/missing-content-for-arguments/index.js.map +1 -1
  22. package/dist/checks/missing-page/index.d.ts +2 -0
  23. package/dist/checks/missing-page/index.js +73 -0
  24. package/dist/checks/missing-page/index.js.map +1 -0
  25. package/dist/checks/missing-partial/index.js +31 -31
  26. package/dist/checks/missing-partial/index.js.map +1 -1
  27. package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
  28. package/dist/checks/missing-render-partial-arguments/index.js +37 -0
  29. package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
  30. package/dist/checks/nested-graphql-query/index.d.ts +2 -0
  31. package/dist/checks/nested-graphql-query/index.js +146 -0
  32. package/dist/checks/nested-graphql-query/index.js.map +1 -0
  33. package/dist/checks/pagination-size/index.js +1 -1
  34. package/dist/checks/pagination-size/index.js.map +1 -1
  35. package/dist/checks/translation-key-exists/index.js +16 -19
  36. package/dist/checks/translation-key-exists/index.js.map +1 -1
  37. package/dist/checks/translation-utils.d.ts +20 -0
  38. package/dist/checks/translation-utils.js +51 -0
  39. package/dist/checks/translation-utils.js.map +1 -0
  40. package/dist/checks/undefined-object/index.js +35 -13
  41. package/dist/checks/undefined-object/index.js.map +1 -1
  42. package/dist/checks/unknown-property/index.js +75 -10
  43. package/dist/checks/unknown-property/index.js.map +1 -1
  44. package/dist/checks/unknown-property/property-shape.js +14 -1
  45. package/dist/checks/unknown-property/property-shape.js.map +1 -1
  46. package/dist/checks/unrecognized-content-for-arguments/index.js +1 -1
  47. package/dist/checks/unrecognized-content-for-arguments/index.js.map +1 -1
  48. package/dist/checks/unused-assign/index.js +23 -1
  49. package/dist/checks/unused-assign/index.js.map +1 -1
  50. package/dist/checks/unused-translation-key/index.d.ts +4 -0
  51. package/dist/checks/unused-translation-key/index.js +85 -0
  52. package/dist/checks/unused-translation-key/index.js.map +1 -0
  53. package/dist/checks/valid-content-for-argument-types/index.js +1 -1
  54. package/dist/checks/valid-content-for-argument-types/index.js.map +1 -1
  55. package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
  56. package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
  57. package/dist/checks/variable-name/index.js +4 -0
  58. package/dist/checks/variable-name/index.js.map +1 -1
  59. package/dist/context-utils.d.ts +2 -1
  60. package/dist/context-utils.js +31 -1
  61. package/dist/context-utils.js.map +1 -1
  62. package/dist/doc-generator/DocBlockGenerator.d.ts +16 -0
  63. package/dist/doc-generator/DocBlockGenerator.js +464 -0
  64. package/dist/doc-generator/DocBlockGenerator.js.map +1 -0
  65. package/dist/doc-generator/index.d.ts +1 -0
  66. package/dist/doc-generator/index.js +6 -0
  67. package/dist/doc-generator/index.js.map +1 -0
  68. package/dist/frontmatter/index.d.ts +59 -0
  69. package/dist/frontmatter/index.js +301 -0
  70. package/dist/frontmatter/index.js.map +1 -0
  71. package/dist/index.d.ts +3 -1
  72. package/dist/index.js +6 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/liquid-doc/arguments.js +9 -0
  75. package/dist/liquid-doc/arguments.js.map +1 -1
  76. package/dist/liquid-doc/utils.d.ts +10 -2
  77. package/dist/liquid-doc/utils.js +26 -1
  78. package/dist/liquid-doc/utils.js.map +1 -1
  79. package/dist/path.d.ts +1 -1
  80. package/dist/path.js +3 -1
  81. package/dist/path.js.map +1 -1
  82. package/dist/to-schema.d.ts +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/types.d.ts +8 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/url-helpers.d.ts +55 -0
  87. package/dist/url-helpers.js +334 -0
  88. package/dist/url-helpers.js.map +1 -0
  89. package/dist/utils/block.js.map +1 -1
  90. package/dist/utils/index.d.ts +1 -0
  91. package/dist/utils/index.js +1 -0
  92. package/dist/utils/index.js.map +1 -1
  93. package/dist/utils/levenshtein.d.ts +3 -0
  94. package/dist/utils/levenshtein.js +39 -0
  95. package/dist/utils/levenshtein.js.map +1 -0
  96. package/package.json +2 -2
  97. package/src/AugmentedPlatformOSDocset.ts +1 -0
  98. package/src/checks/deprecated-filter/index.spec.ts +41 -1
  99. package/src/checks/deprecated-filter/index.ts +17 -0
  100. package/src/checks/graphql/index.spec.ts +173 -0
  101. package/src/checks/graphql/index.ts +21 -10
  102. package/src/checks/index.ts +6 -0
  103. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
  104. package/src/checks/invalid-hash-assign-target/index.ts +6 -4
  105. package/src/checks/missing-page/index.spec.ts +755 -0
  106. package/src/checks/missing-page/index.ts +89 -0
  107. package/src/checks/missing-partial/index.spec.ts +361 -0
  108. package/src/checks/missing-partial/index.ts +39 -47
  109. package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
  110. package/src/checks/missing-render-partial-arguments/index.ts +44 -0
  111. package/src/checks/nested-graphql-query/index.spec.ts +175 -0
  112. package/src/checks/nested-graphql-query/index.ts +203 -0
  113. package/src/checks/parser-blocking-script/index.spec.ts +7 -3
  114. package/src/checks/translation-key-exists/index.spec.ts +79 -2
  115. package/src/checks/translation-key-exists/index.ts +18 -27
  116. package/src/checks/translation-utils.ts +63 -0
  117. package/src/checks/undefined-object/index.spec.ts +153 -19
  118. package/src/checks/undefined-object/index.ts +43 -19
  119. package/src/checks/unknown-property/index.spec.ts +133 -0
  120. package/src/checks/unknown-property/index.ts +84 -10
  121. package/src/checks/unknown-property/property-shape.ts +15 -1
  122. package/src/checks/unused-assign/index.spec.ts +75 -1
  123. package/src/checks/unused-assign/index.ts +26 -1
  124. package/src/checks/unused-doc-param/index.spec.ts +4 -2
  125. package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
  126. package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
  127. package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
  128. package/src/checks/variable-name/index.spec.ts +10 -1
  129. package/src/checks/variable-name/index.ts +5 -0
  130. package/src/context-utils.ts +33 -1
  131. package/src/frontmatter/index.ts +344 -0
  132. package/src/index.ts +6 -0
  133. package/src/liquid-doc/arguments.ts +9 -0
  134. package/src/liquid-doc/utils.ts +26 -2
  135. package/src/path.ts +2 -0
  136. package/src/types.ts +9 -1
  137. package/src/url-helpers.spec.ts +241 -0
  138. package/src/url-helpers.ts +363 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/levenshtein.ts +41 -0
@@ -145,6 +145,116 @@ describe('Module: UndefinedObject', () => {
145
145
  expect(offenses).toHaveLength(0);
146
146
  });
147
147
 
148
+ it('should report an offense when function result variable is used before its definition', async () => {
149
+ const sourceCode = `
150
+ {{ a }}
151
+ {% function a = 'test' %}
152
+ `;
153
+
154
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
155
+
156
+ expect(offenses).toHaveLength(1);
157
+ expect(offenses[0].message).toBe("Unknown object 'a' used.");
158
+ });
159
+
160
+ it('should not report an offense for multiple function result variables', async () => {
161
+ const sourceCode = `
162
+ {% function result1 = 'partial_one' %}
163
+ {% function result2 = 'partial_two' %}
164
+ {{ result1 }}
165
+ {{ result2 }}
166
+ `;
167
+
168
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
169
+
170
+ expect(offenses).toHaveLength(0);
171
+ });
172
+
173
+ it('should not register a scope variable when function target is a hash/array access', async () => {
174
+ const sourceCode = `
175
+ {% parse_json my_hash %}{"key": "value"}{% endparse_json %}
176
+ {% function my_hash['result'] = 'test' %}
177
+ {{ my_hash }}
178
+ `;
179
+
180
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
181
+
182
+ // my_hash is defined via parse_json; function hash-access target does not shadow it
183
+ expect(offenses).toHaveLength(0);
184
+ });
185
+
186
+ it('should report an offense when a variable partial in include is undefined', async () => {
187
+ const sourceCode = `
188
+ {% include undefined_partial %}
189
+ `;
190
+
191
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
192
+
193
+ expect(offenses).toHaveLength(1);
194
+ expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
195
+ });
196
+
197
+ it('should not report an offense when a variable partial in include is defined', async () => {
198
+ const sourceCode = `
199
+ {% assign partial_name = 'some/partial' %}
200
+ {% include partial_name %}
201
+ `;
202
+
203
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
204
+
205
+ expect(offenses).toHaveLength(0);
206
+ });
207
+
208
+ it('should report an offense when a variable partial in function is undefined', async () => {
209
+ const sourceCode = `
210
+ {% function result = undefined_partial %}
211
+ `;
212
+
213
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
214
+
215
+ expect(offenses).toHaveLength(1);
216
+ expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
217
+ });
218
+
219
+ it('should not report an offense for the result variable itself in function tag', async () => {
220
+ const sourceCode = `
221
+ {% function result = undefined_partial %}
222
+ `;
223
+
224
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
225
+
226
+ // only 'undefined_partial' should be reported, not 'result'
227
+ expect(offenses.every((o) => o.message !== "Unknown object 'result' used.")).toBe(true);
228
+ });
229
+
230
+ it('should report offenses for lookup key variables in function result target and partial', async () => {
231
+ const sourceCode = `
232
+ {% parse_json my_hash %}{}{% endparse_json %}
233
+ {% function my_hash[lookup_key] = my_hash[path_var] %}
234
+ `;
235
+
236
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
237
+
238
+ const messages = offenses.map((o) => o.message);
239
+ // lookup_key and path_var are undefined; my_hash is defined
240
+ expect(messages).toContain("Unknown object 'lookup_key' used.");
241
+ expect(messages).toContain("Unknown object 'path_var' used.");
242
+ expect(messages).not.toContain("Unknown object 'my_hash' used.");
243
+ });
244
+
245
+ it('should check the partial variable in function but not the hash-access result target base', async () => {
246
+ const sourceCode = `
247
+ {% parse_json my_hash %}{}{% endparse_json %}
248
+ {% function my_hash['key'] = undefined_partial %}
249
+ `;
250
+
251
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
252
+
253
+ const messages = offenses.map((o) => o.message);
254
+ expect(messages).toContain("Unknown object 'undefined_partial' used.");
255
+ expect(messages).not.toContain("Unknown object 'my_hash' used.");
256
+ });
257
+
148
258
  it('should report an offense when object is defined in a for loop but used outside of the scope (in scenarios where the same variable has multiple scopes in the file)', async () => {
149
259
  const sourceCode = `
150
260
  {% for c in collections %}
@@ -359,10 +469,10 @@ describe('Module: UndefinedObject', () => {
359
469
  expect(offenses[0].message).toBe("Unknown object 'my_var' used.");
360
470
  });
361
471
 
362
- it('should report an offense when job_id is used inside background block', async () => {
472
+ it('should report an offense when undefined variable is used inside background block', async () => {
363
473
  const sourceCode = `
364
- {% background job_id %}
365
- {{ job_id }}
474
+ {% background source_type: 'some form' %}
475
+ {{ undefined_var }}
366
476
  {% endbackground %}
367
477
  `;
368
478
 
@@ -371,12 +481,10 @@ describe('Module: UndefinedObject', () => {
371
481
  expect(offenses).toHaveLength(1);
372
482
  });
373
483
 
374
- it('should not report an offense when job_id is used after background block', async () => {
484
+ it('should not report an offense when job_id is used after background file-based tag', async () => {
375
485
  const sourceCode = `
376
- {% background job_id %}
377
- {% assign a = 5 %}
378
- {% endbackground %}
379
- {{ job_id }}
486
+ {% background my_job = 'some_partial' %}
487
+ {{ my_job }}
380
488
  `;
381
489
 
382
490
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
@@ -384,12 +492,10 @@ describe('Module: UndefinedObject', () => {
384
492
  expect(offenses).toHaveLength(0);
385
493
  });
386
494
 
387
- it('should not report an offense when job_id is used after background block with args', async () => {
495
+ it('should not report an offense when job_id is used after background file-based tag with named args', async () => {
388
496
  const sourceCode = `
389
- {% background job_id, priority: 'low' %}
390
- {% assign a = 5 %}
391
- {% endbackground %}
392
- {{ job_id }}
497
+ {% background my_job = 'some_partial', source_type: 'some form' %}
498
+ {{ my_job }}
393
499
  `;
394
500
 
395
501
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
@@ -397,18 +503,16 @@ describe('Module: UndefinedObject', () => {
397
503
  expect(offenses).toHaveLength(0);
398
504
  });
399
505
 
400
- it('should report an offense when job_id is used before background block', async () => {
506
+ it('should report an offense when job_id is used before background file-based tag', async () => {
401
507
  const sourceCode = `
402
- {{ job_id }}
403
- {% background job_id %}
404
- {% assign a = 5 %}
405
- {% endbackground %}
508
+ {{ my_job }}
509
+ {% background my_job = 'some_partial' %}
406
510
  `;
407
511
 
408
512
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
409
513
 
410
514
  expect(offenses).toHaveLength(1);
411
- expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'job_id' used."]);
515
+ expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_job' used."]);
412
516
  });
413
517
 
414
518
  it('should not report an offense when object is defined with a parse_json tag', async () => {
@@ -437,4 +541,34 @@ describe('Module: UndefinedObject', () => {
437
541
  expect(offenses).toHaveLength(1);
438
542
  expect(offenses[0].message).toBe("Unknown object 'groups_data' used.");
439
543
  });
544
+
545
+ it('should not report an offense for catch variable inside catch block', async () => {
546
+ const sourceCode = `
547
+ {% try %}
548
+ {{ "something" }}
549
+ {% catch error %}
550
+ {{ error }}
551
+ {% endtry %}
552
+ `;
553
+
554
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
555
+
556
+ expect(offenses).toHaveLength(0);
557
+ });
558
+
559
+ it('should report an offense for catch variable used outside catch block', async () => {
560
+ const sourceCode = `
561
+ {% try %}
562
+ {{ "something" }}
563
+ {% catch error %}
564
+ {{ error }}
565
+ {% endtry %}
566
+ {{ error }}
567
+ `;
568
+
569
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
570
+
571
+ expect(offenses).toHaveLength(1);
572
+ expect(offenses[0].message).toBe("Unknown object 'error' used.");
573
+ });
440
574
  });
@@ -9,7 +9,6 @@ import {
9
9
  LiquidTagIncrement,
10
10
  LiquidTagTablerow,
11
11
  LiquidVariableLookup,
12
- LiquidTagFunction,
13
12
  NamedTags,
14
13
  NodeTypes,
15
14
  Position,
@@ -18,11 +17,12 @@ import {
18
17
  LiquidTagGraphQL,
19
18
  LiquidTagParseJson,
20
19
  LiquidTagBackground,
21
- BackgroundInlineMarkup,
20
+ BackgroundMarkup,
22
21
  } from '@platformos/liquid-html-parser';
23
22
  import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types';
24
23
  import { isError, last } from '../../utils';
25
24
  import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
25
+ import yaml from 'js-yaml';
26
26
 
27
27
  type Scope = { start?: number; end?: number };
28
28
 
@@ -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,26 @@ 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,
157
+ });
158
+ }
159
+ },
160
+
161
+ async LiquidBranch(node, ancestors) {
162
+ if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
163
+
164
+ // {% try %} ... {% catch error %} registers the error variable
165
+ if (
166
+ node.name === NamedTags.catch &&
167
+ node.markup &&
168
+ typeof node.markup !== 'string' &&
169
+ 'name' in node.markup &&
170
+ node.markup.name
171
+ ) {
172
+ indexVariableScope(node.markup.name, {
173
+ start: node.blockStartPosition.end,
174
+ end: node.blockEndPosition?.start,
153
175
  });
154
176
  }
155
177
  },
@@ -160,9 +182,10 @@ export const UndefinedObject: LiquidCheckDefinition = {
160
182
  const parent = last(ancestors);
161
183
  if (isLiquidTag(parent) && isLiquidTagCapture(parent)) return;
162
184
  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;
185
+ // Skip the result variable of function tags (it's a definition, not a usage)
186
+ if (isFunctionMarkup(parent) && parent.name === node) return;
187
+ // Skip the error variable definition in catch branches
188
+ if (isLiquidBranchCatch(parent) && parent.markup === node) return;
166
189
 
167
190
  variables.push(node);
168
191
  },
@@ -299,19 +322,20 @@ function isLiquidTagDecrement(node: LiquidTag): node is LiquidTagDecrement {
299
322
 
300
323
  function isLiquidTagBackground(
301
324
  node: LiquidTag,
302
- ): node is LiquidTagBackground & { markup: BackgroundInlineMarkup } {
325
+ ): node is LiquidTagBackground & { markup: BackgroundMarkup } {
303
326
  return (
304
327
  node.name === NamedTags.background &&
305
328
  typeof node.markup !== 'string' &&
306
- node.markup.type === NodeTypes.BackgroundInlineMarkup
329
+ node.markup.type === NodeTypes.BackgroundMarkup
307
330
  );
308
331
  }
309
332
 
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
- );
333
+ function isFunctionMarkup(node?: LiquidHtmlNode): node is FunctionMarkup {
334
+ return node?.type === NodeTypes.FunctionMarkup;
335
+ }
336
+
337
+ function isLiquidBranchCatch(
338
+ node?: LiquidHtmlNode,
339
+ ): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } {
340
+ return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch;
317
341
  }
@@ -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;