@platformos/platformos-check-common 0.0.16 → 0.0.18

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 (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/checks/index.d.ts +1 -1
  3. package/dist/checks/index.js +6 -2
  4. package/dist/checks/index.js.map +1 -1
  5. package/dist/checks/json-literal-quote-style/index.d.ts +2 -0
  6. package/dist/checks/json-literal-quote-style/index.js +42 -0
  7. package/dist/checks/json-literal-quote-style/index.js.map +1 -0
  8. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.d.ts +21 -0
  9. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js +60 -0
  10. package/dist/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.js.map +1 -0
  11. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.d.ts +17 -0
  12. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js +37 -0
  13. package/dist/checks/liquid-html-syntax-error/checks/InvalidOutputPush.js.map +1 -0
  14. package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js +1 -1
  15. package/dist/checks/liquid-html-syntax-error/index.js +17 -0
  16. package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
  17. package/dist/checks/partial-call-arguments/extract-undefined-variables.d.ts +14 -0
  18. package/dist/checks/partial-call-arguments/extract-undefined-variables.js +234 -0
  19. package/dist/checks/partial-call-arguments/extract-undefined-variables.js.map +1 -0
  20. package/dist/checks/partial-call-arguments/index.d.ts +2 -0
  21. package/dist/checks/partial-call-arguments/index.js +117 -0
  22. package/dist/checks/partial-call-arguments/index.js.map +1 -0
  23. package/dist/checks/unknown-property/index.js +22 -2
  24. package/dist/checks/unknown-property/index.js.map +1 -1
  25. package/dist/checks/valid-frontmatter/index.d.ts +2 -0
  26. package/dist/checks/valid-frontmatter/index.js +279 -0
  27. package/dist/checks/valid-frontmatter/index.js.map +1 -0
  28. package/dist/frontmatter/index.d.ts +1 -59
  29. package/dist/frontmatter/index.js +6 -298
  30. package/dist/frontmatter/index.js.map +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +2 -2
  33. package/src/checks/index.ts +6 -2
  34. package/src/checks/json-literal-quote-style/index.spec.ts +129 -0
  35. package/src/checks/json-literal-quote-style/index.ts +45 -0
  36. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.spec.ts +422 -0
  37. package/src/checks/liquid-html-syntax-error/checks/InvalidAssignSyntax.ts +63 -0
  38. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.spec.ts +104 -0
  39. package/src/checks/liquid-html-syntax-error/checks/InvalidOutputPush.ts +39 -0
  40. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +86 -2
  41. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +1 -1
  42. package/src/checks/liquid-html-syntax-error/index.ts +19 -0
  43. package/src/checks/partial-call-arguments/extract-undefined-variables.spec.ts +218 -0
  44. package/src/checks/{metadata-params → partial-call-arguments}/extract-undefined-variables.ts +31 -6
  45. package/src/checks/partial-call-arguments/index.spec.ts +436 -0
  46. package/src/checks/{metadata-params → partial-call-arguments}/index.ts +18 -11
  47. package/src/checks/undefined-object/index.spec.ts +101 -0
  48. package/src/checks/unknown-property/index.spec.ts +42 -0
  49. package/src/checks/unknown-property/index.ts +24 -2
  50. package/src/checks/valid-frontmatter/index.spec.ts +666 -0
  51. package/src/checks/valid-frontmatter/index.ts +344 -0
  52. package/src/frontmatter/index.ts +9 -344
  53. package/src/checks/metadata-params/extract-undefined-variables.spec.ts +0 -115
  54. package/src/checks/metadata-params/index.spec.ts +0 -257
@@ -8,6 +8,7 @@ import {
8
8
  LiquidTagIncrement,
9
9
  LiquidTagTablerow,
10
10
  LiquidVariableLookup,
11
+ LiquidVariable,
11
12
  NamedTags,
12
13
  NodeTypes,
13
14
  Position,
@@ -25,7 +26,10 @@ type Scope = { start?: number; end?: number };
25
26
 
26
27
  /**
27
28
  * Parses a Liquid source string and returns a deduplicated list of variable names
28
- * that are used but never defined. Returns [] on parse errors.
29
+ * that are used but never defined. Returns `{ required: [], optional: [] }` on parse errors.
30
+ *
31
+ * Variables used exclusively with `| default` filter (e.g. `assign x = x | default: val`)
32
+ * are returned in `optional` — the partial handles the missing-argument case itself.
29
33
  *
30
34
  * This mirrors the variable tracking logic from the UndefinedObject check but
31
35
  * packaged as a standalone synchronous function.
@@ -33,17 +37,18 @@ type Scope = { start?: number; end?: number };
33
37
  export function extractUndefinedVariables(
34
38
  source: string,
35
39
  globalObjectNames: string[] = [],
36
- ): string[] {
40
+ ): { required: string[]; optional: string[] } {
37
41
  let ast;
38
42
  try {
39
43
  ast = toLiquidHtmlAST(source);
40
44
  } catch {
41
- return [];
45
+ return { required: [], optional: [] };
42
46
  }
43
47
 
44
48
  const scopedVariables: Map<string, Scope[]> = new Map();
45
49
  const fileScopedVariables: Set<string> = new Set(globalObjectNames);
46
50
  const variables: LiquidVariableLookup[] = [];
51
+ const variablesWithDefault: Set<string> = new Set();
47
52
 
48
53
  function indexVariableScope(variableName: string | null, scope: Scope) {
49
54
  if (!variableName) return;
@@ -166,13 +171,25 @@ export function extractUndefinedVariables(
166
171
  if (isHashAssignMarkup(parent) && parent.target === node) return;
167
172
 
168
173
  variables.push(node);
174
+
175
+ // Detect `x | default: ...` — the variable is the expression of a LiquidVariable
176
+ // that has a `default` filter, meaning the partial handles the missing case itself.
177
+ if (
178
+ node.name &&
179
+ isLiquidVariable(parent) &&
180
+ parent.expression === node &&
181
+ parent.filters.some((f) => f.name === 'default')
182
+ ) {
183
+ variablesWithDefault.add(node.name);
184
+ }
169
185
  }
170
186
 
171
187
  walk(ast, []);
172
188
 
173
189
  // Determine undefined variables
174
190
  const seen = new Set<string>();
175
- const result: string[] = [];
191
+ const required: string[] = [];
192
+ const optional: string[] = [];
176
193
 
177
194
  for (const variable of variables) {
178
195
  if (!variable.name) continue;
@@ -187,11 +204,15 @@ export function extractUndefinedVariables(
187
204
 
188
205
  if (!isVariableDefined) {
189
206
  seen.add(variable.name);
190
- result.push(variable.name);
207
+ if (variablesWithDefault.has(variable.name)) {
208
+ optional.push(variable.name);
209
+ } else {
210
+ required.push(variable.name);
211
+ }
191
212
  }
192
213
  }
193
214
 
194
- return result;
215
+ return { required, optional };
195
216
  }
196
217
 
197
218
  function isNode(x: any): x is LiquidHtmlNode {
@@ -284,3 +305,7 @@ function isLiquidBranchCatch(
284
305
  ): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } {
285
306
  return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch;
286
307
  }
308
+
309
+ function isLiquidVariable(node?: LiquidHtmlNode): node is LiquidVariable {
310
+ return node?.type === NodeTypes.LiquidVariable;
311
+ }
@@ -0,0 +1,436 @@
1
+ import { expect, describe, it } from 'vitest';
2
+ import { PartialCallArguments } from '.';
3
+ import { check } from '../../test';
4
+
5
+ describe('Module: PartialCallArguments', () => {
6
+ // ─── @doc-based validation ───────────────────────────────────────────────
7
+
8
+ it('should use doc tag as complete param list when present', async () => {
9
+ const file = `
10
+ {% doc %}
11
+ @param {Number} variable - param with description
12
+ @param {Number} variable2 - param with description
13
+ {% enddoc %}
14
+
15
+ {% assign a = 5 | plus: variable | plus: variable2 %}
16
+ {{ a }}
17
+ `;
18
+ const file2 = `
19
+ {% function a = 'commands/call/fileToCall', variable: 2, variable2: 12 %}
20
+ {{ a }}
21
+ `;
22
+ const files = {
23
+ 'app/lib/commands/call/fileToCall.liquid': file,
24
+ 'app/lib/caller.liquid': file2,
25
+ };
26
+
27
+ const offenses = await check(files, [PartialCallArguments]);
28
+
29
+ expect(offenses).to.have.length(0);
30
+ });
31
+
32
+ it('should report missing required doc params', async () => {
33
+ const file = `
34
+ {% doc %}
35
+ @param {Number} variable - param with description
36
+ @param {Number} variable2 - param with description
37
+ {% enddoc %}
38
+
39
+ {% assign a = 5 | plus: variable | plus: variable2 %}
40
+ {{ a }}
41
+ `;
42
+ const file2 = `
43
+ {% function a = 'commands/call/fileToCall', variable: 2 %}
44
+ {{ a }}
45
+ `;
46
+ const files = {
47
+ 'app/lib/commands/call/fileToCall.liquid': file,
48
+ 'app/lib/caller.liquid': file2,
49
+ };
50
+
51
+ const offenses = await check(files, [PartialCallArguments]);
52
+
53
+ expect(offenses).to.have.length(1);
54
+ expect(offenses).to.containOffense(
55
+ 'Required parameter variable2 must be passed to function call',
56
+ );
57
+ });
58
+
59
+ it('should report unknown params not in doc', async () => {
60
+ const file = `
61
+ {% doc %}
62
+ @param {Number} variable - param with description
63
+ {% enddoc %}
64
+
65
+ {% assign a = 5 | plus: variable %}
66
+ {{ a }}
67
+ `;
68
+ const file2 = `
69
+ {% function a = 'commands/call/fileToCall', variable: 2, extra: 12 %}
70
+ {{ a }}
71
+ `;
72
+ const files = {
73
+ 'app/lib/commands/call/fileToCall.liquid': file,
74
+ 'app/lib/caller.liquid': file2,
75
+ };
76
+
77
+ const offenses = await check(files, [PartialCallArguments]);
78
+
79
+ expect(offenses).to.have.length(1);
80
+ expect(offenses).to.containOffense('Unknown parameter extra passed to function call');
81
+ });
82
+
83
+ it('should allow doc-optional params without requiring them', async () => {
84
+ const file = `
85
+ {% doc %}
86
+ @param {String} a - required
87
+ @param {String} [b] - optional
88
+ {% enddoc %}
89
+ {{ a }}{{ b }}
90
+ `;
91
+ const file2 = `
92
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
93
+ {{ res }}
94
+ `;
95
+ const files = {
96
+ 'app/lib/commands/call/fileToCall.liquid': file,
97
+ 'app/lib/caller.liquid': file2,
98
+ };
99
+
100
+ const offenses = await check(files, [PartialCallArguments]);
101
+
102
+ expect(offenses).to.have.length(0);
103
+ });
104
+
105
+ it('should allow passing doc-optional params without reporting unknown', async () => {
106
+ const file = `
107
+ {% doc %}
108
+ @param {String} a - required
109
+ @param {String} [b] - optional
110
+ {% enddoc %}
111
+ {{ a }}{{ b }}
112
+ `;
113
+ const file2 = `
114
+ {% function res = 'commands/call/fileToCall', a: 'hello', b: 'world' %}
115
+ {{ res }}
116
+ `;
117
+ const files = {
118
+ 'app/lib/commands/call/fileToCall.liquid': file,
119
+ 'app/lib/caller.liquid': file2,
120
+ };
121
+
122
+ const offenses = await check(files, [PartialCallArguments]);
123
+
124
+ expect(offenses).to.have.length(0);
125
+ });
126
+
127
+ it('should not require doc params that are not used in source', async () => {
128
+ const file = `
129
+ {% doc %}
130
+ @param {String} a - required param
131
+ @param {String} unused - required but not used in source
132
+ {% enddoc %}
133
+ {{ a }}
134
+ `;
135
+ const file2 = `
136
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
137
+ {{ res }}
138
+ `;
139
+ const files = {
140
+ 'app/lib/commands/call/fileToCall.liquid': file,
141
+ 'app/lib/caller.liquid': file2,
142
+ };
143
+
144
+ const offenses = await check(files, [PartialCallArguments]);
145
+
146
+ expect(offenses).to.have.length(0);
147
+ });
148
+
149
+ it('should still require a doc-required param even when implementation uses | default', async () => {
150
+ // The @doc annotation is the public API contract; internal fallbacks do not change it.
151
+ const file = `
152
+ {% doc %}
153
+ @param {String} message - required by contract
154
+ {% enddoc %}
155
+ {% assign message = message | default: 'fallback' %}
156
+ {{ message }}
157
+ `;
158
+ const file2 = `
159
+ {% function res = 'commands/call/fileToCall' %}
160
+ {{ res }}
161
+ `;
162
+ const files = {
163
+ 'app/lib/commands/call/fileToCall.liquid': file,
164
+ 'app/lib/caller.liquid': file2,
165
+ };
166
+
167
+ const offenses = await check(files, [PartialCallArguments]);
168
+
169
+ expect(offenses).to.have.length(1);
170
+ expect(offenses).to.containOffense(
171
+ 'Required parameter message must be passed to function call',
172
+ );
173
+ });
174
+
175
+ // ─── Inferred validation (no @doc) ───────────────────────────────────────
176
+
177
+ it('should infer required params from undefined variables when no doc', async () => {
178
+ const file = `
179
+ {% assign b = a %}
180
+ {{ b }}
181
+ `;
182
+ const file2 = `
183
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
184
+ {{ res }}
185
+ `;
186
+ const files = {
187
+ 'app/lib/commands/call/fileToCall.liquid': file,
188
+ 'app/lib/caller.liquid': file2,
189
+ };
190
+
191
+ const offenses = await check(files, [PartialCallArguments]);
192
+
193
+ expect(offenses).to.have.length(0);
194
+ });
195
+
196
+ it('should report missing inferred params when no doc', async () => {
197
+ const file = `
198
+ {% assign b = a %}
199
+ {{ b }}
200
+ `;
201
+ const file2 = `
202
+ {% function res = 'commands/call/fileToCall' %}
203
+ {{ res }}
204
+ `;
205
+ const files = {
206
+ 'app/lib/commands/call/fileToCall.liquid': file,
207
+ 'app/lib/caller.liquid': file2,
208
+ };
209
+
210
+ const offenses = await check(files, [PartialCallArguments]);
211
+
212
+ expect(offenses).to.have.length(1);
213
+ expect(offenses).to.containOffense('Required parameter a must be passed to function call');
214
+ });
215
+
216
+ it('should report unknown params when passing args not in inferred set', async () => {
217
+ const file = `
218
+ {% assign b = a %}
219
+ {{ b }}
220
+ `;
221
+ const file2 = `
222
+ {% function res = 'commands/call/fileToCall', a: 'hello', extra: 'world' %}
223
+ {{ res }}
224
+ `;
225
+ const files = {
226
+ 'app/lib/commands/call/fileToCall.liquid': file,
227
+ 'app/lib/caller.liquid': file2,
228
+ };
229
+
230
+ const offenses = await check(files, [PartialCallArguments]);
231
+
232
+ expect(offenses).to.have.length(1);
233
+ expect(offenses).to.containOffense('Unknown parameter extra passed to function call');
234
+ });
235
+
236
+ it('should not include global objects like context in inferred params', async () => {
237
+ const file = `
238
+ {{ context.session }}
239
+ {{ a }}
240
+ `;
241
+ const file2 = `
242
+ {% function res = 'commands/call/fileToCall', a: 'hello' %}
243
+ {{ res }}
244
+ `;
245
+ const files = {
246
+ 'app/lib/commands/call/fileToCall.liquid': file,
247
+ 'app/lib/caller.liquid': file2,
248
+ };
249
+
250
+ const offenses = await check(files, [PartialCallArguments]);
251
+
252
+ expect(offenses).to.have.length(0);
253
+ });
254
+
255
+ it('should work with render tags too', async () => {
256
+ const file = `{{ a }}`;
257
+ const file2 = `{% render 'fileToRender' %}`;
258
+ const files = {
259
+ 'app/views/partials/fileToRender.liquid': file,
260
+ 'app/views/pages/caller.liquid': file2,
261
+ };
262
+
263
+ const offenses = await check(files, [PartialCallArguments]);
264
+
265
+ expect(offenses).to.have.length(1);
266
+ expect(offenses).to.containOffense('Required parameter a must be passed to render call');
267
+ });
268
+
269
+ it('should skip validation when no doc and no undefined vars', async () => {
270
+ const file = `
271
+ {% assign a = 5 %}
272
+ {{ a }}
273
+ `;
274
+ const file2 = `
275
+ {% function res = 'commands/call/fileToCall', extra: 'hello' %}
276
+ {{ res }}
277
+ `;
278
+ const files = {
279
+ 'app/lib/commands/call/fileToCall.liquid': file,
280
+ 'app/lib/caller.liquid': file2,
281
+ };
282
+
283
+ const offenses = await check(files, [PartialCallArguments]);
284
+
285
+ expect(offenses).to.have.length(0);
286
+ });
287
+
288
+ // ─── | default — inferred optional params ────────────────────────────────
289
+
290
+ it('should treat assign x = x | default: val as optional (no error when omitted)', async () => {
291
+ const file = `
292
+ {% assign message = message | default: null %}
293
+ {% assign required_param = required_param %}
294
+ {{ message }}{{ required_param }}
295
+ `;
296
+ const file2 = `
297
+ {% function res = 'commands/call/fileToCall', required_param: 'hello' %}
298
+ {{ res }}
299
+ `;
300
+ const files = {
301
+ 'app/lib/commands/call/fileToCall.liquid': file,
302
+ 'app/lib/caller.liquid': file2,
303
+ };
304
+
305
+ const offenses = await check(files, [PartialCallArguments]);
306
+
307
+ expect(offenses).to.have.length(0);
308
+ });
309
+
310
+ it('should allow passing inferred optional params without reporting unknown', async () => {
311
+ const file = `
312
+ {% assign message = message | default: null %}
313
+ {% assign required_param = required_param %}
314
+ {{ message }}{{ required_param }}
315
+ `;
316
+ const file2 = `
317
+ {% function res = 'commands/call/fileToCall', required_param: 'hello', message: 'hi' %}
318
+ {{ res }}
319
+ `;
320
+ const files = {
321
+ 'app/lib/commands/call/fileToCall.liquid': file,
322
+ 'app/lib/caller.liquid': file2,
323
+ };
324
+
325
+ const offenses = await check(files, [PartialCallArguments]);
326
+
327
+ expect(offenses).to.have.length(0);
328
+ });
329
+
330
+ it('should treat inline output {{ x | default: val }} as optional', async () => {
331
+ const file = `{{ message | default: 'fallback' }}`;
332
+ const file2 = `
333
+ {% function res = 'commands/call/fileToCall' %}
334
+ {{ res }}
335
+ `;
336
+ const files = {
337
+ 'app/lib/commands/call/fileToCall.liquid': file,
338
+ 'app/lib/caller.liquid': file2,
339
+ };
340
+
341
+ const offenses = await check(files, [PartialCallArguments]);
342
+
343
+ expect(offenses).to.have.length(0);
344
+ });
345
+
346
+ it('should treat assign y = x | default: val as x optional (different lhs/rhs)', async () => {
347
+ const file = `{% assign label = title | default: 'Untitled' %}{{ label }}`;
348
+ const file2 = `
349
+ {% function res = 'commands/call/fileToCall' %}
350
+ {{ res }}
351
+ `;
352
+ const files = {
353
+ 'app/lib/commands/call/fileToCall.liquid': file,
354
+ 'app/lib/caller.liquid': file2,
355
+ };
356
+
357
+ const offenses = await check(files, [PartialCallArguments]);
358
+
359
+ expect(offenses).to.have.length(0);
360
+ });
361
+
362
+ it('should still report unknown when caller passes arg not in optional or required set', async () => {
363
+ const file = `
364
+ {% assign message = message | default: null %}
365
+ {{ message }}
366
+ `;
367
+ const file2 = `
368
+ {% function res = 'commands/call/fileToCall', unknown_param: 'oops' %}
369
+ {{ res }}
370
+ `;
371
+ const files = {
372
+ 'app/lib/commands/call/fileToCall.liquid': file,
373
+ 'app/lib/caller.liquid': file2,
374
+ };
375
+
376
+ const offenses = await check(files, [PartialCallArguments]);
377
+
378
+ expect(offenses).to.have.length(1);
379
+ expect(offenses).to.containOffense('Unknown parameter unknown_param passed to function call');
380
+ });
381
+
382
+ it('should handle the real register_error pattern: required + optional params', async () => {
383
+ const file = `
384
+ {% liquid
385
+ assign key = key | default: null
386
+ assign message = message | default: null
387
+ assign errors = contract.errors
388
+ assign field_errors = errors[field_name] | default: blank
389
+ assign field_errors << message
390
+ assign errors[field_name] = field_errors
391
+ assign contract.valid = false
392
+ return contract
393
+ %}
394
+ `;
395
+ const file2 = `
396
+ {% function c = 'helpers/register_error', contract: c, field_name: field_name, key: key %}
397
+ `;
398
+ const files = {
399
+ 'app/lib/helpers/register_error.liquid': file,
400
+ 'app/lib/caller.liquid': file2,
401
+ };
402
+
403
+ const offenses = await check(files, [PartialCallArguments]);
404
+
405
+ expect(offenses).to.have.length(0);
406
+ });
407
+
408
+ it('should still require required params even when optional ones are present', async () => {
409
+ const file = `
410
+ {% liquid
411
+ assign key = key | default: null
412
+ assign errors = contract.errors
413
+ assign field_errors = errors[field_name] | default: blank
414
+ return contract
415
+ %}
416
+ `;
417
+ // omits both required: contract and field_name
418
+ const file2 = `
419
+ {% function c = 'helpers/register_error', key: 'some_key' %}
420
+ `;
421
+ const files = {
422
+ 'app/lib/helpers/register_error.liquid': file,
423
+ 'app/lib/caller.liquid': file2,
424
+ };
425
+
426
+ const offenses = await check(files, [PartialCallArguments]);
427
+
428
+ expect(offenses).to.have.length(2);
429
+ expect(offenses).to.containOffense(
430
+ 'Required parameter contract must be passed to function call',
431
+ );
432
+ expect(offenses).to.containOffense(
433
+ 'Required parameter field_name must be passed to function call',
434
+ );
435
+ });
436
+ });
@@ -5,15 +5,16 @@ import { LiquidNamedArgument, Position } from '@platformos/liquid-html-parser';
5
5
  import { relative } from '../../path';
6
6
  import { extractUndefinedVariables } from './extract-undefined-variables';
7
7
 
8
- export const MetadataParamsCheck: LiquidCheckDefinition = {
8
+ export const PartialCallArguments: LiquidCheckDefinition = {
9
9
  meta: {
10
- code: 'MetadataParamsCheck',
11
- name: 'Metadata Params Check',
10
+ code: 'PartialCallArguments',
11
+ aliases: ['MetadataParamsCheck'],
12
+ name: 'Partial Call Arguments',
12
13
  docs: {
13
14
  description:
14
- 'Ensures that parameters referenced in the document exist in the doc tag or are inferred from undefined variables.',
15
+ 'Ensures that all required arguments are passed at render/function call sites, and that no unknown arguments are passed. Required vs optional is determined from the {% doc %} block when present, or inferred from undefined variables in the partial source otherwise. Variables used with | default are treated as optional.',
15
16
  recommended: true,
16
- url: undefined,
17
+ url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/partial-call-arguments',
17
18
  },
18
19
  type: SourceCodeType.LiquidHtml,
19
20
  severity: Severity.ERROR,
@@ -61,14 +62,17 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
61
62
  }
62
63
  }
63
64
  }
64
- const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
65
+ const { required: undefinedRequiredVars, optional: undefinedOptionalVars } =
66
+ extractUndefinedVariables(source, globalObjectNames);
67
+ const undefinedVars = [...undefinedRequiredVars, ...undefinedOptionalVars];
65
68
  const docRequiredNames = docDef.liquidDoc.parameters
66
69
  .filter((p) => p.required)
67
70
  .map((p) => p.name);
68
71
  requiredParams = docRequiredNames.filter((name) => undefinedVars.includes(name));
69
72
  allowedParams = docDef.liquidDoc.parameters.map((p) => p.name);
70
73
  } else {
71
- // No @doc — scan for undefined variables, treat all as required
74
+ // No @doc — infer required vs optional from undefined variables in the source.
75
+ // Variables used with `| default` are treated as optional.
72
76
  const globalObjectNames: string[] = [];
73
77
  if (context.platformosDocset) {
74
78
  const objects = await context.platformosDocset.objects();
@@ -84,11 +88,14 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
84
88
  }
85
89
  }
86
90
 
87
- const undefinedVars = extractUndefinedVariables(source, globalObjectNames);
88
- if (undefinedVars.length === 0) return;
91
+ const { required: requiredVars, optional: optionalVars } = extractUndefinedVariables(
92
+ source,
93
+ globalObjectNames,
94
+ );
95
+ if (requiredVars.length === 0 && optionalVars.length === 0) return;
89
96
 
90
- requiredParams = undefinedVars;
91
- allowedParams = undefinedVars;
97
+ requiredParams = requiredVars;
98
+ allowedParams = [...requiredVars, ...optionalVars];
92
99
  }
93
100
 
94
101
  args
@@ -700,4 +700,105 @@ describe('Module: UndefinedObject', () => {
700
700
  expect(offenses).toHaveLength(1);
701
701
  expect(offenses[0].message).toBe("Unknown object 'error' used.");
702
702
  });
703
+
704
+ describe('JSON literals in assign tag', () => {
705
+ it('should report undefined bare key AND bare value in a hash literal', async () => {
706
+ // bare keys and bare values are both VariableLookups in platformOS semantics —
707
+ // they are evaluated at runtime, so both are subject to undefined-object checks
708
+ const sourceCode = `
709
+ {% doc %}
710
+ {% enddoc %}
711
+ {% assign hash = { bare_key: bare_val } %}
712
+ `;
713
+
714
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
715
+
716
+ expect(offenses.map((o) => o.message).sort()).toEqual([
717
+ "Unknown object 'bare_key' used.",
718
+ "Unknown object 'bare_val' used.",
719
+ ]);
720
+ });
721
+
722
+ it('should NOT report string keys in a hash literal (they are literals, not variables)', async () => {
723
+ const sourceCode = `
724
+ {% doc %}
725
+ {% enddoc %}
726
+ {% assign hash = { "key": "val" } %}
727
+ `;
728
+
729
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
730
+
731
+ expect(offenses).toHaveLength(0);
732
+ });
733
+
734
+ it('should report undefined bare value under a string key', async () => {
735
+ const sourceCode = `
736
+ {% doc %}
737
+ {% enddoc %}
738
+ {% assign hash = { "key": undefined_var } %}
739
+ `;
740
+
741
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
742
+
743
+ expect(offenses).toHaveLength(1);
744
+ expect(offenses[0].message).toBe("Unknown object 'undefined_var' used.");
745
+ });
746
+
747
+ it('should report undefined elements in an array literal', async () => {
748
+ const sourceCode = `
749
+ {% doc %}
750
+ {% enddoc %}
751
+ {% assign arr = [a, undefined_var] %}
752
+ `;
753
+
754
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
755
+
756
+ expect(offenses.map((o) => o.message).sort()).toEqual([
757
+ "Unknown object 'a' used.",
758
+ "Unknown object 'undefined_var' used.",
759
+ ]);
760
+ });
761
+
762
+ it('should NOT report string elements in an array literal', async () => {
763
+ const sourceCode = `
764
+ {% doc %}
765
+ {% enddoc %}
766
+ {% assign arr = ["a", "b", "c"] %}
767
+ `;
768
+
769
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
770
+
771
+ expect(offenses).toHaveLength(0);
772
+ });
773
+
774
+ it('should NOT report bare keys/values when they refer to defined variables', async () => {
775
+ const sourceCode = `
776
+ {% doc %}
777
+ {% enddoc %}
778
+ {% assign bare_key = "k" %}
779
+ {% assign bare_val = "v" %}
780
+ {% assign hash = { bare_key: bare_val } %}
781
+ `;
782
+
783
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
784
+
785
+ expect(offenses).toHaveLength(0);
786
+ });
787
+
788
+ it('should report undefined bare keys/values in nested hash literals', async () => {
789
+ const sourceCode = `
790
+ {% doc %}
791
+ {% enddoc %}
792
+ {% assign hash = { outer: { inner: value } } %}
793
+ `;
794
+
795
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
796
+
797
+ expect(offenses.map((o) => o.message).sort()).toEqual([
798
+ "Unknown object 'inner' used.",
799
+ "Unknown object 'outer' used.",
800
+ "Unknown object 'value' used.",
801
+ ]);
802
+ });
803
+ });
703
804
  });