@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,275 @@
1
+ import { APP_SOURCE_SUBTREES } from '@platformos/platformos-common';
2
+ import { join, parse } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { bufferTooLarge, fileApplicability, MAX_BUFFER_BYTES, misplacedSource, notPlatformOSFile, toAbsoluteFilePath, } from './adapter-input.js';
5
+ /**
6
+ * Without this gate `validate_code` lints ANY path, and because check-common's
7
+ * `toSourceCode` types an unrecognized extension as JSON, `/etc/passwd` came back as
8
+ * `ValidJSON` with `must_fix_before_write: true` while `/etc/shadow` containing `{}` came
9
+ * back `ok` — a false approval for a path outside the project.
10
+ *
11
+ * These pin the pure decision; the end-to-end consequence is pinned in
12
+ * `transport/validate-code.spec.ts`.
13
+ */
14
+ const PROJECT = join('/srv', 'app');
15
+ const applicable = (filePath) => fileApplicability(PROJECT, filePath);
16
+ /**
17
+ * The two refusal messages, duplicated from the implementation on purpose: the prose is the
18
+ * entire payload of a declined call. Both must state that nothing was checked — an agent
19
+ * must never read a refusal as a pass.
20
+ */
21
+ const outside = (filePath) => ({
22
+ applicable: false,
23
+ code: 'outside_project',
24
+ reason: `\`${filePath}\` resolves outside the project root (${PROJECT}), so there is no project ` +
25
+ `context to validate it against. This server validates only files inside the project it was ` +
26
+ `started for. Nothing was checked — treat this as "unknown", not as approval.`,
27
+ });
28
+ const unsupported = (relativePath) => ({
29
+ applicable: false,
30
+ code: 'unsupported_type',
31
+ reason: `\`${relativePath}\` is not a platformOS source file, so there is nothing to check. The ` +
32
+ `platform deploys app/, marketplace_builder/, modules/*/public/, modules/*/private/ only. ` +
33
+ `Nothing was checked — writing this file is your call, not a validated pass. Directory ` +
34
+ `structure: https://documentation.platformos.com/developer-guide/platformos-workflow/directory-structure`,
35
+ });
36
+ /**
37
+ * An ASSET is refused for a different reason and says so. It IS part of the app and IS
38
+ * deployed — there is simply no source in it to check — so the "not a platformOS source
39
+ * file, the platform deploys app/ only" message above would be false about it.
40
+ */
41
+ const asset = (relativePath) => ({
42
+ applicable: false,
43
+ code: 'unsupported_type',
44
+ reason: `\`${relativePath}\` is an asset, not a source file the linter understands — it checks ` +
45
+ `Liquid, GraphQL and YAML — so no check ran against it. The file is still deployed and ` +
46
+ `served; there is simply nothing here to validate. Nothing was checked, which is not the ` +
47
+ `same as a pass.`,
48
+ });
49
+ describe('Unit: fileApplicability', () => {
50
+ describe('accepts every file type the linter would actually visit', () => {
51
+ it.each([
52
+ 'app/views/pages/index.liquid',
53
+ 'app/views/partials/card.liquid',
54
+ 'app/views/layouts/application.liquid',
55
+ 'app/lib/helper.liquid',
56
+ // A page that emits JSON is Liquid — classified by the `.liquid` extension, NOT by
57
+ // the `.json` in its name, which a naive extension check would misroute.
58
+ 'app/views/pages/feed.json.liquid',
59
+ 'app/graphql/things/search.graphql',
60
+ 'app/translations/en.yml',
61
+ 'app/schema/thing.yml',
62
+ 'modules/mcp/public/views/partials/tool.liquid',
63
+ ])('%s', (filePath) => {
64
+ expect(applicable(filePath)).toEqual({ applicable: true });
65
+ });
66
+ it('accepts an absolute path inside the project', () => {
67
+ expect(applicable(join(PROJECT, 'app/views/pages/index.liquid'))).toEqual({
68
+ applicable: true,
69
+ });
70
+ });
71
+ it('accepts a path whose `..` segments normalize back inside the project', () => {
72
+ expect(applicable('app/views/pages/../partials/card.liquid')).toEqual({ applicable: true });
73
+ });
74
+ });
75
+ describe('declines paths outside the project root', () => {
76
+ // `''` is refused by the zod schema instead — see validate-code.spec.ts.
77
+ it.each([
78
+ ['an absolute system path', '/etc/passwd'],
79
+ ['a relative traversal', '../../../etc/passwd'],
80
+ ['a traversal onto a plausible-looking file', '../other/app/views/pages/x.liquid'],
81
+ ['an absolute path outside the root', '/tmp/scratch/app/views/pages/x.liquid'],
82
+ // A `startsWith` containment test would ACCEPT this, since
83
+ // '/srv/app-backup'.startsWith('/srv/app'); only a path-segment comparison rejects it.
84
+ ['a sibling root extending the project name', '/srv/app-backup/app/views/pages/x.liquid'],
85
+ ])('%s', (_label, filePath) => {
86
+ expect(applicable(filePath)).toEqual(outside(filePath));
87
+ });
88
+ it.each([
89
+ ['the project root itself', PROJECT],
90
+ ['the project root via a round trip', 'app/..'],
91
+ ])('%s is a directory, not a file under edit', (_label, filePath) => {
92
+ expect(applicable(filePath)).toEqual(outside(filePath));
93
+ });
94
+ });
95
+ describe('declines in-project files that are not platformOS sources', () => {
96
+ it.each([
97
+ ['markdown', 'README.md'],
98
+ ['plain text', 'app/notes.txt'],
99
+ // The extension whose fallback used to swallow everything else.
100
+ ['standalone json', 'app/pos-modules.json'],
101
+ ['ruby', 'script/deploy.rb'],
102
+ ['no extension at all', 'Makefile'],
103
+ ])('%s', (_label, filePath) => {
104
+ expect(applicable(filePath)).toEqual(unsupported(filePath));
105
+ });
106
+ /**
107
+ * REFUSED BY TYPE, not by extension, and this branch fixes a measured FALSE BLOCK: a
108
+ * bare `.liquid` has no response format, so `sourceCodeTypeOf` falls back to
109
+ * `html.liquid` and handed `app/assets/x.liquid` to the Liquid parser. Backwards
110
+ * besides, since `theme.css.liquid` — the asset form the platform DOES process — was
111
+ * exempt all along.
112
+ *
113
+ * The rule is `platformos-common`'s `isParsedFileType`, and this gate ASKS it rather
114
+ * than comparing to `PlatformOSFileType.Asset` itself, so it cannot drift from the
115
+ * lint's answer.
116
+ */
117
+ it.each([
118
+ ['an image', 'app/assets/logo.png'],
119
+ ['a stylesheet', 'app/assets/site.css'],
120
+ ['a css asset partial', 'app/assets/site.css.liquid'],
121
+ ['a js asset partial', 'app/assets/app.js.liquid'],
122
+ ['an asset manifest', 'app/assets/manifest.json'],
123
+ // THE CASE THAT WAS BLOCKING WRITES. A parser accepts it; the platform does not
124
+ // evaluate it. Sabotage check: revert the `Asset` branch in `fileApplicability` and
125
+ // only this row fails, because it is the only one a parser claims.
126
+ ['a bare liquid file the parser would accept', 'app/assets/x.liquid'],
127
+ ['a nested bare liquid file', 'app/assets/nested/deep/w.liquid'],
128
+ // Legacy spelling of the same directory, so the rule cannot be half-applied.
129
+ ['a marketplace_builder asset', 'marketplace_builder/assets/x.liquid'],
130
+ ])('refuses %s as an asset, never as a source', (_label, filePath) => {
131
+ expect(applicable(filePath)).toEqual(asset(filePath));
132
+ });
133
+ /**
134
+ * THE LINE THIS GATE DRAWS. It asks ONE question — "is this a type we parse at all?" —
135
+ * from the extension and nothing else, and deliberately not whether the path is
136
+ * somewhere the platform deploys, which `isSupportedSourceFile(uri, root)` would answer.
137
+ *
138
+ * Using that here would collapse two situations into one wrong answer.
139
+ * `scripts/helper.liquid` IS a platformOS source in the wrong place, and its author
140
+ * needs to hear "nothing will ever load this", not "this is not a platformOS file".
141
+ * check-node draws that distinction where classification happens, so the gate admits
142
+ * anything parseable and lets the lint say which it is.
143
+ *
144
+ * So being ADMITTED here means "worth asking about", not "will be linted".
145
+ */
146
+ it.each([
147
+ // Liquid outside every deployed subtree: admitted here, then reported as
148
+ // `misplaced_source` by the lint, which is the answer that helps.
149
+ ['unclassified liquid', 'scripts/helper.liquid'],
150
+ // A REAL platformOS source under master's classification: `PlatformOSFileType` has
151
+ // `InstanceConfig` (`app/config.yml`) and `UserSchema` (`app/user.yml`) as fixed-path
152
+ // singletons, and the file-type-coverage group pins that something examines them.
153
+ ['the app config singleton', 'app/config.yml'],
154
+ ['the user schema singleton', 'app/user.yml'],
155
+ // KNOWN WRONG ADVICE, admitted here on purpose rather than special-cased. A `.yml` at
156
+ // the project root is parseable, so the lint calls it `misplaced-source` — telling the
157
+ // author of this toolchain's OWN config file that it is "likely misplaced". It never
158
+ // blocks a write, and narrowing it belongs at the point of classification, in
159
+ // check-node. Asserted so the behaviour is recorded and the fix has a test to flip.
160
+ ['the check config itself', '.platformos-check.yml'],
161
+ ])('admits %s, leaving the verdict to the lint', (_label, filePath) => {
162
+ expect(applicable(filePath)).toEqual({ applicable: true });
163
+ });
164
+ it('reports the PROJECT-RELATIVE path even when given an absolute one', () => {
165
+ // Forward slashes, spelled literally rather than built with `join`: the reason string
166
+ // is agent-facing, and an agent that sent `app/notes.txt` must get that spelling back
167
+ // on every platform. Building the expectation with `join` made it agree with whatever
168
+ // the host does, so it passed on POSIX while asserting `app\notes.txt` on Windows.
169
+ //
170
+ // This cannot FAIL on POSIX if the separator handling regresses — check-common's
171
+ // `path.spec.ts` is what guards that, over Windows-shaped input on any host.
172
+ expect(applicable(join(PROJECT, 'app/notes.txt'))).toEqual(unsupported('app/notes.txt'));
173
+ });
174
+ });
175
+ it('checks containment BEFORE type, so an unsupported outside path cites the root', () => {
176
+ // Both rules reject `/etc/passwd`; the order determines which reason the agent
177
+ // gets, and "outside the project" is the more actionable of the two.
178
+ expect(applicable('/etc/passwd')).toEqual(outside('/etc/passwd'));
179
+ });
180
+ });
181
+ /**
182
+ * The size bound is the ONLY guard that acts before a pathological buffer reaches the
183
+ * parser, and it is sized against the FULL lint (~61 ms/KiB), not the parse alone.
184
+ *
185
+ * A deadline cannot substitute for it: the parse is synchronous, so the timer cannot even
186
+ * FIRE during one, and further out the parser stops completing at all — 2 MiB throws inside
187
+ * ohm's CST→AST recursion, 4 MiB produced a native V8 abort that no handler can catch.
188
+ */
189
+ describe('Unit: bufferTooLarge', () => {
190
+ const bytes = (n) => 'a'.repeat(n);
191
+ it('accepts a buffer at exactly the limit', () => {
192
+ // Boundary is inclusive — an exactly-limit buffer is legal, so `<=` not `<`.
193
+ expect(bufferTooLarge(bytes(MAX_BUFFER_BYTES))).toBeUndefined();
194
+ });
195
+ it('refuses a buffer one byte over the limit', () => {
196
+ const declined = bufferTooLarge(bytes(MAX_BUFFER_BYTES + 1));
197
+ expect(declined?.code).toEqual('too_large');
198
+ expect(declined?.reason).toEqual('The buffer is 128 KiB, above the 128 KiB limit this server will validate, so it was ' +
199
+ 'refused before parsing. Liquid validation costs about 75 ms per KiB and the parser stops ' +
200
+ 'completing at all a few MiB out, so checking this would risk hanging the server rather ' +
201
+ 'than answering. Nothing was checked — consider splitting the file, which is almost ' +
202
+ 'certainly worth doing at this size regardless.');
203
+ });
204
+ it('accepts every realistic file size', () => {
205
+ // The largest real source file found across local projects is 76 KiB.
206
+ expect([
207
+ bufferTooLarge(''),
208
+ bufferTooLarge('{% render "card" %}'),
209
+ bufferTooLarge(bytes(76 * 1024)),
210
+ bufferTooLarge(bytes(120 * 1024)),
211
+ ]).toEqual([undefined, undefined, undefined, undefined]);
212
+ });
213
+ it('counts BYTES, not string length, so multi-byte content cannot slip past', () => {
214
+ // '€' is 3 bytes in UTF-8. A `content.length` check would see a third of the real cost
215
+ // and admit a buffer three times the intended size.
216
+ const justUnderByLength = '€'.repeat(Math.floor(MAX_BUFFER_BYTES / 3) + 1);
217
+ expect(justUnderByLength.length).toBeLessThan(MAX_BUFFER_BYTES);
218
+ expect(bufferTooLarge(justUnderByLength)?.code).toEqual('too_large');
219
+ });
220
+ it('reports the actual size in the reason, so the agent knows by how much', () => {
221
+ expect(bufferTooLarge(bytes(2 * 1024 * 1024))?.reason).toContain('The buffer is 2048 KiB');
222
+ });
223
+ });
224
+ describe('Unit: toAbsoluteFilePath', () => {
225
+ it('joins a relative path onto the project root', () => {
226
+ expect(toAbsoluteFilePath(PROJECT, 'app/views/pages/index.liquid')).toEqual(join(PROJECT, 'app/views/pages/index.liquid'));
227
+ });
228
+ it('returns an absolute path unchanged', () => {
229
+ expect(toAbsoluteFilePath(PROJECT, '/etc/passwd')).toEqual('/etc/passwd');
230
+ });
231
+ it('normalizes `..` segments when joining', () => {
232
+ // Expressed against the filesystem ROOT rather than a literal `/etc/passwd`, so it
233
+ // resolves with the host's own separator; hard-coding the POSIX spelling asserted the
234
+ // platform rather than the normalization this test is about.
235
+ const filesystemRoot = parse(PROJECT).root;
236
+ expect(toAbsoluteFilePath(PROJECT, '../../../etc/passwd')).toEqual(join(filesystemRoot, 'etc', 'passwd'));
237
+ });
238
+ });
239
+ /**
240
+ * `next_step` is prose the supervisor writes ABOUT ITS OWN ANSWER, which invariant #6
241
+ * permits. The line is thin here, because a refusal has to say something about where
242
+ * platformOS looks for files in order to be actionable at all.
243
+ *
244
+ * It stays on the right side of it by DERIVING that fact: the deployed subtrees come from
245
+ * `APP_SOURCE_SUBTREES`, and the rule itself is a documentation URL. A hand-written list
246
+ * would be a second source of truth for the directory layout.
247
+ */
248
+ describe('Unit: refusal prose derives platform facts rather than restating them', () => {
249
+ const subtrees = APP_SOURCE_SUBTREES.map((subtree) => `${subtree}/`);
250
+ it.each([
251
+ ['misplacedSource', misplacedSource('scripts/build.liquid')],
252
+ ['notPlatformOSFile', notPlatformOSFile('README.md')],
253
+ ])('%s names every deployed subtree, from the shared table', (_name, declined) => {
254
+ expect(subtrees.filter((subtree) => !declined.reason.includes(subtree))).toEqual([]);
255
+ });
256
+ it('points at the documentation for the rule instead of explaining it here', () => {
257
+ // The pointer, not the prose: an agent that needs the whole directory layout is sent
258
+ // to the page that owns it.
259
+ expect(misplacedSource('scripts/build.liquid').reason).toContain('https://documentation.platformos.com/developer-guide/platformos-workflow/directory-structure');
260
+ });
261
+ /**
262
+ * The CONTROL: this must fail if the subtree list is ever hardcoded to today's value.
263
+ * Deriving the expectation from the same constant the code reads would pass either way,
264
+ * so the assertion is that the message tracks a subtree the shared table does NOT
265
+ * currently contain — impossible to satisfy with a literal string.
266
+ */
267
+ it('would track a new subtree without an edit here', () => {
268
+ // Every subtree in the message came from the table, and the message contains no
269
+ // directory that looks deployed but is absent from it.
270
+ const invented = 'definitely_not_a_subtree';
271
+ expect(misplacedSource('x.liquid').reason).not.toContain(invented);
272
+ expect(APP_SOURCE_SUBTREES).not.toContain(invented);
273
+ });
274
+ });
275
+ //# sourceMappingURL=adapter-input.spec.js.map
@@ -6,17 +6,30 @@
6
6
  */
