@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.
@@ -1,31 +1,9 @@
1
1
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
2
- import yaml from 'js-yaml';
3
2
  import { DocumentsLocator, DocumentType } from '@platformos/platformos-common';
4
3
  import { URI } from 'vscode-uri';
5
4
  import { LiquidNamedArgument, Position } from '@platformos/liquid-html-parser';
6
5
  import { relative } from '../../path';
7
-
8
- type Metadata = {
9
- metadata: {
10
- params: Record<string, unknown>;
11
- };
12
- };
13
-
14
- function extractMetadataParams(source: string): string[] | null {
15
- source = source.trim();
16
- if (!source.startsWith('---')) return null;
17
-
18
- const end = source.indexOf('---', 3);
19
- if (end === -1) return null;
20
-
21
- const yamlBlock = source.slice(3, end).trim();
22
- try {
23
- const result = yaml.load(yamlBlock) as Metadata;
24
- return Object.keys(result.metadata.params);
25
- } catch (e) {
26
- return null;
27
- }
28
- }
6
+ import { extractUndefinedVariables } from './extract-undefined-variables';
29
7
 
30
8
  export const MetadataParamsCheck: LiquidCheckDefinition = {
31
9
  meta: {
@@ -33,7 +11,7 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
33
11
  name: 'Metadata Params Check',
34
12
  docs: {
35
13
  description:
36
- 'Ensures that parameters referenced in the document exist in metadata.params or in the doc tag.',
14
+ 'Ensures that parameters referenced in the document exist in the doc tag or are inferred from undefined variables.',
37
15
  recommended: true,
38
16
  url: undefined,
39
17
  },
@@ -61,21 +39,60 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
61
39
  if (!locatedFile) {
62
40
  return;
63
41
  }
64
- let params = extractMetadataParams(await context.fs.readFile(locatedFile));
65
- if (!params) {
66
- if (!context.getDocDefinition) return;
67
- const relativePath = relative(locatedFile, context.config.rootUri);
68
- const docDef = await context.getDocDefinition(relativePath);
69
- if (!docDef?.liquidDoc?.parameters) return;
70
- const liquidDocParameters = new Map(docDef.liquidDoc.parameters.map((p) => [p.name, p]));
71
42
 
72
- params = Array.from(liquidDocParameters.values())
43
+ const source = await context.fs.readFile(locatedFile);
44
+ const relativePath = relative(locatedFile, context.config.rootUri);
45
+
46
+ let requiredParams: string[];
47
+ let allowedParams: string[];
48
+
49
+ // Check for @doc tag first — if present, it's the complete param list
50
+ const docDef = context.getDocDefinition
51
+ ? await context.getDocDefinition(relativePath)
52
+ : undefined;
53
+
54
+ if (docDef?.liquidDoc?.parameters) {
55
+ const globalObjectNames: string[] = [];
56
+ if (context.platformosDocset) {
57
+ const objects = await context.platformosDocset.objects();
58
+ for (const obj of objects) {
59
+ if (!obj.access || obj.access.global === true || obj.access.template.length > 0) {
60
+ globalObjectNames.push(obj.name);
61
+ }
62
+ }
63
+ }
64
+ const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
65
+ const docRequiredNames = docDef.liquidDoc.parameters
73
66
  .filter((p) => p.required)
74
67
  .map((p) => p.name);
68
+ requiredParams = docRequiredNames.filter((name) => undefinedVars.includes(name));
69
+ allowedParams = docDef.liquidDoc.parameters.map((p) => p.name);
70
+ } else {
71
+ // No @doc — scan for undefined variables, treat all as required
72
+ const globalObjectNames: string[] = [];
73
+ if (context.platformosDocset) {
74
+ const objects = await context.platformosDocset.objects();
75
+ for (const obj of objects) {
76
+ if (!obj.access || obj.access.global === true || obj.access.template.length > 0) {
77
+ globalObjectNames.push(obj.name);
78
+ }
79
+ }
80
+ }
81
+ if (relativePath.includes('views/partials/') || relativePath.includes('/lib/')) {
82
+ if (!globalObjectNames.includes('app')) {
83
+ globalObjectNames.push('app');
84
+ }
85
+ }
86
+
87
+ const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
88
+ if (undefinedVars.length === 0) return;
89
+
90
+ requiredParams = undefinedVars;
91
+ allowedParams = undefinedVars;
75
92
  }
76
93
 
77
94
  args
78
- .filter((arg) => !params.includes(arg.name))
95
+ .filter((arg) => !allowedParams.includes(arg.name))
79
96
  .forEach((arg) => {
80
97
  context.report({
81
98
  message: `Unknown parameter ${arg.name} passed to ${nodeType} call`,
@@ -84,7 +101,7 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
84
101
  });
85
102
  });
86
103
 
87
- params
104
+ requiredParams
88
105
  .filter((param) => !args.find((arg) => arg.name === param))
89
106
  .forEach((param) => {
90
107
  context.report({
@@ -1,4 +1,4 @@
1
- import { FileType, TranslationProvider } from '@platformos/platformos-common';
1
+ import { AbstractFileSystem, FileType, TranslationProvider } from '@platformos/platformos-common';
2
2
  import { flattenTranslationKeys } from '../utils/levenshtein';
3
3
 
4
4
  /**
@@ -6,7 +6,7 @@ import { flattenTranslationKeys } from '../utils/levenshtein';
6
6
  * Returns a deduplicated set of module names.
7
7
  */
8
8
  export async function discoverModules(
9
- fs: { readDirectory(uri: string): Promise<[string, FileType][]> },
9
+ fs: AbstractFileSystem,
10
10
  ...moduleDirUris: string[]
11
11
  ): Promise<Set<string>> {
12
12
  const modules = new Set<string>();
@@ -18,15 +18,15 @@ export async function discoverModules(
18
18
  modules.add(entryUri.split('/').pop()!);
19
19
  }
20
20
  }
21
- } catch (error) {
22
- console.error(`[translation-utils] Failed to read module directory ${dirUri}:`, error);
21
+ } catch {
22
+ // Directory doesn't exist or isn't accessible — skip
23
23
  }
24
24
  }
25
25
  return modules;
26
26
  }
27
27
 
28
28
  export interface TranslationContext {
29
- fs: { readDirectory(uri: string): Promise<[string, FileType][]> };
29
+ fs: AbstractFileSystem;
30
30
  toUri(relativePath: string): string;
31
31
  getTranslationsForBase(uri: string, locale: string): Promise<Record<string, any>>;
32
32
  }
@@ -4,13 +4,25 @@ import { runLiquidCheck, highlightedOffenses } from '../../test';
4
4
  import { Offense } from '../../types';
5
5
 
6
6
  describe('Module: UndefinedObject', () => {
7
- it('should report an offense when object is undefined', async () => {
7
+ it('should not report offenses when no doc tag is present', async () => {
8
8
  const sourceCode = `
9
9
  {{ my_var }}
10
10
  `;
11
11
 
12
12
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
13
13
 
14
+ expect(offenses).toHaveLength(0);
15
+ });
16
+
17
+ it('should report an offense when object is undefined and doc tag is present', async () => {
18
+ const sourceCode = `
19
+ {% doc %}
20
+ {% enddoc %}
21
+ {{ my_var }}
22
+ `;
23
+
24
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
25
+
14
26
  expect(offenses).toHaveLength(1);
15
27
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_var' used."]);
16
28
 
@@ -18,8 +30,10 @@ describe('Module: UndefinedObject', () => {
18
30
  expect(highlights).toEqual(['my_var']);
19
31
  });
20
32
 
21
- it('should report an offense when object with an attribute is undefined', async () => {
33
+ it('should report an offense when object with an attribute is undefined and doc tag is present', async () => {
22
34
  const sourceCode = `
35
+ {% doc %}
36
+ {% enddoc %}
23
37
  {{ my_var.my_attr }}
24
38
  `;
25
39
 
@@ -29,8 +43,10 @@ describe('Module: UndefinedObject', () => {
29
43
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_var' used."]);
30
44
  });
31
45
 
32
- it('should report an offense when undefined object is used as an argument', async () => {
46
+ it('should report an offense when undefined object is used as an argument with doc tag', async () => {
33
47
  const sourceCode = `
48
+ {% doc %}
49
+ {% enddoc %}
34
50
  {{ product[my_object] }}
35
51
  {{ product[my_object] }}
36
52
 
@@ -47,12 +63,14 @@ describe('Module: UndefinedObject', () => {
47
63
  ]);
48
64
  });
49
65
 
50
- it('should report an offense when object is undefined in a Liquid tag', async () => {
66
+ it('should report an offense when object is undefined in a Liquid tag with doc tag', async () => {
51
67
  const sourceCode = `
52
- {% liquid
53
- echo my_var
54
- %}
55
- `;
68
+ {% doc %}
69
+ {% enddoc %}
70
+ {% liquid
71
+ echo my_var
72
+ %}
73
+ `;
56
74
 
57
75
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
58
76
 
@@ -121,8 +139,11 @@ describe('Module: UndefinedObject', () => {
121
139
  expect(offenses).toHaveLength(0);
122
140
  });
123
141
 
124
- it('should report an offense when object is defined in a for loop but used outside of the scope', async () => {
142
+ it('should report an offense when object is defined in a for loop but used outside of the scope with doc tag', async () => {
125
143
  const sourceCode = `
144
+ {% doc %}
145
+ @param {Array} collections
146
+ {% enddoc %}
126
147
  {% for c in collections %}
127
148
  {{ c }}
128
149
  {% endfor %}{{ c }}
@@ -145,8 +166,10 @@ describe('Module: UndefinedObject', () => {
145
166
  expect(offenses).toHaveLength(0);
146
167
  });
147
168
 
148
- it('should report an offense when function result variable is used before its definition', async () => {
169
+ it('should report an offense when function result variable is used before its definition with doc tag', async () => {
149
170
  const sourceCode = `
171
+ {% doc %}
172
+ {% enddoc %}
150
173
  {{ a }}
151
174
  {% function a = 'test' %}
152
175
  `;
@@ -183,15 +206,14 @@ describe('Module: UndefinedObject', () => {
183
206
  expect(offenses).toHaveLength(0);
184
207
  });
185
208
 
186
- it('should report an offense when a variable partial in include is undefined', async () => {
209
+ it('should not report offenses for undefined partials without doc tag', async () => {
187
210
  const sourceCode = `
188
211
  {% include undefined_partial %}
189
212
  `;
190
213
 
191
214
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
192
215
 
193
- expect(offenses).toHaveLength(1);
194
- expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
216
+ expect(offenses).toHaveLength(0);
195
217
  });
196
218
 
197
219
  it('should not report an offense when a variable partial in include is defined', async () => {
@@ -205,19 +227,20 @@ describe('Module: UndefinedObject', () => {
205
227
  expect(offenses).toHaveLength(0);
206
228
  });
207
229
 
208
- it('should report an offense when a variable partial in function is undefined', async () => {
230
+ it('should not report offenses for undefined function partials without doc tag', async () => {
209
231
  const sourceCode = `
210
232
  {% function result = undefined_partial %}
211
233
  `;
212
234
 
213
235
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
214
236
 
215
- expect(offenses).toHaveLength(1);
216
- expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
237
+ expect(offenses).toHaveLength(0);
217
238
  });
218
239
 
219
240
  it('should not report an offense for the result variable itself in function tag', async () => {
220
241
  const sourceCode = `
242
+ {% doc %}
243
+ {% enddoc %}
221
244
  {% function result = undefined_partial %}
222
245
  `;
223
246
 
@@ -227,8 +250,10 @@ describe('Module: UndefinedObject', () => {
227
250
  expect(offenses.every((o) => o.message !== "Unknown object 'result' used.")).toBe(true);
228
251
  });
229
252
 
230
- it('should report offenses for lookup key variables in function result target and partial', async () => {
253
+ it('should report offenses for lookup key variables in function result target and partial with doc tag', async () => {
231
254
  const sourceCode = `
255
+ {% doc %}
256
+ {% enddoc %}
232
257
  {% parse_json my_hash %}{}{% endparse_json %}
233
258
  {% function my_hash[lookup_key] = my_hash[path_var] %}
234
259
  `;
@@ -242,8 +267,10 @@ describe('Module: UndefinedObject', () => {
242
267
  expect(messages).not.toContain("Unknown object 'my_hash' used.");
243
268
  });
244
269
 
245
- it('should check the partial variable in function but not the hash-access result target base', async () => {
270
+ it('should check the partial variable in function but not the hash-access result target base with doc tag', async () => {
246
271
  const sourceCode = `
272
+ {% doc %}
273
+ {% enddoc %}
247
274
  {% parse_json my_hash %}{}{% endparse_json %}
248
275
  {% function my_hash['key'] = undefined_partial %}
249
276
  `;
@@ -255,8 +282,11 @@ describe('Module: UndefinedObject', () => {
255
282
  expect(messages).not.toContain("Unknown object 'my_hash' used.");
256
283
  });
257
284
 
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 () => {
285
+ it('should report an offense when object is defined in a for loop but used outside of the scope (multiple scopes) with doc tag', async () => {
259
286
  const sourceCode = `
287
+ {% doc %}
288
+ @param {Array} collections
289
+ {% enddoc %}
260
290
  {% for c in collections %}
261
291
  {% comment %} -- Scope 1 -- {% endcomment %}
262
292
  {{ c }}
@@ -278,8 +308,10 @@ describe('Module: UndefinedObject', () => {
278
308
  ]);
279
309
  });
280
310
 
281
- it('should report an offense when undefined object defines another object', async () => {
311
+ it('should report an offense when undefined object defines another object with doc tag', async () => {
282
312
  const sourceCode = `
313
+ {% doc %}
314
+ {% enddoc %}
283
315
  {% assign my_object = my_var %}
284
316
  {{ my_object }}
285
317
  `;
@@ -302,8 +334,11 @@ describe('Module: UndefinedObject', () => {
302
334
  expect(offenses).toHaveLength(0);
303
335
  });
304
336
 
305
- it('should report an offense when object is defined in a tablerow loop but used outside of the scope', async () => {
337
+ it('should report an offense when object is defined in a tablerow loop but used outside of the scope with doc tag', async () => {
306
338
  const sourceCode = `
339
+ {% doc %}
340
+ @param {Array} collections
341
+ {% enddoc %}
307
342
  {% tablerow c in collections %}
308
343
  {{ c }}
309
344
  {% endtablerow %}{{ c }}
@@ -315,8 +350,10 @@ describe('Module: UndefinedObject', () => {
315
350
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'c' used."]);
316
351
  });
317
352
 
318
- it('should contextually report on the undefined nature of the form object (defined in form tag, undefined outside)', async () => {
353
+ it('should contextually report on the undefined nature of the form object with doc tag', async () => {
319
354
  const sourceCode = `
355
+ {% doc %}
356
+ {% enddoc %}
320
357
  {% form "cart" %}
321
358
  {{ form }}
322
359
  {% endform %}{{ form }}
@@ -328,8 +365,10 @@ describe('Module: UndefinedObject', () => {
328
365
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'form' used."]);
329
366
  });
330
367
 
331
- it('should support {% layout none %}', async () => {
368
+ it('should support {% layout none %} with doc tag', async () => {
332
369
  const sourceCode = `
370
+ {% doc %}
371
+ {% enddoc %}
333
372
  {% layout none %}
334
373
  {{ none }}
335
374
  `;
@@ -352,7 +391,7 @@ describe('Module: UndefinedObject', () => {
352
391
  }
353
392
  });
354
393
 
355
- it('should report an offense when object is undefined in a "partial" file with doc tags that are missing the associated param', async () => {
394
+ it('should report an offense when object is undefined in a partial file with empty doc tag', async () => {
356
395
  const sourceCode = `
357
396
  {% doc %}
358
397
  {% enddoc %}
@@ -384,8 +423,10 @@ describe('Module: UndefinedObject', () => {
384
423
  expect(offenses).toHaveLength(0);
385
424
  });
386
425
 
387
- it('should report an offense when object is not global', async () => {
426
+ it('should report an offense when object is not global with doc tag', async () => {
388
427
  const sourceCode = `
428
+ {% doc %}
429
+ {% enddoc %}
389
430
  {{ image }}
390
431
  `;
391
432
 
@@ -409,19 +450,21 @@ describe('Module: UndefinedObject', () => {
409
450
  }
410
451
  });
411
452
 
412
- it('should support contextual exceptions for partials', async () => {
453
+ it('should not report offenses for contextual objects without doc tag', async () => {
413
454
  let offenses: Offense[];
414
455
  const contexts: [string, string][] = [['app', 'app/views/partials/theme-app-extension.liquid']];
415
456
  for (const [object, goodPath] of contexts) {
416
457
  offenses = await runLiquidCheck(UndefinedObject, `{{ ${object} }}`, goodPath);
417
458
  expect(offenses).toHaveLength(0);
418
459
  offenses = await runLiquidCheck(UndefinedObject, `{{ ${object} }}`, 'file.liquid');
419
- expect(offenses).toHaveLength(1);
460
+ expect(offenses).toHaveLength(0);
420
461
  }
421
462
  });
422
463
 
423
- it('should report an offense for forloop/tablerowloop used outside of context', async () => {
464
+ it('should report an offense for forloop/tablerowloop used outside of context with doc tag', async () => {
424
465
  const sourceCode = `
466
+ {% doc %}
467
+ {% enddoc %}
425
468
  {{ forloop }}
426
469
  {{ tablerowloop }}
427
470
  `;
@@ -458,8 +501,10 @@ describe('Module: UndefinedObject', () => {
458
501
  expect(offenses).toHaveLength(0);
459
502
  });
460
503
 
461
- it('should report an offense when assigning an undefined variable to itself', async () => {
504
+ it('should report an offense when assigning an undefined variable to itself with doc tag', async () => {
462
505
  const sourceCode = `
506
+ {% doc %}
507
+ {% enddoc %}
463
508
  {% assign my_var = my_var | default: "fallback" %}
464
509
  `;
465
510
 
@@ -469,7 +514,7 @@ describe('Module: UndefinedObject', () => {
469
514
  expect(offenses[0].message).toBe("Unknown object 'my_var' used.");
470
515
  });
471
516
 
472
- it('should report an offense when undefined variable is used inside background block', async () => {
517
+ it('should not report offenses for undefined variables inside background block without doc tag', async () => {
473
518
  const sourceCode = `
474
519
  {% background source_type: 'some form' %}
475
520
  {{ undefined_var }}
@@ -478,7 +523,7 @@ describe('Module: UndefinedObject', () => {
478
523
 
479
524
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
480
525
 
481
- expect(offenses).toHaveLength(1);
526
+ expect(offenses).toHaveLength(0);
482
527
  });
483
528
 
484
529
  it('should not report an offense when job_id is used after background file-based tag', async () => {
@@ -503,7 +548,7 @@ describe('Module: UndefinedObject', () => {
503
548
  expect(offenses).toHaveLength(0);
504
549
  });
505
550
 
506
- it('should report an offense when job_id is used before background file-based tag', async () => {
551
+ it('should not report offenses for job_id used before background tag without doc tag', async () => {
507
552
  const sourceCode = `
508
553
  {{ my_job }}
509
554
  {% background my_job = 'some_partial' %}
@@ -511,8 +556,7 @@ describe('Module: UndefinedObject', () => {
511
556
 
512
557
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
513
558
 
514
- expect(offenses).toHaveLength(1);
515
- expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_job' used."]);
559
+ expect(offenses).toHaveLength(0);
516
560
  });
517
561
 
518
562
  it('should not report an offense when object is defined with a parse_json tag', async () => {
@@ -528,8 +572,10 @@ describe('Module: UndefinedObject', () => {
528
572
  expect(offenses).toHaveLength(0);
529
573
  });
530
574
 
531
- it('should report an offense when parse_json variable is used before the tag', async () => {
575
+ it('should report an offense when parse_json variable is used before the tag with doc tag', async () => {
532
576
  const sourceCode = `
577
+ {% doc %}
578
+ {% enddoc %}
533
579
  {{ groups_data }}
534
580
  {% parse_json groups_data %}
535
581
  { "hello": "world" }
@@ -556,8 +602,91 @@ describe('Module: UndefinedObject', () => {
556
602
  expect(offenses).toHaveLength(0);
557
603
  });
558
604
 
559
- it('should report an offense for catch variable used outside catch block', async () => {
605
+ it('should report an offense for undefined variables in a page file even without doc tag', async () => {
606
+ const sourceCode = `
607
+ {{ my_var }}
608
+ `;
609
+
610
+ const offenses = await runLiquidCheck(
611
+ UndefinedObject,
612
+ sourceCode,
613
+ 'app/views/pages/home.liquid',
614
+ );
615
+
616
+ expect(offenses).toHaveLength(1);
617
+ expect(offenses[0].message).toBe("Unknown object 'my_var' used.");
618
+ });
619
+
620
+ it('should report an offense for undefined variables in a module page file even without doc tag', async () => {
621
+ const sourceCode = `
622
+ {{ my_var }}
623
+ `;
624
+
625
+ const modulePaths = [
626
+ 'modules/my_module/public/views/pages/home.liquid',
627
+ 'modules/my_module/private/views/pages/home.liquid',
628
+ 'app/modules/my_module/public/views/pages/home.liquid',
629
+ 'app/modules/my_module/private/views/pages/home.liquid',
630
+ ];
631
+
632
+ for (const pagePath of modulePaths) {
633
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode, pagePath);
634
+
635
+ expect(offenses).toHaveLength(1);
636
+ expect(offenses[0].message).toBe("Unknown object 'my_var' used.");
637
+ }
638
+ });
639
+
640
+ it('should not report offenses for global objects in a page file without doc tag', async () => {
641
+ const sourceCode = `
642
+ {{ collections }}
643
+ `;
644
+
645
+ const offenses = await runLiquidCheck(
646
+ UndefinedObject,
647
+ sourceCode,
648
+ 'app/views/pages/home.liquid',
649
+ );
650
+
651
+ expect(offenses).toHaveLength(0);
652
+ });
653
+
654
+ it('should not report offenses for assigned variables in a page file without doc tag', async () => {
655
+ const sourceCode = `
656
+ {% assign my_var = "hello" %}
657
+ {{ my_var }}
658
+ `;
659
+
660
+ const offenses = await runLiquidCheck(
661
+ UndefinedObject,
662
+ sourceCode,
663
+ 'app/views/pages/home.liquid',
664
+ );
665
+
666
+ expect(offenses).toHaveLength(0);
667
+ });
668
+
669
+ it('should respect @param in a page file with doc tag', async () => {
670
+ const sourceCode = `
671
+ {% doc %}
672
+ @param {string} text
673
+ {% enddoc %}
674
+ {{ text }}
675
+ `;
676
+
677
+ const offenses = await runLiquidCheck(
678
+ UndefinedObject,
679
+ sourceCode,
680
+ 'app/views/pages/home.liquid',
681
+ );
682
+
683
+ expect(offenses).toHaveLength(0);
684
+ });
685
+
686
+ it('should report an offense for catch variable used outside catch block with doc tag', async () => {
560
687
  const sourceCode = `
688
+ {% doc %}
689
+ {% enddoc %}
561
690
  {% try %}
562
691
  {{ "something" }}
563
692
  {% catch error %}
@@ -22,6 +22,7 @@ import {
22
22
  import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types';
23
23
  import { isError, last } from '../../utils';
24
24
  import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
25
+ import { isPage } from '../../path';
25
26
  import yaml from 'js-yaml';
26
27
 
27
28
  type Scope = { start?: number; end?: number };
@@ -58,6 +59,7 @@ export const UndefinedObject: LiquidCheckDefinition = {
58
59
  const scopedVariables: Map<string, Scope[]> = new Map();
59
60
  const fileScopedVariables: Set<string> = new Set();
60
61
  const variables: LiquidVariableLookup[] = [];
62
+ let hasDocTag = false;
61
63
 
62
64
  function indexVariableScope(variableName: string | null, scope: Scope) {
63
65
  if (!variableName) return;
@@ -74,6 +76,12 @@ export const UndefinedObject: LiquidCheckDefinition = {
74
76
  }
75
77
  },
76
78
 
79
+ async LiquidRawTag(node) {
80
+ if (node.name === 'doc') {
81
+ hasDocTag = true;
82
+ }
83
+ },
84
+
77
85
  async LiquidTag(node, ancestors) {
78
86
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
79
87
 
@@ -191,6 +199,11 @@ export const UndefinedObject: LiquidCheckDefinition = {
191
199
  },
192
200
 
193
201
  async onCodePathEnd() {
202
+ const fileIsPage = isPage(context.file.uri);
203
+
204
+ // If no @doc tag and not a page, assume undefined variables are params from caller
205
+ if (!hasDocTag && !fileIsPage) return;
206
+
194
207
  const objects = await globalObjects(platformosDocset, relativePath);
195
208
 
196
209
  objects.forEach((obj) => fileScopedVariables.add(obj.name));
@@ -347,6 +347,68 @@ 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 report unknown property on hash literal assigned via another variable', async () => {
405
+ const sourceCode = `{% assign a = {a: 5} %}{% assign b = a.b %}`;
406
+ const offenses = await runLiquidCheck(UnknownProperty, sourceCode);
407
+ expect(offenses).toHaveLength(1);
408
+ expect(offenses[0].message).toEqual("Unknown property 'b' on 'a'.");
409
+ });
410
+ });
411
+
350
412
  describe('error message formatting', () => {
351
413
  it('should include variable name in error message', async () => {
352
414
  const sourceCode = `{% assign myVar = '{"a": 1}' | parse_json %}