@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,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
@@ -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
@@ -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,26 @@
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"). The blast radius is likewise DERIVED per
10
+ * request from 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
+ /**
18
+ * Deadline for the blast radius. Much tighter than the lint's, which is a function of
19
+ * admitted bytes and lives in `cost-model.ts` — deliberately not re-exported from here.
20
+ *
21
+ * Tight because the work is small — one project read (235 ms measured on a 2,615-file
22
+ * project) plus a name filter and a handful of parses, p50 9 ms / p90 63 ms per file — and
23
+ * because exceeding it costs only the answer, which has an `unavailable` state. The tail it
24
+ * exists for is real but rare: the most-referenced partial measured took ~1 s.
25
+ */
26
+ export declare const IMPACT_DEADLINE_MS = 2000;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Deadline for the blast radius. Much tighter than the lint's, which is a function of
3
+ * admitted bytes and lives in `cost-model.ts` — deliberately not re-exported from here.
4
+ *
5
+ * Tight because the work is small — one project read (235 ms measured on a 2,615-file
6
+ * project) plus a name filter and a handful of parses, p50 9 ms / p90 63 ms per file — and
7
+ * because exceeding it costs only the answer, which has an `unavailable` state. The tail it
8
+ * exists for is real but rare: the most-referenced partial measured took ~1 s.
9
+ */
10
+ export const IMPACT_DEADLINE_MS = 2_000;
11
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What a lint COSTS — and therefore how much work one request may admit, and how long the
3
+ * server waits for it.
4
+ *
5
+ * The byte caps and the deadline are ONE relationship, written here as arithmetic and
6
+ * derived rather than chosen. Adding per-node work moves {@link LINT_MS_PER_KIB} and every
7
+ * dependent bound follows; `cost-model.spec.ts` fails if any of them stops fitting. A batch
8
+ * admitted past its deadline returns `timed_out` for EVERY file in the request, so the two
9
+ * drifting apart fails quietly and in the expensive direction.
10
+ *
11
+ * THE DEADLINE SCALES WITH THE REQUEST because a fixed one and a meaningful batch cap
12
+ * cannot both exist: hold it at 60 s and the largest admissible batch is ~133 KiB, smaller
13
+ * than a single legal buffer. Scaling costs little — this deadline is a backstop for ASYNC
14
+ * stalls (see `deadline.ts`) rather than a CPU bound.
15
+ *
16
+ * Every number here is MEASURED, and the measurement is named. A plausible constant is how
17
+ * this file rots back into unrelated magic numbers.
18
+ */
19
+ /**
20
+ * Wall-clock cost of linting one KiB of buffer, on an idle machine, warm cache.
21
+ *
22
+ * Measured end to end (`runValidateCode`, the whole request path), not against the parse
23
+ * alone. Reproduce with `scripts/measure-lint-cost.mjs`, which builds a synthetic project
24
+ * and prints the rate for a single buffer and for batch shapes; the slowest observed was
25
+ * ~51 ms/KiB, on CLEAN markup rather than the diagnostic-dense shape.
26
+ *
27
+ * DO NOT LOWER THIS TO MATCH A FASTER MEASUREMENT. The rate varies by several times with
28
+ * the project and the markup, so the constant is deliberately set above the slowest
29
+ * observed value. Being wrong FAST costs a longer wait before a stall is declared; being
30
+ * wrong SLOW admits a request that cannot finish and returns `timed_out` for every file.
31
+ */
32
+ export declare const LINT_MS_PER_KIB = 75;
33
+ /**
34
+ * How much slower the same lint runs on a contended machine than on an idle one.
35
+ *
36
+ * Measured at ~2.3x (128 KiB: ~10 s idle, ~23 s under load), held at 3 because it bounds
37
+ * someone else's machine. Being wrong low produces a false `timed_out` — a silent
38
+ * no-answer — while being wrong high costs only a longer wait before declaring a stall.
39
+ */
40
+ export declare const LOAD_FACTOR = 3;
41
+ /**
42
+ * How far beyond the expected loaded worst case the deadline sits. The deadline must never
43
+ * fire on work that was legal to admit; it exists to catch a stall.
44
+ */
45
+ export declare const DEADLINE_MARGIN = 2;
46
+ /** Deadline granted per KiB of admitted work: idle cost, under load, with margin. */
47
+ export declare const DEADLINE_MS_PER_KIB: number;
48
+ /**
49
+ * Deadline floor, applied to every request however small.
50
+ *
51
+ * Small requests are not dominated by their buffers: a cold first call also builds the app,
52
+ * loads the config and reconciles the graph (~0.8 s idle, ~1.6 s loaded on a 162-file
53
+ * project). The floor covers that fixed cost with room to spare.
54
+ */
55
+ export declare const MIN_LINT_DEADLINE_MS = 60000;
56
+ /**
57
+ * Deadline ceiling, and therefore the origin of {@link maxBytesWithin}'s answer for the
58
+ * batch cap.
59
+ *
60
+ * POLICY rather than measurement: how long the server holds a request open before
61
+ * concluding it has stalled. Two minutes is past the point where an agent has any use for
62
+ * the answer, and every extra second of ceiling buys a larger admissible batch.
63
+ */
64
+ export declare const MAX_LINT_DEADLINE_MS = 120000;
65
+ /**
66
+ * How much of the AGENT'S CONTEXT one `validate_code` answer may spend.
67
+ *
68
+ * POLICY, like {@link MAX_LINT_DEADLINE_MS}, and the one bound here that is not about the
69
+ * server's own work. Unbounded, a legal call was measured returning ~336 000 tokens — more
70
+ * than most context windows hold, with nothing in the payload saying so.
71
+ *
72
+ * At ~153 bytes per diagnostic this budget holds roughly 200 findings. Measured real calls
73
+ * cost tens to hundreds of tokens, so the cap cannot touch them; what it defends against is
74
+ * the tail — a generated file, or one cascading syntax error in a large partial.
75
+ */
76
+ export declare const RESPONSE_TOKEN_BUDGET = 8000;
77
+ /**
78
+ * Bytes per token, for converting the budget above into something countable.
79
+ *
80
+ * A crude ESTIMATE — this JSON is mostly ASCII prose, where ~4 bytes/token is the usual
81
+ * rule of thumb, and the real tokenizer is the client's. Being wrong by 25% moves the cap
82
+ * between ~150 and ~250 diagnostics, which changes nothing about which calls it affects.
83
+ */
84
+ export declare const BYTES_PER_TOKEN = 4;
85
+ /**
86
+ * The bound actually enforced: serialized bytes of diagnostics one response may
87
+ * carry, across the WHOLE request rather than per file.
88
+ *
89
+ * Per-request because per-file alone leaves the worst case multiplied by the file
90
+ * count — 50 files each just under a per-file cap is 50x the intended bound, and the
91
+ * batch form exists precisely so agents send many files at once.
92
+ */
93
+ export declare const MAX_RESPONSE_DIAGNOSTIC_BYTES: number;
94
+ /**
95
+ * What one file costs in a response BEFORE any diagnostics: its status, gate, `impact`
96
+ * object, empty lists, and — when findings were withheld — the truncation note.
97
+ *
98
+ * Measured, not assumed: a 50-file batch of clean files serializes to 234 bytes per file,
99
+ * and the note adds ~150. Held at 512 because this multiplies by the file count.
100
+ */
101
+ export declare const RESPONSE_ENVELOPE_BYTES_PER_FILE = 512;
102
+ /**
103
+ * The whole response bound, stated as arithmetic rather than enforced directly.
104
+ *
105
+ * The allocator bounds DIAGNOSTICS, the only unbounded dimension; the envelope is O(files)
106
+ * and already bounded by the batch file cap. `transport/validate-code.spec.ts` measures the
107
+ * worst legal request against this.
108
+ *
109
+ * Takes the file count rather than importing `MAX_BATCH_FILES`, which lives in
110
+ * `validate/batch-bounds.ts` and already imports this module.
111
+ */
112
+ export declare function maxResponseBytes(fileCount: number): number;
113
+ /**
114
+ * How long to wait for a lint that was handed `bytes` of buffer.
115
+ *
116
+ * Never below {@link MIN_LINT_DEADLINE_MS}. Callers do not clamp to
117
+ * {@link MAX_LINT_DEADLINE_MS} — the byte caps do that upstream by refusing the request
118
+ * outright, so the ceiling is enforced where the work is admitted, not where it is timed.
119
+ */
120
+ export declare function lintDeadlineMs(bytes: number): number;
121
+ /**
122
+ * The most bytes whose {@link lintDeadlineMs} still fits inside `deadlineMs`.
123
+ *
124
+ * The inverse of the function above, floored to a whole KiB so the answer is exact at the
125
+ * boundary — one byte more and the deadline exceeds `deadlineMs`. Used to derive the batch
126
+ * cap from the deadline ceiling instead of from a round multiple of the per-buffer bound.
127
+ */
128
+ export declare function maxBytesWithin(deadlineMs: number): number;