@codedology/code-forge 0.7.1 → 0.7.3
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 +23 -0
- package/README.md +14 -0
- package/docs/reference/blocks.json +33 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/binnacle.mjs +29 -1
- package/src/cli/review-file.mjs +9 -1
- package/src/cli/review.mjs +11 -3
- package/src/review/engine.mjs +192 -46
- package/src/review/fixloop.mjs +212 -45
- package/src/review/packet.mjs +32 -7
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +282 -0
- package/src/review/validate-review.mjs +11 -0
- package/src/session/spawn.mjs +3 -86
- package/src/util/diagnostic-tail.mjs +95 -0
- package/src/worker/engine.mjs +45 -4
- package/src/worker/loop.mjs +18 -0
package/src/review/fixloop.mjs
CHANGED
|
@@ -47,7 +47,21 @@
|
|
|
47
47
|
* with no context at all, exceed the budget) is deterministic — re-running the round would build
|
|
48
48
|
* the same packet — so it is TERMINAL, never a retry: `stopped`, `next: {action: 'stop', reason:
|
|
49
49
|
* <packet.status>}`, and the gate-relevant `review.cap` row (the gate then refuses the file's open
|
|
50
|
-
* findings until a human waives them or the file is reviewed again).
|
|
50
|
+
* findings until a human waives them or the file is reviewed again). The stop carries the packet's
|
|
51
|
+
* `tokens_in` and `budget` (and a Markdown section's heading as `section`) in `next` and in the
|
|
52
|
+
* `review.cap` row (B54).
|
|
53
|
+
*
|
|
54
|
+
* Markdown sections (B54): a Markdown file whose recheck packet is over the budget is rechecked
|
|
55
|
+
* SECTION BY SECTION (`sections.mjs`, `packRecheck`: packets sized with their open-findings list)
|
|
56
|
+
* instead: one recheck session per section packet, each listing only the open findings that fall
|
|
57
|
+
* in it (`sectionOf`: the spanning or nearest section), each through the stub guard (its
|
|
58
|
+
* own `reviewed_hunks`). A finding closes only when ITS section's session resolves it; new
|
|
59
|
+
* findings get ids `S<section>.<id>`; any section session unavailable ⇒ `retry` of the whole
|
|
60
|
+
* round. A section still over the budget alone ⇒ the terminal stop above (`split_required`).
|
|
61
|
+
*
|
|
62
|
+
* Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
|
|
63
|
+
* packet's second miss is tried once on `review.second_levels.L2` when configured
|
|
64
|
+
* (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
|
|
51
65
|
*
|
|
52
66
|
* A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
|
|
53
67
|
* (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
|
|
@@ -64,10 +78,12 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
64
78
|
import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
65
79
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
66
80
|
import { parseDiff } from './context.mjs';
|
|
67
|
-
import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
|
|
81
|
+
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
82
|
+
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
68
83
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
69
84
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
70
85
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
86
|
+
import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
71
87
|
|
|
72
88
|
/** @typedef {import('./triage.mjs').Finding} Finding */
|
|
73
89
|
/** @typedef {import('./context.mjs').Hunk} Hunk */
|
|
@@ -78,6 +94,9 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
78
94
|
* @property {string} [level] - the level the next fix is coded at.
|
|
79
95
|
* @property {string | null} [trigger] - the escalation trigger that moved the level, if any.
|
|
80
96
|
* @property {string} [reason] - `review_cap`, `l3_patch_exhausted`, a non-ok packet status (`split_required`), … for `stop`; the stub-guard reason for `retry`.
|
|
97
|
+
* @property {number} [tokens_in] - a `split_required` stop: the estimated tokens of the diff-only packet (B54).
|
|
98
|
+
* @property {number} [budget] - a `split_required` stop: the packet budget it exceeded (B54).
|
|
99
|
+
* @property {string} [section] - a `split_required` stop of a Markdown recheck: the heading of the section that alone is over (B54).
|
|
81
100
|
*/
|
|
82
101
|
|
|
83
102
|
/**
|
|
@@ -93,7 +112,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
93
112
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
94
113
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
95
114
|
* @property {NextStep | null} next
|
|
96
|
-
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck packet's
|
|
115
|
+
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
|
|
97
116
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
98
117
|
*/
|
|
99
118
|
|
|
@@ -112,6 +131,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
112
131
|
* @property {string | null} [base] - the block base (only for `recheck_scope: file`).
|
|
113
132
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
114
133
|
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
134
|
+
* @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
|
|
115
135
|
*/
|
|
116
136
|
|
|
117
137
|
/** @param {LoopDeps} deps @param {Record<string, any>} row */
|
|
@@ -123,6 +143,58 @@ async function note(deps, row) {
|
|
|
123
143
|
}
|
|
124
144
|
}
|
|
125
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Token counts over a round's sessions: a missing count (null/undefined) adds nothing, and no
|
|
148
|
+
* count at all stays null (one session without usage ⇒ null, as before B54).
|
|
149
|
+
* @param {number | null | undefined} a @param {number | null | undefined} b @returns {number | null}
|
|
150
|
+
*/
|
|
151
|
+
export const sum = (a, b) => (typeof a !== 'number' ? (typeof b === 'number' ? b : null) : typeof b === 'number' ? a + b : a);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The round's `last_packet` over its recheck packets (B54): the context lines add up; the context
|
|
155
|
+
* mode is the packets' one mode when they all agree, else `mixed`.
|
|
156
|
+
* @param {{context_mode: string, context_lines: number} | null} info
|
|
157
|
+
* @param {{contextMode: string, contextLines: number}} packet
|
|
158
|
+
* @returns {{context_mode: string, context_lines: number}}
|
|
159
|
+
*/
|
|
160
|
+
export function mergePacketInfo(info, packet) {
|
|
161
|
+
if (info === null) return { context_mode: packet.contextMode, context_lines: packet.contextLines };
|
|
162
|
+
return { context_mode: info.context_mode === packet.contextMode ? info.context_mode : 'mixed', context_lines: info.context_lines + packet.contextLines };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pack a Markdown recheck into section packets (B54), each sized with {@link recheckTokens} over
|
|
167
|
+
* its own diff and its own open findings. Each open finding belongs to ONE heading group — the one
|
|
168
|
+
* `sectionOf` picks over the groups (spanning, else nearest) — and is sized and listed in the
|
|
169
|
+
* packet that holds that group, so sizing and listing never disagree. No group at all (no hunk,
|
|
170
|
+
* or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
|
|
171
|
+
* a finding no packet holds is listed in the LAST packet — every open finding is listed once.
|
|
172
|
+
* @param {import('./packet.mjs').FileDiff} diff @param {Finding[]} open @param {number} budget
|
|
173
|
+
* @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
|
|
174
|
+
*/
|
|
175
|
+
export function packRecheck(diff, open, budget) {
|
|
176
|
+
const groups = sectionGroups(diff);
|
|
177
|
+
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
178
|
+
/** @type {Map<Finding, number>} */
|
|
179
|
+
const keyOf = new Map();
|
|
180
|
+
for (const f of open) {
|
|
181
|
+
const at = sectionOf(groups, f);
|
|
182
|
+
if (at >= 0) keyOf.set(f, groups[at].key);
|
|
183
|
+
}
|
|
184
|
+
/** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
|
|
185
|
+
const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
|
|
186
|
+
const packed = packSections({ diff, budget, measure: (part, set) => recheckTokens(part, openIn(set.map((g) => g.key))) });
|
|
187
|
+
if (packed.status !== 'ok') return packed;
|
|
188
|
+
const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
|
|
189
|
+
if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
190
|
+
// defensive: a finding no packet holds goes to the last packet (it is sized there by the check
|
|
191
|
+
// every packet passes before any session) — never dropped, never closed unseen
|
|
192
|
+
const listed = new Set(sections.flatMap((sec) => sec.open));
|
|
193
|
+
const last = /** @type {(typeof sections)[number]} */ (sections.at(-1));
|
|
194
|
+
for (const f of open) if (!listed.has(f)) last.open.push(f);
|
|
195
|
+
return { status: 'ok', sections };
|
|
196
|
+
}
|
|
197
|
+
|
|
126
198
|
/** @param {unknown} v @param {number} d @returns {number} `v` when a non-negative integer, else `d`. */
|
|
127
199
|
const int = (v, d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
|
|
128
200
|
|
|
@@ -247,6 +319,25 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
|
|
|
247
319
|
}
|
|
248
320
|
}
|
|
249
321
|
|
|
322
|
+
/** The heading of the open-findings list a recheck packet ends with. */
|
|
323
|
+
const OPEN_FINDINGS_HEAD = '## open findings\n';
|
|
324
|
+
|
|
325
|
+
/** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
|
|
326
|
+
export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
|
|
327
|
+
|
|
328
|
+
/** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
|
|
329
|
+
const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
|
|
333
|
+
* `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
|
|
334
|
+
* the check before any session use this ONE function.
|
|
335
|
+
* @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open @returns {number}
|
|
336
|
+
*/
|
|
337
|
+
export function recheckTokens(diff, open) {
|
|
338
|
+
return diffOnlyTokens({ diff, lens: 'recheck' }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
|
|
339
|
+
}
|
|
340
|
+
|
|
250
341
|
/**
|
|
251
342
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
252
343
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
@@ -255,8 +346,7 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
|
|
|
255
346
|
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
|
|
256
347
|
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
|
|
257
348
|
if (packet.status !== 'ok') return packet;
|
|
258
|
-
const
|
|
259
|
-
const text = `${packet.text}## open findings\n${listed.length > 0 ? listed.join('\n') : '(none)'}\n`;
|
|
349
|
+
const text = `${packet.text}${openFindingsText(open)}`;
|
|
260
350
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
261
351
|
}
|
|
262
352
|
|
|
@@ -269,7 +359,8 @@ export function insideHunks(f, hunks) {
|
|
|
269
359
|
}
|
|
270
360
|
|
|
271
361
|
/**
|
|
272
|
-
* One recheck session through the stub guard
|
|
362
|
+
* One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
|
|
363
|
+
* on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
|
|
273
364
|
* @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
|
|
274
365
|
* @param {LoopDeps} deps
|
|
275
366
|
*/
|
|
@@ -277,26 +368,62 @@ async function recheckSession(packet, deps) {
|
|
|
277
368
|
mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
|
|
278
369
|
const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
|
|
279
370
|
writeFileSync(promptPath, packet.text, { mode: 0o600 });
|
|
371
|
+
const min = minTokensOut(deps.cfg);
|
|
372
|
+
const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
|
|
373
|
+
/** @param {Record<string, any> | undefined} cfg */
|
|
374
|
+
const attempt = async (cfg) =>
|
|
375
|
+
deps.spawn
|
|
376
|
+
? // a `timeout` is spawned once more on the same packet and level (B30)
|
|
377
|
+
spawnWithTimeoutRetry(
|
|
378
|
+
deps.spawn,
|
|
379
|
+
{ level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
|
|
380
|
+
(row) => note(deps, row),
|
|
381
|
+
)
|
|
382
|
+
: { res: null, attempts: 0 };
|
|
280
383
|
/** @type {Record<string, any> | null} */
|
|
281
384
|
let res = null;
|
|
385
|
+
/** @type {import('./validate-review.mjs').Verdict} */
|
|
386
|
+
let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
|
|
387
|
+
/** @type {number | null} the round's tokens: the first session's, plus a second-level try's */
|
|
388
|
+
let tokensIn = null;
|
|
389
|
+
/** @type {number | null} */
|
|
390
|
+
let tokensOut = null;
|
|
391
|
+
// the packet file is removed on every path, and only after the second-level try (which reads it)
|
|
282
392
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
393
|
+
try {
|
|
394
|
+
res = (await attempt(undefined)).res;
|
|
395
|
+
} catch {
|
|
396
|
+
res = null; // the helper never throws; if it ever did, it is still an `exit` failure
|
|
397
|
+
}
|
|
398
|
+
tokensIn = res?.usage?.tokens_in ?? null;
|
|
399
|
+
tokensOut = res?.usage?.tokens_out ?? null;
|
|
400
|
+
verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
401
|
+
if (!verdict.ok && verdict.reason === 'schema') {
|
|
402
|
+
try {
|
|
403
|
+
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
404
|
+
// (each helper handles its own failed write)
|
|
405
|
+
const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
|
|
406
|
+
const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
|
|
407
|
+
if (second) {
|
|
408
|
+
// compute everything first, then assign together, so a throw never leaves a mixed state
|
|
409
|
+
const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
410
|
+
const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
|
|
411
|
+
const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
|
|
412
|
+
res = second.res;
|
|
413
|
+
verdict = secondVerdict;
|
|
414
|
+
tokensIn = sumIn;
|
|
415
|
+
tokensOut = sumOut;
|
|
416
|
+
if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
// defensive only: the helpers never throw. If they ever did, the first session's result
|
|
420
|
+
// and its `schema` verdict stand (never turned into `exit`, never approval)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
295
423
|
} finally {
|
|
296
424
|
rmSync(promptPath, { force: true });
|
|
297
425
|
}
|
|
298
|
-
|
|
299
|
-
return { verdict, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null };
|
|
426
|
+
return { verdict, tokens_in: tokensIn, tokens_out: tokensOut };
|
|
300
427
|
}
|
|
301
428
|
|
|
302
429
|
/**
|
|
@@ -373,14 +500,20 @@ function retry(state, kind, reason) {
|
|
|
373
500
|
* not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
|
|
374
501
|
* the must-write path — a failed write is a `retry`, never a silent stop.
|
|
375
502
|
* @param {FileState} state @param {LoopDeps} deps
|
|
376
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
503
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
504
|
+
* @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
|
|
377
505
|
* @returns {Promise<FileState>}
|
|
378
506
|
*/
|
|
379
|
-
async function stopBeforeRound(state, deps, kind, file,
|
|
380
|
-
const
|
|
507
|
+
async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
508
|
+
const reason = packet.status;
|
|
509
|
+
const size =
|
|
510
|
+
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
511
|
+
? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
|
|
512
|
+
: {};
|
|
513
|
+
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
381
514
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
382
515
|
state.status = 'stopped';
|
|
383
|
-
state.next = { action: 'stop', reason };
|
|
516
|
+
state.next = { action: 'stop', reason, ...size };
|
|
384
517
|
state.pending_kind = null;
|
|
385
518
|
return state;
|
|
386
519
|
}
|
|
@@ -434,34 +567,68 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
434
567
|
}
|
|
435
568
|
draft.open = [];
|
|
436
569
|
if (pending.length > 0) {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return
|
|
570
|
+
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
571
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
|
|
572
|
+
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
|
|
573
|
+
let parts = [{ index: null, heading: null, diff, open: pending }];
|
|
574
|
+
if (whole.status !== 'ok') {
|
|
575
|
+
// deterministic (the same hunks build the same packet): terminal, never a retry — except
|
|
576
|
+
// a Markdown file whose packet is `split_required`, rechecked section by section (B54);
|
|
577
|
+
// any other status stops as it always did
|
|
578
|
+
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget) : null;
|
|
579
|
+
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
|
|
580
|
+
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open }));
|
|
447
581
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
582
|
+
// EVERY packet is built and held to the budget BEFORE any session runs: a section over it
|
|
583
|
+
// stops the round as split_required (with its heading) before anything is spent
|
|
584
|
+
/** @type {Array<Record<string, any>>} */
|
|
585
|
+
const built = [];
|
|
586
|
+
for (const part of parts) {
|
|
587
|
+
if (part.index === null) {
|
|
588
|
+
built.push(whole);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
|
|
592
|
+
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
593
|
+
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
594
|
+
const size = recheckTokens(part.diff, part.open);
|
|
595
|
+
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
596
|
+
built.push(packet);
|
|
597
|
+
}
|
|
598
|
+
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
599
|
+
const resolved = new Set();
|
|
452
600
|
/** @type {Finding[]} */
|
|
453
601
|
const inside = [];
|
|
454
602
|
/** @type {Finding[]} */
|
|
455
603
|
const blocking = [];
|
|
456
|
-
for (const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
604
|
+
for (const [i, part] of parts.entries()) {
|
|
605
|
+
const packet = /** @type {any} */ (built[i]);
|
|
606
|
+
packetInfo = mergePacketInfo(packetInfo, packet);
|
|
607
|
+
const session = await recheckSession(packet, deps);
|
|
608
|
+
tokensIn = sum(tokensIn, session.tokens_in);
|
|
609
|
+
tokensOut = sum(tokensOut, session.tokens_out);
|
|
610
|
+
if (!session.verdict.ok) {
|
|
611
|
+
await note(deps, { event: 'review.unavailable', file: current.rel, lens: 'recheck', reason: session.verdict.reason, ...(part.index === null ? {} : { section: part.index }) });
|
|
612
|
+
return retry(state, kind, session.verdict.reason ?? 'unavailable');
|
|
613
|
+
}
|
|
614
|
+
const review = /** @type {Record<string, any>} */ (session.verdict.review);
|
|
615
|
+
const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
|
|
616
|
+
// only the findings listed in THIS packet can be resolved by its session
|
|
617
|
+
for (const f of part.open) if (resolvedIds.has(f.id)) resolved.add(f);
|
|
618
|
+
const found = /** @type {Finding[]} */ (review.findings ?? []);
|
|
619
|
+
for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
|
|
620
|
+
if (insideHunks(f, diff.hunks)) {
|
|
621
|
+
inside.push(f);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
draft.late.push(f);
|
|
625
|
+
lateRows.push({ event: 'review.late_finding', file: current.rel, round: state.round + 1, finding: f.id, severity: f.severity, line_start: f.line_start, line_end: f.line_end, mode: s.late });
|
|
626
|
+
if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
|
|
460
627
|
}
|
|
461
|
-
draft.late.push(f);
|
|
462
|
-
lateRows.push({ event: 'review.late_finding', file: current.rel, round: state.round + 1, finding: f.id, severity: f.severity, line_start: f.line_start, line_end: f.line_end, mode: s.late });
|
|
463
|
-
if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
|
|
464
628
|
}
|
|
629
|
+
// every pending finding stays open unless the session that listed it resolved it
|
|
630
|
+
const stillOpen = pending.filter((f) => !resolved.has(f));
|
|
631
|
+
closed += pending.length - stillOpen.length;
|
|
465
632
|
const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
|
|
466
633
|
newInHunks = triaged.fix_now.length;
|
|
467
634
|
draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
|
package/src/review/packet.mjs
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* (`git diff --no-index -- /dev/null <file>`). A tracked file with no change yields no packet.
|
|
18
18
|
*
|
|
19
19
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
20
|
-
* bytes / 4. A diff that alone exceeds it ⇒ `split_required
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
20
|
+
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
21
|
+
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
22
|
+
* ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end; the diff is
|
|
23
|
+
* never cut.
|
|
23
24
|
*
|
|
24
25
|
* Path rule (V4): `file` is repo-root-relative; `./`, `../`, absolute values, symlinks and paths
|
|
25
26
|
* under `.git/` or `.code-forge/` are refused with `bad-path` before git runs. So is anything git
|
|
@@ -40,7 +41,8 @@ export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judg
|
|
|
40
41
|
/** The rules digest never exceeds this many lines (§4.3). */
|
|
41
42
|
export const DIGEST_MAX_LINES = 60;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/** `full_in` was 12 000 until B54: a 1,590-line design brief (~25k tokens) was refused unreviewed. */
|
|
45
|
+
export const DEFAULT_BUDGETS = Object.freeze({ quick_in: 6000, full_in: 32000, judge_in: 8000 });
|
|
44
46
|
|
|
45
47
|
const LENS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'lenses');
|
|
46
48
|
const GIT_TIMEOUT_MS = 30000;
|
|
@@ -191,9 +193,9 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
191
193
|
const lensText = loadLens(lens);
|
|
192
194
|
const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
|
|
193
195
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
194
|
-
const diffSection =
|
|
195
|
-
const bare =
|
|
196
|
-
if (
|
|
196
|
+
const diffSection = diffSectionOf(diff);
|
|
197
|
+
const bare = bareTokens(lensText, diffSection);
|
|
198
|
+
if (bare > limit) return { status: 'split_required', tokensIn: bare, budget: limit };
|
|
197
199
|
|
|
198
200
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
199
201
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
@@ -210,6 +212,29 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
210
212
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The tokens of the diff-only packet (lens + diff, no digest, facts or context): what
|
|
217
|
+
* `assemblePacket` holds against the budget before it says `split_required`.
|
|
218
|
+
* @param {{diff: FileDiff, lens: string}} opts @returns {number}
|
|
219
|
+
*/
|
|
220
|
+
export function diffOnlyTokens({ diff, lens }) {
|
|
221
|
+
return bareTokens(loadLens(lens), diffSectionOf(diff));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The ONE measure behind `split_required` (`assemblePacket`) and section packing (`diffOnlyTokens`,
|
|
226
|
+
* B54), so the two can never disagree.
|
|
227
|
+
* @param {string} lensText @param {string} diffSection @returns {number}
|
|
228
|
+
*/
|
|
229
|
+
function bareTokens(lensText, diffSection) {
|
|
230
|
+
return estimateTokens(render(lensText, [], '', '', diffSection));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {FileDiff} diff @returns {string} the `## diff` section body. */
|
|
234
|
+
function diffSectionOf(diff) {
|
|
235
|
+
return [`file: ${diff.file}`, 'hunks:', ...diff.hunks.map((h) => `- ${h.header}`), '', diff.diffText.trimEnd()].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
/**
|
|
214
239
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
215
240
|
* @param {string} diffSection
|