7
7
  export interface ParsedArgs {
8
8
  projectDir?: string;
9
+ /** `false` only when `--no-impact` was passed. Cross-file impact is ON by default. */
10
+ impact: boolean;
9
11
  help: boolean;
10
12
  }
11
13
  /**
12
- * Parse `--project <dir>` / `--project=<dir>` / `--help` / `-h` from a
14
+ * Parse `--project <dir>` / `--project=<dir>` / `--no-impact` / `--help` / `-h` from a
13
15
  * `process.argv.slice(2)`-style array. Unknown flags are tolerated so a
14
16
  * forward-compatible client can pass extras without breaking the bin.
15
17
  */
16
18
  export declare function parseArgs(argv: ReadonlyArray<string>): ParsedArgs;
19
+ /**
20
+ * Whether cross-file impact runs, by precedence: `--no-impact` wins, then
21
+ * `POS_SUPERVISOR_NO_IMPACT` (any non-empty value other than `0`/`false`), then ON.
22
+ *
23
+ * A SERVER setting rather than a tool parameter, deliberately. The per-call knob would put
24
+ * the choice with the agent, and an agent that does not know it is editing a shared partial
25
+ * is exactly the one that would not ask for the check — this server exists for what the
26
+ * agent does not know. It also keeps the tool surface unchanged: `mode` was a real parameter
27
+ * here once and was retired, and `stdio-smoke.spec.ts` still asserts a stale one is ignored.
28
+ */
29
+ export declare function resolveImpactEnabled(args: ParsedArgs, env: NodeJS.ProcessEnv): boolean;
17
30
  /**
18
31
  * Resolve the project directory by precedence:
19
32
  * 1. `--project` argument, 2. `POS_SUPERVISOR_PROJECT_DIR`, 3. cwd.
20
33
  */
