@codedology/code-forge 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +14 -0
- package/docs/reference/blocks.json +33 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/binnacle.mjs +29 -1
- package/src/cli/review-file.mjs +9 -1
- package/src/cli/review.mjs +11 -3
- package/src/review/engine.mjs +192 -46
- package/src/review/fixloop.mjs +212 -45
- package/src/review/packet.mjs +32 -7
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +282 -0
- package/src/review/validate-review.mjs +11 -0
- package/src/session/spawn.mjs +3 -86
- package/src/util/diagnostic-tail.mjs +95 -0
- package/src/worker/engine.mjs +45 -4
- package/src/worker/loop.mjs +18 -0
|
@@ -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;
|