@codedology/code-forge 0.8.0 → 0.9.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 +56 -0
- package/README.md +18 -0
- package/docs/reference/blocks.json +234 -1
- package/package.json +1 -1
- package/skill/references/code.md +15 -6
- package/skill/references/ledger.md +1 -1
- package/skill/references/review.md +21 -3
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -2
- package/src/cli/block.mjs +137 -10
- package/src/cli/review-file.mjs +264 -13
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +89 -2
- package/src/ledger/write.mjs +7 -1
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +375 -78
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +3 -1
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deletion note (block B74, issue #8): a packet for a pure deletion (the file is gone from the
|
|
3
|
+
* reviewed tree, its diff is all `-` lines) gets a `## deleted file` section before the diff. It
|
|
4
|
+
* says the file is deleted and lists the tree paths that still name the file's basename, so the
|
|
5
|
+
* reviewer checks for dangling references.
|
|
6
|
+
*
|
|
7
|
+
* The reviewed tree is the working tree the review engine diffs (`reviewFile`'s `repoRoot`: the
|
|
8
|
+
* block's worktree, or the main tree), never a commit: the paths come from ONE
|
|
9
|
+
* `git grep -l -z --untracked -F -e <basename> -- .` run with that tree as cwd (argv only, the
|
|
10
|
+
* review git env `reviewGitEnv`: no inherited `GIT_*` variable, `GIT_CONFIG_NOSYSTEM=1`,
|
|
11
|
+
* `GIT_CONFIG_GLOBAL` the null device, `GIT_LITERAL_PATHSPECS=1`). `--untracked` adds the files a
|
|
12
|
+
* block created and has not committed yet (never ignored ones). The list is sorted, never names the
|
|
13
|
+
* deleted path itself or a secret-like path (`isSecretLike`), and stops at {@link MAX_REFERENCES}
|
|
14
|
+
* paths; a path with a control character (it could break the packet layout) is never printed, only
|
|
15
|
+
* counted with the paths past the cap (`(<n> more not listed)`). No match ⇒ `no references found`.
|
|
16
|
+
* A basename shorter than {@link MIN_BASENAME} characters is not grepped, nor is a deleted path
|
|
17
|
+
* with a control character (it is printed JSON-escaped), and a grep that fails (or times out) is
|
|
18
|
+
* not trusted: all say `references not checked`. The note is a hint, never an exemption: a
|
|
19
|
+
* deletion is reviewed and needs a signed approval like any other change (a grep cannot prove that
|
|
20
|
+
* nothing depends on a file).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { exec } from '../util/exec.mjs';
|
|
25
|
+
import { isSecretLike, reviewGitEnv } from './packet.mjs';
|
|
26
|
+
|
|
27
|
+
/** At most this many referencing paths are listed. */
|
|
28
|
+
export const MAX_REFERENCES = 20;
|
|
29
|
+
|
|
30
|
+
/** A basename shorter than this matches too much to be a useful hint: it is not grepped. */
|
|
31
|
+
export const MIN_BASENAME = 3;
|
|
32
|
+
|
|
33
|
+
export const NO_REFERENCES = 'no references found';
|
|
34
|
+
export const NOT_CHECKED = 'references not checked';
|
|
35
|
+
|
|
36
|
+
const GREP_TIMEOUT_MS = 30000;
|
|
37
|
+
|
|
38
|
+
const CONTROL = /[\x00-\x1f\x7f]/;
|
|
39
|
+
|
|
40
|
+
/** @param {string} p @returns {boolean} whether `p` has a control character. */
|
|
41
|
+
const hasControl = (p) => CONTROL.test(p);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} References
|
|
45
|
+
* @property {'found' | 'none' | 'not_checked'} status
|
|
46
|
+
* @property {string[]} paths - sorted, at most {@link MAX_REFERENCES}.
|
|
47
|
+
* @property {number} more - matching paths not listed: past the cap, or with a control character.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** @type {References} */
|
|
51
|
+
const NOT_CHECKED_REFS = Object.freeze({ status: 'not_checked', paths: [], more: 0 });
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The paths of the reviewed tree (`repoRoot`, the grep's cwd) that still name `file`'s basename.
|
|
55
|
+
* @param {{repoRoot: string, file: string, run?: typeof exec}} opts - `run`: the child runner
|
|
56
|
+
* (default `exec`; a test seam).
|
|
57
|
+
* @returns {Promise<References>}
|
|
58
|
+
*/
|
|
59
|
+
export async function findReferences({ repoRoot, file, run = exec }) {
|
|
60
|
+
const base = path.posix.basename(file);
|
|
61
|
+
if (base.length < MIN_BASENAME || hasControl(file)) return { ...NOT_CHECKED_REFS, paths: [] };
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await run(['git', 'grep', '-l', '-z', '--untracked', '-F', '-e', base, '--', '.'], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GREP_TIMEOUT_MS, okExitCodes: [0, 1] });
|
|
65
|
+
} catch {
|
|
66
|
+
return { ...NOT_CHECKED_REFS, paths: [] };
|
|
67
|
+
}
|
|
68
|
+
if (res?.result !== 'ok' || (res.code !== 0 && res.code !== 1) || typeof res.stdout !== 'string') return { ...NOT_CHECKED_REFS, paths: [] };
|
|
69
|
+
const matched = [...new Set(res.stdout.split('\0').filter((p) => p.length > 0 && p !== file && !isSecretLike(p)))];
|
|
70
|
+
const unsafe = matched.filter(hasControl).length;
|
|
71
|
+
const clean = matched.filter((p) => !hasControl(p)).sort();
|
|
72
|
+
if (clean.length === 0 && unsafe === 0) return { status: 'none', paths: [], more: 0 };
|
|
73
|
+
return { status: 'found', paths: clean.slice(0, MAX_REFERENCES), more: Math.max(0, clean.length - MAX_REFERENCES) + unsafe };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `## deleted file` section (heading included) for a deleted `file`. A name with a control
|
|
78
|
+
* character is printed JSON-escaped, never raw.
|
|
79
|
+
* @param {string} file @param {References} refs @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function renderDeletionNote(file, refs) {
|
|
82
|
+
const shown = hasControl(file) ? JSON.stringify(file) : file;
|
|
83
|
+
const out = ['## deleted file', `this file is deleted: ${shown}`, `check that nothing still depends on it. tree paths that still name ${JSON.stringify(path.posix.basename(file))}:`];
|
|
84
|
+
if (refs.status === 'not_checked') out.push(NOT_CHECKED);
|
|
85
|
+
else if (refs.status === 'none') out.push(NO_REFERENCES);
|
|
86
|
+
else {
|
|
87
|
+
out.push(...refs.paths.map((p) => `- ${p}`));
|
|
88
|
+
if (refs.more > 0) out.push(`(${refs.more} more not listed)`);
|
|
89
|
+
}
|
|
90
|
+
return out.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The deletion note for `file`, its references read in `repoRoot` (the reviewed tree). Never
|
|
95
|
+
* rejects: anything that fails is `references not checked`.
|
|
96
|
+
* @param {{repoRoot: string, file: string, run?: typeof exec}} opts @returns {Promise<string>}
|
|
97
|
+
*/
|
|
98
|
+
export async function deletionNote(opts) {
|
|
99
|
+
let refs;
|
|
100
|
+
try {
|
|
101
|
+
refs = await findReferences(opts);
|
|
102
|
+
} catch {
|
|
103
|
+
refs = NOT_CHECKED_REFS;
|
|
104
|
+
}
|
|
105
|
+
return renderDeletionNote(opts.file, refs);
|
|
106
|
+
}
|
package/src/review/engine.mjs
CHANGED
|
@@ -40,12 +40,15 @@
|
|
|
40
40
|
* (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
|
|
41
41
|
* reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
|
|
42
42
|
* and it counts toward the budget in the same measure.
|
|
43
|
+
* 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
|
|
44
|
+
* every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
|
|
45
|
+
* (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
|
|
43
46
|
* Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
|
|
44
47
|
* the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
|
|
45
48
|
* `warning` finding.
|
|
46
49
|
*/
|
|
47
50
|
|
|
48
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
51
|
+
import { lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
49
52
|
import { randomBytes } from 'node:crypto';
|
|
50
53
|
import path from 'node:path';
|
|
51
54
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
@@ -55,6 +58,7 @@ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
|
|
|
55
58
|
import { tierFor } from '../proof/tiers.mjs';
|
|
56
59
|
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
57
60
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
61
|
+
import { deletionNote, renderDeletionNote } from './deletion.mjs';
|
|
58
62
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
59
63
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
60
64
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
@@ -182,6 +186,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
|
|
|
182
186
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
|
|
183
187
|
* (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
|
|
184
188
|
* failed write never fails the review (a failed `review.schema_fallback` write means no try).
|
|
189
|
+
* @property {(opts: {repoRoot: string, file: string}) => Promise<string>} [deletionNote] - B74: the
|
|
190
|
+
* deletion note reader (default `deletionNote`, `deletion.mjs`); a throw gives `references not checked`.
|
|
185
191
|
* @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
|
|
186
192
|
* signed schema-miss history (the worker's); absent ⇒ no second-level try.
|
|
187
193
|
*/
|
|
@@ -233,8 +239,22 @@ export async function reviewFile(input, deps) {
|
|
|
233
239
|
} catch {
|
|
234
240
|
moves = null; // no hint; the review goes on
|
|
235
241
|
}
|
|
242
|
+
// B74: a pure deletion gets the `## deleted file` note in every packet, read once; it is extra
|
|
243
|
+
// text like the moved code, counted in the same measure. "Deleted" is explicit: the diff is not
|
|
244
|
+
// empty (checked above), the reader found no content (`readFileDiff` gives null ONLY for a path
|
|
245
|
+
// not on disk: a binary file is decoded text, an unreadable one throws) AND the path is absent
|
|
246
|
+
// from the reviewed tree now. The references are read in that same tree (`repoRoot`).
|
|
247
|
+
let deleted = '';
|
|
248
|
+
if (diff.content === null && isAbsent(repoRoot, diff.file)) {
|
|
249
|
+
const note = deps.deletionNote ?? deletionNote;
|
|
250
|
+
try {
|
|
251
|
+
deleted = await note({ repoRoot, file: diff.file });
|
|
252
|
+
} catch {
|
|
253
|
+
deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
|
|
254
|
+
}
|
|
255
|
+
}
|
|
236
256
|
/** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
|
|
237
|
-
const movedOf = (keys) => movedText(moves, diff.content, keys);
|
|
257
|
+
const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys)].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
|
|
238
258
|
const min = minTokensOut(cfg);
|
|
239
259
|
// B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
|
|
240
260
|
const schemaFallback = plan.mode !== 'consensus';
|
|
@@ -466,6 +486,20 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
466
486
|
return { ...meta, status: 'ok', reason: null, review: verdict.review };
|
|
467
487
|
}
|
|
468
488
|
|
|
489
|
+
/**
|
|
490
|
+
* B74: whether `file` is absent from the tree at `repoRoot` (lstat says ENOENT or ENOTDIR).
|
|
491
|
+
* @param {string} repoRoot @param {string} file @returns {boolean}
|
|
492
|
+
*/
|
|
493
|
+
function isAbsent(repoRoot, file) {
|
|
494
|
+
try {
|
|
495
|
+
lstatSync(path.join(repoRoot, file));
|
|
496
|
+
return false;
|
|
497
|
+
} catch (err) {
|
|
498
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
499
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
469
503
|
/**
|
|
470
504
|
* Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
|
|
471
505
|
* @param {unknown} a @param {unknown} b @returns {number | null}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -24,11 +24,15 @@
|
|
|
24
24
|
* a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
|
|
25
25
|
* patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
|
|
26
26
|
* `rounds_at_level = 0`, and the `patch_check` goes through `decideNext` like any recheck —
|
|
27
|
-
* clean ⇒ complete;
|
|
28
|
-
* (`trigger: review_stall`); round ≥ cap ⇒
|
|
29
|
-
* `l3_patch: true` in the brief.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* clean ⇒ complete; the patch closed NO finding that was open before it (ids compared, not
|
|
28
|
+
* counts) ⇒ `stopped: l3_patch_exhausted` (`trigger: review_stall`); round ≥ cap ⇒
|
|
29
|
+
* `stopped: review_cap`; else `fix` at L2 with `l3_patch: true` in the brief. B73 (issue #12):
|
|
30
|
+
* a patch that closed ≥ 1 old id below the cap is progress, not a stall, even when new findings
|
|
31
|
+
* in its fix hunks keep the open set from shrinking. A later stall at L2 ⇒ `l3_patch_exhausted`
|
|
32
|
+
* (the rung is spent, never a second one); round 4 still open ⇒ `review_cap`. Either way the
|
|
33
|
+
* ledger `review.cap` row lists the open ids — the human decides (`STOP_EXITS`,
|
|
34
|
+
* `stop-exits.mjs`): `block reopen` after a fix, `block waive` per open finding,
|
|
35
|
+
* `review-file --answers` for a fix in another owned file, or re-decompose.
|
|
32
36
|
* 6. Ledger per round: `review.round {file, round, level, kind, open_before, closed,
|
|
33
37
|
* new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
|
|
34
38
|
* `review.approved {file, content_hash, round}` (the worker signs every row it writes).
|
|
@@ -497,17 +501,20 @@ async function recheckSession(packet, deps) {
|
|
|
497
501
|
/**
|
|
498
502
|
* After a counted round with open findings: cap first (rule 6), else stall / ladder (2b, 2).
|
|
499
503
|
* @param {FileState} state @param {number} openBefore @param {ReturnType<typeof settings>} s
|
|
500
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check`
|
|
501
|
-
*
|
|
504
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` is judged by `closedIds`
|
|
505
|
+
* (B73), whatever the round number.
|
|
506
|
+
* @param {number} closedIds - how many ids open before the round are not open after it.
|
|
502
507
|
* @returns {NextStep}
|
|
503
508
|
*/
|
|
504
|
-
function decideNext(state, openBefore, s, kind) {
|
|
509
|
+
function decideNext(state, openBefore, s, kind, closedIds) {
|
|
505
510
|
if (state.open.length === 0) return { action: 'complete' };
|
|
506
511
|
if (kind === 'patch_check') {
|
|
507
|
-
// the block's one rung is spent: a patch that
|
|
508
|
-
// the rung was taken at the cap — then the cap; else
|
|
509
|
-
|
|
512
|
+
// the block's one rung is spent: a patch that closed no old finding stops FIRST — even when
|
|
513
|
+
// the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
|
|
514
|
+
// whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
|
|
515
|
+
if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
|
|
510
516
|
if (state.round >= s.maxRounds) return { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
|
|
517
|
+
return { action: 'fix', level: state.level, trigger: null };
|
|
511
518
|
}
|
|
512
519
|
if (state.round >= s.maxRounds) {
|
|
513
520
|
const esc = escalationAfterAttempt({ currentLevel: state.level, roundsAtFile: state.round, maxRoundsPerFile: s.maxRounds, openFixNowFinding: true, l3RungAlreadyUsed: state.l3_rung_used });
|
|
@@ -515,7 +522,7 @@ function decideNext(state, openBefore, s, kind) {
|
|
|
515
522
|
}
|
|
516
523
|
const esc = escalationAfterAttempt({
|
|
517
524
|
currentLevel: state.level,
|
|
518
|
-
reviewStall:
|
|
525
|
+
reviewStall: state.round >= 2 && reviewRoundStalled(openBefore, state.open.length),
|
|
519
526
|
openFixNowFinding: true,
|
|
520
527
|
reviewRoundsAtLevel: state.rounds_at_level,
|
|
521
528
|
reviewRoundsPerLevel: s.perLevel,
|
|
@@ -740,9 +747,12 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
740
747
|
draft.status = 'complete';
|
|
741
748
|
draft.next = { action: 'complete' };
|
|
742
749
|
} else {
|
|
743
|
-
// after a patch_check the block continues at L2 (§3.6): the rung is spent, so a
|
|
744
|
-
// stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a
|
|
745
|
-
|
|
750
|
+
// after a patch_check the block continues at L2 (§3.6): the rung is spent, so a patch that
|
|
751
|
+
// closed no old id stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a patch that
|
|
752
|
+
// closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
|
|
753
|
+
const after = new Set(draft.open.map((f) => f.id));
|
|
754
|
+
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
755
|
+
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
746
756
|
if (draft.next.action === 'stop') draft.status = 'stopped';
|
|
747
757
|
}
|
|
748
758
|
const rows = [...lateRows, roundRow];
|