@codedology/code-forge 0.7.1 → 0.7.2
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 +14 -0
- package/README.md +6 -0
- package/docs/reference/blocks.json +17 -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 +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/review-file.mjs +9 -1
- package/src/cli/review.mjs +11 -3
- package/src/review/engine.mjs +101 -27
- package/src/review/fixloop.mjs +154 -30
- package/src/review/packet.mjs +32 -7
- package/src/review/sections.mjs +282 -0
- package/src/worker/engine.mjs +20 -3
- package/src/worker/loop.mjs +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.2] — 2026-10-07
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- review.budgets.full_in defaults to 32,000 tokens (was 12,000), and a Markdown file whose diff alone is still over it
|
|
14
|
+
is reviewed section by section (cut at its headings, outside fenced code; approved only when every section is;
|
|
15
|
+
findings as S<section>.<id>; rechecks split the same way) instead of split_required. Only a section that alone is over
|
|
16
|
+
the budget keeps the file split_required (issue #2).
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- A split_required result lost its size: the signed result, the ledger review.result row, review-file --wait (now stop:
|
|
21
|
+
split_required) and code-forge review output now carry tokens_in and budget (issue #2).
|
|
22
|
+
|
|
9
23
|
## [0.7.1] — 2026-10-07
|
|
10
24
|
|
|
11
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -12,6 +12,12 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
|
|
|
12
12
|
|
|
13
13
|
Next release:
|
|
14
14
|
|
|
15
|
+
- **Long documents get reviewed.** The packet budget (`review.budgets.full_in`) is now 32,000
|
|
16
|
+
tokens, and a Markdown file still too large is reviewed section by section instead of refused;
|
|
17
|
+
a `split_required` result now says its size and the budget.
|
|
18
|
+
|
|
19
|
+
In 0.7.1:
|
|
20
|
+
|
|
15
21
|
- **Short clean reviews count.** A clean pass (no findings, every hunk acknowledged, a summary)
|
|
16
22
|
needs only 40 output tokens on any diff size, so a correct short "no defect" review is no
|
|
17
23
|
longer refused as `too_short`.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"plans/code-forge-plan-v1.3.md §10.4",
|
|
5
5
|
"plans/NIGHT-LOG.md"
|
|
6
6
|
],
|
|
7
|
-
"block_count":
|
|
7
|
+
"block_count": 57,
|
|
8
8
|
"baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
|
|
9
9
|
"baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
|
|
10
10
|
"blocks": {
|
|
@@ -762,6 +762,22 @@
|
|
|
762
762
|
"B52"
|
|
763
763
|
],
|
|
764
764
|
"note": "root-added 2026-10-07; GitHub issue #6; also amends src/review/engine.mjs (smallDiff removed), schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
|
|
765
|
+
},
|
|
766
|
+
"B54": {
|
|
767
|
+
"title": "Long documents get reviewed (issue #2): review.budgets.full_in defaults to 32000; a Markdown file whose diff is still over the budget is reviewed section by section (hunks cut at headings outside fenced code and front matter, sections packed within the budget, every section packet built and checked before any session, approved only when every section is, findings as S<n>.<id>, rechecks sectioned the same way); a split_required result carries tokens_in, budget and section",
|
|
768
|
+
"wave": 12,
|
|
769
|
+
"level": "L2",
|
|
770
|
+
"status": "landed",
|
|
771
|
+
"owned": [
|
|
772
|
+
"src/review/sections.mjs",
|
|
773
|
+
"test/review/sections.test.mjs",
|
|
774
|
+
"test/review/sections-guard.test.mjs",
|
|
775
|
+
"test/worker/split-size.test.mjs"
|
|
776
|
+
],
|
|
777
|
+
"depends_on": [
|
|
778
|
+
"B53"
|
|
779
|
+
],
|
|
780
|
+
"note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-02 (25k-token brief never reviewed); also amends src/review/{packet,engine,fixloop}.mjs, src/worker/{engine,loop}.mjs, src/cli/{review,review-file}.mjs, schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
|
|
765
781
|
}
|
|
766
782
|
}
|
|
767
783
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -96,7 +96,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
|
|
|
96
96
|
| `review.judge_sees_diff` | boolean | | `false` | |
|
|
97
97
|
| `review.budgets` | object | | — | |
|
|
98
98
|
| `review.budgets.quick_in` | integer | | `6000` | min 1 |
|
|
99
|
-
| `review.budgets.full_in` | integer | | `
|
|
99
|
+
| `review.budgets.full_in` | integer | | `32000` | min 1; Packet budget in tokens (bytes / 4) for a full, lens A/B or recheck review session. A diff alone over it is split_required, except a Markdown file, which is reviewed section by section (B54). |
|
|
100
100
|
| `review.budgets.judge_in` | integer | | `8000` | min 1 |
|
|
101
101
|
| `review.budgets.out` | integer | | `1200` | min 1 |
|
|
102
102
|
| `review.context` | object | | — | |
|
package/package.json
CHANGED
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"additionalProperties": false,
|
|
118
118
|
"properties": {
|
|
119
119
|
"quick_in": { "type": "integer", "minimum": 1, "default": 6000 },
|
|
120
|
-
"full_in": { "type": "integer", "minimum": 1, "default":
|
|
120
|
+
"full_in": { "type": "integer", "minimum": 1, "default": 32000, "description": "Packet budget in tokens (bytes / 4) for a full, lens A/B or recheck review session. A diff alone over it is split_required, except a Markdown file, which is reviewed section by section (B54)." },
|
|
121
121
|
"judge_in": { "type": "integer", "minimum": 1, "default": 8000 },
|
|
122
122
|
"out": { "type": "integer", "minimum": 1, "default": 1200 }
|
|
123
123
|
}
|
|
@@ -15,7 +15,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
15
15
|
|
|
16
16
|
## §3 Isolation and the packet
|
|
17
17
|
|
|
18
|
-
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
18
|
+
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
19
19
|
|
|
20
20
|
## §4 The stub guard — what counts as a review (R11)
|
|
21
21
|
|
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.7.
|
|
13
|
+
PINNED_VERSION="0.7.2"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* A done result that is not approved and carries open findings adds `fix_list` (what the coder
|
|
12
12
|
* fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
|
|
13
13
|
* §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) — the coder
|
|
14
|
-
* stops and reports; only the orchestrator and the human act on it.
|
|
14
|
+
* stops and reports; only the orchestrator and the human act on it. A `split_required` result
|
|
15
|
+
* (the packet is over `review.budgets.full_in`) adds `stop: split_required` with `tokens_in` and
|
|
16
|
+
* `budget` (always; null when unknown) and a Markdown `section` — never for another result (B54); a Markdown file gets there only when one of its sections alone is too large.
|
|
15
17
|
*
|
|
16
18
|
* Everything is a file read/write inside the workspace (a sandboxed coder has no network). The
|
|
17
19
|
* path is given relative to the current directory and stored relative to the REPOSITORY ROOT
|
|
@@ -80,6 +82,12 @@ export function fixList(result) {
|
|
|
80
82
|
}));
|
|
81
83
|
}
|
|
82
84
|
if (typeof result.stopped === 'string') out.stop = result.stopped;
|
|
85
|
+
else if (result.status === 'split_required') out.stop = 'split_required';
|
|
86
|
+
// B54: a split_required stop (only) says how large the packet was, the budget, and the section
|
|
87
|
+
if (out.stop === 'split_required') {
|
|
88
|
+
const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
89
|
+
Object.assign(out, { tokens_in: fin(result.tokens_in), budget: fin(result.budget), ...(typeof result.section === 'string' ? { section: result.section } : {}) });
|
|
90
|
+
}
|
|
83
91
|
return out;
|
|
84
92
|
}
|
|
85
93
|
|
package/src/cli/review.mjs
CHANGED
|
@@ -204,7 +204,9 @@ function lastJSON(text) {
|
|
|
204
204
|
|
|
205
205
|
/**
|
|
206
206
|
* @typedef {{file: string, result: 'approved' | 'unchanged' | 'findings' | 'stopped' | 'unavailable', reason: string | null,
|
|
207
|
-
* findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}
|
|
207
|
+
* findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}>,
|
|
208
|
+
* tokens_in?: number | null, budget?: number | null, section?: string}} FileOutcome - `tokens_in`/`budget`
|
|
209
|
+
* (null when unknown; and a Markdown `section`): a `split_required` stop's packet size, only then (B54).
|
|
208
210
|
*/
|
|
209
211
|
|
|
210
212
|
/**
|
|
@@ -222,8 +224,11 @@ export function classify(file, waited) {
|
|
|
222
224
|
if (!r || typeof r !== 'object') return out('unavailable', 'unreadable result');
|
|
223
225
|
if (r.approved === true) return out('approved', null);
|
|
224
226
|
if (r.status === 'no_change') return out('unchanged', null);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
// B54: a split_required stop (only) says how large the packet was and what the budget is
|
|
228
|
+
const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
229
|
+
const size = { tokens_in: fin(r.tokens_in), budget: fin(r.budget), ...(typeof r.section === 'string' ? { section: r.section } : {}) };
|
|
230
|
+
if (r.status === 'stopped') return { ...out('stopped', String(r.stopped ?? 'stopped')), ...(r.stopped === 'split_required' ? size : {}) };
|
|
231
|
+
if (r.status === 'split_required') return { ...out('stopped', 'split_required'), ...size };
|
|
227
232
|
if (r.status === 'reviewed' && findings.length > 0) return out('findings', null);
|
|
228
233
|
return out('unavailable', String(r.reason ?? r.status ?? 'not approved'));
|
|
229
234
|
}
|
|
@@ -231,6 +236,9 @@ export function classify(file, waited) {
|
|
|
231
236
|
/** @param {FileOutcome} o @returns {string} */
|
|
232
237
|
function label(o) {
|
|
233
238
|
if (o.result === 'findings') return `${o.findings.length} finding${o.findings.length === 1 ? '' : 's'}`;
|
|
239
|
+
if (o.result === 'stopped' && o.reason === 'split_required' && Number.isFinite(o.tokens_in) && Number.isFinite(o.budget)) {
|
|
240
|
+
return `${o.result}: ${o.reason} (~${o.tokens_in} tokens, budget ${o.budget}${o.section ? `; section ${JSON.stringify(o.section)}` : ''})`;
|
|
241
|
+
}
|
|
234
242
|
if (o.result === 'stopped' || o.result === 'unavailable') return `${o.result}: ${o.reason}`;
|
|
235
243
|
return o.result;
|
|
236
244
|
}
|
package/src/review/engine.mjs
CHANGED
|
@@ -19,7 +19,15 @@
|
|
|
19
19
|
* request with a non-repo-relative path is refused (`needs_file-refused`, never approval);
|
|
20
20
|
* 6. a session that ends in `timeout` is spawned ONCE more on the same packet and level
|
|
21
21
|
* (`session-retry.mjs`, B30); a second timeout is `unavailable: timeout`. The summary's
|
|
22
|
-
* `attempts` says how many ran
|
|
22
|
+
* `attempts` says how many ran;
|
|
23
|
+
* 7. a diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
|
|
24
|
+
* except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
|
|
25
|
+
* section packet runs the whole plan above (its own sessions, judge and stub guard, its own
|
|
26
|
+
* `review.plan` row with `section`), in file order, stopping at the first one that is not
|
|
27
|
+
* reviewed (the file is then `unavailable`). The file is approved only when EVERY section is;
|
|
28
|
+
* the findings of all sections are merged with ids `S<section>.<id>`, every session summary
|
|
29
|
+
* names its `section`, and `sections` lists them. A section that alone is still over the
|
|
30
|
+
* budget keeps the file `split_required` (plus `section`: its heading) — never truncated.
|
|
23
31
|
* Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
|
|
24
32
|
* the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
|
|
25
33
|
* `warning` finding.
|
|
@@ -32,7 +40,8 @@ import { resolveLevel } from '../config/known-ids.mjs';
|
|
|
32
40
|
import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
33
41
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
34
42
|
import { tierFor } from '../proof/tiers.mjs';
|
|
35
|
-
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
|
|
43
|
+
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
44
|
+
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
36
45
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
37
46
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
38
47
|
import { assertRowPath } from '../worker/ticket.mjs';
|
|
@@ -215,34 +224,99 @@ export async function reviewFile(input, deps) {
|
|
|
215
224
|
};
|
|
216
225
|
|
|
217
226
|
const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
218
|
-
const packets = reviewers.map((spec) => assemblePacket({ diff, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
|
|
219
|
-
const split = packets.find((p) => p.status === 'split_required');
|
|
220
|
-
if (split) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
|
|
221
|
-
const firstPacket = /** @type {any} */ (packets[0]);
|
|
222
|
-
await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: firstPacket?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0) });
|
|
223
|
-
const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
|
|
224
|
-
const sessions = done.map(publicSummary);
|
|
225
|
-
const failed = done.find((s) => s.status !== 'ok');
|
|
226
|
-
if (failed) {
|
|
227
|
-
await note(deps, { event: 'review.unavailable', reason: failed.reason, lens: failed.lens });
|
|
228
|
-
return { status: 'unavailable', reason: failed.reason, approved: false, ...head, sessions };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
let final = done[0];
|
|
232
227
|
const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
228
|
+
/** @param {import('./packet.mjs').FileDiff} part */
|
|
229
|
+
const packetsFor = (part) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
|
|
233
|
+
* @param {import('./packet.mjs').FileDiff} part @param {Array<Record<string, any>>} packets
|
|
234
|
+
* @param {Record<string, any>} [rowExtra] - added to the `review.plan` row (`section`).
|
|
235
|
+
* @returns {Promise<{status: 'reviewed' | 'unavailable', reason?: string, approved: boolean, summary?: string, findings?: Array<Record<string, any>>, sessions: Array<Record<string, any>>}>}
|
|
236
|
+
*/
|
|
237
|
+
const reviewPart = async (part, packets, rowExtra = {}) => {
|
|
238
|
+
await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: packets[0]?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0), ...rowExtra });
|
|
239
|
+
const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
|
|
240
|
+
const sessions = done.map(publicSummary);
|
|
241
|
+
const failed = done.find((s) => s.status !== 'ok');
|
|
242
|
+
if (failed) {
|
|
243
|
+
await note(deps, { event: 'review.unavailable', reason: failed.reason, lens: failed.lens, ...rowExtra });
|
|
244
|
+
return { status: 'unavailable', reason: failed.reason, approved: false, sessions };
|
|
241
245
|
}
|
|
246
|
+
let final = done[0];
|
|
247
|
+
if (judgeSpec) {
|
|
248
|
+
const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
|
|
249
|
+
const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
|
|
250
|
+
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
|
|
251
|
+
sessions.push(publicSummary(final));
|
|
252
|
+
if (final.status !== 'ok') {
|
|
253
|
+
await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge', ...rowExtra });
|
|
254
|
+
return { status: 'unavailable', reason: final.reason, approved: false, sessions };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const findings = final.review.findings;
|
|
258
|
+
const approved = final.review.passed === true && findings.every((/** @type {any} */ f) => f.severity === 'nit');
|
|
259
|
+
return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const packets = packetsFor(diff);
|
|
263
|
+
const split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
|
|
264
|
+
if (!split) {
|
|
265
|
+
const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
|
|
266
|
+
if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
|
|
267
|
+
return { status, approved, ...head, summary, findings, sessions };
|
|
268
|
+
}
|
|
269
|
+
if (!isMarkdownFile(diff.file)) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
|
|
270
|
+
|
|
271
|
+
// B54: a Markdown file over the budget is reviewed section by section
|
|
272
|
+
/** @param {import('./packet.mjs').FileDiff} part @returns {number} the diff-only packet tokens, largest over the lenses. */
|
|
273
|
+
const measure = (part) => Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens })));
|
|
274
|
+
const packed = packSections({ diff, budget: split.budget, measure });
|
|
275
|
+
/**
|
|
276
|
+
* A `split_required` outcome, its size always numeric (measured here when the packet lacks it).
|
|
277
|
+
* @param {import('./packet.mjs').FileDiff} part @param {{tokensIn?: unknown, budget?: unknown}} over @param {string | null} section
|
|
278
|
+
*/
|
|
279
|
+
const splitOutcome = (part, over, section) => ({
|
|
280
|
+
status: 'split_required',
|
|
281
|
+
approved: false,
|
|
282
|
+
...head,
|
|
283
|
+
tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part),
|
|
284
|
+
budget: Number.isFinite(over.budget) ? over.budget : split.budget,
|
|
285
|
+
...(section ? { section } : {}),
|
|
286
|
+
sessions: [],
|
|
287
|
+
});
|
|
288
|
+
if (packed.status !== 'ok') {
|
|
289
|
+
// a named section is measured on its own diff, never the whole file's
|
|
290
|
+
const group = packed.section ? sectionGroups(diff)?.find((g) => g.heading === packed.section) : undefined;
|
|
291
|
+
return splitOutcome(group ? group.diff : diff, packed, packed.section);
|
|
292
|
+
}
|
|
293
|
+
// never approve with no section: no packet ⇒ no session ⇒ split_required at the whole size
|
|
294
|
+
if (packed.sections.length === 0) return splitOutcome(diff, split, null);
|
|
295
|
+
// every section packet is assembled BEFORE any session runs. Packing and `assemblePacket` share
|
|
296
|
+
// one measure, so with the real packer none is ever not `ok`: this is a DEFENSIVE guard (a test
|
|
297
|
+
// reaches it through a mocked packer) — one that is not `ok` stops the file as split_required,
|
|
298
|
+
// sized on that section's own diff
|
|
299
|
+
const sectionPackets = packed.sections.map((section) => packetsFor(section.diff));
|
|
300
|
+
for (const [i, set] of sectionPackets.entries()) {
|
|
301
|
+
const over = /** @type {{tokensIn?: unknown, budget?: unknown} | undefined} */ (set.find((p) => p.status !== 'ok'));
|
|
302
|
+
if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)');
|
|
303
|
+
}
|
|
304
|
+
const sections = packed.sections.map((s) => ({ index: s.index, headings: s.headings, hunks: s.diff.hunks.length }));
|
|
305
|
+
/** @type {Array<Record<string, any>>} */
|
|
306
|
+
const sessions = [];
|
|
307
|
+
/** @type {Array<Record<string, any>>} */
|
|
308
|
+
const findings = [];
|
|
309
|
+
const summaries = [];
|
|
310
|
+
let approved = true;
|
|
311
|
+
for (const [i, section] of packed.sections.entries()) {
|
|
312
|
+
const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length });
|
|
313
|
+
sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
|
|
314
|
+
if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
|
|
315
|
+
approved = approved && part.approved;
|
|
316
|
+
findings.push(...sectionFindings(/** @type {Array<{id: string}>} */ (part.findings), section.index));
|
|
317
|
+
summaries.push(`S${section.index}: ${part.summary}`);
|
|
242
318
|
}
|
|
243
|
-
|
|
244
|
-
const approved = final.review.passed === true && findings.every((/** @type {any} */ f) => f.severity === 'nit');
|
|
245
|
-
return { status: 'reviewed', approved, ...head, summary: final.review.summary, findings, sessions };
|
|
319
|
+
return { status: 'reviewed', approved, ...head, summary: summaries.join('\n'), findings, sessions, sections };
|
|
246
320
|
}
|
|
247
321
|
|
|
248
322
|
/**
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -47,7 +47,17 @@
|
|
|
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`).
|
|
51
61
|
*
|
|
52
62
|
* A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
|
|
53
63
|
* (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
|
|
@@ -64,7 +74,8 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
64
74
|
import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
65
75
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
66
76
|
import { parseDiff } from './context.mjs';
|
|
67
|
-
import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
|
|
77
|
+
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
78
|
+
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
68
79
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
69
80
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
70
81
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
@@ -78,6 +89,9 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
78
89
|
* @property {string} [level] - the level the next fix is coded at.
|
|
79
90
|
* @property {string | null} [trigger] - the escalation trigger that moved the level, if any.
|
|
80
91
|
* @property {string} [reason] - `review_cap`, `l3_patch_exhausted`, a non-ok packet status (`split_required`), … for `stop`; the stub-guard reason for `retry`.
|
|
92
|
+
* @property {number} [tokens_in] - a `split_required` stop: the estimated tokens of the diff-only packet (B54).
|
|
93
|
+
* @property {number} [budget] - a `split_required` stop: the packet budget it exceeded (B54).
|
|
94
|
+
* @property {string} [section] - a `split_required` stop of a Markdown recheck: the heading of the section that alone is over (B54).
|
|
81
95
|
*/
|
|
82
96
|
|
|
83
97
|
/**
|
|
@@ -93,7 +107,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
93
107
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
94
108
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
95
109
|
* @property {NextStep | null} next
|
|
96
|
-
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck packet's
|
|
110
|
+
* @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
111
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
98
112
|
*/
|
|
99
113
|
|
|
@@ -123,6 +137,58 @@ async function note(deps, row) {
|
|
|
123
137
|
}
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Token counts over a round's sessions: a missing count (null/undefined) adds nothing, and no
|
|
142
|
+
* count at all stays null (one session without usage ⇒ null, as before B54).
|
|
143
|
+
* @param {number | null | undefined} a @param {number | null | undefined} b @returns {number | null}
|
|
144
|
+
*/
|
|
145
|
+
export const sum = (a, b) => (typeof a !== 'number' ? (typeof b === 'number' ? b : null) : typeof b === 'number' ? a + b : a);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The round's `last_packet` over its recheck packets (B54): the context lines add up; the context
|
|
149
|
+
* mode is the packets' one mode when they all agree, else `mixed`.
|
|
150
|
+
* @param {{context_mode: string, context_lines: number} | null} info
|
|
151
|
+
* @param {{contextMode: string, contextLines: number}} packet
|
|
152
|
+
* @returns {{context_mode: string, context_lines: number}}
|
|
153
|
+
*/
|
|
154
|
+
export function mergePacketInfo(info, packet) {
|
|
155
|
+
if (info === null) return { context_mode: packet.contextMode, context_lines: packet.contextLines };
|
|
156
|
+
return { context_mode: info.context_mode === packet.contextMode ? info.context_mode : 'mixed', context_lines: info.context_lines + packet.contextLines };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pack a Markdown recheck into section packets (B54), each sized with {@link recheckTokens} over
|
|
161
|
+
* its own diff and its own open findings. Each open finding belongs to ONE heading group — the one
|
|
162
|
+
* `sectionOf` picks over the groups (spanning, else nearest) — and is sized and listed in the
|
|
163
|
+
* packet that holds that group, so sizing and listing never disagree. No group at all (no hunk,
|
|
164
|
+
* or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
|
|
165
|
+
* a finding no packet holds is listed in the LAST packet — every open finding is listed once.
|
|
166
|
+
* @param {import('./packet.mjs').FileDiff} diff @param {Finding[]} open @param {number} budget
|
|
167
|
+
* @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
|
|
168
|
+
*/
|
|
169
|
+
export function packRecheck(diff, open, budget) {
|
|
170
|
+
const groups = sectionGroups(diff);
|
|
171
|
+
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
172
|
+
/** @type {Map<Finding, number>} */
|
|
173
|
+
const keyOf = new Map();
|
|
174
|
+
for (const f of open) {
|
|
175
|
+
const at = sectionOf(groups, f);
|
|
176
|
+
if (at >= 0) keyOf.set(f, groups[at].key);
|
|
177
|
+
}
|
|
178
|
+
/** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
|
|
179
|
+
const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
|
|
180
|
+
const packed = packSections({ diff, budget, measure: (part, set) => recheckTokens(part, openIn(set.map((g) => g.key))) });
|
|
181
|
+
if (packed.status !== 'ok') return packed;
|
|
182
|
+
const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
|
|
183
|
+
if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
|
|
184
|
+
// defensive: a finding no packet holds goes to the last packet (it is sized there by the check
|
|
185
|
+
// every packet passes before any session) — never dropped, never closed unseen
|
|
186
|
+
const listed = new Set(sections.flatMap((sec) => sec.open));
|
|
187
|
+
const last = /** @type {(typeof sections)[number]} */ (sections.at(-1));
|
|
188
|
+
for (const f of open) if (!listed.has(f)) last.open.push(f);
|
|
189
|
+
return { status: 'ok', sections };
|
|
190
|
+
}
|
|
191
|
+
|
|
126
192
|
/** @param {unknown} v @param {number} d @returns {number} `v` when a non-negative integer, else `d`. */
|
|
127
193
|
const int = (v, d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
|
|
128
194
|
|
|
@@ -247,6 +313,25 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
|
|
|
247
313
|
}
|
|
248
314
|
}
|
|
249
315
|
|
|
316
|
+
/** The heading of the open-findings list a recheck packet ends with. */
|
|
317
|
+
const OPEN_FINDINGS_HEAD = '## open findings\n';
|
|
318
|
+
|
|
319
|
+
/** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
|
|
320
|
+
export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
|
|
321
|
+
|
|
322
|
+
/** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
|
|
323
|
+
const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
|
|
327
|
+
* `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
|
|
328
|
+
* the check before any session use this ONE function.
|
|
329
|
+
* @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open @returns {number}
|
|
330
|
+
*/
|
|
331
|
+
export function recheckTokens(diff, open) {
|
|
332
|
+
return diffOnlyTokens({ diff, lens: 'recheck' }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
|
|
333
|
+
}
|
|
334
|
+
|
|
250
335
|
/**
|
|
251
336
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
252
337
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
@@ -255,8 +340,7 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
|
|
|
255
340
|
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
|
|
256
341
|
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
|
|
257
342
|
if (packet.status !== 'ok') return packet;
|
|
258
|
-
const
|
|
259
|
-
const text = `${packet.text}## open findings\n${listed.length > 0 ? listed.join('\n') : '(none)'}\n`;
|
|
343
|
+
const text = `${packet.text}${openFindingsText(open)}`;
|
|
260
344
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
261
345
|
}
|
|
262
346
|
|
|
@@ -373,14 +457,20 @@ function retry(state, kind, reason) {
|
|
|
373
457
|
* not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
|
|
374
458
|
* the must-write path — a failed write is a `retry`, never a silent stop.
|
|
375
459
|
* @param {FileState} state @param {LoopDeps} deps
|
|
376
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
460
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
461
|
+
* @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
|
|
377
462
|
* @returns {Promise<FileState>}
|
|
378
463
|
*/
|
|
379
|
-
async function stopBeforeRound(state, deps, kind, file,
|
|
380
|
-
const
|
|
464
|
+
async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
465
|
+
const reason = packet.status;
|
|
466
|
+
const size =
|
|
467
|
+
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
468
|
+
? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
|
|
469
|
+
: {};
|
|
470
|
+
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
381
471
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
382
472
|
state.status = 'stopped';
|
|
383
|
-
state.next = { action: 'stop', reason };
|
|
473
|
+
state.next = { action: 'stop', reason, ...size };
|
|
384
474
|
state.pending_kind = null;
|
|
385
475
|
return state;
|
|
386
476
|
}
|
|
@@ -434,34 +524,68 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
434
524
|
}
|
|
435
525
|
draft.open = [];
|
|
436
526
|
if (pending.length > 0) {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return
|
|
527
|
+
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
528
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
|
|
529
|
+
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
|
|
530
|
+
let parts = [{ index: null, heading: null, diff, open: pending }];
|
|
531
|
+
if (whole.status !== 'ok') {
|
|
532
|
+
// deterministic (the same hunks build the same packet): terminal, never a retry — except
|
|
533
|
+
// a Markdown file whose packet is `split_required`, rechecked section by section (B54);
|
|
534
|
+
// any other status stops as it always did
|
|
535
|
+
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget) : null;
|
|
536
|
+
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
|
|
537
|
+
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open }));
|
|
447
538
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
539
|
+
// EVERY packet is built and held to the budget BEFORE any session runs: a section over it
|
|
540
|
+
// stops the round as split_required (with its heading) before anything is spent
|
|
541
|
+
/** @type {Array<Record<string, any>>} */
|
|
542
|
+
const built = [];
|
|
543
|
+
for (const part of parts) {
|
|
544
|
+
if (part.index === null) {
|
|
545
|
+
built.push(whole);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
|
|
549
|
+
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
550
|
+
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
551
|
+
const size = recheckTokens(part.diff, part.open);
|
|
552
|
+
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
553
|
+
built.push(packet);
|
|
554
|
+
}
|
|
555
|
+
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
556
|
+
const resolved = new Set();
|
|
452
557
|
/** @type {Finding[]} */
|
|
453
558
|
const inside = [];
|
|
454
559
|
/** @type {Finding[]} */
|
|
455
560
|
const blocking = [];
|
|
456
|
-
for (const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
561
|
+
for (const [i, part] of parts.entries()) {
|
|
562
|
+
const packet = /** @type {any} */ (built[i]);
|
|
563
|
+
packetInfo = mergePacketInfo(packetInfo, packet);
|
|
564
|
+
const session = await recheckSession(packet, deps);
|
|
565
|
+
tokensIn = sum(tokensIn, session.tokens_in);
|
|
566
|
+
tokensOut = sum(tokensOut, session.tokens_out);
|
|
567
|
+
if (!session.verdict.ok) {
|
|
568
|
+
await note(deps, { event: 'review.unavailable', file: current.rel, lens: 'recheck', reason: session.verdict.reason, ...(part.index === null ? {} : { section: part.index }) });
|
|
569
|
+
return retry(state, kind, session.verdict.reason ?? 'unavailable');
|
|
570
|
+
}
|
|
571
|
+
const review = /** @type {Record<string, any>} */ (session.verdict.review);
|
|
572
|
+
const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
|
|
573
|
+
// only the findings listed in THIS packet can be resolved by its session
|
|
574
|
+
for (const f of part.open) if (resolvedIds.has(f.id)) resolved.add(f);
|
|
575
|
+
const found = /** @type {Finding[]} */ (review.findings ?? []);
|
|
576
|
+
for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
|
|
577
|
+
if (insideHunks(f, diff.hunks)) {
|
|
578
|
+
inside.push(f);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
draft.late.push(f);
|
|
582
|
+
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 });
|
|
583
|
+
if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
|
|
460
584
|
}
|
|
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
585
|
}
|
|
586
|
+
// every pending finding stays open unless the session that listed it resolved it
|
|
587
|
+
const stillOpen = pending.filter((f) => !resolved.has(f));
|
|
588
|
+
closed += pending.length - stillOpen.length;
|
|
465
589
|
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
590
|
newInHunks = triaged.fix_now.length;
|
|
467
591
|
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
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown section packets (block B54, issue #2 field note).
|
|
3
|
+
*
|
|
4
|
+
* A Markdown file (`.md`, `.markdown`) whose diff ALONE is over the packet budget
|
|
5
|
+
* (`split_required`) is reviewed section by section instead of refused:
|
|
6
|
+
* 1. the headings of the CURRENT file are found (`#`..`######`, ATX; a heading inside a fenced
|
|
7
|
+
* code block — ``` or ~~~ — is not one);
|
|
8
|
+
* 2. every hunk is cut at each heading line it carries on its new side (a context or `+` line), so
|
|
9
|
+
* a new or rewritten document — ONE hunk — splits too. Each piece is a valid unified-diff hunk
|
|
10
|
+
* with its own header (`@@ -a,b +c,d @@`, real line numbers on both sides); a hunk that holds no
|
|
11
|
+
* heading keeps its own header. A piece with no `+`/`-` line joins its neighbour;
|
|
12
|
+
* 3. the pieces are grouped by the nearest preceding heading (lines before the first heading are
|
|
13
|
+
* one group), and consecutive groups are packed, in file order, into section packets whose
|
|
14
|
+
* diff-only packet stays within the budget — splits happen only at headings.
|
|
15
|
+
* One group that alone is still over the budget ⇒ the file stays `split_required` (with that
|
|
16
|
+
* group's `tokensIn` and its heading): nothing is ever truncated. A diff with no hunk, or whose
|
|
17
|
+
* hunks do not parse one to one, never yields a section: it is `split_required` too — and so,
|
|
18
|
+
* fail-safe, is a deleted file or one with no heading whose diff is over the budget (one group). Any other file keeps
|
|
19
|
+
* `split_required`. Pure functions: no I/O.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parseDiff, splitLines } from './context.mjs';
|
|
23
|
+
|
|
24
|
+
/** @typedef {import('./packet.mjs').FileDiff} FileDiff */
|
|
25
|
+
|
|
26
|
+
/** @param {string} file @returns {boolean} whether `file` is reviewed by section when over budget. */
|
|
27
|
+
export const isMarkdownFile = (file) => /\.(md|markdown)$/i.test(file);
|
|
28
|
+
|
|
29
|
+
/** A fence opener/closer: up to 3 spaces, then 3+ backticks or tildes. */
|
|
30
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
31
|
+
/** An ATX heading: up to 3 spaces, 1–6 `#`, then a space/tab or the end of the line. */
|
|
32
|
+
const HEADING_RE = /^ {0,3}#{1,6}(?:[ \t]|$)/;
|
|
33
|
+
/** A heading is named in results by its text, cut at this many characters. */
|
|
34
|
+
const HEADING_MAX = 120;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The ATX headings of `content`, outside fenced code blocks and outside a YAML front matter block.
|
|
38
|
+
* @param {string | null} content @returns {Map<number, string>} 1-based line ⇒ the heading line, trimmed.
|
|
39
|
+
*/
|
|
40
|
+
export function markdownHeadings(content) {
|
|
41
|
+
/** @type {Map<number, string>} */
|
|
42
|
+
const out = new Map();
|
|
43
|
+
/** @type {{char: string, len: number} | null} */
|
|
44
|
+
let fence = null;
|
|
45
|
+
const lines = splitLines(content).map((raw) => (raw.endsWith('\r') ? raw.slice(0, -1) : raw)); // a CRLF file matches like an LF one
|
|
46
|
+
// YAML front matter (`---` on line 1 … a closing `---` or `...`) holds no heading
|
|
47
|
+
const close = lines[0] === '---' ? lines.findIndex((l, i) => i > 0 && (l === '---' || l === '...')) : -1;
|
|
48
|
+
lines.forEach((line, i) => {
|
|
49
|
+
if (i <= close) return;
|
|
50
|
+
const f = FENCE_RE.exec(line);
|
|
51
|
+
if (fence) {
|
|
52
|
+
// a closing fence: the same character, at least as long, nothing after it
|
|
53
|
+
if (f && f[1][0] === fence.char && f[1].length >= fence.len && f[2].trim() === '') fence = null;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// a backtick fence whose info string carries a backtick is inline code, not a fence
|
|
57
|
+
if (f && !(f[1][0] === '`' && f[2].includes('`'))) {
|
|
58
|
+
fence = { char: f[1][0], len: f[1].length };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (HEADING_RE.test(line)) out.set(i + 1, line.trim().slice(0, HEADING_MAX));
|
|
62
|
+
});
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} Piece - one hunk, or one heading-bounded part of a hunk.
|
|
68
|
+
* @property {number} key - the line of the nearest preceding heading (0 = before the first).
|
|
69
|
+
* @property {string | null} header - the original header line when the piece is the whole hunk.
|
|
70
|
+
* @property {number} oldCursor - the first old-side line the piece covers.
|
|
71
|
+
* @property {number} newCursor - the first new-side line the piece covers.
|
|
72
|
+
* @property {string[]} lines - the body lines (` `, `+`, `-`, `\`).
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** @param {Piece} p @returns {boolean} whether the piece changes anything. */
|
|
76
|
+
const changes = (p) => p.lines.some((l) => l.startsWith('+') || l.startsWith('-'));
|
|
77
|
+
|
|
78
|
+
/** @param {Map<number, string>} headings @param {number} line @returns {number} the last heading at or before `line`, else 0. */
|
|
79
|
+
function headingAtOrBefore(headings, line) {
|
|
80
|
+
let best = 0;
|
|
81
|
+
for (const h of headings.keys()) if (h <= line && h > best) best = h;
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cut one hunk at the heading lines on its new side.
|
|
87
|
+
* @param {{header: string, oldStart: number, oldLines: number, newStart: number, newLines: number}} hunk
|
|
88
|
+
* @param {string} headerLine - the hunk's header line as git printed it.
|
|
89
|
+
* @param {string[]} body @param {Map<number, string>} headings
|
|
90
|
+
* @returns {Piece[]}
|
|
91
|
+
*/
|
|
92
|
+
function cutHunk(hunk, headerLine, body, headings) {
|
|
93
|
+
let oldCursor = hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart;
|
|
94
|
+
let newCursor = hunk.newLines === 0 ? hunk.newStart + 1 : hunk.newStart;
|
|
95
|
+
const firstNew = body.length > 0 && (body[0].startsWith(' ') || body[0].startsWith('+'));
|
|
96
|
+
/** @type {Piece[]} */
|
|
97
|
+
const pieces = [{ key: headingAtOrBefore(headings, firstNew ? newCursor : newCursor - 1), header: headerLine, oldCursor, newCursor, lines: [] }];
|
|
98
|
+
for (const line of body) {
|
|
99
|
+
const onNew = line.startsWith(' ') || line.startsWith('+');
|
|
100
|
+
const current = /** @type {Piece} */ (pieces.at(-1));
|
|
101
|
+
if (onNew && headings.has(newCursor) && current.lines.some((l) => !l.startsWith('\\'))) {
|
|
102
|
+
pieces.push({ key: newCursor, header: null, oldCursor, newCursor, lines: [] });
|
|
103
|
+
}
|
|
104
|
+
/** @type {Piece} */ (pieces.at(-1)).lines.push(line);
|
|
105
|
+
if (line.startsWith(' ')) {
|
|
106
|
+
oldCursor += 1;
|
|
107
|
+
newCursor += 1;
|
|
108
|
+
} else if (line.startsWith('+')) {
|
|
109
|
+
newCursor += 1;
|
|
110
|
+
} else if (line.startsWith('-')) {
|
|
111
|
+
oldCursor += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (pieces.length === 1) return pieces;
|
|
115
|
+
// a piece that only carries context joins the next piece (or, when last, the previous one)
|
|
116
|
+
for (let i = 0; i < pieces.length && pieces.length > 1; ) {
|
|
117
|
+
const p = pieces[i];
|
|
118
|
+
if (changes(p)) {
|
|
119
|
+
i += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const next = pieces[i + 1];
|
|
123
|
+
if (next) pieces[i + 1] = { ...next, oldCursor: p.oldCursor, newCursor: p.newCursor, lines: [...p.lines, ...next.lines] };
|
|
124
|
+
else pieces[i - 1].lines.push(...p.lines);
|
|
125
|
+
pieces.splice(i, 1);
|
|
126
|
+
}
|
|
127
|
+
for (const p of pieces) p.header = null;
|
|
128
|
+
if (pieces.length === 1) pieces[0].header = headerLine;
|
|
129
|
+
return pieces;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {Piece} p @returns {string} the piece's header line. */
|
|
133
|
+
function headerOf(p) {
|
|
134
|
+
if (p.header !== null) return p.header;
|
|
135
|
+
const oldCount = p.lines.filter((l) => l.startsWith(' ') || l.startsWith('-')).length;
|
|
136
|
+
const newCount = p.lines.filter((l) => l.startsWith(' ') || l.startsWith('+')).length;
|
|
137
|
+
// an empty side names the line BEFORE the change, as git does (`-0,0` for a new file)
|
|
138
|
+
return `@@ -${oldCount === 0 ? p.oldCursor - 1 : p.oldCursor},${oldCount} +${newCount === 0 ? p.newCursor - 1 : p.newCursor},${newCount} @@`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @typedef {object} SectionGroup
|
|
143
|
+
* @property {number} key - the heading line (0 = before the first heading).
|
|
144
|
+
* @property {string} heading - the heading line, or `(before first heading)`.
|
|
145
|
+
* @property {FileDiff} diff - the group's own diff (the file header, then its pieces).
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Split a Markdown file's diff into heading groups (steps 1–3 of the module doc, before packing).
|
|
150
|
+
* @param {FileDiff} diff
|
|
151
|
+
* @returns {SectionGroup[] | null} in file order (one group when the file has no heading; none when
|
|
152
|
+
* the diff has no hunk); null when the `@@` lines of the text do not match `diff.hunks` one to
|
|
153
|
+
* one — a hunk is never dropped silently.
|
|
154
|
+
*/
|
|
155
|
+
export function sectionGroups(diff) {
|
|
156
|
+
const headings = markdownHeadings(diff.content);
|
|
157
|
+
const all = diff.diffText.split('\n');
|
|
158
|
+
if (all.at(-1) === '') all.pop();
|
|
159
|
+
const first = all.findIndex((l) => l.startsWith('@@'));
|
|
160
|
+
if (first < 0) return diff.hunks.length === 0 ? [] : null;
|
|
161
|
+
const fileHeader = all.slice(0, first);
|
|
162
|
+
/** @type {Piece[]} */
|
|
163
|
+
const pieces = [];
|
|
164
|
+
let h = 0;
|
|
165
|
+
for (let i = first; i < all.length; ) {
|
|
166
|
+
let end = i + 1;
|
|
167
|
+
while (end < all.length && !all[end].startsWith('@@')) end += 1;
|
|
168
|
+
const hunk = diff.hunks[h];
|
|
169
|
+
if (!hunk || !all[i].startsWith(hunk.header)) return null;
|
|
170
|
+
h += 1;
|
|
171
|
+
pieces.push(...cutHunk(hunk, all[i], all.slice(i + 1, end), headings));
|
|
172
|
+
i = end;
|
|
173
|
+
}
|
|
174
|
+
if (h !== diff.hunks.length) return null;
|
|
175
|
+
/** @type {Array<{key: number, pieces: Piece[]}>} */
|
|
176
|
+
const groups = [];
|
|
177
|
+
for (const p of pieces) {
|
|
178
|
+
const last = groups.at(-1);
|
|
179
|
+
if (last && last.key === p.key) last.pieces.push(p);
|
|
180
|
+
else groups.push({ key: p.key, pieces: [p] });
|
|
181
|
+
}
|
|
182
|
+
return groups.map((g) => ({ key: g.key, heading: headings.get(g.key) ?? '(before first heading)', diff: diffOf(diff, fileHeader, g.pieces) }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {FileDiff} diff @param {string[]} fileHeader @param {Piece[]} pieces
|
|
187
|
+
* @returns {FileDiff} the pieces as one file diff (the hunk list re-parsed from its own text).
|
|
188
|
+
*/
|
|
189
|
+
function diffOf(diff, fileHeader, pieces) {
|
|
190
|
+
const diffText = `${[...fileHeader, ...pieces.flatMap((p) => [headerOf(p), ...p.lines])].join('\n')}\n`;
|
|
191
|
+
return { file: diff.file, kind: diff.kind, diffText, content: diff.content, ...parseDiff(diffText) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @typedef {object} Section
|
|
196
|
+
* @property {number} index - 1-based, in file order.
|
|
197
|
+
* @property {string[]} headings - the headings the section covers.
|
|
198
|
+
* @property {number[]} keys - the heading lines of its groups (0 = before the first heading).
|
|
199
|
+
* @property {FileDiff} diff
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pack the heading groups into section packets within `budget` (step 3 of the module doc). A
|
|
204
|
+
* candidate's size is `measure(its diff, its groups)` (a recheck adds the open findings it lists
|
|
205
|
+
* for those groups). Never `ok` with no section: a diff with no hunk, or one whose hunks do not
|
|
206
|
+
* parse one to one, is `split_required` at `measure(diff, [])` with `section: null`.
|
|
207
|
+
* @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[]) => number}} opts -
|
|
208
|
+
* `measure`: the packet tokens of a candidate (the largest over the session lenses).
|
|
209
|
+
* @returns {{status: 'ok', sections: Section[]} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
|
|
210
|
+
*/
|
|
211
|
+
export function packSections({ diff, budget, measure }) {
|
|
212
|
+
const groups = sectionGroups(diff);
|
|
213
|
+
if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, []), budget, section: null };
|
|
214
|
+
/** @param {SectionGroup[]} set */
|
|
215
|
+
const size = (set) => measure(merge(diff, set), set);
|
|
216
|
+
/** @type {Section[]} */
|
|
217
|
+
const sections = [];
|
|
218
|
+
/** @type {SectionGroup[]} */
|
|
219
|
+
let open = [];
|
|
220
|
+
const flush = () => {
|
|
221
|
+
if (open.length === 0) return;
|
|
222
|
+
sections.push({ index: sections.length + 1, headings: open.map((g) => g.heading), keys: open.map((g) => g.key), diff: merge(diff, open) });
|
|
223
|
+
open = [];
|
|
224
|
+
};
|
|
225
|
+
for (const g of groups) {
|
|
226
|
+
const alone = size([g]);
|
|
227
|
+
if (alone > budget) return { status: 'split_required', tokensIn: alone, budget, section: g.heading };
|
|
228
|
+
if (open.length > 0 && size([...open, g]) > budget) flush();
|
|
229
|
+
open.push(g);
|
|
230
|
+
}
|
|
231
|
+
flush();
|
|
232
|
+
return { status: 'ok', sections };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {FileDiff} diff @param {SectionGroup[]} groups @returns {FileDiff} the groups as one diff.
|
|
237
|
+
*/
|
|
238
|
+
function merge(diff, groups) {
|
|
239
|
+
if (groups.length === 1) return groups[0].diff;
|
|
240
|
+
const bodies = groups.map((g) => {
|
|
241
|
+
const text = g.diff.diffText;
|
|
242
|
+
return text.slice(text.startsWith('@@') ? 0 : text.indexOf('\n@@') + 1);
|
|
243
|
+
});
|
|
244
|
+
const head = groups[0].diff.diffText;
|
|
245
|
+
const fileHeader = head.startsWith('@@') ? '' : head.slice(0, head.indexOf('\n@@') + 1);
|
|
246
|
+
const diffText = `${fileHeader}${bodies.join('')}`;
|
|
247
|
+
return { file: diff.file, kind: diff.kind, diffText, content: diff.content, ...parseDiff(diffText) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Where an open finding is listed in a sectioned recheck (each open finding in exactly ONE packet).
|
|
252
|
+
* Contract: with at least one section, always a valid position — the section whose hunks span the
|
|
253
|
+
* finding's first line, else the nearest section by line distance (ties go to the earlier one), so
|
|
254
|
+
* a finding outside every section (a late finding kept open by `review.late_findings: block`, a
|
|
255
|
+
* line that moved in the fix) still lands in one; -1 only when `sections` is empty.
|
|
256
|
+
* @param {ReadonlyArray<{diff: FileDiff}>} sections @param {{line_start: number}} finding
|
|
257
|
+
* @returns {number} the position in `sections`, or -1.
|
|
258
|
+
*/
|
|
259
|
+
export function sectionOf(sections, finding) {
|
|
260
|
+
let best = -1;
|
|
261
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
262
|
+
sections.forEach((s, i) => {
|
|
263
|
+
const start = Math.min(...s.diff.hunks.map((h) => h.newStart));
|
|
264
|
+
const end = Math.max(...s.diff.hunks.map((h) => h.newStart + Math.max(h.newLines, 1) - 1));
|
|
265
|
+
const line = finding.line_start;
|
|
266
|
+
const distance = line < start ? start - line : line > end ? line - end : 0;
|
|
267
|
+
if (distance < bestDistance) {
|
|
268
|
+
best = i;
|
|
269
|
+
bestDistance = distance;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return best;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A section's findings with ids unique per file: `S<index>.<id>`.
|
|
277
|
+
* @template {{id: string}} F
|
|
278
|
+
* @param {ReadonlyArray<F>} findings @param {number} index @returns {F[]}
|
|
279
|
+
*/
|
|
280
|
+
export function sectionFindings(findings, index) {
|
|
281
|
+
return findings.map((f) => ({ ...f, id: `S${index}.${f.id}` }));
|
|
282
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* last round reviewed and the current content), with the open findings by id;
|
|
16
16
|
* - `next: patch` (the L3 rung at the cap) ⇒ the `patch_check` round; a `retry` re-runs its round;
|
|
17
17
|
* - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
|
|
18
|
-
* repeats the stop — only the human moves on (§4.11 rule 5).
|
|
18
|
+
* repeats the stop — only the human moves on (§4.11 rule 5). A `split_required` result (round 1
|
|
19
|
+
* or a recheck stop) carries `tokens_in` and `budget` (B54).
|
|
19
20
|
* The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
|
|
20
21
|
* `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
|
|
21
22
|
* else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
|
|
@@ -83,6 +84,10 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
83
84
|
* @property {string | null} [trigger] - `review_stall`, `review_rounds`, `review_cap`, or null.
|
|
84
85
|
* @property {string} [stopped] - the stop reason (`review_cap`, `l3_patch_exhausted`, …).
|
|
85
86
|
* @property {number} [late] - late findings recorded so far for the file.
|
|
87
|
+
* @property {number} [tokens_in] - `split_required` (or `stopped: split_required`): the estimated
|
|
88
|
+
* tokens of the diff-only packet that was over the budget (B54).
|
|
89
|
+
* @property {number} [budget] - with `tokens_in`: the packet budget it exceeded (B54).
|
|
90
|
+
* @property {string} [section] - a Markdown file's section that alone is over the budget (B54).
|
|
86
91
|
*/
|
|
87
92
|
|
|
88
93
|
/**
|
|
@@ -302,7 +307,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
302
307
|
reopenForExtraRound(state, extraRounds, ctx.cfg);
|
|
303
308
|
|
|
304
309
|
if (state.status === 'stopped') {
|
|
305
|
-
return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
|
|
310
|
+
return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', ...stopSize(state), approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
|
|
306
311
|
}
|
|
307
312
|
|
|
308
313
|
/** @type {'full' | 'recheck' | 'patch_check'} */
|
|
@@ -404,7 +409,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
404
409
|
if (!approvalWritten) return { ...head, status: 'unavailable', reason: 'approval-not-written', approved: false };
|
|
405
410
|
return { ...head, status: 'reviewed', approved: true, findings: [] };
|
|
406
411
|
}
|
|
407
|
-
if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', approved: false, findings: state.open };
|
|
412
|
+
if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
|
|
408
413
|
return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
|
|
409
414
|
}
|
|
410
415
|
|
|
@@ -457,6 +462,18 @@ function loopFields(state, kind) {
|
|
|
457
462
|
return { round: state.round, ...(kind ? { kind } : {}), level: state.level, next: state.next, trigger: state.next?.trigger ?? null, late: state.late.length };
|
|
458
463
|
}
|
|
459
464
|
|
|
465
|
+
/**
|
|
466
|
+
* B54: a `split_required` stop's packet size (`tokens_in`, `budget` — null when unknown — and a
|
|
467
|
+
* Markdown `section`), from the state's `next`; nothing for any other stop.
|
|
468
|
+
* @param {import('../review/fixloop.mjs').FileState} state @returns {{tokens_in?: number | null, budget?: number | null, section?: string}}
|
|
469
|
+
*/
|
|
470
|
+
function stopSize(state) {
|
|
471
|
+
const next = state.next;
|
|
472
|
+
if (next?.reason !== 'split_required') return {};
|
|
473
|
+
const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
474
|
+
return { tokens_in: fin(next.tokens_in), budget: fin(next.budget), ...(typeof next.section === 'string' ? { section: next.section } : {}) };
|
|
475
|
+
}
|
|
476
|
+
|
|
460
477
|
/**
|
|
461
478
|
* S1 through `askJev` with the worker's key bound (the key stays in this process).
|
|
462
479
|
* @param {string} key @returns {import('../review/triage.mjs').JevAsk}
|
package/src/worker/loop.mjs
CHANGED
|
@@ -159,6 +159,21 @@ export function loopResult(outcome) {
|
|
|
159
159
|
return out;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* B54: the packet size of a `split_required` outcome (round 1 `status: split_required`, or a
|
|
164
|
+
* recheck `stopped: split_required`) — and ONLY of one: `tokens_in` and `budget` always (null when
|
|
165
|
+
* not a finite number), plus the Markdown `section` that alone is over the budget when one is named. The result
|
|
166
|
+
* file and the ledger `review.result` row carry the same fields.
|
|
167
|
+
* @param {Record<string, any>} outcome @returns {Record<string, any>}
|
|
168
|
+
*/
|
|
169
|
+
export function packetSize(outcome) {
|
|
170
|
+
if (outcome.status !== 'split_required' && outcome.stopped !== 'split_required') return {};
|
|
171
|
+
return { tokens_in: finiteOrNull(outcome.tokens_in), budget: finiteOrNull(outcome.budget), ...(typeof outcome.section === 'string' ? { section: outcome.section } : {}) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {unknown} v @returns {number | null} `v` when a finite number, else null. */
|
|
175
|
+
export const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
176
|
+
|
|
162
177
|
/**
|
|
163
178
|
* B52: the verdict a result is recorded with (its signed file and its ledger row), always with
|
|
164
179
|
* `approved` and `open`: `approved: true, open: []` only when the outcome said so AND it lists no
|
|
@@ -302,6 +317,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
302
317
|
engine: outcome.engine,
|
|
303
318
|
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
304
319
|
...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
|
|
320
|
+
...packetSize(outcome),
|
|
305
321
|
...loopResult(outcome),
|
|
306
322
|
sessions: outcome.sessions,
|
|
307
323
|
};
|
|
@@ -387,6 +403,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
387
403
|
...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
|
|
388
404
|
...(typeof result.trigger === 'string' ? { trigger: result.trigger } : {}),
|
|
389
405
|
...(typeof result.stopped === 'string' ? { stopped: result.stopped } : {}),
|
|
406
|
+
// B54: a split_required result says how far over the budget it was (the result file's fields)
|
|
407
|
+
...packetSize(result),
|
|
390
408
|
});
|
|
391
409
|
} catch {
|
|
392
410
|
// the signed result file stands; the gate reads it (§4.6)
|