@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
package/README.md
CHANGED
|
@@ -133,6 +133,7 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
|
|
|
133
133
|
| `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION:` | `runValidator('contract_schema_version', …)` catch | The version gate was called with an unknown contract key (a programmer error at the call site, not a submission fault). |
|
|
134
134
|
| `submission-malformed:` | `index.js` null/non-object early-return | The submission itself was `null` or not an object — a malfunctioning **dispatcher** sent garbage, not a submitter who authored a bad-but-shaped payload. Page ops / inspect the dispatch pipeline; do NOT bounce it to a submitter. |
|
|
135
135
|
| `provenance-fault:` | `index.js` provenance catch | The provenance adapter THREW an operational error confirming the run — a provider **429 rate-limit, 5xx outage, or 401/403 token** fault (`validators/provenance.js` throws these on purpose for non-404 responses). The submitter's payload is fine; the verifier could not reach a verdict. Page ops / retry; do NOT bounce it to a submitter. Distinct from the submission-bad `provenance:` (genuine absence/404). |
|
|
136
|
+
| `scenario-fetch-fault:` | `packages/ingest/load-context.js` | The scenario fetcher THREW after exhausting its retry budget (**5xx/429 outage, transport reject**) or hit a **401/403 credential** fault loading a scenario definition. The submission may be perfectly good — the fetch infrastructure faulted. The ingest CLI lets this propagate (exit 2, nothing persisted); a true missing file is the ingest-class `scenario-load: … (reason: not_found)` instead. |
|
|
136
137
|
|
|
137
138
|
Any future `VALIDATOR_FAULT_<NEW>:` prefix is classified `operational` by family — `parseRejectionReason` matches the `VALIDATOR_FAULT_` head, so a new validator class needs no parser edit. The `submission-malformed:` prefix is matched literally (it is not part of the `VALIDATOR_FAULT_` family).
|
|
138
139
|
|
|
@@ -140,7 +141,7 @@ Any future `VALIDATOR_FAULT_<NEW>:` prefix is classified `operational` by family
|
|
|
140
141
|
|
|
141
142
|
| Prefix | Source | Meaning |
|
|
142
143
|
|---|---|---|
|
|
143
|
-
| `scenario-load:` | `packages/ingest/run.js` | A scenario referenced by `scenario_results` could not be loaded from the source repo (typed-reason: `timeout` / `not_found` / `parse_error` / `invalid_id`). |
|
|
144
|
+
| `scenario-load:` | `packages/ingest/run.js` | A scenario referenced by `scenario_results` could not be loaded from the source repo (typed-reason: `timeout` / `not_found` / `parse_error` / `invalid_id` / `too_large` / `schema_invalid`). Outages and credential faults are NOT this class — they throw `scenario-fetch-fault:` (operational, above) instead of rejecting the submission. |
|
|
144
145
|
|
|
145
146
|
### Operator hygiene
|
|
146
147
|
|
package/cli-lint.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cli-lint.js (VERIFY-F3) — the `dogfood-verify lint <
|
|
2
|
+
* cli-lint.js (VERIFY-F3) — the `dogfood-verify lint <file>` subcommand.
|
|
3
3
|
*
|
|
4
|
-
* A SEPARATE parse/render path from the verify CLI (cli.js): it takes a policy YAML
|
|
5
|
-
* (not a submission JSON
|
|
6
|
-
* verify arg parser or output. cli.js's `main`
|
|
7
|
-
* and leaves the verify `run` path untouched. The
|
|
4
|
+
* A SEPARATE parse/render path from the verify CLI (cli.js): it takes a policy YAML or
|
|
5
|
+
* (with --scenario) a scenario YAML — not a submission JSON — and reports static lint
|
|
6
|
+
* findings, so it does not share the verify arg parser or output. cli.js's `main`
|
|
7
|
+
* dispatcher routes the `lint` verb here and leaves the verify `run` path untouched. The
|
|
8
|
+
* exit contract mirrors that path, identically for both modes:
|
|
8
9
|
*
|
|
9
10
|
* 0 — clean, or warnings-only (footgun advisories never block).
|
|
10
|
-
* 1 — one or more errors (schema-invalid, a static
|
|
11
|
+
* 1 — one or more errors (schema-invalid, a static fault, or unparseable YAML).
|
|
11
12
|
* 2 — operator error (file missing/unreadable, or a malformed invocation).
|
|
12
13
|
*
|
|
13
|
-
* YAML that fails to parse is exit 1 (a lint FINDING about the
|
|
14
|
+
* YAML that fails to parse is exit 1 (a lint FINDING about the file the author must fix —
|
|
14
15
|
* surfacing "line 4: bad indentation" is the lint's job), not exit 2. A file that does not
|
|
15
16
|
* exist is exit 2 (the author pointed at the wrong path). See docs/policy-lint.md.
|
|
17
|
+
*
|
|
18
|
+
* Two modes share the whole render/exit machinery (F-BACKEND-003):
|
|
19
|
+
* - default (policy): lintPolicy(doc, { origin }) → origin global|repo|unknown
|
|
20
|
+
* - --scenario: lintScenario(doc, { file }) → origin 'scenario'
|
|
21
|
+
* Both return the same { ok, origin, errors, warnings, coverageNote } shape, so
|
|
22
|
+
* renderLintText / buildLintJson are reused unchanged.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { readFileSync } from 'node:fs';
|
|
@@ -20,6 +27,7 @@ import { resolve } from 'node:path';
|
|
|
20
27
|
import yaml from 'js-yaml';
|
|
21
28
|
|
|
22
29
|
import { lintPolicy, COVERAGE_NOTE } from './validators/lint-policy.js';
|
|
30
|
+
import { lintScenario, SCENARIO_COVERAGE_NOTE } from './validators/lint-scenario.js';
|
|
23
31
|
|
|
24
32
|
/** Operator-error sentinel → exit 2 (distinct from a lint finding, which is exit 1). */
|
|
25
33
|
class LintOperatorError extends Error {
|
|
@@ -30,20 +38,33 @@ class LintOperatorError extends Error {
|
|
|
30
38
|
}
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
const LINT_USAGE = `dogfood-verify lint — author-time static check for a policy file
|
|
41
|
+
const LINT_USAGE = `dogfood-verify lint — author-time static check for a policy or scenario file
|
|
34
42
|
|
|
35
43
|
USAGE:
|
|
36
44
|
dogfood-verify lint <policy-file> [--json]
|
|
45
|
+
dogfood-verify lint --scenario <scenario-file> [--json]
|
|
37
46
|
|
|
38
|
-
WHAT IT CHECKS (no submission needed):
|
|
47
|
+
WHAT IT CHECKS — policy mode (default, no submission needed):
|
|
39
48
|
- structural validity against policy.schema.json
|
|
40
49
|
- every predicate's known leading field, combinator depth, and node budget
|
|
41
50
|
- an ADVISORY warning on the [] footgun (a negative op over a [] path fails open)
|
|
42
51
|
|
|
43
|
-
It CANNOT statically catch a type_mismatch or a fanout_budget overrun — those depend
|
|
44
|
-
on submission data. Run \`dogfood-verify --file <submission> --explain\` for that.
|
|
52
|
+
It CANNOT statically catch a type_mismatch or a fanout_budget overrun — those depend
|
|
53
|
+
on submission data. Run \`dogfood-verify --file <submission> --explain\` for that.
|
|
54
|
+
|
|
55
|
+
WHAT IT CHECKS — --scenario mode (no submission needed):
|
|
56
|
+
- structural validity against scenario.schema.json
|
|
57
|
+
- every success_criteria.required_steps entry references a declared steps[].id
|
|
58
|
+
- step ids are unique
|
|
59
|
+
- an ADVISORY warning when the file basename does not match scenario_id (the
|
|
60
|
+
receiver fetches dogfood/scenarios/<scenario_id>.yaml, so a mismatch makes the
|
|
61
|
+
committed definition unreachable and required-steps enforcement fails open)
|
|
62
|
+
|
|
63
|
+
It CANNOT verify that a real submission's step_results satisfy required_steps, nor
|
|
64
|
+
that the receiver can fetch the file at the attested commit — run a real ingest.
|
|
45
65
|
|
|
46
66
|
OPTIONS:
|
|
67
|
+
--scenario Lint the file as a scenario definition (default: policy).
|
|
47
68
|
--json Machine-readable result for CI.
|
|
48
69
|
-h, --help Show this help.
|
|
49
70
|
|
|
@@ -52,34 +73,40 @@ EXIT CODES:
|
|
|
52
73
|
|
|
53
74
|
/**
|
|
54
75
|
* Parse the lint argv (everything AFTER the `lint` verb). Accepts exactly one positional
|
|
55
|
-
*
|
|
56
|
-
* on any malformed invocation.
|
|
76
|
+
* file path plus optional `--scenario` / `--json` / `--help`. Throws LintOperatorError
|
|
77
|
+
* (→ exit 2) on any malformed invocation.
|
|
78
|
+
*
|
|
79
|
+
* `--scenario` is a boolean MODE flag (default: policy mode). It selects which linter runs
|
|
80
|
+
* over the one positional file; it never consumes the path itself.
|
|
57
81
|
*
|
|
58
82
|
* @param {string[]} argv
|
|
59
|
-
* @returns {{ help: boolean, file: string|null, json: boolean }}
|
|
83
|
+
* @returns {{ help: boolean, file: string|null, json: boolean, scenario: boolean }}
|
|
60
84
|
*/
|
|
61
85
|
export function parseLintArgs(argv) {
|
|
62
86
|
let file = null;
|
|
63
87
|
let json = false;
|
|
64
88
|
let help = false;
|
|
89
|
+
let scenario = false;
|
|
65
90
|
|
|
66
91
|
for (const arg of argv) {
|
|
67
92
|
if (arg === '-h' || arg === '--help') { help = true; continue; }
|
|
68
93
|
if (arg === '--json') { json = true; continue; }
|
|
94
|
+
if (arg === '--scenario') { scenario = true; continue; }
|
|
69
95
|
if (arg.startsWith('-')) {
|
|
70
96
|
throw new LintOperatorError(`unknown argument: ${arg}`, 'run `dogfood-verify lint --help` for usage');
|
|
71
97
|
}
|
|
72
98
|
if (file !== null) {
|
|
73
|
-
throw new LintOperatorError('more than one
|
|
99
|
+
throw new LintOperatorError('more than one file given', 'lint one file at a time');
|
|
74
100
|
}
|
|
75
101
|
file = arg;
|
|
76
102
|
}
|
|
77
103
|
|
|
78
|
-
if (help) return { help: true, file: null, json: false };
|
|
104
|
+
if (help) return { help: true, file: null, json: false, scenario: false };
|
|
79
105
|
if (file === null) {
|
|
80
|
-
|
|
106
|
+
const usage = scenario ? 'dogfood-verify lint --scenario <scenario-file>' : 'dogfood-verify lint <policy-file>';
|
|
107
|
+
throw new LintOperatorError(`no ${scenario ? 'scenario' : 'policy'} file provided`, usage);
|
|
81
108
|
}
|
|
82
|
-
return { help: false, file, json };
|
|
109
|
+
return { help: false, file, json, scenario };
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
/**
|
|
@@ -172,11 +199,12 @@ export async function runLint(argv, io = {}) {
|
|
|
172
199
|
}
|
|
173
200
|
|
|
174
201
|
const path = resolve(opts.file);
|
|
202
|
+
const kind = opts.scenario ? 'scenario' : 'policy';
|
|
175
203
|
let raw;
|
|
176
204
|
try {
|
|
177
205
|
raw = readFileSync(path, 'utf-8');
|
|
178
206
|
} catch (e) {
|
|
179
|
-
err(`ERROR: could not read
|
|
207
|
+
err(`ERROR: could not read ${kind} file: ${path} — ${e.message}`);
|
|
180
208
|
err(' hint: check the path exists and is readable');
|
|
181
209
|
return 2;
|
|
182
210
|
}
|
|
@@ -185,25 +213,42 @@ export async function runLint(argv, io = {}) {
|
|
|
185
213
|
try {
|
|
186
214
|
doc = yaml.load(raw);
|
|
187
215
|
} catch (e) {
|
|
188
|
-
// A YAML parse failure is a lint finding about the
|
|
216
|
+
// A YAML parse failure is a lint finding about the file (exit 1), not an operator error.
|
|
217
|
+
// Mirrors the policy path exactly, only differing in the origin/label/coverageNote so the
|
|
218
|
+
// scenario report reads as a scenario report.
|
|
189
219
|
const where = e && e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
|
|
190
|
-
const result =
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
const result = opts.scenario
|
|
221
|
+
? {
|
|
222
|
+
ok: false,
|
|
223
|
+
origin: 'scenario',
|
|
224
|
+
errors: [{
|
|
225
|
+
label: 'scenario-schema:',
|
|
226
|
+
code: 'yaml_parse',
|
|
227
|
+
location: '/',
|
|
228
|
+
message: `scenario YAML failed to parse${where} — ${e.message}`,
|
|
229
|
+
}],
|
|
230
|
+
warnings: [],
|
|
231
|
+
coverageNote: SCENARIO_COVERAGE_NOTE,
|
|
232
|
+
}
|
|
233
|
+
: {
|
|
234
|
+
ok: false,
|
|
235
|
+
origin: originForPath(path),
|
|
236
|
+
errors: [{
|
|
237
|
+
label: 'policy-schema:',
|
|
238
|
+
code: 'yaml_parse',
|
|
239
|
+
location: '/',
|
|
240
|
+
message: `policy YAML failed to parse${where} — ${e.message}`,
|
|
241
|
+
}],
|
|
242
|
+
warnings: [],
|
|
243
|
+
coverageNote: COVERAGE_NOTE,
|
|
244
|
+
};
|
|
202
245
|
out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
|
|
203
246
|
return 1;
|
|
204
247
|
}
|
|
205
248
|
|
|
206
|
-
const result =
|
|
249
|
+
const result = opts.scenario
|
|
250
|
+
? lintScenario(doc, { file: path })
|
|
251
|
+
: lintPolicy(doc, { origin: originForPath(path) });
|
|
207
252
|
out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
|
|
208
253
|
return result.ok ? 0 : 1;
|
|
209
254
|
}
|
package/cli.js
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* (0 accepted / 1 rejected / 2 operator error) so a wrapper can reason about
|
|
25
25
|
* both uniformly.
|
|
26
26
|
*
|
|
27
|
+
* KNOWN PREVIEW GAP (V2-CONTRACT-004, documented — wiring deferred to a
|
|
28
|
+
* feature pass): this CLI loads NO scenario definitions, so
|
|
29
|
+
* success_criteria.required_steps enforcement (F-3bfc2885) runs only in
|
|
30
|
+
* production ingest, which fetches scenarios from the source repo at the
|
|
31
|
+
* persisted commit. A preview VERDICT: ACCEPTED therefore does not cover
|
|
32
|
+
* required_steps — the same discipline run.js uses to document the GitLab
|
|
33
|
+
* scenario-fetcher gap. Both --help and every --explain rendering carry the
|
|
34
|
+
* note so no consumer can read a preview verdict as covering it.
|
|
35
|
+
*
|
|
27
36
|
* Exit codes (consistent with packages/ingest/run.js):
|
|
28
37
|
* 0 — submission accepted
|
|
29
38
|
* 1 — submission rejected (verdict reached; the payload is the problem)
|
|
@@ -32,11 +41,12 @@
|
|
|
32
41
|
* submission.
|
|
33
42
|
*/
|
|
34
43
|
|
|
35
|
-
import { readFileSync } from 'node:fs';
|
|
44
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
36
45
|
import { resolve, dirname, join } from 'node:path';
|
|
37
46
|
import { fileURLToPath } from 'node:url';
|
|
38
47
|
import yaml from 'js-yaml';
|
|
39
48
|
|
|
49
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
40
50
|
import { verify, parseRejectionReason } from './index.js';
|
|
41
51
|
import { stubProvenance, provenanceForProvider } from './validators/provenance.js';
|
|
42
52
|
import { runLint } from './cli-lint.js';
|
|
@@ -59,21 +69,21 @@ class OperatorError extends Error {
|
|
|
59
69
|
|
|
60
70
|
const USAGE = `verify — local dry-run / explain for dogfood submissions
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
Usage:
|
|
63
73
|
verify --file <path> [--explain | --json] [--provenance=stub|github]
|
|
64
74
|
verify --payload '<json>' [--explain | --json] [--provenance=stub|github]
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
Input (exactly one required):
|
|
67
77
|
--file <path> Read the submission JSON from a file.
|
|
68
78
|
--payload <json> Pass the submission JSON inline.
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
Output mode (default: --explain):
|
|
71
81
|
--explain Human-readable verdict breakdown with each rejection
|
|
72
82
|
reason classified (who must fix it). [default]
|
|
73
83
|
--json Machine-readable result for tooling. Mutually exclusive
|
|
74
84
|
with --explain.
|
|
75
85
|
|
|
76
|
-
|
|
86
|
+
Provenance (default: stub):
|
|
77
87
|
--provenance=stub No-network local check; provenance is always confirmed.
|
|
78
88
|
This is a LOCAL DRY-RUN — a real ingest re-checks
|
|
79
89
|
provenance against the source run. [default]
|
|
@@ -82,7 +92,12 @@ PROVENANCE (default: stub):
|
|
|
82
92
|
|
|
83
93
|
-h, --help Show this help.
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
Not checked in preview:
|
|
96
|
+
not checked in preview: required_steps (needs scenario definitions).
|
|
97
|
+
Scenario definitions live in the source repo; only a real ingest fetches
|
|
98
|
+
them and enforces success_criteria.required_steps.
|
|
99
|
+
|
|
100
|
+
Exit codes:
|
|
86
101
|
0 accepted 1 rejected 2 operator error (bad flags / IO / JSON)`;
|
|
87
102
|
|
|
88
103
|
/**
|
|
@@ -113,7 +128,12 @@ export function parseArgs(argv) {
|
|
|
113
128
|
arg = arg.slice(0, eq);
|
|
114
129
|
}
|
|
115
130
|
}
|
|
116
|
-
|
|
131
|
+
// F-b4dbdc52: a following token that is itself a flag is NOT a value —
|
|
132
|
+
// `verify --file --json` must hit the '--file requires a path' operator
|
|
133
|
+
// error, not consume '--json' as the path. Same guard as run.js
|
|
134
|
+
// (f-ingest-003), which this parser mirrors.
|
|
135
|
+
const nextIsValue = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--');
|
|
136
|
+
const hasValue = inlineValue !== null || nextIsValue;
|
|
117
137
|
const takeValue = () => (inlineValue !== null ? inlineValue : argv[++i]);
|
|
118
138
|
|
|
119
139
|
switch (arg) {
|
|
@@ -225,6 +245,17 @@ function loadSubmission({ file, payload }) {
|
|
|
225
245
|
* @param {string} repoRoot
|
|
226
246
|
* @returns {{ globalPolicy: object, repoPolicy: object|null, policyVersion: string }}
|
|
227
247
|
*/
|
|
248
|
+
/**
|
|
249
|
+
* Collapse validatePayload errors into the same first-3 single-line summary
|
|
250
|
+
* production's loadGlobalPolicy uses (D2B-005), so preview and production
|
|
251
|
+
* name the offending YAML key identically.
|
|
252
|
+
*/
|
|
253
|
+
function summarizePolicySchemaErrors(errors) {
|
|
254
|
+
const trimmed = errors.slice(0, 3).map(e => `${e.path || '/'} ${e.message}`);
|
|
255
|
+
const ellipsis = errors.length > 3 ? `; (+${errors.length - 3} more)` : '';
|
|
256
|
+
return trimmed.join('; ') + ellipsis;
|
|
257
|
+
}
|
|
258
|
+
|
|
228
259
|
function loadPolicies(submission, repoRoot) {
|
|
229
260
|
const globalPath = join(repoRoot, 'policies', 'global-policy.yaml');
|
|
230
261
|
let globalPolicy;
|
|
@@ -235,22 +266,57 @@ function loadPolicies(submission, repoRoot) {
|
|
|
235
266
|
'run from the testing-os repo root, or set VERIFY_REPO_ROOT to it');
|
|
236
267
|
}
|
|
237
268
|
|
|
269
|
+
// F-99aa42bc: mirror production loadGlobalPolicy's fail-loud schema gate.
|
|
270
|
+
// Pre-fix the preview applied a parses-but-schema-invalid (or null/empty)
|
|
271
|
+
// global policy as-is — production ingest would refuse the same file, and
|
|
272
|
+
// a null policy surfaced downstream as a confusing VALIDATOR_FAULT_POLICY.
|
|
273
|
+
// Same divergence class F-65d4d6dd closed for the repo-policy half.
|
|
274
|
+
const globalValidation = validatePayload('policy', globalPolicy);
|
|
275
|
+
if (!globalValidation.valid) {
|
|
276
|
+
throw new OperatorError(
|
|
277
|
+
`global policy schema-invalid: ${globalPath} — ${summarizePolicySchemaErrors(globalValidation.errors)}`,
|
|
278
|
+
'fix the policy to conform to policy.schema.json — production ingest refuses this file too'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
238
282
|
let repoPolicy = null;
|
|
239
283
|
const repoSlug = submission && typeof submission === 'object' ? submission.repo : null;
|
|
240
284
|
if (typeof repoSlug === 'string' && repoSlug.includes('/')) {
|
|
241
|
-
|
|
285
|
+
// F-54e5fde7: two-segment contract only (nested GitLab subgroups are
|
|
286
|
+
// unsupported by the submission schema). A 3+-segment slug fails closed —
|
|
287
|
+
// destructuring would silently drop the tail and look up the WRONG policy.
|
|
288
|
+
const segments = repoSlug.split('/');
|
|
289
|
+
const [org, repo] = segments.length === 2 ? segments : [null, null];
|
|
242
290
|
// Reject path-traversal segments before touching the filesystem — a hostile
|
|
243
291
|
// submission.repo like '../../etc' must never escape policies/repos/.
|
|
244
292
|
const safe = (s) => typeof s === 'string' && s.length > 0 && !s.includes('..') && !s.includes('\\') && s !== '.';
|
|
245
293
|
if (safe(org) && safe(repo)) {
|
|
246
294
|
const repoPath = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
295
|
+
// F-65d4d6dd: mirror ingest's loadRepoPolicy contract exactly so the
|
|
296
|
+
// preview can never green-light a submission production will reject:
|
|
297
|
+
// - absent file → null (defaults apply)
|
|
298
|
+
// - YAML parse failure → `__torn` sentinel (verify() rejects with
|
|
299
|
+
// `policy: repo policy unreadable — …`)
|
|
300
|
+
// - parses, schema-invalid (D2B-005 class) → `__torn` sentinel too
|
|
301
|
+
// Pre-fix, a parses-but-schema-invalid policy was silently applied as-is
|
|
302
|
+
// and a bad-YAML policy silently became null — both preview/production
|
|
303
|
+
// divergences.
|
|
304
|
+
if (existsSync(repoPath)) {
|
|
305
|
+
try {
|
|
306
|
+
repoPolicy = yaml.load(readFileSync(repoPath, 'utf-8'));
|
|
307
|
+
} catch (e) {
|
|
308
|
+
repoPolicy = { __torn: true, reason: e && e.message ? e.message : String(e), path: repoPath };
|
|
309
|
+
}
|
|
310
|
+
if (repoPolicy && repoPolicy.__torn !== true) {
|
|
311
|
+
const validation = validatePayload('policy', repoPolicy);
|
|
312
|
+
if (!validation.valid) {
|
|
313
|
+
repoPolicy = {
|
|
314
|
+
__torn: true,
|
|
315
|
+
reason: `schema-invalid — ${summarizePolicySchemaErrors(validation.errors)}`,
|
|
316
|
+
path: repoPath
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
254
320
|
}
|
|
255
321
|
}
|
|
256
322
|
}
|
|
@@ -296,6 +362,15 @@ function resolveProvenance(provenanceMode, submission) {
|
|
|
296
362
|
* the routing decision parseRejectionReason() makes; this maps it to operator
|
|
297
363
|
* language so the consumer knows WHOSE problem each reason is.
|
|
298
364
|
*/
|
|
365
|
+
/**
|
|
366
|
+
* V2-CONTRACT-004: appended to EVERY --explain rendering (accepted and
|
|
367
|
+
* rejected alike) so a preview verdict can never be read as covering the
|
|
368
|
+
* required_steps gate, which only production ingest enforces (it fetches the
|
|
369
|
+
* scenario definitions this preview does not have).
|
|
370
|
+
*/
|
|
371
|
+
const PREVIEW_GAP_NOTE =
|
|
372
|
+
'Not checked in preview: required_steps (needs scenario definitions — enforced only by real ingest).';
|
|
373
|
+
|
|
299
374
|
const CLASS_LABEL = {
|
|
300
375
|
'submission-bad': 'SUBMISSION — fix your payload and resubmit',
|
|
301
376
|
'operational': 'OPERATIONAL — verifier/tooling fault; page ops, do not bounce to submitter',
|
|
@@ -334,6 +409,8 @@ export function renderExplain(record) {
|
|
|
334
409
|
if (accepted) {
|
|
335
410
|
lines.push('');
|
|
336
411
|
lines.push('No rejection reasons. This submission would be accepted.');
|
|
412
|
+
lines.push('');
|
|
413
|
+
lines.push(PREVIEW_GAP_NOTE);
|
|
337
414
|
return lines.join('\n');
|
|
338
415
|
}
|
|
339
416
|
|
|
@@ -361,6 +438,8 @@ export function renderExplain(record) {
|
|
|
361
438
|
lines.push(` - ${prefix}${parsed.detail}`);
|
|
362
439
|
}
|
|
363
440
|
}
|
|
441
|
+
lines.push('');
|
|
442
|
+
lines.push(PREVIEW_GAP_NOTE);
|
|
364
443
|
return lines.join('\n');
|
|
365
444
|
}
|
|
366
445
|
|