@codedology/code-forge 0.7.7 → 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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- 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 +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B71 (issue #9 b): an answers ticket — `review-file <answering file> --block <id> --answers <ids>
|
|
3
|
+
* --of <source>` — asks ONE fresh L2 reviewer session whether the answering file (`by_file`)
|
|
4
|
+
* resolves the open findings `<ids>` of the source file. The worker runs it through the engine
|
|
5
|
+
* seam (`reviewTicket` dispatches here on `kind: 'answers'`); `loop.mjs` has already checked the
|
|
6
|
+
* ticket's run, its tree and that the source still has the ticket's hash.
|
|
7
|
+
*
|
|
8
|
+
* Checks here (the ones that count; `review-file` only pre-checks the same rules, unverified):
|
|
9
|
+
* - both files are owned by the block (`answers-not-owned`) and they are two files
|
|
10
|
+
* (`answers-same-file`);
|
|
11
|
+
* - each file is read ONCE: the answering file's bytes must hash to the ticket's `by_hash` (else
|
|
12
|
+
* `stale`, `answering-file-changed`; absent or unreadable: `answers-file-missing`) and the
|
|
13
|
+
* source's to its `content_hash` (else `stale`, `source-changed`); the packet is built from
|
|
14
|
+
* those same bytes, so the row's `by_hash` is the hash of what the reviewer saw;
|
|
15
|
+
* - every id is open on the source's CURRENT hash by the signed rows and not waived or answered
|
|
16
|
+
* yet — `answerableFindings`, the close's own rules (`answers-not-open`).
|
|
17
|
+
* A refusal runs no session. The packet holds each finding (file, lines, severity, claim, evidence,
|
|
18
|
+
* fix — from the source's signed fix-loop state; only id and severity when the state has none),
|
|
19
|
+
* the source lines around it, and the answering file's diff against the block base (its full
|
|
20
|
+
* content when it is new). The reviewer answers per id `resolved` or `not_resolved` with a reason;
|
|
21
|
+
* a missing, repeated or broken verdict is `not_resolved`, and a session that fails (no result,
|
|
22
|
+
* not `ok`, a non-zero exit, a thrown spawn error: `session-error`) makes the ticket `unavailable`
|
|
23
|
+
* with no row. A packet that cannot be built (B79: a secret-like source or answering file too) is
|
|
24
|
+
* `packet-unreadable`; the whole packet stays within the `full_in` budget, and one whose fixed text
|
|
25
|
+
* alone is over it is refused `packet-too-large`. One that cannot be written to the work dir is
|
|
26
|
+
* `packet-write-failed`. All three are refusals (`unavailable`: never approval, no session, no row). For each resolved id the
|
|
27
|
+
* worker writes one signed `review.answered {block, file: source, content_hash: source hash,
|
|
28
|
+
* finding, by_file, by_hash, ticket}` row (`ctx.writeRow` stamps the block, the source and its
|
|
29
|
+
* hash). The ticket's result is `status: 'answers'`, never an approval: the gate never reads it
|
|
30
|
+
* as a verdict on the source (`neverReviewed`), the answering file still needs its own approval,
|
|
31
|
+
* and the source's fix-loop state is not touched.
|
|
32
|
+
*
|
|
33
|
+
* The worker's file and state readers come in through `io` (the worker passes its own
|
|
34
|
+
* `readRegularFileNoFollow`, `loadState` and `assertAnswers`), so this review-side module imports
|
|
35
|
+
* nothing from `src/worker/`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
39
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
42
|
+
import { exec } from '../util/exec.mjs';
|
|
43
|
+
import { redact } from '../util/redact.mjs';
|
|
44
|
+
import { ANSWERED_EVENT, ANSWERS_STATUS, answerableFindings, ticketAnswered } from './gate-check.mjs';
|
|
45
|
+
import { fixHunkDiff } from './fixloop.mjs';
|
|
46
|
+
import { assertPacketPath, budgetFor, isSecretLike, plainGitEnv, reviewGitEnv } from './packet.mjs';
|
|
47
|
+
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
48
|
+
|
|
49
|
+
/** The answer the reviewer gives: one verdict per finding id. */
|
|
50
|
+
export const ANSWERS_SCHEMA = Object.freeze({
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
verdicts: {
|
|
54
|
+
type: 'array',
|
|
55
|
+
items: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
id: { type: 'string' },
|
|
59
|
+
verdict: { type: 'string', enum: ['resolved', 'not_resolved'] },
|
|
60
|
+
reason: { type: 'string' },
|
|
61
|
+
},
|
|
62
|
+
required: ['id', 'verdict', 'reason'],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['verdicts'],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The system prompt of the answers session. */
|
|
72
|
+
export const ANSWERS_LENS =
|
|
73
|
+
'You are a code reviewer for code-forge, judging whether a change in one file resolves findings a review raised about another file. ' +
|
|
74
|
+
'You have no tools and no files; everything you may use is in the message. Answer once, with a JSON object that matches the schema.';
|
|
75
|
+
|
|
76
|
+
/** Lines of the source shown before and after each finding. */
|
|
77
|
+
const AROUND = 5;
|
|
78
|
+
|
|
79
|
+
/** A reason longer than this is cut (the reviewer's free text). */
|
|
80
|
+
const REASON_MAX = 500;
|
|
81
|
+
|
|
82
|
+
/** B79: what a part cut at the packet budget ends with. */
|
|
83
|
+
export const TRUNCATED_MARKER = '[... truncated at the packet budget]';
|
|
84
|
+
|
|
85
|
+
/** B79: what a cut part shows when its cap is too small for {@link TRUNCATED_MARKER}. */
|
|
86
|
+
export const SHORT_MARKER = '[cut]';
|
|
87
|
+
|
|
88
|
+
/** B79: the packet's fixed text (headings, labels, fences) alone is over the budget. */
|
|
89
|
+
class PacketTooLarge extends Error {}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {object} AnswersOutcome
|
|
93
|
+
* @property {string} status - `answers`, `stale` or `unavailable`.
|
|
94
|
+
* @property {boolean} approved - always false.
|
|
95
|
+
* @property {string} engine
|
|
96
|
+
* @property {string} [reason]
|
|
97
|
+
* @property {string} kind - `answers`.
|
|
98
|
+
* @property {Array<{id: string, severity: unknown, verdict: 'resolved' | 'not_resolved', reason: string}>} [findings]
|
|
99
|
+
* @property {Array<Record<string, any>>} sessions
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ticket fields an answers ticket carries (`worker/queue.mjs` `enqueueAnswers`).
|
|
104
|
+
* @typedef {object} AnswersTicket
|
|
105
|
+
* @property {string} ticket
|
|
106
|
+
* @property {string} block
|
|
107
|
+
* @property {string} file - the source.
|
|
108
|
+
* @property {string} content_hash - the source's hash.
|
|
109
|
+
* @property {string} [by_file]
|
|
110
|
+
* @property {string} [by_hash]
|
|
111
|
+
* @property {string[]} [answers]
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What the worker's review context gives an answers ticket.
|
|
116
|
+
* @typedef {object} AnswersContext
|
|
117
|
+
* @property {string} repoRoot
|
|
118
|
+
* @property {string | null} [contentRoot] - the block's tree, when it has one.
|
|
119
|
+
* @property {string} runId
|
|
120
|
+
* @property {string} [runRootDir]
|
|
121
|
+
* @property {Buffer | null} [key]
|
|
122
|
+
* @property {Parameters<typeof budgetFor>[0]} cfg
|
|
123
|
+
* @property {Parameters<typeof spawnWithTimeoutRetry>[0]} spawn
|
|
124
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows]
|
|
125
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The worker's readers (injected, see the module doc).
|
|
130
|
+
* @typedef {object} AnswersIo
|
|
131
|
+
* @property {(root: string, file: string) => Buffer | null} readFile - no symlinks; null when absent.
|
|
132
|
+
* @property {(opts: Record<string, any>) => {status: string, state?: Record<string, any> | null}} loadState - the signed fix-loop state, read only.
|
|
133
|
+
* @property {(answers: unknown) => string[]} assertAnswers - the queue's id-list rule (sorted, unique); throws when the list is bad.
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} status @param {string} reason @param {Array<Record<string, any>>} [sessions]
|
|
138
|
+
* @returns {AnswersOutcome}
|
|
139
|
+
*/
|
|
140
|
+
const outcome = (status, reason, sessions = []) => ({ status, reason, approved: false, engine: 'adaptive', kind: 'answers', sessions });
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run one answers ticket.
|
|
144
|
+
* @param {AnswersTicket} ticket - `kind: 'answers'`.
|
|
145
|
+
* @param {AnswersContext} ctx - `contentRoot` is the block's tree.
|
|
146
|
+
* @param {{entry: {base: string | null, owned: string[], ownedKinds?: Record<string, string>}, workDir: string, io: AnswersIo}} opts
|
|
147
|
+
* @returns {Promise<AnswersOutcome>}
|
|
148
|
+
*/
|
|
149
|
+
export async function reviewAnswers(ticket, ctx, { entry, workDir, io }) {
|
|
150
|
+
const root = ctx.contentRoot ?? ctx.repoRoot;
|
|
151
|
+
const source = ticket.file;
|
|
152
|
+
const byFile = /** @type {string} */ (ticket.by_file);
|
|
153
|
+
// the id list, checked here too by the queue's own rule: 1 to 10 well-formed ids, none twice
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
let ids;
|
|
156
|
+
try {
|
|
157
|
+
ids = io.assertAnswers(ticket.answers);
|
|
158
|
+
} catch {
|
|
159
|
+
return outcome(ANSWERS_STATUS, 'usage');
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(ticket.answers) || ids.length !== ticket.answers.length) return outcome(ANSWERS_STATUS, 'usage');
|
|
162
|
+
if (!ctx.key || !ctx.readRows || !ctx.writeRow) return outcome('unavailable', 'no-ledger');
|
|
163
|
+
if (typeof byFile !== 'string' || path.posix.normalize(byFile) === path.posix.normalize(source)) return outcome(ANSWERS_STATUS, 'answers-same-file');
|
|
164
|
+
if (!ownsFile(entry.owned, source, entry.ownedKinds) || !ownsFile(entry.owned, byFile, entry.ownedKinds)) return outcome(ANSWERS_STATUS, 'answers-not-owned');
|
|
165
|
+
// each file is read ONCE; the hashes and the packet come from these bytes
|
|
166
|
+
/** @type {Buffer | null} */
|
|
167
|
+
let byBytes;
|
|
168
|
+
/** @type {Buffer | null} */
|
|
169
|
+
let srcBytes;
|
|
170
|
+
try {
|
|
171
|
+
byBytes = io.readFile(root, byFile);
|
|
172
|
+
} catch {
|
|
173
|
+
byBytes = null;
|
|
174
|
+
}
|
|
175
|
+
if (byBytes === null) return outcome(ANSWERS_STATUS, 'answers-file-missing');
|
|
176
|
+
const byHash = hashOf(byBytes);
|
|
177
|
+
if (byHash !== ticket.by_hash) return outcome('stale', 'answering-file-changed');
|
|
178
|
+
try {
|
|
179
|
+
srcBytes = io.readFile(root, source);
|
|
180
|
+
} catch {
|
|
181
|
+
srcBytes = null;
|
|
182
|
+
}
|
|
183
|
+
if (srcBytes === null || hashOf(srcBytes) !== ticket.content_hash) return outcome('stale', 'source-changed');
|
|
184
|
+
|
|
185
|
+
let rows;
|
|
186
|
+
try {
|
|
187
|
+
rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
|
|
188
|
+
} catch {
|
|
189
|
+
return outcome('unavailable', 'ledger-unreadable');
|
|
190
|
+
}
|
|
191
|
+
const current = new Map([
|
|
192
|
+
[source, ticket.content_hash],
|
|
193
|
+
[byFile, byHash],
|
|
194
|
+
]);
|
|
195
|
+
// this ticket's own rows do not close an id: a retry after a partial write finishes the rest
|
|
196
|
+
const open = answerableFindings({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, hash: ticket.content_hash, current, ticket: ticket.ticket });
|
|
197
|
+
if (!ids.every((id) => open.has(id))) return outcome(ANSWERS_STATUS, 'answers-not-open');
|
|
198
|
+
|
|
199
|
+
let text;
|
|
200
|
+
try {
|
|
201
|
+
text = await packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// B79: a packet whose fixed text alone is over the budget is refused like an unreadable one:
|
|
204
|
+
// never approval, no session, no row
|
|
205
|
+
if (err instanceof PacketTooLarge) return outcome('unavailable', 'packet-too-large');
|
|
206
|
+
return outcome('unavailable', 'packet-unreadable');
|
|
207
|
+
}
|
|
208
|
+
const promptPath = path.join(workDir, `answers-${randomBytes(6).toString('hex')}.md`);
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
211
|
+
writeFileSync(promptPath, text, { mode: 0o600 });
|
|
212
|
+
} catch {
|
|
213
|
+
return outcome('unavailable', 'packet-write-failed');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const note = async (/** @type {Record<string, any>} */ row) => {
|
|
217
|
+
try {
|
|
218
|
+
await ctx.writeRow?.(row);
|
|
219
|
+
} catch {
|
|
220
|
+
// a note is best-effort
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
({ res } = await spawnWithTimeoutRetry(ctx.spawn, { level: 'L2', role: 'reviewer', promptPath, schema: ANSWERS_SCHEMA, systemPromptText: ANSWERS_LENS, rowExtra: { lens: 'answers' } }, note));
|
|
226
|
+
} catch {
|
|
227
|
+
// a spawn or adapter error is no verdict: retry the ticket
|
|
228
|
+
return outcome('unavailable', 'session-error');
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(promptPath, { force: true });
|
|
231
|
+
}
|
|
232
|
+
const sessions = [{ lens: 'answers', role: 'reviewer', level: 'L2', provider: res?.provider ?? null, model: res?.model ?? null, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null }];
|
|
233
|
+
// a spawn that threw (`spawnWithTimeoutRetry` gives null) is `session-error`, like a throw above
|
|
234
|
+
if (!res) return outcome('unavailable', 'session-error', sessions);
|
|
235
|
+
if (res.status !== 'ok' || (res.exit_code !== undefined && res.exit_code !== 0)) return outcome('unavailable', typeof res.status === 'string' && res.status !== 'ok' ? res.status : 'exit', sessions);
|
|
236
|
+
|
|
237
|
+
const verdicts = verdictsOf(res.answer, ids);
|
|
238
|
+
/** @type {NonNullable<AnswersOutcome['findings']>} */
|
|
239
|
+
const findings = ids.map((id) => {
|
|
240
|
+
const v = verdicts.get(id) ?? { verdict: /** @type {const} */ ('not_resolved'), reason: 'no valid verdict' };
|
|
241
|
+
return { id, severity: open.get(id) ?? null, verdict: v.verdict, reason: v.reason };
|
|
242
|
+
});
|
|
243
|
+
// every verdict is built before any row is written; an id this ticket already answered (an
|
|
244
|
+
// earlier attempt whose later write failed) gets no second row, so a retry ends with one per id
|
|
245
|
+
const done = ticketAnswered({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, current, ticket: ticket.ticket });
|
|
246
|
+
for (const f of findings) {
|
|
247
|
+
if (f.verdict !== 'resolved' || done.has(f.id)) continue;
|
|
248
|
+
try {
|
|
249
|
+
await ctx.writeRow({ event: ANSWERED_EVENT, finding: f.id, by_file: byFile, by_hash: byHash, ticket: ticket.ticket });
|
|
250
|
+
} catch {
|
|
251
|
+
return outcome('unavailable', 'ledger-write-failed', sessions);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { status: ANSWERS_STATUS, approved: false, engine: 'adaptive', kind: 'answers', findings, sessions };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The reviewer's verdict per requested id: only `{id, verdict: 'resolved' | 'not_resolved',
|
|
259
|
+
* reason: string}` entries count; an id given twice, an unknown id or a broken entry gives no
|
|
260
|
+
* verdict for that id (the caller reads it as `not_resolved`).
|
|
261
|
+
* @param {unknown} answer @param {string[]} ids
|
|
262
|
+
* @returns {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>}
|
|
263
|
+
*/
|
|
264
|
+
export function verdictsOf(answer, ids) {
|
|
265
|
+
/** @type {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>} */
|
|
266
|
+
const out = new Map();
|
|
267
|
+
let value = answer;
|
|
268
|
+
if (typeof value === 'string') {
|
|
269
|
+
try {
|
|
270
|
+
value = JSON.parse(value);
|
|
271
|
+
} catch {
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const list = value && typeof value === 'object' && Array.isArray(/** @type {any} */ (value).verdicts) ? /** @type {unknown[]} */ (/** @type {any} */ (value).verdicts) : [];
|
|
276
|
+
/** @type {Set<string>} */
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
/** @type {Set<string>} */
|
|
279
|
+
const repeated = new Set();
|
|
280
|
+
for (const v of list) {
|
|
281
|
+
const e = /** @type {Record<string, unknown>} */ (v);
|
|
282
|
+
if (!e || typeof e !== 'object' || typeof e.id !== 'string' || !ids.includes(e.id)) continue;
|
|
283
|
+
if (seen.has(e.id)) repeated.add(e.id);
|
|
284
|
+
seen.add(e.id);
|
|
285
|
+
if ((e.verdict === 'resolved' || e.verdict === 'not_resolved') && typeof e.reason === 'string') out.set(e.id, { verdict: e.verdict, reason: e.reason.slice(0, REASON_MAX) });
|
|
286
|
+
}
|
|
287
|
+
for (const id of repeated) out.delete(id);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The worker's content hash of bytes it read (`worker/ticket.mjs` `contentHash`: sha256, hex). */
|
|
292
|
+
const hashOf = (/** @type {Buffer} */ bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The answering file's change, built from the bytes that were hashed (never a second read): its
|
|
296
|
+
* diff against its text at the block base (`entry.base`, else HEAD), or its full content when the
|
|
297
|
+
* base has no such file. A secret-like path or a git failure throws (`packet-unreadable`).
|
|
298
|
+
* @param {{root: string, file: string, base: string | null, bytes: Buffer, workDir: string}} opts
|
|
299
|
+
* @returns {Promise<{kind: 'new' | 'tracked', text: string}>}
|
|
300
|
+
*/
|
|
301
|
+
async function answeringChange({ root, file, base, bytes, workDir }) {
|
|
302
|
+
const rel = assertPacketPath(root, file);
|
|
303
|
+
if (isSecretLike(rel)) throw new Error('secret-like path');
|
|
304
|
+
const ref = base ?? 'HEAD';
|
|
305
|
+
if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) throw new Error('the base must be a commit id');
|
|
306
|
+
const git = { cwd: root, timeoutMs: 30000 };
|
|
307
|
+
const listed = await exec(['git', 'ls-tree', '-z', ref, '--', rel], { ...git, env: reviewGitEnv() });
|
|
308
|
+
if (listed.result !== 'ok') throw new Error(`git ls-tree failed (exit ${listed.code})`);
|
|
309
|
+
const current = bytes.toString('utf8');
|
|
310
|
+
if (listed.stdout.length === 0) return { kind: 'new', text: current };
|
|
311
|
+
const shown = await exec(['git', 'show', `${ref}:${rel}`], { ...git, env: plainGitEnv() });
|
|
312
|
+
if (shown.result !== 'ok') throw new Error(`git show failed (exit ${shown.code})`);
|
|
313
|
+
const diff = await fixHunkDiff({ file: rel, previous: shown.stdout, current, workDir });
|
|
314
|
+
return { kind: 'tracked', text: diff.diffText };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* B79: `text` cut to at most `max` UTF-8 bytes, marker included — never more. The bytes are cut on
|
|
319
|
+
* the Buffer and the cut moves back to a character boundary (never half a character); a cut text
|
|
320
|
+
* ends with a newline and {@link TRUNCATED_MARKER}. When `max` is too small for that marker, the
|
|
321
|
+
* part's text is dropped for {@link SHORT_MARKER} (or for nothing, when even that does not fit).
|
|
322
|
+
* A text that fits is returned as it is.
|
|
323
|
+
* @param {string} text @param {number} max @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
export function capBytes(text, max) {
|
|
326
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
327
|
+
if (bytes.length <= max) return text;
|
|
328
|
+
const tail = `\n${TRUNCATED_MARKER}`;
|
|
329
|
+
const tailBytes = Buffer.byteLength(tail);
|
|
330
|
+
if (max < tailBytes) return max >= Buffer.byteLength(SHORT_MARKER) ? SHORT_MARKER : '';
|
|
331
|
+
let end = max - tailBytes;
|
|
332
|
+
// a UTF-8 continuation byte (10xxxxxx) never starts a character: step back to the lead byte
|
|
333
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
334
|
+
return `${bytes.subarray(0, end).toString('utf8')}${tail}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The free-text fields of a finding the packet shows. */
|
|
338
|
+
const FIELDS = /** @type {const} */ (['claim', 'evidence', 'fix']);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The packet: the findings, the source lines around each, and the answering file's change — all
|
|
342
|
+
* from the bytes `reviewAnswers` read and hashed. Neither file may be secret-like (both refused the
|
|
343
|
+
* same way: `packet-unreadable`). B79: the WHOLE packet stays within the budget (`full_in` × 4
|
|
344
|
+
* bytes). Its fixed text (headings, labels, fences) is counted first — over the budget alone it is
|
|
345
|
+
* {@link PacketTooLarge}. Of the room left, the findings get half — per finding, each of claim,
|
|
346
|
+
* evidence and fix a fifth of its share and the source excerpt the rest — and the answering file's
|
|
347
|
+
* change what the findings leave. Each part is redacted BEFORE it is cut (a secret is never cut
|
|
348
|
+
* into a prefix redaction misses), and cut by {@link capBytes}: one marker per cut part, never over
|
|
349
|
+
* its cap. The fixed text is redacted before it is measured, so the joined packet is never cut: it
|
|
350
|
+
* ends with the change's closing fence. A joined packet redaction would still change (a secret
|
|
351
|
+
* across a part boundary) is refused, never cut — `packet-unreadable`.
|
|
352
|
+
* @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, entry: {base: string | null}, open: Map<string, unknown>, io: AnswersIo, ids: string[], srcBytes: Buffer, byBytes: Buffer, workDir: string}} opts
|
|
353
|
+
* @returns {Promise<string>}
|
|
354
|
+
*/
|
|
355
|
+
async function packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir }) {
|
|
356
|
+
const source = ticket.file;
|
|
357
|
+
const byFile = /** @type {string} */ (ticket.by_file);
|
|
358
|
+
if (isSecretLike(assertPacketPath(root, source))) throw new Error('secret-like path');
|
|
359
|
+
const details = findingDetails({ ticket, ctx, rows, root, io });
|
|
360
|
+
const lines = srcBytes.toString('utf8').split('\n');
|
|
361
|
+
const diff = await answeringChange({ root, file: byFile, base: entry.base, bytes: byBytes, workDir });
|
|
362
|
+
const limit = budgetFor(ctx.cfg, 'full_in') * 4;
|
|
363
|
+
// each finding: its fixed lines, and its free text (null: not recorded) and source excerpt (null: none)
|
|
364
|
+
const findings = ids.map((id) => {
|
|
365
|
+
const f = details.get(id) ?? null;
|
|
366
|
+
const start = Number.isInteger(f?.line_start) ? /** @type {number} */ (f?.line_start) : null;
|
|
367
|
+
const end = Number.isInteger(f?.line_end) ? /** @type {number} */ (f?.line_end) : start;
|
|
368
|
+
// the fixed text is redacted before it is measured: the joined packet never grows past its count
|
|
369
|
+
const head = [`## Finding ${id}`, '', `- file: ${source}`, `- lines: ${start === null ? 'unknown' : `${start}-${end}`}`, `- severity: ${String(f?.severity ?? open.get(id) ?? 'unknown')}`].map(redact);
|
|
370
|
+
/** @type {Record<typeof FIELDS[number], string | null>} */
|
|
371
|
+
const text = { claim: null, evidence: null, fix: null };
|
|
372
|
+
for (const k of FIELDS) text[k] = typeof f?.[k] === 'string' ? redact(f[k]) : null;
|
|
373
|
+
/** @type {{label: string, text: string} | null} */
|
|
374
|
+
let excerpt = null;
|
|
375
|
+
// a range recorded backwards (start after end) shows no lines, whatever the lines around it
|
|
376
|
+
if (start !== null && end !== null && start <= end && lines.length > 0) {
|
|
377
|
+
const from = Math.max(1, start - AROUND);
|
|
378
|
+
const to = Math.min(lines.length, end + AROUND);
|
|
379
|
+
// a recorded range past the end of the source, or a backwards one, shows no lines
|
|
380
|
+
if (from <= to) excerpt = { label: redact(`Source lines ${from}-${to}:`), text: redact(lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join('\n')) };
|
|
381
|
+
}
|
|
382
|
+
return { head, text, excerpt };
|
|
383
|
+
});
|
|
384
|
+
const intro = [
|
|
385
|
+
'# Answered findings',
|
|
386
|
+
'',
|
|
387
|
+
`A review of \`${source}\` left the findings below open. The coder says the change in \`${byFile}\` resolves them.`,
|
|
388
|
+
'For EACH finding id, decide whether that change resolves it: `resolved` only when the change clearly removes the problem the finding names; otherwise `not_resolved`. Give a short reason for each.',
|
|
389
|
+
'Reply with ONE JSON object: {"verdicts": [{"id": string, "verdict": "resolved" | "not_resolved", "reason": string}]}, one entry per id below.',
|
|
390
|
+
'',
|
|
391
|
+
].map(redact);
|
|
392
|
+
const changeHead = [`## The change in ${byFile}`, '', diff.kind === 'new' ? '(a new file: its full content)' : `(its diff against the block base)`, '```'].map(redact);
|
|
393
|
+
/**
|
|
394
|
+
* The packet with these part texts (`parts[i]`: finding i's fields and excerpt) and change.
|
|
395
|
+
* @param {Array<{claim: string, evidence: string, fix: string, excerpt: string}>} parts @param {string} change
|
|
396
|
+
*/
|
|
397
|
+
const render = (parts, change) => {
|
|
398
|
+
const out = [...intro];
|
|
399
|
+
findings.forEach((f, i) => {
|
|
400
|
+
out.push(...f.head);
|
|
401
|
+
for (const k of FIELDS) out.push(`- ${k}: ${f.text[k] === null ? '(not recorded)' : parts[i][k]}`);
|
|
402
|
+
out.push('');
|
|
403
|
+
if (f.excerpt !== null) out.push(f.excerpt.label, '```', parts[i].excerpt, '```', '');
|
|
404
|
+
});
|
|
405
|
+
out.push(...changeHead, change, '```', '');
|
|
406
|
+
return out.join('\n');
|
|
407
|
+
};
|
|
408
|
+
const fixed = Buffer.byteLength(render(findings.map(() => ({ claim: '', evidence: '', fix: '', excerpt: '' })), ''));
|
|
409
|
+
if (fixed > limit) throw new PacketTooLarge('the packet fixed text is over the budget');
|
|
410
|
+
const share = Math.floor((limit - fixed) / 2 / Math.max(1, findings.length));
|
|
411
|
+
const textCap = Math.floor(share / 5);
|
|
412
|
+
const sourceCap = share - FIELDS.length * textCap;
|
|
413
|
+
const parts = findings.map((f) => ({
|
|
414
|
+
claim: capBytes(f.text.claim ?? '', textCap),
|
|
415
|
+
evidence: capBytes(f.text.evidence ?? '', textCap),
|
|
416
|
+
fix: capBytes(f.text.fix ?? '', textCap),
|
|
417
|
+
excerpt: capBytes(f.excerpt?.text ?? '', sourceCap),
|
|
418
|
+
}));
|
|
419
|
+
// the change gets what the fixed text and the findings leave
|
|
420
|
+
const used = Buffer.byteLength(render(parts, ''));
|
|
421
|
+
const packet = render(parts, capBytes(redact(diff.text), limit - used));
|
|
422
|
+
// every piece is redacted and counted: the packet is never cut here, only refused (fail closed)
|
|
423
|
+
if (Buffer.byteLength(packet) > limit) throw new PacketTooLarge('the packet is over the budget');
|
|
424
|
+
if (redact(packet) !== packet) throw new Error('a secret across a part boundary');
|
|
425
|
+
return packet;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The open findings' details from the source's signed fix-loop state (read only, never moved or
|
|
430
|
+
* restored here). A state that does not load gives none: the packet then names id and severity.
|
|
431
|
+
* @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, io: AnswersIo}} opts
|
|
432
|
+
* @returns {Map<string, Record<string, any>>}
|
|
433
|
+
*/
|
|
434
|
+
function findingDetails({ ticket, ctx, rows, root, io }) {
|
|
435
|
+
/** @type {Map<string, Record<string, any>>} */
|
|
436
|
+
const out = new Map();
|
|
437
|
+
try {
|
|
438
|
+
const loaded = io.loadState({ runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key: /** @type {Buffer} */ (ctx.key), rows, contentRoot: root, migrate: false });
|
|
439
|
+
if (loaded.status !== 'ok' && loaded.status !== 'restored') return out;
|
|
440
|
+
for (const f of [...(loaded.state?.open ?? []), ...(loaded.state?.late ?? [])]) if (typeof f?.id === 'string' && !out.has(f.id)) out.set(f.id, f);
|
|
441
|
+
} catch {
|
|
442
|
+
// no details: id and severity only
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
@@ -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}
|