@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -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,15 @@ 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
|
+
import { checkFileForm, checkFindingId } from './verb-forms.mjs';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
|
|
100
|
+
* B70: a file that counts as reviewed (never approved) because every finding its review left open was waived.
|
|
101
|
+
* B71: or answered — `findings` are the waived ids, `answered` (present only when there is one)
|
|
102
|
+
* the ids a signed `review.answered` row clears; `by` is null when every id was answered.
|
|
103
|
+
*/
|
|
77
104
|
|
|
78
105
|
/**
|
|
79
106
|
* @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 +123,53 @@ const verified = (row, key) => verifyRow(row, key).ok;
|
|
|
96
123
|
* but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
|
|
97
124
|
* caller that leaves one out gets a TypeError, never a silent default that skips the proof.
|
|
98
125
|
* `runId` is required too: the proof check matches its rows on this run only (fix round 4).
|
|
99
|
-
* @returns {{ok: boolean, refusals: Refusal[]}}
|
|
126
|
+
* @returns {{ok: boolean, refusals: Refusal[], waivedAll: WaivedAll[]}} `waivedAll` (B70): the files
|
|
127
|
+
* that passed as `reviewed_by_waiver` — empty whenever the check refuses.
|
|
100
128
|
* @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
|
|
101
129
|
*/
|
|
102
130
|
export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
|
|
103
131
|
if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
|
|
104
132
|
if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
|
|
105
133
|
if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
|
|
106
|
-
const
|
|
134
|
+
const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
|
|
135
|
+
// B71: the current hashes an answer is checked against — every file the close hashed (the
|
|
136
|
+
// proof set too, so `--no-require-reviews` does not hide the answering file's hash)
|
|
137
|
+
/** @type {Map<string, string>} */
|
|
138
|
+
const current = new Map();
|
|
139
|
+
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
140
|
+
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
107
141
|
/** @type {Refusal[]} */
|
|
108
142
|
const refusals = [];
|
|
109
143
|
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
144
|
const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
|
|
140
145
|
|
|
141
146
|
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
147
|
|
|
143
148
|
if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
|
|
144
149
|
|
|
150
|
+
const rulings = signedOf('review.late_ruling');
|
|
151
|
+
/** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
|
|
152
|
+
const orInvalid = (refusal, finding, invalid) =>
|
|
153
|
+
invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
|
|
154
|
+
|
|
155
|
+
/** @type {WaivedAll[]} */
|
|
156
|
+
const waivedAll = [];
|
|
157
|
+
|
|
145
158
|
/** @type {Array<{file: string, denial: Denial}>} */
|
|
146
159
|
const stale = [];
|
|
147
160
|
for (const { file, content_hash } of files) {
|
|
148
161
|
const approvals = approvedFor(file, content_hash);
|
|
149
162
|
const signedApproval = approvals.some((r) => verified(r, key));
|
|
150
|
-
if (approvals.length === 0)
|
|
151
|
-
|
|
163
|
+
if (approvals.length === 0) {
|
|
164
|
+
// B70: a review whose every open finding was waived counts as reviewed — never as approved
|
|
165
|
+
const all = waivedAllOf(file, content_hash);
|
|
166
|
+
if (all !== null) waivedAll.push(all);
|
|
167
|
+
else {
|
|
168
|
+
// B73: a stopped file (a cap row of the block) names its ways out
|
|
169
|
+
const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
|
|
170
|
+
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
|
|
171
|
+
}
|
|
172
|
+
} else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
|
|
152
173
|
if (signedApproval) {
|
|
153
174
|
const denial = staleBy(file, content_hash);
|
|
154
175
|
if (denial !== null) stale.push({ file, denial });
|
|
@@ -167,29 +188,6 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
167
188
|
for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) refusals.push({ code: 'rule_break', detail: `transcript line ${hit.line}: ${hit.id}` });
|
|
168
189
|
}
|
|
169
190
|
|
|
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
191
|
/** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
|
|
194
192
|
const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
|
|
195
193
|
|
|
@@ -204,20 +202,15 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
204
202
|
notApproved.add(`${file}\0${finding}`);
|
|
205
203
|
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
204
|
// 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));
|
|
205
|
+
const w = waiverOf(file, finding, severity);
|
|
206
|
+
if (w.by === null) refusals.push(orInvalid(refusal, finding, w.invalid));
|
|
215
207
|
}
|
|
216
208
|
}
|
|
217
209
|
|
|
218
210
|
for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
|
|
219
211
|
if (ruledNit(late)) continue;
|
|
220
|
-
|
|
212
|
+
// the answer rule reads the severity the late row records: a missing or unknown one fails closed
|
|
213
|
+
const w = waiverFor(late.file, late.finding, typeof late.severity === 'string' ? late.severity : 'unknown');
|
|
221
214
|
if (w.waived) continue;
|
|
222
215
|
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
216
|
}
|
|
@@ -231,26 +224,311 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
231
224
|
}
|
|
232
225
|
for (const finding of Array.isArray(cap.open) ? cap.open : []) {
|
|
233
226
|
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));
|
|
227
|
+
const w = waiverFor(cap.file, finding, hash === undefined ? 'unknown' : recordedRefusalSeverity(cap.file, hash, finding));
|
|
228
|
+
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
229
|
}
|
|
237
230
|
}
|
|
238
231
|
for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
|
|
239
|
-
const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
|
|
232
|
+
const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING, 'unknown');
|
|
240
233
|
if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
|
|
241
234
|
}
|
|
242
|
-
return { ok: refusals.length === 0, refusals };
|
|
235
|
+
return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
|
|
239
|
+
export const ANSWERED_EVENT = 'review.answered';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* B71: the severities a signed answer clears — a critical too (a reviewer judged the fix; it is
|
|
243
|
+
* not a waiver) and an explicit null (the row records none). An unknown severity, `undefined` or
|
|
244
|
+
* any other value is never cleared by an answer (fail closed).
|
|
245
|
+
* @param {unknown} severity @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
export function answerableSeverity(severity) {
|
|
248
|
+
return severity === null || severity === 'critical' || severity === 'warning' || severity === 'nit';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* THE review rules of one block (B52, B70, B71), shared by the close ({@link checkBlockReviews})
|
|
253
|
+
* and the worker's answers check ({@link answerableFindings}) so both read a finding the same way.
|
|
254
|
+
* `current`: the current content hash per file — an answer counts only while BOTH its hashes are
|
|
255
|
+
* there and equal (a file missing from it fails closed).
|
|
256
|
+
* @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
|
|
257
|
+
*/
|
|
258
|
+
function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verify }) {
|
|
259
|
+
// B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
|
|
260
|
+
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
261
|
+
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
262
|
+
const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
|
|
263
|
+
/**
|
|
264
|
+
* B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
|
|
265
|
+
* `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
|
|
266
|
+
* so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
|
|
267
|
+
* `latest` is the latest refusal for (file, hash) whether or not an approval exists, `latestRow`
|
|
268
|
+
* the row it came from.
|
|
269
|
+
* @param {string} file @param {string} hash
|
|
270
|
+
* @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null, latestRow: Record<string, any> | null}}
|
|
271
|
+
*/
|
|
272
|
+
const latestOf = (file, hash) => {
|
|
273
|
+
let lastApproval = -1;
|
|
274
|
+
/** @type {Denial | null} */
|
|
275
|
+
let denial = null;
|
|
276
|
+
let lastDenial = -1;
|
|
277
|
+
/** @type {Record<string, any> | null} */
|
|
278
|
+
let latestRow = null;
|
|
279
|
+
mine.forEach((r, i) => {
|
|
280
|
+
if (r.file !== file) return;
|
|
281
|
+
if (r.event === 'review.approved') {
|
|
282
|
+
if (r.content_hash === hash && ok(r)) lastApproval = i;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (r.content_hash !== hash && r.content_hash !== undefined) return;
|
|
286
|
+
const d = denialOf(r, resultFiles);
|
|
287
|
+
if (d !== null) {
|
|
288
|
+
denial = d;
|
|
289
|
+
lastDenial = i;
|
|
290
|
+
latestRow = r;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial, latestRow };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && ok(r));
|
|
297
|
+
const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
|
|
298
|
+
const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
|
|
299
|
+
// B71: only the worker's signed rows of THIS run (a row with no run never counts)
|
|
300
|
+
const answers = signedOf(ANSWERED_EVENT).filter((r) => r.run === runId);
|
|
301
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
302
|
+
const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
|
|
303
|
+
/**
|
|
304
|
+
* B71: whether a signed `review.answered` row clears `finding` of `file`: never `proof`, only an
|
|
305
|
+
* {@link answerableSeverity}, and only while the source hash (`content_hash`) and the answering
|
|
306
|
+
* file's hash (`by_hash`) both equal their CURRENT hashes — a change to either makes it stale.
|
|
307
|
+
* `exceptTicket`: the rows of that answers ticket are left out (its own retry, {@link openAt}).
|
|
308
|
+
* A proof finding is cleared by its proof (`proof` rows), never by a reviewer's answer.
|
|
309
|
+
* @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket]
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
const answered = (file, finding, severity, exceptTicket = null) => {
|
|
313
|
+
if (finding === PROOF_FINDING || !answerableSeverity(severity)) return false;
|
|
314
|
+
return answerRows(file).some((r) => r.finding === finding && (exceptTicket === null || r.ticket !== exceptTicket));
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* B71: the signed `review.answered` rows of `file` still current (both hashes), any finding.
|
|
318
|
+
* @param {string} file @returns {Array<Record<string, any>>}
|
|
319
|
+
*/
|
|
320
|
+
const answerRows = (file) => {
|
|
321
|
+
const now = current.get(file);
|
|
322
|
+
if (now === undefined) return [];
|
|
323
|
+
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));
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* THE clearing rule: who clears the finding — `human` (a signed human waiver), `answer` (B71: a
|
|
327
|
+
* signed answer of the worker, {@link answered}), `autopilot` (an autopilot waiver its grant
|
|
328
|
+
* covered, {@link autopilotWaiverProblem}) or null, in that order. `severity`: the one a refusal
|
|
329
|
+
* row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
|
|
330
|
+
* rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
|
|
331
|
+
* (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
|
|
332
|
+
* except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
|
|
333
|
+
* not count (null when none names it).
|
|
334
|
+
* `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
|
|
335
|
+
* (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
|
|
336
|
+
* so an unknown severity is never cleared by an answer even where the autopilot rule reads null.
|
|
337
|
+
* @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket] @param {unknown} [answerSeverity]
|
|
338
|
+
* @returns {{by: 'human' | 'answer' | 'autopilot' | null, invalid: string | null}}
|
|
339
|
+
*/
|
|
340
|
+
const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
|
|
341
|
+
if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
|
|
342
|
+
if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
|
|
343
|
+
const named = autopilotWaivers.filter((r) => matches(r, file, finding));
|
|
344
|
+
// the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
|
|
345
|
+
if (named.length === 0 || verify !== undefined) return { by: null, invalid: null };
|
|
346
|
+
if (!delegableSeverity(severity)) return { by: null, invalid: `the latest review records it ${severity}; only the human waives it` };
|
|
347
|
+
const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key: /** @type {Buffer} */ (key) }));
|
|
348
|
+
if (problems.some((p) => p === null)) return { by: 'autopilot', invalid: null };
|
|
349
|
+
return { by: null, invalid: /** @type {string} */ (problems[0]) };
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* Whether the finding is cleared: a signed human waiver, a valid answer, or an autopilot waiver
|
|
353
|
+
* its grant covered. `answerSeverity`: the severity recorded for the id (REQUIRED: the answer
|
|
354
|
+
* rule fails closed on anything {@link answerableSeverity} refuses).
|
|
355
|
+
* @param {string} file @param {string} finding @param {unknown} answerSeverity @returns {{waived: boolean, invalid: string | null}}
|
|
356
|
+
*/
|
|
357
|
+
const waiverFor = (file, finding, answerSeverity) => {
|
|
358
|
+
const w = waiverOf(file, finding, null, null, answerSeverity);
|
|
359
|
+
return { waived: w.by !== null, invalid: w.invalid };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* B70: the worst severity any signed hash-bound refusal row of `file` at `hash` records for
|
|
364
|
+
* `finding` (a cap row records none), or `unknown` when none does — never delegable.
|
|
365
|
+
* @param {string} file @param {string} hash @param {string} finding @returns {string}
|
|
366
|
+
*/
|
|
367
|
+
const recordedRefusalSeverity = (file, hash, finding) => {
|
|
368
|
+
let worst = '';
|
|
369
|
+
for (const r of mine) {
|
|
370
|
+
if (r.file !== file || r.content_hash !== hash || !ok(r)) continue;
|
|
371
|
+
for (const o of denialOf(r, resultFiles)?.open ?? []) {
|
|
372
|
+
if (o.id !== finding || o.severity === null) continue;
|
|
373
|
+
// 'unknown' is itself an OPEN_RANK key (ranked above critical), so any value outside it
|
|
374
|
+
// maps to 'unknown' and wins: fail closed
|
|
375
|
+
const sev = Object.hasOwn(OPEN_RANK, o.severity) ? o.severity : 'unknown';
|
|
376
|
+
if (worst === '' || /** @type {Record<string, number>} */ (OPEN_RANK)[sev] > /** @type {Record<string, number>} */ (OPEN_RANK)[worst]) worst = sev;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return worst === '' ? 'unknown' : worst;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
|
|
384
|
+
* severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
|
|
385
|
+
* that cap row is the latest refusal (its own null never stands for them).
|
|
386
|
+
* @param {string} file @param {string} hash @returns {Set<string>}
|
|
387
|
+
*/
|
|
388
|
+
const capIdsAt = (file, hash) => {
|
|
389
|
+
/** @type {Set<string>} */
|
|
390
|
+
const ids = new Set();
|
|
391
|
+
for (const cap of mine) {
|
|
392
|
+
if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
|
|
393
|
+
for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
|
|
394
|
+
}
|
|
395
|
+
return ids;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* B70: the findings a real review of (file, hash) left open, with the severity each is read at,
|
|
400
|
+
* or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
|
|
401
|
+
* session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
|
|
402
|
+
* `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
|
|
403
|
+
* are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
|
|
404
|
+
* a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
|
|
405
|
+
* the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
|
|
406
|
+
* `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
|
|
407
|
+
* Any other id the latest refusal names uses the severity THAT row records. A cap row whose
|
|
408
|
+
* `open` is not an array of non-empty strings (the fix loop's shape) fails closed: null.
|
|
409
|
+
* @param {string} file @param {string} hash
|
|
410
|
+
* @returns {Map<string, unknown> | null}
|
|
411
|
+
*/
|
|
412
|
+
const reviewedOpen = (file, hash) => {
|
|
413
|
+
if (!mine.some((r) => r.event === 'review.round' && r.file === file && r.content_hash === hash && ok(r))) return null;
|
|
414
|
+
const { latest, latestRow } = latestOf(file, hash);
|
|
415
|
+
if (latest === null || latestRow === null || latestRow.content_hash !== hash || !ok(latestRow) || latest.open.length === 0) return null;
|
|
416
|
+
/** @type {Map<string, unknown>} */
|
|
417
|
+
const named = new Map(latest.open.map((f) => [f.id, f.severity]));
|
|
418
|
+
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
419
|
+
for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
|
|
420
|
+
if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
|
|
421
|
+
for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
422
|
+
}
|
|
423
|
+
return named;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* B70: a file with no approval for its current hash that counts as reviewed because everything a
|
|
428
|
+
* real review of these bytes left open ({@link reviewedOpen}) is cleared by {@link waiverOf} —
|
|
429
|
+
* waived by the human or a covered autopilot grant, or (B71) answered — or null. `findings` are
|
|
430
|
+
* the waived ids, `answered` the answered ones (only when there is one); `by` is `human` when
|
|
431
|
+
* every clearing waiver is the human's, `autopilot` when one is the autopilot's, and null when
|
|
432
|
+
* every id was answered.
|
|
433
|
+
* @param {string} file @param {string} hash
|
|
434
|
+
* @returns {WaivedAll | null}
|
|
435
|
+
*/
|
|
436
|
+
const waivedAllOf = (file, hash) => {
|
|
437
|
+
const named = reviewedOpen(file, hash);
|
|
438
|
+
if (named === null) return null;
|
|
439
|
+
/** @type {string[]} */
|
|
440
|
+
const waived = [];
|
|
441
|
+
/** @type {string[]} */
|
|
442
|
+
const answeredIds = [];
|
|
443
|
+
let byHuman = true;
|
|
444
|
+
for (const [finding, severity] of named) {
|
|
445
|
+
const w = waiverOf(file, finding, severity);
|
|
446
|
+
if (w.by === null) return null;
|
|
447
|
+
if (w.by === 'answer') answeredIds.push(finding);
|
|
448
|
+
else {
|
|
449
|
+
waived.push(finding);
|
|
450
|
+
if (w.by !== 'human') byHuman = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** @type {'human' | 'autopilot' | null} */
|
|
454
|
+
const by = waived.length === 0 ? null : byHuman ? 'human' : 'autopilot';
|
|
455
|
+
return { file, content_hash: hash, status: 'reviewed_by_waiver', findings: waived.sort(), by, ...(answeredIds.length > 0 ? { answered: answeredIds.sort() } : {}) };
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* B71: the findings still open on `file` at `hash` an answer may name: every id the latest
|
|
460
|
+
* signed, hash-bound refusal row for (file, hash) names, and every open id of a signed `review.cap`
|
|
461
|
+
* row for (file, hash), that {@link waiverOf} does not clear yet (not waived, not answered), with
|
|
462
|
+
* the severity it is read at (a cap id's: {@link recordedRefusalSeverity}, B79). An id the close
|
|
463
|
+
* could never count as answered — the proof finding, or one read at a severity outside {@link answerableSeverity} (an unknown value, or a
|
|
464
|
+
* cap id no signed refusal at `hash` records) — is never open here, so it is refused before any
|
|
465
|
+
* session. `ticket`: that answers ticket's own rows do not close an id (a retry after a partial
|
|
466
|
+
* write finishes instead of being refused).
|
|
467
|
+
* @param {string} file @param {string} hash @param {string | null} [ticket]
|
|
468
|
+
* @returns {Map<string, unknown>}
|
|
469
|
+
*/
|
|
470
|
+
const openAt = (file, hash, ticket = null) => {
|
|
471
|
+
/** @type {Map<string, unknown>} */
|
|
472
|
+
const named = new Map();
|
|
473
|
+
const { latest, latestRow } = latestOf(file, hash);
|
|
474
|
+
if (latest !== null && latestRow !== null && latestRow.content_hash === hash && ok(latestRow)) for (const f of latest.open) named.set(f.id, f.severity);
|
|
475
|
+
// B79: a cap id is read at its recorded severity even when the cap row is the latest refusal
|
|
476
|
+
for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
|
|
477
|
+
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* B71: the findings of `file` at its current hash `hash` that an answer may still clear (the
|
|
485
|
+
* worker's check, the one that counts): open on that hash by the signed rows and not waived or
|
|
486
|
+
* answered yet — read by the SAME rules as the close ({@link reviewRules}). `current`: the current
|
|
487
|
+
* hash per file (the source's and the answering file's). Keyed by id, with the severity read.
|
|
488
|
+
* `ticket`: the asking ticket's own rows do not close an id ({@link reviewRules} `openAt`).
|
|
489
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
|
|
490
|
+
* @returns {Map<string, unknown>}
|
|
491
|
+
*/
|
|
492
|
+
export function answerableFindings({ block, runId, rows, key, file, hash, current, ticket = null }) {
|
|
493
|
+
return reviewRules({ block, runId, rows, key, current }).openAt(file, hash, ticket);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* B71: the findings of `file` the answers ticket `ticket` already answered by a signed row that is
|
|
498
|
+
* still current (a retry writes no second row for them).
|
|
499
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, current: Map<string, string>, ticket: string}} opts
|
|
500
|
+
* @returns {Set<string>}
|
|
501
|
+
*/
|
|
502
|
+
export function ticketAnswered({ block, runId, rows, key, file, current, ticket }) {
|
|
503
|
+
return new Set(reviewRules({ block, runId, rows, key, current }).answerRows(file).filter((r) => r.ticket === ticket).map((r) => r.finding));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* B71: the coder-side pre-check of `review-file --answers` (fast refusal only): the same rules as
|
|
508
|
+
* {@link answerableFindings} ({@link reviewRules}) over rows `review-file` cannot verify (it has no
|
|
509
|
+
* run key), so no MAC is checked and no autopilot waiver is read. It can only refuse: a forged row
|
|
510
|
+
* here at most refuses the coder's own request, and the worker re-checks with the signed rows.
|
|
511
|
+
* @param {{block: string, runId: string, rows: Array<Record<string, any>>, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
|
|
512
|
+
* @returns {Set<string>} the ids still open on (`file`, `hash`).
|
|
513
|
+
*/
|
|
514
|
+
export function namedOnHash({ block, runId, rows, file, hash, current, ticket = null }) {
|
|
515
|
+
return new Set(reviewRules({ block, runId, rows, key: null, current, verify: () => true }).openAt(file, hash, ticket).keys());
|
|
243
516
|
}
|
|
244
517
|
|
|
245
518
|
/**
|
|
246
519
|
* B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
|
|
247
520
|
* whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
|
|
248
521
|
* answered, or the worker failed) and `refused` with reason `other-run` (the ticket was another
|
|
249
|
-
* run's).
|
|
250
|
-
*
|
|
522
|
+
* run's). B71: `answers` too — an answers ticket judged findings of the file, it never reviewed
|
|
523
|
+
* the file (its resolved ids are `review.answered` rows; the rest stay open as they were). Every
|
|
524
|
+
* other status — `reviewed`, `stopped`, `refused` for any other reason, `no_change`,
|
|
525
|
+
* `split_required`, … — is a verdict, and anything but `approved: true` refuses.
|
|
251
526
|
* @param {Record<string, any>} r @returns {boolean}
|
|
252
527
|
*/
|
|
253
|
-
export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
|
|
528
|
+
export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || r.status === ANSWERS_STATUS || (r.status === 'refused' && r.reason === 'other-run');
|
|
529
|
+
|
|
530
|
+
/** B71: the status of every answers-ticket result that is not `stale` or `unavailable`. */
|
|
531
|
+
export const ANSWERS_STATUS = 'answers';
|
|
254
532
|
|
|
255
533
|
/**
|
|
256
534
|
* @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
|
|
@@ -361,6 +639,17 @@ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
|
|
|
361
639
|
/** The finding severities autopilot may waive (B47); anything else is never delegated. */
|
|
362
640
|
export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
|
|
363
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Whether a finding with the severity a refusal row records may be cleared by an autopilot waiver
|
|
644
|
+
* at all (B70, fail closed): an explicit null (the row records none — the signed review rows then
|
|
645
|
+
* decide, {@link autopilotWaiverProblem}) or warning/nit. `undefined`, a critical, an unknown
|
|
646
|
+
* severity or any other value is the human's alone.
|
|
647
|
+
* @param {unknown} severity @returns {boolean}
|
|
648
|
+
*/
|
|
649
|
+
export function delegableSeverity(severity) {
|
|
650
|
+
return severity === null || (typeof severity === 'string' && DELEGABLE_SEVERITIES.includes(severity));
|
|
651
|
+
}
|
|
652
|
+
|
|
364
653
|
/** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
|
|
365
654
|
const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
|
|
366
655
|
|
|
@@ -382,7 +671,8 @@ export function recordedSeverity(rows, { block, file, finding, key }) {
|
|
|
382
671
|
if (r?.block !== block || r.file !== file) continue;
|
|
383
672
|
if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
|
|
384
673
|
// B52: the worker's review.result rows record the open findings' severities too
|
|
385
|
-
|
|
674
|
+
// (B71: an answers result records no severity: it judged findings, it never reviewed the file)
|
|
675
|
+
else if (r.event === 'review.result' && r.status !== ANSWERS_STATUS && Array.isArray(r.open)) {
|
|
386
676
|
for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
|
|
387
677
|
}
|
|
388
678
|
}
|
|
@@ -739,14 +1029,22 @@ function isOnDisk(repoRoot, file) {
|
|
|
739
1029
|
}
|
|
740
1030
|
|
|
741
1031
|
/**
|
|
742
|
-
* A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason.
|
|
1032
|
+
* A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason. B70: on a
|
|
1033
|
+
* pass, `onWaivedAll` (when given) receives the files that passed as `reviewed_by_waiver` (empty
|
|
1034
|
+
* when none). It only buffers that list: the close's `gate.review_waived_all` rows for it are
|
|
1035
|
+
* signed and written by `closeBlock` after EVERY check passed, just before `block.close` — a
|
|
1036
|
+
* refused close writes none.
|
|
743
1037
|
* @param {() => Promise<Parameters<typeof checkBlockReviews>[0]>} load
|
|
1038
|
+
* @param {{onWaivedAll?: (files: WaivedAll[]) => void}} [hooks]
|
|
744
1039
|
* @returns {() => Promise<{ok: boolean, reason?: string}>}
|
|
745
1040
|
*/
|
|
746
|
-
export function reviewGateCheck(load) {
|
|
1041
|
+
export function reviewGateCheck(load, { onWaivedAll } = {}) {
|
|
747
1042
|
return async () => {
|
|
748
1043
|
const result = checkBlockReviews(await load());
|
|
749
|
-
if (result.ok)
|
|
1044
|
+
if (result.ok) {
|
|
1045
|
+
onWaivedAll?.(result.waivedAll);
|
|
1046
|
+
return { ok: true };
|
|
1047
|
+
}
|
|
750
1048
|
return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
|
|
751
1049
|
};
|
|
752
1050
|
}
|
|
@@ -758,10 +1056,10 @@ export function reviewGateCheck(load) {
|
|
|
758
1056
|
* @returns {Promise<Record<string, any>>} the signed row.
|
|
759
1057
|
*/
|
|
760
1058
|
export async function waiveFinding({ runId, id, finding, reason, file, writeRow }) {
|
|
761
|
-
|
|
1059
|
+
checkFindingId(finding);
|
|
762
1060
|
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block waive needs --reason');
|
|
763
1061
|
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block waive needs --file <path>');
|
|
764
|
-
|
|
1062
|
+
checkFileForm(file, { normalized: false });
|
|
765
1063
|
const record = await readRun(runId);
|
|
766
1064
|
if (!record.blocks?.[id]) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
767
1065
|
const row = { event: 'review.waived', block: id, file, finding, reason: reason.trim(), by: 'human' };
|
package/src/review/lenses/A.md
CHANGED
|
@@ -20,3 +20,4 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
20
20
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
21
21
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
22
22
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
23
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
package/src/review/lenses/B.md
CHANGED
|
@@ -21,3 +21,4 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
21
21
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
22
22
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
23
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
24
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -21,3 +21,4 @@ Report each real defect once, at the lines where it lives.
|
|
|
21
21
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
22
22
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
23
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
24
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -19,3 +19,4 @@ Skip style, naming and structure unless it hides a bug. Keep `findings` short.
|
|
|
19
19
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
20
20
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
21
21
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
22
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -21,3 +21,4 @@ that changed since that round, each with a window of the current file around it.
|
|
|
21
21
|
- You have no tools and no project files. The packet is everything you get.
|
|
22
22
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
23
23
|
- Answer with ONE JSON object that matches the answer schema.
|
|
24
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|