@dogfood-lab/verify 1.7.0 → 1.9.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.
@@ -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
  }
@@ -72,7 +77,13 @@ export function validateStepResults(scenarioResult) {
72
77
 
73
78
  /**
74
79
  * Validate step results against a scenario definition's required_steps.
75
- * Used when the scenario definition is available (policy evaluation phase).
80
+ *
81
+ * F-3bfc2885: this is the REAL enforcement behind the `step-results-present`
82
+ * and `step-verdict-consistent` global reject rules (policies/global-policy.yaml,
83
+ * allowlisted in KNOWN_REJECT_RULE_IDS). verify() calls it per scenario_result
84
+ * when the caller supplies loaded scenario definitions (options.scenarios).
85
+ * Each error is tagged with the `[rule-id]` it enforces so operators can pivot
86
+ * from a rejection reason straight to the policy rule.
76
87
  *
77
88
  * @param {object} scenarioResult - A single scenario_results[] item
78
89
  * @param {string[]} requiredSteps - Step IDs from scenario definition's success_criteria.required_steps
@@ -82,25 +93,44 @@ export function validateRequiredSteps(scenarioResult, requiredSteps) {
82
93
  const errors = [];
83
94
  const { step_results, verdict } = scenarioResult;
84
95
 
85
- if (!step_results) return ['step_results missing'];
96
+ if (!step_results) return ['[step-results-present] step_results missing'];
86
97
 
87
- const resultMap = new Map(step_results.map(s => [s.step_id, s]));
98
+ // F-ad98b5ac: build the map defensively the same floor the sibling
99
+ // validateStepResults applies (its verify-B-004 guard). A null / non-object /
100
+ // string-step_id-less element must not throw a TypeError here: that TypeError
101
+ // escapes to runValidator('steps', ...) which mislabels it as an operational
102
+ // VALIDATOR_FAULT_STEPS, inverting a submission-bad signal into an ops fault
103
+ // (the F-efe4f893 family inversion). Structural malformed-step *reporting*
104
+ // stays validateStepResults' job; this only stops the map build from throwing.
105
+ const resultMap = new Map();
106
+ for (const s of step_results) {
107
+ if (s != null && typeof s === 'object' && typeof s.step_id === 'string') {
108
+ resultMap.set(s.step_id, s);
109
+ }
110
+ }
88
111
 
89
112
  // Every required step must have a matching step_result
90
113
  for (const stepId of requiredSteps) {
91
114
  const result = resultMap.get(stepId);
92
115
  if (!result) {
93
- errors.push(`required step "${stepId}" has no matching step_result`);
116
+ errors.push(`[step-results-present] required step "${stepId}" has no matching step_result`);
94
117
  }
95
118
  }
96
119
 
97
- // A scenario cannot be "pass" if any required step is fail/blocked
120
+ // A scenario cannot be "pass" if any required step is fail/blocked — or
121
+ // absent entirely: validateStepResults' structural check only sees REPORTED
122
+ // steps, so a submitter who silently omits a failing required step would
123
+ // otherwise keep a "pass" verdict consistent (F-3bfc2885 sibling case).
98
124
  if (verdict === 'pass') {
99
125
  for (const stepId of requiredSteps) {
100
126
  const result = resultMap.get(stepId);
101
- if (result && (result.status === 'fail' || result.status === 'blocked')) {
127
+ if (!result) {
128
+ errors.push(
129
+ `[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has no step_result`
130
+ );
131
+ } else if (result.status === 'fail' || result.status === 'blocked') {
102
132
  errors.push(
103
- `scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
133
+ `[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
104
134
  );
105
135
  }
106
136
  }
@@ -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
+ });