@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,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
@@ -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<ValidateCodeDiagnostic[]>;
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({ root: projectDir, filePath: absoluteFilePath, content });
27
- return offenses.map(toDiagnostic);
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
- * Result assembly (minimal "lint only" slice).
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
- * Buckets the mapped diagnostics into errors / warnings / infos and derives the
5
- * `status` + `must_fix_before_write` envelope. PURE — no I/O, consumes only the
6
- * diagnostic list and the shared result types.
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
- * The ergonomic transforms (clustering, scorecard, the explicit
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
- import type { ValidateCodeDiagnostic, ValidateCodeMode, ValidateCodeResult } from './types.js';
13
- export declare function assembleResult(diagnostics: ValidateCodeDiagnostic[], _mode: ValidateCodeMode): ValidateCodeResult;
15
+ export declare function assembleNotApplicableResult(declined: Declined): ValidateCodeResult;
@@ -1,25 +1,72 @@
1
- export function assembleResult(diagnostics,
2
- // Reserved: `full`/`quick` do not yet change output (no heavier stages exist).
3
- _mode) {
4
- const errors = diagnostics.filter((d) => d.severity === 'error');
5
- const warnings = diagnostics.filter((d) => d.severity === 'warning');
6
- const infos = diagnostics.filter((d) => d.severity === 'info');
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
- // Minimal gate: any error blocks the write. The richer blocking-warning set
11
- // is defined in the result-assembly task.
12
- must_fix_before_write: errors.length > 0,
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
- proposed_fixes: [],
17
- clusters: [],
18
- scorecard: [],
19
- parse_error: null,
20
- tips: [],
21
- domain_guide: null,
22
- structural: null,
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 {};