@platformos/platformos-mcp-supervisor 0.1.0 → 0.2.1

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.
@@ -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
- // Concurrently: the lint is the long pole and impact must hide behind it.
69
- const [lint, impacts] = await Promise.all([
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
- impactWithDeadline(ctx, adapters, lintable),
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
- * Blast radius per lintable buffer.
206
+ * Impact per lintable buffer.
191
207
  *
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.
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 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
- }));
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, 'blast-radius');
220
- ctx.log(`validate_code: blast-radius exceeded ${IMPACT_DEADLINE_MS} ms, continuing without it`);
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 (const [filePath, impact] of outcome)
226
- byFile.set(filePath, impact);
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.1.0",
3
+ "version": "0.2.1",
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",
@@ -26,9 +26,7 @@
26
26
  "access": "public",
27
27
  "@platformos:registry": "https://registry.npmjs.org"
28
28
  },
29
- "bin": {
30
- "platformos-mcp-supervisor": "dist/bin/platformos-mcp-supervisor.js"
31
- },
29
+ "bin": "dist/bin/platformos-mcp-supervisor.js",
32
30
  "files": [
33
31
  "dist/**/*.js",
34
32
  "dist/**/*.d.ts"
@@ -42,14 +40,17 @@
42
40
  "type-check": "tsc --noEmit"
43
41
  },
44
42
  "dependencies": {
45
- "@modelcontextprotocol/sdk": "^1.29.0",
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",
52
- "zod": "^3.23.8"
43
+ "@modelcontextprotocol/sdk": "^1.32.2",
44
+ "@platformos/liquid-html-parser": "0.2.0",
45
+ "@platformos/platformos-check-common": "1.2.0",
46
+ "@platformos/platformos-check-node": "1.2.0",
47
+ "@platformos/platformos-common": "0.2.0",
48
+ "@platformos/platformos-graph": "0.2.1",
49
+ "@platformos/platformos-language-server-common": "0.1.2",
50
+ "zod": "^4.6.7"
53
51
  },
54
- "devDependencies": {}
55
- }
52
+ "devDependencies": {
53
+ "typescript": "^5.9.0",
54
+ "vitest": "^4.0.18"
55
+ }
56
+ }