@applesnort/crosscheck 0.2.2 → 0.6.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.
@@ -17,9 +17,12 @@
17
17
  // {"lens": "ux", "output": null}] <- null means the lens died
18
18
  //
19
19
  // Usage:
20
- // crosscheck run <path...> --exec '<command>' [--lenses dir] [--only a,b]
20
+ // crosscheck run <path...|--diff|--staged|--since <ref>>
21
+ // --exec '<command>' [--lenses dir] [--only a,b]
21
22
  // [--skip x,y] [--concurrency N] [--out run.json]
22
23
  // [--sarif f] [--baseline b] [--mixed] [--dry-run]
24
+ // crosscheck init [--force] scaffold config, lenses, workflow
25
+ // crosscheck lenses [--lenses dir,dir] [--no-builtin]
23
26
  // crosscheck report [--in run.json] [--baseline b.json]
24
27
  // crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
25
28
  // crosscheck baseline [--in run.json] --out baseline.json
@@ -28,6 +31,13 @@
28
31
  //
29
32
  // Options: --overlap <file> independence data from `overlap` (report/sarif)
30
33
  // --lenses <dir> lens directory (routing + SARIF rule metadata)
34
+ // --max-dispatches N cap lens runs; dropped lenses are named, never
35
+ // silently omitted. crosscheck cannot see tokens or
36
+ // money (--exec is any command), so dispatches are
37
+ // the only unit it can honestly cap.
38
+ // --comment-file <f> write a pull-request summary comment
39
+ // --no-cache do not read or write .crosscheck/cache
40
+ // --cache-dir <dir> relocate the cache
31
41
  // --config <file> config file (default: nearest .crosscheckrc.json,
32
42
  // searching upward and stopping at a repo root)
33
43
  //
@@ -43,29 +53,40 @@
43
53
  // crosscheck run lib/ --exec 'my-wrapper --json' --concurrency 2
44
54
  // Use --dry-run to print the roster and prompts without spawning anything.
45
55
 
46
- import { spawn } from 'node:child_process';
56
+ import { spawn, spawnSync } from 'node:child_process';
47
57
  import {
48
- existsSync, readFileSync, readdirSync, statSync, writeFileSync
58
+ existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync
49
59
  } from 'node:fs';
50
- import { join, relative, resolve } from 'node:path';
60
+ import { dirname, join, relative, resolve } from 'node:path';
51
61
  import { formatScore, score } from '../lib/calibrate.mjs';
52
62
  import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
53
- import { parseFrontmatter } from '../lib/lenses.mjs';
63
+ import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
54
64
  import {
55
- countsBySeverity, lensOverlap, mergeFindings, panelVerdict
65
+ applyVerdicts, countsBySeverity, lensOverlap, mergeFindings, panelVerdict
56
66
  } from '../lib/merge.mjs';
57
67
  import { filterAgainstBaseline, staleBaselineEntries, toBaseline }
58
68
  from '../lib/baseline.mjs';
59
69
  import { parseReports } from '../lib/parse.mjs';
60
- import { planRun, promptsFor, runPanel } from '../lib/run.mjs';
70
+ import {
71
+ planRun, promptsFor, resolveExec, runPanel, verifyFindings
72
+ } from '../lib/run.mjs';
73
+ import { cacheKey, createCache } from '../lib/cache.mjs';
74
+ import { buildComment } from '../lib/comment.mjs';
61
75
  import { toSarifJson } from '../lib/sarif.mjs';
76
+ import { diffCommand, targetFromDiff, withContext } from '../lib/target.mjs';
62
77
 
63
78
  function fail(message) {
64
79
  process.stderr.write(`crosscheck: ${message}\n`);
65
80
  process.exit(2);
66
81
  }
67
82
 
68
- const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed']);
83
+ // --diff is boolean rather than taking an optional ref: `run --diff src/` would
84
+ // otherwise be ambiguous about whether src/ is a ref or a path. Use --since <ref>
85
+ // to compare against something.
86
+ const BOOLEAN_FLAGS = new Set([
87
+ 'dry-run', 'mixed', 'no-builtin', 'staged', 'verify', 'no-verify', 'diff',
88
+ 'no-cache', 'force'
89
+ ]);
69
90
 
