@dogfood-lab/verify 1.9.0 → 1.11.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/index.js CHANGED
@@ -86,6 +86,39 @@ function runValidator(name, fn) {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * F-4036ae25: does `submission` carry a repo / run_id / timing.finished_at
91
+ * shape that packages/ingest/persist.js's computeRecordPath() could place on
92
+ * disk? Mirrors computeRecordPath's OWN structural preconditions (segment
93
+ * count, run_id charset, a parseable finished_at) — deliberately NOT its
94
+ * stricter isUnsafeSegment traversal guard. That guard rejects strings (e.g.
95
+ * `../etc`) this submission schema's own repo pattern also accepts, so a
96
+ * submission can be schema-VALID and still fail it; that gap belongs to
97
+ * F-4acd28d8, handled downstream in writeRecord()/ingest() once the record
98
+ * already has a verdict. Here we only need "is this filable in principle" —
99
+ * a three-field structural question `verify()` can answer locally without
100
+ * importing `@dogfood-lab/ingest` (which already imports `@dogfood-lab/verify`;
101
+ * importing back would close a new cross-package cycle).
102
+ *
103
+ * @param {object} submission
104
+ * @returns {boolean}
105
+ */
106
+ function hasFilablePathIdentity(submission) {
107
+ const repo = submission.repo;
108
+ if (typeof repo !== 'string') return false;
109
+ const segments = repo.split('/');
110
+ if (segments.length !== 2 || !segments[0] || !segments[1]) return false;
111
+
112
+ if (typeof submission.run_id !== 'string' || !/^[\w-]+$/.test(submission.run_id)) {
113
+ return false;
114
+ }
115
+
116
+ const finishedAt = submission.timing && typeof submission.timing === 'object'
117
+ ? submission.timing.finished_at
118
+ : undefined;
119
+ return typeof finishedAt === 'string' && !isNaN(new Date(finishedAt).getTime());
120
+ }
121
+
89
122
  /**
90
123
  * Verify a dogfood submission and produce a persisted record.
91
124
  *
@@ -328,8 +361,85 @@ export async function verify(submission, options) {
328
361
  });
329
362
 
330
363
  // 7. Assemble persisted record
364
+ //
365
+ // A submission whose repo/run_id/timing.finished_at cannot drive
366
+ // computeRecordPath() is marked `_skipPersist` — the SAME sentinel the
367
+ // null/non-object branch above sets, because it is the same claim: we
368
+ // cannot place this on disk, so there is nothing to file. The record is
369
+ // still assembled and returned (the submitter needs the reasons); it is
370
+ // simply never written.
371
+ //
372
+ // F-4036ae25: this used to fire for EVERY schema-invalid submission, not
373
+ // only this unfilable subset — broader than the rationale below justifies.
374
+ // A submission whose repo/run_id/timing.finished_at are perfectly fine but
375
+ // fails schema on an unrelated field (e.g. an unexpected top-level property)
376
+ // IS filable, and blanket-skipping it left the fleet-wide audit trail with a
377
+ // rejection nobody could see anywhere durable. hasFilablePathIdentity()
378
+ // re-checks the three path-deriving fields directly against `submission`
379
+ // (not against WHICH schema rule fired), so this stays correct regardless of
380
+ // which field the schema violation is actually on.
381
+ //
382
+ // Skipping is not merely an economy. isDuplicate checks the _rejected path,
383
+ // so persisting an UNFILABLE rejection isn't possible anyway (computeRecordPath
384
+ // would throw); persisting a FILABLE one, on the other hand, reopens the
385
+ // V2-CROSS-BO-001 / F-82429f90 run_id-poisoning pathology for the
386
+ // newly-persisted category — a corrected resubmission silently dropped as a
387
+ // duplicate (exit 0). packages/ingest/persist.js's isDuplicate() closes that
388
+ // gap: a prior `_rejected` record whose reasons are ALL retryable-class is
389
+ // treated as non-blocking for a same-run_id retry that goes on to be
390
+ // ACCEPTED, so the audit trail and the resubmission path both hold. (A
391
+ // schema-invalid submission that reaches writeRecord() may still fail
392
+ // dogfood-record.schema.json's own mirrored constraints on the SAME field —
393
+ // ingest() routes that RecordValidationError the same way _skipPersist
394
+ // routes here; see F-4acd28d8.) An UNCORRECTED retry — still rejected,
395
+ // same run_id and date — computes to the exact SAME `_rejected` path
396
+ // attempt 1 already occupies regardless of which violation it reports, so
397
+ // it stays an ordinary duplicate rather than reaching writeRecord()'s
398
+ // exclusive-create at all (F-0f9e4077: the retry carve-out only ever
399
+ // waives the collision for a record now headed to a DIFFERENT, accepted
400
+ // path).
401
+ //
402
+ // The line: the duplicate guard otherwise consumes a run_id when we
403
+ // rendered a VERDICT on a run's own reported content. A policy or
404
+ // provenance rejection is such a verdict — it persists, and PERMANENTLY
405
+ // consuming the run_id is the intended anti-gaming behavior (a submitter
406
+ // must not be able to launder a genuinely-bad run into an accepted one by
407
+ // resubmitting different self-reported content under the same run_id).
408
+ // "We could not read/place/shape your submission" (schema, repo,
409
+ // unsafe-record-path, ...) is not a content verdict — it persists too when
410
+ // filable, but stays reusable for a genuinely corrected resubmission.
411
+ //
412
+ // F-f8952a50 (wave 10): this split is now a PER-PREFIX flag —
413
+ // parse-rejection.js's `retryable` field, consulted by persist.js's
414
+ // isRetryableRejection() — not a blanket "schema: only" allowlist. The
415
+ // shape/addressing subset of `submission-bad` (schema:, repo:,
416
+ // unsafe-record-path:, steps[<id>]:, policy-config:,
417
+ // submission-contains-verifier-field:, CONTRACT_SCHEMA_TOO_OLD:) is
418
+ // `retryable: true`; the content-verdict subset (policy:, provenance:)
419
+ // stays `retryable: false`, exactly as this paragraph describes. See
420
+ // parse-rejection.js's file header for the full rationale and
421
+ // schema-invalid-skip-persist.test.js's "REGRESSION GUARD" /
422
+ // "persist-a-verdict doctrine is preserved" tests for the pinned proof that
423
+ // policy:/provenance: remain terminal.
424
+ //
425
+ // CONTRACT_SCHEMA_TOO_NEW: does NOT belong in the shape/addressing list
426
+ // above — corrected here, wave 22 (F-51780da9's sweep found this comment
427
+ // stale in the same way it found parse-rejection.test.js's F-be0deacd test
428
+ // comment stale). F-be0deacd (wave 20) moved it to class 'operational' with
429
+ // `retryable: false` permanently: only a testing-os upgrade satisfies
430
+ // `major > maxMajor`, never a payload correction. `retryable` is a static
431
+ // per-prefix flag with no visibility into a later upgrade, so
432
+ // persist.js's isRetryableRejection() carries one additional, narrow
433
+ // re-check for this prefix only — re-deriving whether the stored
434
+ // rejection's declared major is still above the CURRENT build's
435
+ // SUPPORTED_SCHEMA_VERSIONS ceiling, rather than trusting the frozen
436
+ // boolean — so a stale TOO_NEW rejection does not permanently poison a
437
+ // run_id once the operator upgrades past the declared major. See
438
+ // isRetryableRejection's own doc comment (packages/ingest/persist.js).
439
+ const canFilePath = schemaResult.valid || hasFilablePathIdentity(submission);
331
440
  const persisted = {
332
441
  schema_version: RECORD_SCHEMA_VERSION,
442
+ ...(canFilePath ? {} : { _skipPersist: true }),
333
443
  policy_version: policyVersion,
334
444
  run_id: submission.run_id,
335
445
  repo: submission.repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
6
6
  "main": "index.js",
@@ -8,7 +8,7 @@
8
8
  * a fresh drift source the moment a prefix is added.
9
9
  *
10
10
  * `parseRejectionReason(reason)` is the single exported classifier. It maps a
11
- * raw rejection string to `{ class, prefix, detail }`:
11
+ * raw rejection string to `{ class, prefix, detail, retryable }`:
12
12
  *
13
13
  * - class — the routing decision (who fixes this):
14
14
  * 'submission-bad' → the submitter fixes the payload and resubmits
@@ -20,6 +20,36 @@
20
20
  * `'steps[<id>]:'`), or `null` for the 'unknown' class.
21
21
  * - detail — the human-readable remainder with the matched prefix stripped
22
22
  * (for 'unknown', the whole string verbatim).
23
+ * - retryable — (F-f8952a50, wave 10) may a same-run_id resubmission whose
24
+ * ONLY prior rejection carries this prefix still reach an
25
+ * acceptance, once corrected? This is a NARROWER question than
26
+ * `class`. Every 'operational' / 'ingest' / 'unknown' reason is
27
+ * `retryable: false` (only the submitter's own payload earns a
28
+ * retry — an ops fault or an unrecognized signal never does).
29
+ * Within 'submission-bad', the taxonomy splits further:
30
+ * - retryable: true — the prefix means "we could not even
31
+ * read/place/shape your submission" (schema:, repo:,
32
+ * unsafe-record-path:, steps[<id>]:, policy-config:,
33
+ * submission-contains-verifier-field:,
34
+ * CONTRACT_SCHEMA_TOO_OLD:) — a correction changes nothing
35
+ * about what the run actually did, only how it was
36
+ * addressed/formatted/configured. (CONTRACT_SCHEMA_TOO_NEW:
37
+ * moved to class 'operational' / retryable: false in
38
+ * F-be0deacd, wave 20 — see the LITERAL_PREFIXES entry
39
+ * below for why it is NOT the submitter's problem.)
40
+ * - retryable: false — the prefix means "we read your
41
+ * submission and rendered a VERDICT against its content"
42
+ * (policy:, provenance:) — consuming the run_id is the
43
+ * INTENDED anti-gaming behavior: a submitter cannot launder
44
+ * a genuinely-failed run into an accepted one by resubmitting
45
+ * different self-reported content under the same run_id.
46
+ * Pinned end-to-end: schema-invalid-skip-persist.test.js's
47
+ * "REGRESSION GUARD: a resubmission after a persisted
48
+ * NON-schema rejection is STILL blocked" test (provenance:)
49
+ * and f-0f9e4077-retry-collision-duplicate-rejection.test.js's
50
+ * "F-f8952a50" describe block (policy: / provenance: cases).
51
+ * Consumers key off THIS field, never off `prefix` — see
52
+ * packages/ingest/persist.js's isRetryableRejection().
23
53
  *
24
54
  * The prefix vocabulary below is enumerated from the ACTUAL emitters — it is
25
55
  * NOT invented:
@@ -32,9 +62,16 @@
32
62
  * submission-contains-verifier-field:,
33
63
  * submission-malformed:,
34
64
  * VALIDATOR_FAULT_<NAME>:
35
- * - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
36
- * CONTRACT_SCHEMA_TOO_OLD:
37
- * - packages/ingest/run.js: scenario-load:
65
+ * - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW: (this BUILD is
66
+ * behind a schema major its submitters
67
+ * already adopted → operational, F-be0deacd
68
+ * wave 20), CONTRACT_SCHEMA_TOO_OLD: (the
69
+ * SUBMITTER is behind → submission-bad)
70
+ * - packages/ingest/run.js: scenario-load:,
71
+ * unsafe-record-path: (F-4acd28d8,
72
+ * computeRecordPath's traversal guard
73
+ * rejected a schema-valid repo →
74
+ * submission-bad)
38
75
  * - packages/ingest/load-context.js: scenario-fetch-fault: (V2-CROSS-BO-001,
39
76
  * scenario-fetch outage/credential fault
40
77
  * → operational)
@@ -52,29 +89,42 @@
52
89
 
53
90
  /**
54
91
  * @typedef {object} ParsedRejection
55
- * @property {RejectionClass} class Routing decision (who must act).
56
- * @property {string | null} prefix Canonical prefix token, or null when unknown.
57
- * @property {string} detail Human-readable remainder (prefix stripped).
92
+ * @property {RejectionClass} class Routing decision (who must act).
93
+ * @property {string | null} prefix Canonical prefix token, or null when unknown.
94
+ * @property {string} detail Human-readable remainder (prefix stripped).
95
+ * @property {boolean} retryable Whether a same-run_id resubmission whose
96
+ * ONLY prior rejection carries this prefix may still reach an acceptance.
97
+ * See the file header for the full submission-bad split (shape/addressing
98
+ * vs. rendered-verdict).
58
99
  */
