@codedology/code-forge 0.7.3 → 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 +11 -0
- package/README.md +7 -0
- package/docs/reference/blocks.json +17 -1
- package/package.json +1 -1
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/src/review/engine.mjs +43 -13
- package/src/review/fixloop.mjs +88 -18
- package/src/review/moved.mjs +399 -0
- package/src/review/packet.mjs +32 -23
- package/src/review/sections.mjs +5 -4
- package/src/worker/engine.mjs +54 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.4] — 2026-10-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Review: code moved between two changed files of the same block is named in a ## moved code packet section
|
|
14
|
+
(deterministic line matching against the block's other changed owned files, same base: >= 3 significant lines or >=
|
|
15
|
+
120 significant characters; at most 12 ranges; a Markdown section packet lists only the moves in that section) in
|
|
16
|
+
round 1, rechecks, patch checks and the judge packet, so the source file's reviewer no longer reports the removal as a
|
|
17
|
+
deleted feature; every hunk and the reviewed_hunks check are unchanged, and the section counts toward the packet
|
|
18
|
+
budget (issue #2).
|
|
19
|
+
|
|
9
20
|
## [0.7.3] — 2026-10-08
|
|
10
21
|
|
|
11
22
|
### Added
|
package/README.md
CHANGED
|
@@ -12,6 +12,13 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
|
|
|
12
12
|
|
|
13
13
|
Next release:
|
|
14
14
|
|
|
15
|
+
- **Code moved between files is no longer read as deleted.** When a block moves code from one
|
|
16
|
+
file to another, each file's review packet now says where the lines went (or came from), so the
|
|
17
|
+
reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
|
|
18
|
+
hunk is still reviewed.
|
|
19
|
+
|
|
20
|
+
In 0.7.3:
|
|
21
|
+
|
|
15
22
|
- **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
|
|
16
23
|
fails the schema twice on the same packet and you set `review.second_levels` for that level, the
|
|
17
24
|
worker retries that packet once with the second model, and the ledger records it. Each failed
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"plans/code-forge-plan-v1.3.md §10.4",
|
|
5
5
|
"plans/NIGHT-LOG.md"
|
|
6
6
|
],
|
|
7
|
-
"block_count":
|
|
7
|
+
"block_count": 59,
|
|
8
8
|
"baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
|
|
9
9
|
"baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
|
|
10
10
|
"blocks": {
|
|
@@ -794,6 +794,22 @@
|
|
|
794
794
|
"B54"
|
|
795
795
|
],
|
|
796
796
|
"note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06; also amends src/review/{engine,fixloop,validate-review}.mjs, src/worker/engine.mjs, src/session/spawn.mjs (stderr tail moved to util), src/autopilot/binnacle.mjs, test fakes, docs/how-it-works.md, skill/references/review.md"
|
|
797
|
+
},
|
|
798
|
+
"B56": {
|
|
799
|
+
"title": "Moved-code hint (issue #2): code moved between changed files of the same block is detected by deterministic line matching (>= 3 significant lines or >= 120 significant characters; peers in the base diffed against it, deleted peers included) and named in a ## moved code packet section on both files, in round 1, rechecks, patch checks, the judge packet and per Markdown section; extra text only (every hunk and reviewed_hunks unchanged); counted in the packet budget with one measure; unsafe peer names never read or printed; peer diffs read once per ticket with a 2 MiB per-peer cap",
|
|
800
|
+
"wave": 12,
|
|
801
|
+
"level": "L2",
|
|
802
|
+
"status": "landed",
|
|
803
|
+
"owned": [
|
|
804
|
+
"src/review/moved.mjs",
|
|
805
|
+
"test/review/moved.test.mjs",
|
|
806
|
+
"test/review/moved-wiring.test.mjs",
|
|
807
|
+
"test/worker/moved-peers-guard.test.mjs"
|
|
808
|
+
],
|
|
809
|
+
"depends_on": [
|
|
810
|
+
"B55"
|
|
811
|
+
],
|
|
812
|
+
"note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06 (stale critical from a cross-file move); also amends src/review/{packet,engine,fixloop,sections}.mjs, src/worker/engine.mjs, docs/how-it-works.md, skill/references/review.md"
|
|
797
813
|
}
|
|
798
814
|
}
|
|
799
815
|
}
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
15
15
|
|
|
16
16
|
## §3 Isolation and the packet
|
|
17
17
|
|
|
18
|
-
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
18
|
+
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
19
19
|
|
|
20
20
|
## §4 The stub guard — what counts as a review (R11)
|
|
21
21
|
|
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.7.
|
|
13
|
+
PINNED_VERSION="0.7.4"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/review/engine.mjs
CHANGED
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
* reviewed (the file is then `unavailable`). The file is approved only when EVERY section is;
|
|
34
34
|
* the findings of all sections are merged with ids `S<section>.<id>`, every session summary
|
|
35
35
|
* names its `section`, and `sections` lists them. A section that alone is still over the
|
|
36
|
-
* budget keeps the file `split_required` (plus `section`: its heading) — never truncated
|
|
36
|
+
* budget keeps the file `split_required` (plus `section`: its heading) — never truncated;
|
|
37
|
+
* 8. code moved between this file and another changed file of the block (`input.peers`, both
|
|
38
|
+
* diffed against the block base) is named in a `## moved code` section of every packet —
|
|
39
|
+
* reviewers and judge; a Markdown section packet lists only the moves in that section
|
|
40
|
+
* (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
|
|
41
|
+
* reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
|
|
42
|
+
* and it counts toward the budget in the same measure.
|
|
37
43
|
* Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
|
|
38
44
|
* the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
|
|
39
45
|
* `warning` finding.
|
|
@@ -47,6 +53,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
|
47
53
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
48
54
|
import { tierFor } from '../proof/tiers.mjs';
|
|
49
55
|
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
56
|
+
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
50
57
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
51
58
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
52
59
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
@@ -160,6 +167,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
|
|
|
160
167
|
* @property {string} [kind] - the block kind (`code`, `docs`, `contract`; B34); absent ⇒ `blockKind` of the reviewed file.
|
|
161
168
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
162
169
|
* @property {string} workDir - where packet files are written (under the run's temp root).
|
|
170
|
+
* @property {ReadonlyArray<string>} [peers] - B56: the block's OTHER changed files (repo-relative);
|
|
171
|
+
* code moved between this file and one of them is named in every packet (`## moved code`).
|
|
172
|
+
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
|
|
173
|
+
* of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
|
|
163
174
|
*/
|
|
164
175
|
|
|
165
176
|
/**
|
|
@@ -212,6 +223,17 @@ export async function reviewFile(input, deps) {
|
|
|
212
223
|
if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
|
|
213
224
|
if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
|
|
214
225
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
226
|
+
// B56: the file's moves, read once; every packet and every measure of a part uses `movedOf`
|
|
227
|
+
/** @type {import('./moved.mjs').Moves | null} */
|
|
228
|
+
let moves = null;
|
|
229
|
+
try {
|
|
230
|
+
const peerDiffs = input.peerDiffs ?? peerDiffReader({ repoRoot, base: input.base, file: diff.file, peers: input.peers });
|
|
231
|
+
moves = await movesFor({ diff, peerDiffs });
|
|
232
|
+
} catch {
|
|
233
|
+
moves = null; // no hint; the review goes on
|
|
234
|
+
}
|
|
235
|
+
/** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
|
|
236
|
+
const movedOf = (keys) => movedText(moves, diff.content, keys);
|
|
215
237
|
const min = minTokensOut(cfg);
|
|
216
238
|
// B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
|
|
217
239
|
const schemaFallback = plan.mode !== 'consensus';
|
|
@@ -237,16 +259,17 @@ export async function reviewFile(input, deps) {
|
|
|
237
259
|
|
|
238
260
|
const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
239
261
|
const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
240
|
-
/** @param {import('./packet.mjs').FileDiff} part */
|
|
241
|
-
const packetsFor = (part) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
|
|
262
|
+
/** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
|
|
263
|
+
const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
|
|
242
264
|
|
|
243
265
|
/**
|
|
244
266
|
* The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
|
|
245
267
|
* @param {import('./packet.mjs').FileDiff} part @param {Array<Record<string, any>>} packets
|
|
246
268
|
* @param {Record<string, any>} [rowExtra] - added to the `review.plan` row (`section`).
|
|
269
|
+
* @param {ReadonlyArray<number> | null} [keys] - the section's group keys (its moved-code hint).
|
|
247
270
|
* @returns {Promise<{status: 'reviewed' | 'unavailable', reason?: string, approved: boolean, summary?: string, findings?: Array<Record<string, any>>, sessions: Array<Record<string, any>>}>}
|
|
248
271
|
*/
|
|
249
|
-
const reviewPart = async (part, packets, rowExtra = {}) => {
|
|
272
|
+
const reviewPart = async (part, packets, rowExtra = {}, keys = null) => {
|
|
250
273
|
await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: packets[0]?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0), ...rowExtra });
|
|
251
274
|
const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
|
|
252
275
|
const sessions = done.map(publicSummary);
|
|
@@ -258,7 +281,7 @@ export async function reviewFile(input, deps) {
|
|
|
258
281
|
let final = done[0];
|
|
259
282
|
if (judgeSpec) {
|
|
260
283
|
const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
|
|
261
|
-
const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
|
|
284
|
+
const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg, moved: movedOf(keys) });
|
|
262
285
|
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
|
|
263
286
|
sessions.push(publicSummary(final));
|
|
264
287
|
if (final.status !== 'ok') {
|
|
@@ -281,18 +304,25 @@ export async function reviewFile(input, deps) {
|
|
|
281
304
|
if (!isMarkdownFile(diff.file)) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
|
|
282
305
|
|
|
283
306
|
// B54: a Markdown file over the budget is reviewed section by section
|
|
284
|
-
/**
|
|
285
|
-
|
|
307
|
+
/**
|
|
308
|
+
* @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<{key: number}> | null} [groups] - null ⇒ the whole file.
|
|
309
|
+
* @returns {number} the diff-only packet tokens, largest over the lenses.
|
|
310
|
+
*/
|
|
311
|
+
const measure = (part, groups = null) => {
|
|
312
|
+
const moved = movedOf(groups === null ? null : groups.map((g) => g.key));
|
|
313
|
+
return Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens, moved })));
|
|
314
|
+
};
|
|
286
315
|
const packed = packSections({ diff, budget: split.budget, measure });
|
|
287
316
|
/**
|
|
288
317
|
* A `split_required` outcome, its size always numeric (measured here when the packet lacks it).
|
|
289
318
|
* @param {import('./packet.mjs').FileDiff} part @param {{tokensIn?: unknown, budget?: unknown}} over @param {string | null} section
|
|
319
|
+
* @param {ReadonlyArray<{key: number}> | null} [groups] - the part's heading groups (its moved-code hint); null ⇒ the whole file.
|
|
290
320
|
*/
|
|
291
|
-
const splitOutcome = (part, over, section) => ({
|
|
321
|
+
const splitOutcome = (part, over, section, groups = null) => ({
|
|
292
322
|
status: 'split_required',
|
|
293
323
|
approved: false,
|
|
294
324
|
...head,
|
|
295
|
-
tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part),
|
|
325
|
+
tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part, groups),
|
|
296
326
|
budget: Number.isFinite(over.budget) ? over.budget : split.budget,
|
|
297
327
|
...(section ? { section } : {}),
|
|
298
328
|
sessions: [],
|
|
@@ -300,7 +330,7 @@ export async function reviewFile(input, deps) {
|
|
|
300
330
|
if (packed.status !== 'ok') {
|
|
301
331
|
// a named section is measured on its own diff, never the whole file's
|
|
302
332
|
const group = packed.section ? sectionGroups(diff)?.find((g) => g.heading === packed.section) : undefined;
|
|
303
|
-
return splitOutcome(group ? group.diff : diff, packed, packed.section);
|
|
333
|
+
return splitOutcome(group ? group.diff : diff, packed, packed.section, group ? [group] : null);
|
|
304
334
|
}
|
|
305
335
|
// never approve with no section: no packet ⇒ no session ⇒ split_required at the whole size
|
|
306
336
|
if (packed.sections.length === 0) return splitOutcome(diff, split, null);
|
|
@@ -308,10 +338,10 @@ export async function reviewFile(input, deps) {
|
|
|
308
338
|
// one measure, so with the real packer none is ever not `ok`: this is a DEFENSIVE guard (a test
|
|
309
339
|
// reaches it through a mocked packer) — one that is not `ok` stops the file as split_required,
|
|
310
340
|
// sized on that section's own diff
|
|
311
|
-
const sectionPackets = packed.sections.map((section) => packetsFor(section.diff));
|
|
341
|
+
const sectionPackets = packed.sections.map((section) => packetsFor(section.diff, section.keys));
|
|
312
342
|
for (const [i, set] of sectionPackets.entries()) {
|
|
313
343
|
const over = /** @type {{tokensIn?: unknown, budget?: unknown} | undefined} */ (set.find((p) => p.status !== 'ok'));
|
|
314
|
-
if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)');
|
|
344
|
+
if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)', packed.sections[i].keys.map((key) => ({ key })));
|
|
315
345
|
}
|
|
316
346
|
const sections = packed.sections.map((s) => ({ index: s.index, headings: s.headings, hunks: s.diff.hunks.length }));
|
|
317
347
|
/** @type {Array<Record<string, any>>} */
|
|
@@ -321,7 +351,7 @@ export async function reviewFile(input, deps) {
|
|
|
321
351
|
const summaries = [];
|
|
322
352
|
let approved = true;
|
|
323
353
|
for (const [i, section] of packed.sections.entries()) {
|
|
324
|
-
const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length });
|
|
354
|
+
const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length }, section.keys);
|
|
325
355
|
sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
|
|
326
356
|
if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
|
|
327
357
|
approved = approved && part.approved;
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -59,6 +59,15 @@
|
|
|
59
59
|
* findings get ids `S<section>.<id>`; any section session unavailable ⇒ `retry` of the whole
|
|
60
60
|
* round. A section still over the budget alone ⇒ the terminal stop above (`split_required`).
|
|
61
61
|
*
|
|
62
|
+
* Moved code (B56): when `deps.peerDiffs` (or `deps.peers`) names the block's other changed files,
|
|
63
|
+
* every recheck and patch_check packet carries the file's `## moved code` section — computed from
|
|
64
|
+
* the file's diff against the block base (`deps.base`), never from the fix hunks, so a stale
|
|
65
|
+
* "feature deleted" finding on code that moved to another file meets the same hint as round 1. A
|
|
66
|
+
* Markdown section packet lists only the moves in its section. Sizing (`recheckTokens`,
|
|
67
|
+
* `packRecheck`) uses the same section text. The peers are read once per deps object (one ticket):
|
|
68
|
+
* the caller's `deps.peerDiffs` reader, else one memoised reader per deps (`recheckPeerDiffs`). Any
|
|
69
|
+
* failure there means no hint, never a failed recheck.
|
|
70
|
+
*
|
|
62
71
|
* Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
|
|
63
72
|
* packet's second miss is tried once on `review.second_levels.L2` when configured
|
|
64
73
|
* (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
|
|
@@ -79,6 +88,7 @@ import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
|
79
88
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
80
89
|
import { parseDiff } from './context.mjs';
|
|
81
90
|
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
91
|
+
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
82
92
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
83
93
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
84
94
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
@@ -128,7 +138,9 @@ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
|
128
138
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
129
139
|
* @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the block's L3 patch is in the tree (§3.6).
|
|
130
140
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
131
|
-
* @property {string | null} [base] - the block base (
|
|
141
|
+
* @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
|
|
142
|
+
* @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
|
|
143
|
+
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
|
|
132
144
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
133
145
|
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
134
146
|
* @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
|
|
@@ -170,11 +182,14 @@ export function mergePacketInfo(info, packet) {
|
|
|
170
182
|
* or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
|
|
171
183
|
* a finding no packet holds is listed in the LAST packet — every open finding is listed once.
|
|
172
184
|
* @param {import('./packet.mjs').FileDiff} diff @param {Finding[]} open @param {number} budget
|
|
185
|
+
* @param {import('./moved.mjs').Moves | null} [moves] - the file's moves (B56): each candidate is sized
|
|
186
|
+
* with the `## moved code` text of ITS sections (`movedText`), the whole size with all of them.
|
|
173
187
|
* @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
|
|
174
188
|
*/
|
|
175
|
-
export function packRecheck(diff, open, budget) {
|
|
189
|
+
export function packRecheck(diff, open, budget, moves = null) {
|
|
190
|
+
const moved = movedText(moves, diff.content, null);
|
|
176
191
|
const groups = sectionGroups(diff);
|
|
177
|
-
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
192
|
+
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open, moved), budget, section: null };
|
|
178
193
|
/** @type {Map<Finding, number>} */
|
|
179
194
|
const keyOf = new Map();
|
|
180
195
|
for (const f of open) {
|
|
@@ -183,10 +198,10 @@ export function packRecheck(diff, open, budget) {
|
|
|
183
198
|
}
|
|
184
199
|
/** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
|
|
185
200
|
const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
|
|
186
|
-
const packed = packSections({ diff, budget, measure: (part, set) => recheckTokens(part, openIn(set.map((g) => g.key))) });
|
|
201
|
+
const packed = packSections({ diff, budget, measure: (part, set) => (set === null ? recheckTokens(part, open, moved) : recheckTokens(part, openIn(set.map((g) => g.key)), movedText(moves, diff.content, set.map((g) => g.key)))) });
|
|
187
202
|
if (packed.status !== 'ok') return packed;
|
|
188
203
|
const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
|
|
189
|
-
if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
204
|
+
if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open, moved), budget, section: null };
|
|
190
205
|
// defensive: a finding no packet holds goes to the last packet (it is sized there by the check
|
|
191
206
|
// every packet passes before any session) — never dropped, never closed unseen
|
|
192
207
|
const listed = new Set(sections.flatMap((sec) => sec.open));
|
|
@@ -332,24 +347,77 @@ const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? ope
|
|
|
332
347
|
* The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
|
|
333
348
|
* `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
|
|
334
349
|
* the check before any session use this ONE function.
|
|
335
|
-
* @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open
|
|
350
|
+
* @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open
|
|
351
|
+
* @param {string} [moved] - the `## moved code` section the packet carries (B56).
|
|
352
|
+
* @returns {number}
|
|
336
353
|
*/
|
|
337
|
-
export function recheckTokens(diff, open) {
|
|
338
|
-
return diffOnlyTokens({ diff, lens: 'recheck' }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
|
|
354
|
+
export function recheckTokens(diff, open, moved = '') {
|
|
355
|
+
return diffOnlyTokens({ diff, lens: 'recheck', moved }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
|
|
339
356
|
}
|
|
340
357
|
|
|
341
358
|
/**
|
|
342
359
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
343
360
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
344
|
-
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string}} opts
|
|
361
|
+
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
|
|
362
|
+
* `moved`: the file's `## moved code` section (B56), '' for none.
|
|
345
363
|
*/
|
|
346
|
-
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
|
|
347
|
-
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
|
|
364
|
+
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
|
|
365
|
+
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
|
|
348
366
|
if (packet.status !== 'ok') return packet;
|
|
349
367
|
const text = `${packet.text}${openFindingsText(open)}`;
|
|
350
368
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
351
369
|
}
|
|
352
370
|
|
|
371
|
+
/**
|
|
372
|
+
* B56: the peer readers built from `deps.peers` (no `deps.peerDiffs`): per deps object, one reader
|
|
373
|
+
* per (repoRoot, base, file, peers) key, so A → B → A on one deps reads A's peers once.
|
|
374
|
+
* @type {WeakMap<object, Map<string, () => Promise<import('./moved.mjs').PeerDiff[]>>>}
|
|
375
|
+
*/
|
|
376
|
+
const peerReaders = new WeakMap();
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The peers' diffs reader of a recheck: `deps.peerDiffs` when the caller gave one, else ONE
|
|
380
|
+
* memoised `peerDiffReader` per deps object and (repoRoot, base, file, peers) key, so the peers are
|
|
381
|
+
* read once however many rechecks run with the same deps (one ticket), whatever the order.
|
|
382
|
+
* @param {LoopDeps} deps @param {string} base @param {string} rel
|
|
383
|
+
* @returns {() => Promise<import('./moved.mjs').PeerDiff[]>}
|
|
384
|
+
*/
|
|
385
|
+
function recheckPeerDiffs(deps, base, rel) {
|
|
386
|
+
if (deps.peerDiffs) return deps.peerDiffs;
|
|
387
|
+
const key = JSON.stringify([deps.repoRoot, base, rel, deps.peers ?? null]);
|
|
388
|
+
let readers = peerReaders.get(deps);
|
|
389
|
+
if (!readers) {
|
|
390
|
+
readers = new Map();
|
|
391
|
+
peerReaders.set(deps, readers);
|
|
392
|
+
}
|
|
393
|
+
const known = readers.get(key);
|
|
394
|
+
if (known) return known;
|
|
395
|
+
const reader = peerDiffReader({ repoRoot: deps.repoRoot, base, file: rel, peers: deps.peers });
|
|
396
|
+
readers.set(key, reader);
|
|
397
|
+
return reader;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The recheck's moves (B56): from the file's diff against the block base (the one already read
|
|
402
|
+
* for `recheck_scope: file`, else read here), never from the fix hunks; the peers' diffs from the
|
|
403
|
+
* memoised reader (`recheckPeerDiffs`). No base, no peer, or ANY failure (building the reader
|
|
404
|
+
* included) ⇒ null: no hint, the recheck goes on.
|
|
405
|
+
* @param {LoopDeps} deps @param {string} rel @param {import('./packet.mjs').FileDiff | null} baseDiff
|
|
406
|
+
* @returns {Promise<import('./moved.mjs').Moves | null>}
|
|
407
|
+
*/
|
|
408
|
+
async function recheckMoves(deps, rel, baseDiff) {
|
|
409
|
+
try {
|
|
410
|
+
if (typeof deps.base !== 'string') return null;
|
|
411
|
+
const peerDiffs = recheckPeerDiffs(deps, deps.base, rel);
|
|
412
|
+
// nothing to read when there is no peer: the base diff is read only for a real peer set
|
|
413
|
+
if ((await peerDiffs()).length === 0) return null;
|
|
414
|
+
const diff = baseDiff ?? (await readFileDiff({ repoRoot: deps.repoRoot, file: rel, base: deps.base }));
|
|
415
|
+
return await movesFor({ diff, peerDiffs });
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
353
421
|
/** @param {Finding} f @param {ReadonlyArray<Hunk>} hunks @returns {boolean} */
|
|
354
422
|
export function insideHunks(f, hunks) {
|
|
355
423
|
return hunks.some((h) => {
|
|
@@ -568,16 +636,17 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
568
636
|
draft.open = [];
|
|
569
637
|
if (pending.length > 0) {
|
|
570
638
|
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
639
|
+
const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
|
|
640
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null) });
|
|
641
|
+
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
|
|
642
|
+
let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
|
|
574
643
|
if (whole.status !== 'ok') {
|
|
575
644
|
// deterministic (the same hunks build the same packet): terminal, never a retry — except
|
|
576
645
|
// a Markdown file whose packet is `split_required`, rechecked section by section (B54);
|
|
577
646
|
// any other status stops as it always did
|
|
578
|
-
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget) : null;
|
|
647
|
+
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
|
|
579
648
|
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
|
|
580
|
-
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open }));
|
|
649
|
+
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
|
|
581
650
|
}
|
|
582
651
|
// EVERY packet is built and held to the budget BEFORE any session runs: a section over it
|
|
583
652
|
// stops the round as split_required (with its heading) before anything is spent
|
|
@@ -588,10 +657,11 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
588
657
|
built.push(whole);
|
|
589
658
|
continue;
|
|
590
659
|
}
|
|
591
|
-
const
|
|
660
|
+
const moved = movedText(moves, diff.content, part.keys);
|
|
661
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
|
|
592
662
|
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
593
663
|
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
594
|
-
const size = recheckTokens(part.diff, part.open);
|
|
664
|
+
const size = recheckTokens(part.diff, part.open, moved);
|
|
595
665
|
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
596
666
|
built.push(packet);
|
|
597
667
|
}
|
|
@@ -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
|
'',
|
package/src/review/sections.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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[]} */
|
package/src/worker/engine.mjs
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
* this run for this file and content hash whose MAC verifies with the run key), so a packet's
|
|
35
35
|
* second `schema` miss — this round's first try after the earlier ticket's — is tried once on
|
|
36
36
|
* `review.second_levels.<level>` when one is configured.
|
|
37
|
+
* B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
|
|
38
|
+
* (`blockPeerDiffs`: listed and read at most once per ticket) so the review can name code moved
|
|
39
|
+
* between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
|
|
37
40
|
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
38
41
|
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
39
42
|
* `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
|
|
@@ -46,6 +49,7 @@ import { askJev } from '../decide/jev-client.mjs';
|
|
|
46
49
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
47
50
|
import { budgetFor } from '../review/packet.mjs';
|
|
48
51
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
52
|
+
import { peerDiffReader } from '../review/moved.mjs';
|
|
49
53
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
50
54
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
51
55
|
import { readRun } from '../state/run.mjs';
|
|
@@ -206,19 +210,60 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
206
210
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
207
211
|
}
|
|
208
212
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
213
|
+
const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, file: ticket.file });
|
|
209
214
|
try {
|
|
210
215
|
if (!ctx.key) {
|
|
211
216
|
return await reviewFile(
|
|
212
|
-
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
|
|
217
|
+
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs },
|
|
213
218
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
214
219
|
);
|
|
215
220
|
}
|
|
216
|
-
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
|
|
221
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
|
|
217
222
|
} finally {
|
|
218
223
|
rmSync(workDir, { recursive: true, force: true });
|
|
219
224
|
}
|
|
220
225
|
}
|
|
221
226
|
|
|
227
|
+
/**
|
|
228
|
+
* B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
|
|
229
|
+
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|
|
230
|
+
* ticket's file. The review names code moved between them (`## moved code`). No base, no owned
|
|
231
|
+
* list, or a git failure ⇒ [] (no hint; the review itself goes on unchanged).
|
|
232
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
|
|
233
|
+
* @returns {Promise<string[]>}
|
|
234
|
+
*/
|
|
235
|
+
export async function blockPeers(repoRoot, { base, owned, file }) {
|
|
236
|
+
if (base === null || !Array.isArray(owned) || owned.length === 0) return [];
|
|
237
|
+
try {
|
|
238
|
+
return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f));
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* B56: ONE memoised reader per ticket of the block's other changed files' diffs: the file set is
|
|
246
|
+
* listed and the diffs read (`peerDiffReader`) at most once, however many packets ask — lazily, so
|
|
247
|
+
* a round with no packet (a stopped file) runs no git call. Any throw or rejection on the way ⇒
|
|
248
|
+
* no peer diffs (no hint), memoised like a success.
|
|
249
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
|
|
250
|
+
* @returns {() => Promise<import('../review/moved.mjs').PeerDiff[]>}
|
|
251
|
+
*/
|
|
252
|
+
export function blockPeerDiffs(repoRoot, { base, owned, file }) {
|
|
253
|
+
/** @type {Promise<import('../review/moved.mjs').PeerDiff[]> | null} */
|
|
254
|
+
let once = null;
|
|
255
|
+
return () => {
|
|
256
|
+
once ??= (async () => {
|
|
257
|
+
try {
|
|
258
|
+
return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, file }) })();
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
return once;
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
222
267
|
/**
|
|
223
268
|
* The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
|
|
224
269
|
* It carries the run, so two runs in one process never share a lock.
|
|
@@ -281,10 +326,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
281
326
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
282
327
|
* with the block's other files.
|
|
283
328
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
284
|
-
* @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string}} opts
|
|
329
|
+
* @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
285
330
|
* @returns {Promise<ReviewOutcome>}
|
|
286
331
|
*/
|
|
287
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
|
|
332
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
288
333
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
289
334
|
const readRows = ctx.readRows;
|
|
290
335
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -324,7 +369,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
324
369
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
325
370
|
}
|
|
326
371
|
const fixed = state;
|
|
327
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds });
|
|
372
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
|
|
328
373
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
329
374
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
330
375
|
return { round };
|
|
@@ -335,10 +380,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
335
380
|
/**
|
|
336
381
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
337
382
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
338
|
-
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number}} opts
|
|
383
|
+
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
339
384
|
* @returns {Promise<ReviewOutcome>}
|
|
340
385
|
*/
|
|
341
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0 }) {
|
|
386
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
|
|
342
387
|
/** @type {Record<string, any> | null} */
|
|
343
388
|
let engineOutcome = null;
|
|
344
389
|
let approvalWritten = false;
|
|
@@ -388,10 +433,11 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
388
433
|
writeRow,
|
|
389
434
|
factsExcerpt,
|
|
390
435
|
extraRounds,
|
|
436
|
+
...(peerDiffs ? { peerDiffs } : {}),
|
|
391
437
|
...(jev ? { jev } : {}),
|
|
392
438
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
393
439
|
review: async () => {
|
|
394
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
440
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
395
441
|
return engineOutcome;
|
|
396
442
|
},
|
|
397
443
|
spawn: async (opts) => {
|