@applesnort/crosscheck 0.3.0 → 0.7.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,11 @@
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
23
25
  // crosscheck lenses [--lenses dir,dir] [--no-builtin]
24
26
  // crosscheck report [--in run.json] [--baseline b.json]
25
27
  // crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
@@ -29,6 +31,13 @@
29
31
  //
30
32
  // Options: --overlap <file> independence data from `overlap` (report/sarif)
31
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
32
41
  // --config <file> config file (default: nearest .crosscheckrc.json,
33
42
  // searching upward and stopping at a repo root)
34
43
  //
@@ -44,29 +53,40 @@
44
53
  // crosscheck run lib/ --exec 'my-wrapper --json' --concurrency 2
45
54
  // Use --dry-run to print the roster and prompts without spawning anything.
46
55
 
47
- import { spawn } from 'node:child_process';
56
+ import { spawn, spawnSync } from 'node:child_process';
48
57
  import {
49
- existsSync, readFileSync, readdirSync, statSync, writeFileSync
58
+ existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync
50
59
  } from 'node:fs';
51
- import { join, relative, resolve } from 'node:path';
60
+ import { dirname, join, relative, resolve } from 'node:path';
52
61
  import { formatScore, score } from '../lib/calibrate.mjs';
53
62
  import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
54
63
  import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
55
64
  import {
56
- countsBySeverity, lensOverlap, mergeFindings, panelVerdict
65
+ applyVerdicts, countsBySeverity, lensOverlap, mergeFindings, panelVerdict
57
66
  } from '../lib/merge.mjs';
58
67
  import { filterAgainstBaseline, staleBaselineEntries, toBaseline }
59
68
  from '../lib/baseline.mjs';
60
69
  import { parseReports } from '../lib/parse.mjs';
61
- 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';
62
75
  import { toSarifJson } from '../lib/sarif.mjs';
76
+ import { diffCommand, targetFromDiff, withContext } from '../lib/target.mjs';
63
77
 
64
78
  function fail(message) {
65
79
  process.stderr.write(`crosscheck: ${message}\n`);
66
80
  process.exit(2);
67
81
  }
68
82
 
69
- const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed', 'no-builtin']);
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
+ ]);
70
90
 
71
91
  function parseArgs(argv) {
72
92
  const [command, ...rest] = argv;
@@ -163,26 +183,49 @@ function lensSources(option, { includeBuiltin = true } = {}) {
163
183
  if (dirs.length === 0) {
164
184
  fail('no lens directories to load (--no-builtin with no --lenses?)');
165
185
  }
166
- return dirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
186
+ // The same directory can arrive twice — ./lenses is auto-detected, and running
187
+ // from that project also names it via --lenses. Loading it twice makes every
188
+ // lens in it shadow itself, which reads as a configuration mistake that is not
189
+ // one. Keep the last occurrence, so an explicit --lenses still wins on order.
190
+ const seen = new Map();
191
+ for (const dir of dirs) {
192
+ seen.set(dir, true);
193
+ }
194
+ const uniqueDirs = [...seen.keys()];
195
+ const sources = uniqueDirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
196
+ if (sources.every(source => source.lenses.length === 0)) {
197
+ fail(`no usable lens definitions found in: ${uniqueDirs.join(', ')}`);
198
+ }
199
+ return sources;
167
200
  }
168
201
 
202
+ // Documentation files that live alongside lenses and are not lens attempts.
203
+ const NOT_A_LENS = /^(README|CONTRIBUTING|NOTES)\.md$/i;
204
+
205
+ // Returns whatever lenses the directory holds, possibly none. A source with no
206
+ // lenses is normal once sources layer — a project's own directory may hold only a
207
+ // README while the packaged lenses do the work. Failing here would make an empty
208
+ // local directory fatal; the combined set is what has to be non-empty, and
209
+ // lensSources checks that.
169
210
  function loadLenses(dir) {
170
211
  const files = readdirSync(dir).filter(f => f.endsWith('.md'));
171
212
  const lenses = [];
172
213
  for (const file of files) {
214
+ if (NOT_A_LENS.test(file)) {
215
+ continue;
216
+ }
173
217
  const path = join(dir, file);
174
218
  const text = readFileSync(path, 'utf8');
175
219
  const meta = parseFrontmatter(text);
176
220
  if (!meta?.name) {
221
+ // Named, because a lens silently ignored for a malformed header is a
222
+ // coverage hole that looks like a working roster.
177
223
  process.stderr.write(
178
- `crosscheck: skipping ${file} — no frontmatter with a name\n`);
224
+ `crosscheck: ignoring ${file} — no frontmatter with a name\n`);
179
225
  continue;
180
226
  }
181
227
  lenses.push({ ...meta, definition: text, definitionPath: path });
182
228
  }
183
- if (lenses.length === 0) {
184
- fail(`no usable lens definitions in ${dir}`);
185
- }
186
229
  return lenses;
187
230
  }
