@dogfood-lab/verify 1.9.0 → 1.10.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.
@@ -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
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * f-f50e779b-retry-warn-fallback-guard.test.js
3
+ *
4
+ * F-f50e779b (wave 31, backend amend) — a defect INSIDE the wave-29 fix for
5
+ * F-2a5ddafa, found by this same lane's own wave-30 audit of its own prior
6
+ * work. defaultOnRetryWarn's doc comment states the invariant "the logger
7
+ * must never throw and mask a real retry" and wraps its primary
8
+ * `console.error(JSON.stringify(line))` call in try/catch — but the catch
9
+ * block's OWN recovery action, a second `console.error(...)` call, was not
10
+ * itself guarded. If console.error throws on BOTH calls (a broken stderr fd
11
+ * — EPIPE/ENOSPC, realistic on a CI runner), the second throw escaped
12
+ * defaultOnRetryWarn uncaught, out of the retry loop, out of confirm() —
13
+ * turning a retry that was about to SUCCEED into a rejected promise. Blast
14
+ * radius is bounded by an unrelated, pre-existing mechanism (verify/index.js
15
+ * reclassifies any confirm() throw as a structured PROVENANCE_FAULT, exit 2,
16
+ * nothing persisted) — so this was never a crash, but a submission whose
17
+ * provenance check would have succeeded after a retry was discarded solely
18
+ * because the NEW observability line could not be written: the opposite of
19
+ * what an observability feature should do to reliability.
20
+ *
21
+ * Fix: give the fallback console.error (provenance.js ~line 153-162) its own
22
+ * try/catch, mirroring the two-level guard `dogfood-swarm/lib/log-stage.js`
23
+ * already uses for the identical shape (`catch { try { ... } catch {} }`) —
24
+ * that file was independently swept and confirmed already-hardened; nothing
25
+ * else in this domain shares defaultOnRetryWarn (grepped for
26
+ * onRetryWarn/defaultOnRetryWarn/onWarn/onLog across every owned package —
27
+ * only provenance.js and its two test files match).
28
+ *
29
+ * RED proof — ACTUALLY EXECUTED, not just reasoned (per this wave's brief:
30
+ * "a verification that cannot fail is not a verification"). This exact
31
+ * fixture was run via `node --test` against a byte-identical scratch copy of
32
+ * the PRE-FIX provenance.js in an isolated temp dir (this file has zero
33
+ * imports, so a bare copy is fully self-contained — no workspace needed).
34
+ * Both cases below FAILED against the pre-fix copy, each stack trace pinned
35
+ * to `defaultOnRetryWarn (provenance.js:153)`:
36
+ * - the "resolves true" case rejected with the raw 'EPIPE: simulated
37
+ * broken stderr' error instead of resolving `true`.
38
+ * - the "scope" case rejected with that SAME raw EPIPE error instead of
39
+ * the expected 'provenance: GitHub API timeout' operational error —
40
+ * proving the pre-fix bug also clobbers a genuine exhausted-retry error
41
+ * with the logger's own unrelated failure.
42
+ * The identical fixture was then re-run against a fixed copy (the nested
43
+ * try/catch added, nothing else changed) and both cases passed. Only that
44
+ * one nested try/catch differed between the RED run and the GREEN run.
45
+ *
46
+ * Care taken against the "vacuous test" shape (this wave's own named lesson,
47
+ * re: F-2a5ddafa's original test asserting a fix fired without ever proving
48
+ * it fired ONLY where it should): `opts.onRetryWarn` is deliberately NEVER
49
+ * overridden below — overriding it would bypass defaultOnRetryWarn entirely
50
+ * and the test would pass whether or not this fix exists, regardless of the
51
+ * production code. The global `console.error` (not `process.stderr.write`,
52
+ * which the F-2a5ddafa suite already uses for a *different* assertion) is
53
+ * the monkey-patch target because that is exactly what defaultOnRetryWarn
54
+ * calls. The second test below additionally proves the guard is scoped
55
+ * correctly: it swallows ONLY the logger's own failure and does not mask a
56
+ * real, unrelated operational error the caller is entitled to see.
57
+ */
58
+
59
+ import { describe, it } from 'node:test';
60
+ import assert from 'node:assert/strict';
61
+
62
+ import { githubProvenance } from './provenance.js';
63
+
64
+ const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
65
+
66
+ const GH_SOURCE = {
67
+ provider: 'github',
68
+ workflow: 'dogfood.yml',
69
+ provider_run_id: '9123456789',
70
+ run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
71
+ };
72
+
73
+ function abortError() {
74
+ const e = new Error('This operation was aborted');
75
+ e.name = 'AbortError';
76
+ return e;
77
+ }
78
+
79
+ const noSleep = async () => {};
80
+
81
+ function okGithubResp() {
82
+ return {
83
+ ok: true,
84
+ status: 200,
85
+ json: async () => ({
86
+ id: 9123456789,
87
+ status: 'completed',
88
+ head_sha: RUN_HEAD,
89
+ repository: { full_name: 'acme/widget' }
90
+ })
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Monkey-patch the GLOBAL console.error for the duration of `fn` so every
96
+ * call throws, simulating a broken stderr fd (EPIPE/ENOSPC) — the exact
97
+ * mechanism the finding proved live. Always restores the original in
98
+ * `finally` so a failing assertion cannot leak the patch into a sibling
99
+ * test (node:test runs this file's cases sequentially, but a leaked patch
100
+ * would still poison any later suite in the same process).
101
+ *
102
+ * @param {(getCallCount: () => number) => Promise<void>} fn
103
+ */
104
+ async function withThrowingConsoleError(fn) {
105
+ const orig = console.error;
106
+ let calls = 0;
107
+ console.error = () => {
108
+ calls++;
109
+ throw new Error('EPIPE: simulated broken stderr');
110
+ };
111
+ try {
112
+ await fn(() => calls);
113
+ } finally {
114
+ console.error = orig;
115
+ }
116
+ }
117
+
118
+ /** @pins F-f50e779b */
119
+ describe('F-f50e779b — defaultOnRetryWarn fallback is itself guarded', () => {
120
+ it('a retry that would succeed still resolves true when console.error throws on every call', async () => {
121
+ await withThrowingConsoleError(async (getCalls) => {
122
+ let fetchCalls = 0;
123
+ const adapter = githubProvenance('token', {
124
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
125
+ // Deliberately NOT overriding onRetryWarn — must exercise the real
126
+ // defaultOnRetryWarn, or this test never reaches the guarded code
127
+ // (the exact vacuous-test shape this suite's header warns against).
128
+ fetchImpl: async () => {
129
+ fetchCalls++;
130
+ if (fetchCalls === 1) throw abortError();
131
+ return okGithubResp();
132
+ }
133
+ });
134
+ const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
135
+ assert.equal(ok, true, 'a retry that succeeds must resolve true, not reject');
136
+ assert.equal(getCalls(), 2, 'expected exactly 2 console.error attempts: primary (line ~149) + fallback (line ~153)');
137
+ });
138
+ });
139
+
140
+ it('scope: a genuinely exhausted retry budget still throws the real operational error, not the logger error', async () => {
141
+ await withThrowingConsoleError(async () => {
142
+ const adapter = githubProvenance('token', {
143
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
144
+ fetchImpl: async () => { throw abortError(); } // never succeeds — budget exhausts
145
+ });
146
+ await assert.rejects(
147
+ () => adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD }),
148
+ /provenance: GitHub API timeout/,
149
+ 'the fallback guard must swallow ONLY the logger failure — it must never mask the real exhausted-retry error behind it'
150
+ );
151
+ });
152
+ });
153
+ });
@@ -118,6 +118,15 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
118
118
  * `configErrors` (VERIFY-F1) are eval-time repo custom-rule predicate faults; the
