@dogfood-lab/verify 1.8.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.
@@ -29,6 +29,19 @@ const schemaPath = require.resolve(
29
29
  const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
30
30
  const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
31
31
 
32
+ /**
33
+ * F-a2fb624f: sibling table to provenance-registry.test.js's — the pre-fix
34
+ * tripwire here ('returns null for a provider with no parser') only probed
35
+ * 'bitbucket', a NON-prototype key, so it gave zero coverage for the actual
36
+ * F-7ce07baa defect class (every Object.prototype own-property name, which
37
+ * `RUN_URL_PARSERS[provider]` resolved as a truthy inherited method before
38
+ * the Object.hasOwn fix).
39
+ */
40
+ const OBJECT_PROTOTYPE_KEYS = [
41
+ 'constructor', 'toString', 'valueOf', '__proto__',
42
+ 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
43
+ ];
44
+
32
45
  describe('repo-binding guard coverage (verify-B-001)', () => {
33
46
  it('exposes the provider enum it is meant to cover (sanity)', () => {
34
47
  assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
@@ -115,4 +128,31 @@ describe('repo-binding guard coverage (verify-B-001)', () => {
115
128
  it('returns null for a provider with no parser (no throw)', () => {
116
129
  assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
117
130
  });
131
+
132
+ describe('F-7ce07baa: parseRunUrlRepo never resolves an inherited Object.prototype key', () => {
133
+ for (const key of OBJECT_PROTOTYPE_KEYS) {
134
+ it(`returns null (not the inherited method) for provider="${key}"`, () => {
135
+ // Deletion/emptiness proof: revert parseRunUrlRepo to a bare
136
+ // `RUN_URL_PARSERS[provider]` lookup and this goes red for every key
137
+ // here — each currently resolves a truthy inherited Object.prototype
138
+ // value the `if (!parser)` guard cannot catch, and `parser(runUrl)`
139
+ // then calls an unrelated built-in method.
140
+ assert.equal(
141
+ parseRunUrlRepo(key, 'https://github.com/acme/widget/actions/runs/1'),
142
+ null,
143
+ `provider="${key}" must resolve to null, not an inherited Object.prototype member`
144
+ );
145
+ });
146
+ }
147
+
148
+ it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
149
+ for (const bad of [{}, 42, null, undefined, ['github']]) {
150
+ assert.equal(
151
+ parseRunUrlRepo(bad, 'https://github.com/acme/widget/actions/runs/1'),
152
+ null,
153
+ `provider=${JSON.stringify(bad)} must resolve to null`
154
+ );
155
+ }
156
+ });
157
+ });
118
158
  });
@@ -10,8 +10,9 @@
10
10
  * Validate step results for a single scenario result.
11
11
  *
12
12
  * Note: Without access to the source repo's scenario definition, we validate
13
- * structural integrity. The full required_steps check is done by policy
14
- * evaluation when scenario definitions are available.
13
+ * structural integrity. The full required_steps check is done by
14
+ * validateRequiredSteps below, which verify() runs per scenario_result when
15
+ * the caller supplies loaded scenario definitions (options.scenarios).
15
16
  *
16
17
  * @param {object} scenarioResult - A single scenario_results[] item
17
18
  * @returns {string[]} Array of error messages (empty if valid)
@@ -41,6 +42,10 @@ export function validateStepResults(scenarioResult) {
41
42
  const seenIds = new Set();
42
43
  for (const step of step_results) {
43
44
  if (step == null || typeof step !== 'object') continue;
45
+ // F-70338558: a step without a string step_id is already reported as
46
+ // malformed above; letting `undefined` into the dedupe Set made two
47
+ // malformed steps emit a spurious `duplicate step_id: undefined`.
48
+ if (typeof step.step_id !== 'string') continue;
44
49
  if (seenIds.has(step.step_id)) {
45
50
  errors.push(`duplicate step_id: ${step.step_id}`);
46
51
  }
@@ -67,12 +72,63 @@ export function validateStepResults(scenarioResult) {
67
72
  }
68
73
  }
69
74
 
75
+ // F-88fb37ff: mirror of the pass-direction check above. A scenario cannot
76
+ // claim "fail"/"blocked" while every reported step says otherwise either —
77
+ // computeVerdict() (validators/verdict.js) trusts scenario_results[].verdict
78
+ // verbatim and never re-derives it from step_results, so without this check
79
+ // a self-reported "blocked" verdict backed by zero failing/blocked steps
80
+ // sailed through with no rejection reason at all.
81
+ //
82
+ // F-e42e8f80 (wave 20, amends F-88fb37ff): the original gate required at
83
+ // least one step to ACTIVELY report fail/blocked, which wrongly rejected
84
+ // the common honest shape "the scenario was blocked before any step could
85
+ // run, so every step reports 'skip'" — 'skip' and 'partial' are NEUTRAL
86
+ // (no evidence either way), not "evidence of no failure." Only 'pass' is an
87
+ // ACTIVE CONTRADICTION of a fail/blocked verdict (the step ran and claimed
88
+ // success). The gate now fires only when EVERY reported step actively says
89
+ // "pass" — a single skip/partial/fail/blocked step is enough to keep a
90
+ // fail/blocked verdict internally consistent.
91
+ //
92
+ // F-cc198701 (wave 22, confirming audit of F-e42e8f80): this condition and
93
+ // its validateRequiredSteps mirror below enumerated only 2 of the 4 legal
94
+ // values in scenario_results[].verdict's own schema enum (dogfood-record-
95
+ // submission.schema.json: ["pass","fail","blocked","partial"]) — 'partial'
96
+ // was never checked in either direction. A submitter could self-report
97
+ // verdict:'partial' with EVERY step actively 'fail' (strictly worse,
98
+ // more self-contradictory evidence than the fail/blocked direction already
99
+ // rejects) and sail through with zero rejection reason, because
100
+ // computeVerdict() never re-derives the verdict from step_results either —
101
+ // the ONLY guard against a dishonest self-report was this exact check, and
102
+ // it had a verdict-enum-shaped hole. Widened to also fire for 'partial',
103
+ // reusing the identical "all present steps actively pass" bar: 'partial'
104
+ // backed by zero non-pass evidence is exactly as self-contradictory as
105
+ // 'blocked'/'fail' backed by zero non-pass evidence. Deliberately does NOT
106
+ // touch the pass-direction check above (line ~59) — 'partial' backed by
107
+ // SOME failing steps is not inherently contradictory the way 'partial'
108
+ // backed by all-pass steps is.
109
+ if (verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') {
110
+ const allStepsActivelyPass = step_results.every(
111
+ s => s != null && s.status === 'pass'
112
+ );
113
+ if (allStepsActivelyPass) {
114
+ errors.push(
115
+ `scenario verdict is "${verdict}" but no step reports status fail/blocked`
116
+ );
117
+ }
118
+ }
119
+
70
120
  return errors;
71
121
  }
72
122
 
73
123
  /**
74
124
  * Validate step results against a scenario definition's required_steps.
75
- * Used when the scenario definition is available (policy evaluation phase).
125
+ *
126
+ * F-3bfc2885: this is the REAL enforcement behind the `step-results-present`
127
+ * and `step-verdict-consistent` global reject rules (policies/global-policy.yaml,
128
+ * allowlisted in KNOWN_REJECT_RULE_IDS). verify() calls it per scenario_result
129
+ * when the caller supplies loaded scenario definitions (options.scenarios).
130
+ * Each error is tagged with the `[rule-id]` it enforces so operators can pivot
131
+ * from a rejection reason straight to the policy rule.
76
132
  *
77
133
  * @param {object} scenarioResult - A single scenario_results[] item
78
134
  * @param {string[]} requiredSteps - Step IDs from scenario definition's success_criteria.required_steps
@@ -82,29 +138,80 @@ export function validateRequiredSteps(scenarioResult, requiredSteps) {
82
138
  const errors = [];
83
139
  const { step_results, verdict } = scenarioResult;
84
140
 
85
- if (!step_results) return ['step_results missing'];
141
+ if (!step_results) return ['[step-results-present] step_results missing'];
86
142
 
87
- const resultMap = new Map(step_results.map(s => [s.step_id, s]));
143
+ // F-ad98b5ac: build the map defensively the same floor the sibling
144
+ // validateStepResults applies (its verify-B-004 guard). A null / non-object /
145
+ // string-step_id-less element must not throw a TypeError here: that TypeError
146
+ // escapes to runValidator('steps', ...) which mislabels it as an operational
147
+ // VALIDATOR_FAULT_STEPS, inverting a submission-bad signal into an ops fault
148
+ // (the F-efe4f893 family inversion). Structural malformed-step *reporting*
149
+ // stays validateStepResults' job; this only stops the map build from throwing.
150
+ const resultMap = new Map();
151
+ for (const s of step_results) {
152
+ if (s != null && typeof s === 'object' && typeof s.step_id === 'string') {
153
+ resultMap.set(s.step_id, s);
154
+ }
155
+ }
88
156
 
89
157
  // Every required step must have a matching step_result
90
158
  for (const stepId of requiredSteps) {
91
159
  const result = resultMap.get(stepId);
92
160
  if (!result) {
93
- errors.push(`required step "${stepId}" has no matching step_result`);
161
+ errors.push(`[step-results-present] required step "${stepId}" has no matching step_result`);
94
162
  }
95
163
  }
96
164
 
97
- // A scenario cannot be "pass" if any required step is fail/blocked
165
+ // A scenario cannot be "pass" if any required step is fail/blocked — or
166
+ // absent entirely: validateStepResults' structural check only sees REPORTED
167
+ // steps, so a submitter who silently omits a failing required step would
168
+ // otherwise keep a "pass" verdict consistent (F-3bfc2885 sibling case).
98
169
  if (verdict === 'pass') {
99
170
  for (const stepId of requiredSteps) {
100
171
  const result = resultMap.get(stepId);
101
- if (result && (result.status === 'fail' || result.status === 'blocked')) {
172
+ if (!result) {
102
173
  errors.push(
103
- `scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
174
+ `[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has no step_result`
175
+ );
176
+ } else if (result.status === 'fail' || result.status === 'blocked') {
177
+ errors.push(
178
+ `[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
104
179
  );
105
180
  }
106
181
  }
107
182
  }
108
183
 
184
+ // F-88fb37ff: mirror of the pass-direction block above, scoped to REQUIRED
185
+ // steps (the sibling check in validateStepResults enforces the same rule
186
+ // over ALL reported steps regardless of which are required).
187
+ //
188
+ // F-e42e8f80 (wave 20, amends F-88fb37ff): "no required step reports
189
+ // fail/blocked" was too narrow a bar — a required step honestly reporting
190
+ // 'skip' (never ran because the scenario was blocked upstream) or 'partial'
191
+ // is NEUTRAL, not an active contradiction, and must not force a rejection.
192
+ // The gate now fires only when every PRESENT required step actively says
193
+ // "pass" — mirroring validateStepResults' all-pass bar. A required step
194
+ // that is simply MISSING is "not an active pass" exactly like skip/partial
195
+ // would be, so it never by itself forces this check to fire (`result !=
196
+ // null && result.status === 'pass'` is false for a missing step too); its
197
+ // absence is already rejected unconditionally by the [step-results-present]
198
+ // loop above, regardless of verdict, so this check deliberately does not
199
+ // pile a second, verdict-specific error onto the exact same gap.
200
+ //
201
+ // F-cc198701 (wave 22): mirrors the identical widening in
202
+ // validateStepResults above — 'partial' was the one legal scenario-verdict
203
+ // enum value this scoped-to-required-steps check never enumerated either.
204
+ if ((verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') && requiredSteps.length > 0) {
205
+ const allPresentRequiredStepsActivelyPass = requiredSteps.every(stepId => {
206
+ const result = resultMap.get(stepId);
207
+ return result != null && result.status === 'pass';
208
+ });
209
+ if (allPresentRequiredStepsActivelyPass) {
210
+ errors.push(
211
+ `[step-verdict-consistent] scenario verdict is "${verdict}" but no required step reports status fail/blocked`
212
+ );
213
+ }
214
+ }
215
+
109
216
  return errors;
110
217
  }
@@ -6,7 +6,15 @@
6
6
  * Verdict severity (highest to lowest): fail > blocked > partial > pass
7
7
  */
8
8
 
9
- const VERDICT_RANK = { fail: 0, blocked: 1, partial: 2, pass: 3 };
9
+ // F-937733ee: family sibling of F-2965699b/F-7ce07baa. Object.create(null)
10
+ // removes the prototype chain entirely — `VERDICT_RANK['constructor']` is
11
+ // `undefined`, not `Object.prototype.constructor` — so the `== null` guards
12
+ // below fire correctly for every Object.prototype key, not just for a
13
+ // literal typo. Currently unreachable in production (index.js:326 only ever
14
+ // passes schema-enum-valid verdicts into `scenarioResults`), but sealing it
15
+ // here keeps a future caller that skips the schema gate from silently
16
+ // reopening the class.
17
+ const VERDICT_RANK = Object.assign(Object.create(null), { fail: 0, blocked: 1, partial: 2, pass: 3 });
10
18
 
11
19
  /**
12
20
  * Compute the verified verdict.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * w4-f-8e72d0de-timeout-retry.test.js
3
+ *
4
+ * F-8e72d0de (wave 4): both provenance adapters threw IMMEDIATELY on an
5
+ * AbortError (per-request timeout) while retrying transport rejects and
6
+ * 429/5xx within the PROVENANCE_RETRIES budget. A timeout is at least as
7
+ * transient as a connection refusal — a single slow response failed the
8
+ * whole confirmation when one retry might have confirmed the run. Contrast:
9
+ * the scenario fetcher in ingest/load-context.js already retried timeouts.
10
+ *
11
+ * Contract (both adapters — the documented peer discipline):
12
+ * - an AbortError is retried within the same budget as a transport reject;
13
+ * - on exhaustion the timeout error still throws (operational,
14
+ * `provenance-fault:` at the verify() boundary).
15
+ */
16
+
17
+ import { describe, it } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+
20
+ import { githubProvenance, gitlabProvenance } from './provenance.js';
21
+
22
+ const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
23
+
24
+ const GH_SOURCE = {
25
+ provider: 'github',
26
+ workflow: 'dogfood.yml',
27
+ provider_run_id: '9123456789',
28
+ run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
29
+ };
30
+
31
+ const GL_SOURCE = {
32
+ provider: 'gitlab',
33
+ workflow: '.gitlab-ci.yml',
34
+ provider_run_id: '424242',
35
+ run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
36
+ repo: 'acme/widget'
37
+ };
38
+
39
+ function abortError() {
40
+ const e = new Error('This operation was aborted');
41
+ e.name = 'AbortError';
42
+ return e;
43
+ }
44
+
45
+ const noSleep = async () => {};
46
+
47
+ describe('F-8e72d0de — githubProvenance retries per-request timeouts', () => {
48
+ it('a single timeout followed by a 200 confirms the run', async () => {
49
+ let calls = 0;
50
+ const adapter = githubProvenance('token', {
51
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
52
+ fetchImpl: async () => {
53
+ calls++;
54
+ if (calls === 1) throw abortError();
55
+ return {
56
+ ok: true,
57
+ status: 200,
58
+ json: async () => ({
59
+ id: 9123456789,
60
+ status: 'completed',
61
+ head_sha: RUN_HEAD,
62
+ repository: { full_name: 'acme/widget' }
63
+ })
64
+ };
65
+ }
66
+ });
67
+ const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
68
+ assert.equal(ok, true, 'one slow response must not fail the confirmation');
69
+ assert.equal(calls, 2);
70
+ });
71
+
72
+ it('exhausting every attempt on timeouts still throws the timeout error', async () => {
73
+ let calls = 0;
74
+ const adapter = githubProvenance('token', {
75
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
76
+ fetchImpl: async () => { calls++; throw abortError(); }
77
+ });
78
+ await assert.rejects(
79
+ () => adapter.confirm(GH_SOURCE),
80
+ /provenance: GitHub API timeout after 1000ms/
81
+ );
82
+ assert.equal(calls, 3, 'retries=2 → 3 total attempts before the operational throw');
83
+ });
84
+ });
85
+
86
+ describe('F-8e72d0de — gitlabProvenance mirrors the timeout-retry discipline', () => {
87
+ it('a single timeout followed by a 200 confirms the pipeline', async () => {
88
+ let calls = 0;
89
+ const adapter = gitlabProvenance('token', {
90
+ timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
91
+ fetchImpl: async () => {
92
+ calls++;
93
+ if (calls === 1) throw abortError();
94
+ return {
95
+ ok: true,
96
+ status: 200,
97
+ json: async () => ({ id: 424242, status: 'success', sha: RUN_HEAD })
98
+ };
99
+ }
100
+ });
101
+ const ok = await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
102
+ assert.equal(ok, true);
103
+ assert.equal(calls, 2);
104
+ });
105
+
106
+ it('exhausting every attempt on timeouts still throws the timeout error', async () => {
107
+ let calls = 0;
108
+ const adapter = gitlabProvenance('token', {
109
+ timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
110
+ fetchImpl: async () => { calls++; throw abortError(); }
111
+ });
112
+ await assert.rejects(
113
+ () => adapter.confirm(GL_SOURCE),
114
+ /provenance: GitLab API timeout after 1000ms/
115
+ );
116
+ assert.equal(calls, 2);
117
+ });
118
+ });