@platformos/platformos-mcp-supervisor 0.0.2 → 0.1.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 +90 -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.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- 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 +26 -0
- package/dist/context.js +11 -0
- package/dist/cost-model.d.ts +128 -0
- package/dist/cost-model.js +137 -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 +87 -0
- package/dist/enrich/enrich.js +97 -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/impact.d.ts +9 -0
- package/dist/impact/impact.js +189 -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 +37 -0
- package/dist/impact/project-scan.js +60 -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 +12 -9
- package/dist/result/assemble.js +64 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +86 -0
- package/dist/result/blocking.js +96 -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 +16 -0
- package/dist/result/impact-states.js +18 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +131 -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 +210 -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 +132 -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 +1 -1
- package/dist/transport/server.js +16 -9
- package/dist/transport/validate-code.d.ts +36 -18
- package/dist/transport/validate-code.js +200 -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 +60 -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 +265 -0
- package/package.json +8 -7
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Declined } from '../result/types.js';
|
|
2
|
+
import type { BufferToValidate } from './validate-buffers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Most files in one request.
|
|
5
|
+
*
|
|
6
|
+
* Far above any plausible single coherent edit. In practice the byte cap below binds
|
|
7
|
+
* first for large files; this one exists so a request of thousands of tiny buffers
|
|
8
|
+
* cannot turn into an unbounded number of per-file graph lookups.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_BATCH_FILES = 50;
|
|
11
|
+
/**
|
|
12
|
+
* Most total bytes in one request.
|
|
13
|
+
*
|
|
14
|
+
* DERIVED, not chosen: the largest request whose scaled deadline still fits inside the
|
|
15
|
+
* ceiling the server is willing to wait (see `cost-model.ts`). Without any cap, 50 files
|
|
16
|
+
* just under `MAX_BUFFER_BYTES` would be ~6 MiB — minutes of parsing.
|
|
17
|
+
*
|
|
18
|
+
* A round multiple of `MAX_BUFFER_BYTES` inherits none of that reasoning and drifts the
|
|
19
|
+
* moment throughput moves, admitting a batch that cannot finish inside its deadline — and
|
|
20
|
+
* every file in such a request comes back `timed_out`, unchecked and silently. Deriving it
|
|
21
|
+
* means a change to the cost model moves this too; `cost-model.spec.ts` fails if the
|
|
22
|
+
* arithmetic stops fitting. The worst legal batch measures ~10 s against the deadline it
|
|
23
|
+
* earns; re-run `scripts/measure-lint-cost.mjs` to check the margin.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MAX_BATCH_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* A refusal when the request exceeds its file-count or total-byte cap, else
|
|
28
|
+
* `undefined`.
|
|
29
|
+
*
|
|
30
|
+
* Refuses the WHOLE request rather than trimming it: the cap is a property of the
|
|
31
|
+
* request, so choosing which files to drop would be arbitrary, and silently
|
|
32
|
+
* validating a subset would report a changeset as checked when it was not.
|
|
33
|
+
*/
|
|
34
|
+
export declare function batchTooLarge(buffers: readonly BufferToValidate[]): Declined | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caps on a multi-file request.
|
|
3
|
+
*
|
|
4
|
+
* The per-buffer bound (`MAX_BUFFER_BYTES`) is not enough on its own: a batch is a
|
|
5
|
+
* new way to hand the server unbounded work, and N individually-legal buffers add
|
|
6
|
+
* up. At the measured `LINT_MS_PER_KIB`, a request has to be bounded in total, not
|
|
7
|
+
* just per file.
|
|
8
|
+
*/
|
|
9
|
+
import { LINT_MS_PER_KIB, MAX_LINT_DEADLINE_MS, maxBytesWithin } from '../cost-model.js';
|
|
10
|
+
/**
|
|
11
|
+
* Most files in one request.
|
|
12
|
+
*
|
|
13
|
+
* Far above any plausible single coherent edit. In practice the byte cap below binds
|
|
14
|
+
* first for large files; this one exists so a request of thousands of tiny buffers
|
|
15
|
+
* cannot turn into an unbounded number of per-file graph lookups.
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_BATCH_FILES = 50;
|
|
18
|
+
/**
|
|
19
|
+
* Most total bytes in one request.
|
|
20
|
+
*
|
|
21
|
+
* DERIVED, not chosen: the largest request whose scaled deadline still fits inside the
|
|
22
|
+
* ceiling the server is willing to wait (see `cost-model.ts`). Without any cap, 50 files
|
|
23
|
+
* just under `MAX_BUFFER_BYTES` would be ~6 MiB — minutes of parsing.
|
|
24
|
+
*
|
|
25
|
+
* A round multiple of `MAX_BUFFER_BYTES` inherits none of that reasoning and drifts the
|
|
26
|
+
* moment throughput moves, admitting a batch that cannot finish inside its deadline — and
|
|
27
|
+
* every file in such a request comes back `timed_out`, unchecked and silently. Deriving it
|
|
28
|
+
* means a change to the cost model moves this too; `cost-model.spec.ts` fails if the
|
|
29
|
+
* arithmetic stops fitting. The worst legal batch measures ~10 s against the deadline it
|
|
30
|
+
* earns; re-run `scripts/measure-lint-cost.mjs` to check the margin.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_BATCH_BYTES = maxBytesWithin(MAX_LINT_DEADLINE_MS);
|
|
33
|
+
/**
|
|
34
|
+
* A refusal when the request exceeds its file-count or total-byte cap, else
|
|
35
|
+
* `undefined`.
|
|
36
|
+
*
|
|
37
|
+
* Refuses the WHOLE request rather than trimming it: the cap is a property of the
|
|
38
|
+
* request, so choosing which files to drop would be arbitrary, and silently
|
|
39
|
+
* validating a subset would report a changeset as checked when it was not.
|
|
40
|
+
*/
|
|
41
|
+
export function batchTooLarge(buffers) {
|
|
42
|
+
if (buffers.length > MAX_BATCH_FILES) {
|
|
43
|
+
return {
|
|
44
|
+
code: 'too_large',
|
|
45
|
+
reason: `The request has ${buffers.length} files, above the ${MAX_BATCH_FILES} file limit. ` +
|
|
46
|
+
`Nothing was checked — split it into smaller batches.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const bytes = buffers.reduce((total, buffer) => total + Buffer.byteLength(buffer.content, 'utf8'), 0);
|
|
50
|
+
if (bytes > MAX_BATCH_BYTES) {
|
|
51
|
+
return {
|
|
52
|
+
code: 'too_large',
|
|
53
|
+
reason: `The request totals ${Math.round(bytes / 1024)} KiB, above the ${Math.round(MAX_BATCH_BYTES / 1024)} KiB limit. Validation costs about ${LINT_MS_PER_KIB} ms per KiB, so checking this ` +
|
|
54
|
+
`would take longer than this server will wait. Nothing was checked — split it into ` +
|
|
55
|
+
`smaller batches.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=batch-bounds.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Declined } from '../result/types.js';
|
|
2
|
+
import type { BufferToValidate } from './validate-buffers.js';
|
|
3
|
+
/**
|
|
4
|
+
* A refusal when two or more entries resolve to the same file, else `undefined`.
|
|
5
|
+
*
|
|
6
|
+
* A REFUSAL AND NOT A MERGE. Results are keyed by the caller's own `filePath` STRING, but
|
|
7
|
+
* buffers are overlaid and deduplicated by normalized URI, last entry winning — so when
|
|
8
|
+
* several caller keys resolve to ONE uri they all read back that uri's offenses, and the
|
|
9
|
+
* losing buffer is never linted while its entry carries the WINNER's verdict. Measured
|
|
10
|
+
* before this guard existed: a broken buffer and a clean one under the same path returned
|
|
11
|
+
* `ok` for both, and reversing the argument order flipped both to `error`.
|
|
12
|
+
*
|
|
13
|
+
* The whole request is refused rather than a winner picked, following `batchTooLarge`: the
|
|
14
|
+
* incoherence is a property of the REQUEST. IDENTICAL CONTENT IS REFUSED TOO — merging
|
|
15
|
+
* would be safe, but a rule with a content-equality carve-out is one more branch that can
|
|
16
|
+
* be wrong.
|
|
17
|
+
*
|
|
18
|
+
* Collision is decided on the same normalized URI the overlay uses, so this guard cannot
|
|
19
|
+
* disagree with the mechanism it protects.
|
|
20
|
+
*/
|
|
21
|
+
export declare function collidingBufferPaths(projectDir: string, buffers: readonly BufferToValidate[]): Declined | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a multi-file request CONTRADICTS ITSELF, as opposed to merely being too
|
|
3
|
+
* large.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `batch-bounds.ts` because it asks a different question and needs a
|
|
6
|
+
* different input: a cap is a pure function of the buffers' content, while deciding
|
|
7
|
+
* whether two entries name the same file requires the project root to resolve
|
|
8
|
+
* relative paths against.
|
|
9
|
+
*/
|
|
10
|
+
import { path as pathUtils } from '@platformos/platformos-check-common';
|
|
11
|
+
import { toAbsoluteFilePath } from '../adapter-input.js';
|
|
12
|
+
/**
|
|
13
|
+
* A refusal when two or more entries resolve to the same file, else `undefined`.
|
|
14
|
+
*
|
|
15
|
+
* A REFUSAL AND NOT A MERGE. Results are keyed by the caller's own `filePath` STRING, but
|
|
16
|
+
* buffers are overlaid and deduplicated by normalized URI, last entry winning — so when
|
|
17
|
+
* several caller keys resolve to ONE uri they all read back that uri's offenses, and the
|
|
18
|
+
* losing buffer is never linted while its entry carries the WINNER's verdict. Measured
|
|
19
|
+
* before this guard existed: a broken buffer and a clean one under the same path returned
|
|
20
|
+
* `ok` for both, and reversing the argument order flipped both to `error`.
|
|
21
|
+
*
|
|
22
|
+
* The whole request is refused rather than a winner picked, following `batchTooLarge`: the
|
|
23
|
+
* incoherence is a property of the REQUEST. IDENTICAL CONTENT IS REFUSED TOO — merging
|
|
24
|
+
* would be safe, but a rule with a content-equality carve-out is one more branch that can
|
|
25
|
+
* be wrong.
|
|
26
|
+
*
|
|
27
|
+
* Collision is decided on the same normalized URI the overlay uses, so this guard cannot
|
|
28
|
+
* disagree with the mechanism it protects.
|
|
29
|
+
*/
|
|
30
|
+
export function collidingBufferPaths(projectDir, buffers) {
|
|
31
|
+
const spellingsByFile = new Map();
|
|
32
|
+
for (const buffer of buffers) {
|
|
33
|
+
const uri = pathUtils.normalize(pathUtils.URI.file(toAbsoluteFilePath(projectDir, buffer.filePath)));
|
|
34
|
+
const spellings = spellingsByFile.get(uri);
|
|
35
|
+
if (spellings)
|
|
36
|
+
spellings.push(buffer.filePath);
|
|
37
|
+
else
|
|
38
|
+
spellingsByFile.set(uri, [buffer.filePath]);
|
|
39
|
+
}
|
|
40
|
+
const collisions = [...spellingsByFile.values()].filter((spellings) => spellings.length > 1);
|
|
41
|
+
if (collisions.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
const described = collisions
|
|
44
|
+
.map((spellings) => spellings.map((spelling) => `\`${spelling}\``).join(' and '))
|
|
45
|
+
.join('; ');
|
|
46
|
+
return {
|
|
47
|
+
code: 'internal_error',
|
|
48
|
+
reason: `The same file appears more than once in this request: ${described}. A changeset cannot ` +
|
|
49
|
+
`contain two versions of one file — validating it would mean checking one buffer and ` +
|
|
50
|
+
`reporting its verdict for the other. Nothing was checked; send each file exactly once, ` +
|
|
51
|
+
`with the content you intend to write.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=batch-coherence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collidingBufferPaths } from './batch-coherence.js';
|
|
4
|
+
/**
|
|
5
|
+
* TASK-22. Two entries resolving to one file used to be linted as one buffer (last
|
|
6
|
+
* one wins) while BOTH entries were reported with the winner's verdict — so a
|
|
7
|
+
* buffer that was never validated came back clean, and reversing the argument order
|
|
8
|
+
* flipped which one was lied about.
|
|
9
|
+
*
|
|
10
|
+
* The guard is decided on the same normalized URI the overlay deduplicates on, so
|
|
11
|
+
* every spelling that collapses there must be caught here.
|
|
12
|
+
*/
|
|
13
|
+
const ROOT = path.resolve('/srv/app');
|
|
14
|
+
const buffer = (filePath, content = 'x') => ({ filePath, content });
|
|
15
|
+
describe('Unit: collidingBufferPaths', () => {
|
|
16
|
+
describe('refuses every spelling that resolves to one file', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
[
|
|
19
|
+
'the identical relative path twice',
|
|
20
|
+
'app/views/partials/x.liquid',
|
|
21
|
+
'app/views/partials/x.liquid',
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
'absolute and relative spellings of one file',
|
|
25
|
+
path.join(ROOT, 'app/views/partials/x.liquid'),
|
|
26
|
+
'app/views/partials/x.liquid',
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'a redundant current-directory segment',
|
|
30
|
+
'app/views/partials/x.liquid',
|
|
31
|
+
'app/views/partials/./x.liquid',
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
'a path that climbs out and back in',
|
|
35
|
+
'app/views/partials/x.liquid',
|
|
36
|
+
'app/views/partials/../partials/x.liquid',
|
|
37
|
+
],
|
|
38
|
+
])('%s', (_why, first, second) => {
|
|
39
|
+
const refusal = collidingBufferPaths(ROOT, [
|
|
40
|
+
buffer(first, 'broken'),
|
|
41
|
+
buffer(second, 'clean'),
|
|
42
|
+
]);
|
|
43
|
+
expect(refusal?.code).toEqual('internal_error');
|
|
44
|
+
// Both spellings are named, so the caller can find the entries to remove.
|
|
45
|
+
expect(refusal?.reason).toContain(first);
|
|
46
|
+
expect(refusal?.reason).toContain(second);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it('refuses regardless of argument order', () => {
|
|
50
|
+
const one = 'app/views/partials/x.liquid';
|
|
51
|
+
const two = path.join(ROOT, 'app/views/partials/x.liquid');
|
|
52
|
+
expect(collidingBufferPaths(ROOT, [buffer(one), buffer(two)])?.code).toEqual('internal_error');
|
|
53
|
+
expect(collidingBufferPaths(ROOT, [buffer(two), buffer(one)])?.code).toEqual('internal_error');
|
|
54
|
+
});
|
|
55
|
+
it('refuses even when the colliding buffers carry identical content', () => {
|
|
56
|
+
// Safe to merge in principle, but "same path twice" is a caller bug either way
|
|
57
|
+
// and a content-equality carve-out is one more branch that can be wrong.
|
|
58
|
+
const refusal = collidingBufferPaths(ROOT, [
|
|
59
|
+
buffer('app/views/pages/a.liquid', 'same'),
|
|
60
|
+
buffer('app/views/pages/a.liquid', 'same'),
|
|
61
|
+
]);
|
|
62
|
+
expect(refusal?.code).toEqual('internal_error');
|
|
63
|
+
});
|
|
64
|
+
it('names every colliding group, not just the first', () => {
|
|
65
|
+
const refusal = collidingBufferPaths(ROOT, [
|
|
66
|
+
buffer('app/views/pages/a.liquid'),
|
|
67
|
+
buffer('app/views/pages/b.liquid'),
|
|
68
|
+
buffer('app/views/pages/a.liquid'),
|
|
69
|
+
buffer('app/views/pages/b.liquid'),
|
|
70
|
+
]);
|
|
71
|
+
expect(refusal?.reason).toContain('a.liquid');
|
|
72
|
+
expect(refusal?.reason).toContain('b.liquid');
|
|
73
|
+
});
|
|
74
|
+
it('reports a file repeated three times as one group', () => {
|
|
75
|
+
const refusal = collidingBufferPaths(ROOT, [
|
|
76
|
+
buffer('app/views/pages/a.liquid'),
|
|
77
|
+
buffer('app/views/pages/a.liquid'),
|
|
78
|
+
buffer('app/views/pages/a.liquid'),
|
|
79
|
+
]);
|
|
80
|
+
expect(refusal?.code).toEqual('internal_error');
|
|
81
|
+
});
|
|
82
|
+
describe('allows requests that name each file once', () => {
|
|
83
|
+
it('accepts distinct files', () => {
|
|
84
|
+
expect(collidingBufferPaths(ROOT, [
|
|
85
|
+
buffer('app/views/pages/a.liquid'),
|
|
86
|
+
buffer('app/views/partials/b.liquid'),
|
|
87
|
+
buffer('app/graphql/c.graphql'),
|
|
88
|
+
])).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
it('accepts a mix of absolute and relative spellings of DIFFERENT files', () => {
|
|
91
|
+
// The keying this guard protects exists so exactly this request keeps working.
|
|
92
|
+
expect(collidingBufferPaths(ROOT, [
|
|
93
|
+
buffer(path.join(ROOT, 'app/views/pages/a.liquid')),
|
|
94
|
+
buffer('app/views/partials/b.liquid'),
|
|
95
|
+
])).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
it('accepts a single buffer', () => {
|
|
98
|
+
expect(collidingBufferPaths(ROOT, [buffer('app/views/pages/a.liquid')])).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it('accepts an empty request', () => {
|
|
101
|
+
expect(collidingBufferPaths(ROOT, [])).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
it('does not treat two different files in one directory as a collision', () => {
|
|
104
|
+
expect(collidingBufferPaths(ROOT, [
|
|
105
|
+
buffer('app/views/pages/index.liquid'),
|
|
106
|
+
buffer('app/views/pages/index2.liquid'),
|
|
107
|
+
])).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
//# sourceMappingURL=batch-coherence.spec.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SupervisorContext } from '../context.js';
|
|
2
|
+
import { type DocsetVocabulary } from '../enrich/enrich.js';
|
|
3
|
+
import { runImpact } from '../impact/impact.js';
|
|
4
|
+
import { runBatchLint } from '../lint/lint-batch.js';
|
|
5
|
+
import type { ValidateCodeResult } from '../result/types.js';
|
|
6
|
+
/** One buffer to validate, keyed by the caller's own path string. */
|
|
7
|
+
export interface BufferToValidate {
|
|
8
|
+
/** File under edit — absolute, or relative to the project root, exactly as given. */
|
|
9
|
+
filePath: string;
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
/** The I/O seams the orchestrator drives. Injectable so the contract is unit-testable. */
|
|
13
|
+
export interface ValidateAdapters {
|
|
14
|
+
lint: typeof runBatchLint;
|
|
15
|
+
impact: typeof runImpact;
|
|
16
|
+
/** Resolve the docset enrichment reads — the one piece of I/O the pure stage cannot do. */
|
|
17
|
+
docset: () => Promise<DocsetVocabulary>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate every buffer, returning one result per buffer keyed by the caller's own
|
|
21
|
+
* `filePath` string.
|
|
22
|
+
*
|
|
23
|
+
* Keyed by the caller's key rather than a normalized URI so a caller that mixes
|
|
24
|
+
* relative and absolute paths can find its own results without reproducing our
|
|
25
|
+
* normalization.
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateBuffers(ctx: SupervisorContext, buffers: readonly BufferToValidate[], overrides?: Partial<ValidateAdapters>): Promise<Map<string, ValidateCodeResult>>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE validation orchestrator. Everything the tool does happens here, and it always
|
|
3
|
+
* works on a LIST of buffers — one file is simply a list of length one. The tool surface
|
|
4
|
+
* adapts shapes; the logic exists once.
|
|
5
|
+
*
|
|
6
|
+
* THE BATCH IS NOT ATOMIC. Every requested buffer gets a result. A refusal declines only
|
|
7
|
+
* ITS buffer, and a merely-unchecked buffer never gates the others.
|
|
8
|
+
*
|
|
9
|
+
* The list is the primitive because validating several buffers together is also more
|
|
10
|
+
* CORRECT: with every buffer overlaid at once, a partial introduced in one resolves for a
|
|
11
|
+
* `render` in another.
|
|
12
|
+
*/
|
|
13
|
+
import { getPlatformOSDocset, NodeFileSystem, path as pathUtils, } from '@platformos/platformos-check-node';
|
|
14
|
+
import { assetNotLinted, bufferTooLarge, fileApplicability, ignoredByProjectConfig, misplacedSource, notPlatformOSFile, toAbsoluteFilePath, } from '../adapter-input.js';
|
|
15
|
+
import { IMPACT_DEADLINE_MS } from '../context.js';
|
|
16
|
+
import { enrichDiagnostics } from '../enrich/enrich.js';
|
|
17
|
+
import { lintDeadlineMs } from '../cost-model.js';
|
|
18
|
+
import { TIMED_OUT, withDeadline } from '../deadline.js';
|
|
19
|
+
import { runImpact } from '../impact/impact.js';
|
|
20
|
+
import { createProjectScan } from '../impact/project-scan.js';
|
|
21
|
+
import { runBatchLint, } from '../lint/lint-batch.js';
|
|
22
|
+
import { assembleNotApplicableResult, assembleResult } from '../result/assemble.js';
|
|
23
|
+
import { capToBudget } from '../result/response-budget.js';
|
|
24
|
+
import { UNAVAILABLE_IMPACT } from '../result/impact-states.js';
|
|
25
|
+
/** Read the process-wide docset and flatten it to the arrays enrichment takes. */
|
|
26
|
+
async function resolveDocset() {
|
|
27
|
+
const docset = getPlatformOSDocset();
|
|
28
|
+
const [filters, tags, objects] = await Promise.all([
|
|
29
|
+
docset.filters(),
|
|
30
|
+
docset.tags(),
|
|
31
|
+
docset.objects(),
|
|
32
|
+
]);
|
|
33
|
+
return { filters, tags, objects };
|
|
34
|
+
}
|
|
35
|
+
const DEFAULT_ADAPTERS = {
|
|
36
|
+
lint: runBatchLint,
|
|
37
|
+
impact: runImpact,
|
|
38
|
+
docset: resolveDocset,
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* check-node's "did not check it" status → the refusal the agent reads, by
|
|
42
|
+
* project-relative path.
|
|
43
|
+
*
|
|
44
|
+
* TOTAL OVER {@link LintNotCheckedStatus} BY TYPE, with no `default` arm: every entry hands
|
|
45
|
+
* back different ADVICE, so a status added upstream must fail the build here rather than
|
|
46
|
+
* fall through to someone else's remedy.
|
|
47
|
+
*/
|
|
48
|
+
const DECLINE = {
|
|
49
|
+
'excluded-by-config': ignoredByProjectConfig,
|
|
50
|
+
'misplaced-source': misplacedSource,
|
|
51
|
+
'not-a-platformos-file': notPlatformOSFile,
|
|
52
|
+
'not-a-source-file': assetNotLinted,
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Validate every buffer, returning one result per buffer keyed by the caller's own
|
|
56
|
+
* `filePath` string.
|
|
57
|
+
*
|
|
58
|
+
* Keyed by the caller's key rather than a normalized URI so a caller that mixes
|
|
59
|
+
* relative and absolute paths can find its own results without reproducing our
|
|
60
|
+
* normalization.
|
|
61
|
+
*/
|
|
62
|
+
export async function validateBuffers(ctx, buffers, overrides = {}) {
|
|
63
|
+
const adapters = { ...DEFAULT_ADAPTERS, ...overrides };
|
|
64
|
+
const { declined, lintable } = partition(ctx.projectDir, buffers);
|
|
65
|
+
// Computed ONCE and threaded to both the timer and the message that reports it, so the
|
|
66
|
+
// two cannot disagree about the deadline a caller was held to.
|
|
67
|
+
const lintDeadline = lintDeadlineMs(admittedBytes(lintable));
|
|
68
|
+
// Concurrently: the lint is the long pole and impact must hide behind it.
|
|
69
|
+
const [lint, impacts] = await Promise.all([
|
|
70
|
+
lintWithDeadline(ctx, adapters, lintable, lintDeadline),
|
|
71
|
+
impactWithDeadline(ctx, adapters, lintable),
|
|
72
|
+
]);
|
|
73
|
+
// The lint pass is what knows which buffers were not checked and why — it holds the
|
|
74
|
+
// config AND the classifier. Folding its answer in here keeps ONE source of truth for
|
|
75
|
+
// "is this file part of the app".
|
|
76
|
+
if (lint !== TIMED_OUT) {
|
|
77
|
+
const rootUri = pathUtils.toUri(ctx.projectDir);
|
|
78
|
+
for (const [key, status] of lint.notChecked) {
|
|
79
|
+
const absolute = pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, key));
|
|
80
|
+
declined.set(key, DECLINE[status](pathUtils.relative(absolute, rootUri)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const diagnostics = lint === TIMED_OUT ? TIMED_OUT : await enrich(ctx, lint, adapters.docset);
|
|
84
|
+
// The response bound is applied LAST, to finished results: `resultFor` has already
|
|
85
|
+
// computed `status` and `must_fix_before_write` from the complete diagnostic set, so the
|
|
86
|
+
// cap can only shorten lists — it has no way to soften a verdict.
|
|
87
|
+
return capToBudget(new Map(buffers.map((buffer) => [
|
|
88
|
+
buffer.filePath,
|
|
89
|
+
resultFor(buffer.filePath, declined, diagnostics, impacts, lintDeadline),
|
|
90
|
+
])));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Split the requested buffers into those this server declines outright and those it will
|
|
94
|
+
* send to the lint.
|
|
95
|
+
*
|
|
96
|
+
* PURE and synchronous. Every refusal decidable without I/O happens here — outside the
|
|
97
|
+
* project root, an unsupported file type, over the size bound — so a declined buffer costs
|
|
98
|
+
* nothing and never reaches the engine.
|
|
99
|
+
*
|
|
100
|
+
* Config-level exclusion and telling a MISPLACED source from an unsupported one both need
|
|
101
|
+
* the project, so they are decided by the lint pass instead and folded in by
|
|
102
|
+
* {@link validateBuffers} through {@link DECLINE}.
|
|
103
|
+
*/
|
|
104
|
+
function partition(projectDir, buffers) {
|
|
105
|
+
const declined = new Map();
|
|
106
|
+
const lintable = [];
|
|
107
|
+
for (const buffer of buffers) {
|
|
108
|
+
const refusal = refuse(projectDir, buffer);
|
|
109
|
+
if (refusal) {
|
|
110
|
+
declined.set(buffer.filePath, refusal);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
lintable.push({ filePath: buffer.filePath, content: buffer.content });
|
|
114
|
+
}
|
|
115
|
+
return { declined, lintable };
|
|
116
|
+
}
|
|
117
|
+
/** The pure, per-buffer refusal for this buffer, if any. */
|
|
118
|
+
function refuse(projectDir, buffer) {
|
|
119
|
+
const applicability = fileApplicability(projectDir, buffer.filePath);
|
|
120
|
+
if (!applicability.applicable)
|
|
121
|
+
return applicability;
|
|
122
|
+
return bufferTooLarge(buffer.content);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Lint every lintable buffer in ONE project pass, or {@link TIMED_OUT}.
|
|
126
|
+
*
|
|
127
|
+
* The deadline is a backstop against an async stall, NOT cancellation: a synchronous parse
|
|
128
|
+
* blocks the event loop and the timer cannot even fire during it (see `deadline.ts`). What
|
|
129
|
+
* bounds CPU-bound work is `MAX_BUFFER_BYTES` and `MAX_BATCH_BYTES`.
|
|
130
|
+
*
|
|
131
|
+
* It is sized from the bytes ACTUALLY ADMITTED, since those are the only ones the lint
|
|
132
|
+
* will spend time on.
|
|
133
|
+
*/
|
|
134
|
+
async function lintWithDeadline(ctx, adapters, lintable, deadline) {
|
|
135
|
+
if (lintable.length === 0)
|
|
136
|
+
return { diagnostics: new Map(), sources: new Map(), notChecked: new Map() };
|
|
137
|
+
const work = adapters.lint({ projectDir: ctx.projectDir, buffers: lintable });
|
|
138
|
+
const outcome = await withDeadline(work, deadline);
|
|
139
|
+
if (outcome !== TIMED_OUT)
|
|
140
|
+
return outcome;
|
|
141
|
+
observeAbandoned(ctx, work, 'lint');
|
|
142
|
+
ctx.log(`validate_code: lint exceeded ${deadline} ms`);
|
|
143
|
+
return TIMED_OUT;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Attach the check's documentation URL and the docset entry for the symbol each diagnostic
|
|
147
|
+
* is about.
|
|
148
|
+
*
|
|
149
|
+
* THE ONLY I/O HERE IS RESOLVING THE DOCSET, at this edge rather than inside `enrich/`,
|
|
150
|
+
* which is a pure function of data.
|
|
151
|
+
*
|
|
152
|
+
* FAILING TO ENRICH MUST NOT FAIL THE CALL: a throw degrades to the un-enriched
|
|
153
|
+
* diagnostics and is logged, rather than reporting a broken file as unchecked.
|
|
154
|
+
*
|
|
155
|
+
* NOTHING TO ENRICH MEANS NO I/O AT ALL — resolving the docset would pay
|
|
156
|
+
* `PlatformOSLiquidDocsManager.setup()`'s network revision check for nothing.
|
|
157
|
+
*/
|
|
158
|
+
async function enrich(ctx, lint, resolve) {
|
|
159
|
+
let found = 0;
|
|
160
|
+
for (const diagnostics of lint.diagnostics.values())
|
|
161
|
+
found += diagnostics.length;
|
|
162
|
+
if (found === 0)
|
|
163
|
+
return lint.diagnostics;
|
|
164
|
+
try {
|
|
165
|
+
// ONCE per request, not once per file: the vocabulary is the same for every buffer.
|
|
166
|
+
const vocabulary = await resolve();
|
|
167
|
+
return new Map([...lint.diagnostics].map(([key, diagnostics]) => {
|
|
168
|
+
const source = lint.sources?.get(key);
|
|
169
|
+
// `startIndexes` is index-aligned with `diagnostics` by construction in
|
|
170
|
+
// `runBatchLint`; a missing entry can only mean the two got out of step, so the
|
|
171
|
+
// offset is passed as ABSENT rather than as an out-of-range sentinel that would
|
|
172
|
+
// resolve to some unrelated symbol.
|
|
173
|
+
const inputs = diagnostics.map((diagnostic, index) => ({
|
|
174
|
+
diagnostic,
|
|
175
|
+
startIndex: source?.startIndexes[index],
|
|
176
|
+
}));
|
|
177
|
+
return [key, enrichDiagnostics(inputs, { ast: source?.ast, vocabulary })];
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
ctx.log(`validate_code: enrichment failed, returning findings unenriched: ${describe(error)}`);
|
|
182
|
+
return lint.diagnostics;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Total UTF-8 bytes handed to the lint — what the deadline is sized against. */
|
|
186
|
+
function admittedBytes(lintable) {
|
|
187
|
+
return lintable.reduce((total, buffer) => total + Buffer.byteLength(buffer.content, 'utf8'), 0);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Blast radius per lintable buffer.
|
|
191
|
+
*
|
|
192
|
+
* ONE scan serves the whole batch: `createProjectScan` reads the project's edge sources
|
|
193
|
+
* once, lazily and memoized, so N buffers cost one project read and N name filters. The
|
|
194
|
+
* batch's own buffers are overlaid into it, so a caller a buffer has just added counts.
|
|
195
|
+
*
|
|
196
|
+
* The set shares one tight deadline because impact is discardable enrichment —
|
|
197
|
+
* `unavailable` already means "we don't know".
|
|
198
|
+
*/
|
|
199
|
+
async function impactWithDeadline(ctx, adapters, lintable) {
|
|
200
|
+
const byFile = new Map();
|
|
201
|
+
if (lintable.length === 0)
|
|
202
|
+
return byFile;
|
|
203
|
+
const rootUri = pathUtils.normalize(pathUtils.toUri(ctx.projectDir));
|
|
204
|
+
const scan = createProjectScan(rootUri, NodeFileSystem, new Map(lintable.map((buffer) => [
|
|
205
|
+
pathUtils.normalize(pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, buffer.filePath))),
|
|
206
|
+
buffer.content,
|
|
207
|
+
])));
|
|
208
|
+
const work = Promise.all(lintable.map(async (buffer) => {
|
|
209
|
+
const impact = await adapters
|
|
210
|
+
.impact({ projectDir: ctx.projectDir, filePath: buffer.filePath, content: buffer.content }, scan)
|
|
211
|
+
.catch((error) => {
|
|
212
|
+
ctx.log(`validate_code: blast-radius failed for ${buffer.filePath}: ${describe(error)}`);
|
|
213
|
+
return UNAVAILABLE_IMPACT();
|
|
214
|
+
});
|
|
215
|
+
return [buffer.filePath, impact];
|
|
216
|
+
}));
|
|
217
|
+
const outcome = await withDeadline(work, IMPACT_DEADLINE_MS);
|
|
218
|
+
if (outcome === TIMED_OUT) {
|
|
219
|
+
observeAbandoned(ctx, work, 'blast-radius');
|
|
220
|
+
ctx.log(`validate_code: blast-radius exceeded ${IMPACT_DEADLINE_MS} ms, continuing without it`);
|
|
221
|
+
for (const buffer of lintable)
|
|
222
|
+
byFile.set(buffer.filePath, UNAVAILABLE_IMPACT());
|
|
223
|
+
return byFile;
|
|
224
|
+
}
|
|
225
|
+
for (const [filePath, impact] of outcome)
|
|
226
|
+
byFile.set(filePath, impact);
|
|
227
|
+
return byFile;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Attach a handler to work the deadline abandoned.
|
|
231
|
+
*
|
|
232
|
+
* The promise is STILL RUNNING and now unobserved, so a later rejection would surface as
|
|
233
|
+
* an unhandled rejection — fatal under Node's default. Logged rather than swallowed.
|
|
234
|
+
*/
|
|
235
|
+
function observeAbandoned(ctx, work, what) {
|
|
236
|
+
work.catch((error) => {
|
|
237
|
+
ctx.log(`validate_code: abandoned ${what} later failed: ${describe(error)}`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const describe = (error) => (error instanceof Error ? error.message : String(error));
|
|
241
|
+
function resultFor(filePath, declined, diagnostics, impacts, lintDeadline) {
|
|
242
|
+
const refusal = declined.get(filePath);
|
|
243
|
+
if (refusal)
|
|
244
|
+
return assembleNotApplicableResult(refusal);
|
|
245
|
+
if (diagnostics === TIMED_OUT) {
|
|
246
|
+
return assembleNotApplicableResult({
|
|
247
|
+
code: 'timed_out',
|
|
248
|
+
reason: `Validation exceeded ${Math.round(lintDeadline / 1000)} s and was abandoned, so ` +
|
|
249
|
+
`nothing conclusive was checked for \`${filePath}\`. Treat this as "unknown", not as a ` +
|
|
250
|
+
`verdict on the file. Retrying — with fewer files, if this was a batch — may succeed.`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const forFile = diagnostics.get(filePath);
|
|
254
|
+
if (forFile === undefined) {
|
|
255
|
+
// FAIL SAFE. Unreachable: `runBatchLint` seeds an entry for every requested key. But
|
|
256
|
+
// defaulting to `[]` would report a file CLEAN that was never linted.
|
|
257
|
+
return assembleNotApplicableResult({
|
|
258
|
+
code: 'internal_error',
|
|
259
|
+
reason: `No result was produced for \`${filePath}\`, so nothing was checked. This is a bug in ` +
|
|
260
|
+
`the validator, not a verdict on the file — treat it as "unknown" and please report it.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return assembleResult(forFile, impacts.get(filePath) ?? UNAVAILABLE_IMPACT());
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=validate-buffers.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformos/platformos-mcp-supervisor",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "platformOS Model Context Protocol (MCP) server exposing validate_code for LLM agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist/**/*.js",
|
|
34
|
-
"dist/**/*.d.ts"
|
|
35
|
-
"dist/data/**/*"
|
|
34
|
+
"dist/**/*.d.ts"
|
|
36
35
|
],
|
|
37
36
|
"scripts": {
|
|
38
37
|
"build": "yarn build:ts",
|
|
@@ -44,10 +43,12 @@
|
|
|
44
43
|
},
|
|
45
44
|
"dependencies": {
|
|
46
45
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
|
-
"@platformos/
|
|
48
|
-
"@platformos/platformos-check-
|
|
49
|
-
"@platformos/platformos-check-node": "0.0
|
|
50
|
-
"@platformos/platformos-
|
|
46
|
+
"@platformos/liquid-html-parser": "0.1.0",
|
|
47
|
+
"@platformos/platformos-check-common": "1.0.0",
|
|
48
|
+
"@platformos/platformos-check-node": "1.0.0",
|
|
49
|
+
"@platformos/platformos-common": "0.1.0",
|
|
50
|
+
"@platformos/platformos-graph": "0.1.0",
|
|
51
|
+
"@platformos/platformos-language-server-common": "0.1.0",
|
|
51
52
|
"zod": "^3.23.8"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {}
|