@dogfood-lab/verify 1.7.0 → 1.8.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
@@ -62,6 +62,28 @@ import { validateSchema } from '@dogfood-lab/verify/validators/schema.js';
62
62
  import { validateProvenance } from '@dogfood-lab/verify/validators/provenance.js';
63
63
  ```
64
64
 
65
+ ## CLI (`dogfood-verify`)
66
+
67
+ The package ships a `dogfood-verify` bin with two verbs.
68
+
69
+ **Verify** a submission (local dry-run / explain — never writes):
70
+
71
+ ```bash
72
+ dogfood-verify --file submission.json --explain # human verdict, reasons classified by who-fixes-it
73
+ dogfood-verify --file submission.json --json # machine-readable
74
+ # exit: 0 accepted · 1 rejected · 2 operator error
75
+ ```
76
+
77
+ **Lint** a policy file (VERIFY-F3, author-time — no submission needed):
78
+
79
+ ```bash
80
+ dogfood-verify lint policies/repos/<org>/<repo>.yaml # or global-policy.yaml
81
+ dogfood-verify lint policies/global-policy.yaml --json # for CI
82
+ # exit: 0 clean or warnings-only · 1 errors · 2 operator error
83
+ ```
84
+
85
+ `lint` runs the structural schema gate **plus** the data-independent predicate checks (`unknown_field`, `max_depth`, `node_budget`) over every `when` predicate, and emits an **advisory** warning on the `[]` footgun (a negative operator over a `[]` path fails open) with the fail-closed `not(any(...))` rewrite as a suggestion — never auto-applied, never a hard error. It is the `opa check` analogue. It **cannot** statically catch a `type_mismatch` or a fan-out overrun (both are data-dependent) and says so. Full contract + coverage boundary: [`docs/policy-lint.md`](https://github.com/dogfood-lab/testing-os/blob/main/docs/policy-lint.md).
86
+
65
87
  ## Submission envelope
66
88
 
67
89
  The full envelope shape is defined by `@dogfood-lab/schemas` (`dogfood-record-submission.schema.json`). Minimum required fields:
package/cli-lint.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * cli-lint.js (VERIFY-F3) — the `dogfood-verify lint <policy-file>` subcommand.
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:
8
+ *
9
+ * 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
+ * 2 — operator error (file missing/unreadable, or a malformed invocation).
12
+ *
13
+ * YAML that fails to parse is exit 1 (a lint FINDING about the policy the author must fix —
14
+ * surfacing "line 4: bad indentation" is the lint's job), not exit 2. A file that does not
15
+ * exist is exit 2 (the author pointed at the wrong path). See docs/policy-lint.md.
16
+ */
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { resolve } from 'node:path';
20
+ import yaml from 'js-yaml';
21
+
22
+ import { lintPolicy, COVERAGE_NOTE } from './validators/lint-policy.js';
23
+
24
+ /** Operator-error sentinel → exit 2 (distinct from a lint finding, which is exit 1). */
25
+ class LintOperatorError extends Error {
26
+ constructor(message, hint) {
27
+ super(message);
28
+ this.name = 'LintOperatorError';
29
+ this.hint = hint;
30
+ }
31
+ }
32
+
33
+ const LINT_USAGE = `dogfood-verify lint — author-time static check for a policy file
34
+
35
+ USAGE:
36
+ dogfood-verify lint <policy-file> [--json]
37
+
38
+ WHAT IT CHECKS (no submission needed):
39
+ - structural validity against policy.schema.json
40
+ - every predicate's known leading field, combinator depth, and node budget
41
+ - an ADVISORY warning on the [] footgun (a negative op over a [] path fails open)
42
+
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.
45
+
46
+ OPTIONS:
47
+ --json Machine-readable result for CI.
48
+ -h, --help Show this help.
49
+
50
+ EXIT CODES:
51
+ 0 clean or warnings-only 1 errors found 2 operator error (bad flags / IO)`;
52
+
53
+ /**
54
+ * 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.
57
+ *
58
+ * @param {string[]} argv
59
+ * @returns {{ help: boolean, file: string|null, json: boolean }}
60
+ */
61
+ export function parseLintArgs(argv) {
62
+ let file = null;
63
+ let json = false;
64
+ let help = false;
65
+
66
+ for (const arg of argv) {
67
+ if (arg === '-h' || arg === '--help') { help = true; continue; }
68
+ if (arg === '--json') { json = true; continue; }
69
+ if (arg.startsWith('-')) {
70
+ throw new LintOperatorError(`unknown argument: ${arg}`, 'run `dogfood-verify lint --help` for usage');
71
+ }
72
+ if (file !== null) {
73
+ throw new LintOperatorError('more than one policy file given', 'lint one file at a time');
74
+ }
75
+ file = arg;
76
+ }
77
+
78
+ if (help) return { help: true, file: null, json: false };
79
+ if (file === null) {
80
+ throw new LintOperatorError('no policy file provided', 'dogfood-verify lint <policy-file>');
81
+ }
82
+ return { help: false, file, json };
83
+ }
84
+
85
+ /**
86
+ * Classify a policy file by its path so the report can name the origin (which decides the
87
+ * runtime fault class: global → operational, repo → submission-bad). Mirrors the
88
+ * `policies/global-policy.yaml` vs `policies/repos/<org>/<repo>.yaml` layout.
89
+ */
90
+ export function originForPath(p) {
91
+ const norm = String(p).replace(/\\/g, '/');
92
+ if (/\/policies\/repos\//.test(norm)) return 'repo';
93
+ if (/(^|\/)global-policy\.yaml$/.test(norm)) return 'global';
94
+ return 'unknown';
95
+ }
96
+
97
+ /** Render the human (default) view of a lint result — verdict-first, ERROR before WARNING. */
98
+ export function renderLintText(result, file) {
99
+ const lines = [];
100
+ const verdict = !result.ok ? 'ERRORS' : (result.warnings.length ? 'CLEAN (advisory warnings)' : 'CLEAN');
101
+ lines.push(`VERDICT: ${verdict}`);
102
+ lines.push('');
103
+ lines.push(` file: ${file}`);
104
+ lines.push(` origin: ${result.origin}`);
105
+
106
+ if (result.errors.length) {
107
+ lines.push('');
108
+ lines.push(`ERRORS (${result.errors.length}):`);
109
+ for (const e of result.errors) {
110
+ const field = e.field ? ` — field "${e.field}"` : '';
111
+ lines.push(` - [${e.label} ${e.code}] ${e.location}${field}`);
112
+ lines.push(` ${e.message}`);
113
+ }
114
+ }
115
+
116
+ if (result.warnings.length) {
117
+ lines.push('');
118
+ lines.push(`WARNINGS (${result.warnings.length}) — advisory; the author confirms intent, nothing is auto-applied:`);
119
+ for (const w of result.warnings) {
120
+ lines.push(` - [${w.label} ${w.code}] ${w.location} — field "${w.field}"`);
121
+ lines.push(` ${w.message}`);
122
+ lines.push(` ${w.suggestion}`);
123
+ }
124
+ }
125
+
126
+ if (result.ok && !result.warnings.length) {
127
+ lines.push('');
128
+ lines.push('No findings. The policy passes static lint.');
129
+ }
130
+
131
+ lines.push('');
132
+ lines.push(`note: ${result.coverageNote}`);
133
+ return lines.join('\n');
134
+ }
135
+
136
+ /** Build the machine-readable (--json) result. */
137
+ export function buildLintJson(result, file) {
138
+ return {
139
+ file,
140
+ origin: result.origin,
141
+ ok: result.ok,
142
+ errors: result.errors,
143
+ warnings: result.warnings,
144
+ coverageNote: result.coverageNote,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Run the lint subcommand. Returns the exit code (does not call process.exit) so it is
150
+ * unit-testable with an injected stdout/stderr sink.
151
+ *
152
+ * @param {string[]} argv - args AFTER the `lint` verb
153
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void }} [io]
154
+ * @returns {Promise<number>}
155
+ */
156
+ export async function runLint(argv, io = {}) {
157
+ const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
158
+ const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
159
+
160
+ let opts;
161
+ try {
162
+ opts = parseLintArgs(argv);
163
+ } catch (e) {
164
+ err(`ERROR: ${e.message}`);
165
+ if (e.hint) err(` hint: ${e.hint}`);
166
+ return 2;
167
+ }
168
+
169
+ if (opts.help) {
170
+ out(LINT_USAGE);
171
+ return 0;
172
+ }
173
+
174
+ const path = resolve(opts.file);
175
+ let raw;
176
+ try {
177
+ raw = readFileSync(path, 'utf-8');
178
+ } catch (e) {
179
+ err(`ERROR: could not read policy file: ${path} — ${e.message}`);
180
+ err(' hint: check the path exists and is readable');
181
+ return 2;
182
+ }
183
+
184
+ let doc;
185
+ try {
186
+ doc = yaml.load(raw);
187
+ } catch (e) {
188
+ // A YAML parse failure is a lint finding about the policy (exit 1), not an operator error.
189
+ 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
+ };
202
+ out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
203
+ return 1;
204
+ }
205
+
206
+ const result = lintPolicy(doc, { origin: originForPath(path) });
207
+ out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
208
+ return result.ok ? 0 : 1;
209
+ }
package/cli.js CHANGED
@@ -39,6 +39,7 @@ import yaml from 'js-yaml';
39
39
 
40
40
  import { verify, parseRejectionReason } from './index.js';
41
41
  import { stubProvenance, provenanceForProvider } from './validators/provenance.js';
42
+ import { runLint } from './cli-lint.js';
42
43
 
43
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
 
@@ -440,8 +441,27 @@ export async function run(argv, io = {}) {
440
441
  return record.verification?.status === 'accepted' ? 0 : 1;
441
442
  }
442
443
 
444
+ /**
445
+ * Top-level dispatcher. The bin has two verbs:
446
+ * - `dogfood-verify lint <policy-file>` → the author-time policy lint (VERIFY-F3, cli-lint.js).
447
+ * - `dogfood-verify <flags>` → the verify dry-run/explain (the original `run` path).
448
+ *
449
+ * Dispatching on `argv[0] === 'lint'` is a purely additive change: that token previously hit the
450
+ * verify parser's default arm and threw `unknown argument: lint`. The verify path is unchanged.
451
+ *
452
+ * @param {string[]} argv - process.argv.slice(2)
453
+ * @param {object} [io] - injected stdout/stderr/repoRoot for testing
454
+ * @returns {Promise<number>} exit code
455
+ */
456
+ export async function main(argv, io = {}) {
457
+ if (argv[0] === 'lint') {
458
+ return runLint(argv.slice(1), io);
459
+ }
460
+ return run(argv, io);
461
+ }
462
+
443
463
  // --- CLI entrypoint ---
444
464
  const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'cli.js');
445
465
  if (isMain) {
446
- run(process.argv.slice(2)).then((code) => process.exit(code));
466
+ main(process.argv.slice(2)).then((code) => process.exit(code));
447
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
6
6
  "main": "index.js",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./index.js",
12
12
  "./cli.js": "./cli.js",
13
+ "./cli-lint.js": "./cli-lint.js",
13
14
  "./parse-rejection.js": "./parse-rejection.js",
14
15
  "./validators/*": "./validators/*",
15
16
  "./validators/*.js": "./validators/*.js"
@@ -21,6 +22,7 @@
21
22
  "files": [
22
23
  "index.js",
23
24
  "cli.js",
25
+ "cli-lint.js",
24
26
  "parse-rejection.js",
25
27
  "validators/",
26
28
  "README.md",
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Policy lint (VERIFY-F3)
3
+ *
4
+ * `lintPolicy(policyDoc, { origin })` is the author-time check behind the
5
+ * `dogfood-verify lint` verb: it validates a whole policy file WITHOUT a submission,
6
+ * batch-reporting every fault. It is the `opa check` analogue named (as a deferred
7
+ * companion) in docs/policy-dsl.md and specified in docs/policy-lint.md.
8
+ *
9
+ * Three passes, mirroring the runtime's own layering:
10
+ * 1. Structural gate — `validatePayload('policy', …)` against policy.schema.json (the same
11
+ * gate loadGlobalPolicy / loadRepoPolicy run). Catches unknown op, malformed/banned field,
12
+ * mixed node, value-arity, custom_rules-under-defaults, additionalProperties.
13
+ * 2. Static predicate walk — the data-independent semantic faults the schema cannot express:
14
+ * unknown leading field, combinator over-depth, node budget (via predicate.js#lintPredicate).
15
+ * 3. `[]`-footgun advisory — a deterministic warning (never an error) on a negative op over a
16
+ * `[]` path (via predicate.js#findEmptyArrayFootguns).
17
+ *
18
+ * Coverage boundary (the VERIFY-F2 over-claim lesson): `type_mismatch` and `fanout_budget` are
19
+ * DATA-DEPENDENT and cannot be caught statically. `coverageNote` says so — a clean lint is "no
20
+ * static fault and no footgun," NOT "this policy can never produce a policy-config: rejection."
21
+ */
22
+
23
+ import { validatePayload } from '@dogfood-lab/schemas';
24
+ import { lintPredicate, findEmptyArrayFootguns } from './predicate.js';
25
+
26
+ /** Stated in every lint result so a clean verdict is never read as full coverage. */
27
+ export const COVERAGE_NOTE =
28
+ 'Static lint only — it cannot catch a `type_mismatch` (a numeric op over a non-number field) ' +
29
+ 'or a `fanout_budget` overrun; both depend on submission data. Run ' +
30
+ '`dogfood-verify --file <submission> --explain` to exercise the data-dependent path.';
31
+
32
+ /**
33
+ * Lint a parsed policy document.
34
+ *
35
+ * @param {unknown} policyDoc - The parsed policy YAML/JSON (any value; a non-object is reported
36
+ * by the schema gate).
37
+ * @param {{ origin?: 'global'|'repo'|'unknown' }} [opts] - The policy's origin, used only for
38
+ * reporting (the caller derives it from the file path).
39
+ * @returns {{
40
+ * ok: boolean, origin: string, coverageNote: string,
41
+ * errors: { label: string, code: string, location: string, field?: string, message: string }[],
42
+ * warnings: { label: string, code: string, location: string, field: string, suggestion: string, message: string }[]
43
+ * }} `ok` is true iff there are no errors; warnings (footguns) never affect `ok`.
44
+ */
45
+ export function lintPolicy(policyDoc, { origin = 'unknown' } = {}) {
46
+ const errors = [];
47
+ const warnings = [];
48
+
49
+ // 1. Structural schema gate.
50
+ const schema = validatePayload('policy', policyDoc);
51
+ for (const e of schema.errors) {
52
+ errors.push({
53
+ label: 'policy-schema:',
54
+ code: e.keyword || 'schema',
55
+ location: e.path || '/',
56
+ message: e.message || 'schema violation',
57
+ });
58
+ }
59
+
60
+ // 2 + 3. Static predicate walk + footgun advisory over every `when` location. Defensive: a
61
+ // schema-invalid doc may have malformed predicates; the walkers tolerate non-objects.
62
+ for (const { when, scope, location } of collectPredicates(policyDoc)) {
63
+ for (const f of lintPredicate(when, scope)) {
64
+ errors.push({ label: 'policy-config:', code: f.code, location, field: f.field, message: f.message });
65
+ }
66
+ for (const g of findEmptyArrayFootguns(when)) {
67
+ warnings.push({
68
+ label: 'policy-footgun:',
69
+ code: 'footgun-empty-array',
70
+ location,
71
+ field: g.field,
72
+ suggestion: g.suggestion,
73
+ message:
74
+ `negative operator "${g.op}" over the array path "${g.field}" fails OPEN on an empty or ` +
75
+ `absent array — the rule silently does not fire when there are no elements`,
76
+ });
77
+ }
78
+ }
79
+
80
+ return { ok: errors.length === 0, origin, errors, warnings, coverageNote: COVERAGE_NOTE };
81
+ }
82
+
83
+ /**
84
+ * Enumerate every `when` predicate in a policy with its evaluation scope and an operator-readable
85
+ * location. Walks the SAME two locations the runtime evaluates (validators/policy.js):
86
+ * - `global_rules[].when` at scope `rule.scope || 'submission'`
87
+ * - `surfaces.<surface>.custom_rules[].when` at scope `scenario_result`
88
+ * `custom_rules` under `defaults` is schema-forbidden, so it is not walked here — the structural
89
+ * gate already reports it if present (matching the runtime, which never evaluates it).
90
+ */
91
+ function* collectPredicates(policyDoc) {
92
+ if (policyDoc === null || typeof policyDoc !== 'object') return;
93
+
94
+ const globalRules = Array.isArray(policyDoc.global_rules) ? policyDoc.global_rules : [];
95
+ for (let i = 0; i < globalRules.length; i++) {
96
+ const rule = globalRules[i];
97
+ if (rule && typeof rule === 'object' && rule.when != null) {
98
+ yield {
99
+ when: rule.when,
100
+ scope: rule.scope || 'submission',
101
+ location: `global_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
102
+ };
103
+ }
104
+ }
105
+
106
+ const surfaces = policyDoc.surfaces && typeof policyDoc.surfaces === 'object' ? policyDoc.surfaces : {};
107
+ for (const [surface, sp] of Object.entries(surfaces)) {
108
+ const customRules = sp && Array.isArray(sp.custom_rules) ? sp.custom_rules : [];
109
+ for (let i = 0; i < customRules.length; i++) {
110
+ const rule = customRules[i];
111
+ if (rule && typeof rule === 'object' && rule.when != null) {
112
+ yield {
113
+ when: rule.when,
114
+ scope: 'scenario_result',
115
+ location: `surfaces.${surface}.custom_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
116
+ };
117
+ }
118
+ }
119
+ }
120
+ }
@@ -134,16 +134,30 @@ function resolvePath(root, segments) {
134
134
  return frontier;
135
135
  }
136
136
 
137
- /** Validate the leading field segment against the scope's known-field set. */
138
- function checkLeadingField(field, scope) {
137
+ /**
138
+ * Decide whether a field's LEADING segment is unknown for a scope, returning the
139
+ * fault rather than throwing it. Factored out so the eval path ({@link checkLeadingField},
140
+ * fail-on-first) and the lint path ({@link lintPredicate}, batch-collect) share one
141
+ * source of truth — a divergence between author-time and runtime diagnostics is
142
+ * impossible by construction. Data-INDEPENDENT (the known-field set is derived from
143
+ * the schema), so it is exactly the kind of check the lint can run without a submission.
144
+ */
145
+ function leadingFieldFault(field, scope) {
139
146
  const leading = String(field).split('.')[0].replace('[]', '');
140
147
  const known = KNOWN_FIELDS[scope];
141
148
  if (known && !known.has(leading)) {
142
- throw new PredicateError(
149
+ return new PredicateError(
143
150
  'unknown_field',
144
151
  `field "${field}" references unknown leading field "${leading}" (known ${scope} fields: ${[...known].join(', ')})`
145
152
  );
146
153
  }
154
+ return null;
155
+ }
156
+
157
+ /** Validate the leading field segment against the scope's known-field set (eval path: throws). */
158
+ function checkLeadingField(field, scope) {
159
+ const fault = leadingFieldFault(field, scope);
160
+ if (fault) throw fault;
147
161
  }
148
162
 
149
163
  /** Apply one operator to a single resolved value + comparand. */
@@ -287,3 +301,149 @@ export function buildReason(rule, root) {
287
301
  : (rule.description ?? '');
288
302
  return `[${rule.id}] ${body}`;
289
303
  }
304
+
305
+ /* ───────────────────────── Static analysis (VERIFY-F3 policy-lint) ─────────────────────────
306
+ *
307
+ * The two functions below analyze a predicate AST WITHOUT a submission. They are the reusable
308
+ * leaf the `policy-lint` verb (and any future eager-validation path) builds on. Co-located here,
309
+ * not in a separate module, so they reuse this file's private engine internals (KNOWN_FIELDS via
310
+ * leadingFieldFault, isCombinator, the depth/node-budget constants) without exporting the guts —
311
+ * predicate.js owns *everything about a predicate node*: eval AND static analysis.
312
+ *
313
+ * Coverage boundary (the VERIFY-F2 over-claim lesson — see docs/policy-lint.md):
314
+ * - DATA-INDEPENDENT, caught here: unknown leading field, combinator over-depth, node budget.
315
+ * - STRUCTURAL (unknown op, banned/malformed segment, arity): caught earlier by the schema gate
316
+ * (`validatePayload('policy', …)`), so lintPredicate does not re-report them.
317
+ * - DATA-DEPENDENT, NOT catchable statically: `type_mismatch` (a numeric op over a non-number)
318
+ * and `fanout_budget` (a `[]` selection size) — both need a real submission. The lint says so.
319
+ */
320
+
321
+ /** A static lint finding: a machine `code` (mirrors {@link PredicateError} codes) + an operator message. */
322
+
323
+ /**
324
+ * Statically lint a predicate tree against one scope, batch-collecting the data-independent
325
+ * semantic faults the schema cannot express. Unlike {@link evaluatePredicate} this NEVER throws —
326
+ * a lint reports every fault rather than failing on the first — and it is DEFENSIVE: a malformed
327
+ * node (already flagged by the schema gate) is skipped, not crashed on. The walk mirrors the
328
+ * evaluator's own depth + node accounting (same constants), so a lint verdict agrees with what the
329
+ * engine would do at eval time, and the walk is itself bounded (it cannot become a DoS on a
330
+ * pathological policy file).
331
+ *
332
+ * @param {unknown} node - The predicate tree (any value; non-objects are skipped).
333
+ * @param {'submission'|'scenario_result'} scope - Field-resolution scope.
334
+ * @returns {{ code: string, field?: string, message: string }[]}
335
+ */
336
+ export function lintPredicate(node, scope) {
337
+ const findings = [];
338
+ walkLint(node, scope, 1, { nodes: 0, depthReported: false, budgetReported: false }, findings);
339
+ return findings;
340
+ }
341
+
342
+ function walkLint(node, scope, depth, state, findings) {
343
+ if (state.budgetReported) return;
344
+ if (++state.nodes > PREDICATE_MAX_NODES) {
345
+ if (!state.budgetReported) {
346
+ state.budgetReported = true;
347
+ findings.push({
348
+ code: 'node_budget',
349
+ message: `predicate exceeds the evaluation budget of ${PREDICATE_MAX_NODES} nodes`,
350
+ });
351
+ }
352
+ return;
353
+ }
354
+ if (node === null || typeof node !== 'object') return;
355
+
356
+ if (isCombinator(node)) {
357
+ if (depth > PREDICATE_MAX_DEPTH) {
358
+ // Mirror the evaluator, which throws when a combinator is reached past the cap; report once
359
+ // and stop descending this branch (siblings are still walked — batch reporting).
360
+ if (!state.depthReported) {
361
+ state.depthReported = true;
362
+ findings.push({
363
+ code: 'max_depth',
364
+ message: `predicate nests deeper than the limit of ${PREDICATE_MAX_DEPTH} combinator levels`,
365
+ });
366
+ }
367
+ return;
368
+ }
369
+ const children =
370
+ Array.isArray(node.all) ? node.all :
371
+ Array.isArray(node.any) ? node.any :
372
+ node.not != null ? [node.not] :
373
+ Array.isArray(node.implies) ? node.implies : [];
374
+ for (const child of children) walkLint(child, scope, depth + 1, state, findings);
375
+ return;
376
+ }
377
+
378
+ // Leaf: the only data-independent semantic check is the unknown leading field. The op set and
379
+ // path shape are schema-gated; a numeric-op type mismatch is data-dependent (not checkable here).
380
+ if (typeof node.field === 'string') {
381
+ const fault = leadingFieldFault(node.field, scope);
382
+ if (fault) findings.push({ code: fault.code, field: node.field, message: fault.message });
383
+ }
384
+ }
385
+
386
+ /** Negative operators that fail OPEN over an empty/absent `[]` selection (the footgun). */
387
+ const NEGATIVE_OPS = new Set(['not_equals', 'not_in', 'not_contains', 'not_exists']);
388
+
389
+ /** The positive counterpart used in the fail-closed `not(any(...))` rewrite suggestion. */
390
+ const POSITIVE_OF = { not_equals: 'equals', not_in: 'in', not_contains: 'contains', not_exists: 'exists' };
391
+
392
+ /** Build the deterministic, AST-derived fail-closed rewrite suggestion for a footgun leaf. */
393
+ function footgunSuggestion(field, op) {
394
+ const pos = POSITIVE_OF[op] || 'contains';
395
+ const valuePart = pos === 'exists' ? ' ' : ', value: … ';
396
+ return `if you meant "reject when no element satisfies it", use the fail-closed idiom ` +
397
+ `{ not: { any: [ { field: "${field}", op: ${pos}${valuePart}} ] } }`;
398
+ }
399
+
400
+ /**
401
+ * Find `[]`-footgun leaves: a NEGATIVE operator over a `[]` field path, which fails OPEN on an
402
+ * empty/absent array (existential vacuous truth — see docs/policy-lint.md). ADVISORY only: there
403
+ * are legitimate existential-negatives, so the caller surfaces these as warnings the author
404
+ * confirms, never as hard errors, and never auto-applies the rewrite.
405
+ *
406
+ * Suppression by negation PARITY, not a bare "is there a `not` above me" flag. A leaf inverted an
407
+ * EVEN number of times (0, 2, …) still fails open and is flagged; an ODD number of inversions makes
408
+ * it fail CLOSED, so it is suppressed. Two sources of inversion are counted: a `not` combinator,
409
+ * and the CONSEQUENT (second element) of an `implies` (since `implies:[A,C]` desugars to the
410
+ * violation `all(A, not(C))`). This refinement came from the VERIFY-F3 cross-family adversarial
411
+ * jury (deepseek-v4-pro / glm-5.2 / minimax-m3, 2026-06-30), which converged on two real defects in
412
+ * the original boolean rule: `not(not(X))` over `[]` fails open but was suppressed (false negative),
413
+ * and a negative-op consequent of `implies` fails closed but was flagged (false positive). Parity
414
+ * fixes both. Scope stays NEGATIVE-ops-only and ADVISORY (the jury's "flag every op" and "make it a
415
+ * hard error" suggestions were rejected — the former floods noise on the normal `contains` idiom,
416
+ * the latter blocks legitimate existential-negatives). False positives remain acceptable by design;
417
+ * a false negative (a silent production fail-open) is the expensive failure, so it errs toward warning.
418
+ *
419
+ * @param {unknown} node - The predicate tree.
420
+ * @returns {{ field: string, op: string, suggestion: string }[]}
421
+ */
422
+ export function findEmptyArrayFootguns(node) {
423
+ const guns = [];
424
+ walkFootgun(node, false, { nodes: 0 }, guns);
425
+ return guns;
426
+ }
427
+
428
+ function walkFootgun(node, inverted, state, guns) {
429
+ if (node === null || typeof node !== 'object') return;
430
+ if (++state.nodes > PREDICATE_MAX_NODES) return; // bounded; lintPredicate reports node_budget
431
+
432
+ if (isCombinator(node)) {
433
+ if (Array.isArray(node.all)) { for (const c of node.all) walkFootgun(c, inverted, state, guns); return; }
434
+ if (Array.isArray(node.any)) { for (const c of node.any) walkFootgun(c, inverted, state, guns); return; }
435
+ if (node.not != null) { walkFootgun(node.not, !inverted, state, guns); return; }
436
+ if (Array.isArray(node.implies)) {
437
+ // implies:[A, C] === all(A, not(C)) — only the consequent (index 1) flips parity.
438
+ node.implies.forEach((c, i) => walkFootgun(c, i === 1 ? !inverted : inverted, state, guns));
439
+ return;
440
+ }
441
+ return;
442
+ }
443
+
444
+ if (!inverted &&
445
+ typeof node.op === 'string' && NEGATIVE_OPS.has(node.op) &&
446
+ typeof node.field === 'string' && node.field.includes('[]')) {
447
+ guns.push({ field: node.field, op: node.op, suggestion: footgunSuggestion(node.field, node.op) });
448
+ }
449
+ }