@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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The second-level retry after repeated `schema` misses on ONE packet (block B55, issue #2).
3
+ *
4
+ * Field data: one L2 reviewer model answered `unavailable: schema` four times in a row on the same
5
+ * packet (round 1, its retry, a patch_check recheck, a fresh review), while the model configured in
6
+ * `review.second_levels.L2` reviewed that packet cleanly on its first try.
7
+ *
8
+ * What counts as "2 schema failures on the same packet": a stub-guard verdict `schema` from a
9
+ * session of the level's OWN ladder (never one from a second-level try) on the same packet — the
10
+ * same run, file, content hash and exact packet text (`packet_hash`, sha256 of the text) — at the
11
+ * same level and role (one level's misses or mark never count for, or block, another's). Every
12
+ * such miss leaves one signed `review.schema_invalid` row; the count is those rows (read through
13
+ * `history`, which only counts rows whose MAC verifies) plus the miss in hand. In practice: the
14
+ * round's first session is miss 1; the worker's existing retry of that round (`next: retry`, the
15
+ * next ticket re-runs the SAME packet) is miss 2, and that ticket then tries the second level once.
16
+ * A session that times out is retried first by `session-retry.mjs` and counts once, with its last
17
+ * result; a `needs_file` round or a Markdown section is another packet text, so another count, and
18
+ * so is a recheck whose pending set changed between tries (S1 `resolved` closed another finding,
19
+ * so the open-findings list differs): a different packet starts its own count, by design.
20
+ *
21
+ * The rows written here carry no `run`, `file` or `content_hash`: the worker's `writeRow` stamps
22
+ * them (`worker/loop.mjs`: `{...row, block, file, content_hash: ticket.content_hash}`, signed with
23
+ * the run key), and the worker's history reads them back by the same ticket values — so outside
24
+ * the worker (no history) nothing is ever tried.
25
+ *
26
+ * At most ONCE per packet: a signed `review.schema_fallback` row is written BEFORE the second level
27
+ * is spawned, and any such row for the packet (whatever its outcome) stops every later try; when
28
+ * that row cannot be written nothing is spawned. The second level is `review.second_levels.<level>`
29
+ * only — nothing is tried without one; when every model of it is the one that just failed, the
30
+ * mark says `refused: same-model` and nothing is spawned. The try goes through the same `spawn` as
31
+ * any session, so the budget gate (`budget.usd`, B33) refuses it like any other (its own
32
+ * `budget.refused` row; the review is `unavailable: budget`). For a closed-book role every Codex
33
+ * model of the second level (its own and its ladder) is dropped unless
34
+ * `review.allow_open_book_codex`; when none is left the mark says `refused: closed-book` and
35
+ * nothing is spawned — the spawner's own refusal stays as it was. Its answer goes
36
+ * through the same stub guard; a valid one is a normal answer, a failed one leaves the file
37
+ * unavailable as before.
38
+ *
39
+ * Every schema miss (second-level ones included) is logged as `review.schema_invalid {lens, role,
40
+ * level, provider, model, fallback_step, packet_hash, second_level, ...diagnosis}` — a STRUCTURAL
41
+ * diagnosis of the answer, never its text ({@link diagnoseAnswer}): its size and kind, for JSON the
42
+ * top-level keys with their JSON types and the finding-schema errors (path, keyword, a few
43
+ * schema-side params, Ajv's message), for non-JSON the kind of parse error and its byte offset.
44
+ * Text a model copies from the packet into a JSON string can never reach the ledger this way.
45
+ *
46
+ * Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
47
+ * `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
48
+ * tickets for that file, so one packet's history is read and written by one ticket at a time. The
49
+ * read-history → write-mark section here also runs under an in-process lock per (packet hash,
50
+ * level, role), so even the same file in two blocks cannot take a packet's one try twice.
51
+ *
52
+ * No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
53
+ * throw: a history that cannot be read disables the try (with one warning line), and a
54
+ * second-level spawn that throws is a session with no result (`exit`) after its mark row.
55
+ */
56
+
57
+ import { createHash } from 'node:crypto';
58
+ import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
59
+ import { resolveLevel } from '../config/known-ids.mjs';
60
+ import { logWarning } from '../util/error-log.mjs';
61
+ import { keyedLock } from '../util/locks.mjs';
62
+ import { schemaErrorsOf } from './validate-review.mjs';
63
+
64
+ /** Schema misses of the level's own ladder on one packet before the second level is tried. */
65
+ export const SCHEMA_FAILURES_BEFORE_FALLBACK = 2;
66
+
67
+ /** At most this many schema errors are kept in a diagnosis (`errors_total` has the full count). */
68
+ export const DIAGNOSIS_MAX_ERRORS = 20;
69
+ /** At most this many top-level keys are kept in a diagnosis (the rest counted in `other_keys`). */
70
+ export const DIAGNOSIS_MAX_KEYS = 30;
71
+ /** Ajv's message, and a parse error, are kept up to this length. */
72
+ const MESSAGE_MAX = 120;
73
+ /** A key or path segment is shown only when it is this plain; any other is `*` (it could be content). */
74
+ const PLAIN_KEY = /^[A-Za-z0-9_.-]{1,40}$/;
75
+ /** The Ajv params kept: they come from the schema, never from the answer's data. */
76
+ const KEPT_PARAMS = Object.freeze(['missingProperty', 'additionalProperty', 'type', 'limit', 'comparison', 'format']);
77
+
78
+ /**
79
+ * @typedef {(packetHash: string, who: {level: string, role: string}) => Promise<{failures: number, fellBack: boolean}>} SchemaHistory -
80
+ * the signed history of ONE packet (`packetHash`) at one level and role in this run for this
81
+ * file at this content hash: `failures` = `review.schema_invalid` rows of the level's own ladder
82
+ * (`second_level` not true), `fellBack` = a `review.schema_fallback` row exists. Built by the
83
+ * worker; absent ⇒ no second-level try.
84
+ */
85
+
86
+ /** @param {string} text @returns {string} the packet's identity: sha256 hex of its exact text. */
87
+ export function packetHash(text) {
88
+ return createHash('sha256').update(text, 'utf8').digest('hex');
89
+ }
90
+
91
+ /** @param {unknown} v @returns {string} the JSON type of `v`. */
92
+ function jsonType(v) {
93
+ if (v === null) return 'null';
94
+ if (Array.isArray(v)) return 'array';
95
+ if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
96
+ return typeof v;
97
+ }
98
+
99
+ /** @param {unknown} key @returns {string} the key when plain (or an array index), else `*`. */
100
+ function plainKey(key) {
101
+ if (typeof key === 'number') return String(key);
102
+ return typeof key === 'string' && PLAIN_KEY.test(key) ? key : '*';
103
+ }
104
+
105
+ /** @param {string} pointer @returns {string} the JSON pointer with every non-plain segment as `*`. */
106
+ function plainPath(pointer) {
107
+ if (pointer === '') return '';
108
+ const segments = pointer.slice(1).split('/').map((seg) => (/^\d+$/.test(seg) ? seg : plainKey(seg.replace(/~1/g, '/').replace(/~0/g, '~'))));
109
+ return `/${segments.join('/')}`;
110
+ }
111
+
112
+ /**
113
+ * A parse error without any of the text: V8 quotes the input after a `"` and names the bad
114
+ * character, so the message is cut at its first quote, the token and the line/column dropped.
115
+ * @param {string} message @returns {string}
116
+ */
117
+ function plainParseError(message) {
118
+ const cut = message
119
+ .split('"')[0]
120
+ .replace(/Unexpected token '.*?'/, 'Unexpected token')
121
+ .replace(/\s*\(line \d+ column \d+\)/, '')
122
+ .replace(/[,\s]+$/, '');
123
+ return cut.slice(0, MESSAGE_MAX);
124
+ }
125
+
126
+ /**
127
+ * The structural diagnosis of a schema miss (B55) — never the answer's text: `answer_bytes`,
128
+ * `answer_kind` (`json`, `not-json`, `empty`); for JSON `top_type`, `top_keys` (plain keys ⇒ their
129
+ * JSON type, at most {@link DIAGNOSIS_MAX_KEYS}; any other key only counted in `other_keys`),
130
+ * `schema_errors` (at most {@link DIAGNOSIS_MAX_ERRORS}, from the stub guard's own validator:
131
+ * `path` with non-plain segments as `*`, `keyword`, the schema-side `params`, Ajv's `message` ≤ 120
132
+ * chars) and `errors_total`; for non-JSON `parse_error` (V8's message cut before any quoted input)
133
+ * and `parse_offset` (bytes; null when V8 gives no position).
134
+ * @param {Record<string, any> | null} res - the session result (`text`, else `answer`).
135
+ * @returns {Record<string, any>}
136
+ */
137
+ export function diagnoseAnswer(res) {
138
+ const text = typeof res?.text === 'string' && res.text.length > 0 ? res.text : typeof res?.answer === 'string' ? res.answer : '';
139
+ const answer_bytes = Buffer.byteLength(text);
140
+ /** @type {unknown} */
141
+ let value = res?.answer !== undefined && res?.answer !== null && typeof res.answer !== 'string' ? res.answer : undefined;
142
+ if (value === undefined) {
143
+ if (text.trim().length === 0) return { answer_bytes, answer_kind: 'empty' };
144
+ try {
145
+ value = JSON.parse(text);
146
+ } catch (err) {
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ const at = /at position (\d+)/.exec(message);
149
+ const parse_offset = at ? Buffer.byteLength(text.slice(0, Number(at[1]))) : /end of JSON input/.test(message) ? answer_bytes : null;
150
+ return { answer_bytes, answer_kind: 'not-json', parse_error: plainParseError(message), parse_offset };
151
+ }
152
+ }
153
+ /** @type {Record<string, string>} */
154
+ const top_keys = {};
155
+ let other_keys = 0;
156
+ if (jsonType(value) === 'object') {
157
+ for (const [k, v] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
158
+ if (PLAIN_KEY.test(k) && k !== '__proto__' && Object.keys(top_keys).length < DIAGNOSIS_MAX_KEYS) top_keys[k] = jsonType(v);
159
+ else other_keys += 1;
160
+ }
161
+ }
162
+ const errors = schemaErrorsOf(value);
163
+ const schema_errors = errors.slice(0, DIAGNOSIS_MAX_ERRORS).map((e) => {
164
+ /** @type {Record<string, unknown>} */
165
+ const given = e.params ?? {};
166
+ /** @type {Record<string, unknown>} */
167
+ const params = {};
168
+ for (const name of KEPT_PARAMS) {
169
+ const v = given[name];
170
+ if (v === undefined) continue;
171
+ if (name === 'missingProperty' || name === 'additionalProperty') params[name] = plainKey(v);
172
+ else if (typeof v === 'number' || typeof v === 'string') params[name] = v;
173
+ else if (Array.isArray(v)) params[name] = v.filter((x) => typeof x === 'string');
174
+ }
175
+ return { path: plainPath(e.instancePath ?? ''), keyword: e.keyword, params, message: String(e.message ?? '').slice(0, MESSAGE_MAX) };
176
+ });
177
+ return { answer_bytes, answer_kind: 'json', top_type: jsonType(value), top_keys, other_keys, schema_errors, errors_total: errors.length };
178
+ }
179
+
180
+ /**
181
+ * The config a second-level try runs with: `levels.<level>` replaced by
182
+ * `review.second_levels.<level>`. Every model of that level — its own, then its fallback ladder —
183
+ * gets its effective provider (the entry's own, else the level's: its `provider`, else
184
+ * `review.second_provider`, else the top-level one), and every one equal to the model that just
185
+ * failed is dropped; for a closed-book `role` every model Codex cannot run closed-book is dropped
186
+ * too (unless `review.allow_open_book_codex`). The first one left runs, the rest are its ladder.
187
+ * `ok: false` with `no-second-level` when none is configured for the level, `same-model` when
188
+ * nothing but the failed model is configured, `closed-book` when only refused models are left.
189
+ * @param {Record<string, any>} cfg @param {string} level
190
+ * @param {{provider: string | null, model: string | null}} from - the model that missed.
191
+ * @param {string} [role] - the session's role (closed-book check); absent ⇒ no such check.
192
+ * @returns {{ok: true, cfg: Record<string, any>, provider: string, model: string} | {ok: false, reason: 'no-second-level' | 'same-model' | 'closed-book'}}
193
+ */
194
+ export function secondLevelFor(cfg, level, from, role) {
195
+ const override = cfg?.review?.second_levels?.[level] ?? null;
196
+ if (!override || typeof override !== 'object' || typeof override.model !== 'string' || override.model.length === 0) return { ok: false, reason: 'no-second-level' };
197
+ const levelProvider = override.provider ?? cfg?.review?.second_provider ?? cfg?.provider;
198
+ if (typeof levelProvider !== 'string' || levelProvider.length === 0) return { ok: false, reason: 'no-second-level' };
199
+ const { fallback: ladder, effort: _effort, ...own } = override;
200
+ /** @type {Array<{provider: string, model: string, effort?: string}>} */
201
+ const models = [override, ...(Array.isArray(ladder) ? ladder : [])]
202
+ .filter((e) => e && typeof e === 'object' && typeof e.model === 'string' && e.model.length > 0)
203
+ .map((e) => ({ provider: typeof e.provider === 'string' && e.provider.length > 0 ? e.provider : levelProvider, model: e.model, ...(typeof e.effort === 'string' ? { effort: e.effort } : {}) }))
204
+ .filter((e) => !(e.provider === from.provider && e.model === from.model));
205
+ if (models.length === 0) return { ok: false, reason: 'same-model' };
206
+ const openBook = openBookCodexAllowed(cfg);
207
+ const runnable = role === undefined ? models : models.filter((e) => !closedBookRefused(e.provider, role, openBook));
208
+ if (runnable.length === 0) return { ok: false, reason: 'closed-book' };
209
+ const [first, ...rest] = runnable;
210
+ const entry = { ...own, provider: first.provider, model: first.model, ...(first.effort ? { effort: first.effort } : {}), fallback: rest };
211
+ const next = { ...cfg, levels: { ...cfg.levels, [level]: entry } };
212
+ try {
213
+ resolveLevel(next, /** @type {any} */ (level));
214
+ } catch {
215
+ return { ok: false, reason: 'no-second-level' };
216
+ }
217
+ return { ok: true, cfg: next, provider: first.provider, model: first.model };
218
+ }
219
+
220
+ /**
221
+ * Log a schema miss and, when it is the packet's second (see the module doc), try the second level
222
+ * once. Never throws (see the module doc).
223
+ * @param {object} p
224
+ * @param {Record<string, any> | null} p.res - the missed session's result.
225
+ * @param {{lens: string, role: string, level: string}} p.session
226
+ * @param {string} p.packetText - the exact packet the session read (still on disk for `spawnAt`).
227
+ * @param {Record<string, any>} p.cfg - the config the missed session ran with.
228
+ * @param {boolean} p.allowed - false where a second-level try is never made (consensus mode).
229
+ * @param {SchemaHistory | undefined} p.history
230
+ * @param {((row: Record<string, any>) => Promise<unknown>) | undefined} p.writeRow - throws on a
231
+ * failed write; absent ⇒ nothing is logged or tried.
232
+ * @param {(cfg: Record<string, any>) => Promise<{res: Record<string, any> | null, attempts: number}>} p.spawnAt
233
+ * - the same session on the same packet with another config (timeout retry included).
234
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}} | null>}
235
+ * the second level's result, or null when none was spawned.
236
+ */
237
+ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt }) {
238
+ if (!writeRow) return null;
239
+ const write = writeRow;
240
+ const hash = packetHash(packetText);
241
+ const from = { provider: typeof res?.provider === 'string' ? res.provider : null, model: typeof res?.model === 'string' ? res.model : null };
242
+ /** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
243
+ let go = null;
244
+ try {
245
+ go = await keyedLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
246
+ // the history is read BEFORE this miss is logged: failures = signed earlier misses + this one
247
+ let seen = null;
248
+ if (allowed && history) {
249
+ try {
250
+ seen = await history(hash, { level: session.level, role: session.role });
251
+ } catch {
252
+ seen = null; // unreadable history ⇒ no try (fail closed), one warning line
253
+ await logWarning({ warning: 'review_schema_history', message: 'the schema-miss history could not be read; no second-level try' }).catch(() => null);
254
+ }
255
+ }
256
+ try {
257
+ await write({ event: 'review.schema_invalid', lens: session.lens, role: session.role, level: session.level, ...from, fallback_step: res?.fallback_step ?? 0, packet_hash: hash, second_level: false, ...diagnoseAnswer(res) });
258
+ } catch {
259
+ // the log is best-effort; the count then misses this one (fewer tries, never more)
260
+ }
261
+ if (!seen || seen.fellBack || seen.failures + 1 < SCHEMA_FAILURES_BEFORE_FALLBACK) return null;
262
+ const second = secondLevelFor(cfg, session.level, from, session.role);
263
+ const to = `review.second_levels.${session.level}`;
264
+ /** @type {string | null} */
265
+ let refused = null;
266
+ if (second.ok === false) {
267
+ if (second.reason === 'no-second-level') return null;
268
+ refused = second.reason; // `same-model` or `closed-book`: nothing of the level may run
269
+ }
270
+ await write({
271
+ event: 'review.schema_fallback',
272
+ lens: session.lens,
273
+ role: session.role,
274
+ level: session.level,
275
+ packet_hash: hash,
276
+ from_provider: from.provider,
277
+ from_model: from.model,
278
+ to,
279
+ to_provider: second.ok ? second.provider : null,
280
+ to_model: second.ok ? second.model : null,
281
+ attempts: seen.failures + 1,
282
+ ...(refused ? { refused } : {}),
283
+ }); // the once-per-packet mark: a failed write throws ⇒ no try
284
+ return !second.ok ? null : { cfg: second.cfg, to: { level: to, provider: second.provider, model: second.model } };
285
+ });
286
+ } catch {
287
+ return null;
288
+ }
289
+ if (!go) return null;
290
+ await logWarning({ warning: 'review_schema_fallback', message: 'a review answer failed the schema twice on one packet; the second level was tried once' }).catch(() => null);
291
+ try {
292
+ const out = await spawnAt(go.cfg);
293
+ return { res: out.res, attempts: out.attempts, to: go.to };
294
+ } catch {
295
+ // a throwing spawn is a session with no result: `exit` through the stub guard, never approval
296
+ return { res: null, attempts: 1, to: go.to };
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Log a schema miss of the second-level try itself (never counted, never tried again).
302
+ * @param {{res: Record<string, any> | null, session: {lens: string, role: string, level: string}, packetText: string, writeRow: ((row: Record<string, any>) => Promise<unknown>) | undefined}} p
303
+ */
304
+ export async function logSecondLevelMiss({ res, session, packetText, writeRow }) {
305
+ if (!writeRow) return;
306
+ try {
307
+ await writeRow({
308
+ event: 'review.schema_invalid',
309
+ lens: session.lens,
310
+ role: session.role,
311
+ level: session.level,
312
+ provider: typeof res?.provider === 'string' ? res.provider : null,
313
+ model: typeof res?.model === 'string' ? res.model : null,
314
+ fallback_step: res?.fallback_step ?? 0,
315
+ packet_hash: packetHash(packetText),
316
+ second_level: true,
317
+ ...diagnoseAnswer(res),
318
+ });
319
+ } catch {
320
+ // best-effort
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The worker's {@link SchemaHistory} over the run's ledger: for the packet, level and role asked
326
+ * about, rows of `runId` for `file` at `contentHash` with that `packet_hash`, `level` and `role`
327
+ * whose MAC verifies (`verify`; a row that does not verify, or whose check throws, counts for
328
+ * nothing). A miss on another packet of the same content, or at another level, never counts.
329
+ * @param {{readRows: () => Promise<Array<Record<string, any>>>, verify: (row: Record<string, any>) => boolean, runId: string, file: string, contentHash: string}} opts
330
+ * @returns {SchemaHistory}
331
+ */
332
+ export function ledgerSchemaHistory({ readRows, verify, runId, file, contentHash }) {
333
+ return async (hash, who) => {
334
+ const rows = (await readRows()).filter((r) => r?.run === runId && r.file === file && r.content_hash === contentHash && r.packet_hash === hash && r.level === who.level && r.role === who.role);
335
+ let failures = 0;
336
+ let fellBack = false;
337
+ for (const r of rows) {
338
+ if (r.event !== 'review.schema_invalid' && r.event !== 'review.schema_fallback') continue;
339
+ let ok = false;
340
+ try {
341
+ ok = verify(r);
342
+ } catch {
343
+ ok = false;
344
+ }
345
+ if (!ok) continue;
346
+ if (r.event === 'review.schema_fallback') fellBack = true;
347
+ else if (r.second_level !== true) failures += 1;
348
+ }
349
+ return { failures, fellBack };
350
+ };
351
+ }
@@ -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
+ }
@@ -37,6 +37,17 @@ const validateSchema = (() => {
37
37
  return new Ajv2020({ allErrors: true, strict: false }).compile(body);
38
38
  })();
39
39
 
40
+ /**
41
+ * The finding-schema errors of `value` from the SAME validator the stub guard uses (B55): Ajv's
42
+ * raw error objects, every one (`allErrors`). Callers must strip anything that can carry the
43
+ * answer's content (`review/schema-fallback.mjs`); this returns them as Ajv gives them.
44
+ * @param {unknown} value @returns {Array<import('ajv').ErrorObject>} empty when it validates.
45
+ */
46
+ export function schemaErrorsOf(value) {
47
+ if (validateSchema(value) === true) return [];
48
+ return [...(validateSchema.errors ?? [])];
49
+ }
50
+
40
51
  /** @param {Record<string, any> | undefined} cfg @returns {number} */
41
52
  export function minTokensOut(cfg) {
42
53
  const v = cfg?.review?.min_tokens_out;