@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,201 @@
1
+ import { allChecks } from '@platformos/platformos-check-common';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { BLOCKING_CHECKS, blocksWrite } from './blocking.js';
4
+ /**
5
+ * `must_fix_before_write` is not `errors.length > 0`: check-common severities are
6
+ * calibrated for a linter in an editor rather than for a write gate, so passing an argument
7
+ * a partial does not declare is `Severity.ERROR` while platformOS ignores it and the page
8
+ * renders fine. The gate answers only "will this file be broken if I write it?".
9
+ */
10
+ const at = (check, severity = 'error') => ({
11
+ check,
12
+ severity,
13
+ });
14
+ describe('Unit: blocksWrite', () => {
15
+ describe('blocks on findings that mean the file will not work', () => {
16
+ it.each([
17
+ ['does not parse', 'LiquidHTMLSyntaxError'],
18
+ ['does not parse, and the converter rejects the changeset', 'YAMLSyntaxError'],
19
+ ['broken reference', 'MissingPartial'],
20
+ ['platformOS raises on it', 'UnknownFilter'],
21
+ ['a known filter, wrong argument count — same raise', 'FilterArity'],
22
+ ['raises, and the deploy converter rejects the changeset', 'JsonLiteralQuoteStyle'],
23
+ ['renders an empty page body', 'MissingContentForLayout'],
24
+ ['a declared contract was violated', 'MissingRenderPartialArguments'],
25
+ ['the query fails when executed', 'GraphQLCheck'],
26
+ ['the query fails when executed', 'GraphQLVariablesCheck'],
27
+ ['runtime error', 'InvalidWriteTarget'],
28
+ ])('%s: %s', (_why, check) => {
29
+ expect(blocksWrite([at(check)])).toBe(true);
30
+ });
31
+ });
32
+ describe('does NOT block on findings the file survives', () => {
33
+ it.each([
34
+ // The reported bug. platformOS ignores an undeclared argument.
35
+ ['a dead/unknown argument', 'PartialCallArguments'],
36
+ ['the same dead argument, again', 'UnrecognizedRenderPartialArguments'],
37
+ ['a missing key renders the key', 'TranslationKeyExists'],
38
+ ['a missing key renders the key', 'MatchingTranslations'],
39
+ ['resolves to nil', 'UnknownProperty'],
40
+ ['doc hygiene, no runtime effect', 'UniqueDocParamNames'],
41
+ ['doc hygiene, no runtime effect', 'ValidDocParamTypes'],
42
+ // Both measured against a live instance: the page renders and returns HTTP 200.
43
+ ['the asset 404s, the page is fine', 'MissingAsset'],
44
+ ['visibly wrong, still a working page', 'ReservedVariableName'],
45
+ ['performance advice', 'ImgWidthAndHeight'],
46
+ ['performance advice', 'ParserBlockingScript'],
47
+ ])('%s: %s', (_why, check) => {
48
+ expect(blocksWrite([at(check)])).toBe(false);
49
+ });
50
+ });
51
+ it('treats an UNRECOGNIZED check code as non-blocking', () => {
52
+ // Load-bearing: check-common gains checks over time, and a gate that blocked on codes
53
+ // it had never heard of would silently over-block every time the engine grew.
54
+ expect(blocksWrite([at('SomeBrandNewCheck')])).toBe(false);
55
+ expect(blocksWrite([at('community-extension:Whatever')])).toBe(false);
56
+ });
57
+ it('blocks when a blocking finding is MIXED IN with non-blocking ones', () => {
58
+ expect(blocksWrite([
59
+ at('PartialCallArguments'),
60
+ at('ImgWidthAndHeight'),
61
+ at('MissingPartial'), // the one that matters
62
+ at('UnknownProperty'),
63
+ ])).toBe(true);
64
+ });
65
+ it('does not block when every finding is non-blocking, however many', () => {
66
+ expect(blocksWrite([
67
+ at('PartialCallArguments'),
68
+ at('UnrecognizedRenderPartialArguments'),
69
+ at('ImgWidthAndHeight'),
70
+ at('ParserBlockingScript'),
71
+ at('UnknownProperty'),
72
+ ])).toBe(false);
73
+ });
74
+ it('does not block on an empty list', () => {
75
+ expect(blocksWrite([])).toBe(false);
76
+ });
77
+ it('ignores a blocking code carrying a NON-error severity', () => {
78
+ // A check reconfigured down to warning in a project's config has been
79
+ // deliberately de-escalated by its owner; the gate must respect that.
80
+ expect(blocksWrite([at('MissingPartial', 'warning')])).toBe(false);
81
+ expect(blocksWrite([at('MissingPartial', 'info')])).toBe(false);
82
+ expect(blocksWrite([at('MissingPartial', 'error')])).toBe(true);
83
+ });
84
+ it('exposes the set so the membership decision is inspectable, not implicit', () => {
85
+ // Pinned exactly: adding or removing an entry is a contract change to the write
86
+ // gate and must be a deliberate edit here, not a side effect elsewhere.
87
+ expect([...BLOCKING_CHECKS].sort()).toEqual([
88
+ 'FilterArity',
89
+ 'GraphQLCheck',
90
+ 'GraphQLVariablesCheck',
91
+ 'InvalidWriteTarget',
92
+ 'JsonLiteralQuoteStyle',
93
+ 'LiquidHTMLSyntaxError',
94
+ 'MissingContentForLayout',
95
+ 'MissingPartial',
96
+ 'MissingRenderPartialArguments',
97
+ 'UnknownFilter',
98
+ 'YAMLSyntaxError',
99
+ ]);
100
+ });
101
+ it('does not block the two codes NO accepted buffer can ever produce', () => {
102
+ // Removed on reachability, not on severity — both are `SourceCodeType.JSON` checks, and
103
+ // this server never routes a JSON-typed file to anything. Pinned rather than merely
104
+ // deleted because they READ as obviously belonging; the "every blocking check can
105
+ // actually block" group in `transport/validate-code.spec.ts` holds the behavioural proof.
106
+ expect([BLOCKING_CHECKS.has('ValidJSON'), BLOCKING_CHECKS.has('JSONSyntaxError')]).toEqual([
107
+ false,
108
+ false,
109
+ ]);
110
+ });
111
+ /**
112
+ * Three entries were measured against a live instance and found wrong. These pin the
113
+ * corrections individually, so a future edit that "restores" one has to argue with the
114
+ * evidence rather than silently reverting it.
115
+ *
116
+ * `MissingAsset`: `{{ 'no_such.css' | asset_url }}` renders, page HTTP 200, deploy
117
+ * accepted. `ReservedVariableName`: `{% assign blank = 'oops' %}` renders `[]`, HTTP 200.
118
+ * `JsonLiteralQuoteStyle`: `{% assign o = {'k': 'v'} %}` raises `Invalid JSON in assign`
119
+ * AND is rejected by `--dry-run`, failing the whole changeset.
120
+ */
121
+ it('does not block the two findings measured to render a working page', () => {
122
+ expect(BLOCKING_CHECKS.has('MissingAsset')).toBe(false);
123
+ expect(BLOCKING_CHECKS.has('ReservedVariableName')).toBe(false);
124
+ expect(blocksWrite([at('MissingAsset'), at('ReservedVariableName')])).toBe(false);
125
+ });
126
+ it('blocks the JSON literal quote style, which is fatal at runtime AND on deploy', () => {
127
+ expect(BLOCKING_CHECKS.has('JsonLiteralQuoteStyle')).toBe(true);
128
+ expect(blocksWrite([at('JsonLiteralQuoteStyle')])).toBe(true);
129
+ });
130
+ /**
131
+ * `InvalidWriteTarget` stays on its OWN evidence rather than a neighbour's shared
132
+ * "runtime errors" justification, which measurement disproved for `ReservedVariableName`:
133
+ * `hash_assign` against a number, string, boolean or range each raises
134
+ * `HashAssignTagError`, while the object form it permits renders HTTP 200.
135
+ *
136
+ * Its old over-report on filter-produced arrays was a precision bug in the check rather
137
+ * than grounds to de-block — de-blocking would have approved the cases that genuinely
138
+ * raise.
139
+ */
140
+ it('blocks the hash_assign target error, on its own measured evidence', () => {
141
+ expect(BLOCKING_CHECKS.has('InvalidWriteTarget')).toBe(true);
142
+ expect(blocksWrite([at('InvalidWriteTarget')])).toBe(true);
143
+ });
144
+ it('still reports a de-escalated finding as non-blocking, including the new member', () => {
145
+ // The gate reads severity as well as code, so a project that downgrades
146
+ // `JsonLiteralQuoteStyle` in its config is respected exactly like any other member.
147
+ expect(blocksWrite([at('JsonLiteralQuoteStyle', 'warning')])).toBe(false);
148
+ });
149
+ it('does NOT contain the dead-argument checks — the bug this fixes', () => {
150
+ expect(BLOCKING_CHECKS.has('PartialCallArguments')).toBe(false);
151
+ expect(BLOCKING_CHECKS.has('UnrecognizedRenderPartialArguments')).toBe(false);
152
+ });
153
+ /**
154
+ * `PartialCallArguments` reports two different things under ONE code: a dead argument
155
+ * (harmless) and a missing required one (real). There is no structured discriminator on
156
+ * the offense and non-goal #2 forbids regex over messages, so it is non-blocking
157
+ * wholesale.
158
+ *
159
+ * Safe only because the blocking half is covered independently — a partial WITH a
160
+ * `{% doc %}` block is owned by `MissingRenderPartialArguments`. What stays unblocked is
161
+ * a DOC-LESS partial whose required params are INFERRED from usage.
162
+ */
163
+ it('still blocks a missing required arg via the doc-based check', () => {
164
+ expect(blocksWrite([at('MissingRenderPartialArguments'), at('PartialCallArguments')])).toBe(true);
165
+ });
166
+ it('does not block a doc-less inferred requirement (documented trade-off)', () => {
167
+ // Only PartialCallArguments fires for a doc-less partial. An inferred requirement is a
168
+ // heuristic, and the failure mode is a nil value rather than a crash.
169
+ expect(blocksWrite([at('PartialCallArguments')])).toBe(false);
170
+ });
171
+ });
172
+ /**
173
+ * The gate is POLICY, and policy about check codes goes stale silently.
174
+ *
175
+ * Every entry is a hand-written string, and check-common owns the codes. A check renamed
176
+ * or removed upstream leaves a dead entry here that simply stops matching — the gate
177
+ * quietly stops blocking a thing it was written to block, no test fails, and the symptom
178
+ * is an agent being told to write a broken file. That is the one failure mode this set
179
+ * can have that nothing else would catch.
180
+ */
181
+ describe('Unit: BLOCKING_CHECKS is tied to the check registry', () => {
182
+ const registered = new Set(allChecks.map((check) => check.meta.code));
183
+ it('names only checks that exist in check-common', () => {
184
+ expect([...BLOCKING_CHECKS].filter((code) => !registered.has(code)).sort()).toEqual([]);
185
+ });
186
+ /**
187
+ * The CONVERSE is deliberately not asserted, and this test exists to say so rather than
188
+ * leave the absence looking like an oversight.
189
+ *
190
+ * Blocking is not severity: `blocksWrite` requires an `error` AND membership here, so a
191
+ * new check is non-blocking until someone argues it into the set. Requiring every check
192
+ * to be classified would invert that default.
193
+ */
194
+ it('does not require every registered check to be classified: silence is the default', () => {
195
+ const unclassified = [...registered].filter((code) => !BLOCKING_CHECKS.has(code));
196
+ // Most checks are not blocking, and that is the intended state, not a gap.
197
+ expect(unclassified.length > 0).toBe(true);
198
+ expect(blocksWrite([at(unclassified[0])])).toBe(false);
199
+ });
200
+ });
201
+ //# sourceMappingURL=blocking.spec.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The non-`computed` blast-radius states.
3
+ *
4
+ * Each is a FACTORY, not a shared constant: the zeroed `dependents` object is mutable and
5
+ * ends up inside a returned result, so one shared instance would let a mutation reach every
6
+ * past and future response.
7
+ *
8
+ * IMPORTANT: `dependents` is meaningful ONLY when `status` is `computed`. Every state here
9
+ * zeroes it, and a zero must NEVER be read as "nothing depends on this, safe to change" —
10
+ * that is what `status` is for.
11
+ */
12
+ import type { ValidateCodeImpact } from './types.js';
13
+ /** The blast radius could not be computed at all — a failure, or a deadline. */
14
+ export declare const UNAVAILABLE_IMPACT: () => ValidateCodeImpact;
15
+ /** This file has no dependency edges to speak of — see `ValidateCodeImpactStatus`. */
16
+ export declare const NOT_APPLICABLE_IMPACT: () => ValidateCodeImpact;
@@ -0,0 +1,18 @@
1
+ const noDependents = () => ({
2
+ total: 0,
3
+ by_kind: {},
4
+ sample: [],
5
+ });
6
+ /** The blast radius could not be computed at all — a failure, or a deadline. */
7
+ export const UNAVAILABLE_IMPACT = () => ({
8
+ scope: 'direct',
9
+ status: 'unavailable',
10
+ dependents: noDependents(),
11
+ });
12
+ /** This file has no dependency edges to speak of — see `ValidateCodeImpactStatus`. */
13
+ export const NOT_APPLICABLE_IMPACT = () => ({
14
+ scope: 'direct',
15
+ status: 'not_applicable',
16
+ dependents: noDependents(),
17
+ });
18
+ //# sourceMappingURL=impact-states.js.map
@@ -0,0 +1,9 @@
1
+ import type { ValidateCodeResult } from './types.js';
2
+ /**
3
+ * Apply the response bound to a whole request's results.
4
+ *
5
+ * Pure: returns new results, in the same iteration order, leaving the inputs alone. A result
6
+ * with nothing withheld is returned unchanged and carries no `truncated` field, so the
7
+ * field's presence is a reliable signal.
8
+ */
9
+ export declare function capToBudget(results: ReadonlyMap<string, ValidateCodeResult>, budgetBytes?: number): Map<string, ValidateCodeResult>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Bound the diagnostics one response carries, and say so when anything is withheld.
3
+ *
4
+ * IT RUNS AFTER ASSEMBLY, and that ordering is the design: this module never computes the
5
+ * gate, never re-derives `status`, and never sees an unassembled diagnostic. It takes
6
+ * finished results — whose verdict was computed from the COMPLETE set — and removes entries
7
+ * from the tails of their lists, so no ordering of operations here can soften the gate.
8
+ * `response-budget.spec.ts` asserts that with a buffer whose only blocking error sorts last.
9
+ *
10
+ * WHAT IS SPENT FIRST. The budget belongs to the whole request, so a batch cannot multiply
11
+ * it, and it is allocated in two orders at once:
12
+ *
13
+ * BY SEVERITY every file's errors before any file's warnings, warnings before infos.
14
+ * BY FILE round-robin within each severity, in the order the caller listed them, so
15
+ * one file with thousands of findings cannot starve the others.
16
+ *
17
+ * Within a file and bucket, entries are taken from the FRONT, so what survives is the head
18
+ * of a list already ordered by line and column — where the root cause of a cascade is.
19
+ *
20
+ * A STREAM THAT CANNOT FIT IS CLOSED, not skipped: once a bucket's next entry does not fit
21
+ * it takes nothing further, so the returned list stays a contiguous head rather than a
22
+ * scattered sample.
23
+ *
24
+ * ONE ERROR PER FILE IS GUARANTEED, budget or not — a blocked write with an empty `errors`
25
+ * list names a problem and then declines to say what it is. Bounded: one entry per file,
26
+ * and the batch form already caps files.
27
+ */
28
+ import { MAX_RESPONSE_DIAGNOSTIC_BYTES } from '../cost-model.js';
29
+ /** The buckets, in the order the budget is spent on them. */
30
+ const BUCKETS = ['errors', 'warnings', 'infos'];
31
+ /**
32
+ * What one diagnostic costs in the serialized response.
33
+ *
34
+ * Measured on the thing that is actually sent — the JSON — rather than estimated from a
35
+ * field count, because `hint`, `suggestion` and `fix` make entries vary by an order of
36
+ * magnitude. The `+ 1` is the separating comma.
37
+ */
38
+ function costOf(diagnostic) {
39
+ return Buffer.byteLength(JSON.stringify(diagnostic), 'utf8') + 1;
40
+ }
41
+ function truncationFor(result, taken) {
42
+ const withheld = {};
43
+ let total = 0;
44
+ let returned = 0;
45
+ for (const bucket of BUCKETS) {
46
+ const found = result[bucket].length;
47
+ total += found;
48
+ returned += taken[bucket];
49
+ if (taken[bucket] < found) {
50
+ withheld[bucket] = { returned: taken[bucket], total: found };
51
+ }
52
+ }
53
+ if (Object.keys(withheld).length === 0)
54
+ return undefined;
55
+ return {
56
+ ...withheld,
57
+ // Written for an agent that reads only this object, and kept SHORT: it repeats per
58
+ // file. Two claims earn their bytes — the list is partial, and the verdict is not.
59
+ note: `Showing ${returned} of ${total} findings, from the top of the file; the rest were withheld to bound ` +
60
+ `this response. "status" and "must_fix_before_write" reflect all ${total}. Fix these and validate again.`,
61
+ };
62
+ }
63
+ /**
64
+ * Apply the response bound to a whole request's results.
65
+ *
66
+ * Pure: returns new results, in the same iteration order, leaving the inputs alone. A result
67
+ * with nothing withheld is returned unchanged and carries no `truncated` field, so the
68
+ * field's presence is a reliable signal.
69
+ */
70
+ export function capToBudget(results, budgetBytes = MAX_RESPONSE_DIAGNOSTIC_BYTES) {
71
+ const streams = [];
72
+ for (const [key, result] of results) {
73
+ for (const bucket of BUCKETS) {
74
+ streams.push({ key, bucket, entries: result[bucket], taken: 0, open: true });
75
+ }
76
+ }
77
+ let spent = 0;
78
+ // The guarantee, taken first and charged honestly: it can push `spent` past the
79
+ // budget before the loop below starts, in which case that loop admits nothing more.
80
+ for (const stream of streams) {
81
+ if (stream.bucket !== 'errors' || stream.entries.length === 0)
82
+ continue;
83
+ spent += costOf(stream.entries[0]);
84
+ stream.taken = 1;
85
+ }
86
+ for (const bucket of BUCKETS) {
87
+ const inBucket = streams.filter((stream) => stream.bucket === bucket);
88
+ // Round-robin: one entry from each file in turn, until every stream in this
89
+ // bucket is exhausted or closed by the budget.
90
+ let progressed = true;
91
+ while (progressed) {
92
+ progressed = false;
93
+ for (const stream of inBucket) {
94
+ if (!stream.open || stream.taken >= stream.entries.length)
95
+ continue;
96
+ const cost = costOf(stream.entries[stream.taken]);
97
+ if (spent + cost > budgetBytes) {
98
+ stream.open = false;
99
+ continue;
100
+ }
101
+ spent += cost;
102
+ stream.taken += 1;
103
+ progressed = true;
104
+ }
105
+ }
106
+ }
107
+ const takenByKey = new Map();
108
+ for (const stream of streams) {
109
+ const counts = takenByKey.get(stream.key) ?? { errors: 0, warnings: 0, infos: 0 };
110
+ counts[stream.bucket] = stream.taken;
111
+ takenByKey.set(stream.key, counts);
112
+ }
113
+ const capped = new Map();
114
+ for (const [key, result] of results) {
115
+ const taken = takenByKey.get(key);
116
+ const truncated = truncationFor(result, taken);
117
+ if (!truncated) {
118
+ capped.set(key, result);
119
+ continue;
120
+ }
121
+ capped.set(key, {
122
+ ...result,
123
+ errors: result.errors.slice(0, taken.errors),
124
+ warnings: result.warnings.slice(0, taken.warnings),
125
+ infos: result.infos.slice(0, taken.infos),
126
+ truncated,
127
+ });
128
+ }
129
+ return capped;
130
+ }
131
+ //# sourceMappingURL=response-budget.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { capToBudget } from './response-budget.js';
3
+ import { MAX_RESPONSE_DIAGNOSTIC_BYTES } from '../cost-model.js';
4
+ import { UNAVAILABLE_IMPACT } from './impact-states.js';
5
+ /**
6
+ * The response bound, tested where it is decidable: as a pure function over finished
7
+ * results.
8
+ *
9
+ * ONE ASSERTION MATTERS MORE THAN THE REST — the one about the gate. A cap that drops the
10
+ * diagnostic holding `must_fix_before_write` true, while leaving the flag false,
11
+ * manufactures a false approval on exactly the inputs nobody inspects. The architecture is
12
+ * meant to make that impossible, and the case below is what turns that claim into a
13
+ * measurement.
14
+ */
15
+ const diagnostic = (line, severity, check = 'SomeCheck', padding = '') => ({
16
+ check,
17
+ severity,
18
+ message: `finding at ${line}${padding}`,
19
+ line,
20
+ column: 1,
21
+ });
22
+ const resultWith = (errors, warnings = [], infos = [], blocked = errors.length > 0) => ({
23
+ status: errors.length > 0 ? 'error' : warnings.length > 0 ? 'warning' : 'ok',
24
+ must_fix_before_write: blocked,
25
+ errors,
26
+ warnings,
27
+ infos,
28
+ impact: UNAVAILABLE_IMPACT(),
29
+ });
30
+ const many = (count, severity = 'error') => Array.from({ length: count }, (_, index) => diagnostic(index + 1, severity));
31
+ /** Serialized size of the diagnostics a capped result actually carries. */
32
+ const diagnosticBytes = (results) => {
33
+ let bytes = 0;
34
+ for (const result of results.values()) {
35
+ for (const bucket of [result.errors, result.warnings, result.infos]) {
36
+ for (const entry of bucket)
37
+ bytes += Buffer.byteLength(JSON.stringify(entry), 'utf8') + 1;
38
+ }
39
+ }
40
+ return bytes;
41
+ };
42
+ describe('Unit: capToBudget', () => {
43
+ it('leaves a result that fits completely untouched, with NO truncation field', async () => {
44
+ // The presence of `truncated` has to be a reliable signal. A stub emitted on
45
+ // every response would make an agent inspect it to learn nothing, which is the
46
+ // same reasoning that removed the permanently-empty fields from this contract.
47
+ const results = new Map([['a.liquid', resultWith(many(3))]]);
48
+ const capped = capToBudget(results);
49
+ expect(capped.get('a.liquid')).toEqual(results.get('a.liquid'));
50
+ expect(capped.get('a.liquid').truncated).toBeUndefined();
51
+ });
52
+ it('NEVER softens the gate: the blocking error may be withheld, the verdict may not', async () => {
53
+ // The case the whole design exists for. One thousand findings where the ONLY
54
+ // blocking one sorts last, so any cap that recomputed the verdict from what it
55
+ // returned would flip `must_fix_before_write` to false and tell the agent to
56
+ // write a file the server knows is broken.
57
+ const errors = [...many(999), diagnostic(1000, 'error', 'MissingPartial')];
58
+ const results = new Map([['a.liquid', resultWith(errors, [], [], true)]]);
59
+ const capped = capToBudget(results, 500);
60
+ const result = capped.get('a.liquid');
61
+ expect({
62
+ blocked: result.must_fix_before_write,
63
+ status: result.status,
64
+ returnedFewer: result.errors.length < errors.length,
65
+ blockingOneWithheld: !result.errors.some((error) => error.check === 'MissingPartial'),
66
+ total: result.truncated.errors.total,
67
+ }).toEqual({
68
+ blocked: true,
69
+ status: 'error',
70
+ returnedFewer: true,
71
+ blockingOneWithheld: true,
72
+ total: 1000,
73
+ });
74
+ });
75
+ it('reports the true total per affected bucket, distinguishable from what it returned', async () => {
76
+ const results = new Map([
77
+ ['a.liquid', resultWith(many(50), many(40, 'warning'), many(30, 'info'))],
78
+ ]);
79
+ const capped = capToBudget(results, 900);
80
+ const result = capped.get('a.liquid');
81
+ expect({
82
+ errors: result.truncated.errors,
83
+ returnedErrors: result.errors.length,
84
+ warnings: result.truncated.warnings,
85
+ infos: result.truncated.infos,
86
+ }).toEqual({
87
+ errors: { returned: result.errors.length, total: 50 },
88
+ returnedErrors: result.errors.length,
89
+ warnings: { returned: 0, total: 40 },
90
+ infos: { returned: 0, total: 30 },
91
+ });
92
+ });
93
+ it('names only the buckets it actually withheld from', async () => {
94
+ // A bucket that came back whole must not appear. Otherwise "which of my lists is
95
+ // partial" becomes a question an agent answers by comparing numbers.
96
+ const results = new Map([['a.liquid', resultWith(many(2), many(400, 'warning'))]]);
97
+ const capped = capToBudget(results, 800);
98
+ const truncated = capped.get('a.liquid').truncated;
99
+ expect({
100
+ errors: truncated.errors,
101
+ warnings: truncated.warnings !== undefined,
102
+ infos: truncated.infos,
103
+ }).toEqual({ errors: undefined, warnings: true, infos: undefined });
104
+ });
105
+ it('spends the budget on errors before any info, across every file', async () => {
106
+ // Severity-major allocation. A file that is nothing but infos must not consume
107
+ // the budget a LATER file's errors need — note the noisy file is listed first, so
108
+ // a purely round-robin allocation would serve it before seeing the errors at all.
109
+ //
110
+ // Asserted as the RELATIONSHIP rather than a count: "17 of 20 errors fit in 1500 bytes"
111
+ // is arithmetic about this fixture's message lengths. The property is that while any
112
+ // error is still being withheld, no info has been served.
113
+ const results = new Map([
114
+ ['noisy.liquid', resultWith([], [], many(500, 'info'))],
115
+ ['broken.liquid', resultWith(many(20))],
116
+ ]);
117
+ const capped = capToBudget(results, 1_500);
118
+ expect({
119
+ errorsWithheld: capped.get('broken.liquid').truncated?.errors !== undefined,
120
+ infosReturned: capped.get('noisy.liquid').infos.length,
121
+ }).toEqual({ errorsWithheld: true, infosReturned: 0 });
122
+ });
123
+ it('still serves infos once every error fits, so lower severities are not starved needlessly', async () => {
124
+ // The other half of the same rule. Errors first must not mean infos never.
125
+ const results = new Map([
126
+ ['noisy.liquid', resultWith([], [], many(500, 'info'))],
127
+ ['broken.liquid', resultWith(many(20))],
128
+ ]);
129
+ const capped = capToBudget(results, 20_000);
130
+ expect({
131
+ errorsWhole: capped.get('broken.liquid').truncated,
132
+ someInfos: capped.get('noisy.liquid').infos.length > 0,
133
+ infosPartial: capped.get('noisy.liquid').truncated?.infos?.total,
134
+ }).toEqual({ errorsWhole: undefined, someInfos: true, infosPartial: 500 });
135
+ });
136
+ it('shares the budget round-robin, so one loud file cannot starve the others', async () => {
137
+ // The realistic batch shape: one file with a cascading failure and three ordinary
138
+ // ones. Every file must come back with something to act on.
139
+ const results = new Map([
140
+ ['cascade.liquid', resultWith(many(2000))],
141
+ ['b.liquid', resultWith(many(3))],
142
+ ['c.liquid', resultWith(many(3))],
143
+ ['d.liquid', resultWith(many(3))],
144
+ ]);
145
+ const capped = capToBudget(results, 2_000);
146
+ expect([
147
+ capped.get('b.liquid').errors.length,
148
+ capped.get('c.liquid').errors.length,
149
+ capped.get('d.liquid').errors.length,
150
+ ]).toEqual([3, 3, 3]);
151
+ });
152
+ it('keeps the HEAD of each list, so what survives is the top of the file', async () => {
153
+ // Diagnostics arrive ordered by line and column, and a cascade's root cause is
154
+ // almost always the first entry. Returning a scattered sample would also make
155
+ // "returned 12 of 300" meaningless about WHICH twelve.
156
+ const results = new Map([['a.liquid', resultWith(many(100))]]);
157
+ const capped = capToBudget(results, 400);
158
+ const lines = capped.get('a.liquid').errors.map((error) => error.line);
159
+ expect(lines).toEqual(Array.from({ length: lines.length }, (_, index) => index + 1));
160
+ });
161
+ it('closes a bucket at the first entry that does not fit, rather than skipping it', async () => {
162
+ // Contiguity again, from the other direction: a huge entry in the middle must
163
+ // stop the list there instead of being stepped over to collect smaller ones.
164
+ const errors = [
165
+ diagnostic(1, 'error'),
166
+ diagnostic(2, 'error', 'SomeCheck', 'x'.repeat(5_000)),
167
+ diagnostic(3, 'error'),
168
+ ];
169
+ const results = new Map([['a.liquid', resultWith(errors)]]);
170
+ const capped = capToBudget(results, 1_000);
171
+ expect(capped.get('a.liquid').errors.map((error) => error.line)).toEqual([1]);
172
+ });
173
+ it('always returns one error per file, even when a single entry exceeds the whole budget', async () => {
174
+ // A blocked write with an empty `errors` list names a problem and then refuses to
175
+ // say what it is. The guarantee is bounded to one entry per file.
176
+ const enormous = [
177
+ diagnostic(1, 'error', 'MissingPartial', 'x'.repeat(50_000)),
178
+ diagnostic(2, 'error'),
179
+ ];
180
+ const results = new Map([['a.liquid', resultWith(enormous, [], [], true)]]);
181
+ const capped = capToBudget(results, 100);
182
+ const result = capped.get('a.liquid');
183
+ expect({
184
+ returned: result.errors.length,
185
+ firstCheck: result.errors[0].check,
186
+ blocked: result.must_fix_before_write,
187
+ total: result.truncated.errors.total,
188
+ }).toEqual({ returned: 1, firstCheck: 'MissingPartial', blocked: true, total: 2 });
189
+ });
190
+ it('bounds the REQUEST, not each file, so a 50-file batch cannot multiply the cap', async () => {
191
+ // The bound that per-file capping alone would miss, and the reason the batch form
192
+ // needed its own answer: 50 files under a per-file cap is 50x the intended size.
193
+ const results = new Map(Array.from({ length: 50 }, (_, index) => [`f${index}.liquid`, resultWith(many(500))]));
194
+ const capped = capToBudget(results);
195
+ // The guarantee costs one error per file on top of the budget; that overshoot is
196
+ // bounded by the file cap and is asserted rather than hidden.
197
+ expect(diagnosticBytes(capped)).toBeLessThan(MAX_RESPONSE_DIAGNOSTIC_BYTES * 1.5);
198
+ });
199
+ it('leaves a not_applicable result exactly as it was', async () => {
200
+ // Nothing to withhold, and nothing that should acquire a truncation field.
201
+ const declined = {
202
+ status: 'not_applicable',
203
+ not_applicable_reason: 'too_large',
204
+ must_fix_before_write: false,
205
+ errors: [],
206
+ warnings: [],
207
+ infos: [],
208
+ impact: UNAVAILABLE_IMPACT(),
209
+ next_step: 'refused',
210
+ };
211
+ expect(capToBudget(new Map([['a.liquid', declined]])).get('a.liquid')).toEqual(declined);
212
+ });
213
+ it('preserves the caller order of the map, so a batch still lines up with its request', async () => {
214
+ const results = new Map([
215
+ ['z.liquid', resultWith(many(2))],
216
+ ['a.liquid', resultWith(many(2))],
217
+ ['m.liquid', resultWith(many(2))],
218
+ ]);
219
+ expect([...capToBudget(results).keys()]).toEqual(['z.liquid', 'a.liquid', 'm.liquid']);
220
+ });
221
+ });
222
+ //# sourceMappingURL=response-budget.spec.js.map