@platformos/platformos-mcp-supervisor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/bin/args.d.ts +15 -2
- package/dist/bin/args.js +31 -3
- package/dist/bin/platformos-mcp-supervisor.js +6 -2
- package/dist/context.d.ts +16 -8
- package/dist/context.js +7 -6
- package/dist/cost-model.d.ts +60 -0
- package/dist/cost-model.js +60 -0
- package/dist/enrich/enrich.d.ts +18 -0
- package/dist/enrich/enrich.js +39 -0
- package/dist/impact/dependants.d.ts +75 -0
- package/dist/impact/dependants.js +104 -0
- package/dist/impact/diff.d.ts +27 -0
- package/dist/impact/diff.js +33 -0
- package/dist/impact/impact.d.ts +17 -5
- package/dist/impact/impact.js +135 -164
- package/dist/impact/project-scan.d.ts +12 -10
- package/dist/impact/project-scan.js +12 -10
- package/dist/result/assemble.d.ts +1 -2
- package/dist/result/assemble.js +1 -2
- package/dist/result/blocking.d.ts +10 -4
- package/dist/result/blocking.js +23 -3
- package/dist/result/impact-states.d.ts +12 -9
- package/dist/result/impact-states.js +9 -17
- package/dist/result/response-budget.js +5 -3
- package/dist/result/types.d.ts +42 -40
- package/dist/transport/instructions.js +24 -6
- package/dist/transport/server.d.ts +5 -0
- package/dist/transport/server.js +8 -4
- package/dist/transport/validate-code.d.ts +17 -0
- package/dist/transport/validate-code.js +56 -18
- package/dist/validate/batch-bounds.js +5 -0
- package/dist/validate/validate-buffers.js +81 -27
- package/package.json +7 -7
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
* Within a file and bucket, entries are taken from the FRONT, so what survives is the head
|
|
18
18
|
* of a list already ordered by line and column — where the root cause of a cascade is.
|
|
19
19
|
*
|
|
20
|
-
* A STREAM THAT CANNOT FIT
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* A STREAM THAT CANNOT FIT TAKES NOTHING FURTHER, so the returned list stays a contiguous
|
|
21
|
+
* head rather than a scattered sample. That comes from `taken` not advancing — a retried
|
|
22
|
+
* entry costs the same and `spent` only grows, so it can never fit later. Closing the
|
|
23
|
+
* stream only saves the retry, and earns its keep there: 1.9 ms against 61 ms on a batch of
|
|
24
|
+
* blocked files, same output.
|
|
23
25
|
*
|
|
24
26
|
* ONE ERROR PER FILE IS GUARANTEED, budget or not — a blocked write with an empty `errors`
|
|
25
27
|
* list names a problem and then declines to say what it is. Bounded: one entry per file,
|
package/dist/result/types.d.ts
CHANGED
|
@@ -134,53 +134,56 @@ export interface ValidateCodeDiagnostic {
|
|
|
134
134
|
see_also?: string;
|
|
135
135
|
}
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
137
|
+
* Whether the cross-file comparison ran. `computed` means this file's dependants were linted
|
|
138
|
+
* with the change applied and without it; `unavailable` means that could not be done (a
|
|
139
|
+
* failure, or the deadline); `not_applicable` means the file has no dependants this stage
|
|
140
|
+
* can find — the graph holds no edges pointing at it, or it has no logical name.
|
|
141
141
|
*
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
142
|
+
* `disabled` means this SERVER was started with cross-file impact turned off (`--no-impact`),
|
|
143
|
+
* so nothing was attempted and retrying will not change that — which is the whole reason it
|
|
144
|
+
* is not folded into `unavailable`.
|
|
145
|
+
*
|
|
146
|
+
* NONE OF THE FOUR IS A CLEARANCE, including `computed`. See {@link ValidateCodeImpact}.
|
|
146
147
|
*/
|
|
147
|
-
export type ValidateCodeImpactStatus = 'computed' | 'unavailable' | 'not_applicable';
|
|
148
|
+
export type ValidateCodeImpactStatus = 'computed' | 'unavailable' | 'not_applicable' | 'disabled';
|
|
148
149
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* with the changeset
|
|
150
|
+
* What this change BREAKS in files the request is not editing — the question lint cannot
|
|
151
|
+
* answer, being per-file and forward-looking. Derived per request by linting the edited
|
|
152
|
+
* file's dependants twice, with the changeset applied and without it, and reporting only the
|
|
153
|
+
* difference. The findings are the check engine's own, so they carry its message, severity
|
|
154
|
+
* and documentation link rather than a second opinion invented here.
|
|
152
155
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* the
|
|
156
|
+
* IT DOES NOT ANSWER "WHO DEPENDS ON THIS FILE", and never will: `{% render var %}` names its
|
|
157
|
+
* target at runtime, so no static analysis can enumerate a file's dependants, and a count
|
|
158
|
+
* that omits the ones it cannot see reads as a licence to delete. Only findings are
|
|
159
|
+
* published; their absence claims nothing.
|
|
156
160
|
*/
|
|
157
161
|
export interface ValidateCodeImpact {
|
|
158
|
-
scope: 'direct';
|
|
159
162
|
status: ValidateCodeImpactStatus;
|
|
160
|
-
dependents: {
|
|
161
|
-
/** Number of distinct files that reference the edited file. */
|
|
162
|
-
total: number;
|
|
163
|
-
/** Distinct referencing files per edge kind (render/include/function/…); a file using two kinds counts in both. */
|
|
164
|
-
by_kind: Record<string, number>;
|
|
165
|
-
/** Up to 10 distinct referencing files, project-relative, sorted. */
|
|
166
|
-
sample: string[];
|
|
167
|
-
};
|
|
168
163
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
164
|
+
* Files the change broke, each with the findings it GAINED. A finding already present
|
|
165
|
+
* before the change is not reported, however severe: it is not this edit's doing.
|
|
166
|
+
*
|
|
167
|
+
* PRESENT ONLY WHEN NON-EMPTY, and absence is not a clearance — it means no break was
|
|
168
|
+
* found among the dependants that are visible, which is not the same as none existing.
|
|
169
|
+
*/
|
|
170
|
+
breaks?: ValidateCodeImpactBreak[];
|
|
171
|
+
/**
|
|
172
|
+
* Present only when more dependants were found than the bound allows: `returned` were
|
|
173
|
+
* linted, `total` were found.
|
|
174
|
+
*
|
|
175
|
+
* NOT the same kind of truncation as the result-level `truncated`, which withholds
|
|
176
|
+
* findings that WERE computed. The difference here was never checked at all, so an
|
|
177
|
+
* otherwise-clean answer carrying this field is a PARTIAL one.
|
|
173
178
|
*/
|
|
174
|
-
|
|
179
|
+
unchecked_dependants?: ValidateCodeBucketTruncation;
|
|
175
180
|
}
|
|
176
|
-
/** One
|
|
177
|
-
export interface
|
|
178
|
-
/** The
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
/** Arguments the caller passes that the `{% doc %}` block does not declare. */
|
|
183
|
-
unexpected_args: string[];
|
|
181
|
+
/** One file the change broke, and what it broke in it. */
|
|
182
|
+
export interface ValidateCodeImpactBreak {
|
|
183
|
+
/** The broken file, project-relative. NOT a file the request asked about. */
|
|
184
|
+
file: string;
|
|
185
|
+
/** The findings this change introduced there, in reading order. */
|
|
186
|
+
diagnostics: ValidateCodeDiagnostic[];
|
|
184
187
|
}
|
|
185
188
|
/** How many entries of one bucket were returned, against how many were found. */
|
|
186
189
|
export interface ValidateCodeBucketTruncation {
|
|
@@ -215,9 +218,8 @@ export interface ValidateCodeResult {
|
|
|
215
218
|
warnings: ValidateCodeDiagnostic[];
|
|
216
219
|
infos: ValidateCodeDiagnostic[];
|
|
217
220
|
/**
|
|
218
|
-
* Cross-file
|
|
219
|
-
*
|
|
220
|
-
* See {@link ValidateCodeImpact}.
|
|
221
|
+
* Cross-file consequence of the edit: what it breaks in files you are not editing.
|
|
222
|
+
* Always present, and never a clearance. See {@link ValidateCodeImpact}.
|
|
221
223
|
*/
|
|
222
224
|
impact: ValidateCodeImpact;
|
|
223
225
|
/** Deterministic prose telling the agent what to do next. */
|
|
@@ -102,10 +102,26 @@ truncated
|
|
|
102
102
|
what is listed and validate again to see the rest.
|
|
103
103
|
|
|
104
104
|
impact
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the
|
|
105
|
+
What this change BREAKS in files you are NOT editing — the one thing a per-file lint cannot
|
|
106
|
+
see, since it only ever looks at the buffers you sent. Each entry names the file and
|
|
107
|
+
carries the check's own findings: same message, same severity, same documentation link, and
|
|
108
|
+
the same fixes, exactly as if you had validated that file yourself.
|
|
109
|
+
It reports only what your change INTRODUCED. A problem that file already had is never
|
|
110
|
+
listed, however severe — it is not this edit's doing, and it would bury the one that is.
|
|
111
|
+
A break in someone else's file does NOT set must_fix_before_write: your buffer may be
|
|
112
|
+
perfectly correct. Fix the callers, or decide not to; that is yours to weigh.
|
|
113
|
+
status computed the dependants were linted with your change and without it
|
|
114
|
+
not_applicable nothing can reference this file by name
|
|
115
|
+
unavailable could not be run: a failure, a deadline, or a file referenced from
|
|
116
|
+
more text than can be examined in time
|
|
117
|
+
disabled this server runs with --no-impact, so nothing was attempted
|
|
118
|
+
unchecked_dependants appears when a file has more dependants than one request will lint.
|
|
119
|
+
Those were never checked, so an otherwise-clean answer carrying it is a PARTIAL one.
|
|
120
|
+
NOTHING HERE IS A CLEARANCE. An empty impact means no break was found among the dependants
|
|
121
|
+
that are VISIBLE, and a dependant can be invisible: {% render partial_name %} picks its
|
|
122
|
+
target at runtime, so no analysis can resolve it.
|
|
123
|
+
This server never tells you nothing depends on a file — if you are about to delete or
|
|
124
|
+
rename one, search the project yourself.
|
|
109
125
|
|
|
110
126
|
WHAT IS NOT CHECKED
|
|
111
127
|
${coverage()} run against your buffer. Where a finding exists it explains itself and links
|
|
@@ -118,8 +134,10 @@ as finding it correct, and where no diagnostic can tell you so.
|
|
|
118
134
|
rather than refuse. {{ 5 | upcase }} renders and is not reported. That silence is the
|
|
119
135
|
documentation's answer, not a guarantee the value is right.
|
|
120
136
|
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
A model schema's property TYPE is checked and an unknown one is reported. The rest of a
|
|
138
|
+
schema's shape is not: an unrecognised top-level key is rejected on deploy and nothing
|
|
139
|
+
reports it, so silence there is not a claim the key is valid. A duplicated property name
|
|
140
|
+
IS accepted by the platform.
|
|
123
141
|
|
|
124
142
|
Duplicate YAML keys are compared the way the platform's own parser resolves them, so
|
|
125
143
|
yes: and true: in one mapping are reported as the one key they become. That comparison
|
|
@@ -16,6 +16,11 @@ export interface ServerOptions {
|
|
|
16
16
|
log?: Logger;
|
|
17
17
|
/** Advertised server version. */
|
|
18
18
|
version?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether cross-file impact runs. Defaults to ON — only an explicit `false`, from
|
|
21
|
+
* `--no-impact` or `POS_SUPERVISOR_NO_IMPACT`, turns it off.
|
|
22
|
+
*/
|
|
23
|
+
impactEnabled?: boolean;
|
|
19
24
|
}
|
|
20
25
|
export interface ServerHandle {
|
|
21
26
|
server: McpServer;
|
package/dist/transport/server.js
CHANGED
|
@@ -17,10 +17,14 @@ const DEFAULT_VERSION = '0.0.1';
|
|
|
17
17
|
export async function startServer(opts) {
|
|
18
18
|
const log = opts.log ?? createLogger(SERVER_NAME);
|
|
19
19
|
// NOTHING IS BUILT OR WARMED HERE, by design. check-node owns one lazy `App` per project
|
|
20
|
-
// at process level and reconciles it per call, and
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
const context = {
|
|
20
|
+
// at process level and reconciles it per call, and impact is derived per request from
|
|
21
|
+
// the project's text (`impact/project-scan.ts`) — so there is no graph to build at boot,
|
|
22
|
+
// nothing to keep fresh, and no "still computing" answer.
|
|
23
|
+
const context = {
|
|
24
|
+
projectDir: opts.projectDir,
|
|
25
|
+
log,
|
|
26
|
+
impactEnabled: opts.impactEnabled,
|
|
27
|
+
};
|
|
24
28
|
// `instructions` reaches the model with the tool list; without them an agent has only the
|
|
25
29
|
// tool description to go on and invents a reading of the result (see instructions.ts).
|
|
26
30
|
const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
@@ -22,6 +22,23 @@ import type { SupervisorContext } from '../context.js';
|
|
|
22
22
|
import type { ValidateCodeParams, ValidateCodeResult, ValidateFilesResult } from '../result/types.js';
|
|
23
23
|
import { type ValidateAdapters } from '../validate/validate-buffers.js';
|
|
24
24
|
import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
|
|
25
|
+
/**
|
|
26
|
+
* zod raw shape for the tool input (validated by the MCP SDK before dispatch).
|
|
27
|
+
*
|
|
28
|
+
* Both forms are optional at the schema level and the handler requires exactly one,
|
|
29
|
+
* because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
|
|
30
|
+
* — it emits `anyOf`, which several MCP clients do not surface to the model at all.
|
|
31
|
+
* Two optional fields with a documented rule converts cleanly and reads clearly.
|
|
32
|
+
*
|
|
33
|
+
* WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
|
|
34
|
+
* the arguments arrives as the JSON Schema this shape converts to, so a constraint
|
|
35
|
+
* that does not convert may as well not be advertised at all. Prefer a zod builtin
|
|
36
|
+
* over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
|
|
37
|
+
* pins the emitted document end to end.
|
|
38
|
+
*
|
|
39
|
+
* Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
|
|
40
|
+
* instantiate excessively deeply over the schema (TS2589 under zod 3.25).
|
|
41
|
+
*/
|
|
25
42
|
export declare const VALIDATE_CODE_INPUT: ZodRawShape;
|
|
26
43
|
/**
|
|
27
44
|
* The agent-facing prose, exported so tests can pin claims that must not rot. The
|
|
@@ -22,22 +22,29 @@ import { validateBuffers, } from '../validate/validate-buffers.js';
|
|
|
22
22
|
import { MAX_BATCH_BYTES, MAX_BATCH_FILES, batchTooLarge } from '../validate/batch-bounds.js';
|
|
23
23
|
import { collidingBufferPaths } from '../validate/batch-coherence.js';
|
|
24
24
|
import { assembleNotApplicableResult } from '../result/assemble.js';
|
|
25
|
-
/** A non-blank path. Blank is malformed input, refused at the protocol boundary. */
|
|
26
|
-
const filePath = z
|
|
27
|
-
.string()
|
|
28
|
-
.refine((value) => value.trim().length > 0, { message: 'file_path must not be empty' });
|
|
29
|
-
const content = z.string();
|
|
30
25
|
/**
|
|
31
|
-
*
|
|
26
|
+
* A non-blank path. Blank is malformed input, refused at the protocol boundary.
|
|
32
27
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
28
|
+
* BOTH halves are load-bearing and neither is redundant. The refinement is what
|
|
29
|
+
* refuses a whitespace-only path; `.min(1)` is the half that SURVIVES conversion to
|
|
30
|
+
* JSON Schema, as `minLength`. A refinement converts to nothing at all, so with the
|
|
31
|
+
* refinement alone the rule exists only on this side of the wire — the model is
|
|
32
|
+
* never told, sends `""`, and learns the rule from a rejection. Drop either half and
|
|
33
|
+
* a test fails: the emitted schema loses `minLength`, or `' '` starts being accepted.
|
|
37
34
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* A FUNCTION, not a shared constant, for the same wire-level reason: the SDK's
|
|
36
|
+
* converter dedupes by schema IDENTITY, so one instance reused across both input
|
|
37
|
+
* forms emits `{ $ref: '#/properties/file_path' }` for the second use. That drags the
|
|
38
|
+
* single-file description into the batch entries, where "Pair with `content`" is
|
|
39
|
+
* wrong, and puts a `$ref` in a tool inputSchema — which not every MCP client
|
|
40
|
+
* resolves. A fresh instance per use costs nothing and emits in full.
|
|
40
41
|
*/
|
|
42
|
+
const filePath = () => z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.refine((value) => value.trim().length > 0, { message: 'file_path must not be empty' });
|
|
46
|
+
/** File contents. Empty is legal — an empty file is a file. Fresh per use, as above. */
|
|
47
|
+
const content = () => z.string();
|
|
41
48
|
/**
|
|
42
49
|
* An example path of each kind, for the agent-facing prose below.
|
|
43
50
|
*
|
|
@@ -52,16 +59,36 @@ const exampleOf = (type, name) => `${getAppPaths(type)[0]}/${name}.liquid`;
|
|
|
52
59
|
const EXAMPLE_PARTIAL = exampleOf(PlatformOSFileType.Partial, 'card');
|
|
53
60
|
const EXAMPLE_PAGE = exampleOf(PlatformOSFileType.Page, 'home');
|
|
54
61
|
const EXAMPLE_PROMO = exampleOf(PlatformOSFileType.Partial, 'promo');
|
|
62
|
+
/**
|
|
63
|
+
* zod raw shape for the tool input (validated by the MCP SDK before dispatch).
|
|
64
|
+
*
|
|
65
|
+
* Both forms are optional at the schema level and the handler requires exactly one,
|
|
66
|
+
* because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
|
|
67
|
+
* — it emits `anyOf`, which several MCP clients do not surface to the model at all.
|
|
68
|
+
* Two optional fields with a documented rule converts cleanly and reads clearly.
|
|
69
|
+
*
|
|
70
|
+
* WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
|
|
71
|
+
* the arguments arrives as the JSON Schema this shape converts to, so a constraint
|
|
72
|
+
* that does not convert may as well not be advertised at all. Prefer a zod builtin
|
|
73
|
+
* over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
|
|
74
|
+
* pins the emitted document end to end.
|
|
75
|
+
*
|
|
76
|
+
* Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
|
|
77
|
+
* instantiate excessively deeply over the schema (TS2589 under zod 3.25).
|
|
78
|
+
*/
|
|
55
79
|
export const VALIDATE_CODE_INPUT = {
|
|
56
|
-
file_path: filePath
|
|
80
|
+
file_path: filePath()
|
|
57
81
|
.optional()
|
|
58
82
|
.describe('Path of the file to validate, absolute or relative to the project root ' +
|
|
59
83
|
`(e.g. "${EXAMPLE_PARTIAL}"). Pair with \`content\`.`),
|
|
60
|
-
content: content
|
|
84
|
+
content: content()
|
|
61
85
|
.optional()
|
|
62
86
|
.describe('The exact file contents you are about to write. Pair with `file_path`.'),
|
|
63
87
|
files: z
|
|
64
|
-
.array(z.object({
|
|
88
|
+
.array(z.object({
|
|
89
|
+
file_path: filePath().describe('Path of THIS file, absolute or relative to the project root.'),
|
|
90
|
+
content: content().describe('The exact contents you are about to write to THIS file.'),
|
|
91
|
+
}))
|
|
65
92
|
.min(1)
|
|
66
93
|
.max(MAX_BATCH_FILES)
|
|
67
94
|
.optional()
|
|
@@ -138,9 +165,20 @@ export async function runValidateCode(ctx, params, overrides = {}) {
|
|
|
138
165
|
}
|
|
139
166
|
const { buffers, isBatch } = requested;
|
|
140
167
|
ctx.log(isBatch ? `validate_code: ${buffers.length} file(s)` : `validate_code: ${buffers[0].filePath}`);
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
168
|
+
// Request-level refusals, decided before any work starts. First refusal wins; the cheaper
|
|
169
|
+
// check runs first.
|
|
170
|
+
//
|
|
171
|
+
// GATED ON MORE THAN ONE BUFFER, not on which form carried it. One file is one file, and
|
|
172
|
+
// the two forms must answer identically — the shape an agent happened to reach for cannot
|
|
173
|
+
// change a verdict. Both refusals ask a question a lone buffer cannot answer YES to:
|
|
174
|
+
// collision needs two entries naming one file, and the caps exist because a batch can hand
|
|
175
|
+
// the server work no single buffer could. Collision was already a no-op at one buffer; the
|
|
176
|
+
// total-byte cap was NOT, and it fired FIRST — so a 300 KiB file sent as a one-file batch
|
|
177
|
+
// was refused with "split it into smaller batches", advice its caller cannot follow, and
|
|
178
|
+
// was never told MAX_BUFFER_BYTES, the bound it actually had to get under. Whether ONE
|
|
179
|
+
// buffer is admissible is `bufferTooLarge`'s question, asked per buffer inside
|
|
180
|
+
// `validateBuffers` for both forms alike.
|
|
181
|
+
const refusal = buffers.length > 1
|
|
144
182
|
? (batchTooLarge(buffers) ?? collidingBufferPaths(ctx.projectDir, buffers))
|
|
145
183
|
: undefined;
|
|
146
184
|
if (refusal) {
|
|
@@ -39,6 +39,11 @@ export const MAX_BATCH_BYTES = maxBytesWithin(MAX_LINT_DEADLINE_MS);
|
|
|
39
39
|
* validating a subset would report a changeset as checked when it was not.
|
|
40
40
|
*/
|
|
41
41
|
export function batchTooLarge(buffers) {
|
|
42
|
+
// UNREACHABLE OVER MCP — `VALIDATE_CODE_INPUT.files` caps the array with
|
|
43
|
+
// `.max(MAX_BATCH_FILES)` first, and `runValidateCode` is not on the package's public
|
|
44
|
+
// surface — so zero coverage here is the schema working, not a gap. Kept as defence-in-depth
|
|
45
|
+
// because only this bound lives inside the function that promises it, and asked FIRST
|
|
46
|
+
// because splitting a request by bytes can still leave too many files.
|
|
42
47
|
if (buffers.length > MAX_BATCH_FILES) {
|
|
43
48
|
return {
|
|
44
49
|
code: 'too_large',
|
|
@@ -17,11 +17,12 @@ import { enrichDiagnostics } from '../enrich/enrich.js';
|
|
|
17
17
|
import { lintDeadlineMs } from '../cost-model.js';
|
|
18
18
|
import { TIMED_OUT, withDeadline } from '../deadline.js';
|
|
19
19
|
import { runImpact } from '../impact/impact.js';
|
|
20
|
+
import { canHaveDependants } from '../impact/dependants.js';
|
|
20
21
|
import { createProjectScan } from '../impact/project-scan.js';
|
|
21
22
|
import { runBatchLint, } from '../lint/lint-batch.js';
|
|
22
23
|
import { assembleNotApplicableResult, assembleResult } from '../result/assemble.js';
|
|
23
24
|
import { capToBudget } from '../result/response-budget.js';
|
|
24
|
-
import { UNAVAILABLE_IMPACT } from '../result/impact-states.js';
|
|
25
|
+
import { DISABLED_IMPACT, UNAVAILABLE_IMPACT } from '../result/impact-states.js';
|
|
25
26
|
/** Read the process-wide docset and flatten it to the arrays enrichment takes. */
|
|
26
27
|
async function resolveDocset() {
|
|
27
28
|
const docset = getPlatformOSDocset();
|
|
@@ -65,11 +66,26 @@ export async function validateBuffers(ctx, buffers, overrides = {}) {
|
|
|
65
66
|
// Computed ONCE and threaded to both the timer and the message that reports it, so the
|
|
66
67
|
// two cannot disagree about the deadline a caller was held to.
|
|
67
68
|
const lintDeadline = lintDeadlineMs(admittedBytes(lintable));
|
|
68
|
-
//
|
|
69
|
-
|
|
69
|
+
// The project SCAN overlaps the primary lint; the impact LINT PASSES cannot.
|
|
70
|
+
//
|
|
71
|
+
// `lintBuffers` overlays buffers into check-node's process-shared `App` and reverts them
|
|
72
|
+
// on the way out, and that App has no lock — so two lint passes running at once would
|
|
73
|
+
// corrupt each other. The scan is pure filesystem I/O and touches no App, which is what
|
|
74
|
+
// lets the expensive half of impact hide behind the lint anyway.
|
|
75
|
+
const scan = projectScan(ctx, lintable);
|
|
76
|
+
const [lint] = await Promise.all([
|
|
70
77
|
lintWithDeadline(ctx, adapters, lintable, lintDeadline),
|
|
71
|
-
|
|
78
|
+
scan.warm(),
|
|
72
79
|
]);
|
|
80
|
+
// AFTER the primary lint, and never allowed to break it: the write gate is already
|
|
81
|
+
// decided by this point, so impact can only add to the answer or fail quietly.
|
|
82
|
+
//
|
|
83
|
+
// Turned off for this SERVER (`--no-impact`) it costs NOTHING: `projectScan` declines to
|
|
84
|
+
// read, and the two extra lint passes never start. `disabled` is its own status precisely
|
|
85
|
+
// so an agent does not read it as a failure worth retrying.
|
|
86
|
+
const impacts = ctx.impactEnabled === false
|
|
87
|
+
? new Map(lintable.map((buffer) => [buffer.filePath, DISABLED_IMPACT()]))
|
|
88
|
+
: await impactWithDeadline(ctx, adapters, lintable, scan.scan);
|
|
73
89
|
// The lint pass is what knows which buffers were not checked and why — it holds the
|
|
74
90
|
// config AND the classifier. Folding its answer in here keeps ONE source of truth for
|
|
75
91
|
// "is this file part of the app".
|
|
@@ -187,45 +203,83 @@ function admittedBytes(lintable) {
|
|
|
187
203
|
return lintable.reduce((total, buffer) => total + Buffer.byteLength(buffer.content, 'utf8'), 0);
|
|
188
204
|
}
|
|
189
205
|
/**
|
|
190
|
-
*
|
|
206
|
+
* Impact per lintable buffer.
|
|
191
207
|
*
|
|
192
|
-
* ONE scan serves the whole batch: `createProjectScan` reads the project's edge sources
|
|
193
|
-
*
|
|
194
|
-
*
|
|
208
|
+
* ONE scan serves the whole batch: `createProjectScan` reads the project's edge sources once,
|
|
209
|
+
* LAZILY and memoized, so a batch whose buffers can have no dependants never reads the project
|
|
210
|
+
* at all, and one where some can pays a single read. The batch's own buffers are overlaid into
|
|
211
|
+
* it, so a call a buffer has just added counts.
|
|
195
212
|
*
|
|
196
213
|
* The set shares one tight deadline because impact is discardable enrichment —
|
|
197
214
|
* `unavailable` already means "we don't know".
|
|
198
215
|
*/
|
|
199
|
-
async function impactWithDeadline(ctx, adapters, lintable) {
|
|
216
|
+
async function impactWithDeadline(ctx, adapters, lintable, scan) {
|
|
200
217
|
const byFile = new Map();
|
|
201
218
|
if (lintable.length === 0)
|
|
202
219
|
return byFile;
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
const work = adapters
|
|
221
|
+
.impact({
|
|
222
|
+
projectDir: ctx.projectDir,
|
|
223
|
+
buffers: lintable,
|
|
224
|
+
scan,
|
|
225
|
+
lint: adapters.lint,
|
|
226
|
+
docset: adapters.docset,
|
|
227
|
+
log: (message) => ctx.log(`validate_code: ${message}`),
|
|
228
|
+
})
|
|
229
|
+
.catch((error) => {
|
|
230
|
+
// ONE failure covers the whole changeset: the passes are shared, so a failure says
|
|
231
|
+
// nothing about any individual buffer.
|
|
232
|
+
ctx.log(`validate_code: impact failed: ${describe(error)}`);
|
|
233
|
+
return new Map();
|
|
234
|
+
});
|
|
217
235
|
const outcome = await withDeadline(work, IMPACT_DEADLINE_MS);
|
|
218
236
|
if (outcome === TIMED_OUT) {
|
|
219
|
-
observeAbandoned(ctx, work, '
|
|
220
|
-
ctx.log(`validate_code:
|
|
237
|
+
observeAbandoned(ctx, work, 'impact');
|
|
238
|
+
ctx.log(`validate_code: impact exceeded ${IMPACT_DEADLINE_MS} ms, continuing without it`);
|
|
221
239
|
for (const buffer of lintable)
|
|
222
240
|
byFile.set(buffer.filePath, UNAVAILABLE_IMPACT());
|
|
223
241
|
return byFile;
|
|
224
242
|
}
|
|
225
|
-
for
|
|
226
|
-
|
|
243
|
+
// A buffer the pass did not answer for gets `unavailable` rather than a silent absence:
|
|
244
|
+
// `resultFor` would otherwise default it, and "we did not look" must not read as "we
|
|
245
|
+
// looked and found nothing".
|
|
246
|
+
for (const buffer of lintable) {
|
|
247
|
+
byFile.set(buffer.filePath, outcome.get(buffer.filePath) ?? UNAVAILABLE_IMPACT());
|
|
248
|
+
}
|
|
227
249
|
return byFile;
|
|
228
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* The request's project scan, plus a handle to start reading it.
|
|
253
|
+
*
|
|
254
|
+
* Split so the read can be STARTED alongside the primary lint and awaited later: it is the
|
|
255
|
+
* expensive half of impact (~235 ms on a 2,615-file project) and pure filesystem I/O, so
|
|
256
|
+
* overlapping it with the lint's CPU costs nothing and races nothing.
|
|
257
|
+
*/
|
|
258
|
+
function projectScan(ctx, lintable) {
|
|
259
|
+
const rootUri = pathUtils.normalize(pathUtils.toUri(ctx.projectDir));
|
|
260
|
+
const scan = createProjectScan(rootUri, NodeFileSystem, new Map(lintable.map((buffer) => [
|
|
261
|
+
pathUtils.normalize(pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, buffer.filePath))),
|
|
262
|
+
buffer.content,
|
|
263
|
+
])));
|
|
264
|
+
// Nothing in this changeset can HAVE dependants — every buffer is a YAML file, or sits in
|
|
265
|
+
// no platformOS directory — so impact will never consult the scan and reading the project
|
|
266
|
+
// would be pure waste. Decidable from the paths alone, before any I/O.
|
|
267
|
+
//
|
|
268
|
+
// A PERFORMANCE GUARD, NOT A CORRECTNESS ONE, which is why six mutants survive on it.
|
|
269
|
+
// `warm()` only PRE-warms: `scan.sources()` memoizes its promise and impact awaits that
|
|
270
|
+
// same promise itself, so inverting this buys a wasted read or a colder path, never a
|
|
271
|
+
// different answer. Nothing pins it — nor the `--no-impact` claim above, where what is
|
|
272
|
+
// tested is that the impact ADAPTER goes uncalled, not that the project read is skipped.
|
|
273
|
+
const worthReading = ctx.impactEnabled !== false &&
|
|
274
|
+
lintable.some((buffer) => canHaveDependants(pathUtils.normalize(pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, buffer.filePath))), rootUri));
|
|
275
|
+
return {
|
|
276
|
+
scan,
|
|
277
|
+
// A failed read is impact's problem, not the lint's: swallowed here so it cannot reject
|
|
278
|
+
// the `Promise.all` that the primary lint is riding in, and surfaced when impact awaits
|
|
279
|
+
// the same memoized promise itself.
|
|
280
|
+
warm: () => (worthReading ? scan.sources().catch(() => undefined) : Promise.resolve()),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
229
283
|
/**
|
|
230
284
|
* Attach a handler to work the deadline abandoned.
|
|
231
285
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformos/platformos-mcp-supervisor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
-
"@platformos/liquid-html-parser": "0.
|
|
47
|
-
"@platformos/platformos-check-common": "1.
|
|
48
|
-
"@platformos/platformos-check-node": "1.
|
|
49
|
-
"@platformos/platformos-common": "0.
|
|
50
|
-
"@platformos/platformos-graph": "0.
|
|
51
|
-
"@platformos/platformos-language-server-common": "0.1.
|
|
46
|
+
"@platformos/liquid-html-parser": "0.2.0",
|
|
47
|
+
"@platformos/platformos-check-common": "1.1.0",
|
|
48
|
+
"@platformos/platformos-check-node": "1.1.0",
|
|
49
|
+
"@platformos/platformos-common": "0.2.0",
|
|
50
|
+
"@platformos/platformos-graph": "0.2.0",
|
|
51
|
+
"@platformos/platformos-language-server-common": "0.1.1",
|
|
52
52
|
"zod": "^3.23.8"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {}
|