@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,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
|