@codedology/code-forge 0.7.3 → 0.7.5

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.
@@ -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
+ }
@@ -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
- * @param {{diff: FileDiff, lens: string}} opts @returns {number}
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
- * @param {{diff: FileDiff, reports: {A: unknown, B: unknown}, cfg?: Record<string, any>}} opts
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
  '',
@@ -203,14 +203,15 @@ function diffOf(diff, fileHeader, pieces) {
203
203
  * Pack the heading groups into section packets within `budget` (step 3 of the module doc). A
204
204
  * candidate's size is `measure(its diff, its groups)` (a recheck adds the open findings it lists
205
205
  * for those groups). Never `ok` with no section: a diff with no hunk, or one whose hunks do not
206
- * parse one to one, is `split_required` at `measure(diff, [])` with `section: null`.
207
- * @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[]) => number}} opts -
208
- * `measure`: the packet tokens of a candidate (the largest over the session lenses).
206
+ * parse one to one, is `split_required` at `measure(diff, null)` with `section: null`.
207
+ * @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[] | null) => number}} opts -
208
+ * `measure`: the packet tokens of a candidate (the largest over the session lenses); `groups`
209
+ * null ⇒ the whole file (B56: its whole moved-code section), never an empty section.
209
210
  * @returns {{status: 'ok', sections: Section[]} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
210
211
  */
211
212
  export function packSections({ diff, budget, measure }) {
212
213
  const groups = sectionGroups(diff);
213
- if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, []), budget, section: null };
214
+ if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, null), budget, section: null };
214
215
  /** @param {SectionGroup[]} set */
215
216
  const size = (set) => measure(merge(diff, set), set);
216
217
  /** @type {Section[]} */
@@ -29,6 +29,7 @@
29
29
  import { existsSync, readFileSync } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { assertOwned, findOverlap, ownsFile } from '../state/registry.mjs';
32
+ import { ownedKinds } from '../state/owned-kinds.mjs';
32
33
  import { extractClaims, parseSheet, sha256 } from './facts.mjs';
33
34
  import { findSection, nearMissWarning, REQUIRED_SECTIONS } from './plan-sections.mjs';
34
35
 
@@ -300,6 +301,9 @@ export function recordedLanes(rows, planName) {
300
301
  * `run reload` is then backed by a VERIFIED `<bin> run reload`.
301
302
  * @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
302
303
  * none given ⇒ no block has a recorded lane.
304
+ * @property {string} [root] - B57: the project root the owned paths are relative to; an exact owned
305
+ * path that is a regular file there owns only itself in the overlap and seam checks (as at
306
+ * `block open`). None given ⇒ every exact owned path is directory-like (fail closed).
303
307
  */
304
308
 
305
309
  /**
@@ -383,12 +387,23 @@ export function checkPlan(text, opts = {}) {
383
387
  else if (b.lines > limits.blockLines) errors.push(`block ${b.id}: lines forecast ${b.lines} exceeds budget.block_lines ${limits.blockLines} — split it`);
384
388
  }
385
389
 
386
- // disjoint owned files per wave
390
+ // disjoint owned files per wave (B57: an exact path owns the paths below it unless it is a file)
391
+ // (paths are looked at only once they pass `assertOwned`: never `..` or absolute)
392
+ /** @param {PlanBlock} b @returns {Record<string, string>} */
393
+ const kindsFor = (b) => {
394
+ if (opts.root === undefined) return {};
395
+ try {
396
+ return ownedKinds(opts.root, assertOwned(b.owned));
397
+ } catch {
398
+ return {};
399
+ }
400
+ };
401
+ const kindsOf = new Map(blocks.map((b) => [b.id, kindsFor(b)]));
387
402
  for (let i = 0; i < blocks.length; i += 1) {
388
403
  for (let j = i + 1; j < blocks.length; j += 1) {
389
404
  const [a, b] = [blocks[i], blocks[j]];
390
405
  if (a.wave !== b.wave) continue;
391
- const hit = findOverlap(a.owned, b.owned);
406
+ const hit = findOverlap(a.owned, b.owned, { kindsA: kindsOf.get(a.id), kindsB: kindsOf.get(b.id) });
392
407
  if (hit) errors.push(`blocks ${a.id} and ${b.id} (wave ${a.wave}) overlap: ${hit[0]} / ${hit[1]}`);
393
408
  }
394
409
  }
@@ -398,7 +413,7 @@ export function checkPlan(text, opts = {}) {
398
413
  for (const b of blocks) {
399
414
  const deps = closure(blocks, b.id);
400
415
  for (const imported of b.imports) {
401
- const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported));
416
+ const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported, kindsOf.get(o.id)));
402
417
  if (owner && !deps.has(owner.id)) errors.push(`block ${b.id} imports ${imported} owned by ${owner.id}, which is not in its depends_on`);
403
418
  }
404
419
  }