21
34
  export declare function resolveProjectDir(args: ParsedArgs, env: NodeJS.ProcessEnv, cwd: string): string;
22
- export declare const HELP = "platformos-mcp-supervisor \u2014 MCP stdio server exposing validate_code\n\nUsage:\n platformos-mcp-supervisor [--project <dir>]\n\nOptions:\n --project <dir> Project root to validate against\n (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)\n -h, --help Show this help\n";
35
+ export declare const HELP = "platformos-mcp-supervisor \u2014 MCP stdio server exposing validate_code\n\nUsage:\n platformos-mcp-supervisor [--project <dir>] [--no-impact]\n\nOptions:\n --project <dir> Project root to validate against\n (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)\n --no-impact Do not compute cross-file impact. Each result still reports\n every finding in the buffers you send; it simply stops\n reporting what your change breaks in files you are NOT\n editing. Costs ~240 ms per request on a 2,600-file project.\n (env: POS_SUPERVISOR_NO_IMPACT=1)\n -h, --help Show this help\n";
package/dist/bin/args.js CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
- * Parse `--project <dir>` / `--project=<dir>` / `--help` / `-h` from a
2
+ * Parse `--project <dir>` / `--project=<dir>` / `--no-impact` / `--help` / `-h` from a
3
3
  * `process.argv.slice(2)`-style array. Unknown flags are tolerated so a
