@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

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 (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,2303 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { z } from 'zod';
6
+ import { SourceCodeType, toSourceCode } from '@platformos/platformos-check-common';
7
+ // From `platformos-common`, the single owner of what a path IS; check-common no longer
8
+ // re-exports it (`guards/identity-ownership.spec.ts` fails on a re-export growing back).
9
+ import { PlatformOSFileType, isSupportedSourceFile } from '@platformos/platformos-common';
10
+ import { runValidateCode, TOOL_TEXT, VALIDATE_CODE_INPUT } from './validate-code.js';
11
+ import { SERVER_INSTRUCTIONS } from './instructions.js';
12
+ import { BLOCKING_CHECKS } from '../result/blocking.js';
13
+ import { IMPACT_DEADLINE_MS } from '../context.js';
14
+ import { MAX_RESPONSE_DIAGNOSTIC_BYTES, MIN_LINT_DEADLINE_MS, lintDeadlineMs, maxResponseBytes, } from '../cost-model.js';
15
+ import { MAX_BUFFER_BYTES } from '../adapter-input.js';
16
+ import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
17
+ import { allChecks } from '@platformos/platformos-check-common';
18
+ /**
19
+ * `validate_code` is the supervisor's ENTIRE surface: one file via `file_path`/`content`,
20
+ * or several via `files`. Both forms drive the same orchestrator (`validateBuffers`), so
21
+ * this file pins the TOOL contract — shape adaptation, the write gate, refusals, and
22
+ * bounded work.
23
+ *
24
+ * Adapters are always injected, so nothing here touches a real project — which matters
25
+ * under fake timers, where real config I/O would never settle.
26
+ */
27
+ const ctx = (log = () => { }) => ({
28
+ projectDir: '/srv/app',
29
+ log,
30
+ });
31
+ const COMPUTED = {
32
+ scope: 'direct',
33
+ status: 'computed',
34
+ dependents: { total: 0, by_kind: {}, sample: [] },
35
+ };
36
+ const diagnostic = (check, severity = 'error') => ({ check, severity, message: `${check} fired`, line: 1, column: 1 });
37
+ /**
38
+ * The same diagnostic AS RETURNED — enrichment attaches the check's documentation URL on
39
+ * the way out. Derived from the registry rather than pasted.
40
+ const enriched = (
41
+ check: string,
42
+ severity: ValidateCodeDiagnostic['severity'] = 'error',
43
+ ): ValidateCodeDiagnostic => {
44
+ const url = checkDocs(check)?.url;
45
+ return url ? { ...diagnostic(check, severity), see_also: url } : diagnostic(check, severity);
46
+ };
47
+
48
+ /**
49
+ * Adapters whose lint returns the given diagnostics per caller key.
50
+ *
51
+ * ALL THREE seams are stubbed, the docset included: left to its default it resolves the
52
+ * process docset over the network, which no test here is about. The empty vocabulary is
53
+ * what they assume — `see_also` still attaches, and no symbol resolves to a signature.
54
+ const adaptersFor = (
55
+ byFile: Record<string, ValidateCodeDiagnostic[]> = {},
56
+ ): Partial<ValidateAdapters> => ({
57
+ lint: async ({ buffers }) => ({
58
+ diagnostics: new Map(buffers.map((b) => [b.filePath, byFile[b.filePath] ?? []])),
59
+ notChecked: new Map(),
60
+ }),
61
+ impact: async () => COMPUTED,
62
+ docset: async () => ({ filters: [], tags: [], objects: [] }),
63
+ });
64
+
65
+ const PAGE = 'app/views/pages/index.liquid';
66
+ const PARTIAL = 'app/views/partials/promo.liquid';
67
+
68
+ /** Narrow the union return to the single-file shape. */
69
+ const single = (result) => {
70
+ expect('files' in result).toBe(false);
71
+ return result;
72
+ };
73
+ /** Narrow the union return to the multi-file shape. */
74
+ const batch = (result) => {
75
+ expect('files' in result).toBe(true);
76
+ return result;
77
+ };
78
+ const validateOne = (content = '<div></div>', overrides = adaptersFor(), file_path = PAGE) => runValidateCode(ctx(), { file_path, content }, overrides).then(single);
79
+ /**
80
+ * A real temp project on disk, plus the operations the INTEGRATION groups below perform on
81
+ * it. Those groups inject no adapters — they drive the same call path the MCP handler takes.
82
+ *
83
+ * Call it in a `describe` body: it registers the per-test setup and teardown on that suite,
84
+ * so each case gets its own directory.
85
+ */
86
+ function tempProject(prefix) {
87
+ let projectDir = '';
88
+ beforeEach(() => {
89
+ projectDir = mkdtempSync(join(tmpdir(), prefix));
90
+ // A `.git` directory is how the project root is recognized.
91
+ mkdirSync(join(projectDir, '.git'));
92
+ });
93
+ afterEach(() => {
94
+ rmSync(projectDir, { recursive: true, force: true });
95
+ });
96
+ const context = () => ({
97
+ projectDir,
98
+ log: () => { },
99
+ });
100
+ return {
101
+ context,
102
+ write(files = {}) {
103
+ for (const [relativePath, source] of Object.entries(files)) {
104
+ const absolute = join(projectDir, relativePath);
105
+ mkdirSync(dirname(absolute), { recursive: true });
106
+ writeFileSync(absolute, source, 'utf8');
107
+ }
108
+ },
109
+ /** Real adapters: the same call path the MCP handler takes. */
110
+ async validate(filePath, content) {
111
+ return (await runValidateCode(context(), {
112
+ file_path: filePath,
113
+ content,
114
+ }));
115
+ },
116
+ };
117
+ }
118
+ describe('validate_code: the single-file form', () => {
119
+ it('returns the FLAT result shape — no files[] wrapper for one file', async () => {
120
+ const result = await validateOne();
121
+ expect(result.status).toEqual('ok');
122
+ expect(Object.keys(result).sort()).toEqual([
123
+ 'errors',
124
+ 'impact',
125
+ 'infos',
126
+ 'must_fix_before_write',
127
+ 'status',
128
+ 'warnings',
129
+ ]);
130
+ });
131
+ it('passes diagnostics and impact straight through', async () => {
132
+ const warning = diagnostic('SomeCheck', 'warning');
133
+ const result = await validateOne('<div></div>', adaptersFor({ [PAGE]: [warning] }));
134
+ expect(result).toEqual({
135
+ status: 'warning',
136
+ must_fix_before_write: false,
137
+ errors: [],
138
+ warnings: [warning],
139
+ infos: [],
140
+ impact: COMPUTED,
141
+ });
142
+ });
143
+ it('degrades a failing impact to unavailable, preserving the lint output', async () => {
144
+ const logs = [];
145
+ const warning = diagnostic('SomeCheck', 'warning');
146
+ const result = single(await runValidateCode(ctx((message) => logs.push(message)), { file_path: PAGE, content: '<div></div>' }, {
147
+ lint: async ({ buffers }) => ({
148
+ diagnostics: new Map(buffers.map((b) => [b.filePath, [warning]])),
149
+ notChecked: new Map(),
150
+ }),
151
+ impact: async () => {
152
+ throw new Error('boom');
153
+ },
154
+ }));
155
+ expect(result.warnings).toEqual([warning]);
156
+ expect(result.impact.status).toEqual('unavailable');
157
+ expect(logs.some((line) => line.includes('blast-radius failed'))).toBe(true);
158
+ });
159
+ it('propagates a lint failure — the primary gate is never silently dropped', async () => {
160
+ const failure = new Error('lint exploded');
161
+ await expect(runValidateCode(ctx(), { file_path: PAGE, content: 'x' }, {
162
+ lint: async () => {
163
+ throw failure;
164
+ },
165
+ impact: async () => COMPUTED,
166
+ })).rejects.toThrow(failure);
167
+ });
168
+ });
169
+ describe('validate_code: the multi-file form', () => {
170
+ const validateMany = (files, overrides = adaptersFor()) => runValidateCode(ctx(), { files }, overrides).then(batch);
171
+ it('returns one entry per requested file, in order', async () => {
172
+ const result = await validateMany([
173
+ { file_path: PAGE, content: 'a' },
174
+ { file_path: PARTIAL, content: 'b' },
175
+ ]);
176
+ expect(result.files.map((entry) => entry.file_path)).toEqual([PAGE, PARTIAL]);
177
+ expect(result.files.map((entry) => entry.result.status)).toEqual(['ok', 'ok']);
178
+ });
179
+ it('lints the whole request in a SINGLE pass, not once per file', async () => {
180
+ let calls = 0;
181
+ let sawBuffers = 0;
182
+ await validateMany([
183
+ { file_path: PAGE, content: 'a' },
184
+ { file_path: PARTIAL, content: 'b' },
185
+ { file_path: 'app/views/layouts/theme.liquid', content: 'c' },
186
+ ], {
187
+ lint: async ({ buffers }) => {
188
+ calls++;
189
+ sawBuffers = buffers.length;
190
+ return {
191
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
192
+ notChecked: new Map(),
193
+ };
194
+ },
195
+ impact: async () => COMPUTED,
196
+ });
197
+ expect([calls, sawBuffers]).toEqual([1, 3]);
198
+ });
199
+ /**
200
+ * The blast radius reads the project per REQUEST, not per file, and the proof has to be
201
+ * identity: every buffer is handed the SAME `ProjectScan`, whose `sources()` is memoized.
202
+ * A scan built per buffer returns identical answers and simply multiplies the I/O.
203
+ */
204
+ it('shares ONE project scan across the batch, rather than one per file', async () => {
205
+ const scans = new Set();
206
+ await validateMany([
207
+ { file_path: PAGE, content: 'a' },
208
+ { file_path: PARTIAL, content: 'b' },
209
+ { file_path: 'app/views/layouts/theme.liquid', content: 'c' },
210
+ ], {
211
+ lint: async ({ buffers }) => ({
212
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
213
+ notChecked: new Map(),
214
+ }),
215
+ impact: async (_params, scan) => {
216
+ scans.add(scan);
217
+ return COMPUTED;
218
+ },
219
+ });
220
+ // Identity is the assertion: `ProjectScan.sources()` is memoized per object, so one
221
+ // object means one project read (pinned in `project-scan.spec.ts`).
222
+ expect(scans.size).toEqual(1);
223
+ });
224
+ it('gates the whole changeset when ANY file blocks', async () => {
225
+ const result = await validateMany([
226
+ { file_path: PAGE, content: 'a' },
227
+ { file_path: PARTIAL, content: 'b' },
228
+ ], adaptersFor({ [PARTIAL]: [diagnostic('MissingPartial')] }));
229
+ expect(result.must_fix_before_write).toBe(true);
230
+ expect(result.files.map((e) => e.result.must_fix_before_write)).toEqual([false, true]);
231
+ });
232
+ it('does NOT gate on a non-blocking error, however many files carry one', async () => {
233
+ const result = await validateMany([
234
+ { file_path: PAGE, content: 'a' },
235
+ { file_path: PARTIAL, content: 'b' },
236
+ ], adaptersFor({
237
+ [PAGE]: [diagnostic('PartialCallArguments')],
238
+ [PARTIAL]: [diagnostic('ImgWidthAndHeight')],
239
+ }));
240
+ expect(result.must_fix_before_write).toBe(false);
241
+ // De-escalation of the GATE, not suppression: both are still errors.
242
+ expect(result.files.map((e) => e.result.status)).toEqual(['error', 'error']);
243
+ });
244
+ it('keys results by the caller’s own path string, relative or absolute', async () => {
245
+ const absolute = '/srv/app/app/views/pages/index.liquid';
246
+ const result = await validateMany([
247
+ { file_path: PAGE, content: 'a' },
248
+ { file_path: absolute, content: 'b' },
249
+ ]);
250
+ expect(result.files.map((e) => e.file_path)).toEqual([PAGE, absolute]);
251
+ });
252
+ });
253
+ describe('validate_code: per-file refusals (a request is never all-or-nothing)', () => {
254
+ it('declines only the off-project file and still validates its siblings', async () => {
255
+ const result = batch(await runValidateCode(ctx(), {
256
+ files: [
257
+ { file_path: '/etc/passwd', content: 'root:x:0:0' },
258
+ { file_path: PAGE, content: 'a' },
259
+ ],
260
+ }, adaptersFor({ [PAGE]: [diagnostic('MissingPartial')] })));
261
+ expect(result.files[0].result.not_applicable_reason).toEqual('outside_project');
262
+ expect(result.files[1].result.errors).toEqual([enriched('MissingPartial')]);
263
+ expect(result.must_fix_before_write).toBe(true);
264
+ });
265
+ it.each([
266
+ ['outside the project', '/etc/passwd', 'outside_project'],
267
+ ['an unsupported type', 'README.md', 'unsupported_type'],
268
+ ])('declines a single file %s without linting', async (_label, file_path, reason) => {
269
+ const calls = [];
270
+ const result = await validateOne('x', {
271
+ lint: async ({ buffers }) => {
272
+ calls.push('lint');
273
+ return {
274
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
275
+ notChecked: new Map(),
276
+ };
277
+ },
278
+ impact: async () => {
279
+ calls.push('impact');
280
+ return COMPUTED;
281
+ },
282
+ }, file_path);
283
+ expect(result.status).toEqual('not_applicable');
284
+ expect(result.not_applicable_reason).toEqual(reason);
285
+ expect(result.must_fix_before_write).toBe(false);
286
+ expect(calls).toEqual([]);
287
+ });
288
+ it('does NOT report ok for valid JSON at an off-project path (the false approval)', async () => {
289
+ const result = await validateOne('{}', adaptersFor(), '/etc/shadow');
290
+ expect(result.status).toEqual('not_applicable');
291
+ expect(result.must_fix_before_write).toBe(false);
292
+ });
293
+ it('declines a config-IGNORED file rather than reporting it clean', async () => {
294
+ // `check()` skips ignored files silently, so "no offenses" would mean "never looked
295
+ // at" — the write gate approving a file nothing checked.
296
+ const result = await validateOne('{% if %}{{ unclosed', {
297
+ // The LINT seam reports this — it holds the config. There is no separate ignore
298
+ // adapter to stub, which is the point: one source of truth.
299
+ lint: async () => ({
300
+ diagnostics: new Map(),
301
+ notChecked: new Map([[PAGE, 'excluded-by-config']]),
302
+ }),
303
+ impact: async () => COMPUTED,
304
+ });
305
+ expect(result.status).toEqual('not_applicable');
306
+ expect(result.not_applicable_reason).toEqual('ignored');
307
+ expect(result.must_fix_before_write).toBe(false);
308
+ });
309
+ it('never reaches the lint for a file the pure gate already declined', async () => {
310
+ // The pure gate is synchronous and runs first, so an off-project path costs no I/O.
311
+ let linted = false;
312
+ await validateOne('x', {
313
+ lint: async () => {
314
+ linted = true;
315
+ return { diagnostics: new Map(), notChecked: new Map() };
316
+ },
317
+ impact: async () => COMPUTED,
318
+ }, '/etc/passwd');
319
+ expect(linted).toBe(false);
320
+ });
321
+ it('loads the project ONCE for a whole multi-file request', async () => {
322
+ // The config-exclusion answer rides along with the single lint pass, so one pass IS
323
+ // one config load.
324
+ let passes = 0;
325
+ await runValidateCode(ctx(), {
326
+ files: [
327
+ { file_path: PAGE, content: 'a' },
328
+ { file_path: PARTIAL, content: 'b' },
329
+ { file_path: 'app/views/layouts/theme.liquid', content: 'c' },
330
+ ],
331
+ }, {
332
+ lint: async ({ buffers }) => {
333
+ passes++;
334
+ return {
335
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
336
+ notChecked: new Map(),
337
+ };
338
+ },
339
+ impact: async () => COMPUTED,
340
+ });
341
+ expect(passes).toEqual(1);
342
+ });
343
+ });
344
+ describe('validate_code: bounded work', () => {
345
+ it('refuses an oversized buffer BEFORE parsing', async () => {
346
+ const calls = [];
347
+ const result = await validateOne('a'.repeat(MAX_BUFFER_BYTES + 1), {
348
+ lint: async ({ buffers }) => {
349
+ calls.push('lint');
350
+ return {
351
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
352
+ notChecked: new Map(),
353
+ };
354
+ },
355
+ impact: async () => COMPUTED,
356
+ });
357
+ expect(result.not_applicable_reason).toEqual('too_large');
358
+ expect(result.must_fix_before_write).toBe(false);
359
+ expect(calls).toEqual([]);
360
+ });
361
+ it('accepts a buffer exactly at the limit', async () => {
362
+ expect((await validateOne('a'.repeat(MAX_BUFFER_BYTES))).status).toEqual('ok');
363
+ });
364
+ it('refuses a request over the total-byte cap, declining every entry', async () => {
365
+ const perFile = MAX_BUFFER_BYTES; // each individually legal
366
+ const files = Array.from({ length: Math.floor(MAX_BATCH_BYTES / perFile) + 1 }, (_, i) => ({
367
+ file_path: `app/views/pages/p${i}.liquid`,
368
+ content: 'a'.repeat(perFile),
369
+ }));
370
+ const result = batch(await runValidateCode(ctx(), { files }, adaptersFor()));
371
+ expect(result.files.map((e) => e.result.not_applicable_reason)).toEqual(files.map(() => 'too_large'));
372
+ expect(result.must_fix_before_write).toBe(false);
373
+ });
374
+ /**
375
+ * Results are keyed by the caller's `file_path` string, but buffers are overlaid and
376
+ * deduplicated by normalized URI (last one wins), so two entries naming one file would
377
+ * lint ONE buffer and report its verdict for BOTH — a false approval with no check
378
+ * involved, whose direction flipped with the argument order.
379
+ */
380
+ it.each([
381
+ ['broken buffer first', '{{ x | no_such_filter }}', '<p>clean</p>'],
382
+ ['clean buffer first', '<p>clean</p>', '{{ x | no_such_filter }}'],
383
+ ])('refuses two entries naming the same file — %s', async (_order, first, second) => {
384
+ const files = [
385
+ { file_path: PARTIAL, content: first },
386
+ { file_path: PARTIAL, content: second },
387
+ ];
388
+ const result = batch(await runValidateCode(ctx(), { files }, adaptersFor({ [PARTIAL]: [diagnostic('X')] })));
389
+ // Every entry is declined, and NONE is reported as checked-and-clean.
390
+ expect(result.files.map((e) => e.result.status)).toEqual(['not_applicable', 'not_applicable']);
391
+ expect(result.files.map((e) => e.result.not_applicable_reason)).toEqual([
392
+ 'internal_error',
393
+ 'internal_error',
394
+ ]);
395
+ expect(result.must_fix_before_write).toBe(false);
396
+ expect(result.next_step).toContain(PARTIAL);
397
+ });
398
+ it('refuses aliased spellings of one file, which collide only after normalization', async () => {
399
+ const files = [
400
+ { file_path: PARTIAL, content: '{{ x | no_such_filter }}' },
401
+ { file_path: `/srv/app/${PARTIAL}`, content: '<p>clean</p>' },
402
+ ];
403
+ const result = batch(await runValidateCode(ctx(), { files }, adaptersFor()));
404
+ expect(result.files.map((e) => e.result.not_applicable_reason)).toEqual([
405
+ 'internal_error',
406
+ 'internal_error',
407
+ ]);
408
+ });
409
+ it('never reaches the lint for a self-contradictory request', async () => {
410
+ // The refusal is decidable from the request alone, and no buffer may be linted when
411
+ // we will not report its result honestly.
412
+ const lint = vi.fn();
413
+ await runValidateCode(ctx(), {
414
+ files: [
415
+ { file_path: PARTIAL, content: 'a' },
416
+ { file_path: PARTIAL, content: 'b' },
417
+ ],
418
+ }, { ...adaptersFor(), lint });
419
+ expect(lint).not.toHaveBeenCalled();
420
+ });
421
+ it('still validates a batch that names each file once', async () => {
422
+ // The guard must not cost the legitimate case, including the mixed relative/absolute
423
+ // spelling the caller-string keying exists to support.
424
+ const files = [
425
+ { file_path: PAGE, content: 'a' },
426
+ { file_path: `/srv/app/${PARTIAL}`, content: 'b' },
427
+ ];
428
+ const result = batch(await runValidateCode(ctx(), { files }, adaptersFor()));
429
+ expect(result.files.map((e) => e.result.status)).toEqual(['ok', 'ok']);
430
+ expect(result.files.map((e) => e.file_path)).toEqual([PAGE, `/srv/app/${PARTIAL}`]);
431
+ });
432
+ it('returns a determinate timed_out result when the lint never settles', async () => {
433
+ vi.useFakeTimers();
434
+ try {
435
+ const pending = runValidateCode(ctx(), { file_path: PAGE, content: '<div></div>' }, { lint: () => new Promise(() => { }), impact: async () => COMPUTED });
436
+ await vi.advanceTimersByTimeAsync(MIN_LINT_DEADLINE_MS + 1);
437
+ const result = single(await pending);
438
+ expect(result.not_applicable_reason).toEqual('timed_out');
439
+ // A timeout is OUR failure, not a verdict on the file — it must not block.
440
+ expect(result.must_fix_before_write).toBe(false);
441
+ }
442
+ finally {
443
+ vi.useRealTimers();
444
+ }
445
+ });
446
+ it('gives a large batch the LONGER deadline its size earns, not the floor', async () => {
447
+ // The deadline scales with the bytes admitted, so being still-running at the floor is
448
+ // CORRECT here; a fixed deadline against a larger cap returned `timed_out` for EVERY
449
+ // file in the worst legal batch.
450
+ const big = 'x'.repeat(100 * 1024);
451
+ const files = [
452
+ { file_path: PAGE, content: big },
453
+ { file_path: PARTIAL, content: big },
454
+ ];
455
+ const earned = lintDeadlineMs(2 * 100 * 1024);
456
+ expect(earned).toBeGreaterThan(MIN_LINT_DEADLINE_MS);
457
+ vi.useFakeTimers();
458
+ try {
459
+ let settled = false;
460
+ const pending = runValidateCode(ctx(), { files }, {
461
+ lint: () => new Promise(() => { }),
462
+ impact: async () => COMPUTED,
463
+ }).then((value) => {
464
+ settled = true;
465
+ return value;
466
+ });
467
+ await vi.advanceTimersByTimeAsync(MIN_LINT_DEADLINE_MS + 1);
468
+ expect(settled).toBe(false);
469
+ await vi.advanceTimersByTimeAsync(earned - MIN_LINT_DEADLINE_MS);
470
+ const result = batch(await pending);
471
+ expect(result.files.map((entry) => entry.result.not_applicable_reason)).toEqual([
472
+ 'timed_out',
473
+ 'timed_out',
474
+ ]);
475
+ // The message must quote the deadline actually applied, not the floor — it is the
476
+ // only place the caller can see how long it was held.
477
+ expect(result.files.map((entry) => entry.result.next_step?.startsWith(`Validation exceeded ${Math.round(earned / 1000)} s and was abandoned`))).toEqual([true, true]);
478
+ }
479
+ finally {
480
+ vi.useRealTimers();
481
+ }
482
+ });
483
+ it('a lint abandoned by the deadline is observed, not left unhandled', async () => {
484
+ vi.useFakeTimers();
485
+ const unhandled = [];
486
+ const onUnhandled = (reason) => unhandled.push(reason);
487
+ process.on('unhandledRejection', onUnhandled);
488
+ try {
489
+ const logs = [];
490
+ let rejectLint = () => { };
491
+ const pending = runValidateCode(ctx((message) => logs.push(message)), { file_path: PAGE, content: 'x' }, {
492
+ lint: () => new Promise((_, reject) => {
493
+ rejectLint = reject;
494
+ }),
495
+ impact: async () => COMPUTED,
496
+ });
497
+ await vi.advanceTimersByTimeAsync(MIN_LINT_DEADLINE_MS + 1);
498
+ await pending;
499
+ rejectLint(new Error('too late to matter'));
500
+ await vi.advanceTimersByTimeAsync(1);
501
+ vi.useRealTimers();
502
+ await new Promise((resolve) => setTimeout(resolve, 10));
503
+ expect(logs.some((line) => line.includes('abandoned lint later failed'))).toBe(true);
504
+ expect(unhandled).toEqual([]);
505
+ }
506
+ finally {
507
+ vi.useRealTimers();
508
+ process.off('unhandledRejection', onUnhandled);
509
+ }
510
+ });
511
+ it('caps a stalled impact at ITS deadline, far below the lint deadline', async () => {
512
+ expect(IMPACT_DEADLINE_MS).toBeLessThan(MIN_LINT_DEADLINE_MS);
513
+ vi.useFakeTimers();
514
+ try {
515
+ const pending = runValidateCode(ctx(), { file_path: PAGE, content: 'x' }, {
516
+ lint: async ({ buffers }) => ({
517
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
518
+ notChecked: new Map(),
519
+ }),
520
+ impact: () => new Promise(() => { }),
521
+ });
522
+ // One tick past the IMPACT deadline must settle the call.
523
+ await vi.advanceTimersByTimeAsync(IMPACT_DEADLINE_MS + 1);
524
+ const result = single(await pending);
525
+ expect(result.status).toEqual('ok');
526
+ expect(result.impact.status).toEqual('unavailable');
527
+ }
528
+ finally {
529
+ vi.useRealTimers();
530
+ }
531
+ });
532
+ it('runs lint and impact CONCURRENTLY, not one after the other', async () => {
533
+ // Serializing them would add the whole blast-radius cost to every call. Asserted by
534
+ // observing that impact starts while the lint is still in flight.
535
+ let lintStarted = false;
536
+ let impactSawLintRunning = false;
537
+ let releaseLint = () => { };
538
+ const lintGate = new Promise((resolve) => {
539
+ releaseLint = resolve;
540
+ });
541
+ await runValidateCode(ctx(), { file_path: PAGE, content: 'x' }, {
542
+ lint: async ({ buffers }) => {
543
+ lintStarted = true;
544
+ await lintGate;
545
+ return {
546
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
547
+ notChecked: new Map(),
548
+ };
549
+ },
550
+ impact: async () => {
551
+ impactSawLintRunning = lintStarted;
552
+ releaseLint();
553
+ return COMPUTED;
554
+ },
555
+ });
556
+ expect(impactSawLintRunning).toBe(true);
557
+ });
558
+ });
559
+ describe('validate_code: input shape', () => {
560
+ it('rejects a request carrying BOTH forms rather than guessing', async () => {
561
+ const result = single(await runValidateCode(ctx(), { file_path: PAGE, content: 'a', files: [{ file_path: PARTIAL, content: 'b' }] }, adaptersFor()));
562
+ expect(result.status).toEqual('not_applicable');
563
+ expect(result.not_applicable_reason).toEqual('internal_error');
564
+ expect(result.must_fix_before_write).toBe(false);
565
+ });
566
+ it('rejects a request carrying NEITHER form', async () => {
567
+ const result = single(await runValidateCode(ctx(), {}, adaptersFor()));
568
+ expect(result.not_applicable_reason).toEqual('internal_error');
569
+ });
570
+ it('rejects a half-specified single form', async () => {
571
+ const result = single(await runValidateCode(ctx(), { file_path: PAGE }, adaptersFor()));
572
+ expect(result.not_applicable_reason).toEqual('internal_error');
573
+ });
574
+ it('distinguishes internal_error from timed_out, so retry advice differs', async () => {
575
+ // Reusing `timed_out` here would tell an agent to "retry with fewer files" for
576
+ // something retrying cannot fix.
577
+ const result = single(await runValidateCode(ctx(), {}, adaptersFor()));
578
+ expect(result.not_applicable_reason).not.toEqual('timed_out');
579
+ });
580
+ });
581
+ describe('VALIDATE_CODE_INPUT', () => {
582
+ const schema = z.object(VALIDATE_CODE_INPUT);
583
+ const parse = (args) => schema.safeParse(args).success;
584
+ it('accepts the single form', () => {
585
+ expect(parse({ file_path: PAGE, content: 'x' })).toBe(true);
586
+ });
587
+ it('accepts the multi form', () => {
588
+ expect(parse({ files: [{ file_path: PAGE, content: 'x' }] })).toBe(true);
589
+ });
590
+ it('rejects a blank path in either form', () => {
591
+ expect(parse({ file_path: ' ', content: 'x' })).toBe(false);
592
+ expect(parse({ files: [{ file_path: ' ', content: 'x' }] })).toBe(false);
593
+ });
594
+ it('rejects an empty files array', () => {
595
+ expect(parse({ files: [] })).toBe(false);
596
+ });
597
+ it('rejects a files array over the file-count cap at the protocol boundary', () => {
598
+ const files = Array.from({ length: MAX_BATCH_FILES + 1 }, (_, i) => ({
599
+ file_path: `app/views/pages/p${i}.liquid`,
600
+ content: 'x',
601
+ }));
602
+ expect(parse({ files })).toBe(false);
603
+ });
604
+ it('advertises exactly the three accepted fields', () => {
605
+ expect(Object.keys(VALIDATE_CODE_INPUT).sort()).toEqual(['content', 'file_path', 'files']);
606
+ });
607
+ it('TOLERATES a stale caller still sending mode, dropping it', () => {
608
+ // `mode` was removed; an agent mid-session must not start failing over a parameter
609
+ // that never did anything.
610
+ const parsed = schema.safeParse({ file_path: PAGE, content: 'x', mode: 'quick' });
611
+ expect(parsed.success).toBe(true);
612
+ expect(parsed.success && Object.keys(parsed.data).sort()).toEqual(['content', 'file_path']);
613
+ });
614
+ });
615
+ /**
616
+ * The description and the server instructions ARE the agent's entire understanding of this
617
+ * tool, so a stale claim there converts "I do not know" into false confidence. These pin
618
+ * the claims most likely to rot, and the ones whose rotting is dangerous.
619
+ */
620
+ describe('validate_code: the agent-facing surface', () => {
621
+ const { VALIDATE_CODE_DESCRIPTION } = TOOL_TEXT;
622
+ it('states the either/or rule exactly ONCE, in the description', () => {
623
+ // One rule, stated once, in the place an agent is deciding what to send.
624
+ const inParams = Object.values(VALIDATE_CODE_INPUT).filter((field) => /omit|never both|either/i.test(field.description ?? ''));
625
+ expect(inParams).toEqual([]);
626
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('EITHER one file OR several — never both');
627
+ });
628
+ it('shows a concrete example of BOTH input forms', () => {
629
+ // An agent copies examples. Prose alone leaves the nesting ambiguous.
630
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('"file_path": "app/views/partials/card.liquid"');
631
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('"files": [');
632
+ });
633
+ it('claims YAML only now that YAML syntax is actually validated', () => {
634
+ // Coverage is not total — see the instructions, which say what is and is not checked
635
+ // for YAML.
636
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('Liquid, GraphQL and YAML');
637
+ });
638
+ it('says plainly that a false gate is not a correctness guarantee', () => {
639
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('not a guarantee of correctness');
640
+ });
641
+ it('says not_applicable is neither approval nor refusal', () => {
642
+ expect(VALIDATE_CODE_DESCRIPTION).toContain('neither approval nor refusal');
643
+ });
644
+ });
645
+ describe('server instructions', () => {
646
+ it('documents EVERY not_applicable_reason the code can return', () => {
647
+ // A reason the agent has never heard of is a reason it cannot act on. This fails
648
+ // the moment someone adds a code without documenting it.
649
+ const documented = [
650
+ 'outside_project',
651
+ 'unsupported_type',
652
+ 'ignored',
653
+ 'too_large',
654
+ 'timed_out',
655
+ 'internal_error',
656
+ ];
657
+ for (const reason of documented) {
658
+ expect(SERVER_INSTRUCTIONS).toContain(reason);
659
+ }
660
+ });
661
+ /**
662
+ * "The SHAPE of a model schema is not checked" is a SILENCE, so no diagnostic can carry
663
+ * it and an agent reading a clean YAML result as a shape guarantee is the false
664
+ * confidence this text exists to prevent. `instructions-coverage.spec.ts` holds the
665
+ * behavioural half.
666
+ */
667
+ it('keeps the YAML claim no diagnostic can deliver, and drops the one that fires', () => {
668
+ expect({
669
+ keptTheSilence: SERVER_INSTRUCTIONS.includes('The SHAPE of a model schema is not checked'),
670
+ droppedWhatFires: SERVER_INSTRUCTIONS.includes('one that does not parse is reported'),
671
+ }).toEqual({ keptTheSilence: true, droppedWhatFires: false });
672
+ });
673
+ it('tells the agent to validate BEFORE writing', () => {
674
+ expect(SERVER_INSTRUCTIONS).toContain('BEFORE writing');
675
+ });
676
+ /**
677
+ * THE INVARIANT: the instructions name NO platform rule at all. Every such rule fires a
678
+ * real diagnostic that carries its check's documentation URL, so prose here would be a
679
+ * staler second copy paid for in context on every session. Each phrase below is one that
680
+ * was once required; re-adding a gotcha fails here and is pointed at the check that owns
681
+ * it.
682
+ */
683
+ it('restates no platform rule — the diagnostics carry those, with links', () => {
684
+ const reintroduced = [
685
+ 'must use DOUBLE quotes',
686
+ 'needs a Hash with a key or an Array',
687
+ 'end in a BRACKET',
688
+ 'rejected by the converter',
689
+ 'YAML 1.1',
690
+ 'hash_add_key',
691
+ 'content_for_layout',
692
+ ].filter((phrase) => SERVER_INSTRUCTIONS.includes(phrase));
693
+ expect(reintroduced).toEqual([]);
694
+ });
695
+ it('states the coverage it DOES claim as a derived count, not a hand-written list', () => {
696
+ // Derived from the registry, so it cannot describe a build that no longer exists.
697
+ expect(SERVER_INSTRUCTIONS).toContain(`${allChecks.length} checks run against your buffer`);
698
+ });
699
+ it('describes the size refusal in terms of BOTH bounds it can come from', () => {
700
+ // "Split the file" is the wrong instruction for a request that is legal per file and
701
+ // over the batch cap — the caller would shrink files that were never the problem.
702
+ expect(SERVER_INSTRUCTIONS).toContain('or the request as a whole');
703
+ });
704
+ it('explains that errors[] can be non-empty while the gate is false', () => {
705
+ // The single most confusing thing about the result: error does not imply blocked.
706
+ expect(SERVER_INSTRUCTIONS).toContain('must_fix_before_write is false');
707
+ });
708
+ it('does NOT tell an agent a malformed request is unfixable', () => {
709
+ // `internal_error` covers two different things, and three of its cases are the
710
+ // CALLER's mistake — both input forms, neither, or one file listed twice — which are
711
+ // worth retrying once fixed.
712
+ expect(SERVER_INSTRUCTIONS).not.toContain('retrying will not help');
713
+ expect(SERVER_INSTRUCTIONS).toContain(`is yours
714
+ to fix`);
715
+ });
716
+ it('states the once-per-file rule, which the server enforces by refusing', () => {
717
+ // `validate/batch-coherence.ts` declines a request naming one file twice, so the rule
718
+ // has to be findable BEFORE the agent trips it.
719
+ expect(SERVER_INSTRUCTIONS).toContain('List each file at most once');
720
+ expect(TOOL_TEXT.VALIDATE_CODE_DESCRIPTION).toContain('List each file at most once');
721
+ });
722
+ it('says columns are UTF-16 code units, which is not inferable', () => {
723
+ // Verified behaviour: an emoji advances the column by 2, so an agent counting code
724
+ // points misplaces every column after one.
725
+ expect(SERVER_INSTRUCTIONS).toContain('UTF-16 code units');
726
+ });
727
+ it('scopes the ordering claim to each list rather than across all three', () => {
728
+ // `assembleResult` sorts, THEN partitions, so concatenating errors + warnings + infos
729
+ // does not walk the file in order.
730
+ expect(SERVER_INSTRUCTIONS).toContain('WITHIN ITSELF');
731
+ });
732
+ it('explains that an unavailable blast radius is not "nothing depends on this"', () => {
733
+ // Zeroed dependents on a failed or timed-out lookup read exactly like a real answer.
734
+ expect(SERVER_INSTRUCTIONS).toContain('NOT a claim that nothing depends');
735
+ });
736
+ });
737
+ /**
738
+ * Fixtures are MINIMAL on purpose: the smallest buffer that produces the code, so a failure
739
+ * points at the check rather than at whichever of six constructs broke.
740
+ *
741
+ * Positions and message text are NOT asserted — they belong to check-common and are pinned
742
+ * by its own specs.
743
+ */
744
+ const EMITS = {
745
+ LiquidHTMLSyntaxError: {
746
+ filePath: PAGE,
747
+ content: '{% if true %}\n',
748
+ errors: ['LiquidHTMLSyntaxError'],
749
+ },
750
+ MissingPartial: {
751
+ filePath: PAGE,
752
+ content: "{% render 'no_such_partial' %}\n",
753
+ errors: ['MissingPartial'],
754
+ },
755
+ UnknownFilter: {
756
+ filePath: PAGE,
757
+ content: "{{ 'a' | no_such_filter_xyz }}\n",
758
+ errors: ['UnknownFilter'],
759
+ },
760
+ FilterArity: {
761
+ // `upcase` takes the piped input and nothing else; three positional arguments
762
+ // is `Liquid::ArgumentError` at runtime.
763
+ filePath: PAGE,
764
+ content: "{{ 'a' | upcase: 1, 2, 3 }}\n",
765
+ errors: ['FilterArity'],
766
+ },
767
+ JsonLiteralQuoteStyle: {
768
+ // Single-quoted key in an assign JSON literal. `{{ o }}` only keeps the buffer
769
+ // free of an unrelated `UnusedAssign` warning.
770
+ filePath: PAGE,
771
+ content: "{% assign o = {'k': 1} %}{{ o }}\n",
772
+ errors: ['JsonLiteralQuoteStyle'],
773
+ },
774
+ InvalidWriteTarget: {
775
+ // The two tags are separated. What this fixture proves is that a reported offense
776
+ // reaches the gate and blocks; the detection itself is check-common's business.
777
+ filePath: PAGE,
778
+ content: `{% assign x = 5 %}
779
+ {% hash_assign x['k'] = 'v' %}
780
+ `,
781
+ errors: ['InvalidWriteTarget'],
782
+ },
783
+ MissingContentForLayout: {
784
+ filePath: 'app/views/layouts/application.liquid',
785
+ content: '<html><body></body></html>\n',
786
+ errors: ['MissingContentForLayout'],
787
+ },
788
+ MissingRenderPartialArguments: {
789
+ // A DOCUMENTED partial: the `{% doc %}` block is an explicit contract, and this
790
+ // blocking check owns it ALONE. `PartialCallArguments` deliberately does not fire here
791
+ // — it covers only partials with no contract — so one mistake produces one finding.
792
+ // Asserting the exact error list is what makes that observed rather than restated; the
793
+ // control that `PartialCallArguments` still fires at all lives immediately below.
794
+ project: {
795
+ 'app/views/partials/card.liquid': `{% doc %}
796
+ @param title {string} Title
797
+ {% enddoc %}
798
+ {{ title }}
799
+ `,
800
+ },
801
+ filePath: PAGE,
802
+ content: "{% render 'card' %}\n",
803
+ errors: ['MissingRenderPartialArguments'],
804
+ },
805
+ YAMLSyntaxError: {
806
+ // A model schema whose second property sits one column left of the sequence item above
807
+ // it. `--dry-run` rejects this and fails the whole changeset.
808
+ filePath: 'app/schema/car.yml',
809
+ content: `name: car
810
+ properties:
811
+ - name: make
812
+ type: string
813
+ year: 1
814
+ `,
815
+ errors: ['YAMLSyntaxError'],
816
+ },
817
+ GraphQLCheck: {
818
+ filePath: 'app/graphql/broken.graphql',
819
+ content: 'query { no_such_root_field { id } }\n',
820
+ errors: ['GraphQLCheck'],
821
+ },
822
+ GraphQLVariablesCheck: {
823
+ // Passes a variable the operation does not declare, and omits the one it
824
+ // requires — both are the same code.
825
+ project: {
826
+ 'app/graphql/get_thing.graphql': 'query get_thing($id: ID!) { records { results { id } } }\n',
827
+ },
828
+ filePath: PAGE,
829
+ content: "{% graphql g = 'get_thing', wrong_var: 1 %}\n",
830
+ errors: ['GraphQLVariablesCheck'],
831
+ },
832
+ };
833
+ const NEVER_REACHES_THE_GATE = {
834
+ ValidJSON: { filePath: 'app/config.json', content: 'not a json value at all\n' },
835
+ JSONSyntaxError: { filePath: 'app/views/pages/data.json', content: '{ "a": ,\n' },
836
+ };
837
+ /**
838
+ * Whitespace between two Liquid tags, varied — the axis a fixture cannot exercise by being
839
+ * minimal.
840
+ *
841
+ * Asserting that ONE buffer emits cannot detect a check blind to a DIFFERENT buffer for the
842
+ * same defect. `InvalidWriteTarget` was exactly that: it excluded a lookup at the defining
843
+ * tag's end offset, so it went silent when the two tags abutted and fired with one space
844
+ * between them, while this suite stayed green. Separation between tags is formatting; it
845
+ * must never change a verdict.
846
+ *
847
+ * The transformation is the FIXTURE'S OWN TEXT rather than an injected probe: prefixing a
848
+ * benign `{% assign %}` was measured across all members and changed nothing, because the
849
+ * probe's adjacency is not the pair a check reasons about. The axis exists only where a
850
+ * fixture already contains two related tags, which is pinned below rather than left
851
+ * implicit.
852
+ */
853
+ const TAGS_APART = /%\}\s+\{%/g;
854
+ const TAGS_TOGETHER = /%\}\{%/g;
855
+ /** The distinct spellings of `content` that differ only in inter-tag whitespace. */
856
+ function adjacencyVariants(content) {
857
+ return [
858
+ ...new Set([
859
+ content,
860
+ content.replace(TAGS_APART, '%}{%'),
861
+ content.replace(TAGS_TOGETHER, `%}
862
+ {%`),
863
+ ]),
864
+ ];
865
+ }
866
+ describe('Integration: every blocking check can actually block', () => {
867
+ const { write, validate } = tempProject('mcp-sup-emission-');
868
+ it('has a fixture for every member of BLOCKING_CHECKS, and none for a non-member', () => {
869
+ // The exhaustiveness guard: adding a member without evidence that it fires fails HERE,
870
+ // rather than in production where a check that emits nothing is indistinguishable from
871
+ // a clean file. There is no second list to fall back into.
872
+ expect(Object.keys(EMITS).sort()).toEqual([...BLOCKING_CHECKS].sort());
873
+ });
874
+ it('does not gate on the two removed codes, however the buffer is spelled', () => {
875
+ // Guards the removal itself: re-adding either without making `.json` reachable puts
876
+ // back a member that cannot ever fire.
877
+ expect(Object.keys(NEVER_REACHES_THE_GATE).map((code) => BLOCKING_CHECKS.has(code))).toEqual([
878
+ false,
879
+ false,
880
+ ]);
881
+ });
882
+ for (const [code, fixture] of Object.entries(EMITS)) {
883
+ it(`emits ${code} from a real buffer, and it blocks the write`, async () => {
884
+ write(fixture.project);
885
+ const result = await validate(fixture.filePath, fixture.content);
886
+ expect({
887
+ blocked: result.must_fix_before_write,
888
+ status: result.status,
889
+ errors: [...new Set(result.errors.map((error) => error.check))].sort(),
890
+ }).toEqual({
891
+ blocked: true,
892
+ status: 'error',
893
+ errors: [...fixture.errors].sort(),
894
+ });
895
+ });
896
+ }
897
+ /**
898
+ * `hash_assign` is deprecated, so the fixture above pins the gate against the spelling an
899
+ * author is being told to STOP writing. `assign` and `function` reach the same runtime
900
+ * setter — measured, every container × subscript combination identical — and both carry a
901
+ * second rule in `<<`, which needs an Array and refuses a Hash.
902
+ *
903
+ * All three are asserted end-to-end HERE because the claim that matters is that the
904
+ * offense reaches the gate: `blocksWrite` needs severity `error` AND membership of
905
+ * `BLOCKING_CHECKS`, neither of which is visible from the check's own spec.
906
+ */
907
+ it('blocks a subscript write and an append through assign and function too', async () => {
908
+ // `{% function %}` names a partial, so it needs a real one — otherwise `MissingPartial`
909
+ // joins the verdict and the assertion stops being about this check. A function partial
910
+ // hands its value back through `{% return %}`; without one it returns nil.
911
+ write({ 'app/views/partials/p.liquid': "{% return 'x' %}\n" });
912
+ const buffers = [
913
+ // A subscript write onto a String. The runtime raises "x is abc, expected Hash or Array".
914
+ `{% assign x = 'abc' %}{% assign x['k'] = 'v' %}`,
915
+ // A DOT target onto the same String, which `hash_assign` cannot even parse and `assign`
916
+ // can — so this shape is reachable only through `assign`.
917
+ `{% assign x = 'abc' %}{% assign x.k = 'v' %}`,
918
+ // An append onto a Hash. The runtime raises "x is {}, expected Array".
919
+ `{% parse_json x %}{}{% endparse_json %}{% assign x << 1 %}`,
920
+ // The same two rules under `function`, whose write semantics were measured rather
921
+ // than assumed unmeasurable.
922
+ `{% assign x = 'abc' %}{% function x['k'] = 'p' %}`,
923
+ `{% parse_json x %}{}{% endparse_json %}{% function x << 'p' %}`,
924
+ ];
925
+ const verdicts = [];
926
+ for (const content of buffers) {
927
+ const result = await validate(PAGE, content);
928
+ verdicts.push({
929
+ blocked: result.must_fix_before_write,
930
+ status: result.status,
931
+ errors: [...new Set(result.errors.map((error) => error.check))].sort(),
932
+ });
933
+ }
934
+ expect(verdicts).toEqual(buffers.map(() => ({
935
+ blocked: true,
936
+ status: 'error',
937
+ errors: ['InvalidWriteTarget'],
938
+ })));
939
+ });
940
+ /**
941
+ * THE CONTROL for the silence in the `MissingRenderPartialArguments` fixture, which
942
+ * asserts `PartialCallArguments` does NOT fire for a DOCUMENTED partial. An assertion
943
+ * that something does not fire is worth nothing alone — a check that had stopped working
944
+ * entirely would satisfy it — so this proves the same code still fires on the case it
945
+ * does own, from the same pipeline, and still does not gate the write.
946
+ *
947
+ * Two facts in one assertion on purpose: it FIRES (so the silence above is ownership, not
948
+ * breakage) and it does NOT BLOCK (so the non-blocking entry is real behaviour).
949
+ */
950
+ it('PartialCallArguments still fires for an UNDOCUMENTED partial, and does not block', async () => {
951
+ // No `{% doc %}` block, so the required param is INFERRED from the undefined
952
+ // variable the partial reads. That inference is exactly why the code must not gate a
953
+ // write: it is a heuristic, and the runtime failure it predicts is a nil value.
954
+ write({ 'app/views/partials/bare.liquid': '{{ title }}\n' });
955
+ const result = await validate(PAGE, "{% render 'bare' %}\n");
956
+ expect({
957
+ blocked: result.must_fix_before_write,
958
+ status: result.status,
959
+ errors: [...new Set(result.errors.map((error) => error.check))].sort(),
960
+ }).toEqual({
961
+ blocked: false,
962
+ // Still an `error` in the list — de-escalation of the GATE, not suppression of
963
+ // the finding. The agent is told; it is simply not stopped.
964
+ status: 'error',
965
+ errors: ['PartialCallArguments'],
966
+ });
967
+ });
968
+ it('records which fixtures actually exercise tag adjacency', () => {
969
+ // Not an exemption list — an OBSERVATION, pinned so it cannot drift silently. A fixture
970
+ // rewritten into a single tag stops testing the axis, and stating today's answer is the
971
+ // only way to notice.
972
+ const withAxis = Object.entries(EMITS)
973
+ .filter(([, fixture]) => adjacencyVariants(fixture.content).length > 1)
974
+ .map(([code]) => code);
975
+ expect(withAxis).toEqual(['InvalidWriteTarget']);
976
+ });
977
+ for (const [code, fixture] of Object.entries(EMITS)) {
978
+ it(`${code}: inter-tag whitespace does not change the verdict`, async () => {
979
+ write(fixture.project);
980
+ const variants = adjacencyVariants(fixture.content);
981
+ const verdicts = [];
982
+ for (const content of variants) {
983
+ const result = await validate(fixture.filePath, content);
984
+ verdicts.push({
985
+ blocked: result.must_fix_before_write,
986
+ errors: [...new Set(result.errors.map((error) => error.check))].sort(),
987
+ });
988
+ }
989
+ // Stated as AGREEMENT: every spelling must produce the SAME verdict, and the
990
+ // expectation is written once, so a check that behaves differently across shapes
991
+ // fails here instead of being encoded as though it were intended.
992
+ const agreed = { blocked: true, errors: [...fixture.errors].sort() };
993
+ expect(verdicts).toEqual(variants.map(() => agreed));
994
+ });
995
+ }
996
+ for (const [code, proof] of Object.entries(NEVER_REACHES_THE_GATE)) {
997
+ it(`cannot reach ${code}: the only files that produce it are never checked`, async () => {
998
+ const result = await validate(proof.filePath, proof.content);
999
+ expect({
1000
+ status: result.status,
1001
+ reason: result.not_applicable_reason,
1002
+ blocked: result.must_fix_before_write,
1003
+ errors: result.errors,
1004
+ }).toEqual({
1005
+ status: 'not_applicable',
1006
+ reason: 'unsupported_type',
1007
+ blocked: false,
1008
+ errors: [],
1009
+ });
1010
+ });
1011
+ }
1012
+ /**
1013
+ * The server instructions name filters-in-conditions as a blocking construct and
1014
+ * filters-in-tag-operands as explicitly NOT reported. Both halves are pinned HERE, through
1015
+ * the real pipeline, because prose cannot fail.
1016
+ *
1017
+ * The pairing is the point: a gate wide enough to block every `|` in a tag satisfies the
1018
+ * first table alone, and a grammar wide enough to accept every `|` satisfies the second
1019
+ * alone. Both tables were settled against `pos-cli deploy --dry-run`, each construct
1020
+ * deployed with the filter and again without it.
1021
+ */
1022
+ const FILTER_REFUSED_BY_THE_CONVERTER = [
1023
+ "{% if 'a' | upcase == 'A' %}y{% endif %}\n",
1024
+ "{% unless 'a' | upcase == 'A' %}y{% endunless %}\n",
1025
+ "{% if false %}n{% elsif 'a' | upcase == 'A' %}y{% endif %}\n",
1026
+ "{% for i in 'a,b' | split: ',' %}{{ i }}{% endfor %}\n",
1027
+ ];
1028
+ const FILTER_ACCEPTED_BY_THE_CONVERTER = [
1029
+ "{% cache 'k' | append: '1' %}x{% endcache %}\n",
1030
+ "{% log 'msg' | upcase %}\n",
1031
+ "{% yield 'slot' | upcase %}\n",
1032
+ "{% redirect_to '/p' | append: '/x' %}\n",
1033
+ "{% case 'a' | upcase %}{% when 'A' %}y{% endcase %}\n",
1034
+ "{% cycle 'a' | upcase, 'b' %}\n",
1035
+ ];
1036
+ it('blocks a filter inside a condition, exactly as the instructions claim', async () => {
1037
+ const verdicts = [];
1038
+ for (const content of FILTER_REFUSED_BY_THE_CONVERTER) {
1039
+ const result = await validate(PAGE, content);
1040
+ verdicts.push({
1041
+ blocked: result.must_fix_before_write,
1042
+ errors: [...new Set(result.errors.map((error) => error.check))],
1043
+ });
1044
+ }
1045
+ // The block here is OVER-DETERMINED, measured by sabotage: deleting
1046
+ // `checkFilterInCondition` leaves it green (a truthiness heuristic fires instead), and
1047
+ // so does widening the grammar (stage 2 then throws). So this pins the CLAIM the
1048
+ // instructions make — these constructs block — not which rule produces it. That is
1049
+ // pinned in check-common's `InvalidConditionalNode.spec.ts`.
1050
+ expect(verdicts).toEqual(FILTER_REFUSED_BY_THE_CONVERTER.map(() => ({
1051
+ blocked: true,
1052
+ errors: ['LiquidHTMLSyntaxError'],
1053
+ })));
1054
+ });
1055
+ it('does not BLOCK a filter in a tag operand, but does warn that it has no effect', async () => {
1056
+ // The control for the test above, in both halves.
1057
+ //
1058
+ // NOT BLOCKED is the false-block half: the converter accepts every one of these, and
1059
+ // `errors` must stay empty, since a non-blocking ERROR is still reported to the agent as
1060
+ // something it must deal with.
1061
+ //
1062
+ // WARNED is why asserting silence alone was not enough: the runtime IGNORES the filter
1063
+ // (measured over 15 positions), so approving these without a word would ship code that
1064
+ // does not do what its author wrote.
1065
+ const verdicts = [];
1066
+ for (const content of FILTER_ACCEPTED_BY_THE_CONVERTER) {
1067
+ const result = await validate(PAGE, content);
1068
+ verdicts.push({
1069
+ blocked: result.must_fix_before_write,
1070
+ errors: result.errors,
1071
+ warnings: [...new Set(result.warnings.map((warning) => warning.check))],
1072
+ });
1073
+ }
1074
+ expect(verdicts).toEqual(FILTER_ACCEPTED_BY_THE_CONVERTER.map(() => ({
1075
+ blocked: false,
1076
+ errors: [],
1077
+ warnings: ['FilterWithoutEffect'],
1078
+ })));
1079
+ });
1080
+ /**
1081
+ * The instructions promise two things about tag argument types, both pinned here because
1082
+ * prose cannot fail: an argument the documentation TYPES warns and does not block, and an
1083
+ * argument it says nothing about is not reported at all.
1084
+ *
1085
+ * The pairing is the point. Asserting only the warning would pass on a check that types
1086
+ * everything it can guess at; asserting only the silence would pass with the whole
1087
+ * mechanism deleted. The silent fixture is an argument the documentation publishes NO
1088
+ * parameter for, which is a claim about this pipeline rather than about how much the
1089
+ * platform has documented so far.
1090
+ */
1091
+ it('warns on a mistyped tag argument and stays silent on an undocumented one, as the instructions claim', async () => {
1092
+ const typed = await validate(PAGE, `{% assign y = 'a,b' | split: ',' %}{% for x in y limit: 'ten' %}{{ x }}{% endfor %}\n`);
1093
+ const alsoTyped = await validate(PAGE, `{% cache 'k', expire: 'soon' %}body{% endcache %}\n`);
1094
+ const undocumented = await validate(PAGE, `{% cache 'k', not_in_the_docs: 'soon' %}body{% endcache %}\n`);
1095
+ const verdict = (result) => ({
1096
+ blocked: result.must_fix_before_write,
1097
+ warnings: [...new Set(result.warnings.map((warning) => warning.check))],
1098
+ });
1099
+ expect({
1100
+ typed: verdict(typed),
1101
+ alsoTyped: verdict(alsoTyped),
1102
+ undocumented: verdict(undocumented),
1103
+ }).toEqual({
1104
+ typed: { blocked: false, warnings: ['ValidTagArgumentTypes'] },
1105
+ alsoTyped: { blocked: false, warnings: ['ValidTagArgumentTypes'] },
1106
+ undocumented: { blocked: false, warnings: [] },
1107
+ });
1108
+ });
1109
+ /**
1110
+ * The FILTER half of the same promise, and the half the instructions have to be careful
1111
+ * about. A core Liquid filter COERCES rather than refuses — `{{ 5 | upcase }}` renders,
1112
+ * measured against a live instance — so nothing is reported about it, whether or not the
1113
+ * docset yet separates `object` (a Hash) from `untyped` (several types accepted).
1114
+ *
1115
+ * The other half — a wrong Hash reported — depends on the docset carrying that
1116
+ * separation, so what is pinned here is the part that cannot change under it: neither
1117
+ * call BLOCKS. Promising the warning would be promising a docs release.
1118
+ */
1119
+ it('never blocks on a filter argument, and stays silent on one that coerces, as the instructions claim', async () => {
1120
+ const coercing = await validate(PAGE, `{{ 5 | upcase }}\n`);
1121
+ const wrongHash = await validate(PAGE, `{{ 123 | hash_add_key: 'k', 'v' }}\n`);
1122
+ expect({
1123
+ coercing: {
1124
+ blocked: coercing.must_fix_before_write,
1125
+ warnings: [...new Set(coercing.warnings.map((warning) => warning.check))],
1126
+ },
1127
+ wrongHash: { blocked: wrongHash.must_fix_before_write },
1128
+ }).toEqual({
1129
+ coercing: { blocked: false, warnings: [] },
1130
+ wrongHash: { blocked: false },
1131
+ });
1132
+ });
1133
+ /**
1134
+ * The instructions promise two things about duplicate YAML keys, and this pins BOTH
1135
+ * against the real pipeline.
1136
+ *
1137
+ * The strong promise: a key repeated with the SAME spelling is always reported. The
1138
+ * bounded promise is the control — four spellings are measured NOT to be detected, so the
1139
+ * instructions say look-alike detection is not exhaustive. Asserting only the first half
1140
+ * would let that qualifier be deleted as redundant.
1141
+ */
1142
+ it('reports a duplicate YAML key with the same spelling, without blocking', async () => {
1143
+ const previouslyMissed = ['y', '0X10', '1e3', '.inf', '.nan', '1:30', '2026-01-01'];
1144
+ const verdicts = [];
1145
+ for (const token of previouslyMissed) {
1146
+ const result = await validate('app/translations/en.yml', `en:\n ${token}: x\n ${token}: y\n`);
1147
+ verdicts.push({
1148
+ blocked: result.must_fix_before_write,
1149
+ warnings: [...new Set(result.warnings.map((warning) => warning.check))],
1150
+ });
1151
+ }
1152
+ expect(verdicts).toEqual(previouslyMissed.map(() => ({ blocked: false, warnings: ['DuplicateYAMLKey'] })));
1153
+ });
1154
+ it('stays silent on the look-alike pairs it cannot decide, which is why the instructions say so', async () => {
1155
+ // Each is ONE key to Psych and TWO to npm `yaml`, with no reconciliation available —
1156
+ // `1:30` is 5400 to Ruby and 90 here. Reported as a gap in the instructions rather than
1157
+ // guessed at, because a duplicate claimed where the platform keeps two keys invites
1158
+ // deleting a working key.
1159
+ const undecidable = [
1160
+ ['1:30', '5400'],
1161
+ ['"0X10"', '0X10'],
1162
+ ['"1e3"', '1e3'],
1163
+ ['"y"', 'y'],
1164
+ ];
1165
+ const warnings = [];
1166
+ for (const [first, second] of undecidable) {
1167
+ const result = await validate('app/translations/en.yml', `en:\n ${first}: x\n ${second}: y\n`);
1168
+ warnings.push(result.warnings.map((warning) => warning.check));
1169
+ }
1170
+ expect(warnings).toEqual(undecidable.map(() => []));
1171
+ });
1172
+ it('routes no supported file type to the JSON checks, which is WHY those two were removed', () => {
1173
+ // The cause behind the two results above, asserted structurally so it does not rest on
1174
+ // two hand-picked paths: every extension `isSupportedSourceFile` admits, paired with
1175
+ // the type check-common parses it as.
1176
+ //
1177
+ // JSON appears exactly once, and only against `false`. `toSourceCode` DOES fall back to
1178
+ // JSON for an unrecognised extension — an editor fallback for the language server — so
1179
+ // the invariant is the conjunction: nothing the server ADMITS parses as JSON, and
1180
+ // anything that would parse as JSON is declined before a check runs.
1181
+ //
1182
+ // `isSupportedSourceFile` is ANCHORED in master and takes the root, because a known
1183
+ // directory name is not enough on its own: `seed/post_import/app/migrations/x.liquid`
1184
+ // is not a migration.
1185
+ const rootUri = 'file:///p';
1186
+ const samples = [
1187
+ 'file:///p/app/views/pages/index.liquid',
1188
+ 'file:///p/app/graphql/get_thing.graphql',
1189
+ 'file:///p/app/translations/en.yml',
1190
+ 'file:///p/app/model_schemas/thing.yml',
1191
+ // THE NEGATIVE CONTROL: `.yaml` reads as a YAML file to every human and is not a
1192
+ // platformOS extension — every YAML model on the backend anchors `\.yml\z` — so it is
1193
+ // refused, and it is also the one sample that reaches the JSON fallback. A list of
1194
+ // nothing but `true` rows could not tell the two halves of the invariant apart.
1195
+ 'file:///p/app/model_schemas/thing.yaml',
1196
+ ];
1197
+ expect(samples.map((uri) => [isSupportedSourceFile(uri, rootUri), toSourceCode(uri, '').type])).toEqual([
1198
+ [true, SourceCodeType.LiquidHtml],
1199
+ [true, SourceCodeType.GraphQL],
1200
+ [true, SourceCodeType.YAML],
1201
+ [true, SourceCodeType.YAML],
1202
+ [false, SourceCodeType.JSON],
1203
+ ]);
1204
+ });
1205
+ });
1206
+ const SCHEMA = 'app/schema/thing.yml';
1207
+ const TRANSLATIONS = 'app/translations/en.yml';
1208
+ /** Nesting deep enough to be unusual, shallow enough to be a real file. */
1209
+ const deeplyNested = (levels) => {
1210
+ let out = '';
1211
+ for (let index = 0; index < levels; index++)
1212
+ out += `${' '.repeat(index)}k${index}:\n`;
1213
+ return `${out}${' '.repeat(levels)}leaf: 1\n`;
1214
+ };
1215
+ /**
1216
+ * Valid-but-unusual YAML, imported from the round-4 evaluation rather than re-derived.
1217
+ *
1218
+ * That round deployed 52 shapes individually through `--dry-run` and the converter accepted
1219
+ * 50; the two it refused are the duplicate-key case, included for that reason. Re-deriving
1220
+ * the corpus would mean re-running a live instance or guessing.
1221
+ */
1222
+ const VALID_YAML = {
1223
+ anchor_and_alias: `base: &b
1224
+ a: 1
1225
+ other: *b
1226
+ `,
1227
+ merge_key: `base: &b
1228
+ a: 1
1229
+ child:
1230
+ <<: *b
1231
+ c: 2
1232
+ `,
1233
+ merge_key_multi_source: `a: &a
1234
+ x: 1
1235
+ b: &b
1236
+ y: 2
1237
+ c:
1238
+ <<: [*a, *b]
1239
+ `,
1240
+ block_scalar_literal: `name: |
1241
+ line one
1242
+ line two
1243
+ `,
1244
+ block_scalar_folded: `name: >
1245
+ folded text here
1246
+ `,
1247
+ block_scalar_strip: `name: |-
1248
+ no trailing newline
1249
+ `,
1250
+ block_scalar_keep: 'name: |+\n keep\n\n',
1251
+ block_scalar_explicit_indent: `name: |2
1252
+ two space indent
1253
+ `,
1254
+ explicit_tags: `a: !!str 5
1255
+ b: !!int "7"
1256
+ c: !!seq [1, 2]
1257
+ `,
1258
+ custom_tag: 'a: !mytag foo\n',
1259
+ quoted_scalar_with_colon: 'a: "x: y"\n',
1260
+ quoted_scalar_with_hash: "a: 'c # d'\n",
1261
+ quoted_scalar_with_tab: 'a: "tab\there"\n',
1262
+ empty_document: '',
1263
+ comments_only: '# nothing here\n',
1264
+ byte_order_mark: '\uFEFFname: car\n',
1265
+ crlf_line_endings: 'name: car\r\nother: 1\r\n',
1266
+ document_start_marker: `---
1267
+ name: car
1268
+ `,
1269
+ document_end_marker: `name: car
1270
+ ...
1271
+ `,
1272
+ multi_document: `name: a
1273
+ ---
1274
+ name: b
1275
+ `,
1276
+ bare_scalar: 'just a string\n',
1277
+ top_level_sequence: `- 1
1278
+ - 2
1279
+ `,
1280
+ deep_nesting: deeplyNested(60),
1281
+ very_long_line: `name: ${'x'.repeat(20000)}\n`,
1282
+ non_ascii_keys: `zażółć: gęślą
1283
+ ключ: значение
1284
+ `,
1285
+ emoji_key_and_value: `"🎉": party
1286
+ value: "🚀"
1287
+ `,
1288
+ yaml_directive: `%YAML 1.2
1289
+ ---
1290
+ name: car
1291
+ `,
1292
+ complex_key: `? [a, b]
1293
+ : value
1294
+ `,
1295
+ flow_collections: 'a: {b: 1, c: [1, 2]}\n',
1296
+ infinity_and_nan: `a: .inf
1297
+ b: -.inf
1298
+ c: .nan
1299
+ `,
1300
+ octal_and_hex: `a: 0o14
1301
+ b: 0x1F
1302
+ `,
1303
+ timestamps: `a: 2026-01-01
1304
+ b: 2026-01-01T12:00:00Z
1305
+ `,
1306
+ empty_values: `a:
1307
+ b:
1308
+ `,
1309
+ explicit_nulls: `a: ~
1310
+ b: null
1311
+ `,
1312
+ duplicate_key_top_level: `name: car
1313
+ name: van
1314
+ `,
1315
+ duplicate_key_nested: `name: car
1316
+ properties:
1317
+ make: ford
1318
+ make: audi
1319
+ `,
1320
+ };
1321
+ /** Every shape, in a model schema and in a translation file. */
1322
+ const yamlFixtures = () => Object.entries(VALID_YAML).flatMap(([name, content]) => [
1323
+ { name, filePath: SCHEMA, content, oracle: 'dry-run' },
1324
+ { name: `${name} (translations)`, filePath: TRANSLATIONS, content, oracle: 'dry-run' },
1325
+ ]);
1326
+ const EXISTING_PARTIALS = {
1327
+ 'app/views/partials/card.liquid': 'card body\n',
1328
+ 'app/lib/helper.liquid': 'helper body\n',
1329
+ };
1330
+ const DOCUMENTED_PARTIAL = {
1331
+ 'app/views/partials/card.liquid': `{% doc %}
1332
+ @param title {string} Title
1333
+ {% enddoc %}
1334
+ {{ title }}
1335
+ `,
1336
+ };
1337
+ const GRAPHQL_OPERATION = {
1338
+ 'app/graphql/get_thing.graphql': 'query get_thing($id: ID!) { records(per_page: 10) { results { id } } }\n',
1339
+ };
1340
+ /**
1341
+ * Keyed by blocking check. The keys are pinned against `BLOCKING_CHECKS` below, so a
1342
+ * member added without must-stay-silent coverage fails here rather than shipping with
1343
+ * only half its promise defended.
1344
+ */
1345
+ const STAYS_SILENT = {
1346
+ YAMLSyntaxError: yamlFixtures(),
1347
+ LiquidHTMLSyntaxError: [
1348
+ // Liquid the parser must accept. `by-construction` because the claim is only that
1349
+ // these parse — the evaluations render pages built from exactly these constructs.
1350
+ {
1351
+ name: 'if block',
1352
+ filePath: PAGE,
1353
+ content: '{% if true %}x{% endif %}\n',
1354
+ oracle: 'by-construction',
1355
+ },
1356
+ {
1357
+ name: 'whitespace control',
1358
+ filePath: PAGE,
1359
+ content: '{%- if true -%}x{%- endif -%}\n',
1360
+ oracle: 'by-construction',
1361
+ },
1362
+ {
1363
+ name: 'liquid tag',
1364
+ filePath: PAGE,
1365
+ content: `{% liquid
1366
+ assign a = 1
1367
+ echo a
1368
+ %}
1369
+ `,
1370
+ oracle: 'by-construction',
1371
+ },
1372
+ {
1373
+ name: 'raw block',
1374
+ filePath: PAGE,
1375
+ content: '{% raw %}{{ not_liquid }}{% endraw %}\n',
1376
+ oracle: 'by-construction',
1377
+ },
1378
+ {
1379
+ name: 'case block',
1380
+ filePath: PAGE,
1381
+ content: '{% assign x = 1 %}{% case x %}{% when 1 %}a{% else %}b{% endcase %}\n',
1382
+ oracle: 'by-construction',
1383
+ },
1384
+ {
1385
+ name: 'for over a range',
1386
+ filePath: PAGE,
1387
+ content: '{% for i in (1..3) %}{{ i }}{% endfor %}\n',
1388
+ oracle: 'by-construction',
1389
+ },
1390
+ {
1391
+ name: 'liquid inside an html attribute',
1392
+ filePath: PAGE,
1393
+ content: '<div data-x="{{ 1 }}">{{ 2 }}</div>\n',
1394
+ oracle: 'by-construction',
1395
+ },
1396
+ {
1397
+ name: 'comment block',
1398
+ filePath: PAGE,
1399
+ content: '{% comment %}hi{% endcomment %}\n',
1400
+ oracle: 'by-construction',
1401
+ },
1402
+ {
1403
+ name: 'nested tags',
1404
+ filePath: PAGE,
1405
+ content: '{% if true %}{% for i in (1..2) %}{{ i }}{% endfor %}{% endif %}\n',
1406
+ oracle: 'by-construction',
1407
+ },
1408
+ {
1409
+ name: 'filter chain',
1410
+ filePath: PAGE,
1411
+ content: "{{ 'a' | upcase | downcase }}\n",
1412
+ oracle: 'generated-data',
1413
+ },
1414
+ ],
1415
+ MissingPartial: [
1416
+ {
1417
+ name: 'render a partial that exists',
1418
+ project: EXISTING_PARTIALS,
1419
+ filePath: PAGE,
1420
+ content: "{% render 'card' %}\n",
1421
+ oracle: 'by-construction',
1422
+ },
1423
+ {
1424
+ name: 'function against app/lib',
1425
+ project: EXISTING_PARTIALS,
1426
+ filePath: PAGE,
1427
+ content: "{% function res = 'helper' %}{{ res }}\n",
1428
+ oracle: 'by-construction',
1429
+ },
1430
+ ],
1431
+ UnknownFilter: [
1432
+ // The vocabulary is generated data, so a documented filter is valid by
1433
+ // measurement rather than by recognition.
1434
+ {
1435
+ name: 'documented filters',
1436
+ filePath: PAGE,
1437
+ content: "{{ 'a' | upcase }}{{ 'B' | downcase }}{{ 1 | plus: 2 }}\n",
1438
+ oracle: 'generated-data',
1439
+ },
1440
+ // Liquid's own filters, which the docs API used to omit while the runtime accepted
1441
+ // them. Reporting one is a false block on working code; the platform publishing the
1442
+ // whole vocabulary is what replaced the local list of six names that prevented it.
1443
+ {
1444
+ name: 'undocumented but valid: sum',
1445
+ filePath: PAGE,
1446
+ content: "{% assign arr = '1,2' | split: ',' %}{{ arr | sum }}\n",
1447
+ oracle: 'generated-data',
1448
+ },
1449
+ {
1450
+ name: 'undocumented but valid: where',
1451
+ filePath: PAGE,
1452
+ content: "{% assign arr = '1,2' | split: ',' %}{{ arr | where: 'k', 'v' }}\n",
1453
+ oracle: 'generated-data',
1454
+ },
1455
+ ],
1456
+ FilterArity: [
1457
+ // Argument counts the measured table permits, at both ends of a range.
1458
+ {
1459
+ name: 'exactly the minimum',
1460
+ filePath: PAGE,
1461
+ content: "{{ 'a' | upcase }}\n",
1462
+ oracle: 'generated-data',
1463
+ },
1464
+ {
1465
+ name: 'exactly two',
1466
+ filePath: PAGE,
1467
+ content: "{{ 'a' | append: 'b' }}\n",
1468
+ oracle: 'generated-data',
1469
+ },
1470
+ {
1471
+ name: 'inside a range',
1472
+ filePath: PAGE,
1473
+ content: "{{ 'a' | default: 'd' }}\n",
1474
+ oracle: 'generated-data',
1475
+ },
1476
+ // `array_map` is one of four filters the generator could not determine and left
1477
+ // ABSENT rather than guessed. A filter with no measured arity must produce nothing,
1478
+ // whatever it is passed — the property the check was admitted to the blocking set on.
1479
+ {
1480
+ name: 'a filter with no measured arity',
1481
+ filePath: PAGE,
1482
+ content: "{% assign arr = '1,2' | split: ',' %}{{ arr | array_map: 'k' }}\n",
1483
+ oracle: 'generated-data',
1484
+ },
1485
+ ],
1486
+ JsonLiteralQuoteStyle: [
1487
+ {
1488
+ name: 'double-quoted object literal',
1489
+ filePath: PAGE,
1490
+ content: '{% assign o = {"k": "v"} %}{{ o }}\n',
1491
+ oracle: 'dry-run',
1492
+ },
1493
+ {
1494
+ name: 'double-quoted array literal',
1495
+ filePath: PAGE,
1496
+ content: '{% assign a = ["x", "y"] %}{{ a }}\n',
1497
+ oracle: 'dry-run',
1498
+ },
1499
+ // Single quotes are only a defect INSIDE a JSON literal. An ordinary
1500
+ // single-quoted string is the common case and must never be touched.
1501
+ {
1502
+ name: 'an ordinary single-quoted string',
1503
+ filePath: PAGE,
1504
+ content: "{% assign s = 'plain' %}{{ s }}\n",
1505
+ oracle: 'dry-run',
1506
+ },
1507
+ ],
1508
+ GraphQLCheck: [
1509
+ // A FIXTURE ERROR WORTH RECORDING. Both of these began as `records { results { id } }`;
1510
+ // the schema requires `per_page`, so the check reported them and the "silence"
1511
+ // fixtures were simply wrong. An observation about the input, read as an observation
1512
+ // about the tool.
1513
+ {
1514
+ name: 'valid query with a declared variable',
1515
+ filePath: 'app/graphql/get_thing.graphql',
1516
+ content: 'query get_thing($per_page: Int!) { records(per_page: $per_page) { results { id } } }\n',
1517
+ oracle: 'schema',
1518
+ },
1519
+ {
1520
+ name: 'valid query with no variables',
1521
+ filePath: 'app/graphql/plain.graphql',
1522
+ content: 'query plain { records(per_page: 10) { results { id } } }\n',
1523
+ oracle: 'schema',
1524
+ },
1525
+ ],
1526
+ GraphQLVariablesCheck: [
1527
+ {
1528
+ name: 'passes the declared variable',
1529
+ project: GRAPHQL_OPERATION,
1530
+ filePath: PAGE,
1531
+ content: "{% graphql g = 'get_thing', id: 1 %}{{ g }}\n",
1532
+ oracle: 'by-construction',
1533
+ },
1534
+ ],
1535
+ InvalidWriteTarget: [
1536
+ // From the round-4 structural set: 31 cases, zero false blocks, each run in both
1537
+ // tag spacings. These are the shapes the runtime ACCEPTS — a Hash takes a key, an
1538
+ // Array takes an index — which is exactly the distinction the check models.
1539
+ {
1540
+ name: 'hash with a key',
1541
+ filePath: PAGE,
1542
+ content: `{% assign h = '{}' | parse_json %}
1543
+ {% hash_assign h['k'] = 'v' %}
1544
+ `,
1545
+ oracle: 'runtime',
1546
+ },
1547
+ {
1548
+ name: 'array with an index',
1549
+ filePath: PAGE,
1550
+ content: `{% assign a = '1,2' | split: ',' %}
1551
+ {% hash_assign a[0] = 'v' %}
1552
+ `,
1553
+ oracle: 'runtime',
1554
+ },
1555
+ // A variable subscript cannot be resolved statically, so the accessor is unknown
1556
+ // and the check must decline rather than guess.
1557
+ {
1558
+ name: 'variable subscript',
1559
+ filePath: PAGE,
1560
+ content: `{% assign h = '{}' | parse_json %}
1561
+ {% assign k = 'a' %}
1562
+ {% hash_assign h[k] = 'v' %}
1563
+ `,
1564
+ oracle: 'runtime',
1565
+ },
1566
+ // Never assigned in this file. It raises at runtime HERE, but in a partial the same
1567
+ // variable legitimately arrives as a render argument, so silence is deliberate.
1568
+ {
1569
+ name: 'target never assigned in this file',
1570
+ filePath: PAGE,
1571
+ content: "{% hash_assign x['k'] = 'v' %}\n",
1572
+ oracle: 'by-construction',
1573
+ },
1574
+ // `assign` writes into a Hash too, and is what an author should reach for now that
1575
+ // `hash_assign` is deprecated. The same accepted shapes, under the tag that is not
1576
+ // going away — plus the two `assign` has and `hash_assign` does not.
1577
+ {
1578
+ name: 'assign: hash with a key',
1579
+ filePath: PAGE,
1580
+ content: `{% assign h = '{}' | parse_json %}
1581
+ {% assign h['k'] = 'v' %}
1582
+ `,
1583
+ oracle: 'runtime',
1584
+ },
1585
+ {
1586
+ name: 'assign: array with an index',
1587
+ filePath: PAGE,
1588
+ content: `{% assign a = '1,2' | split: ',' %}
1589
+ {% assign a[0] = 'v' %}
1590
+ `,
1591
+ oracle: 'runtime',
1592
+ },
1593
+ // The shape a rule generalised from `hash_assign` would refuse. Measured with the hash
1594
+ // read back: it writes the key `k`. `hash_assign h.k` raises a PARSE-time syntax error,
1595
+ // and that difference is notation, not semantics.
1596
+ {
1597
+ name: 'assign: DOT target on a hash, which hash_assign cannot parse',
1598
+ filePath: PAGE,
1599
+ content: `{% assign h = '{}' | parse_json %}
1600
+ {% assign h.k = 'v' %}
1601
+ `,
1602
+ oracle: 'runtime',
1603
+ },
1604
+ {
1605
+ name: 'assign: append to an array',
1606
+ filePath: PAGE,
1607
+ content: `{% assign a = '1,2' | split: ',' %}
1608
+ {% assign a << 3 %}
1609
+ `,
1610
+ oracle: 'runtime',
1611
+ },
1612
+ // The false block this suite exists to catch: a write INTO a hash does not replace
1613
+ // it, so the SECOND write must not be refused as a write onto the first one's value.
1614
+ {
1615
+ name: 'assign: two writes into the same hash',
1616
+ filePath: PAGE,
1617
+ content: `{% assign h = '{}' | parse_json %}
1618
+ {% assign h['k'] = 'v' %}
1619
+ {% hash_assign h['j'] = 'w' %}
1620
+ `,
1621
+ oracle: 'runtime',
1622
+ },
1623
+ ],
1624
+ MissingRenderPartialArguments: [
1625
+ {
1626
+ name: 'passes the required parameter',
1627
+ project: DOCUMENTED_PARTIAL,
1628
+ filePath: PAGE,
1629
+ content: "{% render 'card', title: 'x' %}\n",
1630
+ oracle: 'by-construction',
1631
+ },
1632
+ ],
1633
+ MissingContentForLayout: [
1634
+ {
1635
+ name: 'layout outputs content_for_layout',
1636
+ filePath: 'app/views/layouts/application.liquid',
1637
+ content: '<html><body>{{ content_for_layout }}</body></html>\n',
1638
+ oracle: 'by-construction',
1639
+ },
1640
+ ],
1641
+ };
1642
+ describe('Integration: every blocking check stays silent on input the platform accepts', () => {
1643
+ const { write, validate } = tempProject('mcp-sup-silence-');
1644
+ /**
1645
+ * What a fixture must produce. `blocked` covers the whole gate rather than the one
1646
+ * code, so a fixture that trips a DIFFERENT blocking check fails too — which is
1647
+ * correct, because such a fixture is not valid input after all.
1648
+ */
1649
+ const observe = async (code, fixture) => {
1650
+ write(fixture.project);
1651
+ const result = await validate(fixture.filePath, fixture.content);
1652
+ const everyDiagnostic = [...result.errors, ...result.warnings, ...result.infos];
1653
+ return {
1654
+ name: fixture.name,
1655
+ blocked: result.must_fix_before_write,
1656
+ fromCheck: everyDiagnostic
1657
+ .filter((diagnostic) => diagnostic.check === code)
1658
+ .map(({ check, message }) => `${check}: ${message}`),
1659
+ };
1660
+ };
1661
+ const silent = (fixture) => ({
1662
+ name: fixture.name,
1663
+ blocked: false,
1664
+ fromCheck: [],
1665
+ });
1666
+ it('has must-stay-silent coverage for every member of BLOCKING_CHECKS', () => {
1667
+ // The exhaustiveness guard: a new blocking code must arrive with BOTH halves of its
1668
+ // promise — a fixture in the emission suite proving it fires, and at least one here
1669
+ // proving where it does not.
1670
+ expect(Object.keys(STAYS_SILENT).sort()).toEqual([...BLOCKING_CHECKS].sort());
1671
+ });
1672
+ it('has at least one fixture per member, with the corpus size pinned per code', () => {
1673
+ // Pinned, not merely non-empty: the cheapest way to make a failure disappear is to
1674
+ // delete the case that produced it, so a changed count has to be changed here too.
1675
+ expect(Object.fromEntries(Object.entries(STAYS_SILENT).map(([code, fixtures]) => [code, fixtures.length]))).toEqual({
1676
+ YAMLSyntaxError: 72,
1677
+ LiquidHTMLSyntaxError: 10,
1678
+ MissingPartial: 2,
1679
+ UnknownFilter: 3,
1680
+ FilterArity: 4,
1681
+ JsonLiteralQuoteStyle: 3,
1682
+ GraphQLCheck: 2,
1683
+ GraphQLVariablesCheck: 1,
1684
+ InvalidWriteTarget: 9,
1685
+ MissingRenderPartialArguments: 1,
1686
+ MissingContentForLayout: 1,
1687
+ });
1688
+ });
1689
+ it('records the oracle behind every fixture, and which codes rest on which', () => {
1690
+ // An OBSERVATION, pinned so provenance cannot quietly weaken. `by-construction` is the
1691
+ // weakest claim available, so a code drifting toward it is a real loss of evidence.
1692
+ const oraclesByCode = Object.fromEntries(Object.entries(STAYS_SILENT).map(([code, fixtures]) => [
1693
+ code,
1694
+ [...new Set(fixtures.map((fixture) => fixture.oracle))].sort(),
1695
+ ]));
1696
+ expect(oraclesByCode).toEqual({
1697
+ YAMLSyntaxError: ['dry-run'],
1698
+ LiquidHTMLSyntaxError: ['by-construction', 'generated-data'],
1699
+ MissingPartial: ['by-construction'],
1700
+ UnknownFilter: ['generated-data'],
1701
+ FilterArity: ['generated-data'],
1702
+ JsonLiteralQuoteStyle: ['dry-run'],
1703
+ GraphQLCheck: ['schema'],
1704
+ GraphQLVariablesCheck: ['by-construction'],
1705
+ InvalidWriteTarget: ['by-construction', 'runtime'],
1706
+ MissingRenderPartialArguments: ['by-construction'],
1707
+ MissingContentForLayout: ['by-construction'],
1708
+ });
1709
+ });
1710
+ for (const [code, fixtures] of Object.entries(STAYS_SILENT)) {
1711
+ it(`${code}: reports nothing, and blocks nothing, on valid input`, async () => {
1712
+ const observed = [];
1713
+ for (const fixture of fixtures) {
1714
+ observed.push(await observe(code, fixture));
1715
+ }
1716
+ // Whole-value across the entire corpus, so a failure names the shape and the
1717
+ // message it wrongly produced rather than just a count.
1718
+ expect(observed).toEqual(fixtures.map(silent));
1719
+ }, 120_000);
1720
+ }
1721
+ it('records which fixtures actually exercise tag adjacency', () => {
1722
+ // The same observation the emission suite pins, and measured rather than predicted:
1723
+ // `UnknownFilter` does NOT carry the axis, because its fixtures pair a `{% assign %}`
1724
+ // with an `{{ output }}` and the axis only exists between two `{% %}` tags.
1725
+ const withAxis = Object.entries(STAYS_SILENT)
1726
+ .filter(([, fixtures]) => fixtures.some((fixture) => adjacencyVariants(fixture.content).length > 1))
1727
+ .map(([code]) => code);
1728
+ expect(withAxis).toEqual(['LiquidHTMLSyntaxError', 'InvalidWriteTarget']);
1729
+ });
1730
+ for (const [code, fixtures] of Object.entries(STAYS_SILENT)) {
1731
+ const multiTag = fixtures.filter((fixture) => adjacencyVariants(fixture.content).length > 1);
1732
+ if (multiTag.length === 0)
1733
+ continue;
1734
+ it(`${code}: inter-tag whitespace does not break the silence`, async () => {
1735
+ const observed = [];
1736
+ const expected = [];
1737
+ for (const fixture of multiTag) {
1738
+ for (const content of adjacencyVariants(fixture.content)) {
1739
+ observed.push(await observe(code, { ...fixture, content }));
1740
+ expected.push(silent(fixture));
1741
+ }
1742
+ }
1743
+ expect(observed).toEqual(expected);
1744
+ }, 120_000);
1745
+ }
1746
+ it('still refuses YAML that genuinely does not parse, in every admitted location', async () => {
1747
+ // The one control kept here rather than left to the emission suite. Suppressing
1748
+ // `DUPLICATE_KEY` is an edit that could widen into hiding real parse failures, and
1749
+ // the whole YAML corpus above would pass just as happily if it had.
1750
+ const broken = `name: car
1751
+ properties: [unclosed
1752
+ `;
1753
+ const locations = [
1754
+ SCHEMA,
1755
+ TRANSLATIONS,
1756
+ 'app/transactable_types/t.yml',
1757
+ 'app/user_profile_types/u.yml',
1758
+ ];
1759
+ const observed = [];
1760
+ for (const filePath of locations) {
1761
+ const result = await validate(filePath, broken);
1762
+ observed.push({
1763
+ filePath,
1764
+ blocked: result.must_fix_before_write,
1765
+ errors: [...new Set(result.errors.map((error) => error.check))],
1766
+ });
1767
+ }
1768
+ // Expectation built from the INPUT list, not from what was observed — otherwise a
1769
+ // run that produced fewer results than locations would pass.
1770
+ expect(observed).toEqual(locations.map((filePath) => ({ filePath, blocked: true, errors: ['YAMLSyntaxError'] })));
1771
+ }, 120_000);
1772
+ it('reports a duplicate key as a non-blocking WARNING, not as silence and not as a block', async () => {
1773
+ // Once `DuplicateYAMLKey` landed, "no diagnostic at all" and "no block" stopped being
1774
+ // the same claim, and only the second is the promise this server makes. So the absence
1775
+ // of a block is asserted together with the PRESENCE of the advisory: asserting only the
1776
+ // silence would let the check be deleted, asserting only the warning would let it drift
1777
+ // onto the write gate.
1778
+ const observed = [];
1779
+ for (const [name, content] of [
1780
+ [
1781
+ 'top level',
1782
+ `name: car
1783
+ name: van
1784
+ `,
1785
+ ],
1786
+ [
1787
+ 'nested',
1788
+ `name: car
1789
+ properties:
1790
+ make: ford
1791
+ make: audi
1792
+ `,
1793
+ ],
1794
+ ]) {
1795
+ const result = await validate(SCHEMA, content);
1796
+ observed.push({
1797
+ name,
1798
+ blocked: result.must_fix_before_write,
1799
+ errorChecks: [...new Set(result.errors.map((error) => error.check))],
1800
+ warningChecks: [...new Set(result.warnings.map((warning) => warning.check))],
1801
+ });
1802
+ }
1803
+ expect(observed).toEqual([
1804
+ { name: 'top level', blocked: false, errorChecks: [], warningChecks: ['DuplicateYAMLKey'] },
1805
+ { name: 'nested', blocked: false, errorChecks: [], warningChecks: ['DuplicateYAMLKey'] },
1806
+ ]);
1807
+ }, 120_000);
1808
+ });
1809
+ /**
1810
+ * Every file type this server ADMITS must have at least one check that examines it.
1811
+ *
1812
+ * AN INVARIANT, NOT A FACT. `status: 'ok'` is documented as "the file WAS checked", and
1813
+ * that is only worth anything if something actually looked. An evaluation found three YAML
1814
+ * file-type families where the lint ran and ZERO checks applied, so a broken model schema
1815
+ * came back `ok` — indistinguishable on the wire from a file examined and found clean. The
1816
+ * backlog contains work to ADD file types, and adding one without a check that reads it
1817
+ * silently recreates that.
1818
+ *
1819
+ * EXHAUSTIVE TWICE, on purpose: `Record<PlatformOSFileType, …>` makes a new enum member a
1820
+ * COMPILE error, and the runtime pin below repeats it with a readable message.
1821
+ *
1822
+ * WHAT IS ASSERTED: that a deliberately broken buffer of each type produces at least one
1823
+ * diagnostic — the observable proxy for "something read this file". Which check objects is
1824
+ * check-common's business and is deliberately not pinned, except for the four YAML families,
1825
+ * where it is the whole point.
1826
+ */
1827
+ * /;
1828
+ /** Marker for a type this server does NOT admit — it must decline, not approve. */
1829
+ const NOT_ADMITTED = Symbol('not admitted');
1830
+ const BROKEN_LIQUID = "{{ 'a' | no_such_filter_zzz }}\n";
1831
+ const BROKEN_YAML = `name: car
1832
+ properties:
1833
+ - name: make
1834
+ type: string
1835
+ year: 1
1836
+ `;
1837
+ const BROKEN_GRAPHQL = 'query { no_such_root_field { id } }\n';
1838
+ const COVERAGE = {
1839
+ [PlatformOSFileType.Page]: { filePath: 'app/views/pages/i.liquid', content: BROKEN_LIQUID },
1840
+ [PlatformOSFileType.Layout]: { filePath: 'app/views/layouts/l.liquid', content: BROKEN_LIQUID },
1841
+ [PlatformOSFileType.Partial]: {
1842
+ filePath: 'app/views/partials/p.liquid',
1843
+ content: BROKEN_LIQUID,
1844
+ alsoSpelled: ['app/lib/c.liquid'],
1845
+ },
1846
+ [PlatformOSFileType.Authorization]: {
1847
+ filePath: 'app/authorization_policies/a.liquid',
1848
+ content: BROKEN_LIQUID,
1849
+ },
1850
+ [PlatformOSFileType.Email]: { filePath: 'app/emails/e.liquid', content: BROKEN_LIQUID },
1851
+ [PlatformOSFileType.ApiCall]: { filePath: 'app/api_calls/a.liquid', content: BROKEN_LIQUID },
1852
+ [PlatformOSFileType.Sms]: { filePath: 'app/smses/s.liquid', content: BROKEN_LIQUID },
1853
+ [PlatformOSFileType.Migration]: { filePath: 'app/migrations/m.liquid', content: BROKEN_LIQUID },
1854
+ [PlatformOSFileType.FormConfiguration]: {
1855
+ filePath: 'app/forms/f.liquid',
1856
+ content: BROKEN_LIQUID,
1857
+ },
1858
+ // The four that had nothing at all until `YAMLSyntaxError`.
1859
+ [PlatformOSFileType.Table]: {
1860
+ filePath: 'app/schema/c.yml',
1861
+ content: BROKEN_YAML,
1862
+ alsoSpelled: ['app/model_schemas/c.yml', 'app/custom_model_types/c.yml'],
1863
+ },
1864
+ [PlatformOSFileType.UserProfileType]: {
1865
+ filePath: 'app/user_profile_types/u.yml',
1866
+ content: BROKEN_YAML,
1867
+ alsoSpelled: ['app/instance_profile_types/u.yml', 'app/user_profile_schemas/u.yml'],
1868
+ },
1869
+ [PlatformOSFileType.TransactableType]: {
1870
+ filePath: 'app/transactable_types/t.yml',
1871
+ content: BROKEN_YAML,
1872
+ },
1873
+ [PlatformOSFileType.Translation]: {
1874
+ filePath: 'app/translations/en.yml',
1875
+ content: BROKEN_YAML,
1876
+ // Admitted later than the four above, and the reason this file is a guard rather than a
1877
+ // record: each arrived as a `PlatformOSFileType` with no check written for it. They are
1878
+ // covered by `YAMLSyntaxError`, which examines any YAML source whatever directory it is in.
1879
+ // `YAMLSyntaxError` for the same reason the families above are — it examines any YAML
1880
+ // source, whatever directory it sits in.
1881
+ [PlatformOSFileType.ActivityStreamsHandler]: {
1882
+ filePath: 'app/activity_streams/handlers/h.yml',
1883
+ content: BROKEN_YAML,
1884
+ },
1885
+ [PlatformOSFileType.ActivityStreamsGroupingHandler]: {
1886
+ filePath: 'app/activity_streams/grouping_handlers/g.yml',
1887
+ content: BROKEN_YAML,
1888
+ },
1889
+ // The two SINGLETONS: one file at a fixed path, not a directory of files. Spelled out
1890
+ // rather than derived from `getFixedFilePath` on purpose — if the platform ever renames
1891
+ // one, this fixture stops being classified and the test fails loudly, where a derived
1892
+ // path would quietly follow the rename and assert nothing about the old one.
1893
+ [PlatformOSFileType.InstanceConfig]: { filePath: 'app/config.yml', content: BROKEN_YAML },
1894
+ [PlatformOSFileType.UserSchema]: { filePath: 'app/user.yml', content: BROKEN_YAML },
1895
+ [PlatformOSFileType.GraphQL]: {
1896
+ filePath: 'app/graphql/g.graphql',
1897
+ content: BROKEN_GRAPHQL,
1898
+ alsoSpelled: ['app/graph_queries/g.graphql'],
1899
+ },
1900
+ // Assets are served, not linted: nothing here has a parser for an image, a stylesheet
1901
+ // or a `.js` file, and the `.css/.scss/.js.liquid` partials are excluded the same way.
1902
+ // Declining is the honest answer and is what makes the invariant above tractable: a
1903
+ // type with no checks must be OUT, not `ok`.
1904
+ [PlatformOSFileType.Asset]: NOT_ADMITTED,
1905
+ },
1906
+ /**
1907
+ * Real assets, in every spelling that matters.
1908
+ *
1909
+ * A LIST RATHER THAN ONE PATH, because one fixture was measurably the wrong one:
1910
+ * `isSupportedSourceFile('app/assets/x.liquid', root)` used to return `true` — a bare
1911
+ * `.liquid` has no response format, so `sourceCodeTypeOf` fell back to `html.liquid` — and
1912
+ * the test passed while asserting the opposite of what it claimed.
1913
+ *
1914
+ * `platformos-common` now owns the rule (`isParsedFileType`) and applies it in `AppFile` and
1915
+ * `isSupportedSourceFile` alike, so these spellings decline for the SAME reason rather than
1916
+ * by accident. The bare-`.liquid` case is pinned separately below, being the only one a
1917
+ * parser would otherwise have accepted.
1918
+ *
1919
+ * `.json` is here because a standalone `.json` is an asset on this platform — JSON responses
1920
+ * come from `.json.liquid`.
1921
+ */
1922
+ *() { }
1923
+ } /
1924
+ ;
1925
+ const REAL_ASSETS = [
1926
+ 'app/assets/logo.png',
1927
+ 'app/assets/site.css.liquid',
1928
+ 'app/assets/app.js',
1929
+ 'app/assets/manifest.json',
1930
+ ];
1931
+ describe('Integration: every admitted file type is examined by something', () => {
1932
+ const { validate } = tempProject('mcp-sup-coverage-');
1933
+ const examined = (result) => ({
1934
+ status: result.status,
1935
+ examined: result.errors.length + result.warnings.length + result.infos.length > 0,
1936
+ });
1937
+ it('has a decision for every PlatformOSFileType, admitted or not', () => {
1938
+ // The runtime half of the exhaustiveness guard. `Record<PlatformOSFileType, …>`
1939
+ // already makes a new member a compile error; this repeats it with a message that
1940
+ // says what to do about it.
1941
+ expect(Object.keys(COVERAGE).sort()).toEqual(Object.values(PlatformOSFileType).sort());
1942
+ });
1943
+ for (const [fileType, fixture] of Object.entries(COVERAGE)) {
1944
+ if (fixture === NOT_ADMITTED) {
1945
+ it(`${fileType}: declined rather than approved, since nothing lints it`, async () => {
1946
+ const outcomes = [];
1947
+ for (const filePath of REAL_ASSETS) {
1948
+ const result = await validate(filePath, BROKEN_LIQUID);
1949
+ outcomes.push({
1950
+ status: result.status,
1951
+ reason: result.not_applicable_reason,
1952
+ blocked: result.must_fix_before_write,
1953
+ // The prose must say ASSET, not "not a platformOS source file". The file IS
1954
+ // deployed and served — it just has nothing to check — and an author who can
1955
+ // see a message is wrong stops reading the ones that are right.
1956
+ saysAsset: (result.next_step ?? '').includes('is an asset, not a source file'),
1957
+ });
1958
+ }
1959
+ expect(outcomes).toEqual(REAL_ASSETS.map(() => ({
1960
+ status: 'not_applicable',
1961
+ reason: 'unsupported_type',
1962
+ blocked: false,
1963
+ saysAsset: true,
1964
+ })));
1965
+ });
1966
+ it(`${fileType}: a bare .liquid under assets/ is refused too, not blocked on`, async () => {
1967
+ // THE CASE THAT MADE THIS ROW A LIE, and the reason the gate now refuses assets by
1968
+ // TYPE rather than by extension. A bare `.liquid` has no response format, so
1969
+ // `sourceCodeTypeOf` falls back to `html.liquid`; the buffer was parsed as Liquid
1970
+ // and came back `must_fix_before_write: true` with `LiquidHTMLSyntaxError` — a
1971
+ // false block on a file the platform serves as bytes, for the syntax of a language
1972
+ // nothing there evaluates. `theme.css.liquid`, the form the platform DOES process,
1973
+ // was exempt the whole time.
1974
+ //
1975
+ // Kept as its own test rather than folded into the list above because it is the
1976
+ // only asset spelling a parser accepts: revert the `Asset` branch in
1977
+ // `fileApplicability` and this is the single row that fails.
1978
+ const result = await validate('app/assets/x.liquid', BROKEN_LIQUID);
1979
+ expect({
1980
+ status: result.status,
1981
+ reason: result.not_applicable_reason,
1982
+ blocked: result.must_fix_before_write,
1983
+ }).toEqual({ status: 'not_applicable', reason: 'unsupported_type', blocked: false });
1984
+ });
1985
+ continue;
1986
+ }
1987
+ it(`${fileType}: a broken buffer is objected to, so 'ok' would have meant something`, async () => {
1988
+ const spellings = [fixture.filePath, ...(fixture.alsoSpelled ?? [])];
1989
+ const results = [];
1990
+ for (const filePath of spellings) {
1991
+ results.push(examined(await validate(filePath, fixture.content)));
1992
+ }
1993
+ // Every directory spelling of one type must behave identically — a type that is
1994
+ // covered under `app/schema` and silent under `app/model_schemas` is still a
1995
+ // hole, just a harder one to notice.
1996
+ expect(results).toEqual(spellings.map(() => ({ status: 'error', examined: true })));
1997
+ });
1998
+ }
1999
+ it('covers every YAML family and directory spelling with YAMLSyntaxError specifically', async () => {
2000
+ // Pinned exactly, unlike the rest: these are the families that had NOTHING, and
2001
+ // the identity of the check that now covers them is the substance of the fix
2002
+ // rather than an implementation detail. If this ever reports a different code —
2003
+ // or none — the gap has reopened somewhere.
2004
+ //
2005
+ // EVERY spelling of every YAML type is listed, including the two fixed-path
2006
+ // singletons, because the coverage question is per PATH and not per type: a check
2007
+ // that reads `app/schema` and skips `app/model_schemas` leaves a hole in a type this
2008
+ // file would otherwise call covered.
2009
+ const yamlFiles = [
2010
+ 'app/schema/c.yml',
2011
+ 'app/model_schemas/c.yml',
2012
+ 'app/custom_model_types/c.yml',
2013
+ 'app/user_profile_types/u.yml',
2014
+ 'app/instance_profile_types/u.yml',
2015
+ 'app/user_profile_schemas/u.yml',
2016
+ 'app/transactable_types/t.yml',
2017
+ 'app/translations/en.yml',
2018
+ 'app/activity_streams/handlers/h.yml',
2019
+ 'app/activity_streams/grouping_handlers/g.yml',
2020
+ 'app/config.yml',
2021
+ 'app/user.yml',
2022
+ ];
2023
+ const codes = [];
2024
+ for (const filePath of yamlFiles) {
2025
+ const result = await validate(filePath, BROKEN_YAML);
2026
+ codes.push([...new Set(result.errors.map((error) => error.check))]);
2027
+ }
2028
+ expect(codes).toEqual(yamlFiles.map(() => ['YAMLSyntaxError']));
2029
+ });
2030
+ });
2031
+ /**
2032
+ * The response bound, measured end to end on the shapes that motivated it.
2033
+ *
2034
+ * `result/response-budget.spec.ts` proves the allocator's rules over synthetic
2035
+ * results. This file answers the question that only the whole pipeline can: what does
2036
+ * the WORST LEGAL REQUEST actually return now, in bytes.
2037
+ *
2038
+ * BEFORE, measured on this build with the cap removed:
2039
+ *
2040
+ * ```
2041
+ * 128 KiB single buffer 4 228 diagnostics 634 KiB ~162 000 tokens
2042
+ * 266 KiB, 4-file batch 8 784 diagnostics 1 313 KiB ~336 000 tokens
2043
+ * ```
2044
+ *
2045
+ * Every dimension of the REQUEST was bounded and the RESPONSE was bounded by nothing,
2046
+ * at roughly six times the size of the input that produced it. One legal call could
2047
+ * return more tokens than most context windows hold — with no error, and nothing in
2048
+ * the payload saying anything unusual had happened.
2049
+ *
2050
+ * WHAT IS ASSERTED, and why it is a relationship rather than a number: the exact byte
2051
+ * count depends on message wording that check-common owns, so pinning it would make
2052
+ * this file fail on edits that change nothing about the bound. `maxResponseBytes`
2053
+ * states the arithmetic; these cases check the real payload against it.
2054
+ */
2055
+ describe('Integration: the response is bounded, and says so when it withholds', () => {
2056
+ // Named `ctx` locally, shadowing the injected-adapter one above: this group calls
2057
+ // `runValidateCode` directly, because what it measures is the whole real response.
2058
+ const { context: ctx } = tempProject('mcp-sup-bound-');
2059
+ /**
2060
+ * A buffer of exactly `bytes` made of one repeated offending construct, padded with
2061
+ * spaces.
2062
+ *
2063
+ * Both details are deliberate and both were fixture errors during round 4: a tail
2064
+ * that cuts a tag in half raises a syntax error that short-circuits every other
2065
+ * check and collapses the diagnostic count to one, and a buffer one byte over the
2066
+ * cap is refused outright — which reads like a server bug rather than a bad
2067
+ * fixture.
2068
+ */
2069
+ const brokenBuffer = (bytes) => {
2070
+ const unit = "{{ 'a' | no_such_filter_xyz }}\n";
2071
+ const whole = unit.repeat(Math.floor(bytes / unit.length));
2072
+ return whole + ' '.repeat(bytes - whole.length);
2073
+ };
2074
+ const bytesOf = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
2075
+ it('bounds the worst legal SINGLE call, and reports the true total', async () => {
2076
+ const result = (await runValidateCode(ctx(), {
2077
+ file_path: 'app/views/pages/index.liquid',
2078
+ content: brokenBuffer(MAX_BUFFER_BYTES),
2079
+ }));
2080
+ expect({
2081
+ status: result.status,
2082
+ // The gate is computed before anything is withheld — this is the assertion that
2083
+ // a shortened list has not become an approval.
2084
+ blocked: result.must_fix_before_write,
2085
+ withinBound: bytesOf(result) <= maxResponseBytes(1),
2086
+ returnedMatchesList: result.truncated.errors.returned === result.errors.length,
2087
+ // Thousands were found; the response says so rather than implying the short
2088
+ // list is everything.
2089
+ totalExceedsReturned: result.truncated.errors.total > result.errors.length,
2090
+ hasNote: result.truncated.note.length > 0,
2091
+ }).toEqual({
2092
+ status: 'error',
2093
+ blocked: true,
2094
+ withinBound: true,
2095
+ returnedMatchesList: true,
2096
+ totalExceedsReturned: true,
2097
+ hasNote: true,
2098
+ });
2099
+ }, 120_000);
2100
+ it('bounds the worst legal BATCH as a request, not per file', async () => {
2101
+ // The shape a per-file cap alone would miss: the file count multiplies whatever
2102
+ // each file is allowed, and this is the largest count the batch form admits.
2103
+ const files = Array.from({ length: MAX_BATCH_FILES }, (_, index) => ({
2104
+ file_path: `app/views/pages/p${index}.liquid`,
2105
+ content: brokenBuffer(4_096),
2106
+ }));
2107
+ const result = (await runValidateCode(ctx(), { files }));
2108
+ const diagnosticBytes = result.files.reduce((sum, entry) => sum +
2109
+ [...entry.result.errors, ...entry.result.warnings, ...entry.result.infos].reduce((inner, diagnostic) => inner + bytesOf(diagnostic) + 1, 0), 0);
2110
+ expect({
2111
+ blocked: result.must_fix_before_write,
2112
+ everyFilePresent: result.files.length === MAX_BATCH_FILES,
2113
+ // Round-robin: no file may be starved by a louder one, so each still carries
2114
+ // something the agent can act on.
2115
+ everyFileHasAnError: result.files.every((entry) => entry.result.errors.length > 0),
2116
+ everyFileDeclaresItsTotal: result.files.every((entry) => (entry.result.truncated?.errors?.total ?? 0) > entry.result.errors.length),
2117
+ diagnosticsWithinBudget: diagnosticBytes <= MAX_RESPONSE_DIAGNOSTIC_BYTES * 1.5,
2118
+ wholeResponseWithinBound: bytesOf(result) <= maxResponseBytes(MAX_BATCH_FILES),
2119
+ }).toEqual({
2120
+ blocked: true,
2121
+ everyFilePresent: true,
2122
+ everyFileHasAnError: true,
2123
+ everyFileDeclaresItsTotal: true,
2124
+ diagnosticsWithinBudget: true,
2125
+ wholeResponseWithinBound: true,
2126
+ });
2127
+ }, 120_000);
2128
+ it('does not touch a realistic call: no truncation field, nothing withheld', async () => {
2129
+ // The measured common case — a small broken edit — is two orders of magnitude below
2130
+ // the 32 KiB diagnostic cap. A bound that changed this would be solving the tail at
2131
+ //
2132
+ // The 2 KiB line, not 1 KiB, since enrichment began attaching each check's documentation
2133
+ // URL: ~111 serialized bytes per diagnostic, bounded and uniform. The signature hint is
2134
+ // ~8x that but is rendered ONCE PER SYMBOL PER FILE (`enrich.ts`), so it scales with the
2135
+ // number of distinct symbols a file misuses rather than with the size of a cascade.
2136
+ // Neither finding below is a hinted check, so this case measures the URL alone.
2137
+ const result = (await runValidateCode(ctx(), {
2138
+ file_path: 'app/views/pages/index.liquid',
2139
+ content: `{% render 'no_such_partial' %}
2140
+ {{ 'a' | no_such_filter_xyz }}
2141
+ `,
2142
+ }));
2143
+ expect({
2144
+ blocked: result.must_fix_before_write,
2145
+ truncated: result.truncated,
2146
+ errorsReturned: result.errors.length,
2147
+ underTwoKiB: bytesOf(result) < 2048,
2148
+ }).toEqual({
2149
+ blocked: true,
2150
+ truncated: undefined,
2151
+ errorsReturned: 2,
2152
+ underTwoKiB: true,
2153
+ });
2154
+ }, 60_000);
2155
+ });
2156
+ /**
2157
+ * Enrichment sits between the lint and the answer, and it must not be able to damage
2158
+ * either one.
2159
+ *
2160
+ * It adds explanation to findings that are already correct and complete. Losing it costs
2161
+ * an agent some context; losing the FINDINGS would cost it the answer, and turning a
2162
+ * finding into a pass would be the false approval this whole server exists to prevent. So
2163
+ * the stage is deliberately the least privileged one on the request path: it takes data,
2164
+ * returns data, and cannot reach the project.
2165
+ */
2166
+ describe('validate_code: enrichment is bounded — it cannot cost findings or the gate', () => {
2167
+ const BROKEN = 'app/views/pages/broken.liquid';
2168
+ /** Adapters whose docset resolution is observable, and optionally broken. */
2169
+ const withDocset = (behaviour = {}) => {
2170
+ let calls = 0;
2171
+ return {
2172
+ calls: () => calls,
2173
+ adapters: {
2174
+ lint: async ({ buffers }) => ({
2175
+ diagnostics: new Map(buffers.map((b) => [b.filePath, [diagnostic('MissingPartial')]])),
2176
+ sources: new Map(buffers.map((b) => [b.filePath, { startIndexes: [0] }])),
2177
+ notChecked: new Map(),
2178
+ }),
2179
+ impact: async () => COMPUTED,
2180
+ docset: async () => {
2181
+ calls += 1;
2182
+ if (behaviour.throws)
2183
+ throw new Error('docset unavailable');
2184
+ return { filters: [], tags: [], objects: [] };
2185
+ },
2186
+ },
2187
+ };
2188
+ };
2189
+ it('resolves the docset ONCE per request, however many files it carries', async () => {
2190
+ // The property the batch form exists for: a fixed cost must not become a per-file
2191
+ // cost. Counted rather than timed, so it cannot pass on a fast machine.
2192
+ const { adapters, calls } = withDocset();
2193
+ await runValidateCode(ctx(), {
2194
+ files: Array.from({ length: 5 }, (_, i) => ({
2195
+ file_path: `app/views/pages/p${i}.liquid`,
2196
+ content: 'x',
2197
+ })),
2198
+ }, adapters);
2199
+ expect(calls()).toEqual(1);
2200
+ });
2201
+ /**
2202
+ * A request with nothing to enrich must not pay to enrich it.
2203
+ *
2204
+ * The default docset adapter reaches `PlatformOSLiquidDocsManager.setup()` and its
2205
+ * network revision check, so resolving before looking at whether any diagnostic exists
2206
+ * put a round trip on the two commonest answers there are: the clean file, and the
2207
+ * request whose every buffer was declined. Counted rather than timed — a timing
2208
+ * assertion here would pass on a warm docs cache, which is the case that was never the
2209
+ * problem.
2210
+ */
2211
+ it('does not resolve the docset when there is nothing to enrich', async () => {
2212
+ let calls = 0;
2213
+ const adapters = {
2214
+ lint: async ({ buffers }) => ({
2215
+ diagnostics: new Map(buffers.map((b) => [b.filePath, []])),
2216
+ notChecked: new Map(),
2217
+ }),
2218
+ impact: async () => COMPUTED,
2219
+ docset: async () => {
2220
+ calls += 1;
2221
+ return { filters: [], tags: [], objects: [] };
2222
+ },
2223
+ };
2224
+ const clean = (await runValidateCode(ctx(), { file_path: BROKEN, content: 'x' }, adapters));
2225
+ const declined = (await runValidateCode(ctx(), { file_path: 'README.md', content: '# hi' }, adapters));
2226
+ // The answers are unaffected — this is about what was NOT done to produce them.
2227
+ expect({
2228
+ docsetResolutions: calls,
2229
+ cleanStatus: clean.status,
2230
+ declinedStatus: declined.status,
2231
+ }).toEqual({ docsetResolutions: 0, cleanStatus: 'ok', declinedStatus: 'not_applicable' });
2232
+ });
2233
+ it('still returns every finding, and the same gate, when the docset fails', async () => {
2234
+ const { adapters } = withDocset({ throws: true });
2235
+ const result = (await runValidateCode(ctx(), { file_path: BROKEN, content: 'x' }, adapters));
2236
+ // The finding survives, the gate is unchanged, and the diagnostic is simply
2237
+ // un-enriched — no `see_also`. A thrown enricher must not read as a clean file.
2238
+ expect({
2239
+ status: result.status,
2240
+ must_fix_before_write: result.must_fix_before_write,
2241
+ errors: result.errors,
2242
+ }).toEqual({
2243
+ status: 'error',
2244
+ must_fix_before_write: true,
2245
+ errors: [diagnostic('MissingPartial')],
2246
+ });
2247
+ });
2248
+ it('CONTROL: the same call enriches when the docset resolves', async () => {
2249
+ // Without this, the test above would pass with enrichment deleted entirely.
2250
+ const { adapters } = withDocset();
2251
+ const result = (await runValidateCode(ctx(), { file_path: BROKEN, content: 'x' }, adapters));
2252
+ expect(result.errors).toEqual([enriched('MissingPartial')]);
2253
+ });
2254
+ });
2255
+ /**
2256
+ * The one path a stub cannot stand in for: a REAL offense, resolved against the REAL tree
2257
+ * the lint captured, rendered into the answer.
2258
+ *
2259
+ * Every other enrichment test injects a lint, and an injected lint hands over whatever
2260
+ * `sources` the stub invented. The chain that actually has to hold in production is
2261
+ * `lintBuffers` capturing the tree INSIDE its overlay → `runBatchLint` carrying it onto
2262
+ * `LintedSource.ast` → `validateBuffers` pairing it with the index-aligned offset →
2263
+ * `documentedSymbolAt` walking to the symbol → the docset entry rendering. Each link was
2264
+ * covered; the joins were not, and dropping any one of them left the whole suite green
2265
+ * while every hint silently disappeared.
2266
+ *
2267
+ * Only the DOCSET is stubbed, and deliberately: the vocabulary is input to this test, not
2268
+ * a claim about what platformOS publishes, so a docs release cannot fail it. The lint runs
2269
+ * for real against the default check set.
2270
+ */
2271
+ describe('validate_code: a hint survives the whole path, from a real lint to the answer', () => {
2272
+ const project = tempProject('mcp-sup-hint-e2e-');
2273
+ const PAGE_PATH = 'app/views/pages/hint.liquid';
2274
+ /** `{% for %}`'s published entry — the symbol a bad `limit:` argument is answered by. */
2275
+ const FOR_TAG = {
2276
+ filters: [],
2277
+ tags: [{ name: 'for', description: 'Iterates over an array.' }],
2278
+ objects: [],
2279
+ };
2280
+ const validateWith = async (content, vocabulary) => (await runValidateCode(project.context(), { file_path: PAGE_PATH, content }, { docset: async () => vocabulary }));
2281
+ const findingFor = (result, check) => [...result.errors, ...result.warnings, ...result.infos].find((d) => d.check === check);
2282
+ it('renders the published entry for the symbol the offense is about', async () => {
2283
+ const result = await validateWith("{% for x in (1..3) limit: 'ten' %}{{ x }}{% endfor %}", FOR_TAG);
2284
+ expect(findingFor(result, 'ValidTagArgumentTypes')?.hint).toEqual('### for\nIterates over an array.\n\n---\n\n' +
2285
+ '[platformOS Reference](https://documentation.platformos.com/api-reference/liquid/loops#for)');
2286
+ });
2287
+ it('CONTROL: the same finding, with the same tree, when the docset publishes nothing', async () => {
2288
+ // Proves the hint above came from the VOCABULARY rather than from anything the lint
2289
+ // or this test authored — and that a symbol with no entry degrades to no hint rather
2290
+ // than to an invented one. The finding itself is unchanged either way.
2291
+ const result = await validateWith("{% for x in (1..3) limit: 'ten' %}{{ x }}{% endfor %}", {
2292
+ filters: [],
2293
+ tags: [],
2294
+ objects: [],
2295
+ });
2296
+ const finding = findingFor(result, 'ValidTagArgumentTypes');
2297
+ expect({ found: finding !== undefined, hint: finding?.hint }).toEqual({
2298
+ found: true,
2299
+ hint: undefined,
2300
+ });
2301
+ });
2302
+ });
2303
+ //# sourceMappingURL=validate-code.spec.js.map