@codedology/code-forge 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +18 -0
- package/docs/reference/blocks.json +234 -1
- package/package.json +1 -1
- package/skill/references/code.md +15 -6
- package/skill/references/ledger.md +1 -1
- package/skill/references/review.md +21 -3
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -2
- package/src/cli/block.mjs +137 -10
- package/src/cli/review-file.mjs +264 -13
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +89 -2
- package/src/ledger/write.mjs +7 -1
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +375 -78
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +3 -1
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
* another block of the run that is still OPEN owns it — the gate cannot tell which coder wrote
|
|
12
12
|
* it, so the close WARNs naming each such file — or a closed one owns it and its gate approved
|
|
13
13
|
* exactly the current content);
|
|
14
|
-
* - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash
|
|
14
|
+
* - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash —
|
|
15
|
+
* unless (B70) it is `reviewed_by_waiver`: a signed `review.round` row for that hash exists (a
|
|
16
|
+
* session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
|
|
17
|
+
* the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
|
|
18
|
+
* not qualify, though it still refuses) and names at least one finding, and every finding it
|
|
19
|
+
* names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
|
|
20
|
+
* accepts (the same rule as below: a critical only by `by: human`). Such a file is never
|
|
21
|
+
* approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
|
|
15
22
|
* - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
|
|
16
23
|
* - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
|
|
17
24
|
* the block (ledger order) for the same file and hash refuses it ({@link denialOf}: a
|
|
@@ -37,9 +44,20 @@
|
|
|
37
44
|
* `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
|
|
38
45
|
* AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
|
|
39
46
|
* by hand and it was reviewed); a finding already refused as `not_approved` for that file is
|
|
40
|
-
* not repeated
|
|
47
|
+
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
48
|
+
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
49
|
+
* re-decompose`, `stop-exits.mjs`);
|
|
41
50
|
* - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
|
|
42
51
|
* red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
|
|
52
|
+
* B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
|
|
53
|
+
* ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
|
|
54
|
+
* `review.answered {file, content_hash, finding, by_file, by_hash, ticket}` row the worker wrote
|
|
55
|
+
* after a reviewer judged a fix in another owned file: only when its MAC verifies, it is of this
|
|
56
|
+
* run, the finding is not `proof`, the severity read is critical, warning, nit or none (an unknown
|
|
57
|
+
* one stays the human's), and BOTH `content_hash` (the source) and `by_hash` (the answering file
|
|
58
|
+
* `by_file`) equal those files' current hashes — a change to either makes the answer stale. An
|
|
59
|
+
* answer can clear a critical (a reviewer judged the fix; it is not a waiver). It never approves
|
|
60
|
+
* the answering file, which needs its own review.
|
|
43
61
|
* Rulings and waivers only count when their MAC verifies AND name the finding's file exactly
|
|
44
62
|
* (`file` + `finding`; a finding id is scoped to one file, so a waiver of `F1` in `a.mjs` never
|
|
45
63
|
* clears `F1` in `b.mjs`); a late finding counts whether or not it is signed (fail closed).
|
|
@@ -74,6 +92,14 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
74
92
|
import { tierFor } from '../proof/tiers.mjs';
|
|
75
93
|
import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
76
94
|
import { verifyResult } from '../worker/queue.mjs';
|
|
95
|
+
import { stopExitsLine } from './stop-exits.mjs';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
|
|
99
|
+
* B70: a file that counts as reviewed (never approved) because every finding its review left open was waived.
|
|
100
|
+
* B71: or answered — `findings` are the waived ids, `answered` (present only when there is one)
|
|
101
|
+
* the ids a signed `review.answered` row clears; `by` is null when every id was answered.
|
|
102
|
+
*/
|
|
77
103
|
|
|
78
104
|
/**
|
|
79
105
|
* @typedef {{code: 'no_changes' | 'unowned_change' | 'not_approved' | 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
|
|
@@ -96,59 +122,53 @@ const verified = (row, key) => verifyRow(row, key).ok;
|
|
|
96
122
|
* but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
|
|
97
123
|
* caller that leaves one out gets a TypeError, never a silent default that skips the proof.
|
|
98
124
|
* `runId` is required too: the proof check matches its rows on this run only (fix round 4).
|
|
99
|
-
* @returns {{ok: boolean, refusals: Refusal[]}}
|
|
125
|
+
* @returns {{ok: boolean, refusals: Refusal[], waivedAll: WaivedAll[]}} `waivedAll` (B70): the files
|
|
126
|
+
* that passed as `reviewed_by_waiver` — empty whenever the check refuses.
|
|
100
127
|
* @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
|
|
101
128
|
*/
|
|
102
129
|
export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
|
|
103
130
|
if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
|
|
104
131
|
if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
|
|
105
132
|
if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
|
|
106
|
-
const
|
|
133
|
+
const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
|
|
134
|
+
// B71: the current hashes an answer is checked against — every file the close hashed (the
|
|
135
|
+
// proof set too, so `--no-require-reviews` does not hide the answering file's hash)
|
|
136
|
+
/** @type {Map<string, string>} */
|
|
137
|
+
const current = new Map();
|
|
138
|
+
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
139
|
+
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
107
140
|
/** @type {Refusal[]} */
|
|
108
141
|
const refusals = [];
|
|
109
142
|
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
110
|
-
const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
|
|
111
|
-
/**
|
|
112
|
-
* B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
|
|
113
|
-
* `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
|
|
114
|
-
* so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
|
|
115
|
-
* `latest` is the latest refusal for (file, hash) whether or not an approval exists.
|
|
116
|
-
* @param {string} file @param {string} hash
|
|
117
|
-
* @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null}}
|
|
118
|
-
*/
|
|
119
|
-
const latestOf = (file, hash) => {
|
|
120
|
-
let lastApproval = -1;
|
|
121
|
-
/** @type {Denial | null} */
|
|
122
|
-
let denial = null;
|
|
123
|
-
let lastDenial = -1;
|
|
124
|
-
mine.forEach((r, i) => {
|
|
125
|
-
if (r.file !== file) return;
|
|
126
|
-
if (r.event === 'review.approved') {
|
|
127
|
-
if (r.content_hash === hash && verified(r, key)) lastApproval = i;
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (r.content_hash !== hash && r.content_hash !== undefined) return;
|
|
131
|
-
const d = denialOf(r, resultFiles);
|
|
132
|
-
if (d !== null) {
|
|
133
|
-
denial = d;
|
|
134
|
-
lastDenial = i;
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial };
|
|
138
|
-
};
|
|
139
143
|
const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
|
|
140
144
|
|
|
141
145
|
for (const file of unowned) refusals.push({ code: 'unowned_change', file, detail: `${file} changed but no block of the run owns it — block claim it, or put it back` });
|
|
142
146
|
|
|
143
147
|
if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
|
|
144
148
|
|
|
149
|
+
const rulings = signedOf('review.late_ruling');
|
|
150
|
+
/** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
|
|
151
|
+
const orInvalid = (refusal, finding, invalid) =>
|
|
152
|
+
invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
|
|
153
|
+
|
|
154
|
+
/** @type {WaivedAll[]} */
|
|
155
|
+
const waivedAll = [];
|
|
156
|
+
|
|
145
157
|
/** @type {Array<{file: string, denial: Denial}>} */
|
|
146
158
|
const stale = [];
|
|
147
159
|
for (const { file, content_hash } of files) {
|
|
148
160
|
const approvals = approvedFor(file, content_hash);
|
|
149
161
|
const signedApproval = approvals.some((r) => verified(r, key));
|
|
150
|
-
if (approvals.length === 0)
|
|
151
|
-
|
|
162
|
+
if (approvals.length === 0) {
|
|
163
|
+
// B70: a review whose every open finding was waived counts as reviewed — never as approved
|
|
164
|
+
const all = waivedAllOf(file, content_hash);
|
|
165
|
+
if (all !== null) waivedAll.push(all);
|
|
166
|
+
else {
|
|
167
|
+
// B73: a stopped file (a cap row of the block) names its ways out
|
|
168
|
+
const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
|
|
169
|
+
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
|
|
170
|
+
}
|
|
171
|
+
} else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
|
|
152
172
|
if (signedApproval) {
|
|
153
173
|
const denial = staleBy(file, content_hash);
|
|
154
174
|
if (denial !== null) stale.push({ file, denial });
|
|
@@ -167,29 +187,6 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
167
187
|
for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) refusals.push({ code: 'rule_break', detail: `transcript line ${hit.line}: ${hit.id}` });
|
|
168
188
|
}
|
|
169
189
|
|
|
170
|
-
const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && verified(r, key));
|
|
171
|
-
const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
|
|
172
|
-
const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
|
|
173
|
-
const runRows = rows.filter((r) => r?.run === runId);
|
|
174
|
-
const rulings = signedOf('review.late_ruling');
|
|
175
|
-
const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
|
|
176
|
-
/**
|
|
177
|
-
* Whether the finding is waived: a signed human waiver, or an autopilot waiver its grant covered.
|
|
178
|
-
* `invalid`: why the autopilot waivers naming it do not count (null when none names it).
|
|
179
|
-
* @param {string} file @param {string} finding @returns {{waived: boolean, invalid: string | null}}
|
|
180
|
-
*/
|
|
181
|
-
const waiverFor = (file, finding) => {
|
|
182
|
-
if (waivers.some((r) => matches(r, file, finding))) return { waived: true, invalid: null };
|
|
183
|
-
const named = autopilotWaivers.filter((r) => matches(r, file, finding));
|
|
184
|
-
if (named.length === 0) return { waived: false, invalid: null };
|
|
185
|
-
const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key }));
|
|
186
|
-
if (problems.some((p) => p === null)) return { waived: true, invalid: null };
|
|
187
|
-
return { waived: false, invalid: /** @type {string} */ (problems[0]) };
|
|
188
|
-
};
|
|
189
|
-
/** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
|
|
190
|
-
const orInvalid = (refusal, finding, invalid) =>
|
|
191
|
-
invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
|
|
192
|
-
|
|
193
190
|
/** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
|
|
194
191
|
const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
|
|
195
192
|
|
|
@@ -204,20 +201,15 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
204
201
|
notApproved.add(`${file}\0${finding}`);
|
|
205
202
|
const refusal = /** @type {Refusal} */ ({ code: 'not_approved', file, finding, detail: `the latest review of ${file}'s current content left ${finding} open and it is not waived` });
|
|
206
203
|
// the severity the refusal row records wins: anything but warning/nit is the human's alone
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const delegated = autopilotWaivers.some((r) => matches(r, file, finding));
|
|
210
|
-
refusals.push(delegated ? { code: 'autopilot_waiver_invalid', file, finding, detail: `the autopilot waiver of ${finding} in ${file} does not count: the latest review records it ${severity}; only the human waives it` } : refusal);
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const w = waiverFor(file, finding);
|
|
214
|
-
if (!w.waived) refusals.push(orInvalid(refusal, finding, w.invalid));
|
|
204
|
+
const w = waiverOf(file, finding, severity);
|
|
205
|
+
if (w.by === null) refusals.push(orInvalid(refusal, finding, w.invalid));
|
|
215
206
|
}
|
|
216
207
|
}
|
|
217
208
|
|
|
218
209
|
for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
|
|
219
210
|
if (ruledNit(late)) continue;
|
|
220
|
-
|
|
211
|
+
// the answer rule reads the severity the late row records: a missing or unknown one fails closed
|
|
212
|
+
const w = waiverFor(late.file, late.finding, typeof late.severity === 'string' ? late.severity : 'unknown');
|
|
221
213
|
if (w.waived) continue;
|
|
222
214
|
refusals.push(orInvalid({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` }, late.finding, w.invalid));
|
|
223
215
|
}
|
|
@@ -231,26 +223,311 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
231
223
|
}
|
|
232
224
|
for (const finding of Array.isArray(cap.open) ? cap.open : []) {
|
|
233
225
|
if (notApproved.has(`${cap.file}\0${finding}`)) continue;
|
|
234
|
-
const w = waiverFor(cap.file, finding);
|
|
235
|
-
if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` }, finding, w.invalid));
|
|
226
|
+
const w = waiverFor(cap.file, finding, hash === undefined ? 'unknown' : recordedRefusalSeverity(cap.file, hash, finding));
|
|
227
|
+
if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived; ${stopExitsLine}` }, finding, w.invalid));
|
|
236
228
|
}
|
|
237
229
|
}
|
|
238
230
|
for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
|
|
239
|
-
const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
|
|
231
|
+
const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING, 'unknown');
|
|
240
232
|
if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
|
|
241
233
|
}
|
|
242
|
-
return { ok: refusals.length === 0, refusals };
|
|
234
|
+
return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
|
|
238
|
+
export const ANSWERED_EVENT = 'review.answered';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* B71: the severities a signed answer clears — a critical too (a reviewer judged the fix; it is
|
|
242
|
+
* not a waiver) and an explicit null (the row records none). An unknown severity, `undefined` or
|
|
243
|
+
* any other value is never cleared by an answer (fail closed).
|
|
244
|
+
* @param {unknown} severity @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
export function answerableSeverity(severity) {
|
|
247
|
+
return severity === null || severity === 'critical' || severity === 'warning' || severity === 'nit';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* THE review rules of one block (B52, B70, B71), shared by the close ({@link checkBlockReviews})
|
|
252
|
+
* and the worker's answers check ({@link answerableFindings}) so both read a finding the same way.
|
|
253
|
+
* `current`: the current content hash per file — an answer counts only while BOTH its hashes are
|
|
254
|
+
* there and equal (a file missing from it fails closed).
|
|
255
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer | null, resultFiles?: Record<string, ResultFile>, current: Map<string, string>, verify?: (row: Record<string, any>) => boolean}} opts
|
|
256
|
+
*/
|
|
257
|
+
function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verify }) {
|
|
258
|
+
// B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
|
|
259
|
+
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
260
|
+
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
261
|
+
const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
|
|
262
|
+
/**
|
|
263
|
+
* B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
|
|
264
|
+
* `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
|
|
265
|
+
* so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
|
|
266
|
+
* `latest` is the latest refusal for (file, hash) whether or not an approval exists, `latestRow`
|
|
267
|
+
* the row it came from.
|
|
268
|
+
* @param {string} file @param {string} hash
|
|
269
|
+
* @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null, latestRow: Record<string, any> | null}}
|
|
270
|
+
*/
|
|
271
|
+
const latestOf = (file, hash) => {
|
|
272
|
+
let lastApproval = -1;
|
|
273
|
+
/** @type {Denial | null} */
|
|
274
|
+
let denial = null;
|
|
275
|
+
let lastDenial = -1;
|
|
276
|
+
/** @type {Record<string, any> | null} */
|
|
277
|
+
let latestRow = null;
|
|
278
|
+
mine.forEach((r, i) => {
|
|
279
|
+
if (r.file !== file) return;
|
|
280
|
+
if (r.event === 'review.approved') {
|
|
281
|
+
if (r.content_hash === hash && ok(r)) lastApproval = i;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (r.content_hash !== hash && r.content_hash !== undefined) return;
|
|
285
|
+
const d = denialOf(r, resultFiles);
|
|
286
|
+
if (d !== null) {
|
|
287
|
+
denial = d;
|
|
288
|
+
lastDenial = i;
|
|
289
|
+
latestRow = r;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial, latestRow };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && ok(r));
|
|
296
|
+
const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
|
|
297
|
+
const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
|
|
298
|
+
// B71: only the worker's signed rows of THIS run (a row with no run never counts)
|
|
299
|
+
const answers = signedOf(ANSWERED_EVENT).filter((r) => r.run === runId);
|
|
300
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
301
|
+
const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
|
|
302
|
+
/**
|
|
303
|
+
* B71: whether a signed `review.answered` row clears `finding` of `file`: never `proof`, only an
|
|
304
|
+
* {@link answerableSeverity}, and only while the source hash (`content_hash`) and the answering
|
|
305
|
+
* file's hash (`by_hash`) both equal their CURRENT hashes — a change to either makes it stale.
|
|
306
|
+
* `exceptTicket`: the rows of that answers ticket are left out (its own retry, {@link openAt}).
|
|
307
|
+
* A proof finding is cleared by its proof (`proof` rows), never by a reviewer's answer.
|
|
308
|
+
* @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket]
|
|
309
|
+
* @returns {boolean}
|
|
310
|
+
*/
|
|
311
|
+
const answered = (file, finding, severity, exceptTicket = null) => {
|
|
312
|
+
if (finding === PROOF_FINDING || !answerableSeverity(severity)) return false;
|
|
313
|
+
return answerRows(file).some((r) => r.finding === finding && (exceptTicket === null || r.ticket !== exceptTicket));
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* B71: the signed `review.answered` rows of `file` still current (both hashes), any finding.
|
|
317
|
+
* @param {string} file @returns {Array<Record<string, any>>}
|
|
318
|
+
*/
|
|
319
|
+
const answerRows = (file) => {
|
|
320
|
+
const now = current.get(file);
|
|
321
|
+
if (now === undefined) return [];
|
|
322
|
+
return answers.filter((r) => r.file === file && r.content_hash === now && typeof r.by_file === 'string' && r.by_file !== file && current.has(r.by_file) && r.by_hash === current.get(r.by_file));
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* THE clearing rule: who clears the finding — `human` (a signed human waiver), `answer` (B71: a
|
|
326
|
+
* signed answer of the worker, {@link answered}), `autopilot` (an autopilot waiver its grant
|
|
327
|
+
* covered, {@link autopilotWaiverProblem}) or null, in that order. `severity`: the one a refusal
|
|
328
|
+
* row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
|
|
329
|
+
* rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
|
|
330
|
+
* (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
|
|
331
|
+
* except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
|
|
332
|
+
* not count (null when none names it).
|
|
333
|
+
* `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
|
|
334
|
+
* (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
|
|
335
|
+
* so an unknown severity is never cleared by an answer even where the autopilot rule reads null.
|
|
336
|
+
* @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket] @param {unknown} [answerSeverity]
|
|
337
|
+
* @returns {{by: 'human' | 'answer' | 'autopilot' | null, invalid: string | null}}
|
|
338
|
+
*/
|
|
339
|
+
const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
|
|
340
|
+
if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
|
|
341
|
+
if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
|
|
342
|
+
const named = autopilotWaivers.filter((r) => matches(r, file, finding));
|
|
343
|
+
// the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
|
|
344
|
+
if (named.length === 0 || verify !== undefined) return { by: null, invalid: null };
|
|
345
|
+
if (!delegableSeverity(severity)) return { by: null, invalid: `the latest review records it ${severity}; only the human waives it` };
|
|
346
|
+
const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key: /** @type {Buffer} */ (key) }));
|
|
347
|
+
if (problems.some((p) => p === null)) return { by: 'autopilot', invalid: null };
|
|
348
|
+
return { by: null, invalid: /** @type {string} */ (problems[0]) };
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Whether the finding is cleared: a signed human waiver, a valid answer, or an autopilot waiver
|
|
352
|
+
* its grant covered. `answerSeverity`: the severity recorded for the id (REQUIRED: the answer
|
|
353
|
+
* rule fails closed on anything {@link answerableSeverity} refuses).
|
|
354
|
+
* @param {string} file @param {string} finding @param {unknown} answerSeverity @returns {{waived: boolean, invalid: string | null}}
|
|
355
|
+
*/
|
|
356
|
+
const waiverFor = (file, finding, answerSeverity) => {
|
|
357
|
+
const w = waiverOf(file, finding, null, null, answerSeverity);
|
|
358
|
+
return { waived: w.by !== null, invalid: w.invalid };
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* B70: the worst severity any signed hash-bound refusal row of `file` at `hash` records for
|
|
363
|
+
* `finding` (a cap row records none), or `unknown` when none does — never delegable.
|
|
364
|
+
* @param {string} file @param {string} hash @param {string} finding @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
const recordedRefusalSeverity = (file, hash, finding) => {
|
|
367
|
+
let worst = '';
|
|
368
|
+
for (const r of mine) {
|
|
369
|
+
if (r.file !== file || r.content_hash !== hash || !ok(r)) continue;
|
|
370
|
+
for (const o of denialOf(r, resultFiles)?.open ?? []) {
|
|
371
|
+
if (o.id !== finding || o.severity === null) continue;
|
|
372
|
+
// 'unknown' is itself an OPEN_RANK key (ranked above critical), so any value outside it
|
|
373
|
+
// maps to 'unknown' and wins: fail closed
|
|
374
|
+
const sev = Object.hasOwn(OPEN_RANK, o.severity) ? o.severity : 'unknown';
|
|
375
|
+
if (worst === '' || /** @type {Record<string, number>} */ (OPEN_RANK)[sev] > /** @type {Record<string, number>} */ (OPEN_RANK)[worst]) worst = sev;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return worst === '' ? 'unknown' : worst;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
|
|
383
|
+
* severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
|
|
384
|
+
* that cap row is the latest refusal (its own null never stands for them).
|
|
385
|
+
* @param {string} file @param {string} hash @returns {Set<string>}
|
|
386
|
+
*/
|
|
387
|
+
const capIdsAt = (file, hash) => {
|
|
388
|
+
/** @type {Set<string>} */
|
|
389
|
+
const ids = new Set();
|
|
390
|
+
for (const cap of mine) {
|
|
391
|
+
if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
|
|
392
|
+
for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
|
|
393
|
+
}
|
|
394
|
+
return ids;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* B70: the findings a real review of (file, hash) left open, with the severity each is read at,
|
|
399
|
+
* or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
|
|
400
|
+
* session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
|
|
401
|
+
* `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
|
|
402
|
+
* are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
|
|
403
|
+
* a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
|
|
404
|
+
* the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
|
|
405
|
+
* `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
|
|
406
|
+
* Any other id the latest refusal names uses the severity THAT row records. A cap row whose
|
|
407
|
+
* `open` is not an array of non-empty strings (the fix loop's shape) fails closed: null.
|
|
408
|
+
* @param {string} file @param {string} hash
|
|
409
|
+
* @returns {Map<string, unknown> | null}
|
|
410
|
+
*/
|
|
411
|
+
const reviewedOpen = (file, hash) => {
|
|
412
|
+
if (!mine.some((r) => r.event === 'review.round' && r.file === file && r.content_hash === hash && ok(r))) return null;
|
|
413
|
+
const { latest, latestRow } = latestOf(file, hash);
|
|
414
|
+
if (latest === null || latestRow === null || latestRow.content_hash !== hash || !ok(latestRow) || latest.open.length === 0) return null;
|
|
415
|
+
/** @type {Map<string, unknown>} */
|
|
416
|
+
const named = new Map(latest.open.map((f) => [f.id, f.severity]));
|
|
417
|
+
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
418
|
+
for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
|
|
419
|
+
if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
|
|
420
|
+
for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
421
|
+
}
|
|
422
|
+
return named;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* B70: a file with no approval for its current hash that counts as reviewed because everything a
|
|
427
|
+
* real review of these bytes left open ({@link reviewedOpen}) is cleared by {@link waiverOf} —
|
|
428
|
+
* waived by the human or a covered autopilot grant, or (B71) answered — or null. `findings` are
|
|
429
|
+
* the waived ids, `answered` the answered ones (only when there is one); `by` is `human` when
|
|
430
|
+
* every clearing waiver is the human's, `autopilot` when one is the autopilot's, and null when
|
|
431
|
+
* every id was answered.
|
|
432
|
+
* @param {string} file @param {string} hash
|
|
433
|
+
* @returns {WaivedAll | null}
|
|
434
|
+
*/
|
|
435
|
+
const waivedAllOf = (file, hash) => {
|
|
436
|
+
const named = reviewedOpen(file, hash);
|
|
437
|
+
if (named === null) return null;
|
|
438
|
+
/** @type {string[]} */
|
|
439
|
+
const waived = [];
|
|
440
|
+
/** @type {string[]} */
|
|
441
|
+
const answeredIds = [];
|
|
442
|
+
let byHuman = true;
|
|
443
|
+
for (const [finding, severity] of named) {
|
|
444
|
+
const w = waiverOf(file, finding, severity);
|
|
445
|
+
if (w.by === null) return null;
|
|
446
|
+
if (w.by === 'answer') answeredIds.push(finding);
|
|
447
|
+
else {
|
|
448
|
+
waived.push(finding);
|
|
449
|
+
if (w.by !== 'human') byHuman = false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** @type {'human' | 'autopilot' | null} */
|
|
453
|
+
const by = waived.length === 0 ? null : byHuman ? 'human' : 'autopilot';
|
|
454
|
+
return { file, content_hash: hash, status: 'reviewed_by_waiver', findings: waived.sort(), by, ...(answeredIds.length > 0 ? { answered: answeredIds.sort() } : {}) };
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* B71: the findings still open on `file` at `hash` an answer may name: every id the latest
|
|
459
|
+
* signed, hash-bound refusal row for (file, hash) names, and every open id of a signed `review.cap`
|
|
460
|
+
* row for (file, hash), that {@link waiverOf} does not clear yet (not waived, not answered), with
|
|
461
|
+
* the severity it is read at (a cap id's: {@link recordedRefusalSeverity}, B79). An id the close
|
|
462
|
+
* could never count as answered — the proof finding, or one read at a severity outside {@link answerableSeverity} (an unknown value, or a
|
|
463
|
+
* cap id no signed refusal at `hash` records) — is never open here, so it is refused before any
|
|
464
|
+
* session. `ticket`: that answers ticket's own rows do not close an id (a retry after a partial
|
|
465
|
+
* write finishes instead of being refused).
|
|
466
|
+
* @param {string} file @param {string} hash @param {string | null} [ticket]
|
|
467
|
+
* @returns {Map<string, unknown>}
|
|
468
|
+
*/
|
|
469
|
+
const openAt = (file, hash, ticket = null) => {
|
|
470
|
+
/** @type {Map<string, unknown>} */
|
|
471
|
+
const named = new Map();
|
|
472
|
+
const { latest, latestRow } = latestOf(file, hash);
|
|
473
|
+
if (latest !== null && latestRow !== null && latestRow.content_hash === hash && ok(latestRow)) for (const f of latest.open) named.set(f.id, f.severity);
|
|
474
|
+
// B79: a cap id is read at its recorded severity even when the cap row is the latest refusal
|
|
475
|
+
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
476
|
+
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* B71: the findings of `file` at its current hash `hash` that an answer may still clear (the
|
|
484
|
+
* worker's check, the one that counts): open on that hash by the signed rows and not waived or
|
|
485
|
+
* answered yet — read by the SAME rules as the close ({@link reviewRules}). `current`: the current
|
|
486
|
+
* hash per file (the source's and the answering file's). Keyed by id, with the severity read.
|
|
487
|
+
* `ticket`: the asking ticket's own rows do not close an id ({@link reviewRules} `openAt`).
|
|
488
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
|
|
489
|
+
* @returns {Map<string, unknown>}
|
|
490
|
+
*/
|
|
491
|
+
export function answerableFindings({ block, runId, rows, key, file, hash, current, ticket = null }) {
|
|
492
|
+
return reviewRules({ block, runId, rows, key, current }).openAt(file, hash, ticket);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* B71: the findings of `file` the answers ticket `ticket` already answered by a signed row that is
|
|
497
|
+
* still current (a retry writes no second row for them).
|
|
498
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, current: Map<string, string>, ticket: string}} opts
|
|
499
|
+
* @returns {Set<string>}
|
|
500
|
+
*/
|
|
501
|
+
export function ticketAnswered({ block, runId, rows, key, file, current, ticket }) {
|
|
502
|
+
return new Set(reviewRules({ block, runId, rows, key, current }).answerRows(file).filter((r) => r.ticket === ticket).map((r) => r.finding));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* B71: the coder-side pre-check of `review-file --answers` (fast refusal only): the same rules as
|
|
507
|
+
* {@link answerableFindings} ({@link reviewRules}) over rows `review-file` cannot verify (it has no
|
|
508
|
+
* run key), so no MAC is checked and no autopilot waiver is read. It can only refuse: a forged row
|
|
509
|
+
* here at most refuses the coder's own request, and the worker re-checks with the signed rows.
|
|
510
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
|
|
511
|
+
* @returns {Set<string>} the ids still open on (`file`, `hash`).
|
|
512
|
+
*/
|
|
513
|
+
export function namedOnHash({ block, runId, rows, file, hash, current, ticket = null }) {
|
|
514
|
+
return new Set(reviewRules({ block, runId, rows, key: null, current, verify: () => true }).openAt(file, hash, ticket).keys());
|
|
243
515
|
}
|
|
244
516
|
|
|
245
517
|
/**
|
|
246
518
|
* B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
|
|
247
519
|
* whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
|
|
248
520
|
* answered, or the worker failed) and `refused` with reason `other-run` (the ticket was another
|
|
249
|
-
* run's).
|
|
250
|
-
*
|
|
521
|
+
* run's). B71: `answers` too — an answers ticket judged findings of the file, it never reviewed
|
|
522
|
+
* the file (its resolved ids are `review.answered` rows; the rest stay open as they were). Every
|
|
523
|
+
* other status — `reviewed`, `stopped`, `refused` for any other reason, `no_change`,
|
|
524
|
+
* `split_required`, … — is a verdict, and anything but `approved: true` refuses.
|
|
251
525
|
* @param {Record<string, any>} r @returns {boolean}
|
|
252
526
|
*/
|
|
253
|
-
export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
|
|
527
|
+
export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || r.status === ANSWERS_STATUS || (r.status === 'refused' && r.reason === 'other-run');
|
|
528
|
+
|
|
529
|
+
/** B71: the status of every answers-ticket result that is not `stale` or `unavailable`. */
|
|
530
|
+
export const ANSWERS_STATUS = 'answers';
|
|
254
531
|
|
|
255
532
|
/**
|
|
256
533
|
* @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
|
|
@@ -361,6 +638,17 @@ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
|
|
|
361
638
|
/** The finding severities autopilot may waive (B47); anything else is never delegated. */
|
|
362
639
|
export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
|
|
363
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Whether a finding with the severity a refusal row records may be cleared by an autopilot waiver
|
|
643
|
+
* at all (B70, fail closed): an explicit null (the row records none — the signed review rows then
|
|
644
|
+
* decide, {@link autopilotWaiverProblem}) or warning/nit. `undefined`, a critical, an unknown
|
|
645
|
+
* severity or any other value is the human's alone.
|
|
646
|
+
* @param {unknown} severity @returns {boolean}
|
|
647
|
+
*/
|
|
648
|
+
export function delegableSeverity(severity) {
|
|
649
|
+
return severity === null || (typeof severity === 'string' && DELEGABLE_SEVERITIES.includes(severity));
|
|
650
|
+
}
|
|
651
|
+
|
|
364
652
|
/** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
|
|
365
653
|
const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
|
|
366
654
|
|
|
@@ -382,7 +670,8 @@ export function recordedSeverity(rows, { block, file, finding, key }) {
|
|
|
382
670
|
if (r?.block !== block || r.file !== file) continue;
|
|
383
671
|
if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
|
|
384
672
|
// B52: the worker's review.result rows record the open findings' severities too
|
|
385
|
-
|
|
673
|
+
// (B71: an answers result records no severity: it judged findings, it never reviewed the file)
|
|
674
|
+
else if (r.event === 'review.result' && r.status !== ANSWERS_STATUS && Array.isArray(r.open)) {
|
|
386
675
|
for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
|
|
387
676
|
}
|
|
388
677
|
}
|
|
@@ -739,14 +1028,22 @@ function isOnDisk(repoRoot, file) {
|
|
|
739
1028
|
}
|
|
740
1029
|
|
|
741
1030
|
/**
|
|
742
|
-
* A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason.
|
|
1031
|
+
* A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason. B70: on a
|
|
1032
|
+
* pass, `onWaivedAll` (when given) receives the files that passed as `reviewed_by_waiver` (empty
|
|
1033
|
+
* when none). It only buffers that list: the close's `gate.review_waived_all` rows for it are
|
|
1034
|
+
* signed and written by `closeBlock` after EVERY check passed, just before `block.close` — a
|
|
1035
|
+
* refused close writes none.
|
|
743
1036
|
* @param {() => Promise<Parameters<typeof checkBlockReviews>[0]>} load
|
|
1037
|
+
* @param {{onWaivedAll?: (files: WaivedAll[]) => void}} [hooks]
|
|
744
1038
|
* @returns {() => Promise<{ok: boolean, reason?: string}>}
|
|
745
1039
|
*/
|
|
746
|
-
export function reviewGateCheck(load) {
|
|
1040
|
+
export function reviewGateCheck(load, { onWaivedAll } = {}) {
|
|
747
1041
|
return async () => {
|
|
748
1042
|
const result = checkBlockReviews(await load());
|
|
749
|
-
if (result.ok)
|
|
1043
|
+
if (result.ok) {
|
|
1044
|
+
onWaivedAll?.(result.waivedAll);
|
|
1045
|
+
return { ok: true };
|
|
1046
|
+
}
|
|
750
1047
|
return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
|
|
751
1048
|
};
|
|
752
1049
|
}
|