@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/dist/cli.js +8 -18
  4. package/dist/lib/certification-bundle-v3.js +46 -18
  5. package/dist/lib/jcs.js +274 -0
  6. package/dist/lib/requirement-ledger.js +44 -3
  7. package/dist/lib/scenario-disposition.js +37 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +323 -57
  11. package/scenario-majors.json +7 -3
  12. package/schemas/CORPUS-STAMP.json +30 -30
  13. package/src/cli.ts +8 -16
  14. package/src/lib/certification-bundle-v3.ts +42 -17
  15. package/src/lib/front-mux.ts +44 -2
  16. package/src/lib/jcs.ts +229 -0
  17. package/src/lib/llm-cache-key-recipe.ts +10 -20
  18. package/src/lib/requirement-ledger.ts +82 -4
  19. package/src/lib/scenario-disposition.ts +41 -3
  20. package/src/lib/scoped-receiver.ts +223 -0
  21. package/src/lib/triggerBridge.ts +49 -0
  22. package/src/scenarios/auth-subject-link.test.ts +18 -1
  23. package/src/scenarios/jcs-vectors.test.ts +108 -0
  24. package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
  25. package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
  26. package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
  27. package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
  28. package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
  29. package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
  30. package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
  31. package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
  32. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
  33. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
  34. package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
  35. package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
  36. package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
  37. package/src/setup.ts +24 -4
  38. package/vectors/jcs-v1.json +294 -0
  39. 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` mirrors RFC 8785 JCS-style output (sorted keys, no
14
- * whitespace, preserved array order). Hosts that have a real JCS library
15
- * available SHOULD prefer it; this helper is for the conformance side,
16
- * not the host side. Keep in sync with `spec/v1/replay.md` §B.
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-style canonicalization (subset suitable for the recipe
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
- if (value === null) return 'null';
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.localeCompare(b.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.localeCompare(b.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?: { assertionCount?: number; scenarioFile?: string; evidence?: RowEvidence },
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
- const entry: LedgerEntry = {
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
- if (prior === undefined || rank[e.disposition] < rank[prior.disposition]) merged.set(e.requirementId, e);
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') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
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
- if (states.some((s) => s === 'fail')) return { disposition: 'executed-fail', detail: 'one or more assertions in the file failed' };
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;