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