@codedology/code-forge 0.7.2 → 0.7.4
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/CHANGELOG.md +20 -0
- package/README.md +15 -0
- package/docs/reference/blocks.json +33 -1
- package/package.json +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/binnacle.mjs +29 -1
- package/src/review/engine.mjs +135 -33
- package/src/review/fixloop.mjs +146 -33
- package/src/review/moved.mjs +399 -0
- package/src/review/packet.mjs +32 -23
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +5 -4
- package/src/review/validate-review.mjs +11 -0
- package/src/session/spawn.mjs +3 -86
- package/src/util/diagnostic-tail.mjs +95 -0
- package/src/worker/engine.mjs +78 -8
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moved code across the files of one block (B56, issue #2).
|
|
3
|
+
*
|
|
4
|
+
* Each changed file is reviewed alone, so a block that moves code from file A to file B shows A's
|
|
5
|
+
* reviewer only removed lines — read as "feature deleted", a critical that came back on every
|
|
6
|
+
* recheck. This module finds, deterministically and without any model call, the removed runs of
|
|
7
|
+
* one file that were ADDED in another changed file of the same block (both diffed against the
|
|
8
|
+
* same block base), and the reverse, and renders them as the packet's `## moved code` section.
|
|
9
|
+
* The section is extra text only: every hunk still goes to the reviewer and `reviewed_hunks` is
|
|
10
|
+
* unchanged. A move inside ONE file (removed in one hunk, added in another) is not detected.
|
|
11
|
+
*
|
|
12
|
+
* Detection rule:
|
|
13
|
+
* - a diff line is NORMALISED by trimming it and collapsing every whitespace run to one space;
|
|
14
|
+
* - a line is SIGNIFICANT when its normalised form holds at least {@link MIN_LINE_ALNUM} letters
|
|
15
|
+
* or digits — blank lines, lone braces/brackets/`});`, `*` comment rules are not;
|
|
16
|
+
* - a RUN is a maximal sequence of adjacent `-` (or `+`) lines in one hunk; its significant lines
|
|
17
|
+
* are compared in order, the trivial ones skipped (they never break a match);
|
|
18
|
+
* - a removed run of this file matches an added run of a peer (and an added run of this file a
|
|
19
|
+
* removed run of a peer) where their significant lines are EQUAL one after the other; the longest
|
|
20
|
+
* such stretch from each removed line wins (ties: the first peer in sorted order, then the first
|
|
21
|
+
* line), and it counts as moved when it has at least {@link MIN_MOVED_LINES} significant lines
|
|
22
|
+
* or at least {@link MIN_MOVED_CHARS} significant characters (normalised lengths summed);
|
|
23
|
+
* - line numbers are the BASE numbering for removed lines and the current numbering for added
|
|
24
|
+
* lines; a range runs from the first to the last significant line of the stretch.
|
|
25
|
+
*
|
|
26
|
+
* Peers are read ONCE per review (`peerDiffReader`, memoised): one `git ls-tree <base>` (which
|
|
27
|
+
* peers exist in the base — a peer deleted in the block, staged or not, is diffed against the base
|
|
28
|
+
* with every line removed), one `git diff <base>` over those, one `git check-ignore` and one
|
|
29
|
+
* `git diff --no-index` per new peer on disk.
|
|
30
|
+
* Every peer diff is read with `a/`/`b/` prefixes forced (whatever `diff.noprefix` or
|
|
31
|
+
* `diff.mnemonicPrefix` say) and under the {@link MAX_DIFF_BYTES} cap: the listing over the cap is
|
|
32
|
+
* re-read peer by peer, and a peer over the cap alone is skipped — never buffered past the cap.
|
|
33
|
+
*
|
|
34
|
+
* Markdown section packets (B54) carry only the moves that fall in the section: each move has a
|
|
35
|
+
* position in the CURRENT file — an added stretch: its first line; a removed stretch: the current
|
|
36
|
+
* line just BEFORE the cut (0 at the top), so a block cut right above a heading belongs to the
|
|
37
|
+
* section that holds its removed hunk, exactly as `sectionGroups` cuts the hunks — and a section
|
|
38
|
+
* holds the positions under its headings.
|
|
39
|
+
*
|
|
40
|
+
* The section lists at most {@link MAX_MOVED_ENTRIES} ranges (then one "N more" line), so its size
|
|
41
|
+
* is capped; it counts toward the packet budget like any other section (`packet.mjs`).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync } from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { exec } from '../util/exec.mjs';
|
|
47
|
+
import { assertPacketPath, isSecretLike, plainGitEnv, reviewGitEnv } from './packet.mjs';
|
|
48
|
+
import { markdownHeadings } from './sections.mjs';
|
|
49
|
+
|
|
50
|
+
/** A significant line has at least this many letters or digits once normalised. */
|
|
51
|
+
export const MIN_LINE_ALNUM = 4;
|
|
52
|
+
/** A moved stretch has at least this many significant lines … */
|
|
53
|
+
export const MIN_MOVED_LINES = 3;
|
|
54
|
+
/** … or at least this many significant characters (normalised lengths summed). */
|
|
55
|
+
export const MIN_MOVED_CHARS = 120;
|
|
56
|
+
/** The section lists at most this many ranges. */
|
|
57
|
+
export const MAX_MOVED_ENTRIES = 12;
|
|
58
|
+
/** At most this many peer files are read for one file. */
|
|
59
|
+
export const MAX_PEERS = 50;
|
|
60
|
+
/** A peer (or own) diff larger than this many bytes is not scanned. */
|
|
61
|
+
export const MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/** At most this many peer positions are tried for one line (a very common line stays cheap). */
|
|
64
|
+
const MAX_CANDIDATES = 200;
|
|
65
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
66
|
+
/**
|
|
67
|
+
* `git diff` for a peer: `a/`/`b/` prefixes forced (the chunks are matched on them), whatever the
|
|
68
|
+
* repository's `diff.noprefix` / `diff.mnemonicPrefix` say; non-ASCII names unquoted
|
|
69
|
+
* (`core.quotePath=false`; a name git still quotes is never a peer — `printable`); no colour, no
|
|
70
|
+
* external diff.
|
|
71
|
+
*/
|
|
72
|
+
export const GIT_DIFF = Object.freeze(['git', '-c', 'core.quotePath=false', '-c', 'diff.noprefix=false', '-c', 'diff.mnemonicPrefix=false', 'diff', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']);
|
|
73
|
+
|
|
74
|
+
/** The `## moved code` section heading. */
|
|
75
|
+
export const MOVED_HEAD = '## moved code';
|
|
76
|
+
|
|
77
|
+
const ALNUM = /[\p{L}\p{N}]/gu;
|
|
78
|
+
/**
|
|
79
|
+
* A path printed in the packet may not carry a control character (it could forge a section or
|
|
80
|
+
* reorder the text): C0, DEL, C1, the line/paragraph separators and the bidi controls.
|
|
81
|
+
*/
|
|
82
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
|
|
83
|
+
/** … nor a character git quotes in a diff header even with `core.quotePath=false` (`"`, `\`). */
|
|
84
|
+
const QUOTED = /["\\]/;
|
|
85
|
+
|
|
86
|
+
/** @param {unknown} p @returns {boolean} whether `p` may be printed and matched as a peer path. */
|
|
87
|
+
const printable = (p) => typeof p === 'string' && !CONTROL.test(p) && !QUOTED.test(p);
|
|
88
|
+
|
|
89
|
+
/** @param {string} line @returns {string} */
|
|
90
|
+
export const normaliseLine = (line) => line.trim().replace(/\s+/g, ' ');
|
|
91
|
+
|
|
92
|
+
/** @param {string} norm @returns {boolean} */
|
|
93
|
+
export const isSignificant = (norm) => (norm.match(ALNUM)?.length ?? 0) >= MIN_LINE_ALNUM;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {{line: number, norm: string, at: number}} SigLine - `line`: its number on its own side;
|
|
97
|
+
* `at`: its position in the current file (a removed line: the current line just before the cut, 0 at the top).
|
|
98
|
+
*/
|
|
99
|
+
/** @typedef {SigLine[]} Run - the significant lines of one run, in order. */
|
|
100
|
+
/** @typedef {{file: string, diffText: string}} PeerDiff */
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The removed and added runs of one unified diff (one file), significant lines only.
|
|
104
|
+
* @param {string} diffText @returns {{removed: Run[], added: Run[]}}
|
|
105
|
+
*/
|
|
106
|
+
export function diffRuns(diffText) {
|
|
107
|
+
/** @type {Run[]} */
|
|
108
|
+
const removed = [];
|
|
109
|
+
/** @type {Run[]} */
|
|
110
|
+
const added = [];
|
|
111
|
+
if (typeof diffText !== 'string' || Buffer.byteLength(diffText) > MAX_DIFF_BYTES) return { removed, added };
|
|
112
|
+
const lines = diffText.split('\n');
|
|
113
|
+
const first = lines.findIndex((l) => l.startsWith('@@'));
|
|
114
|
+
if (first < 0) return { removed, added };
|
|
115
|
+
let oldNo = 0;
|
|
116
|
+
let newNo = 0;
|
|
117
|
+
/** @type {'-' | '+' | null} */
|
|
118
|
+
let open = null;
|
|
119
|
+
/** @type {Run} */
|
|
120
|
+
let run = [];
|
|
121
|
+
const close = () => {
|
|
122
|
+
if (open === '-' && run.length > 0) removed.push(run);
|
|
123
|
+
if (open === '+' && run.length > 0) added.push(run);
|
|
124
|
+
open = null;
|
|
125
|
+
run = [];
|
|
126
|
+
};
|
|
127
|
+
for (const line of lines.slice(first)) {
|
|
128
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
129
|
+
if (m) {
|
|
130
|
+
close();
|
|
131
|
+
oldNo = Number(m[1]);
|
|
132
|
+
// an empty new side names the line BEFORE the change: the cut sits after it
|
|
133
|
+
newNo = m[3] === '0' ? Number(m[2]) + 1 : Number(m[2]);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const sign = line[0];
|
|
137
|
+
if (sign !== '-' && sign !== '+') {
|
|
138
|
+
close();
|
|
139
|
+
// a context line advances both sides; `` advances none
|
|
140
|
+
if (sign === ' ') {
|
|
141
|
+
oldNo += 1;
|
|
142
|
+
newNo += 1;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (open !== sign) close();
|
|
147
|
+
open = sign;
|
|
148
|
+
const at = newNo - 1;
|
|
149
|
+
const no = sign === '-' ? oldNo++ : newNo++;
|
|
150
|
+
const norm = normaliseLine(line.slice(1));
|
|
151
|
+
if (isSignificant(norm)) run.push({ line: no, norm, at: sign === '-' ? at : no });
|
|
152
|
+
}
|
|
153
|
+
close();
|
|
154
|
+
return { removed, added };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @typedef {object} MoveMatch
|
|
159
|
+
* @property {number} start - first significant line of the stretch in THIS file (its own side).
|
|
160
|
+
* @property {number} end
|
|
161
|
+
* @property {number} at - its position in the current file (section filtering).
|
|
162
|
+
* @property {string} peer - the other file.
|
|
163
|
+
* @property {number} peerStart
|
|
164
|
+
* @property {number} peerEnd
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/** @typedef {{out: MoveMatch[], into: MoveMatch[]}} Moves */
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The moved stretches of `own` runs found in the `peers` runs (see the module doc).
|
|
171
|
+
* @param {Run[]} own @param {Array<{file: string, runs: Run[]}>} peers @returns {MoveMatch[]}
|
|
172
|
+
*/
|
|
173
|
+
export function matchRuns(own, peers) {
|
|
174
|
+
/** @type {Map<string, Array<{p: number, r: number, i: number}>>} */
|
|
175
|
+
const index = new Map();
|
|
176
|
+
peers.forEach((peer, p) =>
|
|
177
|
+
peer.runs.forEach((run, r) =>
|
|
178
|
+
run.forEach((sig, i) => {
|
|
179
|
+
const at = index.get(sig.norm);
|
|
180
|
+
if (at) at.push({ p, r, i });
|
|
181
|
+
else index.set(sig.norm, [{ p, r, i }]);
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
/** @type {MoveMatch[]} */
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const run of own) {
|
|
188
|
+
let k = 0;
|
|
189
|
+
while (k < run.length) {
|
|
190
|
+
let best = { len: 0, chars: 0, p: -1, r: -1, i: -1 };
|
|
191
|
+
for (const c of (index.get(run[k].norm) ?? []).slice(0, MAX_CANDIDATES)) {
|
|
192
|
+
const target = peers[c.p].runs[c.r];
|
|
193
|
+
let len = 0;
|
|
194
|
+
let chars = 0;
|
|
195
|
+
while (k + len < run.length && c.i + len < target.length && run[k + len].norm === target[c.i + len].norm) {
|
|
196
|
+
chars += run[k + len].norm.length;
|
|
197
|
+
len += 1;
|
|
198
|
+
}
|
|
199
|
+
if (len > best.len) best = { len, chars, ...c };
|
|
200
|
+
}
|
|
201
|
+
if (best.len > 0 && (best.len >= MIN_MOVED_LINES || best.chars >= MIN_MOVED_CHARS)) {
|
|
202
|
+
const target = peers[best.p].runs[best.r];
|
|
203
|
+
out.push({ start: run[k].line, end: run[k + best.len - 1].line, at: run[k].at, peer: peers[best.p].file, peerStart: target[best.i].line, peerEnd: target[best.i + best.len - 1].line });
|
|
204
|
+
k += best.len;
|
|
205
|
+
} else {
|
|
206
|
+
k += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The moves between one file's diff and its peers' diffs (pure).
|
|
215
|
+
* @param {{diff: {diffText: string}, peers: ReadonlyArray<PeerDiff>}} opts @returns {Moves}
|
|
216
|
+
*/
|
|
217
|
+
export function movedCode({ diff, peers }) {
|
|
218
|
+
const others = [...peers]
|
|
219
|
+
.filter((p) => printable(p.file))
|
|
220
|
+
.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
|
|
221
|
+
.slice(0, MAX_PEERS)
|
|
222
|
+
.map((p) => ({ file: p.file, runs: diffRuns(p.diffText) }));
|
|
223
|
+
if (others.length === 0) return { out: [], into: [] };
|
|
224
|
+
const own = diffRuns(diff.diffText);
|
|
225
|
+
return {
|
|
226
|
+
out: matchRuns(own.removed, others.map((p) => ({ file: p.file, runs: p.runs.added }))),
|
|
227
|
+
into: matchRuns(own.added, others.map((p) => ({ file: p.file, runs: p.runs.removed }))),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {number} a @param {number} b @returns {string} */
|
|
232
|
+
const range = (a, b) => (a === b ? `${a}` : `${a}-${b}`);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The `## moved code` section for these moves: '' when there is none.
|
|
236
|
+
* @param {Moves} moves @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function renderMoved({ out, into }) {
|
|
239
|
+
if (out.length === 0 && into.length === 0) return '';
|
|
240
|
+
const entries = [
|
|
241
|
+
...out.map((m) => `- base lines ${range(m.start, m.end)} removed here were added in ${m.peer} lines ${range(m.peerStart, m.peerEnd)} in this block.`),
|
|
242
|
+
...into.map((m) => `- lines ${range(m.start, m.end)} were moved here from ${m.peer} (its base lines ${range(m.peerStart, m.peerEnd)}) in this block.`),
|
|
243
|
+
];
|
|
244
|
+
const shown = entries.slice(0, MAX_MOVED_ENTRIES);
|
|
245
|
+
if (entries.length > shown.length) shown.push(`- (${entries.length - shown.length} more moved ranges not listed)`);
|
|
246
|
+
const advice = [];
|
|
247
|
+
if (out.length > 0) advice.push('Do not report the removal of moved lines itself as a defect; review only whether references and imports are updated.');
|
|
248
|
+
if (into.length > 0) advice.push('Moved-in lines are reviewed in their new place like any change (imports, references, behaviour).');
|
|
249
|
+
return [MOVED_HEAD, ...shown, ...advice].join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The `## moved code` section for one file (pure): '' when nothing moved.
|
|
254
|
+
* @param {{diff: {diffText: string}, peers: ReadonlyArray<PeerDiff>}} opts @returns {string}
|
|
255
|
+
*/
|
|
256
|
+
export function movedSection(opts) {
|
|
257
|
+
return renderMoved(movedCode(opts));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** One-entry cache: `markdownHeadings` of the last content asked (packing asks many times). */
|
|
261
|
+
let headingCache = { content: /** @type {string | null | undefined} */ (undefined), keys: /** @type {number[]} */ ([]) };
|
|
262
|
+
|
|
263
|
+
/** @param {string | null} content @returns {number[]} the heading lines, ascending. */
|
|
264
|
+
function headingLines(content) {
|
|
265
|
+
if (headingCache.content !== content) headingCache = { content, keys: [...markdownHeadings(content).keys()].sort((a, b) => a - b) };
|
|
266
|
+
return headingCache.keys;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The section text a packet carries. `keys` null ⇒ every move (a whole-file packet: the ONE
|
|
271
|
+
* whole-file sentinel); an array ⇒ only the moves whose current-file position falls under one of
|
|
272
|
+
* those heading groups (`SectionGroup.key`: the heading line, 0 = before the first heading) — an
|
|
273
|
+
* empty array, or no move there, ⇒ ''.
|
|
274
|
+
* @param {Moves | null | undefined} moves @param {string | null} content - the CURRENT file.
|
|
275
|
+
* @param {ReadonlyArray<number> | null} [keys]
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
export function movedText(moves, content, keys = null) {
|
|
279
|
+
if (!moves) return '';
|
|
280
|
+
if (keys === null) return renderMoved(moves);
|
|
281
|
+
const heads = headingLines(content);
|
|
282
|
+
/** @param {MoveMatch} m */
|
|
283
|
+
const inKeys = (m) => {
|
|
284
|
+
let key = 0;
|
|
285
|
+
for (const h of heads) if (h <= m.at) key = h;
|
|
286
|
+
return keys.includes(key);
|
|
287
|
+
};
|
|
288
|
+
return renderMoved({ out: moves.out.filter(inKeys), into: moves.into.filter(inKeys) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The block's other changed files' diffs against `base`, read with as few git calls as possible
|
|
293
|
+
* (see the module doc). A peer that is not a plain repo path, is secret-like, ignored, unchanged,
|
|
294
|
+
* whose diff is over {@link MAX_DIFF_BYTES}, or whose diff cannot be split out of the listing gives
|
|
295
|
+
* nothing. Throws only when `git ls-tree` or `git check-ignore` fails.
|
|
296
|
+
* @param {{repoRoot: string, base: string, file: string, peers: ReadonlyArray<string>}} opts
|
|
297
|
+
* @returns {Promise<PeerDiff[]>}
|
|
298
|
+
*/
|
|
299
|
+
export async function readPeerDiffs({ repoRoot, base, file, peers }) {
|
|
300
|
+
if (base.length === 0 || base.startsWith('-')) return [];
|
|
301
|
+
/** @type {string[]} */
|
|
302
|
+
const rels = [];
|
|
303
|
+
for (const p of [...new Set(peers)].sort()) {
|
|
304
|
+
if (!printable(p) || p === file) continue;
|
|
305
|
+
try {
|
|
306
|
+
const rel = assertPacketPath(repoRoot, p);
|
|
307
|
+
if (!isSecretLike(rel)) rels.push(rel);
|
|
308
|
+
} catch {
|
|
309
|
+
// not a plain repo file ⇒ no hint from it
|
|
310
|
+
}
|
|
311
|
+
if (rels.length >= MAX_PEERS) break;
|
|
312
|
+
}
|
|
313
|
+
if (rels.length === 0) return [];
|
|
314
|
+
const env = reviewGitEnv();
|
|
315
|
+
const cap = { maxBufferBytes: MAX_DIFF_BYTES };
|
|
316
|
+
// "in base" (not "in the index"): a peer deleted in the block — `git rm` or gone from the
|
|
317
|
+
// working tree — is still diffed against the base, every line removed
|
|
318
|
+
const listed = await exec(['git', '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', base, '--', ...rels], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
|
|
319
|
+
if (listed.result !== 'ok') throw new Error('git ls-tree failed');
|
|
320
|
+
const inBase = new Set(listed.stdout.split('\0').filter((f) => f.length > 0));
|
|
321
|
+
/** @type {PeerDiff[]} */
|
|
322
|
+
const out = [];
|
|
323
|
+
const trackedRels = rels.filter((r) => inBase.has(r));
|
|
324
|
+
if (trackedRels.length > 0) {
|
|
325
|
+
/** @param {string[]} files */
|
|
326
|
+
const listing = (files) => exec([...GIT_DIFF, '--no-renames', base, '--', ...files], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...cap });
|
|
327
|
+
/** @param {string} stdout @param {string[]} files - one chunk per file, named by its exact `diff --git a/<f> b/<f>` line (a quoted name matches none) */
|
|
328
|
+
const take = (stdout, files) => {
|
|
329
|
+
const chunks = stdout.split(/^(?=diff --git )/m);
|
|
330
|
+
for (const rel of files) {
|
|
331
|
+
const chunk = chunks.find((c) => c.startsWith(`diff --git a/${rel} b/${rel}\n`));
|
|
332
|
+
if (chunk && chunk.trim().length > 0) out.push({ file: rel, diffText: chunk });
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const res = await listing(trackedRels);
|
|
336
|
+
if (res.result === 'ok') take(res.stdout, trackedRels);
|
|
337
|
+
else if (trackedRels.length > 1) {
|
|
338
|
+
// the listing is over the cap (or failed): peer by peer, one over the cap alone is skipped
|
|
339
|
+
// (a single peer's failed listing is that skip already)
|
|
340
|
+
for (const rel of trackedRels) {
|
|
341
|
+
const one = await listing([rel]);
|
|
342
|
+
if (one.result === 'ok') take(one.stdout, [rel]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// absent from the base: a new file, read from disk (`--no-index`) unless git ignores it
|
|
347
|
+
const untracked = rels.filter((r) => !inBase.has(r) && existsSync(path.join(repoRoot, r)));
|
|
348
|
+
if (untracked.length > 0) {
|
|
349
|
+
// `check-ignore` refuses the literal-pathspec magic: plain env (no glob, no leading `:` here);
|
|
350
|
+
// the paths go on stdin, NUL-separated (`-z` needs `--stdin`)
|
|
351
|
+
const ignored = await exec(['git', 'check-ignore', '-z', '--stdin'], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1], input: `${untracked.join('\0')}\0` });
|
|
352
|
+
if (ignored.result !== 'ok') throw new Error('git check-ignore failed');
|
|
353
|
+
const skip = new Set(ignored.stdout.split('\0'));
|
|
354
|
+
for (const rel of untracked.filter((r) => !skip.has(r))) {
|
|
355
|
+
const res = await exec([...GIT_DIFF, '--no-index', '--', '/dev/null', rel], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1], ...cap });
|
|
356
|
+
if (res.result === 'ok' && res.stdout.trim().length > 0) out.push({ file: rel, diffText: res.stdout });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A memoised reader of the peers' diffs: the listing runs at most ONCE however often it is asked
|
|
364
|
+
* (one per ticket round: round 1, every section, the recheck). No base or no peer ⇒ []; a git
|
|
365
|
+
* failure ⇒ [] (no hint, the review goes on).
|
|
366
|
+
* @param {{repoRoot: string, base: string | null | undefined, file: string, peers?: ReadonlyArray<string>}} opts
|
|
367
|
+
* @returns {() => Promise<PeerDiff[]>}
|
|
368
|
+
*/
|
|
369
|
+
export function peerDiffReader({ repoRoot, base, file, peers }) {
|
|
370
|
+
/** @type {Promise<PeerDiff[]> | null} */
|
|
371
|
+
let once = null;
|
|
372
|
+
return () => {
|
|
373
|
+
if (once === null) {
|
|
374
|
+
once =
|
|
375
|
+
typeof base !== 'string' || !Array.isArray(peers) || peers.length === 0
|
|
376
|
+
? Promise.resolve([])
|
|
377
|
+
: readPeerDiffs({ repoRoot, base, file, peers }).catch(() => []);
|
|
378
|
+
}
|
|
379
|
+
return once;
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* This file's moves, from its diff against the base and the peers' diffs. Never throws: any
|
|
385
|
+
* failure ⇒ null (no section).
|
|
386
|
+
* @param {{diff: {diffText: string}, peerDiffs?: () => Promise<PeerDiff[]>}} opts
|
|
387
|
+
* @returns {Promise<Moves | null>}
|
|
388
|
+
*/
|
|
389
|
+
export async function movesFor({ diff, peerDiffs }) {
|
|
390
|
+
if (!peerDiffs) return null;
|
|
391
|
+
try {
|
|
392
|
+
const peers = await peerDiffs();
|
|
393
|
+
if (peers.length === 0) return null;
|
|
394
|
+
const moves = movedCode({ diff, peers });
|
|
395
|
+
return moves.out.length + moves.into.length > 0 ? moves : null;
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
package/src/review/packet.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* ## project rules the rules digest (≤ 60 lines)
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
|
+
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
13
14
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
14
15
|
*
|
|
15
16
|
* Diff source (§4.1): a file in the index is diffed against the block's base SHA
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
21
22
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
22
23
|
* ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end; the diff is
|
|
23
|
-
* never cut.
|
|
24
|
+
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
25
|
+
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
24
26
|
*
|
|
25
27
|
* Path rule (V4): `file` is repo-root-relative; `./`, `../`, absolute values, symlinks and paths
|
|
26
28
|
* under `.git/` or `.code-forge/` are refused with `bad-path` before git runs. So is anything git
|
|
@@ -54,10 +56,13 @@ const PATHSPEC_MAGIC = /[*?[\]]/;
|
|
|
54
56
|
const SECRET_LIKE = [/(^|\/)\.env[^/]*$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i, /(^|\/)credentials[^/]*$/i, /(^|\/)\.code-forge(\/|$)/i];
|
|
55
57
|
|
|
56
58
|
/** @param {string} rel @returns {boolean} */
|
|
57
|
-
const isSecretLike = (rel) => SECRET_LIKE.some((re) => re.test(rel));
|
|
59
|
+
export const isSecretLike = (rel) => SECRET_LIKE.some((re) => re.test(rel));
|
|
58
60
|
|
|
59
61
|
/** The env for every child git in this module: B11's clean env plus literal pathspecs. */
|
|
60
|
-
const reviewGitEnv = () => ({ ...gitChildEnv(), GIT_LITERAL_PATHSPECS: '1' });
|
|
62
|
+
export const reviewGitEnv = () => ({ ...gitChildEnv(), GIT_LITERAL_PATHSPECS: '1' });
|
|
63
|
+
|
|
64
|
+
/** B11's clean env alone: for `git check-ignore`, which refuses the literal-pathspec magic. */
|
|
65
|
+
export const plainGitEnv = () => gitChildEnv();
|
|
61
66
|
|
|
62
67
|
/** A refusal with a stable `code`: `bad-path`, `bad-lens`, `git`. */
|
|
63
68
|
export class PacketError extends Error {
|
|
@@ -185,49 +190,50 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
185
190
|
* Assemble a reviewer packet (pure).
|
|
186
191
|
* @param {{
|
|
187
192
|
* diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
|
|
188
|
-
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number,
|
|
189
|
-
* }} opts
|
|
193
|
+
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
|
|
194
|
+
* }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
|
|
190
195
|
* @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
|
|
191
196
|
*/
|
|
192
|
-
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget }) {
|
|
197
|
+
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
|
|
193
198
|
const lensText = loadLens(lens);
|
|
194
199
|
const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
|
|
195
200
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
196
201
|
const diffSection = diffSectionOf(diff);
|
|
197
|
-
const bare = bareTokens(lensText, diffSection);
|
|
202
|
+
const bare = bareTokens(lensText, diffSection, moved);
|
|
198
203
|
if (bare > limit) return { status: 'split_required', tokensIn: bare, budget: limit };
|
|
199
204
|
|
|
200
205
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
201
206
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
202
|
-
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
|
|
207
|
+
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
203
208
|
if (estimateTokens(text) > limit) {
|
|
204
209
|
ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
205
|
-
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
|
|
210
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
206
211
|
}
|
|
207
212
|
while (estimateTokens(text) > limit && digest.length > 0) {
|
|
208
213
|
digest = digest.slice(0, -1);
|
|
209
|
-
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
|
|
214
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
210
215
|
}
|
|
211
216
|
const tokensIn = estimateTokens(text);
|
|
212
217
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
/**
|
|
216
|
-
* The tokens of the diff-only packet (lens + diff, no digest, facts or context): what
|
|
217
|
-
* `assemblePacket` holds against the budget before it says `split_required`.
|
|
218
|
-
*
|
|
221
|
+
* The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
|
|
222
|
+
* `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
|
|
223
|
+
* section the packet gets.
|
|
224
|
+
* @param {{diff: FileDiff, lens: string, moved?: string}} opts @returns {number}
|
|
219
225
|
*/
|
|
220
|
-
export function diffOnlyTokens({ diff, lens }) {
|
|
221
|
-
return bareTokens(loadLens(lens), diffSectionOf(diff));
|
|
226
|
+
export function diffOnlyTokens({ diff, lens, moved = '' }) {
|
|
227
|
+
return bareTokens(loadLens(lens), diffSectionOf(diff), moved);
|
|
222
228
|
}
|
|
223
229
|
|
|
224
230
|
/**
|
|
225
231
|
* The ONE measure behind `split_required` (`assemblePacket`) and section packing (`diffOnlyTokens`,
|
|
226
232
|
* B54), so the two can never disagree.
|
|
227
|
-
* @param {string} lensText @param {string} diffSection @returns {number}
|
|
233
|
+
* @param {string} lensText @param {string} diffSection @param {string} moved @returns {number}
|
|
228
234
|
*/
|
|
229
|
-
function bareTokens(lensText, diffSection) {
|
|
230
|
-
return estimateTokens(render(lensText, [], '', '', diffSection));
|
|
235
|
+
function bareTokens(lensText, diffSection, moved) {
|
|
236
|
+
return estimateTokens(render(lensText, [], '', '', diffSection, moved));
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
/** @param {FileDiff} diff @returns {string} the `## diff` section body. */
|
|
@@ -237,9 +243,9 @@ function diffSectionOf(diff) {
|
|
|
237
243
|
|
|
238
244
|
/**
|
|
239
245
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
240
|
-
* @param {string} diffSection
|
|
246
|
+
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
241
247
|
*/
|
|
242
|
-
function render(lensText, digest, facts, context, diffSection) {
|
|
248
|
+
function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
243
249
|
return [
|
|
244
250
|
'# code-forge review packet',
|
|
245
251
|
'## lens',
|
|
@@ -250,6 +256,7 @@ function render(lensText, digest, facts, context, diffSection) {
|
|
|
250
256
|
facts.trim().length > 0 ? facts.trimEnd() : '(none)',
|
|
251
257
|
'## context',
|
|
252
258
|
context.length > 0 ? context : '(none)',
|
|
259
|
+
...(moved.length > 0 ? [moved] : []),
|
|
253
260
|
'## diff',
|
|
254
261
|
diffSection,
|
|
255
262
|
'',
|
|
@@ -271,11 +278,12 @@ export async function buildPacket(opts) {
|
|
|
271
278
|
|
|
272
279
|
/**
|
|
273
280
|
* The judge's packet (§4.2 step 3, §4.4): the judge lens, the hunk list, the two blind reports,
|
|
274
|
-
* and the diff only when `review.judge_sees_diff` is true (R3, default false)
|
|
275
|
-
*
|
|
281
|
+
* and the diff only when `review.judge_sees_diff` is true (R3, default false); the file's
|
|
282
|
+
* `## moved code` section (B56) before the diff when there is one.
|
|
283
|
+
* @param {{diff: FileDiff, reports: {A: unknown, B: unknown}, cfg?: Record<string, any>, moved?: string}} opts
|
|
276
284
|
* @returns {{status: 'ok', text: string, hunkHeaders: string[], tokensIn: number, budget: number, contextMode: null}}
|
|
277
285
|
*/
|
|
278
|
-
export function assembleJudgePacket({ diff, reports, cfg }) {
|
|
286
|
+
export function assembleJudgePacket({ diff, reports, cfg, moved = '' }) {
|
|
279
287
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
280
288
|
const seesDiff = cfg?.review?.judge_sees_diff === true;
|
|
281
289
|
const text = [
|
|
@@ -286,6 +294,7 @@ export function assembleJudgePacket({ diff, reports, cfg }) {
|
|
|
286
294
|
JSON.stringify(reports.A, null, 1),
|
|
287
295
|
'## reviewer B',
|
|
288
296
|
JSON.stringify(reports.B, null, 1),
|
|
297
|
+
...(moved.length > 0 ? [moved] : []),
|
|
289
298
|
'## diff',
|
|
290
299
|
[`file: ${diff.file}`, 'hunks:', ...hunkHeaders.map((h) => `- ${h}`), ...(seesDiff ? ['', diff.diffText.trimEnd()] : [])].join('\n'),
|
|
291
300
|
'',
|