@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/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
|
+
}
|
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/lenses.mjs
CHANGED
|
@@ -198,3 +198,38 @@ export function applyOverrides(routed, { only, skip } = {}) {
|
|
|
198
198
|
}
|
|
199
199
|
return { roster, skipped };
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
// Layered lens resolution.
|
|
203
|
+
//
|
|
204
|
+
// A single lens directory has to be either yours or the packaged one, which
|
|
205
|
+
// forces anyone adding a lens to fork all of them and lose upstream changes.
|
|
206
|
+
// Sources are layered instead, in increasing precedence: a later source with the
|
|
207
|
+
// same lens `name` shadows an earlier one, so overriding one lens costs one file
|
|
208
|
+
// rather than a fork.
|
|
209
|
+
//
|
|
210
|
+
// sources: [{origin, lenses: [{name, ...}]}] — origin is a label for reporting,
|
|
211
|
+
// usually the directory the lenses were read from.
|
|
212
|
+
export function resolveLensSet(sources) {
|
|
213
|
+
const byName = new Map();
|
|
214
|
+
const shadowed = [];
|
|
215
|
+
for (const source of sources ?? []) {
|
|
216
|
+
for (const lens of source.lenses ?? []) {
|
|
217
|
+
if (!lens?.name) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const previous = byName.get(lens.name);
|
|
221
|
+
if (previous) {
|
|
222
|
+
shadowed.push({
|
|
223
|
+
name: lens.name,
|
|
224
|
+
winner: source.origin,
|
|
225
|
+
shadowedFrom: previous.origin
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
byName.set(lens.name, { ...lens, origin: source.origin });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
lenses: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
233
|
+
shadowed
|
|
234
|
+
};
|
|
235
|
+
}
|
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
|
}
|