70
91
  function parseArgs(argv) {
71
92
  const [command, ...rest] = argv;
@@ -132,37 +153,70 @@ function loadLensMeta(dir) {
132
153
  return meta;
133
154
  }
134
155
 
135
- // Lens directory: an explicit --lenses, else ./lenses, else the copy shipped
136
- // with the package. Resolved loudly so a typo does not silently run zero lenses.
137
- function resolveLensDir(dir) {
138
- const candidates = dir
139
- ? [resolve(dir)]
140
- : [resolve('lenses'), new URL('../lenses/', import.meta.url).pathname];
141
- for (const candidate of candidates) {
142
- if (existsSync(candidate)) {
143
- return candidate;
156
+ const BUILTIN_LENS_DIR = new URL('../lenses/', import.meta.url).pathname;
157
+
158
+ // Lens sources, in increasing precedence: the packaged lenses, then ./lenses or
159
+ // .crosscheck/lenses if present, then anything named by --lenses. Layering
160
+ // rather than replacing means adding one lens costs one file instead of forking
161
+ // all of them and losing upstream changes.
162
+ function lensSources(option, { includeBuiltin = true } = {}) {
163
+ const dirs = [];
164
+ if (includeBuiltin) {
165
+ dirs.push(BUILTIN_LENS_DIR);
166
+ }
167
+ for (const local of ['lenses', '.crosscheck/lenses']) {
168
+ const path = resolve(local);
169
+ if (existsSync(path) && path !== resolve(BUILTIN_LENS_DIR)) {
170
+ dirs.push(path);
171
+ }
172
+ }
173
+ const explicit = Array.isArray(option)
174
+ ? option
175
+ : (option ? String(option).split(',').map(d => d.trim()).filter(Boolean) : []);
176
+ for (const dir of explicit) {
177
+ const path = resolve(dir);
178
+ if (!existsSync(path)) {
179
+ fail(`no such lens directory: ${dir}`);
144
180
  }
181
+ dirs.push(path);
182
+ }
183
+ if (dirs.length === 0) {
184
+ fail('no lens directories to load (--no-builtin with no --lenses?)');
145
185
  }
146
- fail(`no lens directory found (looked in ${candidates.join(', ')})`);
186
+ const sources = dirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
187
+ if (sources.every(source => source.lenses.length === 0)) {
188
+ fail(`no usable lens definitions found in: ${dirs.join(', ')}`);
189
+ }
190
+ return sources;
147
191
  }
148
192
 
193
+ // Documentation files that live alongside lenses and are not lens attempts.
194
+ const NOT_A_LENS = /^(README|CONTRIBUTING|NOTES)\.md$/i;
195
+
196
+ // Returns whatever lenses the directory holds, possibly none. A source with no
197
+ // lenses is normal once sources layer — a project's own directory may hold only a
198
+ // README while the packaged lenses do the work. Failing here would make an empty
199
+ // local directory fatal; the combined set is what has to be non-empty, and
200
+ // lensSources checks that.
149
201
  function loadLenses(dir) {
150
202
  const files = readdirSync(dir).filter(f => f.endsWith('.md'));
151
203
  const lenses = [];
152
204
  for (const file of files) {
205
+ if (NOT_A_LENS.test(file)) {
206
+ continue;
207
+ }
153
208
  const path = join(dir, file);
154
209
  const text = readFileSync(path, 'utf8');
155
210
  const meta = parseFrontmatter(text);
156
211
  if (!meta?.name) {
212
+ // Named, because a lens silently ignored for a malformed header is a
213
+ // coverage hole that looks like a working roster.
157
214
  process.stderr.write(
158
- `crosscheck: skipping ${file} — no frontmatter with a name\n`);
215
+ `crosscheck: ignoring ${file} — no frontmatter with a name\n`);
159
216
  continue;
160
217
  }
161
218
  lenses.push({ ...meta, definition: text, definitionPath: path });
162
219
  }
163
- if (lenses.length === 0) {
164
- fail(`no usable lens definitions in ${dir}`);
165
- }
166
220
  return lenses;
167
221
  }
168
222
 
@@ -202,6 +256,63 @@ function collectFiles(targets) {
202
256
 
203
257
  // Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
204
258
  // about which model or framework produced the text.
259
+ // Run git and return stdout. A failure here is fatal: a diff-scoped review that
260
+ // silently falls back to reviewing everything would cost far more than intended.
261
+ function git(args) {
262
+ const result = spawnSync('git', args, { encoding: 'utf8' });
263
+ if (result.error) {
264
+ fail(`could not run git: ${result.error.message}`);
265
+ }
266
+ if (result.status !== 0) {
267
+ fail(`git ${args.join(' ')} failed: ${String(result.stderr).trim()}`);
268
+ }
269
+ return result.stdout;
270
+ }
271
+
272
+ // A preflight command lets a project impose its own gate — data classification,
273
+ // a clean worktree, a branch policy — without crosscheck knowing what the rule
274
+ // is. Non-zero aborts before any model is called.
275
+ function runPreflight(commandLine) {
276
+ process.stderr.write(`crosscheck: preflight ${commandLine}\n`);
277
+ const result = spawnSync(commandLine, { shell: true, encoding: 'utf8' });
278
+ if (result.error) {
279
+ fail(`preflight could not run: ${result.error.message}`);
280
+ }
281
+ if (result.status !== 0) {
282
+ process.stderr.write(String(result.stdout ?? ''));
283
+ process.stderr.write(String(result.stderr ?? ''));
284
+ fail(`preflight failed (exit ${result.status}); nothing was dispatched`);
285
+ }
286
+ }
287
+
288
+ // A disk-backed cache under .crosscheck/cache. Disabled entirely by --no-cache,
289
+ // in which case nothing is read or written.
290
+ function buildCache(options) {
291
+ if (options['no-cache']) {
292
+ return createCache();
293
+ }
294
+ const dir = resolve(options['cache-dir'] ?? '.crosscheck/cache');
295
+ return createCache({
296
+ read: key => {
297
+ const path = join(dir, `${key}.json`);
298
+ if (!existsSync(path)) {
299
+ return null;
300
+ }
301
+ try {
302
+ return JSON.parse(readFileSync(path, 'utf8'));
303
+ } catch {
304
+ // A corrupt entry is a miss, not a crash: the run should proceed and
305
+ // simply pay for that lens again.
306
+ return null;
307
+ }
308
+ },
309
+ write: (key, entry) => {
310
+ mkdirSync(dir, { recursive: true });
311
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify(entry, null, 2));
312
+ }
313
+ });
314
+ }
315
+
205
316
  function execCommand(commandLine) {
206
317
  return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
207
318
  const child = spawn(commandLine, {
@@ -235,7 +346,7 @@ function buildMerged(options) {
235
346
  };
236
347
  }
237
348
 
238
- function report({ merged, suppressed, stale }) {
349
+ function report({ merged, suppressed, stale, refuted = [] }) {
239
350
  const counts = countsBySeverity(merged.findings);
240
351
  const out = [];
241
352
  out.push('# Crosscheck report');
@@ -250,6 +361,9 @@ function report({ merged, suppressed, stale }) {
250
361
  out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
251
362
  'either fixed, or a lens stopped running.');
252
363
  }
364
+ // Always stated, including zero: a finding that vanished without a count is
365
+ // indistinguishable from one that was never found.
366
+ out.push('', `Refuted in verification: ${refuted.length}.`);
253
367
  if (merged.unparsed.length) {
254
368
  out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
255
369
  `(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
@@ -265,7 +379,12 @@ function report({ merged, suppressed, stale }) {
265
379
  const who = f.consensus
266
380
  ? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
267
381
  : f.lenses.join(', ');
268
- out.push(`- [${who}] ${f.file}:${f.line} ${f.issue}` +
382
+ // More reports than lenses means nearby similar findings were collapsed;
383
+ // say so and give the lines, or the entry understates what was reported.
384
+ const collapsed = f.occurrences > f.lenses.length
385
+ ? ` (${f.occurrences} reports across lines ${f.lines.join(', ')})`
386
+ : '';
387
+ out.push(`- [${who}] ${f.file}:${f.line}${collapsed} — ${f.issue}` +
269
388
  (f.fix ? ` — ${f.fix}` : ''));
270
389
  }
271
390
  }
@@ -316,29 +435,203 @@ function loadConfig(explicitPath) {
316
435
  return { config, path };
317
436
  }
318
437
 
438
+ const INIT_CONFIG = `{
439
+ "// exec": "any command that takes a lens prompt on stdin and returns findings",
440
+ "exec": "claude -p",
441
+ "concurrency": 2,
442
+ "// context": "lines of surrounding code given to a lens around each change",
443
+ "context": 20
444
+ }
445
+ `;
446
+
447
+ const INIT_LENS_README = `# Project lenses
448
+
449
+ Markdown files here are added to the packaged lenses. A file whose \`name\`
450
+ matches a packaged lens overrides it, and the override is printed on every run.
451
+
452
+ Run \`crosscheck lenses\` to see what resolved and where each lens came from.
453
+
454
+ A lens needs five frontmatter keys and a body:
455
+
456
+ ---
457
+ name: house-rules
458
+ summary: conventions this team actually enforces
459
+ when: [**/*.{js,mjs}]
460
+ owns: violations of our written conventions
461
+ not-owns: correctness, security, architecture, usability
462
+ ---
463
+
464
+ # Lens: house-rules
465
+
466
+ ...what to look for...
467
+
468
+ Findings only, one per line: \`file:line — SEVERITY — issue — fix\`.
469
+ SEVERITY is BLOCK, FIX, or CONSIDER. Reply exactly \`NO FINDINGS\` if none.
470
+
471
+ Nothing in this directory is published or uploaded by crosscheck. It is read off
472
+ disk at dispatch and goes nowhere else, so a lens here can encode conventions,
473
+ domain detail, or house rules that would make no sense upstream.
474
+ `;
475
+
476
+ const INIT_WORKFLOW = `name: crosscheck
477
+
478
+ on:
479
+ pull_request:
480
+
481
+ permissions:
482
+ contents: read
483
+ # Required to upload SARIF to code scanning.
484
+ security-events: write
485
+ # Required to post the summary comment.
486
+ pull-requests: write
487
+
488
+ concurrency:
489
+ group: crosscheck-\${{ github.ref }}
490
+ cancel-in-progress: true
491
+
492
+ jobs:
493
+ review:
494
+ runs-on: ubuntu-latest
495
+ steps:
496
+ - uses: actions/checkout@v4
497
+ with:
498
+ # crosscheck reviews a diff, so it needs the base commit too.
499
+ fetch-depth: 0
500
+
501
+ - uses: actions/setup-node@v4
502
+ with:
503
+ node-version: '22.x'
504
+
505
+ - name: Review the change
506
+ env:
507
+ # Provide whatever your --exec command needs. crosscheck itself never
508
+ # talks to a model.
509
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
510
+ run: |
511
+ npx @applesnort/crosscheck run \\
512
+ --since origin/\${{ github.base_ref }} \\
513
+ --sarif crosscheck.sarif \\
514
+ --comment-file comment.md
515
+
516
+ - name: Upload SARIF
517
+ if: always() && hashFiles('crosscheck.sarif') != ''
518
+ uses: github/codeql-action/upload-sarif@v3
519
+ with:
520
+ sarif_file: crosscheck.sarif
521
+
522
+ - name: Post or update the summary comment
523
+ if: always() && hashFiles('comment.md') != ''
524
+ env:
525
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
526
+ run: |
527
+ # Edit our previous comment rather than stacking a new one.
528
+ id=$(gh api "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
529
+ --jq '[.[] | select(.body | contains("<!-- crosscheck:report -->"))] | last | .id // empty')
530
+ if [ -n "$id" ]; then
531
+ gh api --method PATCH "repos/\${{ github.repository }}/issues/comments/$id" \\
532
+ -F body=@comment.md
533
+ else
534
+ gh api --method POST "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
535
+ -F body=@comment.md
536
+ fi
537
+ `;
538
+
539
+ // Scaffold, without overwriting anything. A tool that silently replaces a config
540
+ // someone tuned is worse than one that refuses.
541
+ function initCommand(options) {
542
+ const force = Boolean(options.force);
543
+ const targets = [
544
+ { path: '.crosscheckrc.json', content: INIT_CONFIG },
545
+ { path: '.crosscheck/lenses/README.md', content: INIT_LENS_README },
546
+ { path: '.github/workflows/crosscheck.yml', content: INIT_WORKFLOW }
547
+ ];
548
+ const written = [];
549
+ const kept = [];
550
+ for (const { path, content } of targets) {
551
+ const full = resolve(path);
552
+ if (existsSync(full) && !force) {
553
+ kept.push(path);
554
+ continue;
555
+ }
556
+ mkdirSync(dirname(full), { recursive: true });
557
+ writeFileSync(full, content);
558
+ written.push(path);
559
+ }
560
+ const out = [];
561
+ if (written.length) {
562
+ out.push('Created:', ...written.map(p => ` ${p}`));
563
+ }
564
+ if (kept.length) {
565
+ out.push('Left alone (already present; --force overwrites):',
566
+ ...kept.map(p => ` ${p}`));
567
+ }
568
+ out.push('',
569
+ 'Next: set "exec" in .crosscheckrc.json to the command that runs your model,',
570
+ 'then try a dry run:',
571
+ '',
572
+ ' crosscheck run --diff --dry-run',
573
+ '');
574
+ process.stdout.write(out.join('\n'));
575
+ }
576
+
319
577
  async function runCommand(cliOptions, positional) {
320
578
  const { config, path: configPath } = loadConfig(cliOptions.config);
321
579
  const options = mergeConfig(config, cliOptions);
322
580
  if (configPath) {
323
581
  process.stderr.write(`crosscheck: config ${configPath}\n`);
324
582
  }
325
- if (positional.length === 0) {
326
- fail('run needs at least one path to audit');
583
+ if (options.preflight) {
584
+ runPreflight(options.preflight);
585
+ }
586
+ const diffMode = options.staged || options.since != null ||
587
+ options.diff != null;
588
+ if (positional.length === 0 && !diffMode) {
589
+ fail('run needs a path to audit, or --diff / --staged / --since <ref>');
590
+ }
591
+
592
+ // Resolve the target first: a bad path or an empty diff is the more
593
+ // fundamental error, and reporting a missing flag instead sends the user after
594
+ // the wrong problem.
595
+ let files;
596
+ let rangesByFile = null;
597
+ if (diffMode) {
598
+ const cmd = diffCommand({
599
+ diff: options.diff, staged: options.staged, since: options.since
600
+ });
601
+ process.stderr.write(`crosscheck: git ${cmd.join(' ')}\n`);
602
+ const target = targetFromDiff(git(cmd));
603
+ if (target.files.length === 0) {
604
+ process.stderr.write(
605
+ 'crosscheck: the diff contains no reviewable changes — nothing to do\n');
606
+ return;
607
+ }
608
+ files = target.files;
609
+ // Widen to give a lens the surrounding code. A defect introduced by a change
610
+ // is often only visible against the lines the change did not touch.
611
+ const context = Number(options.context ?? 20);
612
+ rangesByFile = Object.fromEntries(Object.entries(target.rangesByFile)
613
+ .map(([file, ranges]) => [file, withContext(ranges, context)]));
614
+ } else {
615
+ files = collectFiles(positional);
327
616
  }
328
- // Resolve the target first: a bad path is the more fundamental error, and
329
- // reporting a missing flag instead sends the user after the wrong problem.
330
- const files = collectFiles(positional);
331
617
  if (!options.exec && !options['dry-run']) {
332
618
  fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
333
619
  }
334
- const lensDir = resolveLensDir(options.lenses);
335
- const lenses = loadLenses(lensDir);
620
+ const sources = lensSources(options.lenses,
621
+ { includeBuiltin: !options['no-builtin'] });
622
+ const { lenses, shadowed } = resolveLensSet(sources);
623
+ const lensDir = sources.at(-1).origin;
624
+ for (const s of shadowed) {
625
+ process.stderr.write(
626
+ `crosscheck: lens "${s.name}" from ${s.winner} overrides ${s.shadowedFrom}\n`);
627
+ }
336
628
  // mergeConfig has already normalised these to arrays from either source.
337
629
  const overrides = { only: options.only, skip: options.skip };
338
630
  const { roster, skipped, unmatched } = planRun(lenses, files, overrides);
339
631
 
340
632
  process.stderr.write(
341
- `crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
633
+ `crosscheck: ${files.length} file(s), ${lenses.length} lens(es) from ` +
634
+ `${sources.length} source(s)\n` +
342
635
  ` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
343
636
  (skipped.length
344
637
  ? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
@@ -353,7 +646,10 @@ async function runCommand(cliOptions, positional) {
353
646
  fail('no lens matched the target; nothing to run');
354
647
  }
355
648
 
356
- const promptOptions = { mixedCorpus: Boolean(options.mixed) };
649
+ const promptOptions = {
650
+ mixedCorpus: Boolean(options.mixed),
651
+ rangesByFile
652
+ };
357
653
 
358
654
  if (options['dry-run']) {
359
655
  for (const job of promptsFor(roster, promptOptions)) {
@@ -363,17 +659,57 @@ async function runCommand(cliOptions, positional) {
363
659
  return;
364
660
  }
365
661
 
366
- const { reports, failures } = await runPanel({
662
+ // Each lens may run under its own command, so dispatch resolves per lens
663
+ // rather than sharing one executor.
664
+ const byLens = new Map(roster.map(l => [l.name, l]));
665
+ const dispatch = async ({ prompt, lens, files }) => {
666
+ const commandLine = resolveExec(byLens.get(lens), options.exec);
667
+ if (!commandLine) {
668
+ throw new Error(
669
+ `no exec for lens "${lens}" — set exec, or an exec map entry for it`);
670
+ }
671
+ return execCommand(commandLine)({ prompt, lens, files });
672
+ };
673
+
674
+ const cache = buildCache(options);
675
+ const { reports, failures, dropped, cacheStats } = await runPanel({
367
676
  roster,
368
677
  skipped,
369
- exec: execCommand(options.exec),
678
+ exec: dispatch,
370
679
  concurrency: Number(options.concurrency ?? 4),
371
680
  promptOptions,
681
+ cache: cache.enabled ? cache : null,
682
+ cacheKeyFor: cache.enabled
683
+ ? job => cacheKey({
684
+ lens: job.lens,
685
+ definition: byLens.get(job.lens)?.definition,
686
+ files: job.files.map(path => ({
687
+ path,
688
+ content: existsSync(path) ? readFileSync(path, 'utf8') : ''
689
+ })),
690
+ promptOptions
691
+ })
692
+ : null,
693
+ maxDispatches: options['max-dispatches'] == null
694
+ ? null : Number(options['max-dispatches']),
372
695
  onLensStart: lens => process.stderr.write(` → ${lens}\n`),
373
696
  onLensDone: (lens, r) => process.stderr.write(
374
- r.ok ? ` ✓ ${lens} (${r.findings} finding(s))\n` : ` ✗ ${lens} did not complete\n`)
697
+ r.ok
698
+ ? ` ✓ ${lens} (${r.findings} finding(s))${r.cached ? ' [cached]' : ''}\n`
699
+ : ` ✗ ${lens} did not complete\n`)
375
700
  });
376
701
 
702
+ // Both of these are coverage holes, and this tool states its holes.
703
+ if (dropped.length) {
704
+ process.stderr.write(
705
+ `crosscheck: BUDGET REACHED — ${dropped.length} lens(es) not run: ` +
706
+ `${dropped.map(d => d.lens).join(', ')}\n`);
707
+ }
708
+ if (cacheStats?.hits) {
709
+ process.stderr.write(
710
+ `crosscheck: ${cacheStats.hits} lens(es) served from cache\n`);
711
+ }
712
+
377
713
  for (const f of failures) {
378
714
  process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
379
715
  }
@@ -389,6 +725,39 @@ async function runCommand(cliOptions, positional) {
389
725
 
390
726
  const overlap = loadJson(options.overlap) ?? undefined;
391
727
  let merged = mergeFindings(reports, { overlap });
728
+
729
+ // Verification is on by default for BLOCK findings: false positives cost more
730
+ // than misses, because a panel that cries wolf stops being read at all.
731
+ let refuted = [];
732
+ const verifyWanted = options['no-verify'] ? false : true;
733
+ if (verifyWanted) {
734
+ const candidates = merged.findings.filter(f =>
735
+ options.verify ? true : f.severity === 'BLOCK');
736
+ if (candidates.length > 0) {
737
+ process.stderr.write(
738
+ `crosscheck: verifying ${candidates.length} finding(s)\n`);
739
+ const { verdicts, failures: verifyFailures } = await verifyFindings({
740
+ findings: candidates,
741
+ exec: execCommand(options.exec),
742
+ concurrency: Number(options.concurrency ?? 4),
743
+ onVerdict: (f, v) => process.stderr.write(
744
+ ` ${v.refuted ? '✗ refuted' : '✓ confirmed'} ${f.file}:${f.line}\n`)
745
+ });
746
+ for (const f of verifyFailures) {
747
+ // A verifier that did not run is not agreement; the finding stands.
748
+ process.stderr.write(
749
+ `crosscheck: verifier failed for ${f.finding} — ${f.reason}; ` +
750
+ 'the finding is kept\n');
751
+ }
752
+ const applied = applyVerdicts(merged.findings, verdicts);
753
+ refuted = applied.refuted;
754
+ merged = { ...merged, findings: applied.findings };
755
+ if (refuted.length) {
756
+ process.stderr.write(
757
+ `crosscheck: ${refuted.length} finding(s) refuted and removed\n`);
758
+ }
759
+ }
760
+ }
392
761
  let suppressed = [];
393
762
  let stale = [];
394
763
  const baseline = loadJson(options.baseline);
@@ -399,11 +768,27 @@ async function runCommand(cliOptions, positional) {
399
768
  merged = { ...merged, findings: filtered.findings };
400
769
  }
401
770
 
402
- process.stdout.write(report({ merged, suppressed, stale }));
771
+ process.stdout.write(report({ merged, suppressed, stale, refuted }));
772
+
773
+ if (options['comment-file']) {
774
+ writeFileSync(options['comment-file'], buildComment({
775
+ merged,
776
+ refuted,
777
+ suppressed,
778
+ dropped,
779
+ skipped,
780
+ target: diffMode
781
+ ? `${files.length} changed file(s)`
782
+ : `${files.length} file(s)`,
783
+ sarifPath: options.sarif ?? null
784
+ }));
785
+ process.stderr.write(`crosscheck: wrote ${options['comment-file']}\n`);
786
+ }
403
787
 
404
788
  if (options.sarif) {
405
789
  writeFileSync(options.sarif, toSarifJson(merged, {
406
- lensMeta: loadLensMeta(lensDir)
790
+ lensMeta: Object.fromEntries(lenses.map(l => [l.name, l])),
791
+ refuted
407
792
  }));
408
793
  process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
409
794
  }
@@ -430,6 +815,27 @@ async function main() {
430
815
  return;
431
816
  }
432
817
 
818
+ if (command === 'init') {
819
+ initCommand(options);
820
+ return;
821
+ }
822
+
823
+ if (command === 'lenses') {
824
+ const sources = lensSources(options.lenses,
825
+ { includeBuiltin: !options['no-builtin'] });
826
+ const { lenses, shadowed } = resolveLensSet(sources);
827
+ const out = [`${lenses.length} lens(es) from ${sources.length} source(s):`];
828
+ for (const lens of lenses) {
829
+ out.push(` ${lens.name.padEnd(16)} ${lens.origin}`);
830
+ out.push(` when: ${(lens.when ?? []).join(', ')}`);
831
+ }
832
+ for (const s of shadowed) {
833
+ out.push(` override: "${s.name}" from ${s.winner} shadows ${s.shadowedFrom}`);
834
+ }
835
+ process.stdout.write(out.join('\n') + '\n');
836
+ return;
837
+ }
838
+
433
839
  if (command === 'report') {
434
840
  write(options, report(buildMerged(options)));
435
841
  return;