@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/lib/comment.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Build a pull-request comment.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately a summary, not per-line review comments. SARIF uploaded to code
|
|
7
|
+
// scanning already annotates the exact lines in the Files-changed view, and does
|
|
8
|
+
// it better than a bot posting threads. What that view cannot show is the shape of
|
|
9
|
+
// the run: which lenses were skipped, which died, what the baseline suppressed,
|
|
10
|
+
// what verification refuted. That is what this comment carries.
|
|
11
|
+
//
|
|
12
|
+
// Every comment embeds a marker so a later run can edit the previous one instead
|
|
13
|
+
// of stacking. A PR with eleven bot comments gets muted, and a muted reviewer
|
|
14
|
+
// finds nothing.
|
|
15
|
+
|
|
16
|
+
export const COMMENT_MARKER = '<!-- crosscheck:report -->';
|
|
17
|
+
|
|
18
|
+
const LEVEL_LABEL = { BLOCK: 'π΄ BLOCK', FIX: 'π‘ FIX', CONSIDER: 'βͺ CONSIDER' };
|
|
19
|
+
|
|
20
|
+
function severitySection(findings, severity, { limit }) {
|
|
21
|
+
const group = findings.filter(f => f.severity === severity);
|
|
22
|
+
if (group.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const lines = [`### ${LEVEL_LABEL[severity]} (${group.length})`, ''];
|
|
26
|
+
for (const f of group.slice(0, limit)) {
|
|
27
|
+
const who = f.consensus
|
|
28
|
+
? `**${f.lenses.join(' + ')}** agreed`
|
|
29
|
+
: `${f.lenses.join(', ')}`;
|
|
30
|
+
const collapsed = f.occurrences > f.lenses.length
|
|
31
|
+
? ` Β· ${f.occurrences} reports across lines ${f.lines.join(', ')}`
|
|
32
|
+
: '';
|
|
33
|
+
lines.push(
|
|
34
|
+
`- \`${f.file}:${f.line}\` β ${f.issue}` +
|
|
35
|
+
(f.fix ? `\n **Fix:** ${f.fix}` : '') +
|
|
36
|
+
`\n <sub>${who}${collapsed}</sub>`);
|
|
37
|
+
}
|
|
38
|
+
if (group.length > limit) {
|
|
39
|
+
// Named, not hidden: a truncated list that does not say so reads as the
|
|
40
|
+
// whole list.
|
|
41
|
+
lines.push('', `_β¦and ${group.length - limit} more ${severity} finding(s) ` +
|
|
42
|
+
'not shown here. The full set is in the SARIF output and the run log._');
|
|
43
|
+
}
|
|
44
|
+
lines.push('');
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// merged: the object from mergeFindings, optionally with the run's disclosures.
|
|
49
|
+
export function buildComment({
|
|
50
|
+
merged,
|
|
51
|
+
refuted = [],
|
|
52
|
+
suppressed = [],
|
|
53
|
+
dropped = [],
|
|
54
|
+
skipped = [],
|
|
55
|
+
target = null,
|
|
56
|
+
sarifPath = null,
|
|
57
|
+
limitPerSeverity = 10
|
|
58
|
+
} = {}) {
|
|
59
|
+
const findings = merged?.findings ?? [];
|
|
60
|
+
const incomplete = merged?.incomplete ?? [];
|
|
61
|
+
const counts = {
|
|
62
|
+
BLOCK: findings.filter(f => f.severity === 'BLOCK').length,
|
|
63
|
+
FIX: findings.filter(f => f.severity === 'FIX').length,
|
|
64
|
+
CONSIDER: findings.filter(f => f.severity === 'CONSIDER').length
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const out = [COMMENT_MARKER, '', '## crosscheck'];
|
|
68
|
+
|
|
69
|
+
if (target) {
|
|
70
|
+
out.push('', `Reviewed ${target}.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (findings.length === 0) {
|
|
74
|
+
out.push('', 'No findings.');
|
|
75
|
+
} else {
|
|
76
|
+
out.push('',
|
|
77
|
+
`**${counts.BLOCK} block Β· ${counts.FIX} fix Β· ${counts.CONSIDER} consider**` +
|
|
78
|
+
(findings.some(f => f.consensus)
|
|
79
|
+
? ` Β· ${findings.filter(f => f.consensus).length} agreed by more than one lens`
|
|
80
|
+
: ''),
|
|
81
|
+
'');
|
|
82
|
+
for (const severity of ['BLOCK', 'FIX', 'CONSIDER']) {
|
|
83
|
+
out.push(...severitySection(findings, severity, { limit: limitPerSeverity }));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The disclosures. Each one is a hole in the coverage, and a report that omits
|
|
88
|
+
// them reads as completeness that was never there. Zeroes are stated too.
|
|
89
|
+
const notes = [];
|
|
90
|
+
notes.push(`Refuted in verification: ${refuted.length}`);
|
|
91
|
+
if (incomplete.length) {
|
|
92
|
+
notes.push(`**Did not complete: ${incomplete.join(', ')}** β their coverage ` +
|
|
93
|
+
'is missing from this report');
|
|
94
|
+
}
|
|
95
|
+
if (dropped.length) {
|
|
96
|
+
notes.push(`**Budget reached** β not run: ${dropped.map(d => d.lens ?? d).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
if (suppressed.length) {
|
|
99
|
+
notes.push(`Suppressed by baseline: ${suppressed.length}`);
|
|
100
|
+
}
|
|
101
|
+
if (skipped.length) {
|
|
102
|
+
// Two different reasons β nothing in scope matched, or you excluded it β and
|
|
103
|
+
// calling an explicit exclusion "irrelevant" misstates the run.
|
|
104
|
+
const irrelevant = skipped.filter(s => !/--only|--skip/.test(s.reason ?? ''));
|
|
105
|
+
const excluded = skipped.filter(s => /--only|--skip/.test(s.reason ?? ''));
|
|
106
|
+
if (irrelevant.length) {
|
|
107
|
+
notes.push('Not applicable to these files: ' +
|
|
108
|
+
irrelevant.map(s => s.lens ?? s).join(', '));
|
|
109
|
+
}
|
|
110
|
+
if (excluded.length) {
|
|
111
|
+
notes.push('Excluded by request: ' +
|
|
112
|
+
excluded.map(s => s.lens ?? s).join(', '));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
out.push('<details><summary>Run details</summary>', '');
|
|
116
|
+
for (const note of notes) {
|
|
117
|
+
out.push(`- ${note}`);
|
|
118
|
+
}
|
|
119
|
+
if (sarifPath) {
|
|
120
|
+
out.push('', `SARIF written to \`${sarifPath}\` β upload it to code scanning ` +
|
|
121
|
+
'for per-line annotations in the Files changed view.');
|
|
122
|
+
}
|
|
123
|
+
out.push('', '</details>');
|
|
124
|
+
|
|
125
|
+
return out.join('\n') + '\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// True when a comment body was written by crosscheck, so a run can edit its own
|
|
129
|
+
// previous comment rather than adding another.
|
|
130
|
+
export function isOwnComment(body) {
|
|
131
|
+
return String(body ?? '').includes(COMMENT_MARKER);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Given the comments on a PR, the id of ours to update, or null to create one.
|
|
135
|
+
export function findOwnComment(comments) {
|
|
136
|
+
const mine = (comments ?? []).filter(c => isOwnComment(c.body));
|
|
137
|
+
return mine.length ? mine[mine.length - 1].id ?? null : null;
|
|
138
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -23,7 +23,13 @@ export const CONFIG_FILENAMES = [
|
|
|
23
23
|
// an error.
|
|
24
24
|
export const CONFIG_KEYS = new Set([
|
|
25
25
|
'exec', 'lenses', 'concurrency', 'only', 'skip', 'mixed',
|
|
26
|
-
'out', 'sarif', 'baseline', 'overlap'
|
|
26
|
+
'out', 'sarif', 'baseline', 'overlap',
|
|
27
|
+
// Phase 1: change-scoped review, verification, and a project's own gate.
|
|
28
|
+
'preflight', 'context', 'verify', 'no-verify', 'since',
|
|
29
|
+
// Phase 2: cost control.
|
|
30
|
+
'max-dispatches', 'no-cache', 'cache-dir',
|
|
31
|
+
// Phase 3: CI integration.
|
|
32
|
+
'comment-file'
|
|
27
33
|
]);
|
|
28
34
|
|
|
29
35
|
const LIST_KEYS = new Set(['only', 'skip']);
|
|
@@ -60,18 +66,36 @@ export function validateConfig(raw, source = 'config') {
|
|
|
60
66
|
}
|
|
61
67
|
continue;
|
|
62
68
|
}
|
|
63
|
-
|
|
69
|
+
// `exec` may be one command for every lens, or a map choosing per lens so a
|
|
70
|
+
// cheap lens does not pay for an expensive model.
|
|
71
|
+
if (key === 'exec' && value && typeof value === 'object' &&
|
|
72
|
+
!Array.isArray(value)) {
|
|
73
|
+
const bad = Object.entries(value)
|
|
74
|
+
.filter(([, v]) => typeof v !== 'string' || v === '');
|
|
75
|
+
if (bad.length) {
|
|
76
|
+
problems.push(
|
|
77
|
+
`${source}: exec map entries must be non-empty strings ` +
|
|
78
|
+
`(${bad.map(([k]) => k).join(', ')})`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
config.exec = { ...value };
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (key === 'concurrency' || key === 'context' || key === 'max-dispatches') {
|
|
64
85
|
const n = Number(value);
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
const floor = key === 'context' ? 0 : 1;
|
|
87
|
+
if (!Number.isInteger(n) || n < floor) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`${source}: "${key}" must be an integer >= ${floor}`);
|
|
67
90
|
continue;
|
|
68
91
|
}
|
|
69
92
|
config[key] = n;
|
|
70
93
|
continue;
|
|
71
94
|
}
|
|
72
|
-
if (key === 'mixed'
|
|
95
|
+
if (key === 'mixed' || key === 'verify' || key === 'no-verify' ||
|
|
96
|
+
key === 'no-cache') {
|
|
73
97
|
if (typeof value !== 'boolean') {
|
|
74
|
-
problems.push(`${source}: "
|
|
98
|
+
problems.push(`${source}: "${key}" must be true or false`);
|
|
75
99
|
continue;
|
|
76
100
|
}
|
|
77
101
|
config[key] = value;
|
package/lib/merge.mjs
CHANGED
|
@@ -254,7 +254,13 @@ export function mergeFindings(reports, options = {}) {
|
|
|
254
254
|
const finding = {
|
|
255
255
|
...rest,
|
|
256
256
|
consensus: c.lenses.length > 1,
|
|
257
|
-
consensusScore: consensusScore(c.lenses, overlap)
|
|
257
|
+
consensusScore: consensusScore(c.lenses, overlap),
|
|
258
|
+
// How many reports collapsed into this entry. Clustering is transitive β
|
|
259
|
+
// a run of similar findings on consecutive lines chains into one β so a
|
|
260
|
+
// collapse much larger than the lens count needs to be visible rather
|
|
261
|
+
// than looking like a single finding.
|
|
262
|
+
occurrences: members.length,
|
|
263
|
+
lines: [...new Set(members.map(m => m.line))].sort((a, b) => a - b)
|
|
258
264
|
};
|
|
259
265
|
// The key is derived after clustering, from the reported anchor and the
|
|
260
266
|
// representative issue, so a baseline stays stable across runs.
|
package/lib/prompt.mjs
CHANGED
|
@@ -14,6 +14,12 @@ export const CONTRACT_LINE =
|
|
|
14
14
|
// The frontmatter is routing metadata β globs, cites, owns β consumed by the
|
|
15
15
|
// router before dispatch. Sending it to the model costs tokens on every call and
|
|
16
16
|
// tells it nothing it needs, so the body is what gets inlined.
|
|
17
|
+
function formatSpans(spans) {
|
|
18
|
+
return spans
|
|
19
|
+
.map(([start, end]) => start === end ? `${start}` : `${start}-${end}`)
|
|
20
|
+
.join(', ');
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export function stripFrontmatter(text) {
|
|
18
24
|
return String(text ?? '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
|
|
19
25
|
}
|
|
@@ -26,6 +32,7 @@ export function buildLensPrompt(lens, files, options = {}) {
|
|
|
26
32
|
definitionPath = null,
|
|
27
33
|
definition = null,
|
|
28
34
|
mixedCorpus = false,
|
|
35
|
+
rangesByFile = null,
|
|
29
36
|
extra = null
|
|
30
37
|
} = options;
|
|
31
38
|
|
|
@@ -68,9 +75,29 @@ export function buildLensPrompt(lens, files, options = {}) {
|
|
|
68
75
|
'reporting them here duplicates that lens and weakens the panel.');
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
const ranges = rangesByFile ?? {};
|
|
79
|
+
const hasRanges = files.some(f => (ranges[f] ?? []).length > 0);
|
|
71
80
|
parts.push(
|
|
72
81
|
`Audit exactly these ${files.length} file(s), reading each one completely ` +
|
|
73
|
-
'before reporting on it:\n' +
|
|
82
|
+
'before reporting on it:\n' +
|
|
83
|
+
files.map(f => {
|
|
84
|
+
const spans = ranges[f] ?? [];
|
|
85
|
+
return spans.length
|
|
86
|
+
? ` - ${f} (changed lines: ${formatSpans(spans)})`
|
|
87
|
+
: ` - ${f}`;
|
|
88
|
+
}).join('\n'));
|
|
89
|
+
|
|
90
|
+
if (hasRanges) {
|
|
91
|
+
// Reporting only inside the diff would miss the common case: a change that
|
|
92
|
+
// breaks something it did not touch. The changed lines are the priority, not
|
|
93
|
+
// the boundary.
|
|
94
|
+
parts.push(
|
|
95
|
+
'This is a review of a change. The changed lines are listed above β read ' +
|
|
96
|
+
'each file completely for context, and concentrate on what the change ' +
|
|
97
|
+
'introduces or breaks. A defect elsewhere in the file is still worth ' +
|
|
98
|
+
'reporting when the change causes it or depends on it; a pre-existing ' +
|
|
99
|
+
'defect the change does not touch is not what this review is for.');
|
|
100
|
+
}
|
|
74
101
|
|
|
75
102
|
if (mixedCorpus) {
|
|
76
103
|
parts.push(
|
|
@@ -95,3 +122,62 @@ export function buildLensPrompt(lens, files, options = {}) {
|
|
|
95
122
|
|
|
96
123
|
return parts.join('\n\n');
|
|
97
124
|
}
|
|
125
|
+
|
|
126
|
+
// The verification pass. `foreman.md` has always called for refuting every
|
|
127
|
+
// BLOCK by default, on the grounds that a panel which cries wolf stops being
|
|
128
|
+
// read. The skeptic is given the file rather than the finding's own account of
|
|
129
|
+
// it, and told to default to refuted when the evidence is not there β a verifier
|
|
130
|
+
// that accepts a plausible story adds cost and no signal.
|
|
131
|
+
export function buildRefutePrompt(finding, options = {}) {
|
|
132
|
+
const { extra = null } = options;
|
|
133
|
+
if (!finding?.file) {
|
|
134
|
+
throw new Error('buildRefutePrompt requires a finding with a file');
|
|
135
|
+
}
|
|
136
|
+
const parts = [
|
|
137
|
+
'You are verifying one claimed defect. Your job is to REFUTE it.',
|
|
138
|
+
`Claim: ${finding.file}:${finding.line} β ${finding.issue}` +
|
|
139
|
+
(finding.fix ? `\nProposed fix: ${finding.fix}` : ''),
|
|
140
|
+
`Read \`${finding.file}\` yourself. Do not take the claim's description of ` +
|
|
141
|
+
'the code as accurate β check it. Then decide:\n\n' +
|
|
142
|
+
' - Is the code actually as the claim describes?\n' +
|
|
143
|
+
' - Can you name a concrete input, state, or sequence that triggers the ' +
|
|
144
|
+
'defect?\n' +
|
|
145
|
+
' - Does something already in the code prevent it β a guard, a validation, ' +
|
|
146
|
+
'a caller contract, a type?',
|
|
147
|
+
'Default to refuted. If you cannot demonstrate the defect is real, it is ' +
|
|
148
|
+
'refuted. A finding that survives only because nobody could disprove it is ' +
|
|
149
|
+
'the kind this pass exists to remove.'
|
|
150
|
+
];
|
|
151
|
+
if (extra) {
|
|
152
|
+
parts.push(extra);
|
|
153
|
+
}
|
|
154
|
+
parts.push(
|
|
155
|
+
'Reply with exactly one line:\n\n' +
|
|
156
|
+
' REFUTED β why the claim does not hold\n' +
|
|
157
|
+
'or\n' +
|
|
158
|
+
' CONFIRMED β the concrete trigger you verified\n\n' +
|
|
159
|
+
'No preamble, no other text.');
|
|
160
|
+
return parts.join('\n\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const REFUTED = /^\s*REFUTED\b/i;
|
|
164
|
+
const CONFIRMED = /^\s*CONFIRMED\b/i;
|
|
165
|
+
|
|
166
|
+
// An unparseable verdict is treated as refuted, matching the default above: a
|
|
167
|
+
// verifier that produced nothing usable has not established the finding.
|
|
168
|
+
export function parseVerdict(text) {
|
|
169
|
+
const line = String(text ?? '').trim().split('\n')
|
|
170
|
+
.find(l => REFUTED.test(l) || CONFIRMED.test(l)) ?? '';
|
|
171
|
+
if (CONFIRMED.test(line)) {
|
|
172
|
+
return {
|
|
173
|
+
refuted: false,
|
|
174
|
+
reason: line.replace(CONFIRMED, '').replace(/^\s*[β-]\s*/, '').trim() || null
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
refuted: true,
|
|
179
|
+
reason: line
|
|
180
|
+
? line.replace(REFUTED, '').replace(/^\s*[β-]\s*/, '').trim() || null
|
|
181
|
+
: 'verifier produced no usable verdict'
|
|
182
|
+
};
|
|
183
|
+
}
|
package/lib/run.mjs
CHANGED
|
@@ -13,12 +13,42 @@
|
|
|
13
13
|
// mean that lens did not produce a report β recorded as incomplete, never
|
|
14
14
|
// silently treated as "found nothing".
|
|
15
15
|
|
|
16
|
-
import { buildLensPrompt } from './prompt.mjs';
|
|
16
|
+
import { buildLensPrompt, buildRefutePrompt, parseVerdict } from './prompt.mjs';
|
|
17
17
|
import { applyOverrides, routeRoster } from './lenses.mjs';
|
|
18
18
|
import { parseLensOutput } from './parse.mjs';
|
|
19
19
|
|
|
20
20
|
export const DEFAULT_CONCURRENCY = 4;
|
|
21
21
|
|
|
22
|
+
// Which command runs a given lens. A conventions lens checking copyright years
|
|
23
|
+
// does not need what a security lens needs, and cost is what decides whether a
|
|
24
|
+
// team leaves this switched on. Precedence: the lens's own `exec`, then a
|
|
25
|
+
// per-lens entry in the config's exec map, then the single default.
|
|
26
|
+
export function resolveExec(lens, exec) {
|
|
27
|
+
if (lens?.exec) {
|
|
28
|
+
return lens.exec;
|
|
29
|
+
}
|
|
30
|
+
if (exec && typeof exec === 'object') {
|
|
31
|
+
return exec[lens?.name] ?? exec.default ?? null;
|
|
32
|
+
}
|
|
33
|
+
return exec ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// crosscheck cannot see tokens or money β `--exec` is an arbitrary command β so
|
|
37
|
+
// the only unit it can honestly cap is the number of dispatches. Pretending to
|
|
38
|
+
// budget dollars would be a number made up from nothing.
|
|
39
|
+
export function planBudget(jobs, maxDispatches) {
|
|
40
|
+
if (maxDispatches == null || maxDispatches >= (jobs ?? []).length) {
|
|
41
|
+
return { run: jobs ?? [], dropped: [] };
|
|
42
|
+
}
|
|
43
|
+
if (maxDispatches <= 0) {
|
|
44
|
+
return { run: [], dropped: jobs ?? [] };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
run: jobs.slice(0, maxDispatches),
|
|
48
|
+
dropped: jobs.slice(maxDispatches)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
// lenses: [{name, when, owns, 'not-owns', definition?, definitionPath?}]
|
|
23
53
|
// Returns {roster, skipped} β skipped always carries a reason, because a lens
|
|
24
54
|
// dropped without one reads as coverage that never happened.
|
|
@@ -56,6 +86,49 @@ export function promptsFor(roster, options = {}) {
|
|
|
56
86
|
}));
|
|
57
87
|
}
|
|
58
88
|
|
|
89
|
+
// Refute each finding independently, bounded by the same concurrency cap. Returns
|
|
90
|
+
// verdicts keyed by finding key, plus any verifier that failed β a verifier that
|
|
91
|
+
// did not run must not be read as agreement, so its finding is left standing and
|
|
92
|
+
// the failure is reported.
|
|
93
|
+
export async function verifyFindings({
|
|
94
|
+
findings,
|
|
95
|
+
exec,
|
|
96
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
97
|
+
onVerdict = null
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (typeof exec !== 'function') {
|
|
100
|
+
throw new Error('verifyFindings requires an exec function');
|
|
101
|
+
}
|
|
102
|
+
const targets = findings ?? [];
|
|
103
|
+
const verdicts = {};
|
|
104
|
+
const failures = [];
|
|
105
|
+
await mapLimit(targets, concurrency, async finding => {
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await exec({
|
|
109
|
+
prompt: buildRefutePrompt(finding),
|
|
110
|
+
lens: `verify:${finding.lenses?.[0] ?? 'finding'}`,
|
|
111
|
+
files: [finding.file]
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
failures.push({
|
|
115
|
+
finding: finding.key, reason: error?.message ?? String(error)
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if ((result?.code ?? 0) !== 0) {
|
|
120
|
+
failures.push({
|
|
121
|
+
finding: finding.key, reason: `verifier exited ${result?.code}`
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const verdict = parseVerdict(result?.stdout);
|
|
126
|
+
verdicts[finding.key] = verdict;
|
|
127
|
+
onVerdict?.(finding, verdict);
|
|
128
|
+
});
|
|
129
|
+
return { verdicts, failures };
|
|
130
|
+
}
|
|
131
|
+
|
|
59
132
|
// Bounded-concurrency map that preserves input order. Kept local rather than
|
|
60
133
|
// pulling in a dependency for nine lines.
|
|
61
134
|
async function mapLimit(items, limit, worker) {
|
|
@@ -84,17 +157,37 @@ export async function runPanel({
|
|
|
84
157
|
exec,
|
|
85
158
|
concurrency = DEFAULT_CONCURRENCY,
|
|
86
159
|
promptOptions = {},
|
|
160
|
+
cache = null,
|
|
161
|
+
cacheKeyFor = null,
|
|
162
|
+
maxDispatches = null,
|
|
87
163
|
onLensStart = null,
|
|
88
|
-
onLensDone = null
|
|
164
|
+
onLensDone = null,
|
|
165
|
+
onCacheHit = null
|
|
89
166
|
} = {}) {
|
|
90
167
|
if (typeof exec !== 'function') {
|
|
91
168
|
throw new Error('runPanel requires an exec function');
|
|
92
169
|
}
|
|
93
|
-
const
|
|
170
|
+
const allJobs = promptsFor(roster ?? [], promptOptions);
|
|
94
171
|
const failures = [];
|
|
95
172
|
|
|
173
|
+
// Truncation is disclosed, never silent: a run that quietly stopped early
|
|
174
|
+
// looks exactly like a run that found nothing.
|
|
175
|
+
const { run: jobs, dropped } = planBudget(allJobs, maxDispatches);
|
|
176
|
+
|
|
96
177
|
const reports = await mapLimit(jobs, concurrency, async job => {
|
|
97
178
|
onLensStart?.(job.lens);
|
|
179
|
+
const key = cache && cacheKeyFor ? cacheKeyFor(job) : null;
|
|
180
|
+
if (key) {
|
|
181
|
+
const cached = cache.get(key, job.lens);
|
|
182
|
+
if (cached != null) {
|
|
183
|
+
const { findings, unparsed } = parseLensOutput(cached);
|
|
184
|
+
onCacheHit?.(job.lens);
|
|
185
|
+
onLensDone?.(job.lens, {
|
|
186
|
+
ok: true, findings: findings.length, cached: true
|
|
187
|
+
});
|
|
188
|
+
return { lens: job.lens, findings, unparsed, output: cached };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
98
191
|
let result;
|
|
99
192
|
try {
|
|
100
193
|
result = await exec({ prompt: job.prompt, lens: job.lens, files: job.files });
|
|
@@ -124,11 +217,20 @@ export async function runPanel({
|
|
|
124
217
|
return { lens: job.lens, findings: null, unparsed: [], output: null };
|
|
125
218
|
}
|
|
126
219
|
const { findings, unparsed } = parseLensOutput(stdout);
|
|
220
|
+
if (key) {
|
|
221
|
+
cache.set(key, job.lens, stdout);
|
|
222
|
+
}
|
|
127
223
|
onLensDone?.(job.lens, { ok: true, findings: findings.length });
|
|
128
224
|
// `output` is the verbatim lens text, kept so `--out` can save a run and it
|
|
129
225
|
// can be rescored later without paying the model again.
|
|
130
226
|
return { lens: job.lens, findings, unparsed, output: stdout };
|
|
131
227
|
});
|
|
132
228
|
|
|
133
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
reports,
|
|
231
|
+
skipped,
|
|
232
|
+
failures,
|
|
233
|
+
dropped: dropped.map(j => ({ lens: j.lens, files: j.files.length })),
|
|
234
|
+
cacheStats: cache?.stats ?? null
|
|
235
|
+
};
|
|
134
236
|
}
|
package/lib/target.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Resolve what to review from a diff.
|
|
5
|
+
//
|
|
6
|
+
// Auditing whole paths makes cost scale with repository size rather than change
|
|
7
|
+
// size, and re-reviews code nobody touched. A review is of a change, so the
|
|
8
|
+
// target is a diff β parsed here into files and the line ranges that moved.
|
|
9
|
+
//
|
|
10
|
+
// Pure: the caller runs git and passes the text in. That keeps this testable
|
|
11
|
+
// against recorded diffs and keeps git invocation in one place.
|
|
12
|
+
|
|
13
|
+
// `@@ -old,count +new,count @@` β the new-side numbers are the ones that exist in
|
|
14
|
+
// the working tree, so those are what a lens can cite.
|
|
15
|
+
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
16
|
+
const FILE_HEADER = /^\+\+\+ (?:b\/)?(.+)$/;
|
|
17
|
+
const RENAME_TO = /^rename to (.+)$/;
|
|
18
|
+
|
|
19
|
+
// A deleted file has no new-side path; nothing can be reviewed in it.
|
|
20
|
+
const DEV_NULL = '/dev/null';
|
|
21
|
+
|
|
22
|
+
export function parseDiff(diffText) {
|
|
23
|
+
const byFile = new Map();
|
|
24
|
+
let current = null;
|
|
25
|
+
for (const rawLine of String(diffText ?? '').split('\n')) {
|
|
26
|
+
const renamed = RENAME_TO.exec(rawLine);
|
|
27
|
+
if (renamed) {
|
|
28
|
+
current = renamed[1].trim();
|
|
29
|
+
if (!byFile.has(current)) {
|
|
30
|
+
byFile.set(current, []);
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const header = FILE_HEADER.exec(rawLine);
|
|
35
|
+
if (header) {
|
|
36
|
+
const path = header[1].trim();
|
|
37
|
+
current = path === DEV_NULL ? null : path;
|
|
38
|
+
if (current && !byFile.has(current)) {
|
|
39
|
+
byFile.set(current, []);
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const hunk = HUNK.exec(rawLine);
|
|
44
|
+
if (hunk && current) {
|
|
45
|
+
const start = Number(hunk[1]);
|
|
46
|
+
const count = hunk[2] == null ? 1 : Number(hunk[2]);
|
|
47
|
+
// A hunk with count 0 is a pure deletion at that point: there are no new
|
|
48
|
+
// lines to review, so it contributes no range.
|
|
49
|
+
if (count > 0) {
|
|
50
|
+
byFile.get(current).push([start, start + count - 1]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...byFile.entries()].map(([file, ranges]) => ({
|
|
55
|
+
file,
|
|
56
|
+
ranges: mergeRanges(ranges)
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Adjacent or overlapping hunks read better as one range, and a lens does not
|
|
61
|
+
// care that git split them.
|
|
62
|
+
export function mergeRanges(ranges, gap = 1) {
|
|
63
|
+
const sorted = [...(ranges ?? [])].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [start, end] of sorted) {
|
|
66
|
+
const last = out[out.length - 1];
|
|
67
|
+
if (last && start <= last[1] + gap) {
|
|
68
|
+
last[1] = Math.max(last[1], end);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push([start, end]);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Widen each range by `context` lines so a lens can see what surrounds a change.
|
|
77
|
+
// A defect introduced by a diff is frequently visible only against the code the
|
|
78
|
+
// diff did not touch.
|
|
79
|
+
export function withContext(ranges, context = 20, maxLine = Infinity) {
|
|
80
|
+
return mergeRanges((ranges ?? []).map(([start, end]) =>
|
|
81
|
+
[Math.max(1, start - context), Math.min(maxLine, end + context)]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function changedFiles(diffText) {
|
|
85
|
+
return parseDiff(diffText).map(entry => entry.file);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Files with no reviewable new-side content β deletions β are excluded by
|
|
89
|
+
// parseDiff, so anything returned here exists and has lines a lens can cite.
|
|
90
|
+
export function targetFromDiff(diffText, { filter = null } = {}) {
|
|
91
|
+
const entries = parseDiff(diffText)
|
|
92
|
+
.filter(entry => entry.ranges.length > 0)
|
|
93
|
+
.filter(entry => (filter ? filter(entry.file) : true));
|
|
94
|
+
return {
|
|
95
|
+
files: entries.map(e => e.file),
|
|
96
|
+
rangesByFile: Object.fromEntries(entries.map(e => [e.file, e.ranges]))
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatRanges(ranges) {
|
|
101
|
+
return (ranges ?? [])
|
|
102
|
+
.map(([start, end]) => start === end ? `${start}` : `${start}-${end}`)
|
|
103
|
+
.join(', ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The git command for each targeting mode. Returned rather than executed so the
|
|
107
|
+
// caller owns process spawning and this stays pure.
|
|
108
|
+
export function diffCommand({ diff, staged, since } = {}) {
|
|
109
|
+
// --unified=0 keeps hunk headers tight to the changed lines; context is added
|
|
110
|
+
// deliberately by withContext rather than inherited from git's default.
|
|
111
|
+
const base = ['diff', '--unified=0', '--no-color', '--no-ext-diff'];
|
|
112
|
+
if (staged) {
|
|
113
|
+
return [...base, '--cached'];
|
|
114
|
+
}
|
|
115
|
+
if (since) {
|
|
116
|
+
return [...base, `${since}...HEAD`];
|
|
117
|
+
}
|
|
118
|
+
if (typeof diff === 'string' && diff !== '' && diff !== 'true') {
|
|
119
|
+
return [...base, `${diff}...HEAD`];
|
|
120
|
+
}
|
|
121
|
+
// Bare --diff: everything not yet committed, staged or not.
|
|
122
|
+
return [...base, 'HEAD'];
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applesnort/crosscheck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Run independent review lenses in parallel and merge their findings into one deduped, consensus-ranked report \u2014 with SARIF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Joel Mangin",
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"./calibrate": "./lib/calibrate.mjs",
|
|
22
22
|
"./run": "./lib/run.mjs",
|
|
23
23
|
"./prompt": "./lib/prompt.mjs",
|
|
24
|
-
"./config": "./lib/config.mjs"
|
|
24
|
+
"./config": "./lib/config.mjs",
|
|
25
|
+
"./target": "./lib/target.mjs",
|
|
26
|
+
"./cache": "./lib/cache.mjs",
|
|
27
|
+
"./comment": "./lib/comment.mjs"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"bin/",
|