@platformos/platformos-check-common 0.0.12 → 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 (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/checks/circular-render/index.d.ts +2 -0
  3. package/dist/checks/circular-render/index.js +164 -0
  4. package/dist/checks/circular-render/index.js.map +1 -0
  5. package/dist/checks/index.d.ts +1 -1
  6. package/dist/checks/index.js +6 -0
  7. package/dist/checks/index.js.map +1 -1
  8. package/dist/checks/missing-page/index.d.ts +2 -0
  9. package/dist/checks/missing-page/index.js +73 -0
  10. package/dist/checks/missing-page/index.js.map +1 -0
  11. package/dist/checks/missing-partial/index.js +31 -31
  12. package/dist/checks/missing-partial/index.js.map +1 -1
  13. package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
  14. package/dist/checks/missing-render-partial-arguments/index.js +37 -0
  15. package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
  16. package/dist/checks/nested-graphql-query/index.d.ts +2 -0
  17. package/dist/checks/nested-graphql-query/index.js +146 -0
  18. package/dist/checks/nested-graphql-query/index.js.map +1 -0
  19. package/dist/checks/translation-key-exists/index.js +16 -19
  20. package/dist/checks/translation-key-exists/index.js.map +1 -1
  21. package/dist/checks/translation-utils.d.ts +20 -0
  22. package/dist/checks/translation-utils.js +51 -0
  23. package/dist/checks/translation-utils.js.map +1 -0
  24. package/dist/checks/undefined-object/index.js +21 -0
  25. package/dist/checks/undefined-object/index.js.map +1 -1
  26. package/dist/checks/unused-translation-key/index.d.ts +4 -0
  27. package/dist/checks/unused-translation-key/index.js +85 -0
  28. package/dist/checks/unused-translation-key/index.js.map +1 -0
  29. package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
  30. package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
  31. package/dist/context-utils.d.ts +2 -1
  32. package/dist/context-utils.js +31 -1
  33. package/dist/context-utils.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/liquid-doc/arguments.js +4 -0
  38. package/dist/liquid-doc/arguments.js.map +1 -1
  39. package/dist/liquid-doc/utils.d.ts +10 -2
  40. package/dist/liquid-doc/utils.js +26 -1
  41. package/dist/liquid-doc/utils.js.map +1 -1
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/dist/types.d.ts +8 -1
  44. package/dist/types.js.map +1 -1
  45. package/dist/url-helpers.d.ts +55 -0
  46. package/dist/url-helpers.js +334 -0
  47. package/dist/url-helpers.js.map +1 -0
  48. package/dist/utils/index.d.ts +1 -0
  49. package/dist/utils/index.js +1 -0
  50. package/dist/utils/index.js.map +1 -1
  51. package/dist/utils/levenshtein.d.ts +3 -0
  52. package/dist/utils/levenshtein.js +39 -0
  53. package/dist/utils/levenshtein.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/checks/graphql/index.spec.ts +2 -2
  56. package/src/checks/index.ts +6 -0
  57. package/src/checks/missing-page/index.spec.ts +755 -0
  58. package/src/checks/missing-page/index.ts +89 -0
  59. package/src/checks/missing-partial/index.spec.ts +361 -0
  60. package/src/checks/missing-partial/index.ts +39 -47
  61. package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
  62. package/src/checks/missing-render-partial-arguments/index.ts +44 -0
  63. package/src/checks/nested-graphql-query/index.spec.ts +175 -0
  64. package/src/checks/nested-graphql-query/index.ts +203 -0
  65. package/src/checks/parser-blocking-script/index.spec.ts +7 -3
  66. package/src/checks/translation-key-exists/index.spec.ts +79 -2
  67. package/src/checks/translation-key-exists/index.ts +18 -27
  68. package/src/checks/translation-utils.ts +63 -0
  69. package/src/checks/undefined-object/index.spec.ts +30 -0
  70. package/src/checks/undefined-object/index.ts +27 -1
  71. package/src/checks/unused-assign/index.spec.ts +1 -1
  72. package/src/checks/unused-doc-param/index.spec.ts +4 -2
  73. package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
  74. package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
  75. package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
  76. package/src/checks/variable-name/index.spec.ts +1 -1
  77. package/src/context-utils.ts +33 -1
  78. package/src/index.ts +3 -0
  79. package/src/liquid-doc/arguments.ts +6 -0
  80. package/src/liquid-doc/utils.ts +26 -2
  81. package/src/types.ts +9 -1
  82. package/src/url-helpers.spec.ts +241 -0
  83. package/src/url-helpers.ts +363 -0
  84. package/src/utils/index.ts +1 -0
  85. package/src/utils/levenshtein.ts +41 -0
@@ -0,0 +1,755 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RouteTable } from '@platformos/platformos-common';
3
+ import { runLiquidCheck } from '../../test';
4
+ import { MockFileSystem } from '../../test/MockFileSystem';
5
+ import { MissingPage } from './index';
6
+
7
+ describe('Module: MissingPage', () => {
8
+ describe('should report offense', () => {
9
+ it('reports when no pages exist', async () => {
10
+ const sourceCode = '<a href="/nonexistent">Link</a>';
11
+ const offenses = await runLiquidCheck(
12
+ MissingPage,
13
+ sourceCode,
14
+ 'app/views/pages/home.html.liquid',
15
+ );
16
+ expect(offenses.map((o) => o.message)).toEqual([
17
+ "No page found for route '/nonexistent' (GET)",
18
+ ]);
19
+ });
20
+
21
+ it('reports when only GET page exists but form uses POST', async () => {
22
+ const sourceCode = '<form action="/login" method="post"><button>Go</button></form>';
23
+ const offenses = await runLiquidCheck(
24
+ MissingPage,
25
+ sourceCode,
26
+ 'app/views/pages/home.html.liquid',
27
+ {},
28
+ {
29
+ 'app/views/pages/login.html.liquid': '<h1>Login</h1>',
30
+ },
31
+ );
32
+ expect(offenses.map((o) => o.message)).toEqual(["No page found for route '/login' (POST)"]);
33
+ });
34
+
35
+ it('reports for non-matching path', async () => {
36
+ const sourceCode = '<a href="/about-us">About Us</a>';
37
+ const offenses = await runLiquidCheck(
38
+ MissingPage,
39
+ sourceCode,
40
+ 'app/views/pages/home.html.liquid',
41
+ {},
42
+ {
43
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
44
+ },
45
+ );
46
+ expect(offenses.map((o) => o.message)).toEqual(["No page found for route '/about-us' (GET)"]);
47
+ });
48
+
49
+ it('reports for Liquid interpolation with no matching parameterized route', async () => {
50
+ const sourceCode = '<a href="/orders/{{ order.id }}/invoice">Invoice</a>';
51
+ const offenses = await runLiquidCheck(
52
+ MissingPage,
53
+ sourceCode,
54
+ 'app/views/pages/home.html.liquid',
55
+ {},
56
+ {
57
+ 'app/views/pages/orders.html.liquid': '---\nslug: orders/:id\n---\n<h1>Order</h1>',
58
+ },
59
+ );
60
+ expect(offenses.map((o) => o.message)).toEqual([
61
+ "No page found for route '/orders/:_liquid_/invoice' (GET)",
62
+ ]);
63
+ });
64
+
65
+ it('reports for form with _method=delete inside a div wrapper when only POST page exists', async () => {
66
+ const sourceCode =
67
+ '<form action="/users/1" method="post"><div><input type="hidden" name="_method" value="delete"></div><button>Delete</button></form>';
68
+ const offenses = await runLiquidCheck(
69
+ MissingPage,
70
+ sourceCode,
71
+ 'app/views/pages/home.html.liquid',
72
+ {},
73
+ {
74
+ 'app/views/pages/user-post.html.liquid':
75
+ '---\nslug: users/:id\nmethod: post\n---\n<h1>Update</h1>',
76
+ },
77
+ );
78
+ expect(offenses.map((o) => o.message)).toEqual([
79
+ "No page found for route '/users/1' (DELETE)",
80
+ ]);
81
+ });
82
+
83
+ it('reports for form with _method=put inside nested div and fieldset wrappers', async () => {
84
+ const sourceCode =
85
+ '<form action="/users/1" method="post"><div><fieldset><input type="hidden" name="_method" value="put"></fieldset></div><button>Update</button></form>';
86
+ const offenses = await runLiquidCheck(
87
+ MissingPage,
88
+ sourceCode,
89
+ 'app/views/pages/home.html.liquid',
90
+ {},
91
+ {
92
+ 'app/views/pages/user-post.html.liquid':
93
+ '---\nslug: users/:id\nmethod: post\n---\n<h1>Update</h1>',
94
+ },
95
+ );
96
+ expect(offenses.map((o) => o.message)).toEqual(["No page found for route '/users/1' (PUT)"]);
97
+ });
98
+
99
+ it('reports for form with _method=delete when only POST page exists', async () => {
100
+ const sourceCode =
101
+ '<form action="/users/1" method="post"><input type="hidden" name="_method" value="delete"><button>Delete</button></form>';
102
+ const offenses = await runLiquidCheck(
103
+ MissingPage,
104
+ sourceCode,
105
+ 'app/views/pages/home.html.liquid',
106
+ {},
107
+ {
108
+ 'app/views/pages/user-post.html.liquid':
109
+ '---\nslug: users/:id\nmethod: post\n---\n<h1>Update</h1>',
110
+ },
111
+ );
112
+ expect(offenses.map((o) => o.message)).toEqual([
113
+ "No page found for route '/users/1' (DELETE)",
114
+ ]);
115
+ });
116
+
117
+ it('reports when href uses variable assigned with a non-matching URL', async () => {
118
+ const sourceCode = '{% assign url = "/nonexistent" %}\n<a href="{{ url }}">Link</a>';
119
+ const offenses = await runLiquidCheck(
120
+ MissingPage,
121
+ sourceCode,
122
+ 'app/views/pages/home.html.liquid',
123
+ );
124
+ expect(offenses.map((o) => o.message)).toEqual([
125
+ "No page found for route '/nonexistent' (GET)",
126
+ ]);
127
+ });
128
+
129
+ it('reports when href uses variable assigned with append filters and no matching route', async () => {
130
+ const sourceCode =
131
+ '{% assign url = "/groups/" | append: group.id | append: "/edit" %}\n<a href="{{ url }}">Edit</a>';
132
+ const offenses = await runLiquidCheck(
133
+ MissingPage,
134
+ sourceCode,
135
+ 'app/views/pages/home.html.liquid',
136
+ );
137
+ expect(offenses.map((o) => o.message)).toEqual([
138
+ "No page found for route '/groups/:_liquid_/edit' (GET)",
139
+ ]);
140
+ });
141
+
142
+ it('reports when variable is reassigned and latest value has no matching route', async () => {
143
+ const sourceCode =
144
+ '{% assign url = "/about" %}\n{% assign url = "/nonexistent" %}\n<a href="{{ url }}">Link</a>';
145
+ const offenses = await runLiquidCheck(
146
+ MissingPage,
147
+ sourceCode,
148
+ 'app/views/pages/home.html.liquid',
149
+ {},
150
+ {
151
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
152
+ },
153
+ );
154
+ expect(offenses.map((o) => o.message)).toEqual([
155
+ "No page found for route '/nonexistent' (GET)",
156
+ ]);
157
+ });
158
+
159
+ it('reports when variable assigned with non-URL filter chain is unresolvable', async () => {
160
+ // downcase is not append/prepend — assign is not tracked, so {{ url }} is fully dynamic → skipped
161
+ const sourceCode = '{% assign url = "/ABOUT" | downcase %}\n<a href="{{ url }}">Link</a>';
162
+ const offenses = await runLiquidCheck(
163
+ MissingPage,
164
+ sourceCode,
165
+ 'app/views/pages/home.html.liquid',
166
+ );
167
+ // url can't be resolved → fully dynamic → skipped (no offense)
168
+ expect(offenses.map((o) => o.message)).toEqual([]);
169
+ });
170
+
171
+ it('reports when form action uses variable assigned with no matching route', async () => {
172
+ const sourceCode =
173
+ '{% assign action_url = "/submit" %}\n<form action="{{ action_url }}" method="post"><button>Go</button></form>';
174
+ const offenses = await runLiquidCheck(
175
+ MissingPage,
176
+ sourceCode,
177
+ 'app/views/pages/home.html.liquid',
178
+ );
179
+ expect(offenses.map((o) => o.message)).toEqual(["No page found for route '/submit' (POST)"]);
180
+ });
181
+
182
+ it('reports when variable is assigned inside {% liquid %} block with no matching route', async () => {
183
+ const sourceCode =
184
+ '{% liquid\n assign url = "/nonexistent"\n%}\n<a href="{{ url }}">Link</a>';
185
+ const offenses = await runLiquidCheck(
186
+ MissingPage,
187
+ sourceCode,
188
+ 'app/views/pages/home.html.liquid',
189
+ );
190
+ expect(offenses.map((o) => o.message)).toEqual([
191
+ "No page found for route '/nonexistent' (GET)",
192
+ ]);
193
+ });
194
+
195
+ it('reports for absolute self-referencing URL with no matching page', async () => {
196
+ const sourceCode = '<a href="https://{{ context.location.host }}/nonexistent">Link</a>';
197
+ const offenses = await runLiquidCheck(
198
+ MissingPage,
199
+ sourceCode,
200
+ 'app/views/pages/home.html.liquid',
201
+ );
202
+ expect(offenses.map((o) => o.message)).toEqual([
203
+ "No page found for route '/nonexistent' (GET)",
204
+ ]);
205
+ });
206
+ });
207
+
208
+ describe('should NOT report offense', () => {
209
+ it('does not report for existing page', async () => {
210
+ const sourceCode = '<a href="/about">About</a>';
211
+ const offenses = await runLiquidCheck(
212
+ MissingPage,
213
+ sourceCode,
214
+ 'app/views/pages/home.html.liquid',
215
+ {},
216
+ {
217
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
218
+ },
219
+ );
220
+ expect(offenses.map((o) => o.message)).toEqual([]);
221
+ });
222
+
223
+ it('does not report for https://{{ context.location.host }}/path with existing page', async () => {
224
+ const sourceCode = '<a href="https://{{ context.location.host }}/about">About</a>';
225
+ const offenses = await runLiquidCheck(
226
+ MissingPage,
227
+ sourceCode,
228
+ 'app/views/pages/home.html.liquid',
229
+ {},
230
+ {
231
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
232
+ },
233
+ );
234
+ expect(offenses.map((o) => o.message)).toEqual([]);
235
+ });
236
+
237
+ it('does not report for http://{{ context.location.host }}/path with existing page', async () => {
238
+ const sourceCode = '<a href="http://{{ context.location.host }}/about">About</a>';
239
+ const offenses = await runLiquidCheck(
240
+ MissingPage,
241
+ sourceCode,
242
+ 'app/views/pages/home.html.liquid',
243
+ {},
244
+ {
245
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
246
+ },
247
+ );
248
+ expect(offenses.map((o) => o.message)).toEqual([]);
249
+ });
250
+
251
+ it('does not report for root path with index page', async () => {
252
+ const sourceCode = '<a href="/">Home</a>';
253
+ const offenses = await runLiquidCheck(
254
+ MissingPage,
255
+ sourceCode,
256
+ 'app/views/pages/about.html.liquid',
257
+ {},
258
+ {
259
+ 'app/views/pages/index.html.liquid': '<h1>Home</h1>',
260
+ },
261
+ );
262
+ expect(offenses.map((o) => o.message)).toEqual([]);
263
+ });
264
+
265
+ it('does not report for dynamic route matching', async () => {
266
+ const sourceCode = '<a href="/users/42">User Profile</a>';
267
+ const offenses = await runLiquidCheck(
268
+ MissingPage,
269
+ sourceCode,
270
+ 'app/views/pages/home.html.liquid',
271
+ {},
272
+ {
273
+ 'app/views/pages/user.html.liquid': '---\nslug: users/:id\n---\n<h1>User</h1>',
274
+ },
275
+ );
276
+ expect(offenses.map((o) => o.message)).toEqual([]);
277
+ });
278
+
279
+ it('does not report for fully dynamic Liquid href', async () => {
280
+ const sourceCode = '<a href="{{ user.profile_url }}">Profile</a>';
281
+ const offenses = await runLiquidCheck(
282
+ MissingPage,
283
+ sourceCode,
284
+ 'app/views/pages/home.html.liquid',
285
+ );
286
+ expect(offenses.map((o) => o.message)).toEqual([]);
287
+ });
288
+
289
+ it('does not report for external URLs', async () => {
290
+ const sourceCode = '<a href="https://example.com">External</a>';
291
+ const offenses = await runLiquidCheck(
292
+ MissingPage,
293
+ sourceCode,
294
+ 'app/views/pages/home.html.liquid',
295
+ );
296
+ expect(offenses.map((o) => o.message)).toEqual([]);
297
+ });
298
+
299
+ it('does not report for anchor-only href', async () => {
300
+ const sourceCode = '<a href="#section">Jump</a>';
301
+ const offenses = await runLiquidCheck(
302
+ MissingPage,
303
+ sourceCode,
304
+ 'app/views/pages/home.html.liquid',
305
+ );
306
+ expect(offenses.map((o) => o.message)).toEqual([]);
307
+ });
308
+
309
+ it('does not report for mailto', async () => {
310
+ const sourceCode = '<a href="mailto:hello@example.com">Email</a>';
311
+ const offenses = await runLiquidCheck(
312
+ MissingPage,
313
+ sourceCode,
314
+ 'app/views/pages/home.html.liquid',
315
+ );
316
+ expect(offenses.map((o) => o.message)).toEqual([]);
317
+ });
318
+
319
+ it('does not report for empty href', async () => {
320
+ const sourceCode = '<a href="">Empty</a>';
321
+ const offenses = await runLiquidCheck(
322
+ MissingPage,
323
+ sourceCode,
324
+ 'app/views/pages/home.html.liquid',
325
+ );
326
+ expect(offenses.map((o) => o.message)).toEqual([]);
327
+ });
328
+
329
+ it('does not report for Liquid interpolation matching parameterized route', async () => {
330
+ const sourceCode = '<a href="/users/{{ user.id }}">User</a>';
331
+ const offenses = await runLiquidCheck(
332
+ MissingPage,
333
+ sourceCode,
334
+ 'app/views/pages/home.html.liquid',
335
+ {},
336
+ {
337
+ 'app/views/pages/user.html.liquid': '---\nslug: users/:id\n---\n<h1>User</h1>',
338
+ },
339
+ );
340
+ expect(offenses.map((o) => o.message)).toEqual([]);
341
+ });
342
+
343
+ it('does not report for form with matching POST page', async () => {
344
+ const sourceCode = '<form action="/contact" method="post"><button>Send</button></form>';
345
+ const offenses = await runLiquidCheck(
346
+ MissingPage,
347
+ sourceCode,
348
+ 'app/views/pages/home.html.liquid',
349
+ {},
350
+ {
351
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
352
+ },
353
+ );
354
+ expect(offenses.map((o) => o.message)).toEqual([]);
355
+ });
356
+
357
+ it('does not report for form with _method override inside a div wrapper matching DELETE page', async () => {
358
+ const sourceCode =
359
+ '<form action="/users/1" method="post"><div><input type="hidden" name="_method" value="delete"></div><button>Delete</button></form>';
360
+ const offenses = await runLiquidCheck(
361
+ MissingPage,
362
+ sourceCode,
363
+ 'app/views/pages/home.html.liquid',
364
+ {},
365
+ {
366
+ 'app/views/pages/user-delete.html.liquid': '---\nslug: users/:id\nmethod: delete\n---\n',
367
+ },
368
+ );
369
+ expect(offenses.map((o) => o.message)).toEqual([]);
370
+ });
371
+
372
+ it('does not report for form with _method override matching DELETE page', async () => {
373
+ const sourceCode =
374
+ '<form action="/users/1" method="post"><input type="hidden" name="_method" value="delete"><button>Delete</button></form>';
375
+ const offenses = await runLiquidCheck(
376
+ MissingPage,
377
+ sourceCode,
378
+ 'app/views/pages/home.html.liquid',
379
+ {},
380
+ {
381
+ 'app/views/pages/user-delete.html.liquid': '---\nslug: users/:id\nmethod: delete\n---\n',
382
+ },
383
+ );
384
+ expect(offenses.map((o) => o.message)).toEqual([]);
385
+ });
386
+
387
+ it('does not report for index aliased page', async () => {
388
+ const sourceCode = '<a href="/my/page">Link</a><a href="/my/page/index">Also</a>';
389
+ const offenses = await runLiquidCheck(
390
+ MissingPage,
391
+ sourceCode,
392
+ 'app/views/pages/home.html.liquid',
393
+ {},
394
+ {
395
+ 'app/views/pages/my/page/index.html.liquid': '<h1>Page</h1>',
396
+ },
397
+ );
398
+ expect(offenses.map((o) => o.message)).toEqual([]);
399
+ });
400
+
401
+ it('does not report for Liquid tags in href', async () => {
402
+ const sourceCode = '<a href="{% if admin %}/admin{% else %}/home{% endif %}">Go</a>';
403
+ const offenses = await runLiquidCheck(
404
+ MissingPage,
405
+ sourceCode,
406
+ 'app/views/pages/home.html.liquid',
407
+ );
408
+ expect(offenses.map((o) => o.message)).toEqual([]);
409
+ });
410
+
411
+ it('does not report for Liquid interpolation mixed with text in a segment', async () => {
412
+ const sourceCode = '<a href="/{{ context.slug }}feed">Feed</a>';
413
+ const offenses = await runLiquidCheck(
414
+ MissingPage,
415
+ sourceCode,
416
+ 'app/views/pages/home.html.liquid',
417
+ );
418
+ expect(offenses.map((o) => o.message)).toEqual([]);
419
+ });
420
+
421
+ it('does not report when href uses variable assigned with a matching URL', async () => {
422
+ const sourceCode = '{% assign url = "/about" %}\n<a href="{{ url }}">About</a>';
423
+ const offenses = await runLiquidCheck(
424
+ MissingPage,
425
+ sourceCode,
426
+ 'app/views/pages/home.html.liquid',
427
+ {},
428
+ {
429
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
430
+ },
431
+ );
432
+ expect(offenses.map((o) => o.message)).toEqual([]);
433
+ });
434
+
435
+ it('does not report when href uses variable assigned with append filters matching a route', async () => {
436
+ const sourceCode =
437
+ '{% assign url = "/users/" | append: user.id | append: "/edit" %}\n<a href="{{ url }}">Edit</a>';
438
+ const offenses = await runLiquidCheck(
439
+ MissingPage,
440
+ sourceCode,
441
+ 'app/views/pages/home.html.liquid',
442
+ {},
443
+ {
444
+ 'app/views/pages/user-edit.html.liquid':
445
+ '---\nslug: users/:id/edit\n---\n<h1>Edit User</h1>',
446
+ },
447
+ );
448
+ expect(offenses.map((o) => o.message)).toEqual([]);
449
+ });
450
+
451
+ it('does not report when href uses variable assigned with prepend filters matching a route', async () => {
452
+ const sourceCode =
453
+ '{% assign url = "/edit" | prepend: user.id | prepend: "/users/" %}\n<a href="{{ url }}">Edit</a>';
454
+ const offenses = await runLiquidCheck(
455
+ MissingPage,
456
+ sourceCode,
457
+ 'app/views/pages/home.html.liquid',
458
+ {},
459
+ {
460
+ 'app/views/pages/user-edit.html.liquid':
461
+ '---\nslug: users/:id/edit\n---\n<h1>Edit User</h1>',
462
+ },
463
+ );
464
+ expect(offenses.map((o) => o.message)).toEqual([]);
465
+ });
466
+
467
+ it('does not report when variable is used with filters in href (unresolvable)', async () => {
468
+ const sourceCode = '{% assign url = "/about" %}\n<a href="{{ url | escape }}">About</a>';
469
+ const offenses = await runLiquidCheck(
470
+ MissingPage,
471
+ sourceCode,
472
+ 'app/views/pages/home.html.liquid',
473
+ );
474
+ // {{ url | escape }} has filters → not a simple variable → fully dynamic → skipped
475
+ expect(offenses.map((o) => o.message)).toEqual([]);
476
+ });
477
+
478
+ it('does not report when variable has lookups in href (unresolvable)', async () => {
479
+ const sourceCode = '{% assign config = "test" %}\n<a href="{{ config.url }}">Link</a>';
480
+ const offenses = await runLiquidCheck(
481
+ MissingPage,
482
+ sourceCode,
483
+ 'app/views/pages/home.html.liquid',
484
+ );
485
+ // config.url has lookups → not a simple variable → fully dynamic → skipped
486
+ expect(offenses.map((o) => o.message)).toEqual([]);
487
+ });
488
+
489
+ it('does not report for form action with variable assigned to a matching POST route', async () => {
490
+ const sourceCode =
491
+ '{% assign action_url = "/contact" %}\n<form action="{{ action_url }}" method="post"><button>Send</button></form>';
492
+ const offenses = await runLiquidCheck(
493
+ MissingPage,
494
+ sourceCode,
495
+ 'app/views/pages/home.html.liquid',
496
+ {},
497
+ {
498
+ 'app/views/pages/contact.html.liquid': '---\nmethod: post\n---\n<h1>Contact</h1>',
499
+ },
500
+ );
501
+ expect(offenses.map((o) => o.message)).toEqual([]);
502
+ });
503
+
504
+ it('does not report when assigned variable is used alongside static text', async () => {
505
+ // "prefix{{ url }}" — mixed attr, variable map not used; normal extraction applies
506
+ const sourceCode = '{% assign slug = "about" %}\n<a href="/pages/{{ slug }}">About</a>';
507
+ const offenses = await runLiquidCheck(
508
+ MissingPage,
509
+ sourceCode,
510
+ 'app/views/pages/home.html.liquid',
511
+ {},
512
+ {
513
+ 'app/views/pages/page.html.liquid': '---\nslug: pages/:slug\n---\n<h1>Page</h1>',
514
+ },
515
+ );
516
+ expect(offenses.map((o) => o.message)).toEqual([]);
517
+ });
518
+
519
+ it('does not report when variable is assigned inside {% liquid %} block', async () => {
520
+ const sourceCode = '{% liquid\n assign url = "/about"\n%}\n<a href="{{ url }}">About</a>';
521
+ const offenses = await runLiquidCheck(
522
+ MissingPage,
523
+ sourceCode,
524
+ 'app/views/pages/home.html.liquid',
525
+ {},
526
+ {
527
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
528
+ },
529
+ );
530
+ expect(offenses.map((o) => o.message)).toEqual([]);
531
+ });
532
+
533
+ it('does not report when variable reassigned to a matching route', async () => {
534
+ const sourceCode =
535
+ '{% assign url = "/nonexistent" %}\n{% assign url = "/about" %}\n<a href="{{ url }}">About</a>';
536
+ const offenses = await runLiquidCheck(
537
+ MissingPage,
538
+ sourceCode,
539
+ 'app/views/pages/home.html.liquid',
540
+ {},
541
+ {
542
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
543
+ },
544
+ );
545
+ expect(offenses.map((o) => o.message)).toEqual([]);
546
+ });
547
+
548
+ it('does not report when href has multiple tracked variables (fully dynamic)', async () => {
549
+ const sourceCode =
550
+ '{% assign base = "/users" %}\n{% assign suffix = "/edit" %}\n<a href="{{ base }}{{ suffix }}">Edit</a>';
551
+ const offenses = await runLiquidCheck(
552
+ MissingPage,
553
+ sourceCode,
554
+ 'app/views/pages/home.html.liquid',
555
+ );
556
+ // Multiple {{ var }} with no static text → fully dynamic → skipped
557
+ expect(offenses.map((o) => o.message)).toEqual([]);
558
+ });
559
+
560
+ it('does not report when variable is set via {% capture %} (not tracked, fully dynamic)', async () => {
561
+ const sourceCode = '{% capture url %}/about{% endcapture %}\n<a href="{{ url }}">About</a>';
562
+ const offenses = await runLiquidCheck(
563
+ MissingPage,
564
+ sourceCode,
565
+ 'app/views/pages/home.html.liquid',
566
+ );
567
+ // capture is not tracked → {{ url }} is fully dynamic → skipped
568
+ expect(offenses.map((o) => o.message)).toEqual([]);
569
+ });
570
+
571
+ it('does not report for relative URLs without leading slash', async () => {
572
+ const sourceCode = '<a href="about">About</a>';
573
+ const offenses = await runLiquidCheck(
574
+ MissingPage,
575
+ sourceCode,
576
+ 'app/views/pages/home.html.liquid',
577
+ );
578
+ expect(offenses.map((o) => o.message)).toEqual([]);
579
+ });
580
+
581
+ it('does not report for https://{{ other_variable }}/path (only context.location.host is recognized)', async () => {
582
+ const sourceCode = '<a href="https://{{ some_domain }}/about">About</a>';
583
+ const offenses = await runLiquidCheck(
584
+ MissingPage,
585
+ sourceCode,
586
+ 'app/views/pages/home.html.liquid',
587
+ {},
588
+ {
589
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
590
+ },
591
+ );
592
+ expect(offenses.map((o) => o.message)).toEqual([]);
593
+ });
594
+ });
595
+
596
+ describe('format-aware matching', () => {
597
+ it('matches .json URL against json format page when both html and json pages exist', async () => {
598
+ const sourceCode = '<a href="/api/my-page.json">JSON</a>';
599
+ const offenses = await runLiquidCheck(
600
+ MissingPage,
601
+ sourceCode,
602
+ 'app/views/pages/home.html.liquid',
603
+ {},
604
+ {
605
+ 'app/views/pages/api/my-page.html.liquid': '<h1>HTML version</h1>',
606
+ 'app/views/pages/api/my-page.json.liquid': '{ "data": "json version" }',
607
+ },
608
+ );
609
+ expect(offenses.map((o) => o.message)).toEqual([]);
610
+ });
611
+
612
+ it('reports when .json URL has no json format page (only html exists)', async () => {
613
+ const sourceCode = '<a href="/api/my-page.json">JSON</a>';
614
+ const offenses = await runLiquidCheck(
615
+ MissingPage,
616
+ sourceCode,
617
+ 'app/views/pages/home.html.liquid',
618
+ {},
619
+ {
620
+ 'app/views/pages/api/my-page.html.liquid': '<h1>HTML only</h1>',
621
+ },
622
+ );
623
+ expect(offenses.map((o) => o.message)).toEqual([
624
+ "No page found for route '/api/my-page.json' (GET)",
625
+ ]);
626
+ });
627
+
628
+ it('reports when URL has no format suffix but only json page exists', async () => {
629
+ const sourceCode = '<a href="/api/my-page">Link</a>';
630
+ const offenses = await runLiquidCheck(
631
+ MissingPage,
632
+ sourceCode,
633
+ 'app/views/pages/home.html.liquid',
634
+ {},
635
+ {
636
+ 'app/views/pages/api/my-page.json.liquid': '{ "data": true }',
637
+ },
638
+ );
639
+ // Plain URL defaults to html format — only json page exists, so report
640
+ expect(offenses.map((o) => o.message)).toEqual([
641
+ "No page found for route '/api/my-page' (GET)",
642
+ ]);
643
+ });
644
+
645
+ it('does not report for URL without format suffix when html page exists', async () => {
646
+ const sourceCode = '<a href="/api/my-page">Link</a>';
647
+ const offenses = await runLiquidCheck(
648
+ MissingPage,
649
+ sourceCode,
650
+ 'app/views/pages/home.html.liquid',
651
+ {},
652
+ {
653
+ 'app/views/pages/api/my-page.html.liquid': '<h1>HTML</h1>',
654
+ 'app/views/pages/api/my-page.json.liquid': '{ "data": true }',
655
+ },
656
+ );
657
+ expect(offenses.map((o) => o.message)).toEqual([]);
658
+ });
659
+ });
660
+
661
+ describe('assign variable scoping per position', () => {
662
+ it('resolves each link against the variable value at that point in the document', async () => {
663
+ const sourceCode =
664
+ '{% assign url = "/about" %}<a href="{{ url }}">About</a>{% assign url = "/contact" %}<a href="{{ url }}">Contact</a>';
665
+ const offenses = await runLiquidCheck(
666
+ MissingPage,
667
+ sourceCode,
668
+ 'app/views/pages/home.html.liquid',
669
+ {},
670
+ {
671
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
672
+ 'app/views/pages/contact.html.liquid': '<h1>Contact</h1>',
673
+ },
674
+ );
675
+ // Both /about and /contact exist — no offenses
676
+ expect(offenses.map((o) => o.message)).toEqual([]);
677
+ });
678
+
679
+ it('reports only for the link whose preceding assign points to a missing page', async () => {
680
+ const sourceCode =
681
+ '{% assign url = "/about" %}<a href="{{ url }}">About</a>{% assign url = "/nonexistent" %}<a href="{{ url }}">Missing</a>';
682
+ const offenses = await runLiquidCheck(
683
+ MissingPage,
684
+ sourceCode,
685
+ 'app/views/pages/home.html.liquid',
686
+ {},
687
+ {
688
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
689
+ },
690
+ );
691
+ expect(offenses.map((o) => o.message)).toEqual([
692
+ "No page found for route '/nonexistent' (GET)",
693
+ ]);
694
+ });
695
+
696
+ it('reports for first link when only the second assign matches', async () => {
697
+ const sourceCode =
698
+ '{% assign url = "/nonexistent" %}<a href="{{ url }}">Missing</a>{% assign url = "/about" %}<a href="{{ url }}">About</a>';
699
+ const offenses = await runLiquidCheck(
700
+ MissingPage,
701
+ sourceCode,
702
+ 'app/views/pages/home.html.liquid',
703
+ {},
704
+ {
705
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
706
+ },
707
+ );
708
+ expect(offenses.map((o) => o.message)).toEqual([
709
+ "No page found for route '/nonexistent' (GET)",
710
+ ]);
711
+ });
712
+ });
713
+
714
+ describe('route table build behavior', () => {
715
+ it('builds the route table on demand when an unbuilt table is provided', async () => {
716
+ const appFiles = {
717
+ 'app/views/pages/about.html.liquid': '<h1>About</h1>',
718
+ };
719
+ const fs = new MockFileSystem({ '.platformos-check.yml': '', ...appFiles });
720
+ const routeTable = new RouteTable(fs);
721
+ // Do NOT call routeTable.build() — simulates the LSP scenario where the
722
+ // definition provider passes its route table before it has been built.
723
+
724
+ const sourceCode = '<a href="/about">About</a>';
725
+ const offenses = await runLiquidCheck(
726
+ MissingPage,
727
+ sourceCode,
728
+ 'app/views/pages/home.html.liquid',
729
+ { routeTable },
730
+ appFiles,
731
+ );
732
+ expect(offenses).toHaveLength(0);
733
+ });
734
+
735
+ it('uses routes from a pre-built table without rebuilding', async () => {
736
+ const appFiles = {
737
+ 'app/views/pages/contact.html.liquid': '<h1>Contact</h1>',
738
+ };
739
+ const fs = new MockFileSystem({ '.platformos-check.yml': '', ...appFiles });
740
+ const routeTable = new RouteTable(fs);
741
+ await routeTable.build((await import('vscode-uri')).URI.parse('file:///'));
742
+ expect(routeTable.isBuilt()).toBe(true);
743
+
744
+ const sourceCode = '<a href="/contact">Contact</a>';
745
+ const offenses = await runLiquidCheck(
746
+ MissingPage,
747
+ sourceCode,
748
+ 'app/views/pages/home.html.liquid',
749
+ { routeTable },
750
+ appFiles,
751
+ );
752
+ expect(offenses).toHaveLength(0);
753
+ });
754
+ });
755
+ });