119
119
  * caller emits them with a `policy-config:` prefix (submission-bad). A non-empty
120
120
  * `configErrors` makes `valid` false.
121
+ * @throws {Error} F-3ef6b03e: a global rule declared `severity: reject` whose id
122
+ * is outside {@link KNOWN_REJECT_RULE_IDS} — a maintainer-authored
123
+ * global-policy.yaml gap, not a submission fault. Mirrors the GLOBAL-rule
124
+ * predicate-fault throw above: `runValidator('policy', ...)` in index.js
125
+ * wraps this as `VALIDATOR_FAULT_POLICY:`, which parseRejectionReason
126
+ * classifies 'operational' by family match. Pre-fix this pushed into
127
+ * `errors` under the `policy:` prefix, which parseRejectionReason
128
+ * classifies 'submission-bad' — telling every consumer in the fleet to fix
129
+ * a payload that was never the problem.
121
130
  */
122
131
  export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
123
132
  const errors = [];
@@ -196,15 +205,21 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
196
205
 
197
206
  // schema-valid, provenance-confirmed, step-results-present, step-verdict-consistent,
198
207
  // no-verdict-upgrade are enforced by other validators or the main verify() function.
199
- // PROACT-VERIFY-002: a reject rule the build neither handles here NOR enforces
200
- // elsewhere would otherwise pass SILENTLY — the operator's new gate never runs.
201
- // Reject the submission with a diagnostic naming the unenforced rule so the gap
202
- // is visible instead of failing open.
208
+ // PROACT-VERIFY-002 / F-3ef6b03e: a reject rule the build neither handles here NOR
209
+ // enforces elsewhere would otherwise pass SILENTLY — the operator's new gate never
210
+ // runs. THROW rather than push to `errors`: this is a maintainer-authored
211
+ // global-policy.yaml gap (the same class of broken-policy-file fault
212
+ // loadGlobalPolicy's schema gate already fails loud on), not a submission fault.
213
+ // Pushing to `errors` would route through the `policy:` prefix — classified
214
+ // 'submission-bad' by parseRejectionReason — telling the submitter to fix a
215
+ // payload that was never the problem, fleet-wide, for every submission until a
216
+ // maintainer notices. Throwing here mirrors the GLOBAL-rule predicate-fault
217
+ // throw above: runValidator('policy', ...) in index.js wraps it as
218
+ // `VALIDATOR_FAULT_POLICY:`, classified 'operational' — exit 2, nothing
219
+ // persisted, no run_id poisoned via the duplicate guard (F-82429f90).
203
220
  default:
