@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.
- package/README.md +212 -4
- package/bin/crosscheck.mjs +444 -38
- package/lib/cache.mjs +100 -0
- package/lib/comment.mjs +138 -0
- package/lib/config.mjs +30 -6
- package/lib/lenses.mjs +35 -0
- 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,12 @@
|
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
326
|
-
|
|
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
|
|
335
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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;
|