@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.
- package/CHANGELOG.md +24 -0
- package/README.md +15 -0
- package/docs/reference/blocks.json +30 -1
- package/package.json +1 -1
- package/skill/references/code.md +1 -1
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +2 -2
- package/src/cli/plan.mjs +1 -1
- package/src/cli/proof.mjs +3 -2
- package/src/proof/export.mjs +50 -9
- package/src/proof/tiers.mjs +2 -1
- package/src/review/engine.mjs +43 -13
- package/src/review/fixloop.mjs +88 -18
- package/src/review/gate-check.mjs +20 -19
- package/src/review/moved.mjs +399 -0
- package/src/review/packet.mjs +32 -23
- package/src/review/sections.mjs +5 -4
- package/src/session/plan-check.mjs +18 -3
- package/src/state/block.mjs +22 -10
- package/src/state/owned-kinds.mjs +44 -0
- package/src/state/registry.mjs +115 -16
- package/src/worker/engine.mjs +65 -16
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
|
}
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
601
|
-
*
|
|
602
|
-
* no glob character
|
|
603
|
-
* `src/feature/Fan.swift`)
|
|
604
|
-
*
|
|
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
|
-
|
|
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
|
}
|