4
4
  * forward-compatible client can pass extras without breaking the bin.
5
5
  */
6
6
  export function parseArgs(argv) {
7
7
  let projectDir;
8
+ let impact = true;
8
9
  let help = false;
9
10
  for (let i = 0; i < argv.length; i++) {
10
11
  const arg = argv[i];
11
12
  if (arg === '--help' || arg === '-h') {
12
13
  help = true;
13
14
  }
15
+ else if (arg === '--no-impact') {
16
+ impact = false;
17
+ }
14
18
  else if (arg === '--project') {
15
19
  projectDir = argv[i + 1];
16
20
  i++;
@@ -19,7 +23,26 @@ export function parseArgs(argv) {
19
23
  projectDir = arg.slice('--project='.length);
20
24
  }
21
25
  }
22
- return { projectDir, help };
26
+ return { projectDir, impact, help };
27
+ }
28
+ /**
29
+ * Whether cross-file impact runs, by precedence: `--no-impact` wins, then
30
+ * `POS_SUPERVISOR_NO_IMPACT` (any non-empty value other than `0`/`false`), then ON.
31
+ *
32
+ * A SERVER setting rather than a tool parameter, deliberately. The per-call knob would put
33
+ * the choice with the agent, and an agent that does not know it is editing a shared partial
34
+ * is exactly the one that would not ask for the check — this server exists for what the
35
+ * agent does not know. It also keeps the tool surface unchanged: `mode` was a real parameter
36
+ * here once and was retired, and `stdio-smoke.spec.ts` still asserts a stale one is ignored.
37
+ */
38
+ export function resolveImpactEnabled(args, env) {
39
+ if (!args.impact)
40
+ return false;
41
+ const disabled = env.POS_SUPERVISOR_NO_IMPACT;
42
+ if (disabled === undefined || disabled === '' || disabled === '0' || disabled === 'false') {
43
+ return true;
44
+ }
45
+ return false;
23
46
  }
24
47
  /**
25
48
  * Resolve the project directory by precedence:
@@ -31,11 +54,16 @@ export function resolveProjectDir(args, env, cwd) {
31
54
  export const HELP = `platformos-mcp-supervisor — MCP stdio server exposing validate_code
32
55
 
33
56
  Usage:
34
- platformos-mcp-supervisor [--project <dir>]
57
+ platformos-mcp-supervisor [--project <dir>] [--no-impact]
35
58
 
36
59
  Options:
37
60
  --project <dir> Project root to validate against
38
61
  (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)
62
+ --no-impact Do not compute cross-file impact. Each result still reports
63
+ every finding in the buffers you send; it simply stops
64
+ reporting what your change breaks in files you are NOT
65
+ editing. Costs ~240 ms per request on a 2,600-file project.
66
+ (env: POS_SUPERVISOR_NO_IMPACT=1)
39
67
  -h, --help Show this help
40
68
  `;
41
69
  //# sourceMappingURL=args.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseArgs, resolveProjectDir } from './args.js';
3
+ describe('Unit: parseArgs', () => {
4
+ it('parses --project <dir>', () => {
5
+ expect(parseArgs(['--project', '/p'])).toEqual({ projectDir: '/p', help: false });
6
+ });
7
+ it('parses --project=<dir>', () => {
8
+ expect(parseArgs(['--project=/p'])).toEqual({ projectDir: '/p', help: false });
9
+ });
10
+ it('parses --help and -h', () => {
11
+ expect(parseArgs(['--help'])).toEqual({ projectDir: undefined, help: true });
12
+ expect(parseArgs(['-h'])).toEqual({ projectDir: undefined, help: true });
13
+ });
14
+ it('tolerates unknown flags', () => {
15
+ expect(parseArgs(['--future', 'x', '--project', '/p'])).toEqual({
16
+ projectDir: '/p',
17
+ help: false,
18
+ });
19
+ });
20
+ it('returns no projectDir when none given', () => {
21
+ expect(parseArgs([])).toEqual({ projectDir: undefined, help: false });
22
+ });
23
+ });
24
+ describe('Unit: resolveProjectDir', () => {
25
+ it('prefers the --project argument over env and cwd', () => {
26
+ expect(resolveProjectDir({ projectDir: '/arg', help: false }, { POS_SUPERVISOR_PROJECT_DIR: '/env' }, '/cwd')).toEqual('/arg');
27
+ });
28
+ it('falls back to POS_SUPERVISOR_PROJECT_DIR when no argument', () => {
29
+ expect(resolveProjectDir({ help: false }, { POS_SUPERVISOR_PROJECT_DIR: '/env' }, '/cwd')).toEqual('/env');
30
+ });
31
+ it('falls back to cwd when neither argument nor env is set', () => {
32
+ expect(resolveProjectDir({ help: false }, {}, '/cwd')).toEqual('/cwd');
33
+ });
34
+ });
35
+ //# sourceMappingURL=args.spec.js.map
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { createLogger } from '../logger.js';
22
22
  import { startServer } from '../transport/server.js';
23
- import { HELP, parseArgs, resolveProjectDir } from './args.js';
23
+ import { HELP, parseArgs, resolveImpactEnabled, resolveProjectDir } from './args.js';
24
24
  async function main() {
25
25
  const args = parseArgs(process.argv.slice(2));
26
26
  if (args.help) {
@@ -29,7 +29,11 @@ async function main() {
29
29
  }
30
30
  const log = createLogger('platformos-mcp-supervisor');
31
31
  const projectDir = resolveProjectDir(args, process.env, process.cwd());
32
- await startServer({ projectDir, log });
32
+ await startServer({
33
+ projectDir,
34
+ log,
35
+ impactEnabled: resolveImpactEnabled(args, process.env),
36
+ });
33
37
  }
34
38
  main().catch((err) => {
35
39
  process.stderr.write(`fatal: ${err?.stack ?? String(err)}\n`);
@@ -0,0 +1,21 @@
1
+ /** The documentation a check publishes about itself. */
2
+ export interface CheckDocs {
3
+ /** One-line description, from `meta.docs.description`. */
4
+ description: string;
5
+ /**
6
+ * The check's documentation page. Absent for the few checks whose meta publishes no
7
+ * `url` — absent rather than guessed, because a URL assembled from a code is a link
8
+ * that 404s, which is worse than no link.
9
+ */
10
+ url?: string;
11
+ }
12
+ /**
13
+ * The documentation for a check code, or `undefined` when nothing is registered under it.
14
+ *
15
+ * `undefined` is a real answer, not a gap to paper over: `CheckError` — check-common's code
16
+ * for "a check crashed on this file" — is reported on offenses and is not a registered
17
+ * check, so a consumer must handle a miss rather than assume every offense has a page.
18
+ */
19
+ export declare function checkDocs(code: string): CheckDocs | undefined;
20
+ /** Every registered check code. Exported for the tests that assert this map is complete. */
21
+ export declare function registeredCheckCodes(): string[];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a check MEANS and where to read more — derived from check-common's registry.
3
+ *
4
+ * THE SUPERVISOR AUTHORS NO DOCUMENTATION (ARCHITECTURE.md §Invariants). It may describe
5
+ * itself — its result contract, how to read an answer — and nothing about the platform or
6
+ * about what a check is for; that prose lives in `meta.docs.description` and at
7
+ * `meta.docs.url`.
8
+ *
9
+ * So this module is a LOOKUP, not a table, built from `allChecks` at load time: a check
10
+ * renamed, removed or added upstream changes this map without anyone editing it.
11
+ */
12
+ import { allChecks } from '@platformos/platformos-check-common';
13
+ /**
14
+ * Keyed by `meta.code` ONLY, which is the exact string an offense carries
15
+ * (`check: check.meta.code` in check-common's `check()`).
16
+ *
17
+ * `meta.aliases` are deliberately NOT keys: an alias is a name a CONFIG may use, never
18
+ * something an offense carries, so mapping it would add entries nothing can look up.
19
+ */
20
+ const BY_CODE = new Map(allChecks.map((check) => [
21
+ check.meta.code,
22
+ { description: check.meta.docs.description, url: check.meta.docs.url },
23
+ ]));
24
+ /**
25
+ * The documentation for a check code, or `undefined` when nothing is registered under it.
26
+ *
27
+ * `undefined` is a real answer, not a gap to paper over: `CheckError` — check-common's code
28
+ * for "a check crashed on this file" — is reported on offenses and is not a registered
29
+ * check, so a consumer must handle a miss rather than assume every offense has a page.
30
+ */
31
+ export function checkDocs(code) {
32
+ return BY_CODE.get(code);
33
+ }
34
+ /** Every registered check code. Exported for the tests that assert this map is complete. */
35
+ export function registeredCheckCodes() {
36
+ return [...BY_CODE.keys()];
37
+ }
38
+ //# sourceMappingURL=check-docs.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The check-documentation lookup is DERIVED, and these assertions are written so a
3
+ * documentation release cannot fail them while OUR side falling behind does.
4
+ *
5
+ * Nothing here restates what a check's description SAYS, or which URL it has. That is
6
+ * settled in check-common and in the `platformos-documentation` repo, where the page
7
+ * lives; a test here that pinned the text would duplicate a gate it cannot see and rot on
8
+ * a release that is perfectly correct.
9
+ */
10
+ import { allChecks } from '@platformos/platformos-check-common';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { checkDocs, registeredCheckCodes } from './check-docs.js';
13
+ describe('Unit: checkDocs', () => {
14
+ it('answers for every registered check, and for nothing else', () => {
15
+ // Derived on both sides: the registry is the input AND the expectation, so adding a
16
+ // check upstream cannot leave this map behind.
17
+ expect(registeredCheckCodes().sort()).toEqual(allChecks.map((check) => check.meta.code).sort());
18
+ });
19
+ it('returns each check its own description, taken from the registry rather than restated', () => {
20
+ const mismatched = allChecks
21
+ .map((check) => ({
22
+ code: check.meta.code,
23
+ expected: check.meta.docs.description,
24
+ actual: checkDocs(check.meta.code)?.description,
25
+ }))
26
+ .filter((entry) => entry.actual !== entry.expected);
27
+ expect(mismatched).toEqual([]);
28
+ });
29
+ it('returns the check its own documentation URL, and omits it when meta publishes none', () => {
30
+ const mismatched = allChecks
31
+ .map((check) => ({
32
+ code: check.meta.code,
33
+ expected: check.meta.docs.url,
34
+ actual: checkDocs(check.meta.code)?.url,
35
+ }))
36
+ .filter((entry) => entry.actual !== entry.expected);
37
+ expect(mismatched).toEqual([]);
38
+ });
39
+ /**
40
+ * A miss is a real answer. `CheckError` is check-common's code for "a check crashed on
41
+ * this file"; it reaches an agent as an ordinary offense and has no registry entry, so a
42
+ * consumer that assumed every code resolves would throw on exactly the paths that are
43
+ * already going wrong.
44
+ */
45
+ it('returns undefined for a code that is not a registered check', () => {
46
+ expect(checkDocs('CheckError')).toBeUndefined();
47
+ expect(checkDocs('NotACheckAtAll')).toBeUndefined();
48
+ });
49
+ /**
50
+ * EVERY check publishes a documentation URL — a requirement rather than a report, now
51
+ * that the server instructions tell an agent to follow `see_also` instead of restating
52
+ * platform rules. A check with no URL means an agent is stopped by a finding and given
53
+ * nowhere to read about it.
54
+ *
55
+ * A new check therefore fails here until it is documented: the page is part of shipping a
56
+ * check, not a follow-up.
57
+ */
58
+ it('every registered check publishes a documentation URL', () => {
59
+ const withoutUrl = allChecks
60
+ .filter((check) => !check.meta.docs.url)
61
+ .map((check) => check.meta.code)
62
+ .sort();
63
+ expect(withoutUrl, `these checks have no docs.url — add a page under ` +
64
+ `app/views/pages/developer-guide/platformos-check/checks/ in platformos-documentation, ` +
65
+ `plus its overview row and nav entry:\n${withoutUrl.join('\n')}`).toEqual([]);
66
+ });
67
+ /**
68
+ * The URL must be one the documentation site can serve, not merely a non-empty string.
69
+ *
70
+ * Asserted by SHAPE rather than by fetching — no network in a unit test — which still
71
+ * catches the realistic mistake: a URL pointing at the wrong section, or a check code
72
+ * pasted where a slug belongs.
73
+ *
74
+ * A check with NO url is skipped rather than dereferenced; that is the sibling test's
75
+ * subject, and a `TypeError` here would preempt it.
76
+ */
77
+ it('every documentation URL points into the checks section of the docs site', () => {
78
+ const BASE = 'https://documentation.platformos.com/developer-guide/platformos-check/checks/';
79
+ const malformed = allChecks
80
+ .map((check) => ({ code: check.meta.code, url: check.meta.docs.url }))
81
+ .filter(({ url }) => url !== undefined &&
82
+ (!url.startsWith(BASE) || !/^[a-z0-9-]+$/.test(url.slice(BASE.length))))
83
+ .map(({ code, url }) => `${code} -> ${url}`);
84
+ expect(malformed).toEqual([]);
85
+ });
86
+ });
87
+ //# sourceMappingURL=check-docs.spec.js.map
@@ -0,0 +1,34 @@
1
+ /** The per-server context and the request-path deadlines — shared INFRASTRUCTURE. */
2
+ import type { Logger } from './logger.js';
3
+ /**
4
+ * Per-server context threaded into every handler.
5
+ *
6
+ * NO CACHE HANDLES HERE, and their absence is the point. check-node keeps ONE lazy `App`
7
+ * per project at process level and reconciles it per call, so a cache nobody has to
8
+ * remember to pass is a cache no call path can forget — do not reintroduce a handle here
9
+ * (see check-node's "Process-level state"). Impact is likewise DERIVED per request from
10
+ * the project's text (`impact/project-scan.ts`), so nothing can be stale.
11
+ */
12
+ export interface SupervisorContext {
13
+ /** Absolute project root that buffers are validated against. */
14
+ projectDir: string;
15
+ log: Logger;
16
+ /**
17
+ * Whether cross-file impact runs. OPTIONAL AND DEFAULTING TO ON: impact is a safety
18
+ * feature, so a context that forgets to mention it must get the safe behaviour rather than
19
+ * silently lose the check. Only an explicit `false` — from `--no-impact` or
20
+ * `POS_SUPERVISOR_NO_IMPACT` — turns it off.
21
+ */
22
+ impactEnabled?: boolean;
23
+ }
24
+ /**
25
+ * Deadline for impact. Much tighter than the lint's, which is a function of admitted bytes
26
+ * and lives in `cost-model.ts` — deliberately not re-exported from here.
27
+ *
28
+ * A BACKSTOP, NOT THE BOUND. A lint is synchronous CPU work and no timer preempts it (see
29
+ * `deadline.ts`), so what actually limits impact is bounded INPUT — `MAX_CANDIDATE_BYTES` and
30
+ * `MAX_DEPENDANTS_LINTED` in `cost-model.ts`, both derived to fit inside this number. This
31
+ * catches what those cannot: a stalled read, or a project large enough that the read alone
32
+ * runs long. Exceeding it costs only the answer, which has an `unavailable` state.
33
+ */
34
+ export declare const IMPACT_DEADLINE_MS = 2000;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Deadline for impact. Much tighter than the lint's, which is a function of admitted bytes
3
+ * and lives in `cost-model.ts` — deliberately not re-exported from here.
4
+ *
5
+ * A BACKSTOP, NOT THE BOUND. A lint is synchronous CPU work and no timer preempts it (see
6
+ * `deadline.ts`), so what actually limits impact is bounded INPUT — `MAX_CANDIDATE_BYTES` and
7
+ * `MAX_DEPENDANTS_LINTED` in `cost-model.ts`, both derived to fit inside this number. This
8
+ * catches what those cannot: a stalled read, or a project large enough that the read alone
9
+ * runs long. Exceeding it costs only the answer, which has an `unavailable` state.
10
+ */
11
+ export const IMPACT_DEADLINE_MS = 2_000;
12
+ //# sourceMappingURL=context.js.map