@codedology/code-forge 0.1.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 +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block gate's review rows (plan §4.6 rows 2, 9 and 11; §4.9 `block waive`; §4.11; block B12b).
|
|
3
|
+
*
|
|
4
|
+
* `checkBlockReviews` refuses the block when:
|
|
5
|
+
* - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash;
|
|
6
|
+
* - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
|
|
7
|
+
* - `rule_break`: a `rule_break` row names the block, or the coder transcript hits the forbidden
|
|
8
|
+
* list (`scanTranscript`; a read of `~/.code-forge/runs/` — the signer key — is the named case);
|
|
9
|
+
* - `late_unruled`: a `review.late_finding` has neither a signed `review.late_ruling` (the
|
|
10
|
+
* block-close sweep's L3 ruling) nor a signed `review.waived` row;
|
|
11
|
+
* - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
|
|
12
|
+
* `review.waived {by: human}` row — unless the file has a signed approval at its current hash
|
|
13
|
+
* (the human fixed it by hand and it was reviewed);
|
|
14
|
+
* - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
|
|
15
|
+
* red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
|
|
16
|
+
* Rulings and waivers only count when their MAC verifies AND name the finding's file exactly
|
|
17
|
+
* (`file` + `finding`; a finding id is scoped to one file, so a waiver of `F1` in `a.mjs` never
|
|
18
|
+
* clears `F1` in `b.mjs`); a late finding counts whether or not it is signed (fail closed).
|
|
19
|
+
*
|
|
20
|
+
* `block waive <id> <finding> --file <path> --reason <text>` (`waiveFinding`) writes a signed
|
|
21
|
+
* `review.waived` row with `by: human`. The orchestrator runs it only after the human said so in chat; the brief
|
|
22
|
+
* never mentions it; for a coder the argv is forbidden (`mergeForbidden()` always carries B12b's
|
|
23
|
+
* coder-only entries, `CODER_ONLY_FORBIDDEN` in `util/forbidden.mjs`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { lstatSync } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
29
|
+
import { exec } from '../util/exec.mjs';
|
|
30
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
31
|
+
import { StateError } from '../state/paths.mjs';
|
|
32
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
33
|
+
import { tierFor } from '../proof/tiers.mjs';
|
|
34
|
+
import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven', file?: string, finding?: string, detail?: string}} Refusal
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** @param {Record<string, any>} row @param {Buffer} key */
|
|
41
|
+
const verified = (row, key) => verifyRow(row, key).ok;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{
|
|
45
|
+
* block: string, runId: string, files: Array<{file: string, content_hash: string}>,
|
|
46
|
+
* rows: Array<Record<string, any>>, key: Buffer, transcript?: string | null,
|
|
47
|
+
* extraTokens?: string[], proofFiles: Array<{file: string, content_hash: string}>,
|
|
48
|
+
* highPaths: ReadonlyArray<string>,
|
|
49
|
+
* }} opts `proofFiles` is the set the proof check reads — `--no-require-reviews` empties `files`
|
|
50
|
+
* but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
|
|
51
|
+
* caller that leaves one out gets a TypeError, never a silent default that skips the proof.
|
|
52
|
+
* `runId` is required too: the proof check matches its rows on this run only (fix round 4).
|
|
53
|
+
* @returns {{ok: boolean, refusals: Refusal[]}}
|
|
54
|
+
* @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
|
|
55
|
+
*/
|
|
56
|
+
export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths }) {
|
|
57
|
+
if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
|
|
58
|
+
if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
|
|
59
|
+
if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
|
|
60
|
+
const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
|
|
61
|
+
/** @type {Refusal[]} */
|
|
62
|
+
const refusals = [];
|
|
63
|
+
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
64
|
+
const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
|
|
65
|
+
|
|
66
|
+
for (const { file, content_hash } of files) {
|
|
67
|
+
const approvals = approvedFor(file, content_hash);
|
|
68
|
+
if (approvals.length === 0) refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content` });
|
|
69
|
+
else if (!approvals.some((r) => verified(r, key))) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const r of mine.filter((row) => row.event === 'rule_break')) refusals.push({ code: 'rule_break', detail: `rule_break row: ${String(r.id ?? r.rule ?? 'unnamed')}` });
|
|
73
|
+
if (typeof transcript === 'string' && transcript.length > 0) {
|
|
74
|
+
for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) refusals.push({ code: 'rule_break', detail: `transcript line ${hit.line}: ${hit.id}` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && verified(r, key));
|
|
78
|
+
const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
|
|
79
|
+
const rulings = signedOf('review.late_ruling');
|
|
80
|
+
const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
|
|
81
|
+
const waived = (/** @type {string} */ file, /** @type {string} */ finding) => waivers.some((r) => matches(r, file, finding));
|
|
82
|
+
|
|
83
|
+
for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
|
|
84
|
+
if (rulings.some((r) => matches(r, late.file, late.finding)) || waived(late.file, late.finding)) continue;
|
|
85
|
+
refusals.push({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const cap of mine.filter((r) => r.event === 'review.cap')) {
|
|
89
|
+
const hash = hashOf.get(cap.file);
|
|
90
|
+
if (hash !== undefined && approvedFor(cap.file, hash).some((r) => verified(r, key))) continue;
|
|
91
|
+
for (const finding of Array.isArray(cap.open) ? cap.open : []) {
|
|
92
|
+
if (!waived(cap.file, finding)) refusals.push({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
|
|
96
|
+
if (!waived(/** @type {string} */ (r.file), PROOF_FINDING)) refusals.push(r);
|
|
97
|
+
}
|
|
98
|
+
return { ok: refusals.length === 0, refusals };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The finding id a human waives to clear an `unproven` file: `block waive <id> proof --file <path>`. */
|
|
102
|
+
export const PROOF_FINDING = 'proof';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The file's proof tier at close, from the same inputs `proof tier` uses (§7.1): the config's
|
|
106
|
+
* `proof.tiers.high.paths`, the highest `risk` any row of the block recorded for the file (the
|
|
107
|
+
* worker's `review.plan` rows), and `security_sensitive: true` on any such row. The MAC is checked
|
|
108
|
+
* FIRST and fails closed: when ANY row naming the file does not verify — with or without a `risk`
|
|
109
|
+
* field — the file counts as risk 3.
|
|
110
|
+
* @param {{file: string, rows: Array<Record<string, any>>, key: Buffer, highPaths: ReadonlyArray<string>}} opts
|
|
111
|
+
* @returns {import('../proof/tiers.mjs').Tier}
|
|
112
|
+
*/
|
|
113
|
+
export function closeTier({ file, rows, key, highPaths }) {
|
|
114
|
+
let risk = 0;
|
|
115
|
+
let securitySensitive = false;
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
if (r?.file !== file) continue;
|
|
118
|
+
if (!verified(r, key)) {
|
|
119
|
+
risk = 3;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (typeof r.risk === 'number' && Number.isFinite(r.risk)) risk = Math.max(risk, Math.min(3, Math.max(0, r.risk)));
|
|
123
|
+
if (r.security_sensitive === true) securitySensitive = true;
|
|
124
|
+
}
|
|
125
|
+
return tierFor({ file, risk, securitySensitive, highPaths: [...highPaths] }).tier;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* B20 (Ricardo 2026-09-25): every changed owned file of tier `high` needs a covering red→green
|
|
130
|
+
* proof, checked PER FILE. A file is covered by a `proof` row of this run and block (`rows` must
|
|
131
|
+
* already be the block's rows) with `step: 'red-green'`, `proven: true`, `red_kind: 'assertion'`,
|
|
132
|
+
* whose MAC verifies and whose `covers` list (written by `proof red-green`: the sources the
|
|
133
|
+
* `revert` mechanism put back to base, or the test file itself for `assertion-deletion`) names
|
|
134
|
+
* that file. A deleted file has nothing a test can exercise and is skipped (its review row is
|
|
135
|
+
* still required). Light-tier files need nothing.
|
|
136
|
+
* @param {{runId: string, files: Array<{file: string, content_hash: string}>, rows: Array<Record<string, any>>, key: Buffer, highPaths: ReadonlyArray<string>}} opts
|
|
137
|
+
* `runId` and `highPaths` are required: a proof row counts only when `r.run === runId` (a row
|
|
138
|
+
* of another run, or with no `run`, never covers anything), and there is no "no high path" default.
|
|
139
|
+
* @returns {Refusal[]} one `unproven` refusal per uncovered high-tier file.
|
|
140
|
+
* @throws {TypeError} `runId` or `highPaths` undefined
|
|
141
|
+
*/
|
|
142
|
+
export function checkBlockProof({ runId, files, rows, key, highPaths }) {
|
|
143
|
+
if (runId === undefined) throw new TypeError('checkBlockProof: runId is required (a proof row counts only for its own run)');
|
|
144
|
+
if (highPaths === undefined) throw new TypeError('checkBlockProof: highPaths is required (proof.tiers.high.paths)');
|
|
145
|
+
const high = files.filter((f) => f.content_hash !== DELETED_HASH && closeTier({ file: f.file, rows, key, highPaths }) === 'high');
|
|
146
|
+
if (high.length === 0) return [];
|
|
147
|
+
const proofs = rows.filter(
|
|
148
|
+
(r) =>
|
|
149
|
+
r?.event === 'proof' &&
|
|
150
|
+
r.step === 'red-green' &&
|
|
151
|
+
r.proven === true &&
|
|
152
|
+
r.red_kind === 'assertion' &&
|
|
153
|
+
r.run === runId &&
|
|
154
|
+
Array.isArray(r.covers) &&
|
|
155
|
+
verified(r, key),
|
|
156
|
+
);
|
|
157
|
+
return high
|
|
158
|
+
.filter(({ file }) => !proofs.some((r) => r.covers.includes(file)))
|
|
159
|
+
.map(({ file }) => ({ code: /** @type {const} */ ('unproven'), file, detail: `${file} is high tier and has no proven red→green row covering it` }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A file as the gate names it: a repo-relative POSIX path with `./` segments dropped. This is the
|
|
164
|
+
* ONE normalization of the block file set and of a proof row's `covers` (B20), so a path written
|
|
165
|
+
* by `proof red-green` compares equal to the gate's changed-file path. An absolute path, a `..`
|
|
166
|
+
* escape, a backslash or a NUL is refused.
|
|
167
|
+
* @param {string} raw @returns {string}
|
|
168
|
+
* @throws {StateError} `bad-path`
|
|
169
|
+
*/
|
|
170
|
+
export function repoRelativePath(raw) {
|
|
171
|
+
if (typeof raw !== 'string' || raw.length === 0 || path.posix.isAbsolute(raw) || raw.includes('\\') || raw.includes('\0')) {
|
|
172
|
+
throw new StateError('bad-path', 'a block file must be a repo-relative POSIX path');
|
|
173
|
+
}
|
|
174
|
+
const normalized = path.posix.normalize(raw);
|
|
175
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) throw new StateError('bad-path', 'a block file must stay inside the repository');
|
|
176
|
+
return normalized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
|
|
181
|
+
* `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths; a glob owned entry matches through `matchOwned`), each with its content hash.
|
|
182
|
+
* Every path goes through `repoRelativePath`.
|
|
183
|
+
* @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean}} opts
|
|
184
|
+
* @returns {Promise<Array<{file: string, content_hash: string}>>}
|
|
185
|
+
*/
|
|
186
|
+
export async function blockFileSet({ repoRoot, base, owned, matchOwned }) {
|
|
187
|
+
if (typeof base !== 'string' || !/^[0-9a-f]{7,64}$/.test(base)) throw new StateError('bad-ref', 'the block base must be a commit id');
|
|
188
|
+
const env = gitChildEnv();
|
|
189
|
+
const changed = await exec(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], { cwd: repoRoot, env, timeoutMs: 30000 });
|
|
190
|
+
const untracked = await exec(['git', 'ls-files', '-z', '--others', '--exclude-standard'], { cwd: repoRoot, env, timeoutMs: 30000 });
|
|
191
|
+
if (changed.result !== 'ok' || untracked.result !== 'ok') throw new StateError('git-failed', 'git could not list the block file set');
|
|
192
|
+
const all = new Set(
|
|
193
|
+
[...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0).map((f) => repoRelativePath(f)),
|
|
194
|
+
);
|
|
195
|
+
const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
|
|
196
|
+
return [...all]
|
|
197
|
+
.filter((f) => isOwned(f))
|
|
198
|
+
.sort()
|
|
199
|
+
.map((file) => ({ file, content_hash: isOnDisk(repoRoot, file) ? contentHash(repoRoot, file) : DELETED_HASH }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A deleted owned file (its directory may be gone too) is a change that needs review; its hash is
|
|
204
|
+
* `DELETED_HASH`, never a read of a path that is not there.
|
|
205
|
+
* @param {string} repoRoot @param {string} file @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
function isOnDisk(repoRoot, file) {
|
|
208
|
+
try {
|
|
209
|
+
lstatSync(path.join(repoRoot, file));
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason.
|
|
218
|
+
* @param {() => Promise<Parameters<typeof checkBlockReviews>[0]>} load
|
|
219
|
+
* @returns {() => Promise<{ok: boolean, reason?: string}>}
|
|
220
|
+
*/
|
|
221
|
+
export function reviewGateCheck(load) {
|
|
222
|
+
return async () => {
|
|
223
|
+
const result = checkBlockReviews(await load());
|
|
224
|
+
if (result.ok) return { ok: true };
|
|
225
|
+
return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `block waive` — a signed `review.waived` row, `by: human`, scoped to ONE file (`file` is
|
|
231
|
+
* required: the gate matches a waiver by `file` + `finding`). The block must exist in the run.
|
|
232
|
+
* @param {{runId: string, id: string, finding: string, reason: string, file: string, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
233
|
+
* @returns {Promise<Record<string, any>>} the signed row.
|
|
234
|
+
*/
|
|
235
|
+
export async function waiveFinding({ runId, id, finding, reason, file, writeRow }) {
|
|
236
|
+
if (typeof finding !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(finding)) throw new StateError('usage', 'a finding id is required (letters, digits, . _ : -)');
|
|
237
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block waive needs --reason');
|
|
238
|
+
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block waive needs --file <path>');
|
|
239
|
+
if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./')) throw new StateError('usage', '--file must be repo-root-relative');
|
|
240
|
+
const record = await readRun(runId);
|
|
241
|
+
if (!record.blocks?.[id]) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
242
|
+
const row = { event: 'review.waived', block: id, file, finding, reason: reason.trim(), by: 'human' };
|
|
243
|
+
let written = null;
|
|
244
|
+
await writeSigned(runId, async (signed) => {
|
|
245
|
+
written = signed;
|
|
246
|
+
return writeRow(signed);
|
|
247
|
+
}, row);
|
|
248
|
+
return /** @type {Record<string, any>} */ (written);
|
|
249
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Lens: A (correctness and edge cases)
|
|
2
|
+
|
|
3
|
+
You are reviewer A. Another reviewer covers contracts and security; you cover CORRECTNESS.
|
|
4
|
+
You work blind: you do not see the other reviewer's answer.
|
|
5
|
+
|
|
6
|
+
## What to look for
|
|
7
|
+
|
|
8
|
+
- Every branch of the changed code: is each one reachable and right?
|
|
9
|
+
- Boundaries: empty, zero, one, many, maximum; off-by-one in loops, slices and ranges.
|
|
10
|
+
- Error paths: what happens when a call fails, a file is missing, input is malformed.
|
|
11
|
+
- State: ordering, concurrency, partial writes, values that can be stale.
|
|
12
|
+
- Arithmetic and units: rounding, overflow, bytes versus characters, time zones.
|
|
13
|
+
|
|
14
|
+
## Rules for every answer
|
|
15
|
+
|
|
16
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
17
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
18
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
19
|
+
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
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
|
+
- `passed` is true only when you found no `critical` and no `warning`.
|
|
22
|
+
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Lens: B (contracts, project rules, security)
|
|
2
|
+
|
|
3
|
+
You are reviewer B. Another reviewer covers correctness; you cover CONTRACTS, RULES and SECURITY.
|
|
4
|
+
You work blind: you do not see the other reviewer's answer.
|
|
5
|
+
|
|
6
|
+
## What to look for
|
|
7
|
+
|
|
8
|
+
- Contracts: the shape and meaning of every input and output the change touches; callers that
|
|
9
|
+
would break; a changed default.
|
|
10
|
+
- Project rules: every rule in the packet's rules digest that the change could violate.
|
|
11
|
+
- Security: secrets in code, logs or messages; shell strings instead of argument arrays; paths
|
|
12
|
+
that can escape a root (`..`, absolute, symlinks); missing authorization; unsafe defaults.
|
|
13
|
+
- Facts: a flag, path or API the change relies on that the facts excerpt contradicts.
|
|
14
|
+
|
|
15
|
+
## Rules for every answer
|
|
16
|
+
|
|
17
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
19
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
|
+
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
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
|
+
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
|
+
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Lens: full
|
|
2
|
+
|
|
3
|
+
You are a code reviewer doing a FULL review of one changed file.
|
|
4
|
+
|
|
5
|
+
## What to look for
|
|
6
|
+
|
|
7
|
+
- Correctness: every changed branch, boundary and error path; inputs the code does not guard.
|
|
8
|
+
- Contracts: what callers and callees expect (types, return shapes, units, null/empty cases).
|
|
9
|
+
- Tests: whether the change is tested where the diff shows tests; a test that cannot fail.
|
|
10
|
+
- Security: secrets, injection (shell strings, unescaped input), paths that escape a root, unsafe defaults.
|
|
11
|
+
- Project rules given in the packet's rules digest.
|
|
12
|
+
|
|
13
|
+
Report each real defect once, at the lines where it lives.
|
|
14
|
+
|
|
15
|
+
## Rules for every answer
|
|
16
|
+
|
|
17
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
19
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
|
+
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
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
|
+
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
|
+
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Lens: judge
|
|
2
|
+
|
|
3
|
+
You are the judge. Two independent reviewers (A and B) reviewed the same change blind. You get
|
|
4
|
+
their two answers, the list of hunk headers, and the diff only when the packet includes it.
|
|
5
|
+
|
|
6
|
+
## Your job
|
|
7
|
+
|
|
8
|
+
- Decide which findings are real. A finding both reviewers raised is `agreement: both`; one
|
|
9
|
+
only A raised is `A-only`; one only B raised is `B-only`. Set `agreement` on every finding you keep.
|
|
10
|
+
- Drop a finding whose evidence does not support its claim. Merge duplicates into one finding.
|
|
11
|
+
- Keep the reviewers' `file`, `line_start`, `line_end` unless they contradict each other; then
|
|
12
|
+
keep the narrower range.
|
|
13
|
+
- Your `findings` list is final: nothing you drop is fixed, everything you keep is.
|
|
14
|
+
- `passed` is true only when you keep no `critical` and no `warning`.
|
|
15
|
+
|
|
16
|
+
## Rules for every answer
|
|
17
|
+
|
|
18
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
19
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
20
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
21
|
+
- Answer with ONE JSON object that matches the answer schema.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Lens: quick
|
|
2
|
+
|
|
3
|
+
You are a code reviewer doing a QUICK pass on one small, low-risk change.
|
|
4
|
+
|
|
5
|
+
## What to look for
|
|
6
|
+
|
|
7
|
+
- Obvious bugs in the changed lines: wrong condition, wrong variable, off-by-one, a missing `await`, an unhandled error path.
|
|
8
|
+
- A change that plainly does not do what its own code and names say.
|
|
9
|
+
- Anything that leaks a secret or writes outside the paths the change is about.
|
|
10
|
+
|
|
11
|
+
Skip style, naming and structure unless it hides a bug. Keep `findings` short.
|
|
12
|
+
|
|
13
|
+
## Rules for every answer
|
|
14
|
+
|
|
15
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
16
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
17
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
18
|
+
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
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
|
+
- `passed` is true only when you found no `critical` and no `warning`.
|
|
21
|
+
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Lens: recheck
|
|
2
|
+
|
|
3
|
+
The packet is a fix; say per finding whether it is resolved; report a new problem only if it is inside the shown hunks.
|
|
4
|
+
|
|
5
|
+
You get the open findings (by `id`) from the previous round and the FIX HUNKS: only the lines
|
|
6
|
+
that changed since that round, each with a window of the current file around it.
|
|
7
|
+
|
|
8
|
+
## Your job
|
|
9
|
+
|
|
10
|
+
- For every open finding, add one entry to `resolved`: `{id, resolved: true|false, why}`
|
|
11
|
+
(`why` ≤ 200 chars). `resolved: true` only when the shown lines fix the problem without
|
|
12
|
+
introducing a new one.
|
|
13
|
+
- A new problem goes in `findings` ONLY when its `line_start..line_end` lies inside a shown fix
|
|
14
|
+
hunk. Anything outside the hunks is not yours to report in this round.
|
|
15
|
+
- `passed` is true only when every open finding is resolved and you raised no new `critical`
|
|
16
|
+
or `warning`.
|
|
17
|
+
|
|
18
|
+
## Rules for every answer
|
|
19
|
+
|
|
20
|
+
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
21
|
+
- You have no tools and no project files. The packet is everything you get.
|
|
22
|
+
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
23
|
+
- Answer with ONE JSON object that matches the answer schema.
|