@openwop/openwop-conformance 2.37.0 → 2.38.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/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/cli.js +8 -18
- package/dist/lib/certification-bundle-v3.js +46 -18
- package/dist/lib/jcs.js +274 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +323 -57
- package/scenario-majors.json +7 -3
- package/schemas/CORPUS-STAMP.json +30 -30
- package/src/cli.ts +8 -16
- package/src/lib/certification-bundle-v3.ts +42 -17
- package/src/lib/front-mux.ts +44 -2
- package/src/lib/jcs.ts +229 -0
- package/src/lib/llm-cache-key-recipe.ts +10 -20
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/jcs-vectors.test.ts +108 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
- package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
- package/vectors/jcs-v1.json +294 -0
- package/vectors/semantic-request-digest-v2.json +52 -0
package/src/lib/jcs.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
|
|
3
|
+
*
|
|
4
|
+
* Every signature and digest in the corpus is over these bytes: pack
|
|
5
|
+
* signatures (`ed25519-canonical-json`), the certification-bundle attestation,
|
|
6
|
+
* `discovery.sha256`, `witnessSha256`, and the RFC 0150 semantic request digest.
|
|
7
|
+
* `conformance/vectors/jcs-v1.json` is the normative test of this module and of
|
|
8
|
+
* any other-language implementation.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points, because two of the refusals are invisible after a parse:
|
|
11
|
+
*
|
|
12
|
+
* - `canonicalJSON(value)` — the VALUE boundary. Refuses what a native value
|
|
13
|
+
* can still show: non-finite numbers, non-JSON values (undefined, functions,
|
|
14
|
+
* bigint, symbols, class instances such as Date) and lone surrogates.
|
|
15
|
+
* - `parseIJson(text)` — the TEXT boundary. `JSON.parse` keeps the last of two
|
|
16
|
+
* duplicate names and rounds `9007199254740993` to `…992` silently; both are
|
|
17
|
+
* refused here, before coercion. Read a document you will re-canonicalize
|
|
18
|
+
* through this, not `JSON.parse`.
|
|
19
|
+
*
|
|
20
|
+
* Why not `Object.keys(v).sort()` + `JSON.stringify` with no checks (the code
|
|
21
|
+
* this replaces): it is JCS for well-formed input — the default sort IS UTF-16
|
|
22
|
+
* code-unit order and ES number/string serialization IS JCS §3.2.2 — but it
|
|
23
|
+
* turns NaN into `null`, emits the text `undefined`, and signs a rounded
|
|
24
|
+
* integer. Each of those is a document two verifiers read differently with no
|
|
25
|
+
* error on either side.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type JcsRefusalKind = 'duplicate-name' | 'lone-surrogate' | 'integer-out-of-range' | 'non-finite' | 'not-json';
|
|
29
|
+
|
|
30
|
+
export class JcsRefusal extends Error {
|
|
31
|
+
readonly kind: JcsRefusalKind;
|
|
32
|
+
constructor(kind: JcsRefusalKind, message: string) {
|
|
33
|
+
super(`RFC 0212 §B refusal (${kind}): ${message}`);
|
|
34
|
+
this.name = 'JcsRefusal';
|
|
35
|
+
this.kind = kind;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A JSON number literal, matched in place (sticky) so parsing stays linear. */
|
|
40
|
+
const NUMBER = /-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/y;
|
|
41
|
+
|
|
42
|
+
/** ±(2^53 − 1), for the integer-literal refusal at the text boundary. */
|
|
43
|
+
const MAX_EXACT_BIG = 2n ** 53n - 1n;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* RFC 8785 §3.2.3 — compare by UTF-16 code units. Explicit rather than the
|
|
47
|
+
* `Array.prototype.sort` default so the rule is visible at every call site, and
|
|
48
|
+
* never `localeCompare`: collation is locale-dependent (Czech sorts `ch` after
|
|
49
|
+
* `h`; English puts `a` before `A` and ignores `-`), so a digest computed with
|
|
50
|
+
* it depends on the machine that computed it.
|
|
51
|
+
*/
|
|
52
|
+
export function codeUnitCompare(a: string, b: string): number {
|
|
53
|
+
const n = Math.min(a.length, b.length);
|
|
54
|
+
for (let i = 0; i < n; i += 1) {
|
|
55
|
+
const d = a.charCodeAt(i) - b.charCodeAt(i);
|
|
56
|
+
if (d !== 0) return d;
|
|
57
|
+
}
|
|
58
|
+
return a.length - b.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertWellFormed(s: string, where: string): void {
|
|
62
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
63
|
+
const u = s.charCodeAt(i);
|
|
64
|
+
if (u >= 0xd800 && u <= 0xdbff) {
|
|
65
|
+
const next = s.charCodeAt(i + 1);
|
|
66
|
+
if (next >= 0xdc00 && next <= 0xdfff) { i += 1; continue; }
|
|
67
|
+
throw new JcsRefusal('lone-surrogate', `lone high surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
68
|
+
}
|
|
69
|
+
if (u >= 0xdc00 && u <= 0xdfff) throw new JcsRefusal('lone-surrogate', `lone low surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function serializeNumber(n: number): string {
|
|
74
|
+
if (!Number.isFinite(n)) throw new JcsRefusal('non-finite', `${String(n)} is not a JSON number`);
|
|
75
|
+
// No magnitude check here: a double is already exact, and JCS serializes it
|
|
76
|
+
// (RFC 8785 Appendix B includes 9007199254740994). The integer-range refusal
|
|
77
|
+
// is about a LITERAL that no double holds, which only the text shows — see
|
|
78
|
+
// `parseIJson`.
|
|
79
|
+
return Object.is(n, -0) ? '0' : String(n);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPlainObject(v: object): boolean {
|
|
83
|
+
const proto = Object.getPrototypeOf(v) as unknown;
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** RFC 8785 serialization of an I-JSON value. Throws `JcsRefusal` instead of coercing. */
|
|
88
|
+
export function canonicalJSON(value: unknown): string {
|
|
89
|
+
if (value === null) return 'null';
|
|
90
|
+
switch (typeof value) {
|
|
91
|
+
case 'boolean': return value ? 'true' : 'false';
|
|
92
|
+
case 'number': return serializeNumber(value);
|
|
93
|
+
case 'string': assertWellFormed(value, 'a string'); return JSON.stringify(value);
|
|
94
|
+
case 'object': break;
|
|
95
|
+
default: throw new JcsRefusal('not-json', `a ${typeof value} is not a JSON value`);
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
const parts: string[] = [];
|
|
99
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
100
|
+
if (!(i in value)) throw new JcsRefusal('not-json', 'a sparse array hole is not a JSON value');
|
|
101
|
+
parts.push(canonicalJSON(value[i]));
|
|
102
|
+
}
|
|
103
|
+
return `[${parts.join(',')}]`;
|
|
104
|
+
}
|
|
105
|
+
if (!isPlainObject(value)) throw new JcsRefusal('not-json', `a ${(value as object).constructor?.name ?? 'non-plain'} object is not a JSON value`);
|
|
106
|
+
const obj = value as Record<string, unknown>;
|
|
107
|
+
const keys = Object.keys(obj).sort(codeUnitCompare);
|
|
108
|
+
return `{${keys.map((k) => { assertWellFormed(k, 'a member name'); return `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`; }).join(',')}}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse JSON text, refusing what `JSON.parse` would silently accept or change:
|
|
113
|
+
* duplicate member names, integer literals outside ±(2^53 − 1), literals that
|
|
114
|
+
* overflow to ±Infinity, and lone surrogates. Members are defined, not
|
|
115
|
+
* assigned, so a member named `__proto__` is data, not a prototype write.
|
|
116
|
+
*/
|
|
117
|
+
export function parseIJson(text: string): unknown {
|
|
118
|
+
let i = 0;
|
|
119
|
+
const fail = (m: string): never => { throw new JcsRefusal('not-json', `${m} at offset ${i}`); };
|
|
120
|
+
const ws = (): void => { while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i += 1; };
|
|
121
|
+
|
|
122
|
+
const str = (): string => {
|
|
123
|
+
if (text[i] !== '"') fail('expected a string');
|
|
124
|
+
i += 1;
|
|
125
|
+
let out = '';
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (i >= text.length) fail('unterminated string');
|
|
128
|
+
const c = text[i];
|
|
129
|
+
i += 1;
|
|
130
|
+
if (c === '"') break;
|
|
131
|
+
if (c === '\\') {
|
|
132
|
+
const e = text[i];
|
|
133
|
+
i += 1;
|
|
134
|
+
switch (e) {
|
|
135
|
+
case '"': out += '"'; break;
|
|
136
|
+
case '\\': out += '\\'; break;
|
|
137
|
+
case '/': out += '/'; break;
|
|
138
|
+
case 'b': out += '\b'; break;
|
|
139
|
+
case 'f': out += '\f'; break;
|
|
140
|
+
case 'n': out += '\n'; break;
|
|
141
|
+
case 'r': out += '\r'; break;
|
|
142
|
+
case 't': out += '\t'; break;
|
|
143
|
+
case 'u': {
|
|
144
|
+
const h = text.slice(i, i + 4);
|
|
145
|
+
if (!/^[0-9a-fA-F]{4}$/.test(h)) fail('bad \\u escape');
|
|
146
|
+
out += String.fromCharCode(parseInt(h, 16));
|
|
147
|
+
i += 4;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
default: fail('bad escape');
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if ((c as string).charCodeAt(0) < 0x20) fail('raw control character in a string');
|
|
154
|
+
out += c;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
assertWellFormed(out, 'a string');
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const num = (): number => {
|
|
162
|
+
NUMBER.lastIndex = i;
|
|
163
|
+
const m = NUMBER.exec(text);
|
|
164
|
+
if (m === null) return fail('bad number');
|
|
165
|
+
i += m[0].length;
|
|
166
|
+
if (m[2] === undefined && m[3] === undefined) {
|
|
167
|
+
const b = BigInt(m[0]);
|
|
168
|
+
if (b > MAX_EXACT_BIG || b < -MAX_EXACT_BIG) throw new JcsRefusal('integer-out-of-range', `integer literal ${m[0]} is outside ±(2^53 − 1)`);
|
|
169
|
+
}
|
|
170
|
+
const v = Number(m[0]);
|
|
171
|
+
if (!Number.isFinite(v)) throw new JcsRefusal('non-finite', `${m[0]} overflows to a non-finite number`);
|
|
172
|
+
return v;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const val = (): unknown => {
|
|
176
|
+
ws();
|
|
177
|
+
const c = text[i];
|
|
178
|
+
if (c === '{') {
|
|
179
|
+
i += 1;
|
|
180
|
+
const obj: Record<string, unknown> = {};
|
|
181
|
+
ws();
|
|
182
|
+
if (text[i] === '}') { i += 1; return obj; }
|
|
183
|
+
for (;;) {
|
|
184
|
+
ws();
|
|
185
|
+
const k = str();
|
|
186
|
+
if (Object.prototype.hasOwnProperty.call(obj, k)) throw new JcsRefusal('duplicate-name', `duplicate member name ${JSON.stringify(k)}`);
|
|
187
|
+
ws();
|
|
188
|
+
if (text[i] !== ':') fail('expected :');
|
|
189
|
+
i += 1;
|
|
190
|
+
Object.defineProperty(obj, k, { value: val(), enumerable: true, writable: true, configurable: true });
|
|
191
|
+
ws();
|
|
192
|
+
const d = text[i];
|
|
193
|
+
i += 1;
|
|
194
|
+
if (d === '}') return obj;
|
|
195
|
+
if (d !== ',') fail('expected , or }');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (c === '[') {
|
|
199
|
+
i += 1;
|
|
200
|
+
const arr: unknown[] = [];
|
|
201
|
+
ws();
|
|
202
|
+
if (text[i] === ']') { i += 1; return arr; }
|
|
203
|
+
for (;;) {
|
|
204
|
+
arr.push(val());
|
|
205
|
+
ws();
|
|
206
|
+
const d = text[i];
|
|
207
|
+
i += 1;
|
|
208
|
+
if (d === ']') return arr;
|
|
209
|
+
if (d !== ',') fail('expected , or ]');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (c === '"') return str();
|
|
213
|
+
if (text.startsWith('true', i)) { i += 4; return true; }
|
|
214
|
+
if (text.startsWith('false', i)) { i += 5; return false; }
|
|
215
|
+
if (text.startsWith('null', i)) { i += 4; return null; }
|
|
216
|
+
if (c === '-' || (c !== undefined && c >= '0' && c <= '9')) return num();
|
|
217
|
+
return fail('unexpected token');
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const v = val();
|
|
221
|
+
ws();
|
|
222
|
+
if (i !== text.length) fail('trailing data');
|
|
223
|
+
return v;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** JCS bytes of a JSON text, with every §B refusal applied. */
|
|
227
|
+
export function canonicalizeText(text: string): string {
|
|
228
|
+
return canonicalJSON(parseIJson(text));
|
|
229
|
+
}
|
|
@@ -10,30 +10,20 @@
|
|
|
10
10
|
* RFC 0041 §E SECURITY-invariant probe (intra-host reproducibility +
|
|
11
11
|
* non-recipe-field invariance + Phase 4 advertisement alignment).
|
|
12
12
|
*
|
|
13
|
-
* `canonicalize`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `canonicalize` is RFC 8785 JCS with the RFC 0212 I-JSON refusal set (the
|
|
14
|
+
* suite's one implementation, `./jcs.ts`), and `tools[]` sorts by UTF-16 code
|
|
15
|
+
* units — never `localeCompare`, which orders `get_weather` / `getWeather`
|
|
16
|
+
* differently per locale and so breaks the TS/Python/Go agreement RFC 0150 §C
|
|
17
|
+
* asks for. Keep in sync with `spec/v1/replay.md` §B.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { createHash } from 'node:crypto';
|
|
20
21
|
import { driver } from './driver.js';
|
|
22
|
+
import { canonicalJSON, codeUnitCompare } from './jcs.js';
|
|
21
23
|
|
|
22
|
-
/** RFC 8785 JCS
|
|
23
|
-
* fields). Sorted keys recursively; no whitespace; preserved array order;
|
|
24
|
-
* strings JSON-encoded verbatim (no NFC normalization — the recipe
|
|
25
|
-
* inputs in our test seam are ASCII). */
|
|
24
|
+
/** RFC 8785 JCS (RFC 0212). Throws `JcsRefusal` on a non-I-JSON value. */
|
|
26
25
|
export function canonicalize(value: unknown): string {
|
|
27
|
-
|
|
28
|
-
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
|
29
|
-
if (typeof value === 'string') return JSON.stringify(value);
|
|
30
|
-
if (Array.isArray(value)) return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
|
|
31
|
-
if (typeof value === 'object') {
|
|
32
|
-
const obj = value as Record<string, unknown>;
|
|
33
|
-
const keys = Object.keys(obj).sort();
|
|
34
|
-
return '{' + keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',') + '}';
|
|
35
|
-
}
|
|
36
|
-
return JSON.stringify(value);
|
|
26
|
+
return canonicalJSON(value);
|
|
37
27
|
}
|
|
38
28
|
|
|
39
29
|
/** @deprecated RETIRED v1 projection (pre RFC 0150 §C). Kept ONLY so a
|
|
@@ -44,7 +34,7 @@ export function canonicalize(value: unknown): string {
|
|
|
44
34
|
export function projectRecipe(raw: Record<string, unknown>): Record<string, unknown> {
|
|
45
35
|
const out: Record<string, unknown> = { provider: raw.provider, model: raw.model, messages: raw.messages };
|
|
46
36
|
if (Array.isArray(raw.tools) && raw.tools.length > 0) {
|
|
47
|
-
out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name
|
|
37
|
+
out.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
|
|
48
38
|
}
|
|
49
39
|
if (typeof raw.temperature === 'number') out.temperature = raw.temperature;
|
|
50
40
|
if (typeof raw.topP === 'number') out.topP = raw.topP;
|
|
@@ -76,7 +66,7 @@ export const SEMANTIC_REQUEST_RECIPE_V2 = 'openwop-semantic-request-v2';
|
|
|
76
66
|
export function projectSemanticRequestV2(raw: Record<string, unknown>): Record<string, unknown> {
|
|
77
67
|
const request: Record<string, unknown> = { messages: raw.messages };
|
|
78
68
|
if (Array.isArray(raw.tools) && raw.tools.length > 0) {
|
|
79
|
-
request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name
|
|
69
|
+
request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => codeUnitCompare(a.name, b.name));
|
|
80
70
|
}
|
|
81
71
|
for (const k of ['temperature', 'topP', 'topK', 'maxOutputTokens', 'seed'] as const) {
|
|
82
72
|
if (typeof raw[k] === 'number') request[k] = raw[k];
|
|
@@ -87,15 +87,68 @@ const journal: LedgerEntry[] = [];
|
|
|
87
87
|
* dispositions throws: RFC 0148 §A says **exactly one** disposition per
|
|
88
88
|
* requirement, and a silent last-write-wins would let a later soft-skip
|
|
89
89
|
* overwrite an earlier real failure — the failure mode in reverse.
|
|
90
|
+
*
|
|
91
|
+
* `extras.fold` is the one exception, for the per-`it` rows `setup.ts` records
|
|
92
|
+
* when several `it` legs witness ONE requirement id; see its docblock below.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* How certifiable each disposition is, least first. `readLedgerFile` has always
|
|
96
|
+
* resolved a cross-worker disagreement this way — "one worker said it failed"
|
|
97
|
+
* outranks "another said it passed", and an unresolvable disagreement must never
|
|
98
|
+
* round toward certification. `fold` below applies the SAME rule in-worker.
|
|
90
99
|
*/
|
|
100
|
+
const CERTIFIABILITY_RANK: Record<Disposition, number> = {
|
|
101
|
+
'executed-fail': 0,
|
|
102
|
+
blocked: 1,
|
|
103
|
+
'executed-pass': 2,
|
|
104
|
+
skipped: 3,
|
|
105
|
+
inapplicable: 4,
|
|
106
|
+
};
|
|
107
|
+
|
|
91
108
|
export function recordRequirement(
|
|
92
109
|
requirementId: string,
|
|
93
110
|
disposition: Disposition,
|
|
94
111
|
detail?: string,
|
|
95
|
-
extras?: {
|
|
112
|
+
extras?: {
|
|
113
|
+
assertionCount?: number;
|
|
114
|
+
scenarioFile?: string;
|
|
115
|
+
evidence?: RowEvidence;
|
|
116
|
+
/**
|
|
117
|
+
* FOLD instead of throw when this id already has a disposition (2.37.0).
|
|
118
|
+
*
|
|
119
|
+
* The strict contract above — one disposition per requirement per run,
|
|
120
|
+
* contradiction throws — is right for a scenario that classifies ITSELF:
|
|
121
|
+
* two hand-written verdicts for one id is an authoring bug, and the throw
|
|
122
|
+
* is how it surfaces.
|
|
123
|
+
*
|
|
124
|
+
* It is wrong for the per-`it` rows `setup.ts` records, because the corpus
|
|
125
|
+
* deliberately witnesses ONE requirement with SEVERAL `it` legs: 27
|
|
126
|
+
* scenario files hand `req()` a module-level `const ID`, so every leg in
|
|
127
|
+
* the file records under the same id. There the throw is not a guard —
|
|
128
|
+
* `setup.ts` wraps the call in `try {} catch {}` ("never fail a test for
|
|
129
|
+
* bookkeeping"), so the second verdict was silently DISCARDED, and, worse,
|
|
130
|
+
* discarded before it reached the JSONL sink.
|
|
131
|
+
*
|
|
132
|
+
* MEASURED, `v2-run-bulk-cancel.test.ts` on a tier-2 host: leg 1 passed
|
|
133
|
+
* (3 assertions), leg 2 failed on its 7th. The file row recorded
|
|
134
|
+
* `executed-fail` with 10 assertions and the detail "one or more assertions
|
|
135
|
+
* in the file failed"; `openwop.requirement.0170.run-bulk-cancel` recorded
|
|
136
|
+
* `executed-pass` with 3. The failing leg's message — which names the
|
|
137
|
+
* requirement AND prints the offending entry — existed, was computed by
|
|
138
|
+
* `resolveItRecord`, and was thrown away here. The operator had to
|
|
139
|
+
* hand-probe every assertion in the file against production to find out
|
|
140
|
+
* what had failed.
|
|
141
|
+
*
|
|
142
|
+
* Folding by `CERTIFIABILITY_RANK` makes the surviving row the least
|
|
143
|
+
* certifiable of the legs, carrying THAT leg's detail, with the legs'
|
|
144
|
+
* assertion counts summed — the same answer `readLedgerFile` would reach
|
|
145
|
+
* from the sink lines, so the in-memory ledger and the file agree.
|
|
146
|
+
*/
|
|
147
|
+
fold?: true;
|
|
148
|
+
},
|
|
96
149
|
): void {
|
|
97
150
|
const prior = ledger.get(requirementId);
|
|
98
|
-
if (prior !== undefined && prior.disposition !== disposition) {
|
|
151
|
+
if (prior !== undefined && prior.disposition !== disposition && extras?.fold !== true) {
|
|
99
152
|
throw new Error(
|
|
100
153
|
`RFC 0148 §A: ${requirementId} already recorded as '${prior.disposition}', now '${disposition}'. ` +
|
|
101
154
|
'Exactly one disposition per requirement per run.',
|
|
@@ -107,7 +160,7 @@ export function recordRequirement(
|
|
|
107
160
|
'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.',
|
|
108
161
|
);
|
|
109
162
|
}
|
|
110
|
-
|
|
163
|
+
let entry: LedgerEntry = {
|
|
111
164
|
requirementId,
|
|
112
165
|
disposition,
|
|
113
166
|
...(detail === undefined ? {} : { detail }),
|
|
@@ -115,6 +168,27 @@ export function recordRequirement(
|
|
|
115
168
|
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
116
169
|
...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
|
|
117
170
|
};
|
|
171
|
+
if (prior !== undefined && extras?.fold === true) {
|
|
172
|
+
// The least-certifiable leg wins the disposition and keeps its own detail;
|
|
173
|
+
// a tie keeps whichever side actually said something. Counts sum, because
|
|
174
|
+
// both legs really did assert against the target for this one requirement.
|
|
175
|
+
const keepPrior = CERTIFIABILITY_RANK[prior.disposition] <= CERTIFIABILITY_RANK[disposition];
|
|
176
|
+
const winner = keepPrior ? prior : entry;
|
|
177
|
+
const loser = keepPrior ? entry : prior;
|
|
178
|
+
const count = (prior.assertionCount ?? 0) + (extras?.assertionCount ?? 0);
|
|
179
|
+
const keptDetail = winner.detail ?? loser.detail;
|
|
180
|
+
const hasCount = prior.assertionCount !== undefined || extras?.assertionCount !== undefined;
|
|
181
|
+
entry = {
|
|
182
|
+
requirementId,
|
|
183
|
+
disposition: winner.disposition,
|
|
184
|
+
...(keptDetail === undefined ? {} : { detail: keptDetail }),
|
|
185
|
+
...(hasCount ? { assertionCount: count } : {}),
|
|
186
|
+
...(winner.scenarioFile === undefined ? {} : { scenarioFile: winner.scenarioFile }),
|
|
187
|
+
// `evidence` is only meaningful on a pass; a fold that lands anywhere
|
|
188
|
+
// else drops it, exactly as the constructor above does.
|
|
189
|
+
...(winner.disposition === 'executed-pass' && winner.evidence !== undefined ? { evidence: winner.evidence } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
118
192
|
ledger.set(requirementId, entry);
|
|
119
193
|
journal.push(entry);
|
|
120
194
|
// File sink (RFC 0148 acceptance item 2, S6). The in-memory map lives in a
|
|
@@ -159,7 +233,11 @@ export function readLedgerFile(path: string): readonly LedgerEntry[] {
|
|
|
159
233
|
}
|
|
160
234
|
if (typeof e.requirementId !== 'string' || !DISPOSITIONS.includes(e.disposition)) continue;
|
|
161
235
|
const prior = merged.get(e.requirementId);
|
|
162
|
-
|
|
236
|
+
// `<=`, not `<`: a per-`it` FOLD (2.37.0) appends the cumulative row after
|
|
237
|
+
// the leg rows it folded, so on an equal disposition the LAST line is the
|
|
238
|
+
// one carrying the summed `assertionCount`. For genuinely duplicate lines
|
|
239
|
+
// the two are identical and the choice is a no-op.
|
|
240
|
+
if (prior === undefined || rank[e.disposition] <= rank[prior.disposition]) merged.set(e.requirementId, e);
|
|
163
241
|
}
|
|
164
242
|
return [...merged.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));
|
|
165
243
|
}
|
|
@@ -118,8 +118,13 @@ export function resolveItRecord(
|
|
|
118
118
|
* sites were not measured and v1 bundles are read through its EOS.
|
|
119
119
|
*/
|
|
120
120
|
blockedStands = false,
|
|
121
|
+
/** The `it` title, so a failed row says WHICH leg of the requirement failed. */
|
|
122
|
+
testName?: string,
|
|
121
123
|
): { disposition: Disposition; detail?: string } {
|
|
122
|
-
if (state === 'fail')
|
|
124
|
+
if (state === 'fail') {
|
|
125
|
+
const where = testName === undefined || testName.trim() === '' ? '' : ` in "${testName.slice(0, 120)}"`;
|
|
126
|
+
return { disposition: 'executed-fail', detail: `the test executed and failed${where}: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
127
|
+
}
|
|
123
128
|
if (state === 'pass' && assertionCalls > 0) {
|
|
124
129
|
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
125
130
|
// assertions are not the requirement, and the requirement went unobserved.
|
|
@@ -142,14 +147,45 @@ export function resolveItRecord(
|
|
|
142
147
|
return { disposition: 'skipped', detail: 'vitest skipped the test (ctx.skip / it.skip) without a recorded gate reason' };
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/** One failed `it` in a file: its title and its first error message. */
|
|
151
|
+
export interface TestFailure {
|
|
152
|
+
readonly name: string;
|
|
153
|
+
readonly message?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The `executed-fail` detail for a file row: WHICH cases failed, and what the
|
|
158
|
+
* first one said.
|
|
159
|
+
*
|
|
160
|
+
* Until 2.37.0 this was the fixed string "one or more assertions in the file
|
|
161
|
+
* failed". A tier-2 host read exactly that for `v2-run-bulk-cancel` — 10
|
|
162
|
+
* assertions, no case name, no message, and nothing else in the record for that
|
|
163
|
+
* file — and had to hand-probe every assertion in the file against production to
|
|
164
|
+
* find out what had happened. A bundle row whose only detail is that sentence is
|
|
165
|
+
* undiagnosable by construction, and every future flicker in any scenario had
|
|
166
|
+
* the same problem.
|
|
167
|
+
*/
|
|
168
|
+
export function failureDetail(failures: readonly TestFailure[], failedCount: number): string {
|
|
169
|
+
if (failures.length === 0) {
|
|
170
|
+
return `${failedCount} test(s) in the file failed; the runner captured no message`;
|
|
171
|
+
}
|
|
172
|
+
const head = failures[0]!;
|
|
173
|
+
const named = failures.slice(0, 3).map((f) => `"${f.name.slice(0, 120)}"`).join(', ');
|
|
174
|
+
const more = failures.length > 3 ? ` (+${failures.length - 3} more)` : '';
|
|
175
|
+
const msg = head.message === undefined || head.message.trim() === '' ? 'no message' : head.message.slice(0, 400);
|
|
176
|
+
return `${failures.length} test(s) failed — ${named}${more}; first failure: ${msg}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
146
180
|
* the ONE disposition the file records. */
|
|
147
181
|
export function fileDisposition(
|
|
148
182
|
states: readonly FileTestState[],
|
|
149
183
|
gateReason: 'inapplicable' | 'skipped' | undefined,
|
|
150
184
|
assertionCount?: number,
|
|
185
|
+
failures: readonly TestFailure[] = [],
|
|
151
186
|
): { disposition: Disposition; detail?: string } {
|
|
152
|
-
|
|
187
|
+
const failed = states.filter((s) => s === 'fail').length;
|
|
188
|
+
if (failed > 0) return { disposition: 'executed-fail', detail: failureDetail(failures, failed) };
|
|
153
189
|
if (states.some((s) => s === 'pass')) {
|
|
154
190
|
// A test that early-returned through `behaviorGate` is reported by vitest
|
|
155
191
|
// as a pass with zero assertions. When EVERY passing test in the file did
|
|
@@ -195,6 +231,8 @@ export function resolveFileRecord(
|
|
|
195
231
|
assertionCount: number,
|
|
196
232
|
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
|
|
197
233
|
specCoherenceFile?: string,
|
|
234
|
+
/** The failed cases, so an `executed-fail` row NAMES them (2.37.0). */
|
|
235
|
+
failures: readonly TestFailure[] = [],
|
|
198
236
|
): { disposition: Disposition; detail?: string } {
|
|
199
237
|
// A scenario whose subject is the CORPUS, skipped because the published
|
|
200
238
|
// tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
|
|
@@ -211,7 +249,7 @@ export function resolveFileRecord(
|
|
|
211
249
|
) {
|
|
212
250
|
return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
|
|
213
251
|
}
|
|
214
|
-
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
252
|
+
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount, failures);
|
|
215
253
|
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
216
254
|
if (noted !== null) {
|
|
217
255
|
disposition = noted.kind;
|