@codedology/code-forge 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,19 @@
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
+ *
71
+ * Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
72
+ * packet's second miss is tried once on `review.second_levels.L2` when configured
73
+ * (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
74
+ *
62
75
  * A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
63
76
  * (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
64
77
  * round is not counted, nothing closes, and `next.action: 'retry'` with `pending_kind` set —
@@ -75,10 +88,12 @@ import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
75
88
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
76
89
  import { parseDiff } from './context.mjs';
77
90
  import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
91
+ import { movedText, movesFor, peerDiffReader } from './moved.mjs';
78
92
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
79
93
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
80
94
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
81
95
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
96
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
82
97
 
83
98
  /** @typedef {import('./triage.mjs').Finding} Finding */
84
99
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -123,9 +138,12 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
123
138
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
124
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).
125
140
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
126
- * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
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`.
127
144
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
128
145
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
146
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
129
147
  */
130
148
 
131
149
  /** @param {LoopDeps} deps @param {Record<string, any>} row */
@@ -164,11 +182,14 @@ export function mergePacketInfo(info, packet) {
164
182
  * or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
165
183
  * a finding no packet holds is listed in the LAST packet — every open finding is listed once.
166
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.
167
187
  * @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
168
188
  */
169
- export function packRecheck(diff, open, budget) {
189
+ export function packRecheck(diff, open, budget, moves = null) {
190
+ const moved = movedText(moves, diff.content, null);
170
191
  const groups = sectionGroups(diff);
171
- 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 };
172
193
  /** @type {Map<Finding, number>} */
173
194
  const keyOf = new Map();
174
195
  for (const f of open) {
@@ -177,10 +198,10 @@ export function packRecheck(diff, open, budget) {
177
198
  }
178
199
  /** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
179
200
  const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
180
- 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)))) });
181
202
  if (packed.status !== 'ok') return packed;
182
203
  const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
183
- 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 };
184
205
  // defensive: a finding no packet holds goes to the last packet (it is sized there by the check
185
206
  // every packet passes before any session) — never dropped, never closed unseen
186
207
  const listed = new Set(sections.flatMap((sec) => sec.open));
@@ -326,24 +347,77 @@ const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? ope
326
347
  * The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
327
348
  * `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
328
349
  * the check before any session use this ONE function.
329
- * @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open @returns {number}
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}
330
353
  */
331
- export function recheckTokens(diff, open) {
332
- 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);
333
356
  }
334
357
 
335
358
  /**
336
359
  * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
337
360
  * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
338
- * @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.
339
363
  */
340
- export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
341
- 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 });
342
366
  if (packet.status !== 'ok') return packet;
343
367
  const text = `${packet.text}${openFindingsText(open)}`;
344
368
  return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
345
369
  }
346
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
+
347
421
  /** @param {Finding} f @param {ReadonlyArray<Hunk>} hunks @returns {boolean} */
348
422
  export function insideHunks(f, hunks) {
349
423
  return hunks.some((h) => {
@@ -353,7 +427,8 @@ export function insideHunks(f, hunks) {
353
427
  }
354
428
 
355
429
  /**
356
- * One recheck session through the stub guard.
430
+ * One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
431
+ * on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
357
432
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
358
433
  * @param {LoopDeps} deps
359
434
  */
@@ -361,26 +436,62 @@ async function recheckSession(packet, deps) {
361
436
  mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
362
437
  const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
363
438
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
439
+ const min = minTokensOut(deps.cfg);
440
+ const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
441
+ /** @param {Record<string, any> | undefined} cfg */
442
+ const attempt = async (cfg) =>
443
+ deps.spawn
444
+ ? // a `timeout` is spawned once more on the same packet and level (B30)
445
+ spawnWithTimeoutRetry(
446
+ deps.spawn,
447
+ { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
448
+ (row) => note(deps, row),
449
+ )
450
+ : { res: null, attempts: 0 };
364
451
  /** @type {Record<string, any> | null} */
365
452
  let res = null;
453
+ /** @type {import('./validate-review.mjs').Verdict} */
454
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
455
+ /** @type {number | null} the round's tokens: the first session's, plus a second-level try's */
456
+ let tokensIn = null;
457
+ /** @type {number | null} */
458
+ let tokensOut = null;
459
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
366
460
  try {
367
- // a `timeout` is spawned once more on the same packet and level (B30)
368
- res = deps.spawn
369
- ? (
370
- await spawnWithTimeoutRetry(
371
- deps.spawn,
372
- { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } },
373
- (row) => note(deps, row),
374
- )
375
- ).res
376
- : null;
377
- } catch {
378
- res = null; // the helper never throws; if it ever did, it is still an `exit` failure
461
+ try {
462
+ res = (await attempt(undefined)).res;
463
+ } catch {
464
+ res = null; // the helper never throws; if it ever did, it is still an `exit` failure
465
+ }
466
+ tokensIn = res?.usage?.tokens_in ?? null;
467
+ tokensOut = res?.usage?.tokens_out ?? null;
468
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
469
+ if (!verdict.ok && verdict.reason === 'schema') {
470
+ try {
471
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
472
+ // (each helper handles its own failed write)
473
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
474
+ const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
475
+ if (second) {
476
+ // compute everything first, then assign together, so a throw never leaves a mixed state
477
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
478
+ const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
479
+ const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
480
+ res = second.res;
481
+ verdict = secondVerdict;
482
+ tokensIn = sumIn;
483
+ tokensOut = sumOut;
484
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
485
+ }
486
+ } catch {
487
+ // defensive only: the helpers never throw. If they ever did, the first session's result
488
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
489
+ }
490
+ }
379
491
  } finally {
380
492
  rmSync(promptPath, { force: true });
381
493
  }
382
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: minTokensOut(deps.cfg) });
383
- return { verdict, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null };
494
+ return { verdict, tokens_in: tokensIn, tokens_out: tokensOut };
384
495
  }
385
496
 
386
497
  /**
@@ -525,16 +636,17 @@ export async function runRound(state, deps, opts = {}) {
525
636
  draft.open = [];
526
637
  if (pending.length > 0) {
527
638
  const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
528
- const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
529
- /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
530
- let parts = [{ index: null, heading: null, diff, open: pending }];
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 }];
531
643
  if (whole.status !== 'ok') {
532
644
  // deterministic (the same hunks build the same packet): terminal, never a retry — except
533
645
  // a Markdown file whose packet is `split_required`, rechecked section by section (B54);
534
646
  // any other status stops as it always did
535
- 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;
536
648
  if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
537
- 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 }));
538
650
  }
539
651
  // EVERY packet is built and held to the budget BEFORE any session runs: a section over it
540
652
  // stops the round as split_required (with its heading) before anything is spent
@@ -545,10 +657,11 @@ export async function runRound(state, deps, opts = {}) {
545
657
  built.push(whole);
546
658
  continue;
547
659
  }
548
- const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
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 });
549
662
  // a packet that fails to build stops with ITS status (and size, when it has one)
550
663
  if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
551
- const size = recheckTokens(part.diff, part.open);
664
+ const size = recheckTokens(part.diff, part.open, moved);
552
665
  if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
553
666
  built.push(packet);
554
667
  }