@codedology/code-forge 0.10.0 → 0.12.0
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 +74 -0
- package/README.md +34 -3
- package/docs/reference/blocks.json +360 -1
- package/docs/reference/config.md +6 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +3 -2
- package/skill/SKILL.md +3 -1
- package/skill/references/code.md +17 -8
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +32 -5
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +17 -7
- package/src/cli/block.mjs +112 -7
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +118 -14
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +7 -4
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/proof/lock.mjs +23 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/engine.mjs +59 -12
- package/src/review/fixloop.mjs +41 -16
- package/src/review/gate-check.mjs +2 -2
- package/src/review/lenses/A.md +5 -0
- package/src/review/lenses/B.md +5 -0
- package/src/review/lenses/full.md +5 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +5 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +81 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +35 -2
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -0
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
48
48
|
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
49
49
|
* re-decompose`, `stop-exits.mjs`);
|
|
50
|
-
* - `unproven`: a changed owned file of tier `high` (B20,
|
|
50
|
+
* - `unproven`: a changed owned file of tier `high` (B20, the owner 2026-09-25) has no covering
|
|
51
51
|
* red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
|
|
52
52
|
* B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
|
|
53
53
|
* ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
|
|
@@ -803,7 +803,7 @@ export function closeTier({ file, rows, key, highPaths }) {
|
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
/**
|
|
806
|
-
* B20 (
|
|
806
|
+
* B20 (the owner 2026-09-25): every changed owned file of tier `high` needs a covering red→green
|
|
807
807
|
* proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
|
|
808
808
|
* already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
|
|
809
809
|
* whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
|
package/src/review/lenses/A.md
CHANGED
|
@@ -11,9 +11,14 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
11
11
|
- State: ordering, concurrency, partial writes, values that can be stale.
|
|
12
12
|
- Arithmetic and units: rounding, overflow, bytes versus characters, time zones.
|
|
13
13
|
|
|
14
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
15
|
+
The fix for a weak assertion is a stronger assertion.
|
|
16
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
17
|
+
|
|
14
18
|
## Rules for every answer
|
|
15
19
|
|
|
16
20
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
21
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
17
22
|
- You have no tools and no project files. The packet is everything you get.
|
|
18
23
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
19
24
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
package/src/review/lenses/B.md
CHANGED
|
@@ -12,9 +12,14 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
12
12
|
that can escape a root (`..`, absolute, symlinks); missing authorization; unsafe defaults.
|
|
13
13
|
- Facts: a flag, path or API the change relies on that the facts excerpt contradicts.
|
|
14
14
|
|
|
15
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
16
|
+
The fix for a weak assertion is a stronger assertion.
|
|
17
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
18
|
+
|
|
15
19
|
## Rules for every answer
|
|
16
20
|
|
|
17
21
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
22
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
23
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
24
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
25
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
@@ -12,9 +12,14 @@ You are a code reviewer doing a FULL review of one changed file.
|
|
|
12
12
|
|
|
13
13
|
Report each real defect once, at the lines where it lives.
|
|
14
14
|
|
|
15
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
16
|
+
The fix for a weak assertion is a stronger assertion.
|
|
17
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
18
|
+
|
|
15
19
|
## Rules for every answer
|
|
16
20
|
|
|
17
21
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
22
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
23
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
24
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
25
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
@@ -13,6 +13,10 @@ their two answers, the list of hunk headers, and the diff only when the packet i
|
|
|
13
13
|
- Your `findings` list is final: nothing you drop is fixed, everything you keep is.
|
|
14
14
|
- `passed` is true only when you keep no `critical` and no `warning`.
|
|
15
15
|
|
|
16
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
17
|
+
The fix for a weak assertion is a stronger assertion.
|
|
18
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
19
|
+
|
|
16
20
|
## Rules for every answer
|
|
17
21
|
|
|
18
22
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -10,6 +10,10 @@ You are a code reviewer doing a QUICK pass on one small, low-risk change.
|
|
|
10
10
|
|
|
11
11
|
Skip style, naming and structure unless it hides a bug. Keep `findings` short.
|
|
12
12
|
|
|
13
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
14
|
+
The fix for a weak assertion is a stronger assertion.
|
|
15
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
16
|
+
|
|
13
17
|
## Rules for every answer
|
|
14
18
|
|
|
15
19
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
@@ -15,9 +15,14 @@ that changed since that round, each with a window of the current file around it.
|
|
|
15
15
|
- `passed` is true only when every open finding is resolved and you raised no new `critical`
|
|
16
16
|
or `warning`.
|
|
17
17
|
|
|
18
|
+
Never ask for hand mutations or for tests beyond the acceptance clauses.
|
|
19
|
+
The fix for a weak assertion is a stronger assertion.
|
|
20
|
+
Code or tests beyond what the change needs (unused options, extra layers, speculative generality) is at most a `nit` unless it hides a bug.
|
|
21
|
+
|
|
18
22
|
## Rules for every answer
|
|
19
23
|
|
|
20
24
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
25
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
21
26
|
- You have no tools and no project files. The packet is everything you get.
|
|
22
27
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
23
28
|
- Answer with ONE JSON object that matches the answer schema.
|
package/src/review/packet.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* ## project rules the rules digest (≤ 60 lines)
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
|
+
* ## paired optional paired-file names and summary (B97), dropped first over budget
|
|
13
14
|
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
14
15
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
15
16
|
*
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
21
22
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
22
23
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
23
|
-
* ± `min_context_lines` (`minimal`)
|
|
24
|
+
* ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
|
|
24
25
|
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
25
26
|
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
26
27
|
*
|
|
@@ -190,11 +191,11 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
190
191
|
* Assemble a reviewer packet (pure).
|
|
191
192
|
* @param {{
|
|
192
193
|
* diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
|
|
193
|
-
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
|
|
194
|
+
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string, paired?: string,
|
|
194
195
|
* }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
|
|
195
196
|
* @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
|
|
196
197
|
*/
|
|
197
|
-
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
|
|
198
|
+
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '', paired = '' }) {
|
|
198
199
|
const lensText = loadLens(lens);
|
|
199
200
|
const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
|
|
200
201
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
@@ -204,7 +205,12 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
204
205
|
|
|
205
206
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
206
207
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
207
|
-
let
|
|
208
|
+
let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
|
|
209
|
+
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
|
|
210
|
+
if (estimateTokens(text) > limit && pairedSection.length > 0) {
|
|
211
|
+
pairedSection = '';
|
|
212
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
213
|
+
}
|
|
208
214
|
if (estimateTokens(text) > limit) {
|
|
209
215
|
ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
210
216
|
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
@@ -217,6 +223,22 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
217
223
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
/** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
|
|
227
|
+
* @param {string} paired @param {number} fullIn @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
function boundedPaired(paired, fullIn) {
|
|
230
|
+
if (paired.trim().length === 0) return '';
|
|
231
|
+
const heading = '## paired\n';
|
|
232
|
+
let left = Math.floor(fullIn / 16) * 4 - Buffer.byteLength(heading) - 1;
|
|
233
|
+
let body = '';
|
|
234
|
+
for (const char of paired) {
|
|
235
|
+
left -= Buffer.byteLength(char);
|
|
236
|
+
if (left < 0) break;
|
|
237
|
+
body += char;
|
|
238
|
+
}
|
|
239
|
+
return body.length > 0 ? heading + body.trimEnd() : '';
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
/**
|
|
221
243
|
* The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
|
|
222
244
|
* `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
|
|
@@ -244,8 +266,9 @@ function diffSectionOf(diff) {
|
|
|
244
266
|
/**
|
|
245
267
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
246
268
|
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
269
|
+
* @param {string} [paired] - the bounded `## paired` section (heading included).
|
|
247
270
|
*/
|
|
248
|
-
function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
271
|
+
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
|
|
249
272
|
return [
|
|
250
273
|
'# code-forge review packet',
|
|
251
274
|
'## lens',
|
|
@@ -256,6 +279,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
|
256
279
|
facts.trim().length > 0 ? facts.trimEnd() : '(none)',
|
|
257
280
|
'## context',
|
|
258
281
|
context.length > 0 ? context : '(none)',
|
|
282
|
+
...(paired.length > 0 ? [paired] : []),
|
|
259
283
|
...(moved.length > 0 ? [moved] : []),
|
|
260
284
|
'## diff',
|
|
261
285
|
diffSection,
|
|
@@ -319,9 +343,10 @@ function readRepoText(repoRoot, rel) {
|
|
|
319
343
|
|
|
320
344
|
/**
|
|
321
345
|
* Why `rel` may not be attached, or null.
|
|
322
|
-
* @param {string} repoRoot @param {string} rel @
|
|
346
|
+
* @param {string} repoRoot @param {string} rel @param {ReadonlyArray<string>} ownedFiles
|
|
347
|
+
* @param {ReadonlyArray<string>} changedFiles @returns {Promise<string | null>}
|
|
323
348
|
*/
|
|
324
|
-
async function attachRefusal(repoRoot, rel) {
|
|
349
|
+
async function attachRefusal(repoRoot, rel, ownedFiles, changedFiles) {
|
|
325
350
|
if (isSecretLike(rel)) return 'refused: secret-like path';
|
|
326
351
|
let st;
|
|
327
352
|
try {
|
|
@@ -330,20 +355,29 @@ async function attachRefusal(repoRoot, rel) {
|
|
|
330
355
|
return 'missing';
|
|
331
356
|
}
|
|
332
357
|
if (!st.isFile()) return 'not a file';
|
|
333
|
-
const res = await exec(['git', 'ls-files', '-z', '--
|
|
334
|
-
if (res.result !== 'ok'
|
|
358
|
+
const res = await exec(['git', 'ls-files', '-z', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
359
|
+
if (res.result !== 'ok') return 'refused: git failed';
|
|
360
|
+
if (res.stdout === `${rel}\0`) return null;
|
|
361
|
+
if (res.stdout.length > 0) return 'refused: git failed';
|
|
362
|
+
if (!ownedFiles.includes(rel)) return 'refused: untracked, not owned by this block';
|
|
363
|
+
if (!changedFiles.includes(rel)) return 'refused: untracked, not changed in this block';
|
|
364
|
+
const ignored = await exec(['git', 'check-ignore', '-q', '--', rel], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1] });
|
|
365
|
+
if (ignored.result !== 'ok' || (ignored.code !== 0 && ignored.code !== 1)) return 'refused: git failed';
|
|
366
|
+
if (ignored.code === 0) return 'refused: gitignored';
|
|
335
367
|
return null;
|
|
336
368
|
}
|
|
337
369
|
|
|
338
370
|
/**
|
|
339
371
|
* The `needs_file` follow-up (§4.2): the requested files, read-only, appended AFTER the packet
|
|
340
|
-
* (the stable prefix stays stable).
|
|
341
|
-
*
|
|
372
|
+
* (the stable prefix stays stable). Regular tracked files, or untracked files present in BOTH
|
|
373
|
+
* concrete path lists of this block, are attached. Ignored untracked files never reach a provider,
|
|
374
|
+
* and secret-like paths are refused even
|
|
342
375
|
* when tracked; every refusal is named in the section. The attachment is trimmed to `budgetTokens`.
|
|
343
|
-
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number}} opts
|
|
376
|
+
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>}} opts
|
|
377
|
+
* `ownedFiles` and `changedFiles`: resolved paths from the SAME block in this repoRoot; no globs.
|
|
344
378
|
* @returns {Promise<string>} the section to append.
|
|
345
379
|
*/
|
|
346
|
-
export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
380
|
+
export async function attachFiles({ repoRoot, paths, budgetTokens, ownedFiles = [], changedFiles = [] }) {
|
|
347
381
|
const out = ['## attached files (read-only, requested in needs_file)'];
|
|
348
382
|
let left = budgetTokens * 4;
|
|
349
383
|
for (const raw of paths.slice(0, 10)) {
|
|
@@ -356,7 +390,7 @@ export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
|
356
390
|
out.push(notFile ? `### ${String(raw)} (not a file)` : '### (refused: not a repo-relative path)');
|
|
357
391
|
continue;
|
|
358
392
|
}
|
|
359
|
-
const refusal = await attachRefusal(repoRoot, rel);
|
|
393
|
+
const refusal = await attachRefusal(repoRoot, rel, ownedFiles, changedFiles);
|
|
360
394
|
if (refusal) {
|
|
361
395
|
out.push(`### ${rel} (${refusal})`);
|
|
362
396
|
continue;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Stem pairs and bounded names for closed-book review packets (B97). No file reads. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findSecret } from '../util/gh.mjs';
|
|
4
|
+
import { redact } from '../util/redact.mjs';
|
|
5
|
+
|
|
6
|
+
/** @param {string} file */
|
|
7
|
+
function identity(file) {
|
|
8
|
+
if (typeof file !== 'string' || file.startsWith('/') || /[\\\x00-\x1f]/.test(file) || file.split('/').some((s) => s === '.' || s === '..' || s === '')) return null;
|
|
9
|
+
const ext = path.posix.extname(file);
|
|
10
|
+
let stem = path.posix.basename(file, ext);
|
|
11
|
+
let test = /(^|\/)(?:test|tests)\//.test(file);
|
|
12
|
+
if (ext === '.mjs' || ext === '.ts') {
|
|
13
|
+
if (/\.(test|spec)$/.test(stem)) { test = true; stem = stem.replace(/\.(test|spec)$/, ''); }
|
|
14
|
+
} else if (ext === '.php') {
|
|
15
|
+
if (stem.endsWith('Test')) { test = true; stem = stem.slice(0, -4); }
|
|
16
|
+
} else if (ext === '.go') {
|
|
17
|
+
if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
18
|
+
} else if (ext === '.py') {
|
|
19
|
+
if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
|
|
20
|
+
else if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
21
|
+
} else if (ext === '.swift') {
|
|
22
|
+
if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
|
|
23
|
+
} else return null;
|
|
24
|
+
return { ext, stem, test };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} file @param {ReadonlyArray<string>} candidates @returns {string[]} */
|
|
28
|
+
export function pairsFor(file, candidates) {
|
|
29
|
+
const source = identity(file);
|
|
30
|
+
if (!source) return [];
|
|
31
|
+
return [...new Set(candidates)].filter((candidate) => {
|
|
32
|
+
const other = identity(candidate);
|
|
33
|
+
return candidate !== file && other?.ext === source.ext && other.stem === source.stem && other.test !== source.test;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A best-effort list, not a parser or proof of coverage. Names only, never function bodies.
|
|
39
|
+
* Redact before truncating so a token crossing the 120-character boundary cannot leak.
|
|
40
|
+
* @param {string} text @param {string} file @returns {string[]}
|
|
41
|
+
*/
|
|
42
|
+
export function summarize(text, file) {
|
|
43
|
+
const id = identity(file);
|
|
44
|
+
if (!id) return [];
|
|
45
|
+
/** @type {Set<string>} */
|
|
46
|
+
const names = new Set();
|
|
47
|
+
for (const line of text.split(/\r?\n/)) {
|
|
48
|
+
/** @type {RegExpMatchArray | null} */
|
|
49
|
+
let match = null;
|
|
50
|
+
if (id.test) {
|
|
51
|
+
if (id.ext === '.mjs' || id.ext === '.ts' || id.ext === '.php') {
|
|
52
|
+
match = line.match(/^\s*(?:await\s+)?(?:test|it)(?:\.(?:only|skip|todo))?\(\s*(['"`])([^\r\n]+?)\1/);
|
|
53
|
+
if (match) { names.add(match[2]); match = null; }
|
|
54
|
+
else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected)\s+)?function\s+(test\w+)\s*\(/);
|
|
55
|
+
else continue;
|
|
56
|
+
} else if (id.ext === '.go') match = line.match(/^\s*func\s+(Test\w+)\s*\(/);
|
|
57
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/);
|
|
58
|
+
else if (id.ext === '.swift') match = line.match(/^\s*func\s+(test\w+)\s*\(/);
|
|
59
|
+
} else {
|
|
60
|
+
if (id.ext === '.mjs' || id.ext === '.ts') {
|
|
61
|
+
match = line.match(/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/);
|
|
62
|
+
const list = line.match(/^\s*export\s*\{([^}]+)\}/);
|
|
63
|
+
if (list) {
|
|
64
|
+
for (const item of list[1].split(',')) {
|
|
65
|
+
const symbol = item.trim().match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
|
|
66
|
+
if (symbol) names.add(symbol[2] ?? symbol[1]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*(?:function|class|interface|trait)\s+(\w+)/);
|
|
70
|
+
else if (id.ext === '.go') match = line.match(/^\s*(?:func\s+(?:\([^)]*\)\s+)?|(?:type|var|const)\s+)([A-Z]\w*)/);
|
|
71
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([a-zA-Z]\w*)/);
|
|
72
|
+
else if (id.ext === '.swift') match = line.match(/^\s*(?:public|open)\s+(?:final\s+)?(?:func|class|struct|enum|protocol|let|var)\s+(\w+)/);
|
|
73
|
+
}
|
|
74
|
+
if (match) names.add(match[1]);
|
|
75
|
+
if (names.size >= 40) break;
|
|
76
|
+
}
|
|
77
|
+
return [...names].slice(0, 40).map((name) => {
|
|
78
|
+
const safe = redact(name);
|
|
79
|
+
return Array.from(findSecret(safe) ? '[REDACTED]' : safe).slice(0, 120).join('');
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/review/reopen.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - `reopen-not-stopped` — no state, or a state that is open or complete;
|
|
19
19
|
* - `reopen-same-content` — a stopped file whose current content is the content its stop
|
|
20
20
|
* reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
|
|
21
|
-
* is unknown;
|
|
21
|
+
* is unknown and the current hash already has a verified review round;
|
|
22
22
|
* - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
|
|
23
23
|
* for other content (run `review-file`, or the content changed since that reopen);
|
|
24
24
|
* - `reopen-write-failed` — the signed row was not written.
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
|
|
33
33
|
* anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
|
|
34
34
|
* stop counts — written after the file's latest verified `review.reopened` row, with the state's
|
|
35
|
-
* round and stop reason — else the content is unknown
|
|
35
|
+
* round and stop reason — else the content is unknown. B114: unknown content can restart only
|
|
36
|
+
* when no verified review.round of this run/block/file names the current hash (from_hash: null).
|
|
36
37
|
*
|
|
37
38
|
* The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
|
|
38
39
|
* and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
|
|
@@ -102,6 +103,15 @@ export function stopContentHash(state, rows, { runId, block, file, key }) {
|
|
|
102
103
|
return null;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A legacy stop with unknown content can restart only on bytes with no verified round here.
|
|
108
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
109
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, contentHash: string}} at
|
|
110
|
+
*/
|
|
111
|
+
function hasReviewedHash(rows, { runId, block, file, key, contentHash }) {
|
|
112
|
+
return rows.some((r) => r?.event === 'review.round' && r.run === runId && r.block === block && r.file === file && r.content_hash === contentHash && verified(r, key));
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
/**
|
|
106
116
|
* The block checks every reopen runs first, before any config, key or ledger read: the run must
|
|
107
117
|
* know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
|
|
@@ -138,7 +148,8 @@ function latestRowAt(rows, { runId, block, file, key, seq }) {
|
|
|
138
148
|
* verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
|
|
139
149
|
* file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
|
|
140
150
|
* stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
|
|
141
|
-
* nor the content the stop reviewed (`stopContentHash
|
|
151
|
+
* nor the content the stop reviewed (`stopContentHash`). Unknown content requires a null
|
|
152
|
+
* from_hash, the row's to_hash equal to current content, and no verified round at that hash.
|
|
142
153
|
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
143
154
|
* @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
|
|
144
155
|
* @returns {Record<string, any> | null}
|
|
@@ -149,7 +160,8 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
|
|
|
149
160
|
if (status === 'lost') return row.from === 'lost' ? row : null;
|
|
150
161
|
if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
|
|
151
162
|
const stopHash = stopContentHash(state, rows, { runId, block, file, key });
|
|
152
|
-
if (
|
|
163
|
+
if (contentHash === stopHash || contentHash === row.from_hash) return null;
|
|
164
|
+
if (stopHash === null && (row.from_hash !== null || row.to_hash !== contentHash || hasReviewedHash(rows, { runId, block, file, key, contentHash }))) return null;
|
|
153
165
|
return row;
|
|
154
166
|
}
|
|
155
167
|
return null;
|
|
@@ -200,8 +212,8 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
|
|
|
200
212
|
from = 'stopped';
|
|
201
213
|
stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
|
|
202
214
|
fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
|
|
203
|
-
if (fromHash === null) {
|
|
204
|
-
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown
|
|
215
|
+
if (fromHash === null && hasReviewedHash(runRows, { runId, block: id, file, key, contentHash: current })) {
|
|
216
|
+
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown and its current content has already been reviewed; change the file and run block reopen, or the owner can run block close --no-require-reviews; nothing was recorded`);
|
|
205
217
|
}
|
|
206
218
|
if (fromHash === current) {
|
|
207
219
|
throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B96 (E1): the close report is built by the tool. After the review gate passed (`reviewGateCheck`
|
|
3
|
+
* over `checkBlockReviews`), every file the gate checked is accepted at its current hash, either
|
|
4
|
+
* by a signed `review.approved` row or — the gate's `waivedAll` list (B70/B71) — because every
|
|
5
|
+
* finding its review left open was waived or answered. This module turns that verdict into one
|
|
6
|
+
* line per file: `{file, ticket, via}`, `via` one of `approved`, `waiver`, `answered`, and
|
|
7
|
+
* `ticket` the queue's ticket for that file and hash (the caller passes `ticketOf`, the queue's
|
|
8
|
+
* own rule: `src/review` never imports the worker). It never re-decides the acceptance: the gate
|
|
9
|
+
* is the only judge, and a file the gate did not accept is never passed here.
|
|
10
|
+
*
|
|
11
|
+
* It also reads the coder's hand lines (`reviewed <path> ticket <id>`), to name a hand line that
|
|
12
|
+
* names another ticket (D1: a warning, the tool lines are the record).
|
|
13
|
+
*
|
|
14
|
+
* Its warnings (`onNoTicket`, `onBadTicket`) are callbacks: the close holds what they report and
|
|
15
|
+
* prints it only when the block closes (src/cli/block.mjs).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @typedef {'approved' | 'waiver' | 'answered'} Via */
|
|
19
|
+
/** @typedef {{file: string, ticket: string, via: Via}} ReviewedLine */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `via` of a file the gate accepted, read from the gate's own record (`WaivedAll`, built by
|
|
23
|
+
* `waivedAllOf` in gate-check.mjs): no record at the same hash ⇒ `approved` (a signed approval);
|
|
24
|
+
* a record whose `by` is null ⇒ `answered` (the gate sets `by` to null exactly when every open
|
|
25
|
+
* finding was answered); any other `by` (`human` or `autopilot`) ⇒ `waiver` — also for a file
|
|
26
|
+
* with some findings answered and at least one waived (the waiver is what a human or a grant
|
|
27
|
+
* decided, so the line names it).
|
|
28
|
+
* @param {import('./gate-check.mjs').WaivedAll | undefined} record @returns {Via}
|
|
29
|
+
*/
|
|
30
|
+
export function viaOf(record) {
|
|
31
|
+
if (record === undefined) return 'approved';
|
|
32
|
+
return record.by === null ? 'answered' : 'waiver';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A ticket id as the queue gives it: 24 lowercase hex characters (`assertTicketId`). */
|
|
36
|
+
const TICKET_ID = /^[0-9a-f]{24}$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The tool's reviewed lines, sorted by file. Pure but for `onNoTicket` and `onBadTicket`.
|
|
40
|
+
* @param {{
|
|
41
|
+
* files: ReadonlyArray<{file: string, content_hash: string}>,
|
|
42
|
+
* waivedAll: ReadonlyArray<import('./gate-check.mjs').WaivedAll>,
|
|
43
|
+
* ticketOf: (file: {file: string, content_hash: string}) => unknown,
|
|
44
|
+
* onNoTicket?: (file: string) => void,
|
|
45
|
+
* onBadTicket?: (file: string) => void,
|
|
46
|
+
* }} opts `files`: the files the review gate checked and ACCEPTED (it passed); `waivedAll`: the
|
|
47
|
+
* gate's `reviewed_by_waiver` files ({@link viaOf}); every other file passed through a signed
|
|
48
|
+
* approval. `ticketOf`: the queue's ticket for that file at that hash, or null (or undefined)
|
|
49
|
+
* when it cannot be given — then the file has no line (fail closed) and `onNoTicket(file)` is
|
|
50
|
+
* called once. Any other value that is not 24 lowercase hex characters (a string of another
|
|
51
|
+
* shape, a number…) is a bad ticket: the file has no line either and `onBadTicket(file)` is
|
|
52
|
+
* called once instead.
|
|
53
|
+
* @returns {ReviewedLine[]}
|
|
54
|
+
*/
|
|
55
|
+
export function reviewedLines({ files, waivedAll, ticketOf, onNoTicket = () => {}, onBadTicket = () => {} }) {
|
|
56
|
+
/** @type {ReviewedLine[]} */
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
const via = viaOf(waivedAll.find((w) => w.file === f.file && w.content_hash === f.content_hash));
|
|
60
|
+
const ticket = ticketOf(f);
|
|
61
|
+
if (ticket === null || ticket === undefined) onNoTicket(f.file);
|
|
62
|
+
else if (typeof ticket === 'string' && TICKET_ID.test(ticket)) lines.push({ file: f.file, ticket, via });
|
|
63
|
+
else onBadTicket(f.file);
|
|
64
|
+
}
|
|
65
|
+
return lines.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A hand line: exactly `reviewed <path> ticket <id>` once trimmed, with backticks dropped and an
|
|
70
|
+
* optional leading list marker (`- `, `* ` or a number and a dot, `1. `). The id is 24 hex
|
|
71
|
+
* characters of either case: a ticket id is lowercase, so an id in capitals is read here and then
|
|
72
|
+
* never equals the tool's ticket ({@link reportMismatches} compares exactly).
|
|
73
|
+
*/
|
|
74
|
+
const HAND_LINE = /^(?:(?:[-*]|\d{1,9}\.)\s+)?reviewed\s+(\S+)\s+ticket\s+([0-9a-fA-F]{24})$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* D1: the files whose hand line in `report` names a ticket id other than the tool's (compared
|
|
78
|
+
* exactly, so an id in capitals is another ticket). Only a `reviewed <path> ticket <id>` line
|
|
79
|
+
* counts ({@link HAND_LINE}); a hash of another length, or a line of any other shape that names
|
|
80
|
+
* the file, never does.
|
|
81
|
+
* @param {string} report @param {ReadonlyArray<ReviewedLine>} lines
|
|
82
|
+
* @returns {string[]} the files with a mismatched hand line, in the order of `lines`.
|
|
83
|
+
*/
|
|
84
|
+
export function reportMismatches(report, lines) {
|
|
85
|
+
/** @type {Set<string>} */
|
|
86
|
+
const other = new Set();
|
|
87
|
+
for (const raw of report.split('\n')) {
|
|
88
|
+
const m = HAND_LINE.exec(raw.replaceAll('`', '').trim());
|
|
89
|
+
if (m === null) continue;
|
|
90
|
+
const line = lines.find((l) => l.file === m[1]);
|
|
91
|
+
if (line !== undefined && line.ticket !== m[2]) other.add(line.file);
|
|
92
|
+
}
|
|
93
|
+
return lines.filter((l) => other.has(l.file)).map((l) => l.file);
|
|
94
|
+
}
|
package/src/state/block-end.mjs
CHANGED
|
@@ -54,11 +54,13 @@ function safeId(id) {
|
|
|
54
54
|
* @param {{
|
|
55
55
|
* block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
56
56
|
* commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
|
|
57
|
-
* }
|
|
57
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
58
|
+
* }} facts `reviewed` (B96): the close's reviewed lines, sorted by file; the key is added last, and only when given.
|
|
58
59
|
* @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
|
|
59
|
-
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string
|
|
60
|
+
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string,
|
|
61
|
+
* reviewed?: Array<{file: string, ticket: string, via: string}>}}
|
|
60
62
|
*/
|
|
61
|
-
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
|
|
63
|
+
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at, reviewed }) {
|
|
62
64
|
return {
|
|
63
65
|
block,
|
|
64
66
|
run_id: runId,
|
|
@@ -71,6 +73,7 @@ export function doneFileContent({ block, runId, status, event, reason = null, co
|
|
|
71
73
|
unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
|
|
72
74
|
findings: Number.isInteger(findings) ? findings : null,
|
|
73
75
|
at: at.toISOString(),
|
|
76
|
+
...(Array.isArray(reviewed) ? { reviewed: reviewed.map((r) => ({ file: r.file, ticket: r.ticket, via: r.via })).sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0)) } : {}),
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -97,12 +100,13 @@ async function headCommit(cwd) {
|
|
|
97
100
|
* @param {{
|
|
98
101
|
* record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
99
102
|
* openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
|
|
103
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
100
104
|
* }} opts - `record` is the run record read before the end (its `workspace` and the block's
|
|
101
105
|
* `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
|
|
102
|
-
* as `code-forge status` does.
|
|
106
|
+
* as `code-forge status` does. `reviewed` (B96): the close's reviewed lines, kept as the last key.
|
|
103
107
|
* @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
|
|
104
108
|
*/
|
|
105
|
-
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
|
|
109
|
+
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date(), reviewed }) {
|
|
106
110
|
const runId = String(record?.run_id);
|
|
107
111
|
try {
|
|
108
112
|
const file = doneFilePath(runId, id);
|
|
@@ -127,7 +131,7 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
|
|
|
127
131
|
}
|
|
128
132
|
const tree = record.blocks?.[id]?.worktree?.path;
|
|
129
133
|
const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
|
|
130
|
-
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
|
|
134
|
+
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now, ...(reviewed === undefined ? {} : { reviewed }) });
|
|
131
135
|
const dir = path.dirname(file);
|
|
132
136
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
133
137
|
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
package/src/util/exec.mjs
CHANGED
|
@@ -139,6 +139,7 @@ function untrackChild(pid) {
|
|
|
139
139
|
* @param {object} [opts]
|
|
140
140
|
* @param {string} [opts.cwd]
|
|
141
141
|
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
142
|
+
* @param {BufferEncoding} [opts.stdoutEncoding] - stdout encoding (default UTF-8); base64 preserves binary git blobs.
|
|
142
143
|
* @param {number} [opts.timeoutMs] - Kill the child (and its process group) if it runs longer
|
|
143
144
|
* than this. Must be a finite number > 0 when given.
|
|
144
145
|
* @param {number[]} [opts.okExitCodes] - Exit codes treated as `result: "ok"` (default `[0]`).
|
|
@@ -212,7 +213,7 @@ export function exec(argv, opts = {}) {
|
|
|
212
213
|
child.kill(signal);
|
|
213
214
|
};
|
|
214
215
|
|
|
215
|
-
const stdoutText = () => Buffer.concat(stdoutChunks).toString('utf8');
|
|
216
|
+
const stdoutText = () => Buffer.concat(stdoutChunks).toString(opts.stdoutEncoding ?? 'utf8');
|
|
216
217
|
const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
|
|
217
218
|
|
|
218
219
|
/** @param {ExecResult} result */
|
package/src/util/gate-token.mjs
CHANGED