@applesnort/crosscheck 0.2.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.
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+ /*!
3
+ * Copyright (c) 2026 Joel Mangin. MIT License.
4
+ */
5
+ // crosscheck — run a review panel, or merge output a panel already produced.
6
+ //
7
+ // `run` dispatches the lenses and reports. The other commands take lens output
8
+ // that already exists and do the deterministic half: parse, merge, dedupe, score
9
+ // consensus, apply a baseline, emit.
10
+ //
11
+ // crosscheck never talks to a model itself. `run --exec` names a command that
12
+ // receives one lens prompt on stdin and returns findings on stdout, so any agent
13
+ // CLI or wrapper works.
14
+ //
15
+ // For the commands below other than `run`, input is JSON on stdin or via --in:
16
+ // [{"lens": "check", "output": "lib/a.js:41 — BLOCK — issue — fix"},
17
+ // {"lens": "ux", "output": null}] <- null means the lens died
18
+ //
19
+ // Usage:
20
+ // crosscheck run <path...> --exec '<command>' [--lenses dir] [--only a,b]
21
+ // [--skip x,y] [--concurrency N] [--out run.json]
22
+ // [--sarif f] [--baseline b] [--mixed] [--dry-run]
23
+ // crosscheck report [--in run.json] [--baseline b.json]
24
+ // crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
25
+ // crosscheck baseline [--in run.json] --out baseline.json
26
+ // crosscheck overlap [--in run.json] [--out overlap.json]
27
+ // crosscheck calibrate [--in run.json] --expected expected.json
28
+ //
29
+ // Options: --overlap <file> independence data from `overlap` (report/sarif)
30
+ // --lenses <dir> lens directory (routing + SARIF rule metadata)
31
+ //
32
+ // `run` dispatches the lenses itself. crosscheck never talks to a model: --exec
33
+ // names a command that receives one lens prompt on stdin and returns findings on
34
+ // stdout, so any agent CLI works. Examples:
35
+ // crosscheck run lib/ --exec 'claude -p'
36
+ // crosscheck run lib/ --exec 'llm -m gpt-4o'
37
+ // crosscheck run lib/ --exec 'my-wrapper --json' --concurrency 2
38
+ // Use --dry-run to print the roster and prompts without spawning anything.
39
+
40
+ import { spawn } from 'node:child_process';
41
+ import {
42
+ existsSync, readFileSync, readdirSync, statSync, writeFileSync
43
+ } from 'node:fs';
44
+ import { join, relative, resolve } from 'node:path';
45
+ import { formatScore, score } from '../lib/calibrate.mjs';
46
+ import { parseFrontmatter } from '../lib/lenses.mjs';
47
+ import {
48
+ countsBySeverity, lensOverlap, mergeFindings, panelVerdict
49
+ } from '../lib/merge.mjs';
50
+ import { filterAgainstBaseline, staleBaselineEntries, toBaseline }
51
+ from '../lib/baseline.mjs';
52
+ import { parseReports } from '../lib/parse.mjs';
53
+ import { planRun, promptsFor, runPanel } from '../lib/run.mjs';
54
+ import { toSarifJson } from '../lib/sarif.mjs';
55
+
56
+ function fail(message) {
57
+ process.stderr.write(`crosscheck: ${message}\n`);
58
+ process.exit(2);
59
+ }
60
+
61
+ const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed']);
62
+
63
+ function parseArgs(argv) {
64
+ const [command, ...rest] = argv;
65
+ const options = {};
66
+ const positional = [];
67
+ for (let i = 0; i < rest.length; i++) {
68
+ const arg = rest[i];
69
+ if (!arg.startsWith('--')) {
70
+ positional.push(arg);
71
+ continue;
72
+ }
73
+ const key = arg.slice(2);
74
+ if (BOOLEAN_FLAGS.has(key)) {
75
+ options[key] = true;
76
+ continue;
77
+ }
78
+ const value = rest[i + 1];
79
+ if (value == null || value.startsWith('--')) {
80
+ fail(`--${key} requires a value`);
81
+ }
82
+ options[key] = value;
83
+ i += 1;
84
+ }
85
+ return { command, options, positional };
86
+ }
87
+
88
+ function readStdin() {
89
+ try {
90
+ return readFileSync(0, 'utf8');
91
+ } catch {
92
+ return '';
93
+ }
94
+ }
95
+
96
+ function loadRun(options) {
97
+ const raw = options.in ? readFileSync(options.in, 'utf8') : readStdin();
98
+ if (!raw.trim()) {
99
+ fail('no input — pass --in <file> or pipe lens output JSON on stdin');
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ } catch (error) {
105
+ fail(`input is not valid JSON: ${error.message}`);
106
+ }
107
+ if (!Array.isArray(parsed)) {
108
+ fail('input must be an array of {lens, output} objects');
109
+ }
110
+ return parsed;
111
+ }
112
+
113
+ function loadJson(path) {
114
+ return path ? JSON.parse(readFileSync(path, 'utf8')) : null;
115
+ }
116
+
117
+ function loadLensMeta(dir) {
118
+ if (!dir) {
119
+ return {};
120
+ }
121
+ const meta = {};
122
+ for (const lens of loadLenses(dir)) {
123
+ meta[lens.name] = lens;
124
+ }
125
+ return meta;
126
+ }
127
+
128
+ // Lens directory: an explicit --lenses, else ./lenses, else the copy shipped
129
+ // with the package. Resolved loudly so a typo does not silently run zero lenses.
130
+ function resolveLensDir(dir) {
131
+ const candidates = dir
132
+ ? [resolve(dir)]
133
+ : [resolve('lenses'), new URL('../lenses/', import.meta.url).pathname];
134
+ for (const candidate of candidates) {
135
+ if (existsSync(candidate)) {
136
+ return candidate;
137
+ }
138
+ }
139
+ fail(`no lens directory found (looked in ${candidates.join(', ')})`);
140
+ }
141
+
142
+ function loadLenses(dir) {
143
+ const files = readdirSync(dir).filter(f => f.endsWith('.md'));
144
+ const lenses = [];
145
+ for (const file of files) {
146
+ const path = join(dir, file);
147
+ const text = readFileSync(path, 'utf8');
148
+ const meta = parseFrontmatter(text);
149
+ if (!meta?.name) {
150
+ process.stderr.write(
151
+ `crosscheck: skipping ${file} — no frontmatter with a name\n`);
152
+ continue;
153
+ }
154
+ lenses.push({ ...meta, definition: text, definitionPath: path });
155
+ }
156
+ if (lenses.length === 0) {
157
+ fail(`no usable lens definitions in ${dir}`);
158
+ }
159
+ return lenses;
160
+ }
161
+
162
+ // Expand the positional targets into a concrete file list. Directories are walked;
163
+ // everything is reported relative to cwd so paths in findings match what the user
164
+ // typed.
165
+ function collectFiles(targets) {
166
+ const out = [];
167
+ const walk = path => {
168
+ const stat = statSync(path);
169
+ if (stat.isDirectory()) {
170
+ for (const entry of readdirSync(path)) {
171
+ if (entry === 'node_modules' || entry.startsWith('.')) {
172
+ continue;
173
+ }
174
+ walk(join(path, entry));
175
+ }
176
+ return;
177
+ }
178
+ out.push(relative(process.cwd(), path) || path);
179
+ };
180
+ for (const target of targets) {
181
+ if (!existsSync(target)) {
182
+ fail(`no such path: ${target}`);
183
+ }
184
+ walk(target);
185
+ }
186
+ if (out.length === 0) {
187
+ fail('the target expanded to zero files');
188
+ }
189
+ return out.sort();
190
+ }
191
+
192
+ // Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
193
+ // about which model or framework produced the text.
194
+ function execCommand(commandLine) {
195
+ return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
196
+ const child = spawn(commandLine, {
197
+ shell: true,
198
+ stdio: ['pipe', 'pipe', 'pipe']
199
+ });
200
+ let stdout = '';
201
+ let stderr = '';
202
+ child.stdout.on('data', d => { stdout += d; });
203
+ child.stderr.on('data', d => { stderr += d; });
204
+ child.on('error', rejectPromise);
205
+ child.on('close', code => resolvePromise({ stdout, stderr, code }));
206
+ child.stdin.on('error', rejectPromise);
207
+ child.stdin.end(prompt);
208
+ });
209
+ }
210
+
211
+ function buildMerged(options) {
212
+ const reports = parseReports(loadRun(options));
213
+ const overlap = loadJson(options.overlap) ?? undefined;
214
+ const merged = mergeFindings(reports, { overlap });
215
+ const baseline = loadJson(options.baseline);
216
+ if (!baseline) {
217
+ return { merged, reports, suppressed: [], stale: [] };
218
+ }
219
+ const { findings, suppressed } =
220
+ filterAgainstBaseline(merged.findings, baseline);
221
+ const stale = staleBaselineEntries(baseline, merged.findings);
222
+ return {
223
+ merged: { ...merged, findings }, reports, suppressed, stale
224
+ };
225
+ }
226
+
227
+ function report({ merged, suppressed, stale }) {
228
+ const counts = countsBySeverity(merged.findings);
229
+ const out = [];
230
+ out.push('# Crosscheck report');
231
+ if (merged.incomplete.length) {
232
+ out.push('', `**Did not complete: ${merged.incomplete.join(', ')}** — ` +
233
+ 'their coverage is missing from this report.');
234
+ }
235
+ if (suppressed.length) {
236
+ out.push('', `Suppressed by baseline: ${suppressed.length}.`);
237
+ }
238
+ if (stale.length) {
239
+ out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
240
+ 'either fixed, or a lens stopped running.');
241
+ }
242
+ if (merged.unparsed.length) {
243
+ out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
244
+ `(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
245
+ }
246
+ for (const severity of ['BLOCK', 'FIX', 'CONSIDER']) {
247
+ const group = merged.findings.filter(f => f.severity === severity);
248
+ out.push('', `## ${severity} (${group.length})`);
249
+ if (group.length === 0) {
250
+ out.push('None.');
251
+ continue;
252
+ }
253
+ for (const f of group) {
254
+ const who = f.consensus
255
+ ? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
256
+ : f.lenses.join(', ');
257
+ out.push(`- [${who}] ${f.file}:${f.line} — ${f.issue}` +
258
+ (f.fix ? ` — ${f.fix}` : ''));
259
+ }
260
+ }
261
+ out.push('', '## Panel verdict',
262
+ `${panelVerdict(counts)} — ${counts.BLOCK} block, ${counts.FIX} fix, ` +
263
+ `${counts.CONSIDER} consider; ` +
264
+ `${merged.findings.filter(f => f.consensus).length} consensus.`);
265
+ return out.join('\n') + '\n';
266
+ }
267
+
268
+ function write(options, text) {
269
+ if (options.out) {
270
+ writeFileSync(options.out, text);
271
+ process.stderr.write(`crosscheck: wrote ${options.out}\n`);
272
+ } else {
273
+ process.stdout.write(text);
274
+ }
275
+ }
276
+
277
+ async function runCommand(options, positional) {
278
+ if (positional.length === 0) {
279
+ fail('run needs at least one path to audit');
280
+ }
281
+ if (!options.exec && !options['dry-run']) {
282
+ fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
283
+ }
284
+ const files = collectFiles(positional);
285
+ const lensDir = resolveLensDir(options.lenses);
286
+ const lenses = loadLenses(lensDir);
287
+ const overrides = {
288
+ only: options.only?.split(',').map(s => s.trim()).filter(Boolean),
289
+ skip: options.skip?.split(',').map(s => s.trim()).filter(Boolean)
290
+ };
291
+ const { roster, skipped } = planRun(lenses, files, overrides);
292
+
293
+ process.stderr.write(
294
+ `crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
295
+ ` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
296
+ (skipped.length
297
+ ? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
298
+ : ''));
299
+
300
+ if (roster.length === 0) {
301
+ fail('no lens matched the target; nothing to run');
302
+ }
303
+
304
+ const promptOptions = { mixedCorpus: Boolean(options.mixed) };
305
+
306
+ if (options['dry-run']) {
307
+ for (const job of promptsFor(roster, promptOptions)) {
308
+ process.stdout.write(
309
+ `\n===== ${job.lens} (${job.files.length} file(s)) =====\n${job.prompt}\n`);
310
+ }
311
+ return;
312
+ }
313
+
314
+ const { reports, failures } = await runPanel({
315
+ roster,
316
+ skipped,
317
+ exec: execCommand(options.exec),
318
+ concurrency: Number(options.concurrency ?? 4),
319
+ promptOptions,
320
+ onLensStart: lens => process.stderr.write(` → ${lens}\n`),
321
+ onLensDone: (lens, r) => process.stderr.write(
322
+ r.ok ? ` ✓ ${lens} (${r.findings} finding(s))\n` : ` ✗ ${lens} did not complete\n`)
323
+ });
324
+
325
+ for (const f of failures) {
326
+ process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
327
+ }
328
+
329
+ // The raw lens output is written out whenever asked, so a run can be rescored
330
+ // later without paying for the model again.
331
+ if (options.out) {
332
+ writeFileSync(options.out, JSON.stringify(
333
+ reports.map(r => ({ lens: r.lens, output: r.output ?? null })),
334
+ null, 2) + '\n');
335
+ process.stderr.write(`crosscheck: wrote ${options.out}\n`);
336
+ }
337
+
338
+ const overlap = loadJson(options.overlap) ?? undefined;
339
+ let merged = mergeFindings(reports, { overlap });
340
+ let suppressed = [];
341
+ let stale = [];
342
+ const baseline = loadJson(options.baseline);
343
+ if (baseline) {
344
+ const filtered = filterAgainstBaseline(merged.findings, baseline);
345
+ stale = staleBaselineEntries(baseline, merged.findings);
346
+ suppressed = filtered.suppressed;
347
+ merged = { ...merged, findings: filtered.findings };
348
+ }
349
+
350
+ process.stdout.write(report({ merged, suppressed, stale }));
351
+
352
+ if (options.sarif) {
353
+ writeFileSync(options.sarif, toSarifJson(merged, {
354
+ lensMeta: loadLensMeta(lensDir)
355
+ }));
356
+ process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
357
+ }
358
+
359
+ // A panel missing a lens has not produced a full review; say so in the exit
360
+ // code as well as the report.
361
+ if (failures.length > 0) {
362
+ process.exit(1);
363
+ }
364
+ }
365
+
366
+ async function main() {
367
+ const { command, options, positional } = parseArgs(process.argv.slice(2));
368
+
369
+ if (!command || command === 'help' || command === '--help') {
370
+ process.stdout.write(readFileSync(new URL(import.meta.url), 'utf8')
371
+ .split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3))
372
+ .join('\n') + '\n');
373
+ return;
374
+ }
375
+
376
+ if (command === 'run') {
377
+ await runCommand(options, positional);
378
+ return;
379
+ }
380
+
381
+ if (command === 'report') {
382
+ write(options, report(buildMerged(options)));
383
+ return;
384
+ }
385
+
386
+ if (command === 'sarif') {
387
+ const { merged } = buildMerged(options);
388
+ write(options, toSarifJson(merged, {
389
+ lensMeta: loadLensMeta(options.lenses)
390
+ }));
391
+ return;
392
+ }
393
+
394
+ if (command === 'baseline') {
395
+ const { merged } = buildMerged({ ...options, baseline: undefined });
396
+ if (!options.out) {
397
+ fail('baseline requires --out <file>');
398
+ }
399
+ write({ out: options.out },
400
+ JSON.stringify(toBaseline(merged.findings, {
401
+ note: 'Findings present before this baseline was taken.'
402
+ }), null, 2) + '\n');
403
+ return;
404
+ }
405
+
406
+ if (command === 'overlap') {
407
+ const reports = parseReports(loadRun(options));
408
+ write(options, JSON.stringify(lensOverlap(reports), null, 2) + '\n');
409
+ return;
410
+ }
411
+
412
+ if (command === 'calibrate') {
413
+ if (!options.expected) {
414
+ fail('calibrate requires --expected <expected.json>');
415
+ }
416
+ const { merged } = buildMerged(options);
417
+ const result = score(merged.findings, loadJson(options.expected));
418
+ process.stdout.write(formatScore(result) + '\n');
419
+ // A panel that missed a planted defect is a failing panel.
420
+ process.exit(result.missed.length > 0 ? 1 : 0);
421
+ }
422
+
423
+ fail(`unknown command: ${command}`);
424
+ }
425
+
426
+ await main();