@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.
Files changed (85) hide show
  1. package/README.md +90 -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.spec.d.ts +1 -0
  7. package/dist/bin/args.spec.js +35 -0
  8. package/dist/check-docs.d.ts +21 -0
  9. package/dist/check-docs.js +38 -0
  10. package/dist/check-docs.spec.d.ts +1 -0
  11. package/dist/check-docs.spec.js +87 -0
  12. package/dist/context.d.ts +26 -0
  13. package/dist/context.js +11 -0
  14. package/dist/cost-model.d.ts +128 -0
  15. package/dist/cost-model.js +137 -0
  16. package/dist/cost-model.spec.d.ts +1 -0
  17. package/dist/cost-model.spec.js +91 -0
  18. package/dist/deadline.d.ts +27 -0
  19. package/dist/deadline.js +40 -0
  20. package/dist/deadline.spec.d.ts +1 -0
  21. package/dist/deadline.spec.js +83 -0
  22. package/dist/enrich/enrich.d.ts +87 -0
  23. package/dist/enrich/enrich.js +97 -0
  24. package/dist/enrich/enrich.spec.d.ts +1 -0
  25. package/dist/enrich/enrich.spec.js +184 -0
  26. package/dist/enrich/symbol.d.ts +16 -0
  27. package/dist/enrich/symbol.js +58 -0
  28. package/dist/enrich/symbol.spec.d.ts +1 -0
  29. package/dist/enrich/symbol.spec.js +89 -0
  30. package/dist/impact/impact.d.ts +9 -0
  31. package/dist/impact/impact.js +189 -0
  32. package/dist/impact/impact.spec.d.ts +1 -0
  33. package/dist/impact/impact.spec.js +357 -0
  34. package/dist/impact/project-scan.d.ts +37 -0
  35. package/dist/impact/project-scan.js +60 -0
  36. package/dist/impact/project-scan.spec.d.ts +1 -0
  37. package/dist/impact/project-scan.spec.js +121 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/lint/fixes.d.ts +28 -0
  40. package/dist/lint/fixes.js +30 -0
  41. package/dist/lint/lint-batch.d.ts +100 -0
  42. package/dist/lint/lint-batch.js +98 -0
  43. package/dist/lint/lint-batch.spec.d.ts +1 -0
  44. package/dist/lint/lint-batch.spec.js +503 -0
  45. package/dist/lint/lint.d.ts +11 -1
  46. package/dist/lint/lint.js +9 -3
  47. package/dist/result/assemble.d.ts +12 -9
  48. package/dist/result/assemble.js +64 -16
  49. package/dist/result/assemble.spec.d.ts +1 -0
  50. package/dist/result/assemble.spec.js +319 -0
  51. package/dist/result/blocking.d.ts +86 -0
  52. package/dist/result/blocking.js +96 -0
  53. package/dist/result/blocking.spec.d.ts +1 -0
  54. package/dist/result/blocking.spec.js +201 -0
  55. package/dist/result/impact-states.d.ts +16 -0
  56. package/dist/result/impact-states.js +18 -0
  57. package/dist/result/response-budget.d.ts +9 -0
  58. package/dist/result/response-budget.js +131 -0
  59. package/dist/result/response-budget.spec.d.ts +1 -0
  60. package/dist/result/response-budget.spec.js +222 -0
  61. package/dist/result/types.d.ts +210 -104
  62. package/dist/result/types.js +4 -13
  63. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  64. package/dist/transport/instructions-coverage.spec.js +176 -0
  65. package/dist/transport/instructions.d.ts +1 -0
  66. package/dist/transport/instructions.js +132 -0
  67. package/dist/transport/process-guards.d.ts +43 -0
  68. package/dist/transport/process-guards.js +63 -0
  69. package/dist/transport/process-guards.spec.d.ts +1 -0
  70. package/dist/transport/process-guards.spec.js +200 -0
  71. package/dist/transport/server.d.ts +1 -1
  72. package/dist/transport/server.js +16 -9
  73. package/dist/transport/validate-code.d.ts +36 -18
  74. package/dist/transport/validate-code.js +200 -33
  75. package/dist/transport/validate-code.spec.d.ts +1 -0
  76. package/dist/transport/validate-code.spec.js +2303 -0
  77. package/dist/validate/batch-bounds.d.ts +34 -0
  78. package/dist/validate/batch-bounds.js +60 -0
  79. package/dist/validate/batch-coherence.d.ts +21 -0
  80. package/dist/validate/batch-coherence.js +54 -0
  81. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  82. package/dist/validate/batch-coherence.spec.js +111 -0
  83. package/dist/validate/validate-buffers.d.ts +27 -0
  84. package/dist/validate/validate-buffers.js +265 -0
  85. 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.2",
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/platformos-check-common": "0.0.20",
48
- "@platformos/platformos-check-docs-updater": "0.0.20",
49
- "@platformos/platformos-check-node": "0.0.20",
50
- "@platformos/platformos-graph": "0.0.20",
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": {}