@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.
- package/README.md +21 -17
- package/cli-lint.js +81 -36
- package/cli.js +99 -15
- package/index.js +205 -61
- package/package.json +1 -1
- package/parse-rejection.js +125 -27
- package/validators/f-2a5ddafa-provenance-retry-warn.test.js +226 -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/f-f50e779b-retry-warn-fallback-guard.test.js +153 -0
- package/validators/lint-scenario.js +141 -0
- package/validators/policy.js +31 -8
- package/validators/provenance-gitlab.test.js +10 -3
- package/validators/provenance-registry.test.js +35 -0
- package/validators/provenance.js +183 -17
- package/validators/repo-binding.js +35 -12
- package/validators/repo-binding.test.js +40 -0
- package/validators/steps.js +116 -9
- package/validators/verdict.js +9 -1
- 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',
|
|
@@ -113,6 +118,15 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
|
|
|
113
118
|
* `configErrors` (VERIFY-F1) are eval-time repo custom-rule predicate faults; the
|
|
114
119
|
* caller emits them with a `policy-config:` prefix (submission-bad). A non-empty
|
|
115
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.
|
|
116
130
|
*/
|
|
117
131
|
export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
118
132
|
const errors = [];
|
|
@@ -155,7 +169,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
155
169
|
// dropping operator-authored rules on the floor: a declared rule that the
|
|
156
170
|
// build does nothing with is invisible to the operator who wrote it.
|
|
157
171
|
if (rule.severity === 'warn') {
|
|
158
|
-
|
|
172
|
+
// F-57a0c0ad: bracketed `[id]` form — the same shape buildReason gives
|
|
173
|
+
// declarative rules — so one grep pattern finds every rule-attributed
|
|
174
|
+
// message regardless of how the rule is enforced.
|
|
175
|
+
warnings.push(`[${rule.id}] ${rule.description || 'policy warning'}`);
|
|
159
176
|
continue;
|
|
160
177
|
}
|
|
161
178
|
if (rule.severity === 'info') {
|
|
@@ -188,15 +205,21 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
188
205
|
|
|
189
206
|
// schema-valid, provenance-confirmed, step-results-present, step-verdict-consistent,
|
|
190
207
|
// no-verdict-upgrade are enforced by other validators or the main verify() function.
|
|
191
|
-
// PROACT-VERIFY-002: a reject rule the build neither handles here NOR
|
|
192
|
-
// elsewhere would otherwise pass SILENTLY — the operator's new gate never
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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).
|
|
195
220
|
default:
|
|
196
221
|
if (!KNOWN_REJECT_RULE_IDS.has(rule.id)) {
|
|
197
|
-
|
|
198
|
-
// error (mirrors the `[rule.id]` / `surface[...]` messages above).
|
|
199
|
-
errors.push(
|
|
222
|
+
throw new Error(
|
|
200
223
|
`rule "${rule.id}" is declared severity:reject but has no enforcement in this build — ` +
|
|
201
224
|
`add an enforcement arm in validators/policy.js or register it in KNOWN_REJECT_RULE_IDS`
|
|
202
225
|
);
|
|
@@ -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
|
|
|
@@ -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
|
});
|
package/validators/provenance.js
CHANGED
|
@@ -14,6 +14,24 @@
|
|
|
14
14
|
* `source.provider`, so the provider enum, the run_url parsers
|
|
15
15
|
* (validators/repo-binding.js), and the adapters here stay in lockstep —
|
|
16
16
|
* coverage tests fail CI if any of the three drifts.
|
|
17
|
+
*
|
|
18
|
+
* F-936c83f4 (wave 39, feature-pass integration finding): this module's
|
|
19
|
+
* `--provenance=stub|github` (the flag both packages/verify/cli.js and
|
|
20
|
+
* packages/ingest/run.js parse, both delegating to the adapters here) answers
|
|
21
|
+
* ONE of three structurally-similar-but-distinct 'how do we know this claim is
|
|
22
|
+
* true' questions living in this repo: (1) THIS module — does a submitted CI
|
|
23
|
+
* run really exist and match its claims; (2) dogfood-finding.schema.json's
|
|
24
|
+
* `evidence[].evidence_kind` enum — what evidence backs a distilled cross-repo
|
|
25
|
+
* lesson (see that property's own cross-reference note); (3) the swarm
|
|
26
|
+
* control-plane's incoming `verified_how`
|
|
27
|
+
* (independent|self_attested|operator_evidence,
|
|
28
|
+
* docs/trajectory-and-closure.dispatch.md's C2 `swarm close` verb) — how a
|
|
29
|
+
* finding CLOSURE was verified. This is NOT a request to unify the three
|
|
30
|
+
* enums — CI-run provenance, cross-repo-lesson evidence, and
|
|
31
|
+
* closure-verification-method are genuinely different axes, and forcing one
|
|
32
|
+
* shared enum would misrepresent that. The pointer exists only so the next
|
|
33
|
+
* reader of any of the three doesn't rediscover the pattern from scratch a
|
|
34
|
+
* fourth time.
|
|
17
35
|
*/
|
|
18
36
|
|
|
19
37
|
/**
|
|
@@ -60,13 +78,27 @@ function isRetryableStatus(status) {
|
|
|
60
78
|
return status === 429 || (status >= 500 && status <= 599);
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
/**
|
|
82
|
+
* F-5fd3f832: ceiling on a provider-sent Retry-After wait. The per-request
|
|
83
|
+
* AbortController timeout bounds each REQUEST but not the inter-attempt sleep
|
|
84
|
+
* — without this clamp, one hostile or misconfigured `Retry-After: 86400`
|
|
85
|
+
* (plausible via gitlabProvenance's self-hosted `apiBase` override) would
|
|
86
|
+
* stall the concurrency-serialized ingest.yml queue for a day. 30s matches
|
|
87
|
+
* the per-request timeout: an honest throttle rarely asks for more, and a
|
|
88
|
+
* provider that does is better surfaced as an exhausted-retries operational
|
|
89
|
+
* throw than silently obeyed.
|
|
90
|
+
*/
|
|
91
|
+
export const MAX_RETRY_AFTER_MS = 30_000;
|
|
92
|
+
|
|
63
93
|
/**
|
|
64
94
|
* 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
|
-
*
|
|
95
|
+
* seconds, or an HTTP-date) is honored when present and parseable — clamped
|
|
96
|
+
* to {@link MAX_RETRY_AFTER_MS} in both branches (F-5fd3f832); otherwise
|
|
97
|
+
* fall back to exponential backoff (already bounded by the retry budget).
|
|
98
|
+
* `attempt` is 1-based (1 = wait before the 2nd request).
|
|
68
99
|
*
|
|
69
|
-
* @param {Response} resp - The non-ok response carrying a possible
|
|
100
|
+
* @param {Response|null} resp - The non-ok response carrying a possible
|
|
101
|
+
* Retry-After, or null for a transport reject (no response → exponential).
|
|
70
102
|
* @param {number} attempt - 1-based retry index.
|
|
71
103
|
* @param {number} backoffMs - Base backoff.
|
|
72
104
|
* @returns {number} Milliseconds to wait (never negative).
|
|
@@ -76,11 +108,11 @@ function nextBackoffMs(resp, attempt, backoffMs) {
|
|
|
76
108
|
if (header != null && header !== '') {
|
|
77
109
|
const asSeconds = Number(header);
|
|
78
110
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
|
79
|
-
return Math.round(asSeconds * 1000);
|
|
111
|
+
return Math.min(Math.round(asSeconds * 1000), MAX_RETRY_AFTER_MS);
|
|
80
112
|
}
|
|
81
113
|
const asDate = Date.parse(header);
|
|
82
114
|
if (!Number.isNaN(asDate)) {
|
|
83
|
-
return Math.max(0, asDate - Date.now());
|
|
115
|
+
return Math.min(Math.max(0, asDate - Date.now()), MAX_RETRY_AFTER_MS);
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
return backoffMs * 2 ** (attempt - 1);
|
|
@@ -91,6 +123,67 @@ function defaultSleep(ms) {
|
|
|
91
123
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
92
124
|
}
|
|
93
125
|
|
|
126
|
+
/**
|
|
127
|
+
* F-2a5ddafa: emit ONE structured NDJSON warn line to stderr each time a
|
|
128
|
+
* retry is about to happen, so an operator tailing CI logs sees a degrading
|
|
129
|
+
* provider BEFORE the retry budget exhausts and throws. Pre-fix, every
|
|
130
|
+
* `await sleep(...); continue;` branch in both adapters below was completely
|
|
131
|
+
* silent — a submission that succeeded only after 1-2 retries against a
|
|
132
|
+
* degrading GitHub/GitLab API was byte-for-byte indistinguishable in the log
|
|
133
|
+
* from one that succeeded on the first try. The eventual exhausted-retry
|
|
134
|
+
* failure is already loud (an operational throw); this closes the gap on
|
|
135
|
+
* the way there, which is exactly the window an operator could act in
|
|
136
|
+
* (e.g. correlate a slow ingest with a known provider incident).
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT named `logStage` and NOT importing
|
|
139
|
+
* `@dogfood-lab/dogfood-swarm/lib/log-stage.js`: packages/verify depends on
|
|
140
|
+
* nothing but `@dogfood-lab/schemas` + `js-yaml` today (see package.json),
|
|
141
|
+
* and the accepted workspace cycle (root CLAUDE.md, "Workspace dependency
|
|
142
|
+
* graph") is `findings -> ingest -> dogfood-swarm -> findings`. `ingest`
|
|
143
|
+
* ALSO depends on `verify`, so a NEW `verify -> dogfood-swarm` edge would
|
|
144
|
+
* not reuse that cycle — it would close a SECOND, larger one:
|
|
145
|
+
* `verify -> dogfood-swarm -> findings -> ingest -> verify`. This mirrors
|
|
146
|
+
* the repo's existing precedent for exactly this constraint —
|
|
147
|
+
* `packages/ingest/lib/sleep-sync.js` duplicates
|
|
148
|
+
* `packages/findings/lib/file-lock.js`'s private `sleepSync` verbatim,
|
|
149
|
+
* per that file's own header, rather than take a disallowed cross-edge —
|
|
150
|
+
* so this emits the same NDJSON stage-line SHAPE independently instead of
|
|
151
|
+
* importing the shared helper.
|
|
152
|
+
* `packages/ingest/wave22-log-stage-discipline.test.js` (Class #9 sweep)
|
|
153
|
+
* enforces that no file under `packages/**` defines its OWN `logStage`
|
|
154
|
+
* without delegating to the shared helper; this helper uses a distinct
|
|
155
|
+
* name for exactly that reason and is not a competing definition of that
|
|
156
|
+
* convention.
|
|
157
|
+
*
|
|
158
|
+
* Injectable via `opts.onRetryWarn` (mirrors the already-injectable
|
|
159
|
+
* `opts.fetchImpl` / `opts.sleepImpl` on this file) so tests assert the
|
|
160
|
+
* exact fields without scraping stderr.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ kind: string, provider: string, attempt: number, status_or_reason: string|number, next_backoff_ms: number }} fields
|
|
163
|
+
*/
|
|
164
|
+
function defaultOnRetryWarn(fields) {
|
|
165
|
+
const line = { ts: new Date().toISOString(), component: 'verify', stage: 'warn', ...fields };
|
|
166
|
+
try {
|
|
167
|
+
console.error(JSON.stringify(line));
|
|
168
|
+
} catch {
|
|
169
|
+
// The logger must never throw and mask a real retry — same last-resort
|
|
170
|
+
// discipline as the shared dogfood-swarm helper (log-stage.js). F-f50e779b:
|
|
171
|
+
// this fallback itself must not throw either — a broken stderr fd
|
|
172
|
+
// (EPIPE/ENOSPC) fails BOTH console.error calls, and pre-fix this second
|
|
173
|
+
// call was unguarded, so its throw escaped defaultOnRetryWarn uncaught and
|
|
174
|
+
// turned a retry that was about to SUCCEED into a rejected confirm() (the
|
|
175
|
+
// observability feature converting a good outcome into a discarded
|
|
176
|
+
// submission — see log-stage.js's own two-level guard for the same shape,
|
|
177
|
+
// which this now mirrors exactly).
|
|
178
|
+
try {
|
|
179
|
+
console.error('{"stage":"warn","kind":"log_serialization_failed"}');
|
|
180
|
+
} catch {
|
|
181
|
+
// stderr itself is broken; nothing further to do, but must not crash
|
|
182
|
+
// the caller — there is no third fallback to hand the failure to.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
94
187
|
/**
|
|
95
188
|
* Stub provenance adapter. Always confirms.
|
|
96
189
|
* Use in tests and local development.
|
|
@@ -125,6 +218,7 @@ export function githubProvenance(token, opts = {}) {
|
|
|
125
218
|
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
126
219
|
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
127
220
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
221
|
+
const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
|
|
128
222
|
return {
|
|
129
223
|
/**
|
|
130
224
|
* @param {object} source - submission.source (provider-scoped run claim)
|
|
@@ -185,11 +279,35 @@ export function githubProvenance(token, opts = {}) {
|
|
|
185
279
|
});
|
|
186
280
|
} catch (err) {
|
|
187
281
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
282
|
+
// F-8e72d0de: a per-request timeout is at least as transient as a
|
|
283
|
+
// connection refusal — retry within the same budget as the
|
|
284
|
+
// transport-reject branch below (mirrors the scenario fetcher's
|
|
285
|
+
// retryable-timeout discipline), then throw on exhaustion.
|
|
286
|
+
if (attempt < retries) {
|
|
287
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
288
|
+
// F-2a5ddafa: visibility BEFORE the budget exhausts (see
|
|
289
|
+
// defaultOnRetryWarn's doc above for why this is 'warn').
|
|
290
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
|
|
291
|
+
await sleep(waitMs);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
188
294
|
throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
|
|
189
295
|
}
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
296
|
+
// F-dac7e08c: a transport reject (DNS failure, ECONNREFUSED) means the
|
|
297
|
+
// provider or the runner's NETWORK is down — an operational incident,
|
|
298
|
+
// not evidence the run is absent. Retry within the same budget as a
|
|
299
|
+
// 5xx (at least as transient), then THROW so the reason lands under
|
|
300
|
+
// the operational `provenance-fault:` prefix. The old `return false`
|
|
301
|
+
// persisted a REJECTED submission-bad record during a network blip,
|
|
302
|
+
// and the duplicate guard then blocked a clean resubmission under the
|
|
303
|
+
// same run_id. `return false` is reserved for HTTP 404 (mirrors GitLab).
|
|
304
|
+
if (attempt < retries) {
|
|
305
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
306
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
|
|
307
|
+
await sleep(waitMs);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`provenance: network error: ${err.message}`);
|
|
193
311
|
} finally {
|
|
194
312
|
clearTimeout(timer);
|
|
195
313
|
}
|
|
@@ -206,7 +324,9 @@ export function githubProvenance(token, opts = {}) {
|
|
|
206
324
|
// last 429/5xx throws so a real outage still surfaces (operational).
|
|
207
325
|
if (resp.status === 404) return false;
|
|
208
326
|
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
209
|
-
|
|
327
|
+
const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
|
|
328
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
|
|
329
|
+
await sleep(waitMs);
|
|
210
330
|
continue;
|
|
211
331
|
}
|
|
212
332
|
throw new Error(`provenance: GitHub API returned ${resp.status}`);
|
|
@@ -280,6 +400,7 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
280
400
|
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
281
401
|
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
282
402
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
403
|
+
const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
|
|
283
404
|
return {
|
|
284
405
|
/**
|
|
285
406
|
* @param {object} source - submission.source (provider-scoped run claim)
|
|
@@ -312,8 +433,12 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
312
433
|
if (urlRunId !== String(provider_run_id)) return false;
|
|
313
434
|
|
|
314
435
|
// Bind the project path to source.repo BEFORE the network call — a forged
|
|
315
|
-
// project claim is a cheap, offline rejection.
|
|
316
|
-
//
|
|
436
|
+
// project claim is a cheap, offline rejection. V2-CROSS-BO-005
|
|
437
|
+
// (F-54e5fde7 contract): submission.repo is strictly two-segment — the
|
|
438
|
+
// submission schema's `repo` pattern forbids a second slash, so nested
|
|
439
|
+
// GitLab subgroups are UNSUPPORTED end-to-end. A nested project path
|
|
440
|
+
// decoded from the run_url (group/subgroup/project) can therefore never
|
|
441
|
+
// equal a schema-valid source.repo and fails closed right here.
|
|
317
442
|
if (source.repo && projectPath !== source.repo) return false;
|
|
318
443
|
|
|
319
444
|
const projectId = encodeURIComponent(projectPath);
|
|
@@ -343,10 +468,25 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
343
468
|
});
|
|
344
469
|
} catch (err) {
|
|
345
470
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
471
|
+
// F-8e72d0de: retry timeouts within the shared budget — peer
|
|
472
|
+
// discipline with githubProvenance (see that adapter's note).
|
|
473
|
+
if (attempt < retries) {
|
|
474
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
475
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
|
|
476
|
+
await sleep(waitMs);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
346
479
|
throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
|
|
347
480
|
}
|
|
348
|
-
//
|
|
349
|
-
return false
|
|
481
|
+
// F-dac7e08c: transport reject = operational, retried then thrown —
|
|
482
|
+
// mirrors githubProvenance exactly. `return false` is 404-only.
|
|
483
|
+
if (attempt < retries) {
|
|
484
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
485
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
|
|
486
|
+
await sleep(waitMs);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
throw new Error(`provenance: network error: ${err.message}`);
|
|
350
490
|
} finally {
|
|
351
491
|
clearTimeout(timer);
|
|
352
492
|
}
|
|
@@ -361,7 +501,9 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
361
501
|
// budget. 401/403 = non-transient operational, throw immediately.
|
|
362
502
|
if (resp.status === 404) return false;
|
|
363
503
|
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
364
|
-
|
|
504
|
+
const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
|
|
505
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
|
|
506
|
+
await sleep(waitMs);
|
|
365
507
|
continue;
|
|
366
508
|
}
|
|
367
509
|
throw new Error(`provenance: GitLab API returned ${resp.status}`);
|
|
@@ -412,10 +554,34 @@ export const PROVENANCE_ADAPTERS = {
|
|
|
412
554
|
/**
|
|
413
555
|
* Resolve the provenance-adapter factory for a `source.provider`.
|
|
414
556
|
*
|
|
557
|
+
* F-2965699b: `PROVENANCE_ADAPTERS[provider]` alone resolves inherited
|
|
558
|
+
* `Object.prototype` keys (`provider: 'valueOf'` returns
|
|
559
|
+
* `Object.prototype.valueOf`, a truthy function) — the `??` operator only
|
|
560
|
+
* catches `null`/`undefined`, so the `if (!factory)` unknown-provider gate at
|
|
561
|
+
* this function's ONE caller (`resolveProviderProvenance`, packages/ingest/
|
|
562
|
+
* run.js) never fires for the whole Object.prototype key space, and
|
|
563
|
+
* `factory(token)` goes on to call an unrelated Object.prototype method with
|
|
564
|
+
* an unbound `this`, producing an uncaught TypeError (`valueOf`/`__proto__`)
|
|
565
|
+
* or a misleading downstream error (`constructor`/`toString`/
|
|
566
|
+
* `isPrototypeOf`) instead of the correct "unknown provenance provider"
|
|
567
|
+
* message. This site is reached on FULLY UNTRUSTED input — `submission.
|
|
568
|
+
* source.provider` — BEFORE verify()'s schema gate constrains it to the
|
|
569
|
+
* [github, gitlab] enum.
|
|
570
|
+
*
|
|
571
|
+
* `Object.hasOwn` restricts the lookup to the registry's own properties,
|
|
572
|
+
* matching the idiom `safeGet()` already uses in validators/predicate.js —
|
|
573
|
+
* the two prototype-safety sites now read the same.
|
|
574
|
+
*
|
|
415
575
|
* @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
|
|
416
576
|
* @returns {((token: string, opts?: object) => { confirm: Function }) | null}
|
|
417
|
-
* The adapter factory, or `null` when the provider has no registered adapter
|
|
577
|
+
* The adapter factory, or `null` when the provider has no registered adapter
|
|
578
|
+
* (including every Object.prototype key — `constructor`, `toString`,
|
|
579
|
+
* `valueOf`, `__proto__`, `hasOwnProperty`, `isPrototypeOf`,
|
|
580
|
+
* `propertyIsEnumerable`, `toLocaleString` — none of which are OWN
|
|
581
|
+
* properties of PROVENANCE_ADAPTERS).
|
|
418
582
|
*/
|
|
419
583
|
export function provenanceForProvider(provider) {
|
|
420
|
-
return
|
|
584
|
+
return typeof provider === 'string' && Object.hasOwn(PROVENANCE_ADAPTERS, provider)
|
|
585
|
+
? PROVENANCE_ADAPTERS[provider]
|
|
586
|
+
: null;
|
|
421
587
|
}
|
|
@@ -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
|
|
@@ -80,13 +88,28 @@ export const RUN_URL_PARSERS = {
|
|
|
80
88
|
/**
|
|
81
89
|
* Decode the owner/repo a run_url attests to, for the given provider.
|
|
82
90
|
*
|
|
91
|
+
* F-7ce07baa: family sibling of F-2965699b (validators/provenance.js). Plain
|
|
92
|
+
* bracket access resolves inherited Object.prototype keys, so
|
|
93
|
+
* `RUN_URL_PARSERS[provider]` for `provider: '__proto__'` (etc.) returns a
|
|
94
|
+
* truthy Object.prototype method instead of `undefined` — the `if (!parser)`
|
|
95
|
+
* unknown-provider gate cannot fire for that key space, and `parser(runUrl)`
|
|
96
|
+
* goes on to call an unrelated Object.prototype method. This site is reached
|
|
97
|
+
* even EARLIER in the trust chain than provenanceForProvider: verify/
|
|
98
|
+
* index.js's repo-binding guard runs at step 0, before the schema gate, so
|
|
99
|
+
* `submission.source.provider` is raw untrusted input here too.
|
|
100
|
+
* `Object.hasOwn` matches the safeGet()/provenanceForProvider idiom so every
|
|
101
|
+
* prototype-safety site in this codebase reads the same.
|
|
102
|
+
*
|
|
83
103
|
* @param {string} provider The `source.provider` token (e.g. 'github').
|
|
84
104
|
* @param {string} runUrl The `source.run_url`.
|
|
85
105
|
* @returns {RunUrlRepo | null} `{ owner, repo }`, or `null` when the provider
|
|
86
|
-
* has no parser or the URL does not
|
|
106
|
+
* has no parser (including every Object.prototype key) or the URL does not
|
|
107
|
+
* match the provider's shape.
|
|
87
108
|
*/
|
|
88
109
|
export function parseRunUrlRepo(provider, runUrl) {
|
|
89
|
-
const parser = RUN_URL_PARSERS
|
|
110
|
+
const parser = typeof provider === 'string' && Object.hasOwn(RUN_URL_PARSERS, provider)
|
|
111
|
+
? RUN_URL_PARSERS[provider]
|
|
112
|
+
: null;
|
|
90
113
|
if (!parser) return null;
|
|
91
114
|
return parser(runUrl);
|
|
92
115
|
}
|