@dogfood-lab/verify 1.10.0 → 1.12.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 +277 -62
- package/package.json +1 -1
- package/validators/policy.js +18 -7
- package/validators/verdict.js +10 -0
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,
|
|
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
|
|
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
|
|
76
|
-
*
|
|
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
|
|
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,
|
|
112
|
+
* @returns {{ help: boolean, paths: string[], json: boolean, scenario: boolean }}
|
|
84
113
|
*/
|
|
85
114
|
export function parseLintArgs(argv) {
|
|
86
|
-
|
|
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
|
-
|
|
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,
|
|
105
|
-
if (
|
|
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,
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
+
return opts.scenario
|
|
250
312
|
? lintScenario(doc, { file: path })
|
|
251
313
|
: lintPolicy(doc, { origin: originForPath(path) });
|
|
252
|
-
|
|
253
|
-
|
|
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
package/validators/policy.js
CHANGED
|
@@ -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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
package/validators/verdict.js
CHANGED
|
@@ -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) {
|