@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,503 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, 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 } from 'vitest';
|
|
5
|
+
import { runBatchLint } from './lint-batch.js';
|
|
6
|
+
/**
|
|
7
|
+
* Adapter integration: drives the real check-node `lintBuffers` against a temp
|
|
8
|
+
* project. Hermetic config (`extends: platformos-check:nothing` + one check) keeps
|
|
9
|
+
* the assertions deterministic and docset/network-free.
|
|
10
|
+
*
|
|
11
|
+
* This is the ONLY lint adapter — the orchestrator always works on a list, so a
|
|
12
|
+
* single file is a one-element batch. These assertions therefore cover the
|
|
13
|
+
* single-file path too; there is no separate one to diverge from.
|
|
14
|
+
*/
|
|
15
|
+
describe('Integration: runBatchLint (the lint adapter)', () => {
|
|
16
|
+
let projectDir;
|
|
17
|
+
// The exact diagnostic the enabled MissingContentForLayout check produces for a layout
|
|
18
|
+
// that omits `{{ content_for_layout }}`, reported at offset 0 (1-based line/column 1).
|
|
19
|
+
//
|
|
20
|
+
// Parameterised by `insertAt` because the check also SUGGESTS the insertion, and the
|
|
21
|
+
// engine's edit points at this buffer's `</body>` — so the expectation moves with the
|
|
22
|
+
// fixture rather than being a constant.
|
|
23
|
+
const missingContentForLayout = (insertAt) => ({
|
|
24
|
+
check: 'MissingContentForLayout',
|
|
25
|
+
severity: 'error',
|
|
26
|
+
message: "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)",
|
|
27
|
+
line: 1,
|
|
28
|
+
column: 1,
|
|
29
|
+
end_line: 1,
|
|
30
|
+
end_column: 1,
|
|
31
|
+
suggestions: [
|
|
32
|
+
{
|
|
33
|
+
description: 'Insert `{{ content_for_layout }}` before the closing </body> tag',
|
|
34
|
+
edits: [
|
|
35
|
+
{ start_index: insertAt, end_index: insertAt, new_text: '{{ content_for_layout }}\n' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
/** `<html><body><header>Site</header>` — the offset of `</body>` in the longer fixture. */
|
|
41
|
+
const INSERT_AFTER_HEADER = 33;
|
|
42
|
+
/** `<html><body>` — the offset of `</body>` in the bare fixture. */
|
|
43
|
+
const INSERT_BARE_BODY = 12;
|
|
44
|
+
const LAYOUT = 'app/views/layouts/application.liquid';
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-lint-'));
|
|
47
|
+
mkdirSync(join(projectDir, '.git'));
|
|
48
|
+
writeFileSync(join(projectDir, '.platformos-check.yml'), ['extends: platformos-check:nothing', 'MissingContentForLayout:', ' enabled: true', ''].join('\n'), 'utf8');
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
const lint = (buffers) => runBatchLint({ projectDir, buffers });
|
|
54
|
+
it('maps a real offense to the exact diagnostic (check, severity, message, 1-based range)', async () => {
|
|
55
|
+
const byFile = await lint([
|
|
56
|
+
{ filePath: LAYOUT, content: '<html><body><header>Site</header></body></html>' },
|
|
57
|
+
]);
|
|
58
|
+
expect(byFile.diagnostics.get(LAYOUT)).toEqual([missingContentForLayout(INSERT_AFTER_HEADER)]);
|
|
59
|
+
});
|
|
60
|
+
it('returns no diagnostics for a clean layout', async () => {
|
|
61
|
+
const byFile = await lint([
|
|
62
|
+
{ filePath: LAYOUT, content: '<html><body>{{ content_for_layout }}</body></html>' },
|
|
63
|
+
]);
|
|
64
|
+
expect(byFile.diagnostics.get(LAYOUT)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
it('returns an entry for every requested buffer, so "clean" is distinguishable from "not linted"', async () => {
|
|
67
|
+
const clean = 'app/views/layouts/clean.liquid';
|
|
68
|
+
const byFile = await lint([
|
|
69
|
+
{ filePath: LAYOUT, content: '<html><body></body></html>' },
|
|
70
|
+
{ filePath: clean, content: '<html>{{ content_for_layout }}</html>' },
|
|
71
|
+
]);
|
|
72
|
+
expect([...byFile.diagnostics.keys()].sort()).toEqual([clean, LAYOUT].sort());
|
|
73
|
+
expect(byFile.diagnostics.get(clean)).toEqual([]);
|
|
74
|
+
expect(byFile.diagnostics.get(LAYOUT)).toEqual([missingContentForLayout(INSERT_BARE_BODY)]);
|
|
75
|
+
});
|
|
76
|
+
it('accepts an absolute file path and keys the result by that same string', async () => {
|
|
77
|
+
// Results are keyed by the CALLER's key, so a caller mixing relative and
|
|
78
|
+
// absolute paths can find its own results without reproducing our normalization.
|
|
79
|
+
const absolute = join(projectDir, LAYOUT);
|
|
80
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
81
|
+
const byFile = await lint([{ filePath: absolute, content: '<html><body></body></html>' }]);
|
|
82
|
+
expect(byFile.diagnostics.get(absolute)).toEqual([missingContentForLayout(INSERT_BARE_BODY)]);
|
|
83
|
+
});
|
|
84
|
+
it('gives a relative and an absolute path for the SAME file identical diagnostics', async () => {
|
|
85
|
+
const absolute = join(projectDir, LAYOUT);
|
|
86
|
+
const content = '<html><body></body></html>';
|
|
87
|
+
const byFile = await lint([
|
|
88
|
+
{ filePath: LAYOUT, content },
|
|
89
|
+
{ filePath: absolute, content },
|
|
90
|
+
]);
|
|
91
|
+
expect(byFile.diagnostics.get(LAYOUT)).toEqual(byFile.diagnostics.get(absolute));
|
|
92
|
+
expect(byFile.diagnostics.get(LAYOUT)).toEqual([missingContentForLayout(INSERT_BARE_BODY)]);
|
|
93
|
+
});
|
|
94
|
+
/**
|
|
95
|
+
* `sources` is optional on the TYPE, for the benefit of test stubs; the real adapter has
|
|
96
|
+
* no such licence.
|
|
97
|
+
*
|
|
98
|
+
* The alignment is the part that could go wrong silently: `startIndexes[i]` belongs to
|
|
99
|
+
* `diagnostics[i]`, and out of step, enrichment would resolve each diagnostic against
|
|
100
|
+
* another's node — a confidently wrong hint on a correct finding, with nothing failing.
|
|
101
|
+
*/
|
|
102
|
+
it('populates sources for every file it reports diagnostics for, index-aligned', async () => {
|
|
103
|
+
const clean = 'app/views/layouts/clean.liquid';
|
|
104
|
+
const byFile = await lint([
|
|
105
|
+
{ filePath: LAYOUT, content: '<html><body></body></html>' },
|
|
106
|
+
{ filePath: clean, content: '<html>{{ content_for_layout }}</html>' },
|
|
107
|
+
]);
|
|
108
|
+
expect([...byFile.diagnostics].map(([key, diagnostics]) => ({
|
|
109
|
+
key,
|
|
110
|
+
hasSource: byFile.sources?.has(key) ?? false,
|
|
111
|
+
aligned: byFile.sources?.get(key)?.startIndexes.length === diagnostics.length,
|
|
112
|
+
}))).toEqual([
|
|
113
|
+
{ key: LAYOUT, hasSource: true, aligned: true },
|
|
114
|
+
{ key: clean, hasSource: true, aligned: true },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* `sources.ast` is the tree of the BUFFER, and the reason `LintBufferResult` hands one
|
|
119
|
+
* back instead of letting a caller read the `App` afterwards: the overlay is reverted
|
|
120
|
+
* before this returns, so a tree fetched later describes DISK — right on every unchanged
|
|
121
|
+
* file and wrong on exactly the ones being edited.
|
|
122
|
+
*
|
|
123
|
+
* The file on disk and the buffer carry DIFFERENT filter names, so a test reading the
|
|
124
|
+
* wrong tree cannot agree by accident.
|
|
125
|
+
*/
|
|
126
|
+
it('returns the tree of the BUFFER, not of the file on disk', async () => {
|
|
127
|
+
const absolute = join(projectDir, LAYOUT);
|
|
128
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
129
|
+
writeFileSync(absolute, '<html><body>{{ x | on_disk_filter }}</body></html>', 'utf8');
|
|
130
|
+
const byFile = await lint([
|
|
131
|
+
{ filePath: LAYOUT, content: '<html><body>{{ x | in_buffer_filter }}</body></html>' },
|
|
132
|
+
]);
|
|
133
|
+
const names = [];
|
|
134
|
+
const walk = (node) => {
|
|
135
|
+
if (!node || typeof node !== 'object')
|
|
136
|
+
return;
|
|
137
|
+
const candidate = node;
|
|
138
|
+
if (candidate.type === 'LiquidFilter' && typeof candidate.name === 'string') {
|
|
139
|
+
names.push(candidate.name);
|
|
140
|
+
}
|
|
141
|
+
for (const value of Object.values(node)) {
|
|
142
|
+
if (Array.isArray(value))
|
|
143
|
+
value.forEach(walk);
|
|
144
|
+
else if (value && typeof value === 'object')
|
|
145
|
+
walk(value);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
walk(byFile.sources?.get(LAYOUT)?.ast);
|
|
149
|
+
expect(names).toEqual(['in_buffer_filter']);
|
|
150
|
+
});
|
|
151
|
+
it('returns an empty map for an empty request without touching the project', async () => {
|
|
152
|
+
expect(await lint([])).toEqual({
|
|
153
|
+
diagnostics: new Map(),
|
|
154
|
+
sources: new Map(),
|
|
155
|
+
notChecked: new Map(),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* The engine's structured fixes have to REACH the agent.
|
|
161
|
+
*
|
|
162
|
+
* `Offense.fix` and `Offense.suggest` are `Fixer` functions, and an adapter that drops them
|
|
163
|
+
* returns English describing a change check-common had already computed to the character.
|
|
164
|
+
* Whole-value assertions on purpose: a fix is a set of edits, and "a fix is present" is not
|
|
165
|
+
* the property that matters. The offsets are.
|
|
166
|
+
*/
|
|
167
|
+
describe('Integration: runBatchLint — structured fixes and suggestions', () => {
|
|
168
|
+
let projectDir;
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-fixes-'));
|
|
171
|
+
mkdirSync(join(projectDir, '.git'));
|
|
172
|
+
writeFileSync(join(projectDir, '.platformos-check.yml'), `extends: platformos-check:nothing
|
|
173
|
+
JsonLiteralQuoteStyle:
|
|
174
|
+
enabled: true
|
|
175
|
+
DeprecatedTag:
|
|
176
|
+
enabled: true
|
|
177
|
+
DeprecatedFilter:
|
|
178
|
+
enabled: true
|
|
179
|
+
ParserBlockingScript:
|
|
180
|
+
enabled: true
|
|
181
|
+
`, 'utf8');
|
|
182
|
+
});
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
185
|
+
});
|
|
186
|
+
const diagnose = async (filePath, content) => {
|
|
187
|
+
const { diagnostics } = await runBatchLint({ projectDir, buffers: [{ filePath, content }] });
|
|
188
|
+
return diagnostics.get(filePath);
|
|
189
|
+
};
|
|
190
|
+
/** Apply edits back-to-front, so earlier offsets are not shifted by later inserts. */
|
|
191
|
+
const applyEdits = (source, edits) => [...edits]
|
|
192
|
+
.sort((a, b) => b.start_index - a.start_index)
|
|
193
|
+
.reduce((out, edit) => out.slice(0, edit.start_index) + edit.new_text + out.slice(edit.end_index), source);
|
|
194
|
+
it('carries a single-edit autofix, with offsets into the buffer', async () => {
|
|
195
|
+
const source = "{% assign o = {'k': 1} %}{{ o }}";
|
|
196
|
+
expect(await diagnose('app/views/pages/json.liquid', source)).toEqual([
|
|
197
|
+
{
|
|
198
|
+
check: 'JsonLiteralQuoteStyle',
|
|
199
|
+
severity: 'error',
|
|
200
|
+
message: 'Use double quotes for string literals inside object/array literals (e.g. \'{"key": "value"}\', not "{\'key\': \'value\'}").',
|
|
201
|
+
line: 1,
|
|
202
|
+
column: 16,
|
|
203
|
+
end_line: 1,
|
|
204
|
+
end_column: 19,
|
|
205
|
+
fix: { edits: [{ start_index: 15, end_index: 18, new_text: '"k"' }] },
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* THE case that decided `AgentFix.edits` is a list rather than one edit.
|
|
211
|
+
*
|
|
212
|
+
* Renaming a block tag means renaming `end<name>` too — check-common's `renamesFor` says
|
|
213
|
+
* it outright — so a single-edit shape would have kept the first edit and dropped the
|
|
214
|
+
* second SILENTLY, handing an agent a fix that breaks the file. Measured frequency over
|
|
215
|
+
* three real projects: 15 of 6,238 fixes are multi-edit.
|
|
216
|
+
*/
|
|
217
|
+
it('carries BOTH edits of a block-tag rename, and applying them produces valid source', async () => {
|
|
218
|
+
const source = '{% try_rc %}{% assign z = 1 %}{% endtry_rc %}';
|
|
219
|
+
const diagnostics = (await diagnose('app/views/pages/block.liquid', source)) ?? [];
|
|
220
|
+
expect(diagnostics).toEqual([
|
|
221
|
+
{
|
|
222
|
+
check: 'DeprecatedTag',
|
|
223
|
+
severity: 'warning',
|
|
224
|
+
message: "Deprecated tag 'try_rc': Use {% try %} instead.",
|
|
225
|
+
line: 1,
|
|
226
|
+
column: 4,
|
|
227
|
+
end_line: 1,
|
|
228
|
+
end_column: 10,
|
|
229
|
+
fix: {
|
|
230
|
+
edits: [
|
|
231
|
+
{ start_index: 3, end_index: 9, new_text: 'try' },
|
|
232
|
+
{ start_index: 33, end_index: 42, new_text: 'endtry' },
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
// The edits are not merely present — they are correct. Applying them renames both
|
|
238
|
+
// halves, which is the only reason the fix is safe to hand an agent.
|
|
239
|
+
expect(applyEdits(source, diagnostics[0].fix.edits)).toEqual('{% try %}{% assign z = 1 %}{% endtry %}');
|
|
240
|
+
});
|
|
241
|
+
it('carries every suggestion the engine offers, each with its own description', async () => {
|
|
242
|
+
const source = '<script src="a.js"></script>';
|
|
243
|
+
expect(await diagnose('app/views/pages/script.liquid', source)).toEqual([
|
|
244
|
+
{
|
|
245
|
+
check: 'ParserBlockingScript',
|
|
246
|
+
severity: 'error',
|
|
247
|
+
message: 'Avoid parser blocking scripts by adding `defer` or `async` on this tag',
|
|
248
|
+
line: 1,
|
|
249
|
+
column: 1,
|
|
250
|
+
end_line: 1,
|
|
251
|
+
end_column: 29,
|
|
252
|
+
suggestions: [
|
|
253
|
+
{
|
|
254
|
+
description: 'Use an HTML script tag with the defer attribute instead',
|
|
255
|
+
edits: [{ start_index: 18, end_index: 18, new_text: ' defer' }],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
description: 'Use an HTML script tag with the async attribute instead',
|
|
259
|
+
edits: [{ start_index: 18, end_index: 18, new_text: ' async' }],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
/**
|
|
266
|
+
* The CONTROL for every assertion above: a diagnostic the engine offers nothing for comes
|
|
267
|
+
* back with neither key. `DeprecatedFilter` on `parse_json` is that case — upstream
|
|
268
|
+
* publishes no successor filter, so the check reports without a fix.
|
|
269
|
+
*
|
|
270
|
+
* Without this, "the fix fields are populated" could be satisfied by populating them
|
|
271
|
+
* unconditionally.
|
|
272
|
+
*/
|
|
273
|
+
it('omits fix and suggestions entirely when the engine offered neither', async () => {
|
|
274
|
+
expect(await diagnose('app/views/pages/nofix.liquid', "{{ 'x' | parse_json }}")).toEqual([
|
|
275
|
+
{
|
|
276
|
+
check: 'DeprecatedFilter',
|
|
277
|
+
severity: 'warning',
|
|
278
|
+
message: "Deprecated filter 'parse_json'.",
|
|
279
|
+
line: 1,
|
|
280
|
+
column: 8,
|
|
281
|
+
end_line: 1,
|
|
282
|
+
end_column: 20,
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
/**
|
|
287
|
+
* Offsets index the BUFFER, never the file on disk.
|
|
288
|
+
*
|
|
289
|
+
* The two differ on every `validate_code` call — that is what the tool is for — and a
|
|
290
|
+
* fix computed against the saved file would land at plausible-looking, wrong offsets.
|
|
291
|
+
* The on-disk copy here is deliberately longer, so an offset taken from it would not
|
|
292
|
+
* merely differ but point inside the wrong token.
|
|
293
|
+
*/
|
|
294
|
+
it('computes edits against the sent buffer, not the file on disk', async () => {
|
|
295
|
+
const filePath = 'app/views/pages/stale.liquid';
|
|
296
|
+
const onDisk = join(projectDir, filePath);
|
|
297
|
+
mkdirSync(dirname(onDisk), { recursive: true });
|
|
298
|
+
writeFileSync(onDisk, "{% comment %}padding padding{% endcomment %}{% assign o = {'k': 1} %}", 'utf8');
|
|
299
|
+
const buffer = "{% assign o = {'k': 1} %}";
|
|
300
|
+
const diagnostics = (await diagnose(filePath, buffer)) ?? [];
|
|
301
|
+
expect(diagnostics.map((d) => d.fix)).toEqual([
|
|
302
|
+
{ edits: [{ start_index: 15, end_index: 18, new_text: '"k"' }] },
|
|
303
|
+
]);
|
|
304
|
+
expect(applyEdits(buffer, diagnostics[0].fix.edits)).toEqual('{% assign o = {"k": 1} %}');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* ONE lint pass per request, and the correctness that follows from it.
|
|
309
|
+
*
|
|
310
|
+
* Everything expensive is per-PROJECT, so a changeset is overlaid all at once and linted
|
|
311
|
+
* once. That is a performance argument, but more importantly a CORRECTNESS one: with every
|
|
312
|
+
* buffer in the app at the same time, a partial being created in one resolves for the
|
|
313
|
+
* `{% render %}` in another.
|
|
314
|
+
*
|
|
315
|
+
* Asserted behaviourally rather than by counting calls into check-node, which would pin an
|
|
316
|
+
* implementation detail through a package boundary and still not say whether the shared
|
|
317
|
+
* pass produced a shared VIEW of the project.
|
|
318
|
+
*
|
|
319
|
+
* The two halves are a matched pair: the CONTROL shows `MissingPartial` genuinely fires for
|
|
320
|
+
* this fixture when the files are sent separately, so the silence in the batched case is
|
|
321
|
+
* caused by the batching and not by a fixture with nothing to report.
|
|
322
|
+
*/
|
|
323
|
+
describe('Integration: runBatchLint — one pass, one shared view of the project', () => {
|
|
324
|
+
let projectDir;
|
|
325
|
+
const CALLER = 'app/views/pages/home.liquid';
|
|
326
|
+
const PARTIAL = 'app/views/partials/brand_new.liquid';
|
|
327
|
+
const CALLER_SOURCE = "{% render 'brand_new' %}";
|
|
328
|
+
const PARTIAL_SOURCE = '<div>hello</div>';
|
|
329
|
+
beforeEach(() => {
|
|
330
|
+
projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-batch-'));
|
|
331
|
+
mkdirSync(join(projectDir, '.git'));
|
|
332
|
+
writeFileSync(join(projectDir, '.platformos-check.yml'), ['extends: platformos-check:nothing', 'MissingPartial:', ' enabled: true', ''].join('\n'), 'utf8');
|
|
333
|
+
});
|
|
334
|
+
afterEach(() => {
|
|
335
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
it('resolves a partial created in the SAME request, so a coherent changeset is not reported broken', async () => {
|
|
338
|
+
const { diagnostics } = await runBatchLint({
|
|
339
|
+
projectDir,
|
|
340
|
+
buffers: [
|
|
341
|
+
{ filePath: CALLER, content: CALLER_SOURCE },
|
|
342
|
+
{ filePath: PARTIAL, content: PARTIAL_SOURCE },
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
expect({ caller: diagnostics.get(CALLER), partial: diagnostics.get(PARTIAL) }).toEqual({
|
|
346
|
+
caller: [],
|
|
347
|
+
partial: [],
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
it('CONTROL: the same caller sent alone IS reported as rendering a missing partial', async () => {
|
|
351
|
+
const { diagnostics } = await runBatchLint({
|
|
352
|
+
projectDir,
|
|
353
|
+
buffers: [{ filePath: CALLER, content: CALLER_SOURCE }],
|
|
354
|
+
});
|
|
355
|
+
expect(diagnostics.get(CALLER)).toEqual([
|
|
356
|
+
{
|
|
357
|
+
check: 'MissingPartial',
|
|
358
|
+
severity: 'error',
|
|
359
|
+
message: "'brand_new' does not exist",
|
|
360
|
+
line: 1,
|
|
361
|
+
column: 11,
|
|
362
|
+
end_line: 1,
|
|
363
|
+
end_column: 22,
|
|
364
|
+
},
|
|
365
|
+
]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
/**
|
|
369
|
+
* Line endings must not change a single number an agent reads.
|
|
370
|
+
*
|
|
371
|
+
* The agent-facing half of a defect in check-common's shared offset-to-position mapping: a
|
|
372
|
+
* carriage return was counted as a character of the line it terminates, so any diagnostic
|
|
373
|
+
* landing on a terminator came back one column further right under CRLF. The language
|
|
374
|
+
* server never showed it — the LSP clamps an over-long character — but this layer publishes
|
|
375
|
+
* a 1-based column with no clamp.
|
|
376
|
+
*
|
|
377
|
+
* Pinned HERE as well as in check-common because the two consumers disagree about what the
|
|
378
|
+
* numbers mean (0-based there, 1-based after `toDiagnostic`), so a fix made for one moves
|
|
379
|
+
* the other silently.
|
|
380
|
+
*/
|
|
381
|
+
describe('Integration: runBatchLint — CRLF and LF are indistinguishable', () => {
|
|
382
|
+
let projectDir;
|
|
383
|
+
beforeEach(() => {
|
|
384
|
+
projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-crlf-'));
|
|
385
|
+
mkdirSync(join(projectDir, '.git'));
|
|
386
|
+
writeFileSync(join(projectDir, '.platformos-check.yml'), `extends: platformos-check:nothing
|
|
387
|
+
YAMLSyntaxError:
|
|
388
|
+
enabled: true
|
|
389
|
+
LiquidHTMLSyntaxError:
|
|
390
|
+
enabled: true
|
|
391
|
+
`, 'utf8');
|
|
392
|
+
});
|
|
393
|
+
afterEach(() => {
|
|
394
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
395
|
+
});
|
|
396
|
+
const diagnose = async (filePath, content) => {
|
|
397
|
+
const { diagnostics } = await runBatchLint({ projectDir, buffers: [{ filePath, content }] });
|
|
398
|
+
return diagnostics.get(filePath);
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Each case is one source written twice, differing ONLY in line endings. The
|
|
402
|
+
* fixtures deliberately span both defect classes: an offset landing on the
|
|
403
|
+
* terminator, an offset at end of input, and a mid-line offset as the control that
|
|
404
|
+
* proves nothing else moved.
|
|
405
|
+
*/
|
|
406
|
+
const CASES = [
|
|
407
|
+
{
|
|
408
|
+
name: 'unclosed flow sequence (end of input)',
|
|
409
|
+
file: 'app/schema/flow.yml',
|
|
410
|
+
lf: 'c: [1, 2\n',
|
|
411
|
+
expected: [
|
|
412
|
+
{
|
|
413
|
+
check: 'YAMLSyntaxError',
|
|
414
|
+
severity: 'error',
|
|
415
|
+
message: 'Flow sequence in block collection must be sufficiently indented and end with a ]',
|
|
416
|
+
line: 2,
|
|
417
|
+
column: 1,
|
|
418
|
+
end_line: 2,
|
|
419
|
+
end_column: 1,
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'unterminated quote (end of input)',
|
|
425
|
+
file: 'app/schema/quote.yml',
|
|
426
|
+
lf: 'name: "oops\n',
|
|
427
|
+
expected: [
|
|
428
|
+
{
|
|
429
|
+
check: 'YAMLSyntaxError',
|
|
430
|
+
severity: 'error',
|
|
431
|
+
message: 'Missing closing "quote',
|
|
432
|
+
line: 2,
|
|
433
|
+
column: 1,
|
|
434
|
+
end_line: 2,
|
|
435
|
+
end_column: 1,
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
name: 'tab indentation (mid-line — the control)',
|
|
441
|
+
file: 'app/schema/tab.yml',
|
|
442
|
+
lf: 'a:\n\tb: 1\n',
|
|
443
|
+
expected: [
|
|
444
|
+
{
|
|
445
|
+
check: 'YAMLSyntaxError',
|
|
446
|
+
severity: 'error',
|
|
447
|
+
message: 'Tabs are not allowed as indentation',
|
|
448
|
+
line: 2,
|
|
449
|
+
column: 1,
|
|
450
|
+
end_line: 2,
|
|
451
|
+
end_column: 2,
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'unclosed liquid tag (span ending on the terminator)',
|
|
457
|
+
file: 'app/views/pages/tag.liquid',
|
|
458
|
+
lf: '{% if x \n',
|
|
459
|
+
expected: [
|
|
460
|
+
{
|
|
461
|
+
check: 'LiquidHTMLSyntaxError',
|
|
462
|
+
severity: 'error',
|
|
463
|
+
message: 'SyntaxError: expected "%}"',
|
|
464
|
+
line: 1,
|
|
465
|
+
column: 9,
|
|
466
|
+
end_line: 2,
|
|
467
|
+
end_column: 1,
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
for (const { name, file, lf, expected } of CASES) {
|
|
473
|
+
it(`reports the same diagnostic under LF and CRLF — ${name}`, async () => {
|
|
474
|
+
const crlfFile = file.replace(/\.(yml|liquid)$/, '_crlf.$1');
|
|
475
|
+
expect({
|
|
476
|
+
lf: await diagnose(file, lf),
|
|
477
|
+
crlf: await diagnose(crlfFile, lf.replace(/\n/g, '\r\n')),
|
|
478
|
+
}).toEqual({ lf: expected, crlf: expected });
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
it('never publishes a column past the end of the line it points at', async () => {
|
|
482
|
+
// The property the equalities above are instances of, stated directly so a future
|
|
483
|
+
// fixture cannot satisfy LF === CRLF while both halves are wrong. A 1-based column may
|
|
484
|
+
// be at most `line length + 1` — the end-of-line insertion point — with the line
|
|
485
|
+
// measured WITHOUT its terminator. Every source here puts a diagnostic ON a terminator,
|
|
486
|
+
// which is the only place the old mapping could overrun.
|
|
487
|
+
const sources = [
|
|
488
|
+
['app/schema/bounds_flow.yml', 'c: [1, 2\r\n'],
|
|
489
|
+
['app/schema/bounds_quote.yml', 'name: "oops\r\n'],
|
|
490
|
+
['app/views/pages/bounds_tag.liquid', '{% if x \r\n'],
|
|
491
|
+
];
|
|
492
|
+
const overruns = [];
|
|
493
|
+
for (const [filePath, source] of sources) {
|
|
494
|
+
const lines = source.split('\r\n');
|
|
495
|
+
const diagnostics = (await diagnose(filePath, source)) ?? [];
|
|
496
|
+
overruns.push(...diagnostics
|
|
497
|
+
.filter(({ line, column }) => column > (lines[line - 1] ?? '').length + 1)
|
|
498
|
+
.map(({ check, line, column }) => ({ filePath, check, line, column })));
|
|
499
|
+
}
|
|
500
|
+
expect(overruns).toEqual([]);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
//# sourceMappingURL=lint-batch.spec.js.map
|
package/dist/lint/lint.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LintBufferStatus } from '@platformos/platformos-check-node';
|
|
1
2
|
import type { ValidateCodeDiagnostic } from '../result/types.js';
|
|
2
3
|
export interface RunLintParams {
|
|
3
4
|
/** Absolute project root the buffer is validated against. */
|
|
@@ -7,5 +8,14 @@ export interface RunLintParams {
|
|
|
7
8
|
/** In-memory buffer contents. */
|
|
8
9
|
content: string;
|
|
9
10
|
}
|
|
11
|
+
export interface LintOutcome {
|
|
12
|
+
diagnostics: ValidateCodeDiagnostic[];
|
|
13
|
+
/**
|
|
14
|
+
* Set when the seam did NOT check the file, with its reason. An agent asked
|
|
15
|
+
* whether a file is correct, so "no diagnostics" and "nothing was looked at"
|
|
16
|
+
* cannot arrive as the same answer — see `lintBuffer`'s `LintBufferStatus`.
|
|
17
|
+
*/
|
|
18
|
+
notChecked?: Exclude<LintBufferStatus, 'checked'>;
|
|
19
|
+
}
|
|
10
20
|
/** Lint the buffer and return the mapped diagnostics for the file. */
|
|
11
|
-
export declare function runLint(params: RunLintParams): Promise<
|
|
21
|
+
export declare function runLint(params: RunLintParams): Promise<LintOutcome>;
|
package/dist/lint/lint.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `Offense.fix` / `suggest` are intentionally not translated.
|
|
14
14
|
*/
|
|
15
15
|
import { isAbsolute, join } from 'node:path';
|
|
16
|
-
import { lintBuffer, Severity } from '@platformos/platformos-check-node';
|
|
16
|
+
import { lintBuffer, Severity, } from '@platformos/platformos-check-node';
|
|
17
17
|
const SEVERITY = {
|
|
18
18
|
[Severity.ERROR]: 'error',
|
|
19
19
|
[Severity.WARNING]: 'warning',
|
|
@@ -23,8 +23,14 @@ const SEVERITY = {
|
|
|
23
23
|
export async function runLint(params) {
|
|
24
24
|
const { projectDir, filePath, content } = params;
|
|
25
25
|
const absoluteFilePath = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
|
|
26
|
-
const offenses = await lintBuffer({
|
|
27
|
-
|
|
26
|
+
const { status, offenses } = await lintBuffer({
|
|
27
|
+
root: projectDir,
|
|
28
|
+
filePath: absoluteFilePath,
|
|
29
|
+
content,
|
|
30
|
+
});
|
|
31
|
+
if (status !== 'checked')
|
|
32
|
+
return { diagnostics: [], notChecked: status };
|
|
33
|
+
return { diagnostics: offenses.map(toDiagnostic) };
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
30
36
|
* Map a check-common `Offense` to a `ValidateCodeDiagnostic`.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import type { Declined, ValidateCodeDiagnostic, ValidateCodeImpact, ValidateCodeResult } from './types.js';
|
|
2
|
+
export declare function assembleResult(diagnostics: ValidateCodeDiagnostic[], impact: ValidateCodeImpact): ValidateCodeResult;
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
4
|
+
* The result for a file this server did NOT check — outside the project root, not a
|
|
5
|
+
* platformOS source type, too large to parse safely, or past its deadline (see
|
|
6
|
+
* `fileApplicability` / `bufferTooLarge` / `withDeadline`).
|
|
3
7
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
8
|
+
* Everything is empty and `must_fix_before_write` is `false`, so the call neither blocks
|
|
9
|
+
* the write nor approves it — including for a TIMEOUT, since blocking a write because our
|
|
10
|
+
* own validation failed would make the tool a liability. `reason` lands in `next_step`;
|
|
11
|
+
* `code` lands in `not_applicable_reason` for an agent to branch on.
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
* blocking-warning set, `next_step`, tips, domain_guide, structural) are added
|
|
10
|
-
* in later tasks; they are left empty/null here.
|
|
13
|
+
* `impact` is `not_applicable`: nothing cross-file was compared either.
|
|
11
14
|
*/
|
|
12
|
-
|
|
13
|
-
export declare function assembleResult(diagnostics: ValidateCodeDiagnostic[], _mode: ValidateCodeMode): ValidateCodeResult;
|
|
15
|
+
export declare function assembleNotApplicableResult(declined: Declined): ValidateCodeResult;
|
package/dist/result/assemble.js
CHANGED
|
@@ -1,25 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Result assembly.
|
|
3
|
+
*
|
|
4
|
+
* Buckets the mapped diagnostics into errors / warnings / infos and derives the `status` +
|
|
5
|
+
* `must_fix_before_write` envelope. PURE — no I/O, consumes only the diagnostic list and
|
|
6
|
+
* the shared result types. `impact` is pre-computed by the impact adapter and included
|
|
7
|
+
* verbatim.
|
|
8
|
+
*
|
|
9
|
+
* The result carries ONLY fields that are actually populated: an agent cannot tell an
|
|
10
|
+
* always-empty field from a meaningful one.
|
|
11
|
+
*/
|
|
12
|
+
import { blocksWrite } from './blocking.js';
|
|
13
|
+
import { NOT_APPLICABLE_IMPACT } from './impact-states.js';
|
|
14
|
+
/**
|
|
15
|
+
* Order diagnostics the way someone READS the file: top to bottom, left to right.
|
|
16
|
+
*
|
|
17
|
+
* `check()` collects results per check, so the raw order is grouped by check code — an
|
|
18
|
+
* `ImgWidthAndHeight` on line 5 arrives before a `MissingPartial` on line 1.
|
|
19
|
+
*
|
|
20
|
+
* Sorting belongs HERE rather than in check-common: the engine's job is detection, and
|
|
21
|
+
* per-check batching is what makes it fast. `check` is the final tiebreak so two findings
|
|
22
|
+
* at the identical position have a deterministic order across runs.
|
|
23
|
+
*/
|
|
24
|
+
function inReadingOrder(diagnostics) {
|
|
25
|
+
return [...diagnostics].sort((a, b) => a.line - b.line || a.column - b.column || a.check.localeCompare(b.check));
|
|
26
|
+
}
|
|
27
|
+
export function assembleResult(diagnostics, impact) {
|
|
28
|
+
const ordered = inReadingOrder(diagnostics);
|
|
29
|
+
const errors = ordered.filter((d) => d.severity === 'error');
|
|
30
|
+
const warnings = ordered.filter((d) => d.severity === 'warning');
|
|
31
|
+
const infos = ordered.filter((d) => d.severity === 'info');
|
|
32
|
+
// `ok` MEANS "checked, nothing objected" — honest only while every file type this server
|
|
33
|
+
// admits has at least one check that examines it. That is an INVARIANT, not a fact, and
|
|
34
|
+
// `transport/validate-code.spec.ts`'s file-type-coverage group fails if a type is ever
|
|
35
|
+
// admitted with nothing looking at it. Read it before adding a `PlatformOSFileType`.
|
|
7
36
|
const status = errors.length > 0 ? 'error' : warnings.length > 0 ? 'warning' : 'ok';
|
|
8
37
|
return {
|
|
9
38
|
status,
|
|
10
|
-
//
|
|
11
|
-
// is
|
|
12
|
-
must_fix_before_write: errors
|
|
39
|
+
// NOT `errors.length > 0`. `status` says what was FOUND; this says whether the file is
|
|
40
|
+
// broken, which is a strictly smaller question — see `blocking.ts`.
|
|
41
|
+
must_fix_before_write: blocksWrite(errors),
|
|
13
42
|
errors,
|
|
14
43
|
warnings,
|
|
15
44
|
infos,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
45
|
+
impact,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The result for a file this server did NOT check — outside the project root, not a
|
|
50
|
+
* platformOS source type, too large to parse safely, or past its deadline (see
|
|
51
|
+
* `fileApplicability` / `bufferTooLarge` / `withDeadline`).
|
|
52
|
+
*
|
|
53
|
+
* Everything is empty and `must_fix_before_write` is `false`, so the call neither blocks
|
|
54
|
+
* the write nor approves it — including for a TIMEOUT, since blocking a write because our
|
|
55
|
+
* own validation failed would make the tool a liability. `reason` lands in `next_step`;
|
|
56
|
+
* `code` lands in `not_applicable_reason` for an agent to branch on.
|
|
57
|
+
*
|
|
58
|
+
* `impact` is `not_applicable`: nothing cross-file was compared either.
|
|
59
|
+
*/
|
|
60
|
+
export function assembleNotApplicableResult(declined) {
|
|
61
|
+
return {
|
|
62
|
+
status: 'not_applicable',
|
|
63
|
+
not_applicable_reason: declined.code,
|
|
64
|
+
must_fix_before_write: false,
|
|
65
|
+
errors: [],
|
|
66
|
+
warnings: [],
|
|
67
|
+
infos: [],
|
|
68
|
+
impact: NOT_APPLICABLE_IMPACT(),
|
|
69
|
+
next_step: declined.reason,
|
|
23
70
|
};
|
|
24
71
|
}
|
|
25
72
|
//# sourceMappingURL=assemble.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|