@dogfood-lab/verify 1.10.0 → 1.11.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/cli-lint.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * cli-lint.js (VERIFY-F3) — the `dogfood-verify lint <file>` subcommand.
2
+ * cli-lint.js (VERIFY-F3 + F-49940082) — the `dogfood-verify lint <file>` subcommand.
3
3
  *
4
4
  * A SEPARATE parse/render path from the verify CLI (cli.js): it takes a policy YAML or
5
5
  * (with --scenario) a scenario YAML — not a submission JSON — and reports static lint
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * 0 — clean, or warnings-only (footgun advisories never block).
11
11
  * 1 — one or more errors (schema-invalid, a static fault, or unparseable YAML).
12
- * 2 — operator error (file missing/unreadable, or a malformed invocation).
12
+ * 2 — operator error (file missing/unreadable, a malformed invocation, or a batch
13
+ * whose positionals resolved to zero lintable files).
13
14
  *
14
15
  * YAML that fails to parse is exit 1 (a lint FINDING about the file the author must fix —
15
16
  * surfacing "line 4: bad indentation" is the lint's job), not exit 2. A file that does not
@@ -19,11 +20,21 @@
19
20
  * - default (policy): lintPolicy(doc, { origin }) → origin global|repo|unknown
20
21
  * - --scenario: lintScenario(doc, { file }) → origin 'scenario'
21
22
  * Both return the same { ok, origin, errors, warnings, coverageNote } shape, so
22
- * renderLintText / buildLintJson are reused unchanged.
23
+ * renderLintText / buildLintJson are reused unchanged, single-file or batch.
24
+ *
25
+ * Directory-or-multi-path mode (F-49940082): one or more positionals are accepted. A
26
+ * positional that is a directory is walked recursively for YAML files (extension match,
27
+ * mirroring the discovery semantics of scripts/lint-policies.test.mjs's own policyFiles()
28
+ * walker — see walkYamlFiles below). A positional that is a file is linted directly, no
29
+ * extension filter (an explicit path is trusted as authored intent; the extension filter
30
+ * only exists to skip non-policy noise during an unattended directory walk). The exact
31
+ * single-positional-file invocation shape is untouched: it still runs through the very
32
+ * same statements it always has, so its output is byte-identical to before this mode
33
+ * existed — see the "legacy single-file path" branch in runLint.
23
34
  */
24
35
 
25
- import { readFileSync } from 'node:fs';
26
- import { resolve } from 'node:path';
36
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
37
+ import { resolve, join } from 'node:path';
27
38
  import yaml from 'js-yaml';
28
39
 
29
40
  import { lintPolicy, COVERAGE_NOTE } from './validators/lint-policy.js';
@@ -42,7 +53,19 @@ const LINT_USAGE = `dogfood-verify lint — author-time static check for a polic
42
53
 
43
54
  Usage:
44
55
  dogfood-verify lint <policy-file> [--json]
56
+ dogfood-verify lint <path> [<path> ...] [--json]
45
57
  dogfood-verify lint --scenario <scenario-file> [--json]
58
+ dogfood-verify lint --scenario <path> [<path> ...] [--json]
59
+
60
+ Directory-or-multi-path mode:
61
+ Give more than one positional, or point at a directory, and every path is linted as
62
+ a batch. A directory positional is walked recursively for *.yaml and *.yml files; a
63
+ file positional is linted directly regardless of extension. Results print one block
64
+ per file (same format as single-file mode) followed by a batch summary — files
65
+ linted / clean / with-errors / with-warnings. The batch exit code is 1 if any file
66
+ has an error, 0 if every file is clean or warnings-only — the single-file contract,
67
+ extended across the set. A directory (or set of directories) with nothing to lint is
68
+ an operator error (exit 2), never a silent pass.
46
69
 
47
70
  What it checks — policy mode (default, no submission needed):
48
71
  - structural validity against policy.schema.json
@@ -64,26 +87,32 @@ What it checks — --scenario mode (no submission needed):
64
87
  that the receiver can fetch the file at the attested commit — run a real ingest.
65
88
 
66
89
  Options:
67
- --scenario Lint the file as a scenario definition (default: policy).
68
- --json Machine-readable result for CI.
90
+ --scenario Lint the file(s) as scenario definitions (default: policy).
91
+ --json Machine-readable result for CI. A single-file invocation emits one
92
+ result object, unchanged. Multi-path/directory mode emits
93
+ { mode: "batch", ok, summary, files: [ <one result object per file> ] }.
69
94
  -h, --help Show this help.
70
95
 
71
96
  Exit codes:
72
- 0 clean or warnings-only 1 errors found 2 operator error (bad flags / IO)`;
97
+ 0 clean or warnings-only 1 errors found 2 operator error (bad flags / IO / nothing to lint)`;
73
98
 
74
99
  /**
75
- * Parse the lint argv (everything AFTER the `lint` verb). Accepts exactly one positional
76
- * file path plus optional `--scenario` / `--json` / `--help`. Throws LintOperatorError
77
- * (→ exit 2) on any malformed invocation.
100
+ * Parse the lint argv (everything AFTER the `lint` verb). Accepts one or more positional
101
+ * paths (files and/or directories) plus optional `--scenario` / `--json` / `--help`.
102
+ * Throws LintOperatorError (→ exit 2) on any malformed invocation.
78
103
  *
79
104
  * `--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.
105
+ * over every positional; it never consumes a path itself.
106
+ *
107
+ * Whether a given invocation is "single-file" (legacy, byte-identical output) or "batch"
108
+ * (F-49940082) is NOT decided here — this just collects positionals. runLint makes that
109
+ * call once it knows whether the sole positional is a directory.
81
110
  *
82
111
  * @param {string[]} argv
83
- * @returns {{ help: boolean, file: string|null, json: boolean, scenario: boolean }}
112
+ * @returns {{ help: boolean, paths: string[], json: boolean, scenario: boolean }}
84
113
  */
85
114
  export function parseLintArgs(argv) {
86
- let file = null;
115
+ const paths = [];
87
116
  let json = false;
88
117
  let help = false;
89
118
  let scenario = false;
@@ -95,18 +124,15 @@ export function parseLintArgs(argv) {
95
124
  if (arg.startsWith('-')) {
96
125
  throw new LintOperatorError(`unknown argument: ${arg}`, 'run `dogfood-verify lint --help` for usage');
97
126
  }
98
- if (file !== null) {
99
- throw new LintOperatorError('more than one file given', 'lint one file at a time');
100
- }
101
- file = arg;
127
+ paths.push(arg);
102
128
  }
103
129
 
104
- if (help) return { help: true, file: null, json: false, scenario: false };
105
- if (file === null) {
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);
130
+ if (help) return { help: true, paths: [], json: false, scenario: false };
131
+ if (paths.length === 0) {
132
+ const usage = scenario ? 'dogfood-verify lint --scenario <scenario-file|dir> [...]' : 'dogfood-verify lint <policy-file|dir> [...]';
133
+ throw new LintOperatorError(`no ${scenario ? 'scenario' : 'policy'} file or directory provided`, usage);
108
134
  }
109
- return { help: false, file, json, scenario };
135
+ return { help: false, paths, json, scenario };
110
136
  }
111
137
 
112
138
  /**
@@ -121,6 +147,35 @@ export function originForPath(p) {
121
147
  return 'unknown';
122
148
  }
123
149
 
150
+ /**
151
+ * Recursively collect every YAML file (extension .yaml or .yml) under `dir`, depth-first.
152
+ * This is the F-49940082 directory-walk primitive for batch mode, deliberately mirroring
153
+ * policyFiles() in scripts/lint-policies.test.mjs — the CI gate that has quietly re-walked
154
+ * `policies/` on its own since VERIFY-F3 rather than exercising this CLI (that gap is the
155
+ * whole reason F-49940082 exists). Same two rules, on purpose: no skip-list (a hidden
156
+ * directory or a stray `node_modules` is walked like any other — the intended input here is
157
+ * an author-controlled tree such as `policies/`, not an arbitrary large directory), and
158
+ * symlinks are followed (`statSync`, not `lstatSync`).
159
+ *
160
+ * One deliberate divergence: this returns results in this function's own recursion order,
161
+ * which the caller (runLint) sorts across every discovered file from every positional —
162
+ * policyFiles() returns readdirSync's raw per-directory order because its only consumer is
163
+ * a test that aggregates failures into an unordered list; a human-facing CLI report reads
164
+ * better, and diffs better across runs, sorted.
165
+ *
166
+ * @param {string} dir - an already-resolved, existing directory path
167
+ * @returns {string[]} absolute file paths, unsorted (caller sorts across the full batch)
168
+ */
169
+ export function walkYamlFiles(dir) {
170
+ const out = [];
171
+ for (const entry of readdirSync(dir)) {
172
+ const full = join(dir, entry);
173
+ if (statSync(full).isDirectory()) out.push(...walkYamlFiles(full));
174
+ else if (entry.endsWith('.yaml') || entry.endsWith('.yml')) out.push(full);
175
+ }
176
+ return out;
177
+ }
178
+
124
179
  /** Render the human (default) view of a lint result — verdict-first, ERROR before WARNING. */
125
180
  export function renderLintText(result, file) {
126
181
  const lines = [];
@@ -172,42 +227,51 @@ export function buildLintJson(result, file) {
172
227
  };
173
228
  }
174
229
 
230
+ /** Render the batch summary footer printed after every per-file block in
231
+ * directory-or-multi-path mode (F-49940082). */
232
+ export function renderBatchSummary(summary) {
233
+ const { total, clean, withErrors, withWarnings } = summary;
234
+ return [
235
+ '-'.repeat(60),
236
+ `BATCH SUMMARY: ${total} file(s) linted -- ${clean} clean, ${withWarnings} with warnings, ${withErrors} with errors`,
237
+ ].join('\n');
238
+ }
239
+
175
240
  /**
176
- * Run the lint subcommand. Returns the exit code (does not call process.exit) so it is
177
- * unit-testable with an injected stdout/stderr sink.
178
- *
179
- * @param {string[]} argv - args AFTER the `lint` verb
180
- * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void }} [io]
181
- * @returns {Promise<number>}
241
+ * Build the machine-readable (--json) result for batch mode (F-49940082). Each entry in
242
+ * `files` has the exact shape buildLintJson produces for a single file, so a consumer
243
+ * written against the single-file --json shape only needs to index into `.files[i]`
244
+ * instead of the top-level object. There is no pre-existing batch JSON consumer to match
245
+ * instead: scripts/lint-policies.test.mjs (the only other batch-shaped lint caller in this
246
+ * repo) bypasses the CLI's JSON output entirely and calls lintPolicy() directly — that
247
+ * bypass is F-49940082's own subject, not a shape for this to imitate.
182
248
  */
183
- export async function runLint(argv, io = {}) {
184
- const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
185
- const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
186
-
187
- let opts;
188
- try {
189
- opts = parseLintArgs(argv);
190
- } catch (e) {
191
- err(`ERROR: ${e.message}`);
192
- if (e.hint) err(` hint: ${e.hint}`);
193
- return 2;
194
- }
195
-
196
- if (opts.help) {
197
- out(LINT_USAGE);
198
- return 0;
199
- }
249
+ export function buildBatchLintJson(files, summary) {
250
+ return {
251
+ mode: 'batch',
252
+ ok: summary.withErrors === 0,
253
+ summary,
254
+ files,
255
+ };
256
+ }
200
257
 
201
- const path = resolve(opts.file);
202
- const kind = opts.scenario ? 'scenario' : 'policy';
203
- let raw;
204
- try {
205
- raw = readFileSync(path, 'utf-8');
206
- } catch (e) {
207
- err(`ERROR: could not read ${kind} file: ${path} ${e.message}`);
208
- err(' hint: check the path exists and is readable');
209
- return 2;
210
- }
258
+ /**
259
+ * Read, parse, and lint ONE file — the primitive shared by the legacy single-file path
260
+ * and batch mode (F-49940082). Deliberately does not catch a read failure: the two
261
+ * callers give it different severity. The legacy path treats "the one named file cannot
262
+ * be read" as an operator mistake (exit 2, and it aborts — there is nothing else to try).
263
+ * Batch mode treats "one file among several cannot be read" as a per-file finding folded
264
+ * into the batch (see unreadableResult) so one bad path does not hide the results for
265
+ * every other path given alongside it. A YAML parse failure, by contrast, IS handled
266
+ * here and returned as an ok:false result — that has always been a lint finding, not an
267
+ * operator error, in both modes.
268
+ *
269
+ * @param {string} path - an already-resolved file path
270
+ * @param {{ scenario: boolean }} opts
271
+ * @returns {{ ok: boolean, origin: string, errors: object[], warnings: object[], coverageNote: string }}
272
+ */
273
+ function lintOneFile(path, opts) {
274
+ const raw = readFileSync(path, 'utf-8');
211
275
 
212
276
  let doc;
213
277
  try {
@@ -217,7 +281,7 @@ export async function runLint(argv, io = {}) {
217
281
  // Mirrors the policy path exactly, only differing in the origin/label/coverageNote so the
218
282
  // scenario report reads as a scenario report.
219
283
  const where = e && e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
220
- const result = opts.scenario
284
+ return opts.scenario
221
285
  ? {
222
286
  ok: false,
223
287
  origin: 'scenario',
@@ -242,13 +306,164 @@ export async function runLint(argv, io = {}) {
242
306
  warnings: [],
243
307
  coverageNote: COVERAGE_NOTE,
244
308
  };
245
- out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
246
- return 1;
247
309
  }
248
310
 
249
- const result = opts.scenario
311
+ return opts.scenario
250
312
  ? lintScenario(doc, { file: path })
251
313
  : lintPolicy(doc, { origin: originForPath(path) });
252
- out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
253
- return result.ok ? 0 : 1;
314
+ }
315
+
316
+ /**
317
+ * Build a lint-result-shaped entry for a batch-mode path that could not be read at all
318
+ * (missing, a permission fault, or any other readFileSync failure). `detail` is the real
319
+ * underlying Error#message from that failed read — not a guessed ENOENT string — so the
320
+ * report matches what the OS actually said, the same way the legacy single-file operator
321
+ * error always has.
322
+ *
323
+ * @param {string} path
324
+ * @param {{ scenario: boolean }} opts
325
+ * @param {string} detail - e.message from the failed readFileSync
326
+ */
327
+ function unreadableResult(path, opts, detail) {
328
+ const kind = opts.scenario ? 'scenario' : 'policy';
329
+ const message = `could not read ${kind} file: ${path} — ${detail}`;
330
+ return opts.scenario
331
+ ? {
332
+ ok: false,
333
+ origin: 'scenario',
334
+ errors: [{ label: 'scenario-schema:', code: 'path_unreadable', location: '/', message }],
335
+ warnings: [],
336
+ coverageNote: SCENARIO_COVERAGE_NOTE,
337
+ }
338
+ : {
339
+ ok: false,
340
+ origin: originForPath(path),
341
+ errors: [{ label: 'policy-schema:', code: 'path_unreadable', location: '/', message }],
342
+ warnings: [],
343
+ coverageNote: COVERAGE_NOTE,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * stat a path without throwing. `null` means "does not exist, or could not be stat'd for
349
+ * any other reason" — the caller never needs to know which, because either way the path
350
+ * is not a confirmed directory, so it is handled as a candidate file, and any real fault
351
+ * (missing, permission, ...) surfaces through the normal readFileSync path with its own
352
+ * accurate message instead of a guess made here.
353
+ */
354
+ function safeStat(path) {
355
+ try {
356
+ return statSync(path);
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Run the lint subcommand. Returns the exit code (does not call process.exit) so it is
364
+ * unit-testable with an injected stdout/stderr sink.
365
+ *
366
+ * Routing (F-49940082): a single positional that does not resolve to an existing
367
+ * directory takes the legacy single-file path below, unchanged in every statement and
368
+ * every error message from before directory-or-multi-path mode existed. Anything else —
369
+ * two or more positionals, or the sole positional resolving to a directory — is batch
370
+ * mode: every positional is either walked (if a directory) or linted directly (if a
371
+ * file), results are aggregated, and a batch summary is printed after the per-file
372
+ * blocks. See the module docstring for the discovery-semantics parity statement against
373
+ * scripts/lint-policies.test.mjs's policyFiles().
374
+ *
375
+ * @param {string[]} argv - args AFTER the `lint` verb
376
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void }} [io]
377
+ * @returns {Promise<number>}
378
+ */
379
+ export async function runLint(argv, io = {}) {
380
+ const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
381
+ const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
382
+
383
+ let opts;
384
+ try {
385
+ opts = parseLintArgs(argv);
386
+ } catch (e) {
387
+ err(`ERROR: ${e.message}`);
388
+ if (e.hint) err(` hint: ${e.hint}`);
389
+ return 2;
390
+ }
391
+
392
+ if (opts.help) {
393
+ out(LINT_USAGE);
394
+ return 0;
395
+ }
396
+
397
+ const resolvedPaths = opts.paths.map((p) => resolve(p));
398
+ const soleStat = resolvedPaths.length === 1 ? safeStat(resolvedPaths[0]) : null;
399
+ const isBatch = resolvedPaths.length > 1 || (soleStat !== null && soleStat.isDirectory());
400
+
401
+ if (!isBatch) {
402
+ // ── legacy single-file path — byte-identical to pre-F-49940082 behavior. Same
403
+ // statements, same order, same error text; only factored through lintOneFile so
404
+ // batch mode below can share it instead of duplicating the read/parse/lint sequence. ──
405
+ const path = resolvedPaths[0];
406
+ const kind = opts.scenario ? 'scenario' : 'policy';
407
+ let result;
408
+ try {
409
+ result = lintOneFile(path, opts);
410
+ } catch (e) {
411
+ err(`ERROR: could not read ${kind} file: ${path} — ${e.message}`);
412
+ err(' hint: check the path exists and is readable');
413
+ return 2;
414
+ }
415
+ out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
416
+ return result.ok ? 0 : 1;
417
+ }
418
+
419
+ // ── batch mode (F-49940082): directory-or-multi-path ──
420
+ const kind = opts.scenario ? 'scenario' : 'policy';
421
+ const discovered = new Set();
422
+ for (const p of resolvedPaths) {
423
+ const st = safeStat(p);
424
+ if (st !== null && st.isDirectory()) {
425
+ for (const f of walkYamlFiles(p)) discovered.add(f);
426
+ } else {
427
+ // Not a confirmed directory: a file, a missing path, or anything else safeStat
428
+ // could not classify. Hand it to lintOneFile as a candidate — a genuinely missing
429
+ // or unreadable path surfaces its real OS error there, not a guess made here.
430
+ discovered.add(p);
431
+ }
432
+ }
433
+ const files = [...discovered].sort((a, b) => a.localeCompare(b));
434
+
435
+ if (files.length === 0) {
436
+ // Every positional was a confirmed directory and none contributed a file to lint.
437
+ // A lint that finds nothing and exits 0 is a vacuous pass — fail loud instead of
438
+ // silently doing nothing (swarms/CLAUDE.md: "a gate that can't go red is theater").
439
+ err(`ERROR: no ${kind} files found under: ${resolvedPaths.join(', ')}`);
440
+ err(' hint: an empty directory, or one with no yaml files, has nothing to lint');
441
+ return 2;
442
+ }
443
+
444
+ const summary = { total: files.length, clean: 0, withErrors: 0, withWarnings: 0 };
445
+ const textBlocks = [];
446
+ const jsonFiles = [];
447
+
448
+ for (const path of files) {
449
+ let result;
450
+ try {
451
+ result = lintOneFile(path, opts);
452
+ } catch (e) {
453
+ result = unreadableResult(path, opts, e.message);
454
+ }
455
+
456
+ if (result.errors.length) summary.withErrors++;
457
+ else if (result.warnings.length) summary.withWarnings++;
458
+ else summary.clean++;
459
+
460
+ if (opts.json) jsonFiles.push(buildLintJson(result, path));
461
+ else textBlocks.push(renderLintText(result, path));
462
+ }
463
+
464
+ out(opts.json
465
+ ? JSON.stringify(buildBatchLintJson(jsonFiles, summary))
466
+ : `${textBlocks.join('\n\n')}\n\n${renderBatchSummary(summary)}`);
467
+
468
+ return summary.withErrors > 0 ? 1 : 0;
254
469
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -314,13 +314,24 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
314
314
  const ciReqs = surfacePolicy.ci_requirements;
315
315
  if (!ciReqs) continue;
316
316
 
317
- if (ciReqs.tests_must_pass && submission.ci_checks) {
318
- const failingTests = submission.ci_checks.filter(
319
- c => c.kind === 'test' && c.status === 'fail'
320
- );
321
- if (failingTests.length > 0) {
322
- const ids = failingTests.map(c => c.id).join(', ');
323
- errors.push(`surface[${surface}]: CI tests must pass but [${ids}] failed`);
317
+ // F-3b34d51e: mirror coverage_min's missing-data contract. Pre-fix the
318
+ // gate was `if (tests_must_pass && submission.ci_checks)` — omitting the
319
+ // optional ci_checks field (or sending [] / no kind:'test' entries)
320
+ // skipped the branch entirely and policy-validated clean under a
321
+ // tests_must_pass:true surface. Require at least one kind:'test' check
322
+ // whose status is not 'fail'; reject absent/empty/no-test-kind evidence.
323
+ if (ciReqs.tests_must_pass) {
324
+ const testChecks = (submission.ci_checks || []).filter(c => c.kind === 'test');
325
+ if (testChecks.length === 0) {
326
+ errors.push(
327
+ `surface[${surface}]: tests_must_pass is true but no kind:test CI check provided`
328
+ );
329
+ } else {
330
+ const failingTests = testChecks.filter(c => c.status === 'fail');
331
+ if (failingTests.length > 0) {
332
+ const ids = failingTests.map(c => c.id).join(', ');
333
+ errors.push(`surface[${surface}]: CI tests must pass but [${ids}] failed`);
334
+ }
324
335
  }
325
336
  }
326
337
 
@@ -68,6 +68,16 @@ export function computeVerdict(proposed, context) {
68
68
  downgrade_reasons.push('policy validation failed');
69
69
  }
70
70
 
71
+ // F-a0a4d806: non-empty rejection reasons are a fail floor. verify() sets
72
+ // status from reasons.length > 0, but overall_verdict.verified previously
73
+ // ignored the already-plumbed `reasons` argument — a steps[...] or
74
+ // repo:mismatch rejection could leave verified:'pass' next to
75
+ // status:'rejected'. Treat reasons as evidence, not a dual signal.
76
+ if (Array.isArray(reasons) && reasons.length > 0) {
77
+ floorVerdict = 'fail';
78
+ downgrade_reasons.push('non-empty rejection reasons force fail');
79
+ }
80
+
71
81
  // The verified verdict is the worse of proposed and floor
72
82
  // (we never upgrade, so if proposed is worse than floor, keep proposed)
73
83
  if (proposed && VERDICT_RANK[proposed] == null) {