@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.
- package/README.md +138 -4
- package/bin/crosscheck.mjs +388 -25
- package/lib/cache.mjs +100 -0
- package/lib/comment.mjs +138 -0
- package/lib/config.mjs +30 -6
- package/lib/merge.mjs +7 -1
- package/lib/prompt.mjs +87 -1
- package/lib/run.mjs +106 -4
- package/lib/target.mjs +123 -0
- package/package.json +5 -2
package/bin/crosscheck.mjs
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// {"lens": "ux", "output": null}] <- null means the lens died
|
|
18
18
|
//
|
|
19
19
|
// Usage:
|
|
20
|
-
// crosscheck run <path
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
346
|
-
|
|
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 = {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|