204
221
  if (!KNOWN_REJECT_RULE_IDS.has(rule.id)) {
205
- // No `policy:` prefix here — index.js prepends it to every policy
206
- // error (mirrors the `[rule.id]` / `surface[...]` messages above).
207
- errors.push(
222
+ throw new Error(
208
223
  `rule "${rule.id}" is declared severity:reject but has no enforcement in this build — ` +
209
224
  `add an enforcement arm in validators/policy.js or register it in KNOWN_REJECT_RULE_IDS`
210
225
  );
@@ -25,6 +25,22 @@ const schemaPath = require.resolve(
25
25
  const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
26
26
  const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
27
27
 
28
+ /**
29
+ * F-a2fb624f: the pre-fix tripwire (`provenanceForProvider('bitbucket') ===
30
+ * null`) only probed a NON-prototype unknown key — green both before AND
31
+ * after F-2965699b's fix, so it gave zero coverage for the actual defect
32
+ * class. This table-driven set is the real regression guard: every
33
+ * Object.prototype own-property name, which is exactly the key space
34
+ * `PROVENANCE_ADAPTERS[provider]` resolved (as a truthy inherited method,
35
+ * defeating `?? null`) before the Object.hasOwn fix. Shared verbatim with
36
+ * repo-binding.test.js's sibling table so both prototype-safety sites are
37
+ * proven against the identical key set.
38
+ */
39
+ const OBJECT_PROTOTYPE_KEYS = [
40
+ 'constructor', 'toString', 'valueOf', '__proto__',
41
+ 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
42
+ ];
43
+
28
44
  describe('provenance adapter coverage', () => {
29
45
  it('exposes the provider enum it is meant to cover (sanity)', () => {
30
46
  assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
@@ -53,4 +69,23 @@ describe('provenance adapter coverage', () => {
53
69
  it('provenanceForProvider returns null for an unknown provider (no throw)', () => {
54
70
  assert.equal(provenanceForProvider('bitbucket'), null);
55
71
  });
72
+
73
+ describe('F-2965699b: provenanceForProvider never resolves an inherited Object.prototype key', () => {
74
+ for (const key of OBJECT_PROTOTYPE_KEYS) {
75
+ it(`returns null (not the inherited method) for provider="${key}"`, () => {
76
+ // Deletion/emptiness proof: revert provenanceForProvider to
77
+ // `PROVENANCE_ADAPTERS[provider] ?? null` and this goes red for
78
+ // every key here — each one currently resolves a truthy inherited
79
+ // Object.prototype value that `?? null` cannot catch.
80
+ assert.equal(provenanceForProvider(key), null,
81
+ `provider="${key}" must resolve to null, not an inherited Object.prototype member`);
82
+ });
83
+ }
84
+
85
+ it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
86
+ for (const bad of [{}, 42, null, undefined, ['github']]) {
87
+ assert.equal(provenanceForProvider(bad), null, `provider=${JSON.stringify(bad)} must resolve to null`);
88
+ }
89
+ });
90
+ });
56
91
  });