@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
@@ -0,0 +1,666 @@
1
+ import { expect, describe, it } from 'vitest';
2
+ import { ValidFrontmatter } from '.';
3
+ import { check } from '../../test';
4
+
5
+ const PAGE = 'app/views/pages/test.html.liquid';
6
+ const FORM = 'app/forms/test.liquid';
7
+ const AUTH = 'app/authorization_policies/test.liquid';
8
+ const EMAIL = 'app/emails/test.liquid';
9
+ const SMS = 'app/smses/test.liquid';
10
+ const API_CALL = 'app/api_calls/test.liquid';
11
+ const LAYOUT = 'app/views/layouts/application.liquid';
12
+ const PARTIAL = 'app/views/partials/card.liquid';
13
+ const MIGRATION = 'app/migrations/20240101_seed.liquid';
14
+
15
+ describe('ValidFrontmatter', () => {
16
+ // ── Required fields ───────────────────────────────────────────────────────
17
+
18
+ describe('no required fields (name derived from file path)', () => {
19
+ it('does not report on Page with no frontmatter fields', async () => {
20
+ const offenses = await check({ [PAGE]: `---\nslug: /test\n---\n{{ content }}` }, [
21
+ ValidFrontmatter,
22
+ ]);
23
+ expect(offenses).to.have.length(0);
24
+ });
25
+
26
+ it('does not report on FormConfiguration with no name field', async () => {
27
+ const offenses = await check({ [FORM]: `---\nresource: User\n---\n` }, [ValidFrontmatter]);
28
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
29
+ });
30
+
31
+ it('does not report on AuthorizationPolicy with no name field', async () => {
32
+ const offenses = await check({ [AUTH]: `---\nhttp_status: 403\n---\n` }, [ValidFrontmatter]);
33
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
34
+ });
35
+
36
+ it('does not report on Email with only from field', async () => {
37
+ const offenses = await check({ [EMAIL]: `---\nfrom: sender@example.com\n---\nHi` }, [
38
+ ValidFrontmatter,
39
+ ]);
40
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
41
+ });
42
+
43
+ it('does not report on ApiCall with only to and request_type', async () => {
44
+ const offenses = await check(
45
+ { [API_CALL]: `---\nto: https://example.com\nrequest_type: GET\n---\n` },
46
+ [ValidFrontmatter],
47
+ );
48
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
49
+ });
50
+
51
+ it('does not report on SMS with only to field', async () => {
52
+ const offenses = await check({ [SMS]: `---\nto: "+15550001234"\n---\n` }, [ValidFrontmatter]);
53
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
54
+ });
55
+
56
+ it('does not report when there is no frontmatter', async () => {
57
+ const offenses = await check({ [FORM]: `{{ content }}` }, [ValidFrontmatter]);
58
+ expect(offenses).to.have.length(0);
59
+ });
60
+
61
+ it('does not report on empty frontmatter block', async () => {
62
+ const offenses = await check({ [FORM]: `---\n---\n` }, [ValidFrontmatter]);
63
+ expect(offenses.some((o) => o.message.includes('Missing required'))).toBe(false);
64
+ });
65
+ });
66
+
67
+ // ── Deprecated fields ─────────────────────────────────────────────────────
68
+
69
+ describe('deprecated fields', () => {
70
+ it('warns on layout_name on Page', async () => {
71
+ const files = {
72
+ 'app/views/layouts/application.liquid': `{{ content }}`,
73
+ [PAGE]: `---\nlayout_name: application\n---\n{{ content }}`,
74
+ };
75
+ const offenses = await check(files, [ValidFrontmatter]);
76
+ expect(offenses).to.containOffense('Use `layout` instead of `layout_name`.');
77
+ });
78
+
79
+ it('warns on deprecated redirect_url on Page', async () => {
80
+ const offenses = await check({ [PAGE]: `---\nredirect_url: /home\n---\n{{ content }}` }, [
81
+ ValidFrontmatter,
82
+ ]);
83
+ expect(offenses).to.containOffense('Use `redirect_to` instead of `redirect_url`.');
84
+ });
85
+
86
+ it('does not warn on redirect_to (non-deprecated)', async () => {
87
+ const offenses = await check({ [PAGE]: `---\nredirect_to: /home\n---\n{{ content }}` }, [
88
+ ValidFrontmatter,
89
+ ]);
90
+ expect(offenses).to.have.length(0);
91
+ });
92
+
93
+ it('warns on deprecated return_to in FormConfiguration', async () => {
94
+ const offenses = await check({ [FORM]: `---\nname: my_form\nreturn_to: /home\n---\n` }, [
95
+ ValidFrontmatter,
96
+ ]);
97
+ expect(offenses).to.containOffense('Use `redirect_to` instead of `return_to`.');
98
+ });
99
+
100
+ it('does not warn on redirect_to in FormConfiguration (non-deprecated)', async () => {
101
+ const offenses = await check({ [FORM]: `---\nname: my_form\nredirect_to: /home\n---\n` }, [
102
+ ValidFrontmatter,
103
+ ]);
104
+ expect(offenses).to.have.length(0);
105
+ });
106
+
107
+ it('warns on deprecated layout_path in Email', async () => {
108
+ const offenses = await check({ [EMAIL]: `---\nlayout_path: email_base\n---\nHi` }, [
109
+ ValidFrontmatter,
110
+ ]);
111
+ expect(offenses).to.containOffense('Use `layout` instead of `layout_path`.');
112
+ });
113
+
114
+ it('does not warn on layout in Email (non-deprecated)', async () => {
115
+ const files = {
116
+ 'app/views/layouts/email_base.liquid': `{{ content }}`,
117
+ [EMAIL]: `---\nlayout: email_base\n---\nHi`,
118
+ };
119
+ const offenses = await check(files, [ValidFrontmatter]);
120
+ expect(offenses).to.have.length(0);
121
+ });
122
+
123
+ it('warns on deprecated headers field in ApiCall', async () => {
124
+ const offenses = await check(
125
+ { [API_CALL]: `---\nto: https://example.com\nrequest_type: GET\nheaders: "{}"\n---\n` },
126
+ [ValidFrontmatter],
127
+ );
128
+ expect(offenses).to.containOffense('Use `request_headers` instead of `headers`.');
129
+ });
130
+
131
+ it('does not warn on non-deprecated fields', async () => {
132
+ const files = {
133
+ 'app/views/layouts/application.liquid': `{{ content }}`,
134
+ [PAGE]: `---\nlayout: application\n---\n{{ content }}`,
135
+ };
136
+ const offenses = await check(files, [ValidFrontmatter]);
137
+ expect(offenses).to.have.length(0);
138
+ });
139
+ });
140
+
141
+ // ── Enum validation ───────────────────────────────────────────────────────
142
+
143
+ describe('enum validation', () => {
144
+ // Page method
145
+ it('reports invalid method on Page', async () => {
146
+ const offenses = await check({ [PAGE]: `---\nmethod: invalid\n---\n{{ content }}` }, [
147
+ ValidFrontmatter,
148
+ ]);
149
+ expect(offenses).to.containOffense(
150
+ "Invalid value 'invalid' for 'method'. Must be one of: delete, get, patch, post, put, options",
151
+ );
152
+ });
153
+
154
+ it('accepts all valid method values on Page', async () => {
155
+ for (const method of ['get', 'post', 'put', 'patch', 'delete', 'options']) {
156
+ const offenses = await check({ [PAGE]: `---\nmethod: ${method}\n---\n{{ content }}` }, [
157
+ ValidFrontmatter,
158
+ ]);
159
+ expect(offenses).to.have.length(0);
160
+ }
161
+ });
162
+
163
+ it('is case-insensitive for method values', async () => {
164
+ const offenses = await check({ [PAGE]: `---\nmethod: GET\n---\n{{ content }}` }, [
165
+ ValidFrontmatter,
166
+ ]);
167
+ expect(offenses).to.have.length(0);
168
+ });
169
+
170
+ // Page redirect_code
171
+ it('reports invalid redirect_code on Page', async () => {
172
+ const offenses = await check(
173
+ { [PAGE]: `---\nredirect_to: /home\nredirect_code: 200\n---\n` },
174
+ [ValidFrontmatter],
175
+ );
176
+ expect(offenses).to.containOffense(
177
+ "Invalid value '200' for 'redirect_code'. Must be one of: 301, 302, 307",
178
+ );
179
+ });
180
+
181
+ it('accepts valid redirect_code values', async () => {
182
+ for (const code of [301, 302, 307]) {
183
+ const offenses = await check(
184
+ { [PAGE]: `---\nredirect_to: /home\nredirect_code: ${code}\n---\n` },
185
+ [ValidFrontmatter],
186
+ );
187
+ expect(offenses).to.have.length(0);
188
+ }
189
+ });
190
+
191
+ // AuthorizationPolicy http_status
192
+ it('reports invalid http_status on AuthorizationPolicy', async () => {
193
+ const offenses = await check({ [AUTH]: `---\nname: my_policy\nhttp_status: 500\n---\n` }, [
194
+ ValidFrontmatter,
195
+ ]);
196
+ expect(offenses).to.containOffense(
197
+ "Invalid value '500' for 'http_status'. Must be one of: 403, 404",
198
+ );
199
+ });
200
+
201
+ it('accepts valid http_status values', async () => {
202
+ for (const status of [403, 404]) {
203
+ const offenses = await check(
204
+ { [AUTH]: `---\nname: my_policy\nhttp_status: ${status}\n---\n` },
205
+ [ValidFrontmatter],
206
+ );
207
+ expect(offenses).to.have.length(0);
208
+ }
209
+ });
210
+
211
+ // FormConfiguration spam_protection
212
+ it('reports invalid spam_protection in FormConfiguration', async () => {
213
+ const offenses = await check(
214
+ { [FORM]: `---\nname: my_form\nspam_protection: invalid_type\n---\n` },
215
+ [ValidFrontmatter],
216
+ );
217
+ expect(offenses).to.containOffense(
218
+ "Invalid value 'invalid_type' for 'spam_protection'. Must be one of: recaptcha, recaptcha_v2, recaptcha_v3, hcaptcha",
219
+ );
220
+ });
221
+
222
+ it('accepts all valid spam_protection values', async () => {
223
+ for (const val of ['recaptcha', 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha']) {
224
+ const offenses = await check(
225
+ { [FORM]: `---\nname: my_form\nspam_protection: ${val}\n---\n` },
226
+ [ValidFrontmatter],
227
+ );
228
+ expect(offenses).to.have.length(0);
229
+ }
230
+ });
231
+
232
+ // ApiCall request_type
233
+ it('reports invalid request_type in ApiCall', async () => {
234
+ const offenses = await check(
235
+ { [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: INVALID\n---\n` },
236
+ [ValidFrontmatter],
237
+ );
238
+ expect(offenses).to.containOffense(
239
+ "Invalid value 'INVALID' for 'request_type'. Must be one of: GET, POST, PUT, PATCH, DELETE",
240
+ );
241
+ });
242
+
243
+ it('accepts all valid request_type values', async () => {
244
+ for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
245
+ const offenses = await check(
246
+ {
247
+ [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: ${method}\n---\n`,
248
+ },
249
+ [ValidFrontmatter],
250
+ );
251
+ expect(offenses).to.have.length(0);
252
+ }
253
+ });
254
+
255
+ it('is case-insensitive for request_type values', async () => {
256
+ const offenses = await check(
257
+ { [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: get\n---\n` },
258
+ [ValidFrontmatter],
259
+ );
260
+ expect(offenses).to.have.length(0);
261
+ });
262
+
263
+ it('does not validate method on non-Page files', async () => {
264
+ const offenses = await check({ [FORM]: `---\nname: my_form\nmethod: invalid\n---\n` }, [
265
+ ValidFrontmatter,
266
+ ]);
267
+ expect(offenses.some((o) => o.message.includes("for 'method'"))).toBe(false);
268
+ });
269
+ });
270
+
271
+ // ── Layout association ────────────────────────────────────────────────────
272
+
273
+ describe('layout association', () => {
274
+ // Page
275
+ it('reports missing layout file on Page', async () => {
276
+ const offenses = await check({ [PAGE]: `---\nlayout: nonexistent\n---\n{{ content }}` }, [
277
+ ValidFrontmatter,
278
+ ]);
279
+ expect(offenses).to.containOffense("Layout 'nonexistent' does not exist");
280
+ });
281
+
282
+ it('does not report when layout file exists on Page', async () => {
283
+ const files = {
284
+ 'app/views/layouts/application.liquid': `{{ content }}`,
285
+ [PAGE]: `---\nlayout: application\n---\n{{ content }}`,
286
+ };
287
+ const offenses = await check(files, [ValidFrontmatter]);
288
+ expect(offenses).to.have.length(0);
289
+ });
290
+
291
+ it('reports missing module layout (public path)', async () => {
292
+ const offenses = await check(
293
+ { [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}` },
294
+ [ValidFrontmatter],
295
+ );
296
+ expect(offenses).to.containOffense("Layout 'modules/my-module/layouts/email' does not exist");
297
+ });
298
+
299
+ it('does not report when module layout exists at public path', async () => {
300
+ const files = {
301
+ 'modules/my-module/public/views/layouts/layouts/email.liquid': `{{ content }}`,
302
+ [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}`,
303
+ };
304
+ const offenses = await check(files, [ValidFrontmatter]);
305
+ expect(offenses).to.have.length(0);
306
+ });
307
+
308
+ it('does not report when module layout exists at private path', async () => {
309
+ const files = {
310
+ 'modules/my-module/private/views/layouts/layouts/email.liquid': `{{ content }}`,
311
+ [PAGE]: `---\nlayout: modules/my-module/layouts/email\n---\n{{ content }}`,
312
+ };
313
+ const offenses = await check(files, [ValidFrontmatter]);
314
+ expect(offenses).to.have.length(0);
315
+ });
316
+
317
+ it('reports layout: false (boolean) and suggests empty string on Page', async () => {
318
+ const offenses = await check({ [PAGE]: `---\nlayout: false\n---\n{{ content }}` }, [
319
+ ValidFrontmatter,
320
+ ]);
321
+ expect(offenses).to.containOffense(
322
+ "`layout: false` falls back to the default layout. Use `layout: ''` to disable layout rendering.",
323
+ );
324
+ });
325
+
326
+ it('does not warn for layout: empty string on Page (valid disable)', async () => {
327
+ const offenses = await check({ [PAGE]: `---\nlayout: ''\n---\n{{ content }}` }, [
328
+ ValidFrontmatter,
329
+ ]);
330
+ expect(offenses.some((o) => o.message.includes('does not exist'))).toBe(false);
331
+ expect(offenses.some((o) => o.message.includes('falls back'))).toBe(false);
332
+ });
333
+
334
+ // Email layout
335
+ it('reports missing layout file on Email', async () => {
336
+ const offenses = await check({ [EMAIL]: `---\nlayout: nonexistent_email_layout\n---\nHi` }, [
337
+ ValidFrontmatter,
338
+ ]);
339
+ expect(offenses).to.containOffense("Layout 'nonexistent_email_layout' does not exist");
340
+ });
341
+
342
+ it('does not report when layout file exists on Email', async () => {
343
+ const files = {
344
+ 'app/views/layouts/email_base.liquid': `{{ content }}`,
345
+ [EMAIL]: `---\nlayout: email_base\n---\nHi`,
346
+ };
347
+ const offenses = await check(files, [ValidFrontmatter]);
348
+ expect(offenses).to.have.length(0);
349
+ });
350
+
351
+ it('reports layout: false (boolean) on Email', async () => {
352
+ const offenses = await check({ [EMAIL]: `---\nlayout: false\n---\nHi` }, [ValidFrontmatter]);
353
+ expect(offenses).to.containOffense(
354
+ "`layout: false` falls back to the default layout. Use `layout: ''` to disable layout rendering.",
355
+ );
356
+ });
357
+ });
358
+
359
+ // ── Authorization policy association ─────────────────────────────────────
360
+
361
+ describe('authorization_policies association', () => {
362
+ it('reports missing authorization policy file', async () => {
363
+ const offenses = await check(
364
+ { [PAGE]: `---\nauthorization_policies:\n - missing_policy\n---\n{{ content }}` },
365
+ [ValidFrontmatter],
366
+ );
367
+ expect(offenses).to.containOffense("Authorization policy 'missing_policy' does not exist");
368
+ });
369
+
370
+ it('does not report when authorization policy file exists', async () => {
371
+ const files = {
372
+ 'app/authorization_policies/require_login.liquid': `---\nname: require_login\n---\n`,
373
+ [PAGE]: `---\nauthorization_policies:\n - require_login\n---\n{{ content }}`,
374
+ };
375
+ const offenses = await check(files, [ValidFrontmatter]);
376
+ expect(offenses.some((o) => o.message.includes('Authorization policy'))).toBe(false);
377
+ });
378
+
379
+ it('reports each missing policy in the list', async () => {
380
+ const offenses = await check(
381
+ { [PAGE]: `---\nauthorization_policies:\n - policy_a\n - policy_b\n---\n{{ content }}` },
382
+ [ValidFrontmatter],
383
+ );
384
+ expect(offenses).to.containOffense("Authorization policy 'policy_a' does not exist");
385
+ expect(offenses).to.containOffense("Authorization policy 'policy_b' does not exist");
386
+ });
387
+ });
388
+
389
+ // ── Form notification associations ───────────────────────────────────────
390
+
391
+ describe('form notification associations', () => {
392
+ it('reports missing email notification', async () => {
393
+ const offenses = await check(
394
+ { [FORM]: `---\nname: my_form\nemail_notifications:\n - missing_email\n---\n` },
395
+ [ValidFrontmatter],
396
+ );
397
+ expect(offenses).to.containOffense("Email notification 'missing_email' does not exist");
398
+ });
399
+
400
+ it('reports missing SMS notification', async () => {
401
+ const offenses = await check(
402
+ { [FORM]: `---\nname: my_form\nsms_notifications:\n - missing_sms\n---\n` },
403
+ [ValidFrontmatter],
404
+ );
405
+ expect(offenses).to.containOffense("SMS notification 'missing_sms' does not exist");
406
+ });
407
+
408
+ it('reports missing API call notification', async () => {
409
+ const offenses = await check(
410
+ { [FORM]: `---\nname: my_form\napi_call_notifications:\n - missing_api_call\n---\n` },
411
+ [ValidFrontmatter],
412
+ );
413
+ expect(offenses).to.containOffense("API call notification 'missing_api_call' does not exist");
414
+ });
415
+
416
+ it('does not report when email notification file exists', async () => {
417
+ const files = {
418
+ 'app/emails/welcome.liquid': `---\nto: user@example.com\nsubject: Welcome\n---\n`,
419
+ [FORM]: `---\nemail_notifications:\n - welcome\n---\n`,
420
+ };
421
+ const offenses = await check(files, [ValidFrontmatter]);
422
+ expect(offenses.some((o) => o.message.includes('Email notification'))).toBe(false);
423
+ });
424
+
425
+ it('does not report when SMS notification file exists', async () => {
426
+ const files = {
427
+ 'app/smses/alert.liquid': `---\nto: "+15550001234"\n---\n`,
428
+ [FORM]: `---\nsms_notifications:\n - alert\n---\n`,
429
+ };
430
+ const offenses = await check(files, [ValidFrontmatter]);
431
+ expect(offenses.some((o) => o.message.includes('SMS notification'))).toBe(false);
432
+ });
433
+
434
+ it('does not report when API call notification file exists', async () => {
435
+ const files = {
436
+ 'app/api_calls/webhook.liquid': `---\nto: https://example.com\nrequest_type: POST\n---\n`,
437
+ [FORM]: `---\napi_call_notifications:\n - webhook\n---\n`,
438
+ };
439
+ const offenses = await check(files, [ValidFrontmatter]);
440
+ expect(offenses.some((o) => o.message.includes('API call notification'))).toBe(false);
441
+ });
442
+
443
+ it('reports each missing notification individually in a list', async () => {
444
+ const offenses = await check(
445
+ { [FORM]: `---\nname: my_form\nemail_notifications:\n - email_a\n - email_b\n---\n` },
446
+ [ValidFrontmatter],
447
+ );
448
+ expect(offenses).to.containOffense("Email notification 'email_a' does not exist");
449
+ expect(offenses).to.containOffense("Email notification 'email_b' does not exist");
450
+ });
451
+
452
+ it('only reports missing items when some exist and some do not', async () => {
453
+ const files = {
454
+ 'app/emails/welcome.liquid': `---\nto: u@e.com\nsubject: Hi\n---\n`,
455
+ [FORM]: `---\nemail_notifications:\n - welcome\n - missing_one\n---\n`,
456
+ };
457
+ const offenses = await check(files, [ValidFrontmatter]);
458
+ expect(offenses).to.containOffense("Email notification 'missing_one' does not exist");
459
+ expect(offenses.some((o) => o.message.includes("'welcome'"))).toBe(false);
460
+ });
461
+ });
462
+
463
+ // ── home.html.liquid deprecation ─────────────────────────────────────────
464
+
465
+ describe('home.html.liquid deprecation', () => {
466
+ it('warns when home.html.liquid is used', async () => {
467
+ const offenses = await check(
468
+ { 'app/views/pages/home.html.liquid': `---\nslug: /\n---\n{{ content }}` },
469
+ [ValidFrontmatter],
470
+ );
471
+ expect(offenses).to.containOffense(
472
+ "'home.html.liquid' is deprecated. Rename to 'index.html.liquid' to serve as the root page.",
473
+ );
474
+ });
475
+
476
+ it('does not warn for index.html.liquid', async () => {
477
+ const offenses = await check(
478
+ { 'app/views/pages/index.html.liquid': `---\nslug: /\n---\n{{ content }}` },
479
+ [ValidFrontmatter],
480
+ );
481
+ expect(offenses.some((o) => o.message.includes('home.html.liquid'))).toBe(false);
482
+ });
483
+
484
+ it('does not warn for files whose name contains home but is not home.html.liquid', async () => {
485
+ const offenses = await check({ 'app/views/pages/homepage.html.liquid': `{{ content }}` }, [
486
+ ValidFrontmatter,
487
+ ]);
488
+ expect(offenses.some((o) => o.message.includes('home.html.liquid'))).toBe(false);
489
+ });
490
+ });
491
+
492
+ // ── Unknown key validation ────────────────────────────────────────────────
493
+
494
+ describe('unknown key validation', () => {
495
+ it('warns on unknown keys in Page', async () => {
496
+ const offenses = await check({ [PAGE]: `---\nmy_custom_field: value\n---\n{{ content }}` }, [
497
+ ValidFrontmatter,
498
+ ]);
499
+ expect(offenses).to.containOffense(
500
+ "Unknown frontmatter field 'my_custom_field' in Page file",
501
+ );
502
+ });
503
+
504
+ it('warns on unknown keys in FormConfiguration', async () => {
505
+ const offenses = await check({ [FORM]: `---\nname: my_form\nunknown_field: value\n---\n` }, [
506
+ ValidFrontmatter,
507
+ ]);
508
+ expect(offenses).to.containOffense(
509
+ "Unknown frontmatter field 'unknown_field' in FormConfiguration file",
510
+ );
511
+ });
512
+
513
+ it('warns on unknown keys in AuthorizationPolicy', async () => {
514
+ const offenses = await check(
515
+ { [AUTH]: `---\nname: my_policy\nunknown_field: value\n---\n` },
516
+ [ValidFrontmatter],
517
+ );
518
+ expect(offenses).to.containOffense(
519
+ "Unknown frontmatter field 'unknown_field' in AuthorizationPolicy file",
520
+ );
521
+ });
522
+
523
+ it('flash_notice is not valid in AuthorizationPolicy (only flash_alert is)', async () => {
524
+ const offenses = await check(
525
+ { [AUTH]: `---\nname: my_policy\nflash_notice: Denied\n---\n` },
526
+ [ValidFrontmatter],
527
+ );
528
+ expect(offenses).to.containOffense(
529
+ "Unknown frontmatter field 'flash_notice' in AuthorizationPolicy file",
530
+ );
531
+ });
532
+
533
+ it('warns on unknown keys in Email', async () => {
534
+ const offenses = await check(
535
+ { [EMAIL]: `---\nname: my_email\nto: u@e.com\nsubject: Hi\nunknown_field: value\n---\n` },
536
+ [ValidFrontmatter],
537
+ );
538
+ expect(offenses).to.containOffense("Unknown frontmatter field 'unknown_field' in Email file");
539
+ });
540
+
541
+ it('accepts unique_args in Email (valid server-side field)', async () => {
542
+ const offenses = await check(
543
+ {
544
+ [EMAIL]: `---\nname: my_email\nto: u@e.com\nsubject: Hi\nunique_args:\n campaign: welcome\n---\n`,
545
+ },
546
+ [ValidFrontmatter],
547
+ );
548
+ expect(offenses.some((o) => o.message.includes("'unique_args'"))).toBe(false);
549
+ });
550
+
551
+ it('warns on unknown keys in SMS', async () => {
552
+ const offenses = await check(
553
+ {
554
+ [SMS]: `---\nname: my_sms\nto: "+15550001234"\ncontent: Hello\nunknown_field: value\n---\n`,
555
+ },
556
+ [ValidFrontmatter],
557
+ );
558
+ expect(offenses).to.containOffense("Unknown frontmatter field 'unknown_field' in SMS file");
559
+ });
560
+
561
+ it('warns on unknown keys in ApiCall', async () => {
562
+ const offenses = await check(
563
+ {
564
+ [API_CALL]: `---\nname: my_call\nto: https://example.com\nrequest_type: GET\nunknown_field: value\n---\n`,
565
+ },
566
+ [ValidFrontmatter],
567
+ );
568
+ expect(offenses).to.containOffense(
569
+ "Unknown frontmatter field 'unknown_field' in ApiCall file",
570
+ );
571
+ });
572
+
573
+ it('warns on unknown keys in Layout', async () => {
574
+ const offenses = await check({ [LAYOUT]: `---\nunknown_field: value\n---\n{{ content }}` }, [
575
+ ValidFrontmatter,
576
+ ]);
577
+ expect(offenses).to.containOffense(
578
+ "Unknown frontmatter field 'unknown_field' in Layout file",
579
+ );
580
+ });
581
+
582
+ it('name is not a valid Layout frontmatter field (derived from file path)', async () => {
583
+ const offenses = await check({ [LAYOUT]: `---\nname: my_layout\n---\n{{ content }}` }, [
584
+ ValidFrontmatter,
585
+ ]);
586
+ expect(offenses).to.containOffense("Unknown frontmatter field 'name' in Layout file");
587
+ });
588
+
589
+ it('warns on unknown keys in Partial', async () => {
590
+ const offenses = await check({ [PARTIAL]: `---\nunknown_field: value\n---\n{{ content }}` }, [
591
+ ValidFrontmatter,
592
+ ]);
593
+ expect(offenses).to.containOffense(
594
+ "Unknown frontmatter field 'unknown_field' in Partial file",
595
+ );
596
+ });
597
+
598
+ it('does not validate Migration files (no schema — arbitrary frontmatter allowed)', async () => {
599
+ const offenses = await check(
600
+ { [MIGRATION]: `---\ncustom_key: value\nanother_key: 123\n---\n{{ content }}` },
601
+ [ValidFrontmatter],
602
+ );
603
+ expect(offenses.some((o) => o.message.includes('Unknown frontmatter'))).toBe(false);
604
+ });
605
+ });
606
+
607
+ // ── Union-type field validation ───────────────────────────────────────────
608
+
609
+ describe('union-type fields', () => {
610
+ it('accepts trigger_condition as boolean in Email', async () => {
611
+ const offenses = await check({ [EMAIL]: `---\ntrigger_condition: true\n---\n` }, [
612
+ ValidFrontmatter,
613
+ ]);
614
+ expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false);
615
+ });
616
+
617
+ it('accepts trigger_condition as string in Email', async () => {
618
+ const offenses = await check(
619
+ { [EMAIL]: `---\ntrigger_condition: "{{ context.current_user != blank }}"\n---\n` },
620
+ [ValidFrontmatter],
621
+ );
622
+ expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false);
623
+ });
624
+
625
+ it('accepts trigger_condition as boolean in SMS', async () => {
626
+ const offenses = await check({ [SMS]: `---\ntrigger_condition: false\n---\n` }, [
627
+ ValidFrontmatter,
628
+ ]);
629
+ expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false);
630
+ });
631
+
632
+ it('accepts trigger_condition as boolean in ApiCall', async () => {
633
+ const offenses = await check(
634
+ {
635
+ [API_CALL]: `---\nto: https://example.com\nrequest_type: POST\ntrigger_condition: true\n---\n`,
636
+ },
637
+ [ValidFrontmatter],
638
+ );
639
+ expect(offenses.some((o) => o.message.includes("'trigger_condition'"))).toBe(false);
640
+ });
641
+
642
+ it('accepts default_payload as string in FormConfiguration', async () => {
643
+ const offenses = await check({ [FORM]: `---\ndefault_payload: "{}"\n---\n` }, [
644
+ ValidFrontmatter,
645
+ ]);
646
+ expect(offenses.some((o) => o.message.includes("'default_payload'"))).toBe(false);
647
+ });
648
+
649
+ it('accepts resource as string in FormConfiguration', async () => {
650
+ const offenses = await check({ [FORM]: `---\nresource: User\n---\n` }, [ValidFrontmatter]);
651
+ expect(offenses.some((o) => o.message.includes("'resource'"))).toBe(false);
652
+ });
653
+ });
654
+
655
+ // ── Unrecognized file type ────────────────────────────────────────────────
656
+
657
+ describe('unknown file type', () => {
658
+ it('skips files in unknown directories', async () => {
659
+ const offenses = await check(
660
+ { 'some/random/path/file.liquid': `---\nfoo: bar\n---\n{{ content }}` },
661
+ [ValidFrontmatter],
662
+ );
663
+ expect(offenses).to.have.length(0);
664
+ });
665
+ });
666
+ });