@dogfood-lab/verify 1.8.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 +2 -1
- package/cli-lint.js +78 -33
- package/cli.js +94 -15
- package/index.js +95 -61
- package/package.json +1 -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-scenario.js +141 -0
- package/validators/policy.js +9 -1
- 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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario lint (F-BACKEND-003)
|
|
3
|
+
*
|
|
4
|
+
* `lintScenario(scenarioDoc, { file })` is the author-time check behind the
|
|
5
|
+
* `dogfood-verify lint --scenario <file>` mode: it validates a whole scenario
|
|
6
|
+
* definition WITHOUT a submission, batch-reporting every fault. It is the
|
|
7
|
+
* scenario-side sibling of lint-policy.js (VERIFY-F3) and shares its shape,
|
|
8
|
+
* exit contract, and honest-coverage discipline.
|
|
9
|
+
*
|
|
10
|
+
* Three passes, mirroring lint-policy's layering:
|
|
11
|
+
* 1. Structural gate — `validatePayload('scenario', …)` against scenario.schema.json
|
|
12
|
+
* (the same registered schema production ingest uses to fetch + validate a
|
|
13
|
+
* committed scenario). Catches missing required fields, bad enums, pattern
|
|
14
|
+
* violations, additionalProperties, the verifiable⇒expected conditional.
|
|
15
|
+
* 2. Author-time value checks the schema cannot express (errors, `scenario-config:`):
|
|
16
|
+
* required_steps referencing an undeclared step id, and duplicate step ids.
|
|
17
|
+
* The scenario schema literally says required_steps "Must reference valid step
|
|
18
|
+
* IDs" but JSON Schema cannot enforce a cross-field id reference, and it
|
|
19
|
+
* validates each step in isolation so it cannot see a repeated id.
|
|
20
|
+
* 3. `filename → scenario_id` advisory (a WARNING, never an error,
|
|
21
|
+
* `scenario-footgun:`): the receiver fetches dogfood/scenarios/<scenario_id>.yaml,
|
|
22
|
+
* so a basename that differs from scenario_id makes the committed definition
|
|
23
|
+
* unreachable and required-steps enforcement silently fails open.
|
|
24
|
+
*
|
|
25
|
+
* Coverage boundary (the VERIFY-F2 over-claim lesson): a clean scenario lint is
|
|
26
|
+
* "no static fault," NOT "a real submission will satisfy this scenario." Static
|
|
27
|
+
* lint validates the definition in isolation; it cannot verify that a submission's
|
|
28
|
+
* step_results actually satisfy required_steps, nor that the receiver can reach the
|
|
29
|
+
* file at the attested commit. `coverageNote` says so.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { basename } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
35
|
+
|
|
36
|
+
/** Stated in every scenario lint result so a clean verdict is never read as full coverage. */
|
|
37
|
+
export const SCENARIO_COVERAGE_NOTE =
|
|
38
|
+
'Static lint only — it validates the scenario DEFINITION in isolation. It cannot verify that a ' +
|
|
39
|
+
"real submission's `step_results` actually satisfy `required_steps`, nor that the receiver can " +
|
|
40
|
+
'fetch this file at the attested commit (the filename → scenario_id check is a heuristic, not a ' +
|
|
41
|
+
'guarantee). Run a real ingest of a submission for that.';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lint a parsed scenario document.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} scenarioDoc - The parsed scenario YAML/JSON (any value; a non-object is
|
|
47
|
+
* reported by the schema gate).
|
|
48
|
+
* @param {{ file?: string }} [opts] - The source file path (basename → scenario_id advisory).
|
|
49
|
+
* When absent, the filename check is skipped (there is no basename to compare).
|
|
50
|
+
* @returns {{
|
|
51
|
+
* ok: boolean, origin: 'scenario', coverageNote: string,
|
|
52
|
+
* errors: { label: string, code: string, location: string, field?: string, message: string }[],
|
|
53
|
+
* warnings: { label: string, code: string, location: string, field: string, suggestion: string, message: string }[]
|
|
54
|
+
* }} `ok` is true iff there are no errors; warnings never affect `ok`.
|
|
55
|
+
*/
|
|
56
|
+
export function lintScenario(scenarioDoc, { file } = {}) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
const warnings = [];
|
|
59
|
+
|
|
60
|
+
// 1. Structural schema gate.
|
|
61
|
+
const schema = validatePayload('scenario', scenarioDoc);
|
|
62
|
+
for (const e of schema.errors) {
|
|
63
|
+
errors.push({
|
|
64
|
+
label: 'scenario-schema:',
|
|
65
|
+
code: e.keyword || 'schema',
|
|
66
|
+
location: e.path || '/',
|
|
67
|
+
message: e.message || 'schema violation',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Author-time value checks. Defensive: a schema-invalid doc may have a malformed or
|
|
72
|
+
// missing steps/success_criteria, so guard every access against non-arrays/non-objects
|
|
73
|
+
// (mirrors collectPredicates' defensiveness in lint-policy.js).
|
|
74
|
+
const doc = scenarioDoc && typeof scenarioDoc === 'object' ? scenarioDoc : {};
|
|
75
|
+
const steps = Array.isArray(doc.steps) ? doc.steps : [];
|
|
76
|
+
|
|
77
|
+
// Collect declared step ids, flagging duplicates as we go. A repeated id validates clean
|
|
78
|
+
// structurally (the schema checks each step in isolation) but is ambiguous at runtime: a
|
|
79
|
+
// step_result keyed by that id cannot say which step it satisfied.
|
|
80
|
+
const declaredIds = new Set();
|
|
81
|
+
const seenIds = new Set();
|
|
82
|
+
for (let i = 0; i < steps.length; i++) {
|
|
83
|
+
const step = steps[i];
|
|
84
|
+
if (!step || typeof step !== 'object') continue;
|
|
85
|
+
const id = step.id;
|
|
86
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
87
|
+
declaredIds.add(id);
|
|
88
|
+
if (seenIds.has(id)) {
|
|
89
|
+
errors.push({
|
|
90
|
+
label: 'scenario-config:',
|
|
91
|
+
code: 'duplicate_step_id',
|
|
92
|
+
location: `steps[${i}]`,
|
|
93
|
+
field: id,
|
|
94
|
+
message: `step id "${id}" is declared more than once — step ids must be unique so a step_result can name exactly one step`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
seenIds.add(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// required_steps must reference a declared step id. The scenario schema says so in prose but
|
|
101
|
+
// cannot enforce a cross-field id reference.
|
|
102
|
+
const sc = doc.success_criteria && typeof doc.success_criteria === 'object' ? doc.success_criteria : {};
|
|
103
|
+
const requiredSteps = Array.isArray(sc.required_steps) ? sc.required_steps : [];
|
|
104
|
+
for (let i = 0; i < requiredSteps.length; i++) {
|
|
105
|
+
const ref = requiredSteps[i];
|
|
106
|
+
if (typeof ref !== 'string') continue; // a non-string entry is a schema fault, already reported above
|
|
107
|
+
if (!declaredIds.has(ref)) {
|
|
108
|
+
errors.push({
|
|
109
|
+
label: 'scenario-config:',
|
|
110
|
+
code: 'required_step_undeclared',
|
|
111
|
+
location: `success_criteria.required_steps[${i}]`,
|
|
112
|
+
field: ref,
|
|
113
|
+
message: `required step "${ref}" is not declared by any steps[].id — the scenario can never pass because the receiver enforces a step the exercise never runs`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3. filename → scenario_id advisory. The receiver fetches dogfood/scenarios/<scenario_id>.yaml,
|
|
119
|
+
// so a mismatch makes the committed definition unreachable and required-steps enforcement fails
|
|
120
|
+
// OPEN silently. Advisory only — a repo may legitimately hold a scenario under a different name
|
|
121
|
+
// during authoring (e.g. examples/scenario.example.yaml holds scenario_id "cli-smoke").
|
|
122
|
+
const scenarioId = doc.scenario_id;
|
|
123
|
+
if (file && typeof scenarioId === 'string' && scenarioId.length > 0) {
|
|
124
|
+
const base = basename(String(file)).replace(/\.ya?ml$/i, '');
|
|
125
|
+
if (base !== scenarioId) {
|
|
126
|
+
warnings.push({
|
|
127
|
+
label: 'scenario-footgun:',
|
|
128
|
+
code: 'filename_scenario_id_mismatch',
|
|
129
|
+
location: '/',
|
|
130
|
+
field: 'scenario_id',
|
|
131
|
+
suggestion: `rename the file to "${scenarioId}.yaml" (or change scenario_id to "${base}")`,
|
|
132
|
+
message:
|
|
133
|
+
`the file basename "${base}" does not match scenario_id "${scenarioId}" — the receiver ` +
|
|
134
|
+
`fetches dogfood/scenarios/${scenarioId}.yaml, so this committed definition is unreachable ` +
|
|
135
|
+
`and required-steps enforcement silently fails OPEN`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ok: errors.length === 0, origin: 'scenario', errors, warnings, coverageNote: SCENARIO_COVERAGE_NOTE };
|
|
141
|
+
}
|
package/validators/policy.js
CHANGED
|
@@ -50,6 +50,11 @@ const KNOWN_REJECT_RULE_IDS = new Set([
|
|
|
50
50
|
// enforced by other validators or by verify() itself (default-arm no-op is correct)
|
|
51
51
|
'schema-valid',
|
|
52
52
|
'provenance-confirmed',
|
|
53
|
+
// F-3bfc2885: enforced by validateRequiredSteps (validators/steps.js), which
|
|
54
|
+
// verify() runs per scenario_result when the ingest layer supplies loaded
|
|
55
|
+
// scenario definitions (options.scenarios). The structural half (non-empty
|
|
56
|
+
// step_results, dup ids, pass-vs-fail over REPORTED steps) is
|
|
57
|
+
// validateStepResults.
|
|
53
58
|
'step-results-present',
|
|
54
59
|
'step-verdict-consistent',
|
|
55
60
|
'no-verdict-upgrade',
|
|
@@ -155,7 +160,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
155
160
|
// dropping operator-authored rules on the floor: a declared rule that the
|
|
156
161
|
// build does nothing with is invisible to the operator who wrote it.
|
|
157
162
|
if (rule.severity === 'warn') {
|
|
158
|
-
|
|
163
|
+
// F-57a0c0ad: bracketed `[id]` form — the same shape buildReason gives
|
|
164
|
+
// declarative rules — so one grep pattern finds every rule-attributed
|
|
165
|
+
// message regardless of how the rule is enforced.
|
|
166
|
+
warnings.push(`[${rule.id}] ${rule.description || 'policy warning'}`);
|
|
159
167
|
continue;
|
|
160
168
|
}
|
|
161
169
|
if (rule.severity === 'info') {
|
|
@@ -261,14 +261,21 @@ describe('gitlabProvenance fetch timeout', () => {
|
|
|
261
261
|
assert.ok(Date.now() - start < 5000, 'expected fast abort');
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
-
it('
|
|
264
|
+
it('throws provenance: network error on persistent transport failures (F-dac7e08c)', async () => {
|
|
265
|
+
// Pre-F-dac7e08c this pinned `return false` — which classified a network
|
|
266
|
+
// outage as submission-bad and permanently persisted a rejected record.
|
|
267
|
+
// The contract is now: retry within budget, then THROW (operational).
|
|
265
268
|
const failingFetch = async () => { throw new Error('connection refused'); };
|
|
266
269
|
const adapter = gitlabProvenance('token', {
|
|
267
270
|
timeoutMs: 1000,
|
|
271
|
+
retries: 1,
|
|
272
|
+
sleepImpl: async () => {},
|
|
268
273
|
fetchImpl: failingFetch
|
|
269
274
|
});
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
await assert.rejects(
|
|
276
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
277
|
+
/provenance: network error: connection refused/
|
|
278
|
+
);
|
|
272
279
|
});
|
|
273
280
|
});
|
|
274
281
|
|
package/validators/provenance.js
CHANGED
|
@@ -60,13 +60,27 @@ function isRetryableStatus(status) {
|
|
|
60
60
|
return status === 429 || (status >= 500 && status <= 599);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* F-5fd3f832: ceiling on a provider-sent Retry-After wait. The per-request
|
|
65
|
+
* AbortController timeout bounds each REQUEST but not the inter-attempt sleep
|
|
66
|
+
* — without this clamp, one hostile or misconfigured `Retry-After: 86400`
|
|
67
|
+
* (plausible via gitlabProvenance's self-hosted `apiBase` override) would
|
|
68
|
+
* stall the concurrency-serialized ingest.yml queue for a day. 30s matches
|
|
69
|
+
* the per-request timeout: an honest throttle rarely asks for more, and a
|
|
70
|
+
* provider that does is better surfaced as an exhausted-retries operational
|
|
71
|
+
* throw than silently obeyed.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_RETRY_AFTER_MS = 30_000;
|
|
74
|
+
|
|
63
75
|
/**
|
|
64
76
|
* Resolve the wait before the next attempt. A `Retry-After` header (delay in
|
|
65
|
-
* seconds, or an HTTP-date) is honored when present and parseable
|
|
66
|
-
*
|
|
67
|
-
*
|
|
77
|
+
* seconds, or an HTTP-date) is honored when present and parseable — clamped
|
|
78
|
+
* to {@link MAX_RETRY_AFTER_MS} in both branches (F-5fd3f832); otherwise
|
|
79
|
+
* fall back to exponential backoff (already bounded by the retry budget).
|
|
80
|
+
* `attempt` is 1-based (1 = wait before the 2nd request).
|
|
68
81
|
*
|
|
69
|
-
* @param {Response} resp - The non-ok response carrying a possible
|
|
82
|
+
* @param {Response|null} resp - The non-ok response carrying a possible
|
|
83
|
+
* Retry-After, or null for a transport reject (no response → exponential).
|
|
70
84
|
* @param {number} attempt - 1-based retry index.
|
|
71
85
|
* @param {number} backoffMs - Base backoff.
|
|
72
86
|
* @returns {number} Milliseconds to wait (never negative).
|
|
@@ -76,11 +90,11 @@ function nextBackoffMs(resp, attempt, backoffMs) {
|
|
|
76
90
|
if (header != null && header !== '') {
|
|
77
91
|
const asSeconds = Number(header);
|
|
78
92
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
|
79
|
-
return Math.round(asSeconds * 1000);
|
|
93
|
+
return Math.min(Math.round(asSeconds * 1000), MAX_RETRY_AFTER_MS);
|
|
80
94
|
}
|
|
81
95
|
const asDate = Date.parse(header);
|
|
82
96
|
if (!Number.isNaN(asDate)) {
|
|
83
|
-
return Math.max(0, asDate - Date.now());
|
|
97
|
+
return Math.min(Math.max(0, asDate - Date.now()), MAX_RETRY_AFTER_MS);
|
|
84
98
|
}
|
|
85
99
|
}
|
|
86
100
|
return backoffMs * 2 ** (attempt - 1);
|
|
@@ -185,11 +199,29 @@ export function githubProvenance(token, opts = {}) {
|
|
|
185
199
|
});
|
|
186
200
|
} catch (err) {
|
|
187
201
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
202
|
+
// F-8e72d0de: a per-request timeout is at least as transient as a
|
|
203
|
+
// connection refusal — retry within the same budget as the
|
|
204
|
+
// transport-reject branch below (mirrors the scenario fetcher's
|
|
205
|
+
// retryable-timeout discipline), then throw on exhaustion.
|
|
206
|
+
if (attempt < retries) {
|
|
207
|
+
await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
188
210
|
throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
|
|
189
211
|
}
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
212
|
+
// F-dac7e08c: a transport reject (DNS failure, ECONNREFUSED) means the
|
|
213
|
+
// provider or the runner's NETWORK is down — an operational incident,
|
|
214
|
+
// not evidence the run is absent. Retry within the same budget as a
|
|
215
|
+
// 5xx (at least as transient), then THROW so the reason lands under
|
|
216
|
+
// the operational `provenance-fault:` prefix. The old `return false`
|
|
217
|
+
// persisted a REJECTED submission-bad record during a network blip,
|
|
218
|
+
// and the duplicate guard then blocked a clean resubmission under the
|
|
219
|
+
// same run_id. `return false` is reserved for HTTP 404 (mirrors GitLab).
|
|
220
|
+
if (attempt < retries) {
|
|
221
|
+
await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`provenance: network error: ${err.message}`);
|
|
193
225
|
} finally {
|
|
194
226
|
clearTimeout(timer);
|
|
195
227
|
}
|
|
@@ -312,8 +344,12 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
312
344
|
if (urlRunId !== String(provider_run_id)) return false;
|
|
313
345
|
|
|
314
346
|
// Bind the project path to source.repo BEFORE the network call — a forged
|
|
315
|
-
// project claim is a cheap, offline rejection.
|
|
316
|
-
//
|
|
347
|
+
// project claim is a cheap, offline rejection. V2-CROSS-BO-005
|
|
348
|
+
// (F-54e5fde7 contract): submission.repo is strictly two-segment — the
|
|
349
|
+
// submission schema's `repo` pattern forbids a second slash, so nested
|
|
350
|
+
// GitLab subgroups are UNSUPPORTED end-to-end. A nested project path
|
|
351
|
+
// decoded from the run_url (group/subgroup/project) can therefore never
|
|
352
|
+
// equal a schema-valid source.repo and fails closed right here.
|
|
317
353
|
if (source.repo && projectPath !== source.repo) return false;
|
|
318
354
|
|
|
319
355
|
const projectId = encodeURIComponent(projectPath);
|
|
@@ -343,10 +379,21 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
343
379
|
});
|
|
344
380
|
} catch (err) {
|
|
345
381
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
382
|
+
// F-8e72d0de: retry timeouts within the shared budget — peer
|
|
383
|
+
// discipline with githubProvenance (see that adapter's note).
|
|
384
|
+
if (attempt < retries) {
|
|
385
|
+
await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
346
388
|
throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
|
|
347
389
|
}
|
|
348
|
-
//
|
|
349
|
-
return false
|
|
390
|
+
// F-dac7e08c: transport reject = operational, retried then thrown —
|
|
391
|
+
// mirrors githubProvenance exactly. `return false` is 404-only.
|
|
392
|
+
if (attempt < retries) {
|
|
393
|
+
await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`provenance: network error: ${err.message}`);
|
|
350
397
|
} finally {
|
|
351
398
|
clearTimeout(timer);
|
|
352
399
|
}
|
|
@@ -50,16 +50,24 @@ export const RUN_URL_PARSERS = {
|
|
|
50
50
|
// PIPELINE: https://<host>/<namespace>/<project>/-/pipelines/<id>
|
|
51
51
|
// The project path is everything between the host and the `/-/` run segment.
|
|
52
52
|
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// the
|
|
62
|
-
//
|
|
53
|
+
// TWO-SEGMENT CONTRACT (F-54e5fde7, load-bearing decision): nested GitLab
|
|
54
|
+
// subgroups (`group/subgroup/project`) are UNSUPPORTED end-to-end. The
|
|
55
|
+
// submission schema's `repo` pattern (^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$)
|
|
56
|
+
// forbids a second slash, so a nested project path can never be a valid
|
|
57
|
+
// submission.repo, and the policy loaders (ingest load-context.js, verify
|
|
58
|
+
// cli.js) fail closed on 3+-segment slugs. A GitLab consumer with nested
|
|
59
|
+
// subgroups must submit from a top-level group/project (or mirror the repo)
|
|
60
|
+
// until the contract is widened deliberately — schema pattern, both policy
|
|
61
|
+
// loaders, and the records/ path layout together, never piecemeal.
|
|
62
|
+
//
|
|
63
|
+
// The parser still maps owner = full namespace (slashes preserved) and
|
|
64
|
+
// repo = last segment. That is DELIBERATE fail-closed behavior, not nested
|
|
65
|
+
// support: for a nested run_url the reconstructed `${owner}/${repo}` carries
|
|
66
|
+
// 2+ slashes and can never equal a schema-valid submission.repo, so the
|
|
67
|
+
// binding guard rejects with repo:mismatch instead of silently skipping the
|
|
68
|
+
// anti-forgery check (returning null here would fail OPEN). For a flat
|
|
69
|
+
// `group/project` this degenerates to owner='group', repo='project' — the
|
|
70
|
+
// same shape as GitHub, and the only shape the contract supports.
|
|
63
71
|
//
|
|
64
72
|
// A single-segment path (no namespace + project, just `<project>/-/jobs/<id>`)
|
|
65
73
|
// returns null — it cannot be split into owner + repo and is not a valid
|
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
|
+
});
|