@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/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/",
|