@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,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>;
@@ -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 {};