@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 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 <policy-file>` subcommand.
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) and reports static lint findings, so it does not share the
6
- * verify arg parser or output. cli.js's `main` dispatcher routes the `lint` verb here
7
- * and leaves the verify `run` path untouched. The exit contract mirrors that path:
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 predicate fault, or unparseable YAML).
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 policy the author must fix —
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
- * policy-file path plus optional `--json` / `--help`. Throws LintOperatorError (→ exit 2)
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 policy file given', 'lint one file at a time');
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
- throw new LintOperatorError('no policy file provided', 'dogfood-verify lint <policy-file>');
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 policy file: ${path} — ${e.message}`);
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 policy (exit 1), not an operator error.
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
- ok: false,
192
- origin: originForPath(path),
193
- errors: [{
194
- label: 'policy-schema:',
195
- code: 'yaml_parse',
196
- location: '/',
197
- message: `policy YAML failed to parse${where} — ${e.message}`,
198
- }],
199
- warnings: [],
200
- coverageNote: COVERAGE_NOTE,
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 = lintPolicy(doc, { origin: originForPath(path) });
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
- USAGE:
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
- INPUT (exactly one required):
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
- OUTPUT MODE (default: --explain):
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
- PROVENANCE (default: stub):
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
- EXIT CODES:
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
- const hasValue = inlineValue !== null || argv[i + 1] !== undefined;
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
- const [org, repo] = repoSlug.split('/');
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
- try {
248
- repoPolicy = yaml.load(readFileSync(repoPath, 'utf-8'));
249
- } catch {
250
- // Absent or unreadable repo policy → null (defaults apply). This CLI is
251
- // a preview; it does not reproduce ingest's torn-policy sentinel. A real
252
- // ingest is the authority on a corrupt repo-policy file.
253
- repoPolicy = null;
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