59
100
 
60
101
  /**
61
102
  * Literal-prefix matchers, ordered most-specific-first. Each carries the
62
- * canonical prefix token and the routing class. `match` is the exact string the
63
- * reason must start with; `detail` is whatever follows it (trimmed of one
64
- * leading space). `repo:` matches the `repo:mismatch: …` family the canonical
65
- * token reported is the stable `repo:` head.
103
+ * canonical prefix token, the routing class, and whether a resubmission past
104
+ * this rejection is retryable (F-f8952a50 see file header). `match` is the
105
+ * exact string the reason must start with; `detail` is whatever follows it
106
+ * (trimmed of one leading space). `repo:` matches the `repo:mismatch: …`
107
+ * family — the canonical token reported is the stable `repo:` head.
66
108
  */
67
109
  const LITERAL_PREFIXES = [
68
- // submission-bad
69
- { match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
110
+ // submission-bad, retryable — "we could not even read your submission".
111
+ { match: 'schema:', prefix: 'schema:', class: 'submission-bad', retryable: true },
70
112
  // VERIFY-F1: a malformed REPO custom-rule predicate (eval-time semantic fault the
71
113
  // schema could not catch). submission-bad — the repo authored the bad policy YAML,
72
114
  // so the fix belongs to the submitter, not studio ops. Ordered BEFORE `policy:`
73
115
  // to keep the most-specific-first invariant (the tokens don't overlap — `policy:`
74
116
  // ends at the colon, `policy-config:` continues with `-config:` — but the order is
75
117
  // explicit). A malformed GLOBAL predicate is operational (VALIDATOR_FAULT_POLICY).
76
- { match: 'policy-config:', prefix: 'policy-config:', class: 'submission-bad' },
77
- { match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
118
+ // retryable — the REPO's policy YAML was broken, not a judgment on this run's
119
+ // content; once the repo fixes policies/*.yaml, the identical submission can
120
+ // legitimately evaluate cleanly. Shape/config, not a verdict.
121
+ { match: 'policy-config:', prefix: 'policy-config:', class: 'submission-bad', retryable: true },
122
+ // NOT retryable — a rendered verdict on the run's own reported content
123
+ // (forbidden tags, missing required evidence, a failed custom_rules
124
+ // predicate). Consuming the run_id here is the deliberate anti-gaming
125
+ // behavior: pinned by schema-invalid-skip-persist.test.js's "the
126
+ // persist-a-verdict doctrine is preserved" describe block.
127
+ { match: 'policy:', prefix: 'policy:', class: 'submission-bad', retryable: false },
78
128
  // operational — a provider-side provenance FAULT (429/5xx/401/403). The
79
129
  // adapter THROWS these (verify-A-002); index.js catches the throw and emits
80
130
  // this distinct `provenance-fault:` prefix so the incident pages ops. Ordered
@@ -82,29 +132,57 @@ const LITERAL_PREFIXES = [
82
132
  // the longer, more-specific match first preserves the most-specific-first
83
133
  // invariant the array is sorted by. The genuine not-confirmed case
84
134
  // (`provenance: source run could not be confirmed`) stays submission-bad below.
85
- { match: 'provenance-fault:', prefix: 'provenance-fault:', class: 'operational' },
86
- { match: 'provenance:', prefix: 'provenance:', class: 'submission-bad' },
87
- { match: 'repo:', prefix: 'repo:', class: 'submission-bad' },
135
+ { match: 'provenance-fault:', prefix: 'provenance-fault:', class: 'operational', retryable: false },
136
+ // submission-bad, NOT retryable — a rendered verdict: the provider could not
137
+ // confirm this specific run happened as claimed. Resubmitting different
138
+ // self-reported content under the same run_id to "become confirmable" is
139
+ // exactly the laundering the anti-gaming doctrine blocks. Pinned by
140
+ // schema-invalid-skip-persist.test.js's REGRESSION GUARD test.
141
+ { match: 'provenance:', prefix: 'provenance:', class: 'submission-bad', retryable: false },
142
+ // submission-bad, retryable — pure identity/addressing: the run happened,
143
+ // only the repo/run_url pairing was mis-stated. Proven live (F-f8952a50): a
144
+ // repo:mismatch correction must reach verify() again, not vanish as a
145
+ // silent duplicate.
146
+ { match: 'repo:', prefix: 'repo:', class: 'submission-bad', retryable: true },
88
147
  {
89
148
  match: 'submission-contains-verifier-field:',
90
149
  prefix: 'submission-contains-verifier-field:',
91
150
  class: 'submission-bad',
151
+ retryable: true,
92
152
  },
93
- { match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'submission-bad' },
94
- { match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad' },
153
+ // F-be0deacd (wave 20): TOO_NEW and TOO_OLD are NOT symmetric, despite the
154
+ // near-identical prefixes and shared emitter (validators/schema-version.js).
155
+ // TOO_OLD means the SUBMITTER is behind — re-emitting against the current
156
+ // contract fixes it, so it stays submission-bad/retryable. TOO_NEW means
157
+ // THIS BUILD is behind a schema major its own submitters have already
158
+ // adopted (schema-version.js's own JSDoc: "operator must upgrade
159
+ // testing-os") — no resubmission, corrected or not, can ever satisfy a
160
+ // `major > maxMajor` comparison until testing-os itself ships an upgrade.
161
+ // That is an ops action, not a submitter action, so TOO_NEW routes
162
+ // operational/not-retryable — the same bucket VALIDATOR_FAULT_* and
163
+ // submission-malformed: use for "page ops, don't bounce it back".
164
+ { match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'operational', retryable: false },
165
+ { match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad', retryable: true },
166
+ // submission-bad — F-4acd28d8: the record passed schema validation but its
167
+ // OWN repo identifier is unsafe to file under (computeRecordPath's
168
+ // isUnsafeSegment traversal guard, stricter than the schema's repo
169
+ // pattern — see F-bbbe2e1f). The submitter's repo string is the problem,
170
+ // not the verifier/ingest tooling, so this stays submission-bad.
171
+ // retryable — addressing, not a verdict on the run's content.
172
+ { match: 'unsafe-record-path:', prefix: 'unsafe-record-path:', class: 'submission-bad', retryable: true },
95
173
  // operational — a null/non-object submission is a malfunctioning dispatcher
96
174
  // (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
97
175
  // do NOT bounce it back to the submitter.
98
- { match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
176
+ { match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational', retryable: false },
99
177
  // operational — the scenario fetcher THREW after exhausting its retry
100
178
  // budget (5xx/429 outage, transport reject) or hit a credential fault
101
179
  // (401/403). V2-CROSS-BO-001: packages/ingest/load-context.js throws this
102
180
  // classified error instead of returning `not_found`, so an outage never
103
181
  // rejects a good submission. Ordered before `scenario-load:` to keep the
104
182
  // most-specific-first invariant explicit (the tokens do not overlap).
105
- { match: 'scenario-fetch-fault:', prefix: 'scenario-fetch-fault:', class: 'operational' },
183
+ { match: 'scenario-fetch-fault:', prefix: 'scenario-fetch-fault:', class: 'operational', retryable: false },
106
184
  // ingest
107
- { match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
185
+ { match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest', retryable: false },
108
186
  ];
109
187
 
110
188
  /** Strip a matched literal prefix and one optional leading space from a reason. */
@@ -121,27 +199,33 @@ function stripLiteral(reason, match) {
121
199
  */
122
200
  export function parseRejectionReason(reason) {
123
201
  if (typeof reason !== 'string') {
124
- return { class: 'unknown', prefix: null, detail: '' };
202
+ return { class: 'unknown', prefix: null, detail: '', retryable: false };
125
203
  }
126
204
 
127
205
  // 1. Operational faults — matched by FAMILY so future VALIDATOR_FAULT_<NEW>
128
206
  // classes need no edit here. (runValidator emits `VALIDATOR_FAULT_<CLS>: …`.)
207
+ // Never retryable — an ops fault is not the submitter's to fix.
129
208
  const faultMatch = reason.match(/^(VALIDATOR_FAULT_[A-Z0-9_]+):\s*/);
130
209
  if (faultMatch) {
131
210
  return {
132
211
  class: 'operational',
133
212
  prefix: `${faultMatch[1]}:`,
134
213
  detail: reason.slice(faultMatch[0].length),
214
+ retryable: false,
135
215
  };
136
216
  }
137
217
 
138
218
  // 2. steps[<id>]: — bracketed id, so matched by regex rather than a literal.
219
+ // retryable — a structural/completeness mismatch between reported step
220
+ // results and the scenario's declared required_steps, not a verdict on
221
+ // whether the run's steps passed.
139
222
  const stepsMatch = reason.match(/^steps\[[^\]]*\]:\s*/);
140
223
  if (stepsMatch) {
141
224
  return {
142
225
  class: 'submission-bad',
143
226
  prefix: 'steps[<id>]:',
144
227
  detail: reason.slice(stepsMatch[0].length),
228
+ retryable: true,
145
229
  };
146
230
  }
147
231
 
@@ -154,12 +238,16 @@ export function parseRejectionReason(reason) {
154
238
  class: entry.class,
155
239
  prefix: entry.prefix,
156
240
  detail: stripLiteral(reason, entry.match),
241
+ retryable: entry.retryable,
157
242
  };
158
243
  }
159
244
  }
160
245
 
161
- // 4. Unrecognized — log + surface raw. (The null/non-object submission reason
162
- // now carries the typed `submission-malformed:` prefix 'operational', so it
163
- // no longer falls through here; see verify-B-003.)
164
- return { class: 'unknown', prefix: null, detail: reason };
246
+ // 4. Unrecognized — log + surface raw, and never retryable: an unrecognized
247
+ // prefix is not affirmatively known to be a shape/addressing mistake, so
248
+ // the same "fails closed" discipline the taxonomy applies elsewhere
249
+ // applies here too. (The null/non-object submission reason now carries
250
+ // the typed `submission-malformed:` prefix → 'operational', so it no
251
+ // longer falls through here; see verify-B-003.)
252
+ return { class: 'unknown', prefix: null, detail: reason, retryable: false };
165
253
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * f-2a5ddafa-provenance-retry-warn.test.js
3
+ *
4
+ * F-2a5ddafa (Stage C humanization) — both provenance adapters retried
5
+ * transient faults (429/5xx/timeout/network) with exponential backoff, but
6
+ * the retry loop itself was completely silent: `await sleep(...); continue;`
7
+ * fired with no logStage/log call anywhere inside either loop. A submission
8
+ * that succeeded only after 1-2 retries against a degrading GitHub/GitLab API
9
+ * was byte-for-byte indistinguishable in the log from one that succeeded on
10
+ * the first try — an operator had zero early-warning signal of a degrading
11
+ * provider until the retry budget fully exhausted and threw.
12
+ *
13
+ * Fix: `onRetryWarn(fields)` fires at the point each loop decides to retry,
14
+ * defaulting to a structured NDJSON stderr line (`component:'verify'`,
15
+ * `stage:'warn'`) and injectable via `opts.onRetryWarn` for tests (mirrors
16
+ * the already-injectable `opts.fetchImpl` / `opts.sleepImpl` on this file).
17
+ *
18
+ * Why NOT `logStage` from `@dogfood-lab/dogfood-swarm/lib/log-stage.js`:
19
+ * packages/verify has no dependency on dogfood-swarm and taking one would
20
+ * close a SECOND, larger workspace cycle (verify -> dogfood-swarm ->
21
+ * findings -> ingest -> verify) — see provenance.js's `defaultOnRetryWarn`
22
+ * doc block for the full reasoning. This suite pins that the emitted shape
23
+ * is real and correct without that import.
24
+ *
25
+ * RED proof (reasoned, not re-executed as a hang risk): before this fix,
26
+ * `onRetryWarn` did not exist on the opts object at all — every assertion
27
+ * in the "onRetryWarn fires" describe blocks below would fail with "onRetryWarn
28
+ * was never called" (the array stays empty), since nothing invoked it. This is
29
+ * independently re-derived from reading the pre-fix source (confirmed: zero
30
+ * occurrences of any warn/log call inside either retry loop), not carried
31
+ * over from the finding's own prose.
32
+ */
33
+
34
+ import { describe, it } from 'node:test';
35
+ import assert from 'node:assert/strict';
36
+
37
+ import { githubProvenance, gitlabProvenance } from './provenance.js';
38
+
39
+ const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
40
+
41
+ const GH_SOURCE = {
42
+ provider: 'github',
43
+ workflow: 'dogfood.yml',
44
+ provider_run_id: '9123456789',
45
+ run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
46
+ };
47
+
48
+ const GL_SOURCE = {
49
+ provider: 'gitlab',
50
+ workflow: '.gitlab-ci.yml',
51
+ provider_run_id: '424242',
52
+ run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
53
+ repo: 'acme/widget'
54
+ };
55
+
56
+ function abortError() {
57
+ const e = new Error('This operation was aborted');
58
+ e.name = 'AbortError';
59
+ return e;
60
+ }
61
+
62
+ const noSleep = async () => {};
63
+
64
+ function okGithubResp() {
65
+ return {
66
+ ok: true,
67
+ status: 200,
68
+ json: async () => ({
69
+ id: 9123456789,
70
+ status: 'completed',
71
+ head_sha: RUN_HEAD,
72
+ repository: { full_name: 'acme/widget' }
73
+ })
74
+ };
75
+ }
76
+
77
+ function okGitlabResp() {
78
+ return { ok: true, status: 200, json: async () => ({ id: 424242, status: 'success', sha: RUN_HEAD }) };
79
+ }
80
+
81
+ /** @pins F-2a5ddafa */
82
+ describe('F-2a5ddafa — githubProvenance emits onRetryWarn before each retry', () => {
83
+ it('a timeout then a 200: onRetryWarn fires once with status_or_reason="timeout"', async () => {
84
+ let calls = 0;
85
+ const warnings = [];
86
+ const adapter = githubProvenance('token', {
87
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
88
+ onRetryWarn: (f) => warnings.push(f),
89
+ fetchImpl: async () => {
90
+ calls++;
91
+ if (calls === 1) throw abortError();
92
+ return okGithubResp();
93
+ }
94
+ });
95
+ const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
96
+ assert.equal(ok, true);
97
+ assert.equal(warnings.length, 1, `expected exactly one retry warning; got ${JSON.stringify(warnings)}`);
98
+ assert.equal(warnings[0].kind, 'provenance_retry');
99
+ assert.equal(warnings[0].provider, 'github');
100
+ assert.equal(warnings[0].attempt, 1);
101
+ assert.equal(warnings[0].status_or_reason, 'timeout');
102
+ assert.equal(typeof warnings[0].next_backoff_ms, 'number');
103
+ });
104
+
105
+ it('a 503 then a 200: onRetryWarn fires with status_or_reason=503 (the numeric status)', async () => {
106
+ let calls = 0;
107
+ const warnings = [];
108
+ const adapter = githubProvenance('token', {
109
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
110
+ onRetryWarn: (f) => warnings.push(f),
111
+ fetchImpl: async () => {
112
+ calls++;
113
+ return calls === 1 ? { ok: false, status: 503 } : okGithubResp();
114
+ }
115
+ });
116
+ await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
117
+ assert.equal(warnings.length, 1);
118
+ assert.equal(warnings[0].status_or_reason, 503);
119
+ });
120
+
121
+ it('a transport reject then a 200: onRetryWarn carries the underlying error message', async () => {
122
+ let calls = 0;
123
+ const warnings = [];
124
+ const adapter = githubProvenance('token', {
125
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
126
+ onRetryWarn: (f) => warnings.push(f),
127
+ fetchImpl: async () => {
128
+ calls++;
129
+ if (calls === 1) throw new Error('ECONNRESET');
130
+ return okGithubResp();
131
+ }
132
+ });
133
+ await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
134
+ assert.equal(warnings.length, 1);
135
+ assert.match(warnings[0].status_or_reason, /ECONNRESET/);
136
+ });
137
+
138
+ it('exhausting the retry budget fires onRetryWarn exactly `retries` times, not on the final throw', async () => {
139
+ const warnings = [];
140
+ const adapter = githubProvenance('token', {
141
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
142
+ onRetryWarn: (f) => warnings.push(f),
143
+ fetchImpl: async () => { throw abortError(); }
144
+ });
145
+ await assert.rejects(() => adapter.confirm(GH_SOURCE), /provenance: GitHub API timeout/);
146
+ assert.equal(warnings.length, 2, 'retries=2 → 2 warn events before the 3rd attempt throws');
147
+ assert.deepEqual(warnings.map(w => w.attempt), [1, 2]);
148
+ });
149
+
150
+ it('DEFAULT onRetryWarn (no opts override): writes a structured NDJSON warn line to stderr', async () => {
151
+ let calls = 0;
152
+ const captured = [];
153
+ const origErr = process.stderr.write.bind(process.stderr);
154
+ process.stderr.write = (chunk) => { captured.push(chunk.toString()); return true; };
155
+ try {
156
+ const adapter = githubProvenance('token', {
157
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
158
+ fetchImpl: async () => {
159
+ calls++;
160
+ if (calls === 1) throw abortError();
161
+ return okGithubResp();
162
+ }
163
+ });
164
+ await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
165
+ } finally {
166
+ process.stderr.write = origErr;
167
+ }
168
+ const jsonLine = captured.find((c) => c.trim().startsWith('{'));
169
+ assert.ok(jsonLine, `expected a JSON warn line on stderr; got ${JSON.stringify(captured)}`);
170
+ const parsed = JSON.parse(jsonLine.trim());
171
+ assert.equal(parsed.component, 'verify');
172
+ assert.equal(parsed.stage, 'warn');
173
+ assert.equal(parsed.kind, 'provenance_retry');
174
+ assert.equal(parsed.provider, 'github');
175
+ });
176
+ });
177
+
178
+ /** @pins F-2a5ddafa */
179
+ describe('F-2a5ddafa — gitlabProvenance mirrors the onRetryWarn discipline', () => {
180
+ it('a timeout then a 200: onRetryWarn fires once with provider="gitlab"', async () => {
181
+ let calls = 0;
182
+ const warnings = [];
183
+ const adapter = gitlabProvenance('token', {
184
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
185
+ onRetryWarn: (f) => warnings.push(f),
186
+ fetchImpl: async () => {
187
+ calls++;
188
+ if (calls === 1) throw abortError();
189
+ return okGitlabResp();
190
+ }
191
+ });
192
+ const ok = await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
193
+ assert.equal(ok, true);
194
+ assert.equal(warnings.length, 1);
195
+ assert.equal(warnings[0].kind, 'provenance_retry');
196
+ assert.equal(warnings[0].provider, 'gitlab');
197
+ assert.equal(warnings[0].status_or_reason, 'timeout');
198
+ });
199
+
200
+ it('a 429 then a 200: onRetryWarn carries the numeric status', async () => {
201
+ let calls = 0;
202
+ const warnings = [];
203
+ const adapter = gitlabProvenance('token', {
204
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
205
+ onRetryWarn: (f) => warnings.push(f),
206
+ fetchImpl: async () => {
207
+ calls++;
208
+ return calls === 1 ? { ok: false, status: 429 } : okGitlabResp();
209
+ }
210
+ });
211
+ await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
212
+ assert.equal(warnings.length, 1);
213
+ assert.equal(warnings[0].status_or_reason, 429);
214
+ });
215
+
216
+ it('exhausting the retry budget fires onRetryWarn exactly `retries` times', async () => {
217
+ const warnings = [];
218
+ const adapter = gitlabProvenance('token', {
219
+ timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
220
+ onRetryWarn: (f) => warnings.push(f),
221
+ fetchImpl: async () => { throw abortError(); }
222
+ });
223
+ await assert.rejects(() => adapter.confirm(GL_SOURCE), /provenance: GitLab API timeout/);
224
+ assert.equal(warnings.length, 1, 'retries=1 → 1 warn event before the 2nd attempt throws');
225
+ });
226
+ });