@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/state/block.mjs
CHANGED
|
@@ -17,9 +17,10 @@ import os from 'node:os';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
19
19
|
import { exec } from '../util/exec.mjs';
|
|
20
|
+
import { ownedKinds, pathKind } from './owned-kinds.mjs';
|
|
20
21
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
21
22
|
import { StateError } from './paths.mjs';
|
|
22
|
-
import { assertOwned, findOverlap, scopeGate } from './registry.mjs';
|
|
23
|
+
import { assertOwned, findOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
23
24
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
24
25
|
import { verifyRow, loadKey } from './signer.mjs';
|
|
25
26
|
|
|
@@ -289,13 +290,16 @@ export async function openBlock(opts) {
|
|
|
289
290
|
|
|
290
291
|
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
291
292
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
293
|
+
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
294
|
+
// file owns only itself, here and in every later check
|
|
295
|
+
const kinds = ownedKinds(record.workspace, owned);
|
|
292
296
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
293
|
-
const pair = findOverlap(owned, other.owned_files);
|
|
297
|
+
const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
294
298
|
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
295
299
|
}
|
|
296
300
|
const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
|
|
297
301
|
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
298
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
302
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
299
303
|
record.blocks[id] = entry;
|
|
300
304
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
301
305
|
return {
|
|
@@ -318,7 +322,8 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
318
322
|
* `block rebase` — move the base to `head` only when the old base is its ancestor and no commit
|
|
319
323
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
320
324
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
321
|
-
* is a conflict too. Never silent.
|
|
325
|
+
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
326
|
+
* below it).
|
|
322
327
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
323
328
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
324
329
|
*/
|
|
@@ -329,7 +334,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
329
334
|
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
330
335
|
if (ancestor.code !== 0) throw new StateError('base-not-ancestor', `block ${id}: base ${block.base_sha.slice(0, 12)} is not an ancestor of ${head}`);
|
|
331
336
|
const diff = await git(['git', 'diff', '--no-renames', '--name-only', block.base_sha, newBase], cwd);
|
|
332
|
-
const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 &&
|
|
337
|
+
const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && ownsFile(block.owned_files, file, block.owned_kinds));
|
|
333
338
|
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
334
339
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
335
340
|
block.base_sha = newBase;
|
|
@@ -339,18 +344,25 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
339
344
|
|
|
340
345
|
/**
|
|
341
346
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
342
|
-
* glob, and when another open block owns it
|
|
347
|
+
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
348
|
+
* owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
|
|
349
|
+
* unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
|
|
343
350
|
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
344
351
|
*/
|
|
345
352
|
export async function claimPath({ runId, id, file, writeRow }) {
|
|
346
353
|
assertOwned([file]);
|
|
347
|
-
if (
|
|
354
|
+
if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
348
355
|
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
356
|
+
const kinds = { [file]: pathKind(record.workspace, file) };
|
|
349
357
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
350
|
-
|
|
358
|
+
const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
359
|
+
if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
351
360
|
}
|
|
352
|
-
if (!block.owned_files.includes(file))
|
|
353
|
-
|
|
361
|
+
if (!block.owned_files.includes(file)) {
|
|
362
|
+
block.owned_files.push(file);
|
|
363
|
+
block.owned_kinds = { ...block.owned_kinds, ...kinds };
|
|
364
|
+
}
|
|
365
|
+
record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
|
|
354
366
|
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
|
|
355
367
|
});
|
|
356
368
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B57: what each EXACT owned entry is on disk when a block is opened or a path claimed — the
|
|
3
|
+
* answer the registry's ownership rule (`state/registry.mjs`) reads through `owned_kinds`. Only a
|
|
4
|
+
* regular file (`lstat`, so never a symlink, whatever it points at) is `file`: it owns only itself.
|
|
5
|
+
* Everything else stays directory-like (fail closed): `dir`, `absent` (not there yet, or a parent
|
|
6
|
+
* is not a directory), `other` (a symlink, a socket, or a path `lstat` could not read).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lstatSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { isExactEntry } from './registry.mjs';
|
|
12
|
+
|
|
13
|
+
/** @typedef {'file' | 'dir' | 'absent' | 'other'} OwnedKind */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} root - the directory the entry is relative to (the run's workspace).
|
|
17
|
+
* @param {string} entry - a repo-relative POSIX path (already through `assertOwned`).
|
|
18
|
+
* @returns {OwnedKind}
|
|
19
|
+
*/
|
|
20
|
+
export function pathKind(root, entry) {
|
|
21
|
+
let st;
|
|
22
|
+
try {
|
|
23
|
+
st = lstatSync(path.join(root, ...entry.split('/')));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const code = /** @type {any} */ (err)?.code;
|
|
26
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'other';
|
|
27
|
+
}
|
|
28
|
+
if (st.isFile()) return 'file';
|
|
29
|
+
if (st.isDirectory()) return 'dir';
|
|
30
|
+
return 'other';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The kind of every exact entry of `owned` (`isExactEntry` — a glob has no kind), keyed by the
|
|
35
|
+
* entry as written.
|
|
36
|
+
* @param {string} root @param {ReadonlyArray<string>} owned
|
|
37
|
+
* @returns {Record<string, OwnedKind>}
|
|
38
|
+
*/
|
|
39
|
+
export function ownedKinds(root, owned) {
|
|
40
|
+
/** @type {Record<string, OwnedKind>} */
|
|
41
|
+
const kinds = Object.create(null); // an entry named `__proto__` is a key like any other
|
|
42
|
+
for (const entry of owned) if (isExactEntry(entry)) kinds[entry] = pathKind(root, entry);
|
|
43
|
+
return kinds;
|
|
44
|
+
}
|
package/src/state/registry.mjs
CHANGED
|
@@ -18,11 +18,29 @@
|
|
|
18
18
|
* `test/fixtures/repos/node/**` are disjoint, `src/**` and `src/cli/*.mjs` overlap, and so do
|
|
19
19
|
* `src/*.mjs` and `src/cli/*.mjs` (they cannot share a path, but overlap is not ruled out by
|
|
20
20
|
* the prefix rule — the owner resolves it by naming exact paths).
|
|
21
|
+
*
|
|
22
|
+
* B57 — ONE ownership rule, here, for every caller (block open/claim/rebase, the scope gate, the
|
|
23
|
+
* worker's file set and budget, proof, plan check, the close gate's `ownsPath`): an entry with no
|
|
24
|
+
* glob character (`*`, `?`, `{`) owns that exact path AND every path below it, as a directory
|
|
25
|
+
* would (`src/feature` owns `src/feature/a.swift`, never `src/featureX/a.swift`); a glob entry
|
|
26
|
+
* matches as above and nothing more — a brace entry (`src/{a,b}`) is a glob, so each alternative
|
|
27
|
+
* owns exactly itself, nothing below it. This module is pure and cannot tell a file from a
|
|
28
|
+
* directory, so the caller passes what it saw: `kinds` (a block's `owned_kinds`, recorded at
|
|
29
|
+
* `block open` and `block claim` by `state/owned-kinds.mjs`) maps an exact entry to `file` when it
|
|
30
|
+
* was a regular file — then it owns only itself. Any other kind, or no kind (not there yet, an
|
|
31
|
+
* older block, a plan), stays directory-like: fail closed. So `findOverlap` reports a parent/child
|
|
32
|
+
* pair of directory-like entries (either way round), and a directory-like entry against a glob
|
|
33
|
+
* that matches the entry itself or ANY path below it (decided exactly, `globBelow`: `src/feature`
|
|
34
|
+
* vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
|
|
35
|
+
* a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
|
|
36
|
+
* `src/**\/*.mjs` does not).
|
|
21
37
|
*/
|
|
22
38
|
|
|
23
39
|
import { StateError } from './paths.mjs';
|
|
24
40
|
|
|
25
41
|
const GLOB_CHARS = /[*?]/;
|
|
42
|
+
/** What makes an owned ENTRY a glob (B57: the one definition): `*`, `?`, or a `{a,b}` group. */
|
|
43
|
+
const GLOB_ENTRY_CHARS = /[*?{]/;
|
|
26
44
|
/** Glob syntax this matcher does NOT implement — refused inside a glob entry only. */
|
|
27
45
|
const UNSUPPORTED_GLOB_CHARS = /[[\]()!+@]/;
|
|
28
46
|
|
|
@@ -99,7 +117,7 @@ export function assertOwned(owned) {
|
|
|
99
117
|
};
|
|
100
118
|
if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
|
|
101
119
|
if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
|
|
102
|
-
const isGlob =
|
|
120
|
+
const isGlob = !isExactEntry(entry);
|
|
103
121
|
if (isGlob && UNSUPPORTED_GLOB_CHARS.test(entry)) {
|
|
104
122
|
refuse('a glob may not contain [ ] ( ) ! + @ — character classes and extglobs are not supported; name the files exactly or use only *, ?, ** and {a,b}');
|
|
105
123
|
}
|
|
@@ -123,43 +141,124 @@ function staticSegments(p) {
|
|
|
123
141
|
const segmentPrefix = (a, b) => a.length <= b.length && a.every((s, i) => s === b[i]);
|
|
124
142
|
|
|
125
143
|
/**
|
|
126
|
-
*
|
|
144
|
+
* What a caller saw on disk for each exact owned entry (B57, the module doc); only `file` changes
|
|
145
|
+
* the rule. Keys are the entries' original spellings.
|
|
146
|
+
* @typedef {Readonly<Record<string, string>> | null | undefined} OwnedKinds
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* B57: is owned `entry` an EXACT path — no `*`, `?` or `{` (`[ ] ( ) ! + @ }` are literal there)?
|
|
151
|
+
* The one definition `assertOwned`, `block claim`, the ownership rule, proof export and
|
|
152
|
+
* `state/owned-kinds.mjs` share.
|
|
153
|
+
* @param {string} entry @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function isExactEntry(entry) {
|
|
156
|
+
return !GLOB_ENTRY_CHARS.test(entry);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* B57: an exact entry that is not a recorded regular file — it owns its path and everything below
|
|
161
|
+
* it (see the module doc). `kinds` is keyed by the entry as written.
|
|
162
|
+
* @param {string} entry @param {OwnedKinds} kinds @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
const isTreeEntry = (entry, kinds) => isExactEntry(entry) && !(kinds != null && Object.hasOwn(kinds, entry) && kinds[entry] === 'file');
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* B57: an exact entry's path for matching — trailing slashes dropped (`assertOwned` refuses them in
|
|
168
|
+
* the run record; the close gate's callers may still pass one), the same in both functions.
|
|
169
|
+
* @param {string} entry @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
const exactPath = (entry) => entry.replace(/\/+$/, '');
|
|
172
|
+
|
|
173
|
+
/** @param {string} root @param {string} file @returns {boolean} `file` is `root` or a path below it. */
|
|
174
|
+
const coversPath = (root, file) => file === root || file.startsWith(`${root}/`);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* B57: can brace-free glob `pattern` match some path strictly below directory `dir` (one or more
|
|
178
|
+
* segments after it)? Decided exactly: `dir`'s segments must match a prefix of the pattern, and
|
|
179
|
+
* any pattern left over (or a `**` reached on the way) can always be met by some name below.
|
|
180
|
+
* @param {string} dir @param {string} pattern
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function globBelow(dir, pattern) {
|
|
184
|
+
const f = dir.split('/');
|
|
185
|
+
const p = pattern.split('/');
|
|
186
|
+
for (let i = 0; ; i += 1) {
|
|
187
|
+
if (i < p.length && p[i] === '**') return true;
|
|
188
|
+
if (i === f.length) return i < p.length;
|
|
189
|
+
if (i === p.length || !segmentRegExp(p[i]).test(f[i])) return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One brace alternative of an owned entry: `tree` when the entry is directory-like (it owns the
|
|
195
|
+
* paths below it too). An exact entry is one alternative, its {@link exactPath}; an empty one
|
|
196
|
+
* (`/`) has none.
|
|
197
|
+
* @typedef {{p: string, tree: boolean}} Alternative
|
|
198
|
+
* @param {string} entry @param {OwnedKinds} kinds @returns {Alternative[]}
|
|
199
|
+
*/
|
|
200
|
+
function alternatives(entry, kinds) {
|
|
201
|
+
if (!isExactEntry(entry)) return expandBraces(entry).map((p) => ({ p, tree: false }));
|
|
202
|
+
const p = exactPath(entry);
|
|
203
|
+
return p.length === 0 ? [] : [{ p, tree: isTreeEntry(entry, kinds) }];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {Alternative} a @param {Alternative} b
|
|
127
208
|
* @returns {boolean}
|
|
128
209
|
*/
|
|
129
210
|
function patternsOverlap(a, b) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
211
|
+
if (b.tree && !a.tree) return patternsOverlap(b, a);
|
|
212
|
+
if (a.tree) {
|
|
213
|
+
if (b.tree) return coversPath(a.p, b.p) || coversPath(b.p, a.p);
|
|
214
|
+
if (!GLOB_CHARS.test(b.p)) return coversPath(a.p, b.p);
|
|
215
|
+
return globMatch(a.p, b.p) || globBelow(a.p, b.p);
|
|
216
|
+
}
|
|
217
|
+
const aGlob = GLOB_CHARS.test(a.p);
|
|
218
|
+
const bGlob = GLOB_CHARS.test(b.p);
|
|
219
|
+
if (!aGlob && !bGlob) return a.p === b.p;
|
|
220
|
+
if (!aGlob) return globMatch(a.p, b.p);
|
|
221
|
+
if (!bGlob) return globMatch(b.p, a.p);
|
|
222
|
+
const sa = staticSegments(a.p);
|
|
223
|
+
const sb = staticSegments(b.p);
|
|
137
224
|
return segmentPrefix(sa, sb) || segmentPrefix(sb, sa);
|
|
138
225
|
}
|
|
139
226
|
|
|
140
227
|
/**
|
|
228
|
+
* Could two owned lists ever own one path? An exact entry is directory-like unless its kind says
|
|
229
|
+
* `file` (B57, the module doc), so `src/feature` and `src/feature/x.swift` overlap either way round.
|
|
141
230
|
* @param {ReadonlyArray<string>} ownedA @param {ReadonlyArray<string>} ownedB
|
|
231
|
+
* @param {{kindsA?: OwnedKinds, kindsB?: OwnedKinds}} [kinds] - each list's `owned_kinds`.
|
|
142
232
|
* @returns {[string, string] | null} the first overlapping pair (original spellings), or null.
|
|
143
233
|
*/
|
|
144
|
-
export function findOverlap(ownedA, ownedB) {
|
|
234
|
+
export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
|
|
145
235
|
for (const a of ownedA) {
|
|
146
236
|
for (const b of ownedB) {
|
|
147
|
-
if (
|
|
237
|
+
if (alternatives(a, kindsA).some((x) => alternatives(b, kindsB).some((y) => patternsOverlap(x, y)))) return [a, b];
|
|
148
238
|
}
|
|
149
239
|
}
|
|
150
240
|
return null;
|
|
151
241
|
}
|
|
152
242
|
|
|
153
243
|
/**
|
|
154
|
-
*
|
|
244
|
+
* Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
|
|
245
|
+
* below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
|
|
246
|
+
* non-string or empty entry owns nothing; an exact entry is matched as its {@link exactPath}, as
|
|
247
|
+
* in `findOverlap`.
|
|
248
|
+
* @param {ReadonlyArray<unknown>} owned @param {string} file @param {OwnedKinds} [kinds] - the block's `owned_kinds`.
|
|
155
249
|
* @returns {boolean}
|
|
156
250
|
*/
|
|
157
|
-
export function ownsFile(owned, file) {
|
|
158
|
-
return owned.some((entry) =>
|
|
251
|
+
export function ownsFile(owned, file, kinds) {
|
|
252
|
+
return owned.some((entry) => {
|
|
253
|
+
if (typeof entry !== 'string' || entry.length === 0) return false;
|
|
254
|
+
if (!isExactEntry(entry)) return expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p)));
|
|
255
|
+
const root = exactPath(entry);
|
|
256
|
+
return root.length > 0 && (isTreeEntry(entry, kinds) ? coversPath(root, file) : root === file);
|
|
257
|
+
});
|
|
159
258
|
}
|
|
160
259
|
|
|
161
260
|
/**
|
|
162
|
-
* @typedef {{owned_files: string[], status?: string}} RegistryBlock
|
|
261
|
+
* @typedef {{owned_files: string[], owned_kinds?: Record<string, string>, status?: string}} RegistryBlock
|
|
163
262
|
* @param {Record<string, RegistryBlock>} blocks - the ACTIVE blocks only (status `open`)
|
|
164
263
|
* @param {ReadonlyArray<string>} files - the shared tree's changed + untracked files
|
|
165
264
|
* @returns {{scopes: Record<string, string[]>, orphans: string[]}}
|
|
@@ -169,7 +268,7 @@ export function computeScopes(blocks, files) {
|
|
|
169
268
|
const scopes = Object.fromEntries(Object.keys(blocks).map((id) => [id, []]));
|
|
170
269
|
const orphans = [];
|
|
171
270
|
for (const file of [...new Set(files)].sort()) {
|
|
172
|
-
const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file));
|
|
271
|
+
const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file, blocks[id].owned_kinds));
|
|
173
272
|
if (owners.length === 0) orphans.push(file);
|
|
174
273
|
for (const id of owners) scopes[id].push(file);
|
|
175
274
|
}
|
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';
|
|
@@ -103,16 +107,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
103
107
|
* block entry without a base `block-base-missing` — a guessed base could let a change escape review.
|
|
104
108
|
* @param {string} runId @param {string} block
|
|
105
109
|
* The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
|
|
106
|
-
* of its owned files.
|
|
110
|
+
* of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
|
|
111
|
+
* regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
|
|
107
112
|
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
108
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
|
|
113
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null}>}
|
|
109
114
|
*/
|
|
110
115
|
export async function blockEntryFor(runId, block) {
|
|
111
116
|
let record;
|
|
112
117
|
try {
|
|
113
118
|
record = await readRun(runId);
|
|
114
119
|
} catch (err) {
|
|
115
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
|
|
120
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
|
|
116
121
|
throw new Error('run-record-unreadable');
|
|
117
122
|
}
|
|
118
123
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -121,7 +126,9 @@ export async function blockEntryFor(runId, block) {
|
|
|
121
126
|
const sha = entry?.base_sha;
|
|
122
127
|
if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
|
|
123
128
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
124
|
-
|
|
129
|
+
const kinds = entry.owned_kinds;
|
|
130
|
+
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
131
|
+
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -206,19 +213,60 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
206
213
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
207
214
|
}
|
|
208
215
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
216
|
+
const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
209
217
|
try {
|
|
210
218
|
if (!ctx.key) {
|
|
211
219
|
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) },
|
|
220
|
+
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs },
|
|
213
221
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
214
222
|
);
|
|
215
223
|
}
|
|
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) });
|
|
224
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
|
|
217
225
|
} finally {
|
|
218
226
|
rmSync(workDir, { recursive: true, force: true });
|
|
219
227
|
}
|
|
220
228
|
}
|
|
221
229
|
|
|
230
|
+
/**
|
|
231
|
+
* B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
|
|
232
|
+
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|
|
233
|
+
* ticket's file. The review names code moved between them (`## moved code`). No base, no owned
|
|
234
|
+
* list, or a git failure ⇒ [] (no hint; the review itself goes on unchanged).
|
|
235
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
|
|
236
|
+
* @returns {Promise<string[]>}
|
|
237
|
+
*/
|
|
238
|
+
export async function blockPeers(repoRoot, { base, owned, ownedKinds, file }) {
|
|
239
|
+
if (base === null || !Array.isArray(owned) || owned.length === 0) return [];
|
|
240
|
+
try {
|
|
241
|
+
return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f, ownedKinds));
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* B56: ONE memoised reader per ticket of the block's other changed files' diffs: the file set is
|
|
249
|
+
* listed and the diffs read (`peerDiffReader`) at most once, however many packets ask — lazily, so
|
|
250
|
+
* a round with no packet (a stopped file) runs no git call. Any throw or rejection on the way ⇒
|
|
251
|
+
* no peer diffs (no hint), memoised like a success.
|
|
252
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
|
|
253
|
+
* @returns {() => Promise<import('../review/moved.mjs').PeerDiff[]>}
|
|
254
|
+
*/
|
|
255
|
+
export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
|
|
256
|
+
/** @type {Promise<import('../review/moved.mjs').PeerDiff[]> | null} */
|
|
257
|
+
let once = null;
|
|
258
|
+
return () => {
|
|
259
|
+
once ??= (async () => {
|
|
260
|
+
try {
|
|
261
|
+
return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, ownedKinds, file }) })();
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
return once;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
222
270
|
/**
|
|
223
271
|
* The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
|
|
224
272
|
* It carries the run, so two runs in one process never share a lock.
|
|
@@ -281,10 +329,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
281
329
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
282
330
|
* with the block's other files.
|
|
283
331
|
* @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
|
|
332
|
+
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
285
333
|
* @returns {Promise<ReviewOutcome>}
|
|
286
334
|
*/
|
|
287
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
|
|
335
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
288
336
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
289
337
|
const readRows = ctx.readRows;
|
|
290
338
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -298,7 +346,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
298
346
|
// Check-then-write under the block lock: no other ticket of this block reads the rows between
|
|
299
347
|
// this check and the budget row's write, so the row is written once per block.
|
|
300
348
|
if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
|
|
301
|
-
await recordBlockBudget(ticket, ctx, { base, owned });
|
|
349
|
+
await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
|
|
302
350
|
}
|
|
303
351
|
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
304
352
|
const loaded = loadState(where);
|
|
@@ -324,7 +372,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
324
372
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
325
373
|
}
|
|
326
374
|
const fixed = state;
|
|
327
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds });
|
|
375
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
|
|
328
376
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
329
377
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
330
378
|
return { round };
|
|
@@ -335,10 +383,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
335
383
|
/**
|
|
336
384
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
337
385
|
* @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
|
|
386
|
+
* @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
387
|
* @returns {Promise<ReviewOutcome>}
|
|
340
388
|
*/
|
|
341
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0 }) {
|
|
389
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
|
|
342
390
|
/** @type {Record<string, any> | null} */
|
|
343
391
|
let engineOutcome = null;
|
|
344
392
|
let approvalWritten = false;
|
|
@@ -388,10 +436,11 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
388
436
|
writeRow,
|
|
389
437
|
factsExcerpt,
|
|
390
438
|
extraRounds,
|
|
439
|
+
...(peerDiffs ? { peerDiffs } : {}),
|
|
391
440
|
...(jev ? { jev } : {}),
|
|
392
441
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
393
442
|
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 } : {}) });
|
|
443
|
+
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
444
|
return engineOutcome;
|
|
396
445
|
},
|
|
397
446
|
spawn: async (opts) => {
|
|
@@ -443,15 +492,15 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
443
492
|
* not run yet), written as ONE `review.budget` row (+ `review.over_budget` when over). Best effort:
|
|
444
493
|
* a git or ledger failure is swallowed, the review goes on.
|
|
445
494
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
446
|
-
* @param {{base: string | null, owned: string[]}} opts
|
|
495
|
+
* @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>}} opts
|
|
447
496
|
*/
|
|
448
|
-
async function recordBlockBudget(ticket, ctx, { base, owned }) {
|
|
497
|
+
async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
|
|
449
498
|
try {
|
|
450
499
|
/** @type {string[]} */
|
|
451
500
|
let changed = [];
|
|
452
501
|
if (base !== null && owned.length > 0) {
|
|
453
502
|
try {
|
|
454
|
-
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f));
|
|
503
|
+
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
455
504
|
} catch {
|
|
456
505
|
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
457
506
|
// once per block); the block's next keyed ticket retries
|