@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.
- package/LICENSE +21 -0
- package/PROVENANCE.md +57 -0
- package/README.md +244 -0
- package/bin/crosscheck.mjs +426 -0
- package/fixtures/calibration/PREREGISTERED.md +522 -0
- package/fixtures/calibration/expected.json +144 -0
- package/fixtures/calibration/src/session.js +123 -0
- package/foreman.md +140 -0
- package/lenses/architect.md +92 -0
- package/lenses/check.md +87 -0
- package/lenses/security-check.md +105 -0
- package/lenses/taint.md +102 -0
- package/lenses/ux.md +105 -0
- package/lib/baseline.mjs +78 -0
- package/lib/calibrate.mjs +169 -0
- package/lib/corpus.mjs +340 -0
- package/lib/lenses.mjs +200 -0
- package/lib/merge.mjs +310 -0
- package/lib/parse.mjs +96 -0
- package/lib/prompt.mjs +85 -0
- package/lib/run.mjs +129 -0
- package/lib/sarif.mjs +176 -0
- package/package.json +53 -0
package/lib/run.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Plan and execute a panel.
|
|
5
|
+
//
|
|
6
|
+
// crosscheck does not talk to a model. It builds the prompts, decides the roster,
|
|
7
|
+
// fans out, and merges what comes back — the caller supplies a command that turns
|
|
8
|
+
// a prompt into text. That keeps the tool agnostic about which agent framework or
|
|
9
|
+
// model you use, and keeps this module testable with a fake executor.
|
|
10
|
+
//
|
|
11
|
+
// The executor contract: exec({prompt, lens}) resolves to
|
|
12
|
+
// {stdout, stderr?, code?}. A non-zero code, a thrown error, or empty stdout all
|
|
13
|
+
// mean that lens did not produce a report — recorded as incomplete, never
|
|
14
|
+
// silently treated as "found nothing".
|
|
15
|
+
|
|
16
|
+
import { buildLensPrompt } from './prompt.mjs';
|
|
17
|
+
import { applyOverrides, routeRoster } from './lenses.mjs';
|
|
18
|
+
import { parseLensOutput } from './parse.mjs';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
21
|
+
|
|
22
|
+
// lenses: [{name, when, owns, 'not-owns', definition?, definitionPath?}]
|
|
23
|
+
// Returns {roster, skipped} — skipped always carries a reason, because a lens
|
|
24
|
+
// dropped without one reads as coverage that never happened.
|
|
25
|
+
export function planRun(lenses, files, overrides = {}) {
|
|
26
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
27
|
+
throw new Error('no files in scope — nothing to audit');
|
|
28
|
+
}
|
|
29
|
+
const routed = routeRoster(lenses, files);
|
|
30
|
+
const { roster, skipped } = applyOverrides(routed, overrides);
|
|
31
|
+
if (overrides.only?.length) {
|
|
32
|
+
const known = new Set((lenses ?? []).map(l => l.name));
|
|
33
|
+
for (const name of overrides.only) {
|
|
34
|
+
if (!known.has(name)) {
|
|
35
|
+
throw new Error(`--only names an unknown lens: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { roster, skipped };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function promptsFor(roster, options = {}) {
|
|
43
|
+
return roster.map(lens => ({
|
|
44
|
+
lens: lens.name,
|
|
45
|
+
files: lens.files,
|
|
46
|
+
prompt: buildLensPrompt(lens, lens.files, {
|
|
47
|
+
definition: lens.definition,
|
|
48
|
+
definitionPath: lens.definitionPath,
|
|
49
|
+
...options
|
|
50
|
+
})
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bounded-concurrency map that preserves input order. Kept local rather than
|
|
55
|
+
// pulling in a dependency for nine lines.
|
|
56
|
+
async function mapLimit(items, limit, worker) {
|
|
57
|
+
const results = new Array(items.length);
|
|
58
|
+
let next = 0;
|
|
59
|
+
const runners = Array.from(
|
|
60
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
61
|
+
async () => {
|
|
62
|
+
while (true) {
|
|
63
|
+
const index = next++;
|
|
64
|
+
if (index >= items.length) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
results[index] = await worker(items[index], index);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
await Promise.all(runners);
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Returns reports in the shape mergeFindings consumes, plus what was skipped and
|
|
75
|
+
// what failed. `findings: null` marks a lens that did not complete.
|
|
76
|
+
export async function runPanel({
|
|
77
|
+
roster,
|
|
78
|
+
skipped = [],
|
|
79
|
+
exec,
|
|
80
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
81
|
+
promptOptions = {},
|
|
82
|
+
onLensStart = null,
|
|
83
|
+
onLensDone = null
|
|
84
|
+
} = {}) {
|
|
85
|
+
if (typeof exec !== 'function') {
|
|
86
|
+
throw new Error('runPanel requires an exec function');
|
|
87
|
+
}
|
|
88
|
+
const jobs = promptsFor(roster ?? [], promptOptions);
|
|
89
|
+
const failures = [];
|
|
90
|
+
|
|
91
|
+
const reports = await mapLimit(jobs, concurrency, async job => {
|
|
92
|
+
onLensStart?.(job.lens);
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await exec({ prompt: job.prompt, lens: job.lens, files: job.files });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failures.push({ lens: job.lens, reason: error?.message ?? String(error) });
|
|
98
|
+
onLensDone?.(job.lens, { ok: false });
|
|
99
|
+
return { lens: job.lens, findings: null, unparsed: [], output: null };
|
|
100
|
+
}
|
|
101
|
+
const code = result?.code ?? 0;
|
|
102
|
+
const stdout = String(result?.stdout ?? '');
|
|
103
|
+
if (code !== 0) {
|
|
104
|
+
failures.push({
|
|
105
|
+
lens: job.lens,
|
|
106
|
+
reason: `exec exited ${code}` +
|
|
107
|
+
(result?.stderr ? `: ${String(result.stderr).trim().slice(0, 500)}` : '')
|
|
108
|
+
});
|
|
109
|
+
onLensDone?.(job.lens, { ok: false });
|
|
110
|
+
return { lens: job.lens, findings: null, unparsed: [], output: null };
|
|
111
|
+
}
|
|
112
|
+
if (stdout.trim() === '') {
|
|
113
|
+
failures.push({
|
|
114
|
+
lens: job.lens,
|
|
115
|
+
reason: 'exec produced no output; a lens with nothing to report must ' +
|
|
116
|
+
'say NO FINDINGS'
|
|
117
|
+
});
|
|
118
|
+
onLensDone?.(job.lens, { ok: false });
|
|
119
|
+
return { lens: job.lens, findings: null, unparsed: [], output: null };
|
|
120
|
+
}
|
|
121
|
+
const { findings, unparsed } = parseLensOutput(stdout);
|
|
122
|
+
onLensDone?.(job.lens, { ok: true, findings: findings.length });
|
|
123
|
+
// `output` is the verbatim lens text, kept so `--out` can save a run and it
|
|
124
|
+
// can be rescored later without paying the model again.
|
|
125
|
+
return { lens: job.lens, findings, unparsed, output: stdout };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { reports, skipped, failures };
|
|
129
|
+
}
|
package/lib/sarif.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Emit merged panel findings as SARIF 2.1.0.
|
|
5
|
+
//
|
|
6
|
+
// Why: every other multi-persona review panel emits prose for a human to read
|
|
7
|
+
// once. SARIF is the OASIS interchange format that static analyzers already
|
|
8
|
+
// speak, so emitting it puts lens findings into GitHub code scanning, editor
|
|
9
|
+
// problem panels, and security dashboards without any of them knowing an LLM
|
|
10
|
+
// produced the results.
|
|
11
|
+
//
|
|
12
|
+
// Two panel-specific properties ride along in `properties`, since SARIF has no
|
|
13
|
+
// native concept for either: the lenses that reported a finding, and the
|
|
14
|
+
// consensus score. Lenses that failed to complete are recorded as tool
|
|
15
|
+
// execution notifications — the format's own place for "this run was partial",
|
|
16
|
+
// which keeps the disclosure machine-readable instead of a line of prose.
|
|
17
|
+
|
|
18
|
+
const SARIF_VERSION = '2.1.0';
|
|
19
|
+
const SCHEMA =
|
|
20
|
+
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json';
|
|
21
|
+
|
|
22
|
+
// BLOCK/FIX/CONSIDER onto SARIF's result levels.
|
|
23
|
+
const LEVEL = { BLOCK: 'error', FIX: 'warning', CONSIDER: 'note' };
|
|
24
|
+
|
|
25
|
+
export function sarifLevel(severity) {
|
|
26
|
+
return LEVEL[severity] ?? 'note';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Stable across runs and independent of finding order, so a consumer can match
|
|
30
|
+
// the same defect between two runs. SARIF's own mechanism for this.
|
|
31
|
+
export function fingerprint(finding) {
|
|
32
|
+
const basis = `${finding.file}:${finding.line}|${finding.issue}`;
|
|
33
|
+
// A short, dependency-free digest. Not cryptographic — it only has to be
|
|
34
|
+
// stable and collision-resistant enough to key one repo's findings.
|
|
35
|
+
let h1 = 0x811c9dc5;
|
|
36
|
+
let h2 = 0x01000193;
|
|
37
|
+
for (let i = 0; i < basis.length; i++) {
|
|
38
|
+
const c = basis.charCodeAt(i);
|
|
39
|
+
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
|
40
|
+
h2 = Math.imul(h2 + c, 0x85ebca6b) >>> 0;
|
|
41
|
+
}
|
|
42
|
+
return (h1.toString(16).padStart(8, '0') +
|
|
43
|
+
h2.toString(16).padStart(8, '0'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One SARIF rule per lens: the lens is what "decided" the finding, so it is the
|
|
47
|
+
// unit a consumer will want to filter and configure by.
|
|
48
|
+
function rulesForLenses(lenses, lensMeta = {}) {
|
|
49
|
+
return [...lenses].sort().map(name => {
|
|
50
|
+
const meta = lensMeta[name] ?? {};
|
|
51
|
+
const rule = {
|
|
52
|
+
id: `lens/${name}`,
|
|
53
|
+
name,
|
|
54
|
+
shortDescription: { text: meta.summary ?? `${name} review lens` },
|
|
55
|
+
properties: {}
|
|
56
|
+
};
|
|
57
|
+
if (meta.cites?.length) {
|
|
58
|
+
rule.properties.cites = meta.cites;
|
|
59
|
+
}
|
|
60
|
+
if (meta.owns) {
|
|
61
|
+
rule.properties.owns = meta.owns;
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(rule.properties).length === 0) {
|
|
64
|
+
delete rule.properties;
|
|
65
|
+
}
|
|
66
|
+
return rule;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resultFor(finding) {
|
|
71
|
+
const text = finding.fix
|
|
72
|
+
? `${finding.issue} — fix: ${finding.fix}`
|
|
73
|
+
: finding.issue;
|
|
74
|
+
return {
|
|
75
|
+
// A finding confirmed by several lenses is attributed to the first that
|
|
76
|
+
// reported it, with the full set in properties; SARIF results carry one
|
|
77
|
+
// ruleId.
|
|
78
|
+
ruleId: `lens/${finding.lenses[0]}`,
|
|
79
|
+
level: sarifLevel(finding.severity),
|
|
80
|
+
message: { text },
|
|
81
|
+
locations: [{
|
|
82
|
+
physicalLocation: {
|
|
83
|
+
artifactLocation: { uri: finding.file },
|
|
84
|
+
region: { startLine: finding.line }
|
|
85
|
+
}
|
|
86
|
+
}],
|
|
87
|
+
partialFingerprints: { crosscheckFindingV1: fingerprint(finding) },
|
|
88
|
+
properties: {
|
|
89
|
+
lenses: finding.lenses,
|
|
90
|
+
consensus: finding.consensus === true,
|
|
91
|
+
consensusScore: finding.consensusScore ?? 1,
|
|
92
|
+
severity: finding.severity
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// merged: the object returned by mergeFindings (plus optional refuted list).
|
|
98
|
+
// options: { toolVersion, informationUri, lensMeta, refuted }
|
|
99
|
+
export function toSarif(merged, options = {}) {
|
|
100
|
+
const {
|
|
101
|
+
toolVersion = '0.1.0',
|
|
102
|
+
informationUri = 'https://github.com/applesnort/crosscheck',
|
|
103
|
+
lensMeta = {},
|
|
104
|
+
refuted = []
|
|
105
|
+
} = options;
|
|
106
|
+
|
|
107
|
+
const findings = merged?.findings ?? [];
|
|
108
|
+
const incomplete = merged?.incomplete ?? [];
|
|
109
|
+
const unparsed = merged?.unparsed ?? [];
|
|
110
|
+
const lenses = new Set(findings.flatMap(f => f.lenses));
|
|
111
|
+
for (const lens of incomplete) {
|
|
112
|
+
lenses.add(lens);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const notifications = [];
|
|
116
|
+
for (const lens of incomplete) {
|
|
117
|
+
notifications.push({
|
|
118
|
+
level: 'error',
|
|
119
|
+
message: {
|
|
120
|
+
text: `Lens "${lens}" did not complete; its coverage is missing from ` +
|
|
121
|
+
'this run.'
|
|
122
|
+
},
|
|
123
|
+
descriptor: { id: 'crosscheck/lensIncomplete' },
|
|
124
|
+
properties: { lens }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
for (const { lens, line } of unparsed) {
|
|
128
|
+
notifications.push({
|
|
129
|
+
level: 'warning',
|
|
130
|
+
message: {
|
|
131
|
+
text: `Lens "${lens}" emitted a line that does not match the finding ` +
|
|
132
|
+
`contract: ${line}`
|
|
133
|
+
},
|
|
134
|
+
descriptor: { id: 'crosscheck/unparsedOutput' },
|
|
135
|
+
properties: { lens }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
for (const finding of refuted) {
|
|
139
|
+
notifications.push({
|
|
140
|
+
level: 'note',
|
|
141
|
+
message: {
|
|
142
|
+
text: `Finding at ${finding.file}:${finding.line} was refuted during ` +
|
|
143
|
+
`verification and excluded: ${finding.issue}`
|
|
144
|
+
},
|
|
145
|
+
descriptor: { id: 'crosscheck/refuted' },
|
|
146
|
+
properties: { lenses: finding.lenses }
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
$schema: SCHEMA,
|
|
152
|
+
version: SARIF_VERSION,
|
|
153
|
+
runs: [{
|
|
154
|
+
tool: {
|
|
155
|
+
driver: {
|
|
156
|
+
name: 'crosscheck',
|
|
157
|
+
version: toolVersion,
|
|
158
|
+
informationUri,
|
|
159
|
+
rules: rulesForLenses(lenses, lensMeta)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
invocations: [{
|
|
163
|
+
// A panel missing a lens still produced results, so the invocation
|
|
164
|
+
// succeeded; the gap is reported, not hidden, and not faked as success
|
|
165
|
+
// of the whole roster.
|
|
166
|
+
executionSuccessful: incomplete.length === 0,
|
|
167
|
+
toolExecutionNotifications: notifications
|
|
168
|
+
}],
|
|
169
|
+
results: findings.map(resultFor)
|
|
170
|
+
}]
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function toSarifJson(merged, options = {}) {
|
|
175
|
+
return JSON.stringify(toSarif(merged, options), null, 2) + '\n';
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@applesnort/crosscheck",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Run independent review lenses in parallel and merge their findings into one deduped, consensus-ranked report \u2014 with SARIF output.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Joel Mangin",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"crosscheck": "bin/crosscheck.mjs"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./lib/merge.mjs",
|
|
16
|
+
"./merge": "./lib/merge.mjs",
|
|
17
|
+
"./parse": "./lib/parse.mjs",
|
|
18
|
+
"./sarif": "./lib/sarif.mjs",
|
|
19
|
+
"./baseline": "./lib/baseline.mjs",
|
|
20
|
+
"./lenses": "./lib/lenses.mjs",
|
|
21
|
+
"./calibrate": "./lib/calibrate.mjs",
|
|
22
|
+
"./run": "./lib/run.mjs",
|
|
23
|
+
"./prompt": "./lib/prompt.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"lib/",
|
|
28
|
+
"lenses/",
|
|
29
|
+
"fixtures/calibration/",
|
|
30
|
+
"foreman.md",
|
|
31
|
+
"PROVENANCE.md",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test test/*.test.mjs"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"code-review",
|
|
40
|
+
"sarif",
|
|
41
|
+
"static-analysis",
|
|
42
|
+
"llm",
|
|
43
|
+
"agents",
|
|
44
|
+
"consensus"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/applesnort/crosscheck.git"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|