@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.
- package/README.md +90 -0
- package/dist/adapter-input.d.ts +113 -0
- package/dist/adapter-input.js +210 -0
- package/dist/adapter-input.spec.d.ts +1 -0
- package/dist/adapter-input.spec.js +275 -0
- package/dist/bin/args.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- package/dist/check-docs.d.ts +21 -0
- package/dist/check-docs.js +38 -0
- package/dist/check-docs.spec.d.ts +1 -0
- package/dist/check-docs.spec.js +87 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.js +11 -0
- package/dist/cost-model.d.ts +128 -0
- package/dist/cost-model.js +137 -0
- package/dist/cost-model.spec.d.ts +1 -0
- package/dist/cost-model.spec.js +91 -0
- package/dist/deadline.d.ts +27 -0
- package/dist/deadline.js +40 -0
- package/dist/deadline.spec.d.ts +1 -0
- package/dist/deadline.spec.js +83 -0
- package/dist/enrich/enrich.d.ts +87 -0
- package/dist/enrich/enrich.js +97 -0
- package/dist/enrich/enrich.spec.d.ts +1 -0
- package/dist/enrich/enrich.spec.js +184 -0
- package/dist/enrich/symbol.d.ts +16 -0
- package/dist/enrich/symbol.js +58 -0
- package/dist/enrich/symbol.spec.d.ts +1 -0
- package/dist/enrich/symbol.spec.js +89 -0
- package/dist/impact/impact.d.ts +9 -0
- package/dist/impact/impact.js +189 -0
- package/dist/impact/impact.spec.d.ts +1 -0
- package/dist/impact/impact.spec.js +357 -0
- package/dist/impact/project-scan.d.ts +37 -0
- package/dist/impact/project-scan.js +60 -0
- package/dist/impact/project-scan.spec.d.ts +1 -0
- package/dist/impact/project-scan.spec.js +121 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint/fixes.d.ts +28 -0
- package/dist/lint/fixes.js +30 -0
- package/dist/lint/lint-batch.d.ts +100 -0
- package/dist/lint/lint-batch.js +98 -0
- package/dist/lint/lint-batch.spec.d.ts +1 -0
- package/dist/lint/lint-batch.spec.js +503 -0
- package/dist/lint/lint.d.ts +11 -1
- package/dist/lint/lint.js +9 -3
- package/dist/result/assemble.d.ts +12 -9
- package/dist/result/assemble.js +64 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +86 -0
- package/dist/result/blocking.js +96 -0
- package/dist/result/blocking.spec.d.ts +1 -0
- package/dist/result/blocking.spec.js +201 -0
- package/dist/result/impact-states.d.ts +16 -0
- package/dist/result/impact-states.js +18 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +131 -0
- package/dist/result/response-budget.spec.d.ts +1 -0
- package/dist/result/response-budget.spec.js +222 -0
- package/dist/result/types.d.ts +210 -104
- package/dist/result/types.js +4 -13
- package/dist/transport/instructions-coverage.spec.d.ts +1 -0
- package/dist/transport/instructions-coverage.spec.js +176 -0
- package/dist/transport/instructions.d.ts +1 -0
- package/dist/transport/instructions.js +132 -0
- package/dist/transport/process-guards.d.ts +43 -0
- package/dist/transport/process-guards.js +63 -0
- package/dist/transport/process-guards.spec.d.ts +1 -0
- package/dist/transport/process-guards.spec.js +200 -0
- package/dist/transport/server.d.ts +1 -1
- package/dist/transport/server.js +16 -9
- package/dist/transport/validate-code.d.ts +36 -18
- package/dist/transport/validate-code.js +200 -33
- package/dist/transport/validate-code.spec.d.ts +1 -0
- package/dist/transport/validate-code.spec.js +2303 -0
- package/dist/validate/batch-bounds.d.ts +34 -0
- package/dist/validate/batch-bounds.js +60 -0
- package/dist/validate/batch-coherence.d.ts +21 -0
- package/dist/validate/batch-coherence.js +54 -0
- package/dist/validate/batch-coherence.spec.d.ts +1 -0
- package/dist/validate/batch-coherence.spec.js +111 -0
- package/dist/validate/validate-buffers.d.ts +27 -0
- package/dist/validate/validate-buffers.js +265 -0
- package/package.json +8 -7
|
@@ -0,0 +1,137 @@
|
|
|
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 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 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 const DEADLINE_MARGIN = 2;
|
|
46
|
+
/** Deadline granted per KiB of admitted work: idle cost, under load, with margin. */
|
|
47
|
+
export const DEADLINE_MS_PER_KIB = LINT_MS_PER_KIB * LOAD_FACTOR * DEADLINE_MARGIN;
|
|
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 const MIN_LINT_DEADLINE_MS = 60_000;
|
|
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 const MAX_LINT_DEADLINE_MS = 120_000;
|
|
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 const RESPONSE_TOKEN_BUDGET = 8_000;
|
|
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 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 const MAX_RESPONSE_DIAGNOSTIC_BYTES = RESPONSE_TOKEN_BUDGET * BYTES_PER_TOKEN;
|
|
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 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 function maxResponseBytes(fileCount) {
|
|
113
|
+
return MAX_RESPONSE_DIAGNOSTIC_BYTES + fileCount * RESPONSE_ENVELOPE_BYTES_PER_FILE;
|
|
114
|
+
}
|
|
115
|
+
/** KiB in `bytes`, rounded UP — a partial KiB still costs a full unit of work. */
|
|
116
|
+
const kib = (bytes) => Math.ceil(bytes / 1024);
|
|
117
|
+
/**
|
|
118
|
+
* How long to wait for a lint that was handed `bytes` of buffer.
|
|
119
|
+
*
|
|
120
|
+
* Never below {@link MIN_LINT_DEADLINE_MS}. Callers do not clamp to
|
|
121
|
+
* {@link MAX_LINT_DEADLINE_MS} — the byte caps do that upstream by refusing the request
|
|
122
|
+
* outright, so the ceiling is enforced where the work is admitted, not where it is timed.
|
|
123
|
+
*/
|
|
124
|
+
export function lintDeadlineMs(bytes) {
|
|
125
|
+
return Math.max(MIN_LINT_DEADLINE_MS, kib(bytes) * DEADLINE_MS_PER_KIB);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The most bytes whose {@link lintDeadlineMs} still fits inside `deadlineMs`.
|
|
129
|
+
*
|
|
130
|
+
* The inverse of the function above, floored to a whole KiB so the answer is exact at the
|
|
131
|
+
* boundary — one byte more and the deadline exceeds `deadlineMs`. Used to derive the batch
|
|
132
|
+
* cap from the deadline ceiling instead of from a round multiple of the per-buffer bound.
|
|
133
|
+
*/
|
|
134
|
+
export function maxBytesWithin(deadlineMs) {
|
|
135
|
+
return Math.floor(deadlineMs / DEADLINE_MS_PER_KIB) * 1024;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=cost-model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEADLINE_MARGIN, DEADLINE_MS_PER_KIB, LINT_MS_PER_KIB, LOAD_FACTOR, MAX_LINT_DEADLINE_MS, MIN_LINT_DEADLINE_MS, lintDeadlineMs, maxBytesWithin, } from './cost-model.js';
|
|
3
|
+
import { MAX_BUFFER_BYTES } from './adapter-input.js';
|
|
4
|
+
import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from './validate/batch-bounds.js';
|
|
5
|
+
/**
|
|
6
|
+
* The arithmetic, and the invariants that make it safe.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS FILE IS FOR. The defect it exists to prevent is not a wrong number —
|
|
9
|
+
* it is two numbers that stopped being related to each other without anything
|
|
10
|
+
* noticing. `MAX_BATCH_BYTES` was `4 * MAX_BUFFER_BYTES` and the deadline was 60 s,
|
|
11
|
+
* and they were compatible until `FilterArity` moved throughput; after that the
|
|
12
|
+
* worst legal batch could not finish inside the deadline it was given, and every
|
|
13
|
+
* file in such a request comes back `timed_out` — no validation, no error, no sign.
|
|
14
|
+
*
|
|
15
|
+
* So the assertions below are mostly RELATIONSHIPS, not values. A relationship
|
|
16
|
+
* fails the moment someone changes one side of it, which is precisely when it needs
|
|
17
|
+
* to be looked at. The few literal values that are pinned are pinned because they
|
|
18
|
+
* are what makes the relationships legible in a failure message.
|
|
19
|
+
*/
|
|
20
|
+
describe('Unit: the lint cost model', () => {
|
|
21
|
+
it('composes the per-KiB deadline from its three named factors', () => {
|
|
22
|
+
// Pinned as a composition, not as 450: a reader who changes LOAD_FACTOR should
|
|
23
|
+
// see this follow, and a reader who changes this alone should see it break.
|
|
24
|
+
expect([LINT_MS_PER_KIB, LOAD_FACTOR, DEADLINE_MARGIN, DEADLINE_MS_PER_KIB]).toEqual([
|
|
25
|
+
75,
|
|
26
|
+
3,
|
|
27
|
+
2,
|
|
28
|
+
75 * 3 * 2,
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
it('charges a partial KiB as a whole one', () => {
|
|
32
|
+
// Rounding DOWN would grant a deadline smaller than the work admitted, which is
|
|
33
|
+
// the failure this module exists to prevent — so the rounding direction is part
|
|
34
|
+
// of the contract, not an implementation detail.
|
|
35
|
+
expect([lintDeadlineMs(300 * 1024), lintDeadlineMs(300 * 1024 - 1023)]).toEqual([
|
|
36
|
+
300 * DEADLINE_MS_PER_KIB,
|
|
37
|
+
300 * DEADLINE_MS_PER_KIB,
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
it('never returns less than the floor, however small the request', () => {
|
|
41
|
+
expect([lintDeadlineMs(0), lintDeadlineMs(1), lintDeadlineMs(1024)]).toEqual([
|
|
42
|
+
MIN_LINT_DEADLINE_MS,
|
|
43
|
+
MIN_LINT_DEADLINE_MS,
|
|
44
|
+
MIN_LINT_DEADLINE_MS,
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
it('inverts exactly: one KiB more than maxBytesWithin overshoots the deadline', () => {
|
|
48
|
+
const bytes = maxBytesWithin(MAX_LINT_DEADLINE_MS);
|
|
49
|
+
expect([lintDeadlineMs(bytes) <= MAX_LINT_DEADLINE_MS, lintDeadlineMs(bytes + 1024)]).toEqual([
|
|
50
|
+
true,
|
|
51
|
+
lintDeadlineMs(bytes) + DEADLINE_MS_PER_KIB,
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('Unit: the bounds derived from the cost model', () => {
|
|
56
|
+
it('admits the worst legal BATCH inside the deadline that batch is granted', () => {
|
|
57
|
+
// The regression, stated directly. Before this derivation MAX_BATCH_BYTES was
|
|
58
|
+
// 512 KiB against a fixed 60 s deadline: 512 KiB of measured work is ~38 s idle
|
|
59
|
+
// and ~115 s loaded, so the worst legal batch could not finish in time and every
|
|
60
|
+
// file in it returned `timed_out`.
|
|
61
|
+
expect(lintDeadlineMs(MAX_BATCH_BYTES) <= MAX_LINT_DEADLINE_MS).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('admits the worst legal single BUFFER inside the deadline it is granted', () => {
|
|
64
|
+
expect(lintDeadlineMs(MAX_BUFFER_BYTES) <= MAX_LINT_DEADLINE_MS).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it('leaves the single-file path on exactly the deadline it had before scaling', () => {
|
|
67
|
+
// Every real single-file call is far below MAX_BUFFER_BYTES, and even a maximal
|
|
68
|
+
// one lands on the floor. Scaling the deadline therefore changed nothing about
|
|
69
|
+
// the path that carries essentially all traffic — which is why it was safe to
|
|
70
|
+
// introduce. If this ever fails, single-file latency behaviour has moved and the
|
|
71
|
+
// 60 s figure quoted in `context.ts` and in the timeout message is stale.
|
|
72
|
+
expect(lintDeadlineMs(MAX_BUFFER_BYTES)).toEqual(MIN_LINT_DEADLINE_MS);
|
|
73
|
+
});
|
|
74
|
+
it('never refuses a batch for being smaller than one legal single buffer', () => {
|
|
75
|
+
// A batch cap below the per-buffer cap would refuse a one-file batch containing
|
|
76
|
+
// a file the single-file form accepts — the same content, refused for the shape
|
|
77
|
+
// of the request. This is also what forces the deadline to scale: at a fixed
|
|
78
|
+
// 60 s the derived batch cap is ~133 KiB, and this invariant fails.
|
|
79
|
+
expect(MAX_BATCH_BYTES > MAX_BUFFER_BYTES).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('states the derived cap, so a change to the model shows up as a number', () => {
|
|
82
|
+
expect([MAX_BATCH_BYTES, MAX_BATCH_BYTES / 1024, MAX_BATCH_FILES]).toEqual([272_384, 266, 50]);
|
|
83
|
+
});
|
|
84
|
+
it('bounds the whole request even at the file-count cap', () => {
|
|
85
|
+
// MAX_BATCH_FILES alone cannot bound the work: 50 files just under the buffer
|
|
86
|
+
// cap would be ~6 MiB. The byte cap is what binds, and it binds well before the
|
|
87
|
+
// file cap does — this records which of the two is load-bearing.
|
|
88
|
+
expect(MAX_BATCH_FILES * MAX_BUFFER_BYTES > MAX_BATCH_BYTES).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
//# sourceMappingURL=cost-model.spec.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deadline wrapper for the request path.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS DOES AND DOES NOT BUY. The lint cannot be interrupted mid-flight: parsing is
|
|
5
|
+
* synchronous CPU work on the one event loop, so no timer preempts it. Racing a deadline
|
|
6
|
+
* returns control to the CALLER while the work runs on to completion. That fixes the
|
|
7
|
+
* failure mode that hurts — the agent stops waiting — but it is a BACKSTOP, not the primary
|
|
8
|
+
* defence, which is bounding the input (`MAX_BUFFER_BYTES`). True cancellation would need
|
|
9
|
+
* the lint on a worker thread.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Returned instead of a value when the deadline expires. A unique symbol rather
|
|
13
|
+
* than `undefined`/`null` so it can never collide with a legitimate result.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TIMED_OUT: unique symbol;
|
|
16
|
+
export type TimedOut = typeof TIMED_OUT;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve with `work`'s value, or with {@link TIMED_OUT} once `ms` elapses.
|
|
19
|
+
*
|
|
20
|
+
* The timer is cleared on BOTH outcomes, and `unref`'d besides, so a pending deadline can
|
|
21
|
+
* never hold the process open.
|
|
22
|
+
*
|
|
23
|
+
* A rejection from `work` propagates unchanged. NOTE that when the deadline wins, `work` is
|
|
24
|
+
* left running and unobserved — the caller MUST attach a handler to it, or a later
|
|
25
|
+
* rejection becomes an unhandled rejection.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withDeadline<T>(work: Promise<T>, ms: number): Promise<T | TimedOut>;
|
package/dist/deadline.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deadline wrapper for the request path.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS DOES AND DOES NOT BUY. The lint cannot be interrupted mid-flight: parsing is
|
|
5
|
+
* synchronous CPU work on the one event loop, so no timer preempts it. Racing a deadline
|
|
6
|
+
* returns control to the CALLER while the work runs on to completion. That fixes the
|
|
7
|
+
* failure mode that hurts — the agent stops waiting — but it is a BACKSTOP, not the primary
|
|
8
|
+
* defence, which is bounding the input (`MAX_BUFFER_BYTES`). True cancellation would need
|
|
9
|
+
* the lint on a worker thread.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Returned instead of a value when the deadline expires. A unique symbol rather
|
|
13
|
+
* than `undefined`/`null` so it can never collide with a legitimate result.
|
|
14
|
+
*/
|
|
15
|
+
export const TIMED_OUT = Symbol('platformos-supervisor:timed-out');
|
|
16
|
+
/**
|
|
17
|
+
* Resolve with `work`'s value, or with {@link TIMED_OUT} once `ms` elapses.
|
|
18
|
+
*
|
|
19
|
+
* The timer is cleared on BOTH outcomes, and `unref`'d besides, so a pending deadline can
|
|
20
|
+
* never hold the process open.
|
|
21
|
+
*
|
|
22
|
+
* A rejection from `work` propagates unchanged. NOTE that when the deadline wins, `work` is
|
|
23
|
+
* left running and unobserved — the caller MUST attach a handler to it, or a later
|
|
24
|
+
* rejection becomes an unhandled rejection.
|
|
25
|
+
*/
|
|
26
|
+
export function withDeadline(work, ms) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
29
|
+
// Node-only: `unref` keeps a pending deadline from holding the event loop.
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
work.then((value) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve(value);
|
|
34
|
+
}, (error) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(error);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=deadline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TIMED_OUT, withDeadline } from './deadline.js';
|
|
3
|
+
/**
|
|
4
|
+
* TASK-15. `withDeadline` is a backstop, and its contract is narrow on purpose:
|
|
5
|
+
* add a deadline, change nothing else. In particular a rejection must pass through
|
|
6
|
+
* untouched, because the lint is the primary gate and swallowing its failure would
|
|
7
|
+
* turn a real error into a silent pass.
|
|
8
|
+
*/
|
|
9
|
+
const after = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
10
|
+
const rejectAfter = (ms, error) => new Promise((_, reject) => setTimeout(() => reject(error), ms));
|
|
11
|
+
describe('Unit: withDeadline', () => {
|
|
12
|
+
it('resolves with the value when the work finishes first', async () => {
|
|
13
|
+
await expect(withDeadline(after(1, 'done'), 1_000)).resolves.toEqual('done');
|
|
14
|
+
});
|
|
15
|
+
it('resolves with TIMED_OUT when the deadline wins', async () => {
|
|
16
|
+
await expect(withDeadline(after(1_000, 'too late'), 5)).resolves.toBe(TIMED_OUT);
|
|
17
|
+
});
|
|
18
|
+
it('resolves with TIMED_OUT for work that never settles at all', async () => {
|
|
19
|
+
// The exact shape that used to hang the tool for the life of the session.
|
|
20
|
+
await expect(withDeadline(new Promise(() => { }), 5)).resolves.toBe(TIMED_OUT);
|
|
21
|
+
});
|
|
22
|
+
it('propagates a rejection unchanged rather than converting it to TIMED_OUT', async () => {
|
|
23
|
+
// The lint is the primary gate: a genuine failure must stay a failure.
|
|
24
|
+
const failure = new Error('lint exploded');
|
|
25
|
+
await expect(withDeadline(rejectAfter(1, failure), 1_000)).rejects.toThrow(failure);
|
|
26
|
+
});
|
|
27
|
+
it('preserves a falsy result, which a null/undefined sentinel would have destroyed', async () => {
|
|
28
|
+
// TIMED_OUT is a unique Symbol precisely so these stay distinguishable.
|
|
29
|
+
await expect(withDeadline(Promise.resolve(undefined), 1_000)).resolves.toBeUndefined();
|
|
30
|
+
await expect(withDeadline(Promise.resolve(null), 1_000)).resolves.toBeNull();
|
|
31
|
+
await expect(withDeadline(Promise.resolve(0), 1_000)).resolves.toEqual(0);
|
|
32
|
+
await expect(withDeadline(Promise.resolve([]), 1_000)).resolves.toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
it('clears the timer when the work wins, so nothing is left pending', async () => {
|
|
35
|
+
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
|
|
36
|
+
try {
|
|
37
|
+
await withDeadline(after(1, 'done'), 60_000);
|
|
38
|
+
// A 60 s timer left armed would hold a stdio server open past its work.
|
|
39
|
+
expect(clearSpy).toHaveBeenCalled();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearSpy.mockRestore();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it('clears the timer when the work REJECTS, not only when it resolves', async () => {
|
|
46
|
+
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
|
|
47
|
+
try {
|
|
48
|
+
await expect(withDeadline(rejectAfter(1, new Error('x')), 60_000)).rejects.toThrow();
|
|
49
|
+
expect(clearSpy).toHaveBeenCalled();
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearSpy.mockRestore();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('unrefs the deadline timer so a pending one cannot hold the process open', async () => {
|
|
56
|
+
const unref = vi.fn();
|
|
57
|
+
const setSpy = vi
|
|
58
|
+
.spyOn(globalThis, 'setTimeout')
|
|
59
|
+
.mockImplementation((() => ({ unref })));
|
|
60
|
+
try {
|
|
61
|
+
void withDeadline(Promise.resolve('immediate'), 60_000);
|
|
62
|
+
expect(unref).toHaveBeenCalledOnce();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setSpy.mockRestore();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('does not stop the abandoned work from continuing — the documented limitation', async () => {
|
|
69
|
+
// Parsing is synchronous CPU work; nothing here can preempt it. The deadline
|
|
70
|
+
// frees the CALLER, it does not cancel. Pinned so the limitation is not
|
|
71
|
+
// mistaken for a bug later, and so nobody assumes cancellation they don't have.
|
|
72
|
+
let finished = false;
|
|
73
|
+
const work = after(30, 'eventually').then((value) => {
|
|
74
|
+
finished = true;
|
|
75
|
+
return value;
|
|
76
|
+
});
|
|
77
|
+
expect(await withDeadline(work, 5)).toBe(TIMED_OUT);
|
|
78
|
+
expect(finished).toBe(false);
|
|
79
|
+
await work;
|
|
80
|
+
expect(finished).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=deadline.spec.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PURE enrichment stage: what the supervisor adds to a diagnostic beyond what the
|
|
3
|
+
* engine reported.
|
|
4
|
+
*
|
|
5
|
+
* It adds exactly two things, and **authors neither of them**:
|
|
6
|
+
*
|
|
7
|
+
* `see_also` the check's own documentation page, from check-common `meta.docs.url`.
|
|
8
|
+
* `hint` the docset entry for the symbol the diagnostic is about, rendered by the
|
|
9
|
+
* same function every editor hover uses.
|
|
10
|
+
*
|
|
11
|
+
* The restraint is the design. The supervisor ships no documentation (ARCHITECTURE.md
|
|
12
|
+
* §Invariants #6): the words an agent reads come from `filters.json` / `tags.json` /
|
|
13
|
+
* `objects.json` and from the check registry, both owned and tested where they are
|
|
14
|
+
* published. A hint composed here would be a second source of truth that goes stale.
|
|
15
|
+
*
|
|
16
|
+
* PURE, and mechanically so: no `fs`, no `process`, no import of `lint/`, nothing async.
|
|
17
|
+
* The docset arrives already resolved to plain arrays, so "no I/O below the lint edge" is a
|
|
18
|
+
* matter of shape rather than of trust.
|
|
19
|
+
*/
|
|
20
|
+
import type { FilterEntry, LiquidHtmlNode, ObjectEntry, TagEntry } from '@platformos/platformos-check-common';
|
|
21
|
+
import type { ValidateCodeDiagnostic } from '../result/types.js';
|
|
22
|
+
/**
|
|
23
|
+
* The docset, resolved to data.
|
|
24
|
+
*
|
|
25
|
+
* Plain arrays rather than the `AugmentedPlatformOSDocset` itself, whose methods return
|
|
26
|
+
* promises and would let a future edit `await` inside the pure stage. The caller resolves it
|
|
27
|
+
* once per request and hands over the result.
|
|
28
|
+
*/
|
|
29
|
+
export interface DocsetVocabulary {
|
|
30
|
+
filters: readonly FilterEntry[];
|
|
31
|
+
tags: readonly TagEntry[];
|
|
32
|
+
objects: readonly ObjectEntry[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The checks for which a published SIGNATURE is the answer.
|
|
36
|
+
*
|
|
37
|
+
* Policy, not documentation — a statement about which findings an agent can act on with a
|
|
38
|
+
* signature in front of it, the same kind of judgement as `BLOCKING_CHECKS`. Each entry
|
|
39
|
+
* says why, because "it seemed relevant" is how this grows into noise.
|
|
40
|
+
*
|
|
41
|
+
* DEFAULTS TO SILENCE. A check not listed gets no hint, so a check added upstream cannot
|
|
42
|
+
* start emitting an unrelated wall of markdown before anyone has looked at it.
|
|
43
|
+
*
|
|
44
|
+
* A list rather than a structural rule, because no structural rule separates the useful
|
|
45
|
+
* case from the useless one: `{% render 'ghost' %}` resolves to the `render` TAG, so
|
|
46
|
+
* rendering whatever symbol an offense sits on answers "'ghost' does not exist" with
|
|
47
|
+
* general documentation about how `render` works.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SIGNATURE_HINT_CHECKS: ReadonlySet<string>;
|
|
50
|
+
/** One diagnostic, with the buffer offset the engine reported it at. */
|
|
51
|
+
export interface EnrichInput {
|
|
52
|
+
diagnostic: ValidateCodeDiagnostic;
|
|
53
|
+
/**
|
|
54
|
+
* 0-based offset of the offense start, in the buffer as sent. Carried alongside rather
|
|
55
|
+
* than added to `ValidateCodeDiagnostic`, which an agent reads.
|
|
56
|
+
*
|
|
57
|
+
* OPTIONAL, and absence means "do not resolve a symbol for this one" — not spelled as an
|
|
58
|
+
* out-of-range sentinel, which only LOOKS inert: `findCurrentNode` always answers, and at
|
|
59
|
+
* an impossible offset it answers `Document`.
|
|
60
|
+
*/
|
|
61
|
+
startIndex?: number;
|
|
62
|
+
}
|
|
63
|
+
/** Everything enrichment reads. All of it data; none of it fetched here. */
|
|
64
|
+
export interface EnrichContext {
|
|
65
|
+
/**
|
|
66
|
+
* The buffer's Liquid tree, as returned by the lint that produced these diagnostics.
|
|
67
|
+
*
|
|
68
|
+
* `undefined` for a buffer with no Liquid tree — GraphQL, YAML, or a file that did not
|
|
69
|
+
* parse. Those diagnostics simply get no symbol hint; the check's documentation URL
|
|
70
|
+
* still attaches, because it does not depend on the tree.
|
|
71
|
+
*/
|
|
72
|
+
ast?: LiquidHtmlNode;
|
|
73
|
+
vocabulary: DocsetVocabulary;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Attach what we can to each diagnostic, and nothing we cannot.
|
|
77
|
+
*
|
|
78
|
+
* Returns NEW objects; the inputs are not mutated. A diagnostic gains a key only when there
|
|
79
|
+
* is a real value for it — never an empty string or a placeholder — so an agent can read
|
|
80
|
+
* absence as "nothing more is known".
|
|
81
|
+
*
|
|
82
|
+
* ONE HINT PER SYMBOL PER FILE. A signature is ~880 bytes of markdown and it is the SAME
|
|
83
|
+
* bytes every time the symbol is misused, so repeats would spend the diagnostic budget
|
|
84
|
+
* (`result/response-budget.ts`) restating one paragraph. The FIRST occurrence keeps it,
|
|
85
|
+
* which is also the one truncation keeps: the cap retains the top of the file.
|
|
86
|
+
*/
|
|
87
|
+
export declare function enrichDiagnostics(inputs: readonly EnrichInput[], context: EnrichContext): ValidateCodeDiagnostic[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Deep import, NOT the package root: measured at +5 ms against +110 ms, the difference
|
|
2
|
+
// being the css / json / graphql language services this package never uses. The bin is
|
|
3
|
+
// launched once per agent session, so that is paid every session. See ADR 002 Amendment 1.
|
|
4
|
+
import { render } from '@platformos/platformos-language-server-common/dist/docset/index.js';
|
|
5
|
+
import { checkDocs } from '../check-docs.js';
|
|
6
|
+
import { documentedSymbolAt } from './symbol.js';
|
|
7
|
+
/**
|
|
8
|
+
* The checks for which a published SIGNATURE is the answer.
|
|
9
|
+
*
|
|
10
|
+
* Policy, not documentation — a statement about which findings an agent can act on with a
|
|
11
|
+
* signature in front of it, the same kind of judgement as `BLOCKING_CHECKS`. Each entry
|
|
12
|
+
* says why, because "it seemed relevant" is how this grows into noise.
|
|
13
|
+
*
|
|
14
|
+
* DEFAULTS TO SILENCE. A check not listed gets no hint, so a check added upstream cannot
|
|
15
|
+
* start emitting an unrelated wall of markdown before anyone has looked at it.
|
|
16
|
+
*
|
|
17
|
+
* A list rather than a structural rule, because no structural rule separates the useful
|
|
18
|
+
* case from the useless one: `{% render 'ghost' %}` resolves to the `render` TAG, so
|
|
19
|
+
* rendering whatever symbol an offense sits on answers "'ghost' does not exist" with
|
|
20
|
+
* general documentation about how `render` works.
|
|
21
|
+
*/
|
|
22
|
+
export const SIGNATURE_HINT_CHECKS = new Set([
|
|
23
|
+
// "given 1, expected 2" — the published arity IS the fix, and it is the one thing the
|
|
24
|
+
// message cannot carry without restating the documentation.
|
|
25
|
+
'FilterArity',
|
|
26
|
+
// An argument of the wrong type. What the filter actually accepts is the answer, and
|
|
27
|
+
// the parameter list is where it is written down.
|
|
28
|
+
'ValidFilterArgumentTypes',
|
|
29
|
+
// The same for a tag's arguments: `{% for x in y limit: 'ten' %}` is answered by the
|
|
30
|
+
// `for` entry's parameter types.
|
|
31
|
+
'ValidTagArgumentTypes',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Attach what we can to each diagnostic, and nothing we cannot.
|
|
35
|
+
*
|
|
36
|
+
* Returns NEW objects; the inputs are not mutated. A diagnostic gains a key only when there
|
|
37
|
+
* is a real value for it — never an empty string or a placeholder — so an agent can read
|
|
38
|
+
* absence as "nothing more is known".
|
|
39
|
+
*
|
|
40
|
+
* ONE HINT PER SYMBOL PER FILE. A signature is ~880 bytes of markdown and it is the SAME
|
|
41
|
+
* bytes every time the symbol is misused, so repeats would spend the diagnostic budget
|
|
42
|
+
* (`result/response-budget.ts`) restating one paragraph. The FIRST occurrence keeps it,
|
|
43
|
+
* which is also the one truncation keeps: the cap retains the top of the file.
|
|
44
|
+
*/
|
|
45
|
+
export function enrichDiagnostics(inputs, context) {
|
|
46
|
+
const rendered = new Set();
|
|
47
|
+
return inputs.map(({ diagnostic, startIndex }) => {
|
|
48
|
+
const enriched = { ...diagnostic };
|
|
49
|
+
const url = checkDocs(diagnostic.check)?.url;
|
|
50
|
+
if (url)
|
|
51
|
+
enriched.see_also = url;
|
|
52
|
+
const hint = hintFor(diagnostic.check, startIndex, context, rendered);
|
|
53
|
+
if (hint)
|
|
54
|
+
enriched.hint = hint;
|
|
55
|
+
return enriched;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The rendered docset entry for the symbol at `offset`, when the docset publishes one and
|
|
60
|
+
* this file has not already been shown it.
|
|
61
|
+
*
|
|
62
|
+
* NOTHING when it does not: a symbol the docset has never heard of has no signature to
|
|
63
|
+
* show, and the engine's own `suggestions` are what help there.
|
|
64
|
+
*
|
|
65
|
+
* Searched from the offset OUTWARDS through the ancestors — `{% for x in y limit: 'ten' %}`
|
|
66
|
+
* reports on the argument, and the `for` entry that answers it is three levels up.
|
|
67
|
+
*
|
|
68
|
+
* `rendered` is mutated only when a hint is actually produced, so an unpublished symbol
|
|
69
|
+
* cannot consume the slot its name has and silence a later published one.
|
|
70
|
+
*/
|
|
71
|
+
function hintFor(check, offset, context, rendered) {
|
|
72
|
+
if (!SIGNATURE_HINT_CHECKS.has(check))
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!context.ast || offset === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
const symbol = documentedSymbolAt(context.ast, offset);
|
|
77
|
+
if (!symbol)
|
|
78
|
+
return undefined;
|
|
79
|
+
const key = `${symbol.kind}:${symbol.name}`;
|
|
80
|
+
if (rendered.has(key))
|
|
81
|
+
return undefined;
|
|
82
|
+
const entry = entryFor(symbol, context.vocabulary);
|
|
83
|
+
if (!entry)
|
|
84
|
+
return undefined;
|
|
85
|
+
rendered.add(key);
|
|
86
|
+
return render(entry, undefined, symbol.kind);
|
|
87
|
+
}
|
|
88
|
+
/** The published entry for a symbol, matched by exact name within its own kind. */
|
|
89
|
+
function entryFor(symbol, vocabulary) {
|
|
90
|
+
const entries = symbol.kind === 'filter'
|
|
91
|
+
? vocabulary.filters
|
|
92
|
+
: symbol.kind === 'tag'
|
|
93
|
+
? vocabulary.tags
|
|
94
|
+
: vocabulary.objects;
|
|
95
|
+
return entries.find((entry) => entry.name === symbol.name);
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=enrich.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|