@applesnort/crosscheck 0.3.0 → 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,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,40 @@ 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
+ 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;
167
191
  }
168
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.
169
201
  function loadLenses(dir) {
170
202
  const files = readdirSync(dir).filter(f => f.endsWith('.md'));
171
203
  const lenses = [];
172
204
  for (const file of files) {
205
+ if (NOT_A_LENS.test(file)) {
206
+ continue;
207
+ }
173
208
  const path = join(dir, file);
174
209
  const text = readFileSync(path, 'utf8');
175
210
  const meta = parseFrontmatter(text);
176
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.
177
214
  process.stderr.write(
178
- `crosscheck: skipping ${file} — no frontmatter with a name\n`);
215
+ `crosscheck: ignoring ${file} — no frontmatter with a name\n`);
179
216
  continue;
180
217
  }
181
218
  lenses.push({ ...meta, definition: text, definitionPath: path });
182
219
  }
183
- if (lenses.length === 0) {
184
- fail(`no usable lens definitions in ${dir}`);
185
- }
186
220
  return lenses;
187
221
  }
188
222
 
@@ -222,6 +256,63 @@ function collectFiles(targets) {
222
256
 
223
257
  // Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
224
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
+
225
316
  function execCommand(commandLine) {
226
317
  return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
227
318
  const child = spawn(commandLine, {
@@ -255,7 +346,7 @@ function buildMerged(options) {
255
346
  };
256
347
  }
257
348
 
258
- function report({ merged, suppressed, stale }) {
349
+ function report({ merged, suppressed, stale, refuted = [] }) {
259
350
  const counts = countsBySeverity(merged.findings);
260
351
  const out = [];
261
352
  out.push('# Crosscheck report');
@@ -270,6 +361,9 @@ function report({ merged, suppressed, stale }) {
270
361
  out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
271
362
  'either fixed, or a lens stopped running.');
272
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}.`);
273
367
  if (merged.unparsed.length) {
274
368
  out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
275
369
  `(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
@@ -285,7 +379,12 @@ function report({ merged, suppressed, stale }) {
285
379
  const who = f.consensus
286
380
  ? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
287
381
  : f.lenses.join(', ');
288
- 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}` +
289
388
  (f.fix ? ` — ${f.fix}` : ''));
290
389
  }
291
390
  }
@@ -336,18 +435,185 @@ function loadConfig(explicitPath) {
336
435
  return { config, path };
337
436
  }
338
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
+
339
577
  async function runCommand(cliOptions, positional) {
340
578
  const { config, path: configPath } = loadConfig(cliOptions.config);
341
579
  const options = mergeConfig(config, cliOptions);
342
580
  if (configPath) {
343
581
  process.stderr.write(`crosscheck: config ${configPath}\n`);
344
582
  }
345
- if (positional.length === 0) {
346
- 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);
347
616
  }
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
617
  if (!options.exec && !options['dry-run']) {
352
618
  fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
353
619
  }
@@ -380,7 +646,10 @@ async function runCommand(cliOptions, positional) {
380
646
  fail('no lens matched the target; nothing to run');
381
647
  }
382
648
 
383
- const promptOptions = { mixedCorpus: Boolean(options.mixed) };
649
+ const promptOptions = {
650
+ mixedCorpus: Boolean(options.mixed),
651
+ rangesByFile
652
+ };
384
653
 
385
654
  if (options['dry-run']) {
386
655
  for (const job of promptsFor(roster, promptOptions)) {
@@ -390,17 +659,57 @@ async function runCommand(cliOptions, positional) {
390
659
  return;
391
660
  }
392
661
 
393
- 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({
394
676
  roster,
395
677
  skipped,
396
- exec: execCommand(options.exec),
678
+ exec: dispatch,
397
679
  concurrency: Number(options.concurrency ?? 4),
398
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']),
399
695
  onLensStart: lens => process.stderr.write(` → ${lens}\n`),
400
696
  onLensDone: (lens, r) => process.stderr.write(
401
- 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`)
402
700
  });
403
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
+
404
713
  for (const f of failures) {
405
714
  process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
406
715
  }
@@ -416,6 +725,39 @@ async function runCommand(cliOptions, positional) {
416
725
 
417
726
  const overlap = loadJson(options.overlap) ?? undefined;
418
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
+ }
419
761
  let suppressed = [];
420
762
  let stale = [];
421
763
  const baseline = loadJson(options.baseline);
@@ -426,11 +768,27 @@ async function runCommand(cliOptions, positional) {
426
768
  merged = { ...merged, findings: filtered.findings };
427
769
  }
428
770
 
429
- 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
+ }
430
787
 
431
788
  if (options.sarif) {
432
789
  writeFileSync(options.sarif, toSarifJson(merged, {
433
- lensMeta: Object.fromEntries(lenses.map(l => [l.name, l]))
790
+ lensMeta: Object.fromEntries(lenses.map(l => [l.name, l])),
791
+ refuted
434
792
  }));
435
793
  process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
436
794
  }
@@ -457,6 +815,11 @@ async function main() {
457
815
  return;
458
816
  }
459
817
 
818
+ if (command === 'init') {
819
+ initCommand(options);
820
+ return;
821
+ }
822
+
460
823
  if (command === 'lenses') {
461
824
  const sources = lensSources(options.lenses,
462
825
  { includeBuiltin: !options['no-builtin'] });
package/lib/cache.mjs ADDED
@@ -0,0 +1,100 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Result cache.
5
+ //
6
+ // A panel that re-reviews unchanged files on every run costs money for nothing,
7
+ // and a tool that costs money for nothing gets switched off. Caching is keyed on
8
+ // everything that could change the answer: the lens definition, the files, their
9
+ // contents, and the prompt options.
10
+ //
11
+ // The lens definition is part of the key on purpose. Editing a lens must
12
+ // invalidate its cached results — a cache that survives a prompt change would
13
+ // quietly serve answers from the old lens and there would be no way to tell.
14
+ //
15
+ // Pure key computation; IO is injected so this is testable without a disk.
16
+
17
+ // FNV-1a over the inputs. Not cryptographic: it only has to distinguish inputs
18
+ // within one project, and a collision costs a stale result rather than a
19
+ // security failure.
20
+ export function digest(parts) {
21
+ let h = 0x811c9dc5;
22
+ for (const part of parts) {
23
+ const s = String(part);
24
+ for (let i = 0; i < s.length; i++) {
25
+ h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
26
+ }
27
+ // Separator, so ['ab','c'] and ['a','bc'] do not collide.
28
+ h = Math.imul(h ^ 0x1f, 0x01000193) >>> 0;
29
+ }
30
+ return h.toString(16).padStart(8, '0');
31
+ }
32
+
33
+ export const CACHE_VERSION = 1;
34
+
35
+ // files: [{path, content}] in the order the lens will see them.
36
+ export function cacheKey({ lens, definition, files, promptOptions = {} }) {
37
+ if (!lens) {
38
+ throw new Error('cacheKey requires a lens name');
39
+ }
40
+ return digest([
41
+ `v${CACHE_VERSION}`,
42
+ lens,
43
+ // The definition body decides what the lens does, so it decides the answer.
44
+ definition ?? '',
45
+ ...(files ?? []).flatMap(f => [f.path, f.content ?? '']),
46
+ JSON.stringify(promptOptions ?? {})
47
+ ]);
48
+ }
49
+
50
+ // entry: {key, lens, output, storedAt}
51
+ export function isUsableEntry(entry, key) {
52
+ return Boolean(
53
+ entry &&
54
+ entry.key === key &&
55
+ entry.version === CACHE_VERSION &&
56
+ typeof entry.output === 'string' &&
57
+ entry.output.length > 0);
58
+ }
59
+
60
+ export function makeEntry({ key, lens, output, now }) {
61
+ return {
62
+ version: CACHE_VERSION,
63
+ key,
64
+ lens,
65
+ // Recorded for a human reading the cache directory, not used for matching.
66
+ storedAt: now ?? null,
67
+ output
68
+ };
69
+ }
70
+
71
+ // A cache wired to injected IO. `read` returns a parsed entry or null; `write`
72
+ // persists one. Both may be omitted to disable caching entirely, which is what
73
+ // --no-cache does.
74
+ export function createCache({ read = null, write = null } = {}) {
75
+ const stats = { hits: 0, misses: 0, writes: 0, hitLenses: [] };
76
+ return {
77
+ stats,
78
+ enabled: Boolean(read || write),
79
+ get(key, lens) {
80
+ if (!read) {
81
+ return null;
82
+ }
83
+ const entry = read(key);
84
+ if (isUsableEntry(entry, key)) {
85
+ stats.hits += 1;
86
+ stats.hitLenses.push(lens);
87
+ return entry.output;
88
+ }
89
+ stats.misses += 1;
90
+ return null;
91
+ },
92
+ set(key, lens, output, now = null) {
93
+ if (!write || !output) {
94
+ return;
95
+ }
96
+ write(key, makeEntry({ key, lens, output, now }));
97
+ stats.writes += 1;
98
+ }
99
+ };
100
+ }