@codedology/code-forge 0.1.0 → 0.2.1
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 +43 -0
- package/README.md +29 -6
- package/docs/README.md +4 -1
- package/docs/reference/blocks.json +13 -2
- package/package.json +6 -2
- package/skill/references/review.md +4 -0
- package/skill/scripts/forge +1 -1
- package/src/cli/review.mjs +610 -0
- package/src/install/wizard/answers.mjs +102 -46
- package/src/install/wizard/run.mjs +37 -7
- package/src/install/wizard/steps.mjs +134 -34
- package/src/install/wizard/summary.mjs +168 -0
- package/src/worker/engine.mjs +50 -8
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge review` (block B22) — review-only: the review engine on changes you already have,
|
|
3
|
+
* with no plan, no coder, no proof and no block close.
|
|
4
|
+
*
|
|
5
|
+
* review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"]
|
|
6
|
+
* [--run <id>] [--max <seconds>] [--json] [--keep-run]
|
|
7
|
+
*
|
|
8
|
+
* 1. Base: `git merge-base HEAD <default>`, where `<default>` is `origin/HEAD`'s branch, else
|
|
9
|
+
* `main`, else `master`; when that is HEAD itself (you are on the default branch), the base is
|
|
10
|
+
* HEAD and the uncommitted changes are reviewed. `--base <ref>` overrides. Resolved to a sha.
|
|
11
|
+
* 2. Files: `git diff --name-only <base>` (committed, staged and unstaged changes against the
|
|
12
|
+
* working tree) plus untracked files git does not ignore. Deleted, binary, symlinked and
|
|
13
|
+
* secret-like files are skipped, and so is code-forge's own state (`.code-forge/`,
|
|
14
|
+
* `.code-forge.yml`). `--files` replaces the list; each path is checked before anything starts.
|
|
15
|
+
* 3. Acceptance: `--acceptance <file>` as given; `--intent` becomes one clause; neither ⇒ one
|
|
16
|
+
* default clause. A generated file lives in this command's temp root, never in the tree.
|
|
17
|
+
* 4. Flow, every step a `code-forge` child with a `GIT_*`-free env: `run start` (launches the
|
|
18
|
+
* worker) → `block open R-<stamp> --level L2 --base <sha> --owned <files…>` → `review-file`
|
|
19
|
+
* per file → `review-file --wait` per ticket (`--max`, default 900 s per file) → summary →
|
|
20
|
+
* `block stop` (a review never closes a block) → `run end` unless `--keep-run`. The cleanup runs
|
|
21
|
+
* in a `finally`, on SIGINT/SIGTERM and after a timeout, and no worker outlives the command.
|
|
22
|
+
* Each cleanup step is independent (a failed or throwing step is reported on stderr and the
|
|
23
|
+
* next one still runs), and a cleanup error never replaces the review's own exit code.
|
|
24
|
+
* `--run <id>` names the run: an active run with that id is reused (and never ended here);
|
|
25
|
+
* otherwise a new run starts with that id.
|
|
26
|
+
* Exit codes: 0 every file approved (or nothing to review); 1 any file with findings, stopped,
|
|
27
|
+
* unavailable or timed out, or the flow failed; 2 usage, not a git repository, a bad base, a bad
|
|
28
|
+
* path, or no `.code-forge.yml`.
|
|
29
|
+
*
|
|
30
|
+
* `--files` paths go through `review-file`'s own path rule (`normalizeRequestPath`) before anything
|
|
31
|
+
* starts, and `review-file` re-checks each one when it enqueues it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { randomBytes } from 'node:crypto';
|
|
35
|
+
import { closeSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { parse as parseYAML } from 'yaml';
|
|
39
|
+
import { loadProjectConfig } from '../config/load.mjs';
|
|
40
|
+
import { computeFileSet } from '../gates/scope.mjs';
|
|
41
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
42
|
+
import { assertRunId, newRunId, StateError } from '../state/paths.mjs';
|
|
43
|
+
import { processStartTime, readRun } from '../state/run.mjs';
|
|
44
|
+
import { exec } from '../util/exec.mjs';
|
|
45
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
46
|
+
import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
47
|
+
import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
|
|
48
|
+
import { readWorker } from '../worker/queue.mjs';
|
|
49
|
+
import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
50
|
+
|
|
51
|
+
const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
|
|
52
|
+
|
|
53
|
+
export const USAGE =
|
|
54
|
+
'usage: code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]\n';
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_MAX_S = 900;
|
|
57
|
+
export const DEFAULT_CLAUSE = 'Review for correctness, security and test quality; no intent was stated.';
|
|
58
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
59
|
+
const STEP_TIMEOUT_MS = 120_000;
|
|
60
|
+
|
|
61
|
+
/** Never sent to a reviewer (the packet refuses them too): env files, keys, certificates, credentials. */
|
|
62
|
+
const SECRET_LIKE = [/(^|\/)\.env[^/]*$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i, /(^|\/)credentials[^/]*$/i];
|
|
63
|
+
|
|
64
|
+
/** A refusal that exits 2 (usage, not a repo, bad base, bad path, no config). */
|
|
65
|
+
class UsageError extends Error {}
|
|
66
|
+
/** The run was interrupted (signal). */
|
|
67
|
+
class Interrupted extends Error {}
|
|
68
|
+
|
|
69
|
+
/** `process.env` minus every inherited `GIT_*` (a hook's `GIT_DIR` must never redirect a child). */
|
|
70
|
+
export function scrubbedEnv() {
|
|
71
|
+
/** @type {Record<string, string>} */
|
|
72
|
+
const env = {};
|
|
73
|
+
for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_') && value !== undefined) env[key] = value;
|
|
74
|
+
return env;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string[]} args @param {string} cwd @param {number[]} [ok]
|
|
79
|
+
* @returns {Promise<import('../util/exec.mjs').ExecResult>}
|
|
80
|
+
*/
|
|
81
|
+
const git = (args, cwd, ok) => exec(['git', ...args], { cwd, env: gitChildEnv(), timeoutMs: GIT_TIMEOUT_MS, ...(ok ? { okExitCodes: ok } : {}) });
|
|
82
|
+
|
|
83
|
+
/** @param {string} ref @param {string} cwd @returns {Promise<string | null>} the commit sha, or null. */
|
|
84
|
+
async function commitOf(ref, cwd) {
|
|
85
|
+
if (ref.length === 0 || ref.startsWith('-')) return null;
|
|
86
|
+
const res = await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
|
|
87
|
+
const sha = res.stdout.trim();
|
|
88
|
+
return res.result === 'ok' && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The base sha and how it was chosen.
|
|
93
|
+
* @param {string} repoRoot @param {string | undefined} given
|
|
94
|
+
* @returns {Promise<{sha: string, from: string}>}
|
|
95
|
+
* @throws {UsageError}
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveBase(repoRoot, given) {
|
|
98
|
+
if (given !== undefined) {
|
|
99
|
+
const sha = await commitOf(given, repoRoot);
|
|
100
|
+
if (sha === null) throw new UsageError(`--base ${JSON.stringify(given)} is not a commit of this repository`);
|
|
101
|
+
return { sha, from: given };
|
|
102
|
+
}
|
|
103
|
+
const head = await commitOf('HEAD', repoRoot);
|
|
104
|
+
if (head === null) throw new UsageError('the repository has no commit yet — commit once, or pass --base');
|
|
105
|
+
const candidates = [];
|
|
106
|
+
const originHead = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], repoRoot, [0, 1]);
|
|
107
|
+
if (originHead.result === 'ok' && originHead.code === 0 && originHead.stdout.trim().length > 0) candidates.push(originHead.stdout.trim());
|
|
108
|
+
candidates.push('main', 'master');
|
|
109
|
+
for (const name of candidates) {
|
|
110
|
+
const tip = await commitOf(name, repoRoot);
|
|
111
|
+
if (tip === null) continue;
|
|
112
|
+
const mb = await git(['merge-base', 'HEAD', tip], repoRoot);
|
|
113
|
+
const sha = mb.stdout.trim();
|
|
114
|
+
if (mb.result !== 'ok' || sha.length === 0) throw new UsageError(`HEAD and ${name} share no history — pass --base <ref>`);
|
|
115
|
+
return sha === head ? { sha: head, from: 'HEAD (uncommitted changes)' } : { sha, from: `merge-base HEAD ${name}` };
|
|
116
|
+
}
|
|
117
|
+
return { sha: head, from: 'HEAD (uncommitted changes; no main, master or origin/HEAD)' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A `--files` path through `review-file`'s own rule (`normalizeRequestPath`: relative to `cwd`, no
|
|
122
|
+
* `..` segment, not absolute, inside the repository after resolving the directory's real path,
|
|
123
|
+
* never under `.git/` or `.code-forge/`, never a symlink).
|
|
124
|
+
* @param {string} raw @param {string} cwd @param {string} repoRoot - realpath'd
|
|
125
|
+
* @returns {string} the repo-root-relative POSIX path
|
|
126
|
+
* @throws {UsageError}
|
|
127
|
+
*/
|
|
128
|
+
export function checkRequestPath(raw, cwd, repoRoot) {
|
|
129
|
+
try {
|
|
130
|
+
return normalizeRequestPath(raw, cwd, repoRoot);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof WorkerError) throw new UsageError(`--files ${JSON.stringify(raw)}: ${err.message}`);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Why a candidate file is not sent for review, or null when it is.
|
|
139
|
+
* @param {string} repoRoot @param {string} rel @param {{discovered: boolean}} opts
|
|
140
|
+
* @returns {string | null}
|
|
141
|
+
*/
|
|
142
|
+
export function skipReason(repoRoot, rel, { discovered }) {
|
|
143
|
+
const top = rel.split('/')[0].toLowerCase();
|
|
144
|
+
if (top === '.code-forge' || top === '.git') return 'code-forge state';
|
|
145
|
+
if (discovered && rel === '.code-forge.yml') return 'code-forge config';
|
|
146
|
+
if (rel.startsWith('-') || rel.includes('\\')) return 'unsupported file name';
|
|
147
|
+
if (SECRET_LIKE.some((re) => re.test(rel))) return 'secret-like name, never sent to a reviewer';
|
|
148
|
+
let st;
|
|
149
|
+
try {
|
|
150
|
+
st = lstatSync(path.join(repoRoot, rel));
|
|
151
|
+
} catch {
|
|
152
|
+
return 'deleted';
|
|
153
|
+
}
|
|
154
|
+
if (st.isSymbolicLink()) return 'symlink';
|
|
155
|
+
if (!st.isFile()) return 'not a regular file';
|
|
156
|
+
// git's own heuristic: a NUL byte in the first 8000 bytes means binary
|
|
157
|
+
let fd;
|
|
158
|
+
try {
|
|
159
|
+
fd = openSync(path.join(repoRoot, rel), 'r');
|
|
160
|
+
const head = Buffer.alloc(8000);
|
|
161
|
+
const n = readSync(fd, head, 0, head.length, 0);
|
|
162
|
+
if (head.subarray(0, n).includes(0)) return 'binary';
|
|
163
|
+
} catch {
|
|
164
|
+
return 'unreadable';
|
|
165
|
+
} finally {
|
|
166
|
+
if (fd !== undefined) closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The acceptance clauses from a file the user gave.
|
|
173
|
+
* @param {string} file @returns {Array<{clause: string, tests: string[]}>}
|
|
174
|
+
* @throws {UsageError}
|
|
175
|
+
*/
|
|
176
|
+
function readAcceptance(file) {
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = parseYAML(readFileSync(file, 'utf8'), { prettyErrors: false });
|
|
180
|
+
} catch {
|
|
181
|
+
throw new UsageError(`--acceptance ${file}: not a readable YAML or JSON file`);
|
|
182
|
+
}
|
|
183
|
+
const ok =
|
|
184
|
+
Array.isArray(parsed) &&
|
|
185
|
+
parsed.length > 0 &&
|
|
186
|
+
parsed.every((c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((/** @type {unknown} */ t) => typeof t === 'string' && t.length > 0));
|
|
187
|
+
if (!ok) throw new UsageError(`--acceptance ${file}: must be a non-empty list of {clause, tests: [test ids…]}`);
|
|
188
|
+
return parsed;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string} text @returns {any} the last line of `text` as JSON, or null. */
|
|
192
|
+
function lastJSON(text) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(text.trim().split('\n').at(-1) ?? '');
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @typedef {{file: string, result: 'approved' | 'unchanged' | 'findings' | 'stopped' | 'unavailable', reason: string | null,
|
|
202
|
+
* findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}>}} FileOutcome
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One file's outcome from its `review-file --wait` answer (or null when there was none).
|
|
207
|
+
* @param {string} file @param {Record<string, any> | null} waited
|
|
208
|
+
* @returns {FileOutcome}
|
|
209
|
+
*/
|
|
210
|
+
export function classify(file, waited) {
|
|
211
|
+
const findings = Array.isArray(waited?.fix_list) ? waited.fix_list : [];
|
|
212
|
+
const out = (/** @type {FileOutcome['result']} */ result, /** @type {string | null} */ reason) => ({ file, result, reason, findings });
|
|
213
|
+
if (!waited) return out('unavailable', 'no answer');
|
|
214
|
+
if (waited.status === 'pending') return out('stopped', 'timeout');
|
|
215
|
+
if (waited.status !== 'done') return out('unavailable', String(waited.reason ?? waited.status ?? 'no answer'));
|
|
216
|
+
const r = waited.result;
|
|
217
|
+
if (!r || typeof r !== 'object') return out('unavailable', 'unreadable result');
|
|
218
|
+
if (r.approved === true) return out('approved', null);
|
|
219
|
+
if (r.status === 'no_change') return out('unchanged', null);
|
|
220
|
+
if (r.status === 'stopped') return out('stopped', String(r.stopped ?? 'stopped'));
|
|
221
|
+
if (r.status === 'split_required') return out('stopped', 'split_required');
|
|
222
|
+
if (r.status === 'reviewed' && findings.length > 0) return out('findings', null);
|
|
223
|
+
return out('unavailable', String(r.reason ?? r.status ?? 'not approved'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {FileOutcome} o @returns {string} */
|
|
227
|
+
function label(o) {
|
|
228
|
+
if (o.result === 'findings') return `${o.findings.length} finding${o.findings.length === 1 ? '' : 's'}`;
|
|
229
|
+
if (o.result === 'stopped' || o.result === 'unavailable') return `${o.result}: ${o.reason}`;
|
|
230
|
+
return o.result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {FileOutcome[]} outcomes */
|
|
234
|
+
export function totals(outcomes) {
|
|
235
|
+
const count = (/** @type {string} */ r) => outcomes.filter((o) => o.result === r).length;
|
|
236
|
+
return {
|
|
237
|
+
files: outcomes.length,
|
|
238
|
+
approved: count('approved'),
|
|
239
|
+
unchanged: count('unchanged'),
|
|
240
|
+
with_findings: count('findings'),
|
|
241
|
+
findings: outcomes.reduce((n, o) => n + (o.result === 'findings' ? o.findings.length : 0), 0),
|
|
242
|
+
stopped: count('stopped'),
|
|
243
|
+
unavailable: count('unavailable'),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @param {FileOutcome[]} outcomes @returns {string} the human summary. */
|
|
248
|
+
export function renderText(outcomes) {
|
|
249
|
+
const width = Math.max(...outcomes.map((o) => o.file.length));
|
|
250
|
+
const lines = outcomes.map((o) => `${o.file.padEnd(width)} ${label(o)}`);
|
|
251
|
+
for (const o of outcomes) {
|
|
252
|
+
if (o.result === 'approved' || o.result === 'unchanged' || o.findings.length === 0) continue;
|
|
253
|
+
lines.push('', `fix list: ${o.file}`);
|
|
254
|
+
for (const f of o.findings) lines.push(` ${f.id} · ${f.severity} · lines ${f.lines}`, ` claim: ${f.claim}`, ` fix: ${f.fix}`);
|
|
255
|
+
}
|
|
256
|
+
const t = totals(outcomes);
|
|
257
|
+
lines.push(
|
|
258
|
+
'',
|
|
259
|
+
`totals: ${t.files} file${t.files === 1 ? '' : 's'} · ${t.approved} approved · ${t.with_findings} with findings (${t.findings} finding${t.findings === 1 ? '' : 's'}) · ${t.stopped} stopped · ${t.unavailable} unavailable${t.unchanged > 0 ? ` · ${t.unchanged} unchanged` : ''}`,
|
|
260
|
+
);
|
|
261
|
+
return `${lines.join('\n')}\n`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The worker pids this run left, each with the start time that proves it is still the same
|
|
266
|
+
* process: the pin in the run record and the queue's announcement for this run.
|
|
267
|
+
* @param {string} repoRoot @param {string} runId
|
|
268
|
+
* @returns {Promise<Array<{pid: number, same: () => Promise<boolean>}>>}
|
|
269
|
+
*/
|
|
270
|
+
async function runWorkers(repoRoot, runId) {
|
|
271
|
+
const found = [];
|
|
272
|
+
try {
|
|
273
|
+
const pin = (await readRun(runId)).worker;
|
|
274
|
+
if (Number.isInteger(pin?.pid) && pin.pid > 1) found.push({ pid: pin.pid, same: async () => (await processStartTime(pin.pid)) === pin.started_at });
|
|
275
|
+
} catch {
|
|
276
|
+
// no record: nothing pinned
|
|
277
|
+
}
|
|
278
|
+
const w = readWorker(repoRoot);
|
|
279
|
+
if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
|
|
280
|
+
found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
|
|
281
|
+
}
|
|
282
|
+
return found;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* After `run end` (which sends SIGTERM and returns): wait up to 5 s for this run's worker to be
|
|
287
|
+
* gone, then SIGKILL its process group. Also catches a worker a `run start` launched but never
|
|
288
|
+
* pinned because it was interrupted.
|
|
289
|
+
* @param {string} repoRoot @param {string} runId @returns {Promise<number>} workers killed here
|
|
290
|
+
*/
|
|
291
|
+
async function reapWorkers(repoRoot, runId) {
|
|
292
|
+
let killed = 0;
|
|
293
|
+
for (const w of await runWorkers(repoRoot, runId)) {
|
|
294
|
+
if (!isAlive(w.pid) || !(await w.same())) continue;
|
|
295
|
+
try {
|
|
296
|
+
process.kill(w.pid, 'SIGTERM');
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const until = Date.now() + 5000;
|
|
301
|
+
while (isAlive(w.pid) && Date.now() < until) await new Promise((r) => setTimeout(r, 100));
|
|
302
|
+
if (isAlive(w.pid) && (await w.same())) {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-w.pid, 'SIGKILL');
|
|
305
|
+
} catch {
|
|
306
|
+
try {
|
|
307
|
+
process.kill(w.pid, 'SIGKILL');
|
|
308
|
+
} catch {
|
|
309
|
+
// gone
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
killed += 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return killed;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {unknown} err @returns {string} */
|
|
319
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {string[]} args
|
|
323
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, exec?: typeof exec}} [deps] -
|
|
324
|
+
* `exec` runs every `code-forge` child of this command (the flow steps, the cleanup and the
|
|
325
|
+
* second-signal exit); a test seam, the real `exec` otherwise.
|
|
326
|
+
* @returns {Promise<number>}
|
|
327
|
+
*/
|
|
328
|
+
export async function runReview(args, deps = {}) {
|
|
329
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
330
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
331
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
332
|
+
const note = (/** @type {string} */ s) => writeSafe(stderr, `review: ${s}\n`);
|
|
333
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
334
|
+
const execChild = deps.exec ?? exec;
|
|
335
|
+
|
|
336
|
+
/** @type {Record<string, any>} */
|
|
337
|
+
let flags;
|
|
338
|
+
try {
|
|
339
|
+
({ flags } = parseReviewArgs(args));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
writeSafe(stderr, `review: ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
|
|
342
|
+
return 2;
|
|
343
|
+
}
|
|
344
|
+
if (flags.help === true) {
|
|
345
|
+
out(USAGE);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
const json = flags.json === true;
|
|
349
|
+
const maxS = flags.max === undefined ? DEFAULT_MAX_S : Number(String(flags.max).replace(/s$/, ''));
|
|
350
|
+
|
|
351
|
+
// ---- everything below until `run start` only reads: a refusal here exits 2 with no child started
|
|
352
|
+
let repoRoot;
|
|
353
|
+
let base;
|
|
354
|
+
/** @type {string[]} */
|
|
355
|
+
let files = [];
|
|
356
|
+
/** @type {Array<{file: string, reason: string}>} */
|
|
357
|
+
const skipped = [];
|
|
358
|
+
let cfg;
|
|
359
|
+
let acceptanceFile = null;
|
|
360
|
+
try {
|
|
361
|
+
repoRoot = await repoRootOf(cwd).catch(() => {
|
|
362
|
+
throw new UsageError('not inside a git repository');
|
|
363
|
+
});
|
|
364
|
+
base = await resolveBase(repoRoot, typeof flags.base === 'string' ? flags.base : undefined);
|
|
365
|
+
|
|
366
|
+
const given = Array.isArray(flags.files) ? flags.files.map((raw) => checkRequestPath(raw, cwd, repoRoot)) : null;
|
|
367
|
+
const candidates = given ? [...new Set(given)] : (await computeFileSet({ cwd: repoRoot, base: base.sha })).all;
|
|
368
|
+
for (const file of candidates) {
|
|
369
|
+
const why = skipReason(repoRoot, file, { discovered: given === null });
|
|
370
|
+
if (why === null) files.push(file);
|
|
371
|
+
else skipped.push({ file, reason: why });
|
|
372
|
+
}
|
|
373
|
+
files = files.sort();
|
|
374
|
+
|
|
375
|
+
if (files.length > 0) {
|
|
376
|
+
const loaded = await loadProjectConfig(repoRoot);
|
|
377
|
+
if (!loaded.ok) throw new UsageError(loaded.error === 'not-found' ? 'no .code-forge.yml at the repository root — run `code-forge init` first' : `.code-forge.yml does not load (${loaded.error}) — run \`code-forge validate\``);
|
|
378
|
+
cfg = loaded.config;
|
|
379
|
+
if (typeof flags.acceptance === 'string') {
|
|
380
|
+
acceptanceFile = path.resolve(cwd, flags.acceptance);
|
|
381
|
+
readAcceptance(acceptanceFile);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (!(err instanceof UsageError)) throw err;
|
|
386
|
+
note(err.message);
|
|
387
|
+
return 2;
|
|
388
|
+
}
|
|
389
|
+
for (const s of skipped) note(`skipped ${s.file} (${s.reason})`);
|
|
390
|
+
|
|
391
|
+
if (files.length === 0) {
|
|
392
|
+
if (json) out(`${JSON.stringify({ ok: true, base: base.sha, base_from: base.from, run: null, block: null, files: [], skipped, totals: totals([]), nothing_to_review: true })}\n`);
|
|
393
|
+
else out('nothing to review\n');
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---- the run
|
|
398
|
+
const runId = typeof flags.run === 'string' ? flags.run : newRunId();
|
|
399
|
+
let reuse = false;
|
|
400
|
+
if (typeof flags.run === 'string') {
|
|
401
|
+
try {
|
|
402
|
+
const record = await readRun(runId);
|
|
403
|
+
if (record.status !== 'active') {
|
|
404
|
+
note(`run ${runId} has ended — give another --run or none`);
|
|
405
|
+
return 2;
|
|
406
|
+
}
|
|
407
|
+
reuse = true;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (/** @type {any} */ (err)?.code !== 'no-run') throw err;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const blockId = `R-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}-${randomBytes(2).toString('hex')}`;
|
|
413
|
+
const tmpRootCfg = typeof cfg?.tmp?.root === 'string' ? path.resolve(repoRoot, cfg.tmp.root) : undefined;
|
|
414
|
+
const tmp = runRoot(`review-${runId}`, { root: tmpBase(tmpRootCfg) });
|
|
415
|
+
setRunRoot(tmp);
|
|
416
|
+
|
|
417
|
+
let started = false;
|
|
418
|
+
let blockOpen = false;
|
|
419
|
+
const keepRun = flags['keep-run'] === true;
|
|
420
|
+
|
|
421
|
+
// Signals. The FIRST SIGINT/SIGTERM kills the in-flight `code-forge` child (its process group,
|
|
422
|
+
// found in this command's pid registry, start time re-checked) and rejects the step's promise at
|
|
423
|
+
// once, so control reaches the `finally` cleanup without waiting for a `review-file --wait` to
|
|
424
|
+
// run out its `--max`. Once the cleanup has started (`inCleanup`), a first signal only counts:
|
|
425
|
+
// it kills nothing and aborts nothing, so the block stop and `run end` it would otherwise cut
|
|
426
|
+
// short complete. A SECOND signal stops waiting for that cleanup: a best-effort block stop,
|
|
427
|
+
// `run end` and worker reap (10 s each at most), then a hard exit.
|
|
428
|
+
const abort = new AbortController();
|
|
429
|
+
/** @type {Promise<never>} */
|
|
430
|
+
const aborted = new Promise((_, reject) => {
|
|
431
|
+
abort.signal.addEventListener('abort', () => reject(new Interrupted(String(abort.signal.reason))), { once: true });
|
|
432
|
+
});
|
|
433
|
+
aborted.catch(() => {});
|
|
434
|
+
let signals = 0;
|
|
435
|
+
let inCleanup = false;
|
|
436
|
+
const killInFlight = () => {
|
|
437
|
+
for (const entry of listEntries(pidsDir(tmp))) {
|
|
438
|
+
if (entry.start_time !== UNKNOWN_START_TIME && readStartTime(entry.pid) !== entry.start_time) continue;
|
|
439
|
+
try {
|
|
440
|
+
process.kill(-entry.pid, 'SIGTERM');
|
|
441
|
+
} catch {
|
|
442
|
+
try {
|
|
443
|
+
process.kill(entry.pid, 'SIGTERM');
|
|
444
|
+
} catch {
|
|
445
|
+
// already gone
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const hardExit = async (/** @type {NodeJS.Signals} */ signal) => {
|
|
451
|
+
note(`second ${signal}: ending the run and exiting now`);
|
|
452
|
+
const quick = (/** @type {string[]} */ a) => Promise.resolve().then(() => execChild([process.execPath, BIN, ...a], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs: 10_000 })).catch(() => null);
|
|
453
|
+
if (blockOpen) await quick(['block', 'stop', blockId, '--run', runId, '--reason', 'review only: interrupted']);
|
|
454
|
+
if (started && !keepRun) {
|
|
455
|
+
await quick(['run', 'end', '--run', runId]);
|
|
456
|
+
await reapWorkers(repoRoot, runId).catch(() => 0);
|
|
457
|
+
}
|
|
458
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
459
|
+
process.exit(1);
|
|
460
|
+
};
|
|
461
|
+
const onSignal = (/** @type {NodeJS.Signals} */ signal) => {
|
|
462
|
+
signals += 1;
|
|
463
|
+
if (signals === 1) {
|
|
464
|
+
if (inCleanup) {
|
|
465
|
+
note(`${signal} during cleanup: finishing it (a second ${signal} exits at once)`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
killInFlight();
|
|
469
|
+
abort.abort(signal);
|
|
470
|
+
} else if (signals === 2) {
|
|
471
|
+
void hardExit(signal);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
process.on('SIGINT', onSignal);
|
|
475
|
+
process.on('SIGTERM', onSignal);
|
|
476
|
+
|
|
477
|
+
/** @param {string[]} cfArgs @param {number} [timeoutMs] */
|
|
478
|
+
const cf = async (cfArgs, timeoutMs = STEP_TIMEOUT_MS) => {
|
|
479
|
+
if (abort.signal.aborted) throw new Interrupted(String(abort.signal.reason));
|
|
480
|
+
const res = await Promise.race([execChild([process.execPath, BIN, ...cfArgs], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs }), aborted]);
|
|
481
|
+
if (abort.signal.aborted) throw new Interrupted(String(abort.signal.reason));
|
|
482
|
+
return res;
|
|
483
|
+
};
|
|
484
|
+
/** @param {import('../util/exec.mjs').ExecResult} res @param {string} what */
|
|
485
|
+
const must = (res, what) => {
|
|
486
|
+
if (res.code !== 0) throw new Error(`${what} exited ${res.code ?? res.signal}: ${(res.stderr || res.stdout).trim().split('\n').at(-1) ?? ''}`);
|
|
487
|
+
return res;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
let code = 1;
|
|
491
|
+
/** @type {FileOutcome[]} */
|
|
492
|
+
const outcomes = [];
|
|
493
|
+
try {
|
|
494
|
+
if (!acceptanceFile) {
|
|
495
|
+
const clause = typeof flags.intent === 'string' ? flags.intent : DEFAULT_CLAUSE;
|
|
496
|
+
acceptanceFile = path.join(tmp, 'acceptance.json');
|
|
497
|
+
writeFileSync(acceptanceFile, JSON.stringify([{ clause, tests: ['review-only'] }]), { mode: 0o600 });
|
|
498
|
+
}
|
|
499
|
+
note(`base ${base.sha.slice(0, 12)} (${base.from}) · ${files.length} file${files.length === 1 ? '' : 's'} · run ${runId}${reuse ? ' (reused)' : ''}`);
|
|
500
|
+
if (!reuse) {
|
|
501
|
+
started = true;
|
|
502
|
+
must(await cf(['run', 'start', '--cwd', repoRoot, '--run', runId]), 'run start');
|
|
503
|
+
}
|
|
504
|
+
blockOpen = true; // before the call: an interrupted `block open` may still have opened it (a stop of an unopened block only fails)
|
|
505
|
+
must(await cf(['block', 'open', blockId, '--run', runId, '--level', 'L2', '--base', base.sha, '--acceptance', acceptanceFile, '--owned', ...files]), 'block open');
|
|
506
|
+
|
|
507
|
+
/** @type {Array<{file: string, ticket: string | null, answer: any}>} */
|
|
508
|
+
const queued = [];
|
|
509
|
+
for (const file of files) {
|
|
510
|
+
const res = await cf(['review-file', file, '--block', blockId, '--run', runId]);
|
|
511
|
+
const answer = lastJSON(res.stdout);
|
|
512
|
+
queued.push({ file, ticket: typeof answer?.ticket === 'string' ? answer.ticket : null, answer });
|
|
513
|
+
}
|
|
514
|
+
for (const q of queued) {
|
|
515
|
+
if (q.ticket === null) {
|
|
516
|
+
outcomes.push(classify(q.file, { status: q.answer?.status ?? 'refused', reason: q.answer?.reason ?? q.answer?.status ?? 'not queued' }));
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const res = await cf(['review-file', '--wait', q.ticket, '--max', `${maxS}s`], maxS * 1000 + 60_000);
|
|
520
|
+
outcomes.push(classify(q.file, lastJSON(res.stdout)));
|
|
521
|
+
}
|
|
522
|
+
const allOk = outcomes.every((o) => o.result === 'approved' || o.result === 'unchanged');
|
|
523
|
+
code = allOk ? 0 : 1;
|
|
524
|
+
if (json) out(`${JSON.stringify({ ok: allOk, base: base.sha, base_from: base.from, run: runId, block: blockId, files: outcomes, skipped, totals: totals(outcomes) })}\n`);
|
|
525
|
+
else out(renderText(outcomes));
|
|
526
|
+
} catch (err) {
|
|
527
|
+
note(err instanceof Interrupted ? `interrupted by ${err.message}; stopping the review` : `failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
528
|
+
code = 1;
|
|
529
|
+
} finally {
|
|
530
|
+
// Every step below is independent: a step that fails or throws is reported and the next one
|
|
531
|
+
// still runs; the nested `finally` always detaches the handlers and removes the temp root. The
|
|
532
|
+
// exit code is the review's own whatever the cleanup does.
|
|
533
|
+
inCleanup = true;
|
|
534
|
+
try {
|
|
535
|
+
/**
|
|
536
|
+
* One cleanup child. `exec` forwards every signal it receives to its live children, so a
|
|
537
|
+
* first signal during the cleanup still cuts the running step short; a step that died of a
|
|
538
|
+
* signal (not of its timeout) is run once more, so the block is stopped and the run ends.
|
|
539
|
+
* @param {string[]} a
|
|
540
|
+
*/
|
|
541
|
+
const cleanup = async (a) => {
|
|
542
|
+
const run = () => execChild([process.execPath, BIN, ...a], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs: 60_000 });
|
|
543
|
+
const res = await run();
|
|
544
|
+
return res.code === null && res.signal !== null && !res.timedOut && signals > 0 ? run() : res;
|
|
545
|
+
};
|
|
546
|
+
if (blockOpen) {
|
|
547
|
+
try {
|
|
548
|
+
const stopped = await cleanup(['block', 'stop', blockId, '--run', runId, '--reason', 'review only: never closed, no proof']);
|
|
549
|
+
if (stopped.code !== 0) note(`block stop exited ${stopped.code ?? stopped.signal}`);
|
|
550
|
+
} catch (err) {
|
|
551
|
+
note(`block stop failed: ${messageOf(err)}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (started && !keepRun) {
|
|
555
|
+
try {
|
|
556
|
+
const ended = await cleanup(['run', 'end', '--run', runId]);
|
|
557
|
+
if (ended.code !== 0) note(`run end exited ${ended.code ?? ended.signal}: ${ended.stderr.trim()}`);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
note(`run end failed: ${messageOf(err)}`);
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
await reapWorkers(repoRoot, runId);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
note(`worker reap failed: ${messageOf(err)}`);
|
|
565
|
+
}
|
|
566
|
+
} else if (started) {
|
|
567
|
+
note(`run ${runId} kept (--keep-run): reuse it with --run ${runId}; end it with \`code-forge run end --run ${runId}\``);
|
|
568
|
+
}
|
|
569
|
+
} finally {
|
|
570
|
+
process.off('SIGINT', onSignal);
|
|
571
|
+
process.off('SIGTERM', onSignal);
|
|
572
|
+
setRunRoot(null);
|
|
573
|
+
try {
|
|
574
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
note(`temp root not removed: ${messageOf(err)}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return code;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* @param {string[]} args
|
|
585
|
+
* @returns {{flags: Record<string, any>}}
|
|
586
|
+
* @throws {StateError} `usage`
|
|
587
|
+
*/
|
|
588
|
+
function parseReviewArgs(args) {
|
|
589
|
+
const { flags, positionals } = parseFlags(args, { values: ['base', 'acceptance', 'intent', 'run', 'max'], multi: ['files'], booleans: ['json', 'keep-run', 'help'] });
|
|
590
|
+
if (positionals.length > 0) throw new StateError('usage', `unexpected argument ${JSON.stringify(positionals[0])}`);
|
|
591
|
+
if (flags.acceptance !== undefined && flags.intent !== undefined) throw new StateError('usage', 'give --acceptance or --intent, not both');
|
|
592
|
+
if (typeof flags.intent === 'string' && flags.intent.trim().length === 0) throw new StateError('usage', '--intent needs text');
|
|
593
|
+
if (flags.max !== undefined && !/^[1-9]\d{0,5}s?$/.test(String(flags.max))) throw new StateError('usage', '--max must be whole seconds, like 900 or 900s');
|
|
594
|
+
if (typeof flags.run === 'string') assertRunIdUsage(flags.run);
|
|
595
|
+
return { flags };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** @param {string} id */
|
|
599
|
+
function assertRunIdUsage(id) {
|
|
600
|
+
try {
|
|
601
|
+
assertRunId(id);
|
|
602
|
+
} catch {
|
|
603
|
+
throw new StateError('usage', '--run must match ^[a-z0-9][a-z0-9-]{0,63}$');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
608
|
+
export default async function review(args) {
|
|
609
|
+
return runReview(args);
|
|
610
|
+
}
|