Skip to content

Commit da56f66

Browse files
Allow expected-failures entries to name a single check (#406)
A baseline entry names a scenario, but a scenario is many checks: server-stateless is 29. Excusing the one or two a spec change breaks means baselining the whole scenario, which stops enforcing the rest. An entry may now be '<scenario>:<check-id>', in which case every failing check in that scenario is judged on its own. Bare scenario entries are unchanged, and YAML parses '- a:b' as a plain string, so every existing baseline keeps working untouched. loadExpectedFailures now owns the entry grammar and returns parsed BaselineEntry values, so evaluateBaseline only matches and cannot throw. Rejecting a bad entry and interpreting a good one no longer live in separate layers. A check id matches all of its occurrences, since ids repeat within a run; they collapse to one verdict, most-severe first. Absent and SKIPPED are tolerated, matching how the runner already treats them as green. Move collapseDuplicateChecks to src/checks/collapse.ts with its tests: expected-failures.ts needs the reducer and cannot import dpop.ts's auth server stack. No behavior change. Reject two configs that previously coerced to an entry matching nothing: a mapping ('- scenario: check-id', with a space) and an empty list item.
1 parent d1c0b95 commit da56f66

6 files changed

Lines changed: 564 additions & 95 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,40 @@ This ensures:
134134
- CI fails on new regressions (unexpected failures)
135135
- CI fails when a fix lands but the baseline isn't updated (stale entries)
136136

137+
### Baselining a single check
138+
139+
A scenario is many checks — `server-stateless` alone is over twenty — so baselining the
140+
whole scenario to excuse one of them stops enforcing the other nineteen. An entry can
141+
instead name a single check, as `<scenario>:<check-id>`:
142+
143+
```yaml
144+
server:
145+
- tasks-lifecycle # whole scenario may fail
146+
- server-stateless:sep-2575-server-implements-discover # only this check may fail
147+
```
148+
149+
The check id is the left-hand column the runner already prints for each check, so it can
150+
be copied straight out of a failing run.
151+
152+
With a per-check entry, every failing check in that scenario is judged on its own: the
153+
named one is excused, and any other failure is still an unexpected regression. The four
154+
exit-code rules above apply per check rather than per scenario, with one addition — a
155+
baselined check that is absent or skipped is tolerated, because a scenario that bails on
156+
a failed prerequisite legitimately never reaches its later checks, and the prerequisite
157+
reports its own failure anyway.
158+
159+
Two things to know:
160+
161+
- **A check id addresses every occurrence of that id.** Ids repeat within a run (a loop,
162+
a retried flow), and the occurrences collapse to one verdict, most-severe first. So
163+
baselining a repeated id excuses all of its occurrences — coarser than ideal, still far
164+
narrower than baselining the scenario.
165+
- **Mind the space.** `- scenario:check-id` is a string; `- scenario: check-id` is YAML
166+
for a mapping and is rejected with an error.
167+
168+
A scenario cannot be listed both wholesale and per-check — the wholesale entry already
169+
excuses everything, so the pair is contradictory and is rejected.
170+
137171
## GitHub Action
138172

139173
This repo provides a composite GitHub Action so SDK repos don't need to write their own conformance scripts.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { collapseDuplicateChecks } from './dpop';
2-
import type { ConformanceCheck, CheckStatus } from '../../../types';
1+
import { collapseDuplicateChecks } from './collapse';
2+
import type { ConformanceCheck, CheckStatus } from '../types';
33

44
/** Minimal check factory for the dedupe unit tests. */
55
function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck {
@@ -13,7 +13,7 @@ function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck {
1313
};
1414
}
1515

16-
describe('collapseDuplicateChecks (DPoP nonce-posture shared-check dedupe)', () => {
16+
describe('collapseDuplicateChecks', () => {
1717
it('collapses duplicate SUCCESS ids to a single entry', () => {
1818
const out = collapseDuplicateChecks([
1919
chk('token-request', 'SUCCESS'),

src/checks/collapse.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { CheckStatus, ConformanceCheck } from '../types';
2+
3+
/**
4+
* Collapse duplicate non-INFO check IDs to a single entry, preferring the
5+
* MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g.
6+
* SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST
7+
* occurrence. Per-request INFO log entries are always kept.
8+
*
9+
* A check ID is not unique within a run: scenarios re-emit a shared ID when a
10+
* flow repeats. The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge
11+
* → retry), so the shared token-flow checks (`token-request`, `pkce-*`) are
12+
* appended twice; `sep-2575-http-server-meta-invalid-400` is emitted once per
13+
* iteration of the `_meta` test-case loop. Collapsing reports each ID once
14+
* without hiding a failure recorded on any occurrence, which is what lets an
15+
* expected-failures baseline address a check by ID.
16+
*/
17+
export function collapseDuplicateChecks(
18+
checks: ConformanceCheck[]
19+
): ConformanceCheck[] {
20+
const severity = (s: CheckStatus): number =>
21+
s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0;
22+
// Winning index per non-INFO id: highest severity, ties → last occurrence.
23+
const winner = new Map<string, number>();
24+
checks.forEach((c, i) => {
25+
if (c.status === 'INFO') return;
26+
const cur = winner.get(c.id);
27+
if (
28+
cur === undefined ||
29+
severity(c.status) >= severity(checks[cur].status)
30+
) {
31+
winner.set(c.id, i);
32+
}
33+
});
34+
return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i);
35+
}

0 commit comments

Comments
 (0)