188
231
 
@@ -222,6 +265,63 @@ function collectFiles(targets) {
222
265
 
223
266
  // Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
224
267
  // about which model or framework produced the text.
268
+ // Run git and return stdout. A failure here is fatal: a diff-scoped review that
269
+ // silently falls back to reviewing everything would cost far more than intended.
270
+ function git(args) {
271
+ const result = spawnSync('git', args, { encoding: 'utf8' });
272
+ if (result.error) {
273
+ fail(`could not run git: ${result.error.message}`);
274
+ }
275
+ if (result.status !== 0) {
276
+ fail(`git ${args.join(' ')} failed: ${String(result.stderr).trim()}`);
277
+ }
278
+ return result.stdout;
279
+ }
280
+
281
+ // A preflight command lets a project impose its own gate — data classification,
282
+ // a clean worktree, a branch policy — without crosscheck knowing what the rule
283
+ // is. Non-zero aborts before any model is called.
284
+ function runPreflight(commandLine) {
285
+ process.stderr.write(`crosscheck: preflight ${commandLine}\n`);
286
+ const result = spawnSync(commandLine, { shell: true, encoding: 'utf8' });
287
+ if (result.error) {
288
+ fail(`preflight could not run: ${result.error.message}`);
289
+ }
290
+ if (result.status !== 0) {
291
+ process.stderr.write(String(result.stdout ?? ''));
292
+ process.stderr.write(String(result.stderr ?? ''));
293
+ fail(`preflight failed (exit ${result.status}); nothing was dispatched`);
294
+ }
295
+ }
296
+
297
+ // A disk-backed cache under .crosscheck/cache. Disabled entirely by --no-cache,
298
+ // in which case nothing is read or written.
299
+ function buildCache(options) {
300
+ if (options['no-cache']) {
301
+ return createCache();
302
+ }
303
+ const dir = resolve(options['cache-dir'] ?? '.crosscheck/cache');
304
+ return createCache({
305
+ read: key => {
306
+ const path = join(dir, `${key}.json`);
307
+ if (!existsSync(path)) {
308
+ return null;
309
+ }
310
+ try {
311
+ return JSON.parse(readFileSync(path, 'utf8'));
312
+ } catch {
313
+ // A corrupt entry is a miss, not a crash: the run should proceed and
314
+ // simply pay for that lens again.
315
+ return null;
316
+ }
317
+ },
318
+ write: (key, entry) => {
319
+ mkdirSync(dir, { recursive: true });
320
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify(entry, null, 2));
321
+ }
322
+ });
323
+ }
324
+
225
325
  function execCommand(commandLine) {
226
326
  return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
227
327
  const child = spawn(commandLine, {
@@ -255,7 +355,7 @@ function buildMerged(options) {
255
355
  };
256
356
  }
257
357
 
258
- function report({ merged, suppressed, stale }) {
358
+ function report({ merged, suppressed, stale, refuted = [] }) {
259
359
  const counts = countsBySeverity(merged.findings);
260
360
  const out = [];
261
361
  out.push('# Crosscheck report');
@@ -270,6 +370,9 @@ function report({ merged, suppressed, stale }) {
270
370
  out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
271
371
  'either fixed, or a lens stopped running.');
272
372
  }
373
+ // Always stated, including zero: a finding that vanished without a count is
374
+ // indistinguishable from one that was never found.
375
+ out.push('', `Refuted in verification: ${refuted.length}.`);
273
376
  if (merged.unparsed.length) {
274
377
  out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
275
378
  `(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
@@ -285,7 +388,12 @@ function report({ merged, suppressed, stale }) {
285
388
  const who = f.consensus
286
389
  ? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
287
390
  : f.lenses.join(', ');
288
- out.push(`- [${who}] ${f.file}:${f.line} ${f.issue}` +
391
+ // More reports than lenses means nearby similar findings were collapsed;
392
+ // say so and give the lines, or the entry understates what was reported.
393
+ const collapsed = f.occurrences > f.lenses.length
394
+ ? ` (${f.occurrences} reports across lines ${f.lines.join(', ')})`
395
+ : '';
396
+ out.push(`- [${who}] ${f.file}:${f.line}${collapsed} — ${f.issue}` +
289
397
  (f.fix ? ` — ${f.fix}` : ''));
290
398
  }
291
399
  }
@@ -336,18 +444,195 @@ function loadConfig(explicitPath) {
336
444
  return { config, path };
337
445
  }
338
446
 
447
+ const INIT_CONFIG = `{
448
+ "// exec": "any command that takes a lens prompt on stdin and returns findings",
449
+ "exec": "claude -p",
450
+ "concurrency": 2,
451
+ "// context": "lines of surrounding code given to a lens around each change",
452
+ "context": 20
453
+ }
454
+ `;
455
+
456
+ const INIT_LENS_README = `# Project lenses
457
+
458
+ Markdown files here are added to the packaged lenses. A file whose \`name\`
459
+ matches a packaged lens overrides it, and the override is printed on every run.
460
+
461
+ Run \`crosscheck lenses\` to see what resolved and where each lens came from.
462
+
463
+ A lens needs five frontmatter keys and a body:
464
+
465
+ ---
466
+ name: house-rules
467
+ summary: conventions this team actually enforces
468
+ when: [**/*.{js,mjs}]
469
+ owns: violations of our written conventions
470
+ not-owns: correctness, security, architecture, usability
471
+ ---
472
+
473
+ # Lens: house-rules
474
+
475
+ ...what to look for...
476
+
477
+ Findings only, one per line: \`file:line — SEVERITY — issue — fix\`.
478
+ SEVERITY is BLOCK, FIX, or CONSIDER. Reply exactly \`NO FINDINGS\` if none.
479
+
480
+ Nothing in this directory is published or uploaded by crosscheck. It is read off
481
+ disk at dispatch and goes nowhere else, so a lens here can encode conventions,
482
+ domain detail, or house rules that would make no sense upstream.
483
+ `;
484
+
485
+ const INIT_WORKFLOW = `name: crosscheck
486
+
487
+ on:
488
+ pull_request:
489
+
490
+ permissions:
491
+ contents: read
492
+ # Required to upload SARIF to code scanning.
493
+ security-events: write
494
+ # Required to post the summary comment.
495
+ pull-requests: write
496
+
497
+ concurrency:
498
+ group: crosscheck-\${{ github.ref }}
499
+ cancel-in-progress: true
500
+
501
+ jobs:
502
+ review:
503
+ runs-on: ubuntu-latest
504
+ steps:
505
+ - uses: actions/checkout@v4
506
+ with:
507
+ # crosscheck reviews a diff, so it needs the base commit too.
508
+ fetch-depth: 0
509
+
510
+ - uses: actions/setup-node@v4
511
+ with:
512
+ node-version: '22.x'
513
+
514
+ # EDIT THIS STEP. crosscheck never talks to a model, so whatever your
515
+ # \`exec\` command names has to exist on the runner. Nothing here is
516
+ # installed for you, and a missing command fails every lens with ENOENT.
517
+ #
518
+ # Claude Code: npm i -g @anthropic-ai/claude-code (exec: claude -p)
519
+ # llm: pipx install llm (exec: llm -m ...)
520
+ # your own: whatever installs it
521
+ - name: Install the model CLI
522
+ run: npm i -g @anthropic-ai/claude-code
523
+
524
+ - name: Review the change
525
+ env:
526
+ # Whatever your exec command needs to authenticate. crosscheck reads no
527
+ # credentials of its own.
528
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
529
+ run: |
530
+ npx @applesnort/crosscheck run \\
531
+ --since origin/\${{ github.base_ref }} \\
532
+ --sarif crosscheck.sarif \\
533
+ --comment-file comment.md
534
+
535
+ - name: Upload SARIF
536
+ if: always() && hashFiles('crosscheck.sarif') != ''
537
+ uses: github/codeql-action/upload-sarif@v3
538
+ with:
539
+ sarif_file: crosscheck.sarif
540
+
541
+ - name: Post or update the summary comment
542
+ if: always() && hashFiles('comment.md') != ''
543
+ env:
544
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
545
+ run: |
546
+ # Edit our previous comment rather than stacking a new one.
547
+ id=$(gh api "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
548
+ --jq '[.[] | select(.body | contains("<!-- crosscheck:report -->"))] | last | .id // empty')
549
+ if [ -n "$id" ]; then
550
+ gh api --method PATCH "repos/\${{ github.repository }}/issues/comments/$id" \\
551
+ -F body=@comment.md
552
+ else
553
+ gh api --method POST "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
554
+ -F body=@comment.md
555
+ fi
556
+ `;
557
+
558
+ // Scaffold, without overwriting anything. A tool that silently replaces a config
559
+ // someone tuned is worse than one that refuses.
560
+ function initCommand(options) {
561
+ const force = Boolean(options.force);
562
+ const targets = [
563
+ { path: '.crosscheckrc.json', content: INIT_CONFIG },
564
+ { path: '.crosscheck/lenses/README.md', content: INIT_LENS_README },
565
+ { path: '.github/workflows/crosscheck.yml', content: INIT_WORKFLOW }
566
+ ];
567
+ const written = [];
568
+ const kept = [];
569
+ for (const { path, content } of targets) {
570
+ const full = resolve(path);
571
+ if (existsSync(full) && !force) {
572
+ kept.push(path);
573
+ continue;
574
+ }
575
+ mkdirSync(dirname(full), { recursive: true });
576
+ writeFileSync(full, content);
577
+ written.push(path);
578
+ }
579
+ const out = [];
580
+ if (written.length) {
581
+ out.push('Created:', ...written.map(p => ` ${p}`));
582
+ }
583
+ if (kept.length) {
584
+ out.push('Left alone (already present; --force overwrites):',
585
+ ...kept.map(p => ` ${p}`));
586
+ }
587
+ out.push('',
588
+ 'Next: set "exec" in .crosscheckrc.json to the command that runs your model,',
589
+ 'then try a dry run:',
590
+ '',
591
+ ' crosscheck run --diff --dry-run',
592
+ '');
593
+ process.stdout.write(out.join('\n'));
594
+ }
595
+
339
596
  async function runCommand(cliOptions, positional) {
340
597
  const { config, path: configPath } = loadConfig(cliOptions.config);
341
598
  const options = mergeConfig(config, cliOptions);
342
599
  if (configPath) {
343
600
  process.stderr.write(`crosscheck: config ${configPath}\n`);
344
601
  }
345
- if (positional.length === 0) {
346
- fail('run needs at least one path to audit');
602
+ if (options.preflight) {
603
+ runPreflight(options.preflight);
604
+ }
605
+ const diffMode = options.staged || options.since != null ||
606
+ options.diff != null;
607
+ if (positional.length === 0 && !diffMode) {
608
+ fail('run needs a path to audit, or --diff / --staged / --since <ref>');
609
+ }
610
+
611
+ // Resolve the target first: a bad path or an empty diff is the more
612
+ // fundamental error, and reporting a missing flag instead sends the user after
613
+ // the wrong problem.
614
+ let files;
615
+ let rangesByFile = null;
616
+ if (diffMode) {
617
+ const cmd = diffCommand({
618
+ diff: options.diff, staged: options.staged, since: options.since
619
+ });
620
+ process.stderr.write(`crosscheck: git ${cmd.join(' ')}\n`);
621
+ const target = targetFromDiff(git(cmd));
622
+ if (target.files.length === 0) {
623
+ process.stderr.write(
624
+ 'crosscheck: the diff contains no reviewable changes — nothing to do\n');
625
+ return;
626
+ }
627
+ files = target.files;
628
+ // Widen to give a lens the surrounding code. A defect introduced by a change
629
+ // is often only visible against the lines the change did not touch.
630
+ const context = Number(options.context ?? 20);
631
+ rangesByFile = Object.fromEntries(Object.entries(target.rangesByFile)
632
+ .map(([file, ranges]) => [file, withContext(ranges, context)]));
633
+ } else {
634
+ files = collectFiles(positional);
347
635
  }
348
- // Resolve the target first: a bad path is the more fundamental error, and
349
- // reporting a missing flag instead sends the user after the wrong problem.
350
- const files = collectFiles(positional);
351
636
  if (!options.exec && !options['dry-run']) {
352
637
  fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
353
638
  }
@@ -380,7 +665,10 @@ async function runCommand(cliOptions, positional) {
380
665
  fail('no lens matched the target; nothing to run');
381
666
  }
382
667
 
383
- const promptOptions = { mixedCorpus: Boolean(options.mixed) };
668
+ const promptOptions = {
669
+ mixedCorpus: Boolean(options.mixed),
670
+ rangesByFile
671
+ };
384
672
 
385
673
  if (options['dry-run']) {
386
674
  for (const job of promptsFor(roster, promptOptions)) {
@@ -390,17 +678,57 @@ async function runCommand(cliOptions, positional) {
390
678
  return;
391
679
  }
392
680
 
393
- const { reports, failures } = await runPanel({
681
+ // Each lens may run under its own command, so dispatch resolves per lens
682
+ // rather than sharing one executor.
683
+ const byLens = new Map(roster.map(l => [l.name, l]));
684
+ const dispatch = async ({ prompt, lens, files }) => {
685
+ const commandLine = resolveExec(byLens.get(lens), options.exec);
686
+ if (!commandLine) {
687
+ throw new Error(
688
+ `no exec for lens "${lens}" — set exec, or an exec map entry for it`);
689
+ }
690
+ return execCommand(commandLine)({ prompt, lens, files });
691
+ };
692
+
693
+ const cache = buildCache(options);
694
+ const { reports, failures, dropped, cacheStats } = await runPanel({
394
695
  roster,
395
696
  skipped,
396
- exec: execCommand(options.exec),
697
+ exec: dispatch,
397
698
  concurrency: Number(options.concurrency ?? 4),
398
699
  promptOptions,
700
+ cache: cache.enabled ? cache : null,
701
+ cacheKeyFor: cache.enabled
702
+ ? job => cacheKey({
703
+ lens: job.lens,
704
+ definition: byLens.get(job.lens)?.definition,
705
+ files: job.files.map(path => ({
706
+ path,
707
+ content: existsSync(path) ? readFileSync(path, 'utf8') : ''
708
+ })),
709
+ promptOptions
710
+ })
711
+ : null,
712
+ maxDispatches: options['max-dispatches'] == null
713
+ ? null : Number(options['max-dispatches']),
399
714
  onLensStart: lens => process.stderr.write(` → ${lens}\n`),
400
715
  onLensDone: (lens, r) => process.stderr.write(
401
- r.ok ? ` ✓ ${lens} (${r.findings} finding(s))\n` : ` ✗ ${lens} did not complete\n`)
716
+ r.ok
717
+ ? ` ✓ ${lens} (${r.findings} finding(s))${r.cached ? ' [cached]' : ''}\n`
718
+ : ` ✗ ${lens} did not complete\n`)
402
719
  });
403
720
 
721
+ // Both of these are coverage holes, and this tool states its holes.
722
+ if (dropped.length) {
723
+ process.stderr.write(
724
+ `crosscheck: BUDGET REACHED — ${dropped.length} lens(es) not run: ` +
725
+ `${dropped.map(d => d.lens).join(', ')}\n`);
726
+ }
727
+ if (cacheStats?.hits) {
728
+ process.stderr.write(
729
+ `crosscheck: ${cacheStats.hits} lens(es) served from cache\n`);
730
+ }
731
+
404
732
  for (const f of failures) {
405
733
  process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
406
734
  }
@@ -416,6 +744,39 @@ async function runCommand(cliOptions, positional) {
416
744
 
417
745
  const overlap = loadJson(options.overlap) ?? undefined;
418
746
  let merged = mergeFindings(reports, { overlap });
747
+
748
+ // Verification is on by default for BLOCK findings: false positives cost more
749
+ // than misses, because a panel that cries wolf stops being read at all.
750
+ let refuted = [];
751
+ const verifyWanted = options['no-verify'] ? false : true;
752
+ if (verifyWanted) {
753
+ const candidates = merged.findings.filter(f =>
754
+ options.verify ? true : f.severity === 'BLOCK');
755
+ if (candidates.length > 0) {
756
+ process.stderr.write(
757
+ `crosscheck: verifying ${candidates.length} finding(s)\n`);
758
+ const { verdicts, failures: verifyFailures } = await verifyFindings({
759
+ findings: candidates,
760
+ exec: execCommand(options.exec),
761
+ concurrency: Number(options.concurrency ?? 4),
762
+ onVerdict: (f, v) => process.stderr.write(
763
+ ` ${v.refuted ? '✗ refuted' : '✓ confirmed'} ${f.file}:${f.line}\n`)
764
+ });
765
+ for (const f of verifyFailures) {
766
+ // A verifier that did not run is not agreement; the finding stands.
767
+ process.stderr.write(
768
+ `crosscheck: verifier failed for ${f.finding} — ${f.reason}; ` +
769
+ 'the finding is kept\n');
770
+ }
771
+ const applied = applyVerdicts(merged.findings, verdicts);
772
+ refuted = applied.refuted;
773
+ merged = { ...merged, findings: applied.findings };
774
+ if (refuted.length) {
775
+ process.stderr.write(
776
+ `crosscheck: ${refuted.length} finding(s) refuted and removed\n`);
777
+ }
778
+ }
779
+ }
419
780
  let suppressed = [];
420
781
  let stale = [];
421
782
  const baseline = loadJson(options.baseline);
@@ -426,11 +787,27 @@ async function runCommand(cliOptions, positional) {
426
787
  merged = { ...merged, findings: filtered.findings };
427
788
  }
428
789
 
429
- process.stdout.write(report({ merged, suppressed, stale }));
790
+ process.stdout.write(report({ merged, suppressed, stale, refuted }));
791
+
792
+ if (options['comment-file']) {
793
+ writeFileSync(options['comment-file'], buildComment({
794
+ merged,
795
+ refuted,
796
+ suppressed,
797
+ dropped,
798
+ skipped,
799
+ target: diffMode
800
+ ? `${files.length} changed file(s)`
801
+ : `${files.length} file(s)`,
802
+ sarifPath: options.sarif ?? null
803
+ }));
804
+ process.stderr.write(`crosscheck: wrote ${options['comment-file']}\n`);
805
+ }
430
806
 
431
807
  if (options.sarif) {
432
808
  writeFileSync(options.sarif, toSarifJson(merged, {
433
- lensMeta: Object.fromEntries(lenses.map(l => [l.name, l]))
809
+ lensMeta: Object.fromEntries(lenses.map(l => [l.name, l])),
810
+ refuted
434
811
  }));
435
812
  process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
436
813
  }
@@ -457,6 +834,11 @@ async function main() {
457
834
  return;
458
835
  }
459
836
 
837
+ if (command === 'init') {
838
+ initCommand(options);
839
+ return;
840
+ }
841
+
460
842
  if (command === 'lenses') {
461
843
  const sources = lensSources(options.lenses,
462
844
  { includeBuiltin: !options['no-builtin'] });
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: architect
3
3
  summary: structure, data shape, coupling, and reversibility of decisions
4
- when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql}, "**/migrations/**", "**/schema*"]
4
+ when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql,vue,svelte}, "**/migrations/**", "**/schema*"]
5
5
  owns: couplings and lock-in that make later change expensive
6
6
  not-owns: line-level correctness, style, security categories, usability
7
7
  cites: []
package/lenses/check.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: check
3
3
  summary: correctness — boundaries, absent values, error paths, concurrency
4
- when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
4
+ when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
5
5
  owns: defects that produce wrong behavior at runtime
6
6
  not-owns: style, naming, architecture, security categories, usability
7
7
  cites: []
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: security-check
3
3
  summary: application security — trust boundaries, injection, secrets, exposure
4
- when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql}]
4
+ when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql,vue,svelte}]
5
5
  owns: exploitable weaknesses reachable by an untrusted or under-privileged caller
6
6
  not-owns: general correctness, architecture preference, usability, styling
7
7
  cites: ["OWASP Top 10 (2021)", "OWASP ASVS", "CWE"]
package/lenses/taint.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: taint
3
3
  summary: data flow from untrusted origin to dangerous operation, and what sanitises it
4
- when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
4
+ when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
5
5
  owns: untrusted values reaching an operation that interprets them, unsanitised
6
6
  not-owns: security policy, authentication design, crypto choice, correctness, architecture, usability
7
7
  cites: []