@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.
- package/README.md +24 -1
- package/cli-lint.js +254 -0
- package/cli.js +115 -16
- package/index.js +95 -61
- package/package.json +3 -1
- package/parse-rejection.js +10 -0
- package/validators/f-5fd3f832-retry-after-clamp.test.js +96 -0
- package/validators/f-ad98b5ac-required-steps-map-guard.test.js +85 -0
- package/validators/f-dac7e08c-transport-error-operational.test.js +121 -0
- package/validators/lint-policy.js +120 -0
- package/validators/lint-scenario.js +141 -0
- package/validators/policy.js +9 -1
- package/validators/predicate.js +163 -3
- package/validators/provenance-gitlab.test.js +10 -3
- package/validators/provenance.js +60 -13
- package/validators/repo-binding.js +18 -10
- package/validators/steps.js +39 -9
- package/validators/w4-f-8e72d0de-timeout-retry.test.js +118 -0
package/validators/steps.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|