@codedology/code-forge 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 (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`.
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 @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}
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 whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
572
- /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
573
- 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 }];
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 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 });
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
  }
@@ -68,7 +68,7 @@ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
68
68
  import { exec } from '../util/exec.mjs';
69
69
  import { readRun, writeSigned } from '../state/run.mjs';
70
70
  import { StateError } from '../state/paths.mjs';
71
- import { findOverlap } from '../state/registry.mjs';
71
+ import { ownsFile } from '../state/registry.mjs';
72
72
  import { sameStamp, treeStamp } from '../state/block.mjs';
73
73
  import { verifyRow } from '../state/signer.mjs';
74
74
  import { tierFor } from '../proof/tiers.mjs';
@@ -565,15 +565,15 @@ export function repoRelativePath(raw) {
565
565
 
566
566
  /**
567
567
  * The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
568
- * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths; a glob owned entry matches through `matchOwned`), each with its content hash.
568
+ * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (`matchOwned`, default the registry's `ownsFile` with `ownedKinds`: an exact entry owns its path and the paths below it unless recorded as a regular file, B57), each with its content hash.
569
569
  * Every path goes through `repoRelativePath`.
570
- * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean, changed?: string[]}} opts
570
+ * @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[]}} opts
571
571
  * `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
572
572
  * @returns {Promise<Array<{file: string, content_hash: string}>>}
573
573
  */
574
- export async function blockFileSet({ repoRoot, base, owned, matchOwned, changed }) {
574
+ export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
575
575
  const all = changed ?? (await changedFiles({ repoRoot, base }));
576
- const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
576
+ const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
577
577
  return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
578
578
  }
579
579
 
@@ -597,15 +597,16 @@ export async function changedFiles({ repoRoot, base }) {
597
597
  }
598
598
 
599
599
  /**
600
- * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate: the `findOverlap`
601
- * match `block open` uses (exact paths and globs), and — what the field run needed — an entry with
602
- * no glob character also covers every path below it as a directory (`src/feature` owns
603
- * `src/feature/Fan.swift`). A wider match only ever puts more files under the gate's checks.
604
- * @param {ReadonlyArray<string>} owned @param {string} file @returns {boolean}
600
+ * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate. B57: the registry's one
601
+ * ownership rule (`ownsFile`) — the rule `block open`, the scope gate and the worker use: an entry
602
+ * with no glob character covers its path and every path below it as a directory (`src/feature`
603
+ * owns `src/feature/Fan.swift`, never `src/featureX/B.swift`) unless `kinds` (the block's
604
+ * `owned_kinds`) recorded it as a regular file, a glob what it matches.
605
+ * @param {ReadonlyArray<string>} owned @param {string} file @param {Readonly<Record<string, string>>} [kinds]
606
+ * @returns {boolean}
605
607
  */
606
- export function ownsPath(owned, file) {
607
- if (findOverlap(owned, [file]) !== null) return true;
608
- return owned.some((o) => typeof o === 'string' && o.length > 0 && !/[*?[\]{}]/.test(o) && file.startsWith(`${o.replace(/\/+$/, '')}/`));
608
+ export function ownsPath(owned, file, kinds) {
609
+ return ownsFile(owned, file, kinds);
609
610
  }
610
611
 
611
612
  /**
@@ -614,11 +615,11 @@ export function ownsPath(owned, file) {
614
615
  * given, or — for a workspace in a subdirectory (`wsPrefix`, the workspace relative to the top;
615
616
  * `''` when they are the same) — relative to the workspace. A file outside the workspace is
616
617
  * matched as given only.
617
- * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string) => boolean}
618
+ * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean}
618
619
  */
619
620
  export function ownsAt(wsPrefix) {
620
621
  const prefix = wsPrefix.replace(/\/+$/, '');
621
- return (owned, file) => ownsPath(owned, file) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1)));
622
+ return (owned, file, kinds) => ownsPath(owned, file, kinds) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1), kinds));
622
623
  }
623
624
 
624
625
  /** B52: the review rows that name a file the block's worker reviewed. */
@@ -673,8 +674,8 @@ const currentHash = (repoRoot, file) => (isOnDisk(repoRoot, file) ? contentHash(
673
674
  * warns that the check did not run (never a silent pass).
674
675
  * @param {{
675
676
  * top: string, changed: ReadonlyArray<string>, id: string, blocks: Record<string, any>,
676
- * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string) => boolean, stateDir?: string,
677
- * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` match a path.
677
+ * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean, stateDir?: string,
678
+ * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` (with its `owned_kinds`) match a path.
678
679
  * @returns {Promise<{unowned: string[], siblings: Array<{file: string, block: string}>} | null>}
679
680
  */
680
681
  export async function unownedChanges({ top, changed, id, blocks, rows, key, isMine, owns = ownsPath, stateDir = '.code-forge' }) {
@@ -692,7 +693,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
692
693
  return others.some(
693
694
  ([other, b]) =>
694
695
  b.status === 'closed' &&
695
- owns(b.owned_files, file) &&
696
+ owns(b.owned_files, file, b.owned_kinds) &&
696
697
  rows.some((r) => r?.event === 'review.approved' && r.block === other && r.file === file && r.content_hash === hash && verified(r, key)),
697
698
  );
698
699
  };
@@ -712,7 +713,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
712
713
  const siblings = [];
713
714
  for (const file of changed) {
714
715
  if (state(file) || isMine(file) || (await unchangedSinceOpen(file))) continue;
715
- const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file));
716
+ const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file, b.owned_kinds));
716
717
  if (sibling) siblings.push({ file, block: sibling[0] });
717
718
  else if (!approvedClosed(file)) unowned.push(file);
718
719
  }