@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.
- package/README.md +105 -0
- package/dist/adapter-input.d.ts +113 -0
- package/dist/adapter-input.js +210 -0
- package/dist/adapter-input.spec.d.ts +1 -0
- package/dist/adapter-input.spec.js +275 -0
- package/dist/bin/args.d.ts +15 -2
- package/dist/bin/args.js +31 -3
- package/dist/bin/args.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- package/dist/bin/platformos-mcp-supervisor.js +6 -2
- package/dist/check-docs.d.ts +21 -0
- package/dist/check-docs.js +38 -0
- package/dist/check-docs.spec.d.ts +1 -0
- package/dist/check-docs.spec.js +87 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +12 -0
- package/dist/cost-model.d.ts +188 -0
- package/dist/cost-model.js +197 -0
- package/dist/cost-model.spec.d.ts +1 -0
- package/dist/cost-model.spec.js +91 -0
- package/dist/deadline.d.ts +27 -0
- package/dist/deadline.js +40 -0
- package/dist/deadline.spec.d.ts +1 -0
- package/dist/deadline.spec.js +83 -0
- package/dist/enrich/enrich.d.ts +105 -0
- package/dist/enrich/enrich.js +136 -0
- package/dist/enrich/enrich.spec.d.ts +1 -0
- package/dist/enrich/enrich.spec.js +184 -0
- package/dist/enrich/symbol.d.ts +16 -0
- package/dist/enrich/symbol.js +58 -0
- package/dist/enrich/symbol.spec.d.ts +1 -0
- package/dist/enrich/symbol.spec.js +89 -0
- package/dist/impact/dependants.d.ts +75 -0
- package/dist/impact/dependants.js +104 -0
- package/dist/impact/diff.d.ts +27 -0
- package/dist/impact/diff.js +33 -0
- package/dist/impact/impact.d.ts +21 -0
- package/dist/impact/impact.js +160 -0
- package/dist/impact/impact.spec.d.ts +1 -0
- package/dist/impact/impact.spec.js +357 -0
- package/dist/impact/project-scan.d.ts +39 -0
- package/dist/impact/project-scan.js +62 -0
- package/dist/impact/project-scan.spec.d.ts +1 -0
- package/dist/impact/project-scan.spec.js +121 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint/fixes.d.ts +28 -0
- package/dist/lint/fixes.js +30 -0
- package/dist/lint/lint-batch.d.ts +100 -0
- package/dist/lint/lint-batch.js +98 -0
- package/dist/lint/lint-batch.spec.d.ts +1 -0
- package/dist/lint/lint-batch.spec.js +503 -0
- package/dist/lint/lint.d.ts +11 -1
- package/dist/lint/lint.js +9 -3
- package/dist/result/assemble.d.ts +11 -9
- package/dist/result/assemble.js +63 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +92 -0
- package/dist/result/blocking.js +116 -0
- package/dist/result/blocking.spec.d.ts +1 -0
- package/dist/result/blocking.spec.js +201 -0
- package/dist/result/impact-states.d.ts +19 -0
- package/dist/result/impact-states.js +10 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +133 -0
- package/dist/result/response-budget.spec.d.ts +1 -0
- package/dist/result/response-budget.spec.js +222 -0
- package/dist/result/types.d.ts +212 -104
- package/dist/result/types.js +4 -13
- package/dist/transport/instructions-coverage.spec.d.ts +1 -0
- package/dist/transport/instructions-coverage.spec.js +176 -0
- package/dist/transport/instructions.d.ts +1 -0
- package/dist/transport/instructions.js +150 -0
- package/dist/transport/process-guards.d.ts +43 -0
- package/dist/transport/process-guards.js +63 -0
- package/dist/transport/process-guards.spec.d.ts +1 -0
- package/dist/transport/process-guards.spec.js +200 -0
- package/dist/transport/server.d.ts +6 -1
- package/dist/transport/server.js +21 -10
- package/dist/transport/validate-code.d.ts +50 -15
- package/dist/transport/validate-code.js +238 -33
- package/dist/transport/validate-code.spec.d.ts +1 -0
- package/dist/transport/validate-code.spec.js +2303 -0
- package/dist/validate/batch-bounds.d.ts +34 -0
- package/dist/validate/batch-bounds.js +65 -0
- package/dist/validate/batch-coherence.d.ts +21 -0
- package/dist/validate/batch-coherence.js +54 -0
- package/dist/validate/batch-coherence.spec.d.ts +1 -0
- package/dist/validate/batch-coherence.spec.js +111 -0
- package/dist/validate/validate-buffers.d.ts +27 -0
- package/dist/validate/validate-buffers.js +319 -0
- 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
|