@bookedsolid/rea 0.10.3 → 0.11.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/.husky/pre-push +22 -167
- package/agents/codex-adversarial.md +5 -3
- package/commands/codex-review.md +3 -5
- package/dist/audit/append.d.ts +7 -32
- package/dist/audit/append.js +7 -35
- package/dist/cli/audit.d.ts +0 -31
- package/dist/cli/audit.js +5 -74
- package/dist/cli/doctor.js +6 -16
- package/dist/cli/hook.d.ts +48 -0
- package/dist/cli/hook.js +127 -0
- package/dist/cli/index.js +5 -80
- package/dist/cli/init.js +1 -1
- package/dist/cli/install/gitignore.d.ts +2 -2
- package/dist/cli/install/gitignore.js +3 -3
- package/dist/cli/install/pre-push.d.ts +146 -271
- package/dist/cli/install/pre-push.js +471 -2633
- package/dist/cli/install/settings-merge.d.ts +17 -0
- package/dist/cli/install/settings-merge.js +48 -1
- package/dist/cli/upgrade.js +131 -3
- package/dist/config/tier-map.js +18 -25
- package/dist/hooks/push-gate/base.d.ts +57 -0
- package/dist/hooks/push-gate/base.js +77 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
- package/dist/hooks/push-gate/codex-runner.js +223 -0
- package/dist/hooks/push-gate/findings.d.ts +68 -0
- package/dist/hooks/push-gate/findings.js +142 -0
- package/dist/hooks/push-gate/halt.d.ts +28 -0
- package/dist/hooks/push-gate/halt.js +49 -0
- package/dist/hooks/push-gate/index.d.ts +90 -0
- package/dist/hooks/push-gate/index.js +351 -0
- package/dist/hooks/push-gate/policy.d.ts +41 -0
- package/dist/hooks/push-gate/policy.js +55 -0
- package/dist/hooks/push-gate/report.d.ts +89 -0
- package/dist/hooks/push-gate/report.js +140 -0
- package/dist/policy/loader.d.ts +10 -10
- package/dist/policy/loader.js +7 -6
- package/dist/policy/types.d.ts +31 -22
- package/package.json +1 -1
- package/dist/cache/review-cache.d.ts +0 -115
- package/dist/cache/review-cache.js +0 -200
- package/dist/cli/cache.d.ts +0 -84
- package/dist/cli/cache.js +0 -150
- package/dist/hooks/review-gate/args.d.ts +0 -126
- package/dist/hooks/review-gate/args.js +0 -315
- package/dist/hooks/review-gate/audit.d.ts +0 -131
- package/dist/hooks/review-gate/audit.js +0 -181
- package/dist/hooks/review-gate/banner.d.ts +0 -97
- package/dist/hooks/review-gate/banner.js +0 -172
- package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
- package/dist/hooks/review-gate/base-resolve.js +0 -247
- package/dist/hooks/review-gate/cache-key.d.ts +0 -55
- package/dist/hooks/review-gate/cache-key.js +0 -41
- package/dist/hooks/review-gate/cache.d.ts +0 -108
- package/dist/hooks/review-gate/cache.js +0 -120
- package/dist/hooks/review-gate/constants.d.ts +0 -26
- package/dist/hooks/review-gate/constants.js +0 -34
- package/dist/hooks/review-gate/diff.d.ts +0 -181
- package/dist/hooks/review-gate/diff.js +0 -232
- package/dist/hooks/review-gate/errors.d.ts +0 -72
- package/dist/hooks/review-gate/errors.js +0 -100
- package/dist/hooks/review-gate/hash.d.ts +0 -43
- package/dist/hooks/review-gate/hash.js +0 -46
- package/dist/hooks/review-gate/index.d.ts +0 -31
- package/dist/hooks/review-gate/index.js +0 -35
- package/dist/hooks/review-gate/metadata.d.ts +0 -98
- package/dist/hooks/review-gate/metadata.js +0 -158
- package/dist/hooks/review-gate/policy.d.ts +0 -55
- package/dist/hooks/review-gate/policy.js +0 -71
- package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
- package/dist/hooks/review-gate/protected-paths.js +0 -76
- package/hooks/_lib/push-review-core.sh +0 -1250
- package/hooks/commit-review-gate.sh +0 -330
- package/hooks/push-review-gate-git.sh +0 -94
- package/hooks/push-review-gate.sh +0 -92
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI runner for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Shells to `codex exec review --base <ref> --json --ephemeral` and consumes
|
|
5
|
+
* the JSONL event stream. Every event is parsed; the sequence of
|
|
6
|
+
* `agent_message` items becomes the review text that `findings.ts` then
|
|
7
|
+
* parses for P1/P2/P3 markers.
|
|
8
|
+
*
|
|
9
|
+
* Errors are typed so `index.ts` can distinguish:
|
|
10
|
+
*
|
|
11
|
+
* - `CodexNotInstalledError` → clear install-Codex prompt
|
|
12
|
+
* - `CodexTimeoutError` → `review.timeout_ms` exceeded; kill signal
|
|
13
|
+
* - `CodexProtocolError` → stdout was not JSONL or lacked agent output
|
|
14
|
+
* - `CodexSubprocessError` → non-zero exit with captured stderr
|
|
15
|
+
*
|
|
16
|
+
* The `GitExecutor` interface is a narrow shim around `git` invocations the
|
|
17
|
+
* gate needs (base resolution, diff-names, HEAD resolution). Extracted so
|
|
18
|
+
* `./base.ts` and `./index.ts` can be unit-tested with deterministic fakes
|
|
19
|
+
* and so the one git dependency surface is in one place.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Errors
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export class CodexNotInstalledError extends Error {
|
|
26
|
+
kind = 'not-installed';
|
|
27
|
+
constructor() {
|
|
28
|
+
super('codex CLI not found on PATH. Install with `npm i -g @openai/codex`, or set `review.codex_required: false` in .rea/policy.yaml to disable the push-gate.');
|
|
29
|
+
this.name = 'CodexNotInstalledError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class CodexTimeoutError extends Error {
|
|
33
|
+
timeoutMs;
|
|
34
|
+
kind = 'timeout';
|
|
35
|
+
constructor(timeoutMs) {
|
|
36
|
+
super(`codex exec review exceeded policy.review.timeout_ms (${timeoutMs}ms). The subprocess was killed. Consider raising the timeout, narrowing the diff, or running /codex-review manually to debug.`);
|
|
37
|
+
this.timeoutMs = timeoutMs;
|
|
38
|
+
this.name = 'CodexTimeoutError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class CodexProtocolError extends Error {
|
|
42
|
+
detail;
|
|
43
|
+
sampleLine;
|
|
44
|
+
kind = 'protocol';
|
|
45
|
+
constructor(detail, sampleLine) {
|
|
46
|
+
super(`codex exec review produced unexpected output: ${detail}${sampleLine !== undefined ? ` (sample: ${sampleLine.slice(0, 120)})` : ''}`);
|
|
47
|
+
this.detail = detail;
|
|
48
|
+
this.sampleLine = sampleLine;
|
|
49
|
+
this.name = 'CodexProtocolError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class CodexSubprocessError extends Error {
|
|
53
|
+
exitCode;
|
|
54
|
+
signal;
|
|
55
|
+
stderrTail;
|
|
56
|
+
kind = 'subprocess';
|
|
57
|
+
constructor(exitCode, signal, stderrTail) {
|
|
58
|
+
super(`codex exec review exited ${exitCode !== null ? `with code ${exitCode}` : `via signal ${signal ?? 'unknown'}`}. stderr tail: ${stderrTail.slice(-800)}`);
|
|
59
|
+
this.exitCode = exitCode;
|
|
60
|
+
this.signal = signal;
|
|
61
|
+
this.stderrTail = stderrTail;
|
|
62
|
+
this.name = 'CodexSubprocessError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Real git implementation using `spawnSync`. Each call is independent (no
|
|
67
|
+
* persistent git process) — the gate runs infrequently enough that the
|
|
68
|
+
* fork overhead is inaudible.
|
|
69
|
+
*/
|
|
70
|
+
export function createRealGitExecutor(cwd) {
|
|
71
|
+
const run = (args) => {
|
|
72
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
73
|
+
return {
|
|
74
|
+
code: r.status ?? -1,
|
|
75
|
+
stdout: typeof r.stdout === 'string' ? r.stdout : '',
|
|
76
|
+
stderr: typeof r.stderr === 'string' ? r.stderr : '',
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
tryRevParse(args) {
|
|
81
|
+
const r = run(['rev-parse', ...args]);
|
|
82
|
+
return r.code === 0 ? r.stdout.trim() : '';
|
|
83
|
+
},
|
|
84
|
+
trySymbolicRef(ref) {
|
|
85
|
+
const r = run(['symbolic-ref', ref]);
|
|
86
|
+
return r.code === 0 ? r.stdout.trim() : '';
|
|
87
|
+
},
|
|
88
|
+
headSha() {
|
|
89
|
+
const r = run(['rev-parse', 'HEAD']);
|
|
90
|
+
return r.code === 0 ? r.stdout.trim() : '';
|
|
91
|
+
},
|
|
92
|
+
diffNames(base, head) {
|
|
93
|
+
const r = run(['diff', '--name-only', base, head]);
|
|
94
|
+
if (r.code !== 0)
|
|
95
|
+
return [];
|
|
96
|
+
return r.stdout.split(/\r?\n/).filter((l) => l.length > 0);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Execute `codex exec review` and return the concatenated review text on
|
|
102
|
+
* success. Callers then pass the text to `summarizeReview()` to get a
|
|
103
|
+
* structured verdict.
|
|
104
|
+
*
|
|
105
|
+
* Every error case throws a typed `CodexRunError`. Callers are expected to
|
|
106
|
+
* catch and translate to an exit code + audit event.
|
|
107
|
+
*/
|
|
108
|
+
export async function runCodexReview(options) {
|
|
109
|
+
const spawner = options.spawnImpl ?? spawn;
|
|
110
|
+
const baseArgs = ['exec', 'review', '--base', options.baseRef, '--json', '--ephemeral'];
|
|
111
|
+
const args = options.prompt !== undefined && options.prompt.length > 0 ? [...baseArgs, options.prompt] : baseArgs;
|
|
112
|
+
let child;
|
|
113
|
+
try {
|
|
114
|
+
child = spawner('codex', args, {
|
|
115
|
+
cwd: options.cwd,
|
|
116
|
+
env: options.env ?? process.env,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (isEnoent(e))
|
|
121
|
+
throw new CodexNotInstalledError();
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
const stdoutChunks = [];
|
|
125
|
+
const stderrChunks = [];
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
// SIGTERM first; graceful shutdown. Codex cleans up its session files
|
|
130
|
+
// on SIGTERM. We don't escalate to SIGKILL here — if the subprocess
|
|
131
|
+
// hangs the event loop's own timeout handling will surface it.
|
|
132
|
+
child.kill('SIGTERM');
|
|
133
|
+
reject(new CodexTimeoutError(options.timeoutMs));
|
|
134
|
+
}, options.timeoutMs);
|
|
135
|
+
timer.unref?.();
|
|
136
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
137
|
+
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
|
|
138
|
+
child.on('error', (e) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
if (isEnoent(e)) {
|
|
141
|
+
reject(new CodexNotInstalledError());
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
reject(e);
|
|
145
|
+
});
|
|
146
|
+
// `close` (not `exit`) fires after BOTH stdio streams drain and the
|
|
147
|
+
// process has exited. Node can emit `exit` before the final stdout
|
|
148
|
+
// chunks are flushed on large reviews or slow pipes, causing
|
|
149
|
+
// `parseCodexJsonl()` to run against a truncated buffer and
|
|
150
|
+
// misclassify a blocking review as pass. Waiting for `close`
|
|
151
|
+
// guarantees every agent_message chunk is in `stdoutChunks`.
|
|
152
|
+
child.on('close', (code, signal) => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
if (code === null && signal !== null) {
|
|
155
|
+
reject(new CodexSubprocessError(null, signal, Buffer.concat(stderrChunks).toString('utf8')));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
resolve(code);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
const durationSeconds = (Date.now() - started) / 1000;
|
|
162
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
163
|
+
throw new CodexSubprocessError(exitCode, null, Buffer.concat(stderrChunks).toString('utf8'));
|
|
164
|
+
}
|
|
165
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
166
|
+
const { reviewText, eventCount } = parseCodexJsonl(stdout);
|
|
167
|
+
return { reviewText, eventCount, durationSeconds };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Parse the JSONL event stream emitted by `codex exec review --json`. We
|
|
171
|
+
* tolerate partial lines (stream chunks may split mid-object; our caller
|
|
172
|
+
* gives us the full stdout after exit, but robustness costs nothing).
|
|
173
|
+
*
|
|
174
|
+
* The only events we care about are `item.completed` where `item.type ===
|
|
175
|
+
* "agent_message"` — those carry the review text. Everything else (turn
|
|
176
|
+
* lifecycle, command_execution telemetry, thread metadata) is counted but
|
|
177
|
+
* discarded.
|
|
178
|
+
*
|
|
179
|
+
* A JSONL line that doesn't parse as JSON is tolerated: we skip it and
|
|
180
|
+
* continue. Codex occasionally emits warnings outside the JSON envelope
|
|
181
|
+
* (e.g. macOS xcrun cache errors leak into stderr but can accidentally
|
|
182
|
+
* land on stdout in misbehaving shells); we treat these as non-fatal.
|
|
183
|
+
*
|
|
184
|
+
* We throw `CodexProtocolError` only when the ENTIRE stdout contains zero
|
|
185
|
+
* parseable events AND zero `agent_message`-carrying items. An empty diff
|
|
186
|
+
* can legitimately yield zero agent messages with events (thread.started,
|
|
187
|
+
* turn.started, turn.completed), so we allow zero findings when at least
|
|
188
|
+
* one event parsed.
|
|
189
|
+
*/
|
|
190
|
+
export function parseCodexJsonl(stdout) {
|
|
191
|
+
const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0);
|
|
192
|
+
let reviewText = '';
|
|
193
|
+
let eventCount = 0;
|
|
194
|
+
let parsedAny = false;
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
let evt;
|
|
197
|
+
try {
|
|
198
|
+
evt = JSON.parse(line);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Non-JSON line. Could be a shell warning that leaked to stdout. Skip.
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
parsedAny = true;
|
|
205
|
+
eventCount += 1;
|
|
206
|
+
if (evt.type === 'item.completed' &&
|
|
207
|
+
evt.item !== undefined &&
|
|
208
|
+
evt.item.type === 'agent_message' &&
|
|
209
|
+
typeof evt.item.text === 'string') {
|
|
210
|
+
reviewText = reviewText.length > 0 ? `${reviewText}\n\n${evt.item.text}` : evt.item.text;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!parsedAny && lines.length > 0) {
|
|
214
|
+
throw new CodexProtocolError('no parseable JSONL events in stdout', lines[0]);
|
|
215
|
+
}
|
|
216
|
+
return { reviewText, eventCount };
|
|
217
|
+
}
|
|
218
|
+
function isEnoent(e) {
|
|
219
|
+
if (e === null || typeof e !== 'object')
|
|
220
|
+
return false;
|
|
221
|
+
const code = e.code;
|
|
222
|
+
return code === 'ENOENT';
|
|
223
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding shape and verdict inference for the stateless push-gate.
|
|
3
|
+
*
|
|
4
|
+
* `codex exec review --json` emits JSONL events over stdout. The terminal
|
|
5
|
+
* event is an `item.completed` with `item.type === "agent_message"` whose
|
|
6
|
+
* `text` body is human-prose review output using Codex's standard severity
|
|
7
|
+
* convention:
|
|
8
|
+
*
|
|
9
|
+
* - `[P1]` — blocking. Must be addressed before merge.
|
|
10
|
+
* - `[P2]` — concerns. Significant risk the reviewer wants fixed.
|
|
11
|
+
* - `[P3]` — nits / low-priority suggestions.
|
|
12
|
+
*
|
|
13
|
+
* We extract one `Finding` per severity-marker bullet in the message text
|
|
14
|
+
* and infer a verdict:
|
|
15
|
+
*
|
|
16
|
+
* - Any `P1` → `blocking`
|
|
17
|
+
* - Else any `P2` → `concerns`
|
|
18
|
+
* - Else (P3 or nothing) → `pass`
|
|
19
|
+
*
|
|
20
|
+
* This is a text-parse, not a schema consumer. Codex does not expose a
|
|
21
|
+
* structured review schema through the JSONL event stream today (only
|
|
22
|
+
* `--output-schema` on plain `codex exec` does that). When the plugin
|
|
23
|
+
* ecosystem catches up we can swap the parser without touching the gate.
|
|
24
|
+
*/
|
|
25
|
+
export type Severity = 'P1' | 'P2' | 'P3';
|
|
26
|
+
export type Verdict = 'pass' | 'concerns' | 'blocking';
|
|
27
|
+
export interface Finding {
|
|
28
|
+
severity: Severity;
|
|
29
|
+
title: string;
|
|
30
|
+
/** File path, when the marker line carried one. */
|
|
31
|
+
file?: string;
|
|
32
|
+
/** Starting line number, when the marker carried `<path>:<line>`. */
|
|
33
|
+
line?: number;
|
|
34
|
+
/** Full body of the finding (all lines up to the next marker or EOF). */
|
|
35
|
+
body: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ReviewSummary {
|
|
38
|
+
verdict: Verdict;
|
|
39
|
+
findings: Finding[];
|
|
40
|
+
/** The raw `agent_message` text, concatenated from every turn. */
|
|
41
|
+
reviewText: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse Codex review prose into structured findings. The parser is
|
|
45
|
+
* conservative — lines that don't start with a severity marker are folded
|
|
46
|
+
* into the previous finding's body. Unknown markers (`[P4]`, `[P0]`) are
|
|
47
|
+
* ignored; we only recognize P1/P2/P3.
|
|
48
|
+
*
|
|
49
|
+
* Expected marker shapes, matched line-by-line:
|
|
50
|
+
*
|
|
51
|
+
* - [P1] Title goes here — path/to/file.ts:42
|
|
52
|
+
* - [P1] Title — path/to/file.ts
|
|
53
|
+
* - [P1] Title
|
|
54
|
+
*
|
|
55
|
+
* The dash/bullet prefix is optional (Codex emits both `- [P1]` and bare
|
|
56
|
+
* `[P1]` depending on model and prompt). Whitespace around the severity
|
|
57
|
+
* marker is tolerated.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseFindings(reviewText: string): Finding[];
|
|
60
|
+
/**
|
|
61
|
+
* Map a finding array to a single verdict. Safe-fail order: any P1 wins,
|
|
62
|
+
* then any P2, else pass.
|
|
63
|
+
*/
|
|
64
|
+
export declare function inferVerdict(findings: Finding[]): Verdict;
|
|
65
|
+
/**
|
|
66
|
+
* Convenience: parse + infer in one call.
|
|
67
|
+
*/
|
|
68
|
+
export declare function summarizeReview(reviewText: string): ReviewSummary;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding shape and verdict inference for the stateless push-gate.
|
|
3
|
+
*
|
|
4
|
+
* `codex exec review --json` emits JSONL events over stdout. The terminal
|
|
5
|
+
* event is an `item.completed` with `item.type === "agent_message"` whose
|
|
6
|
+
* `text` body is human-prose review output using Codex's standard severity
|
|
7
|
+
* convention:
|
|
8
|
+
*
|
|
9
|
+
* - `[P1]` — blocking. Must be addressed before merge.
|
|
10
|
+
* - `[P2]` — concerns. Significant risk the reviewer wants fixed.
|
|
11
|
+
* - `[P3]` — nits / low-priority suggestions.
|
|
12
|
+
*
|
|
13
|
+
* We extract one `Finding` per severity-marker bullet in the message text
|
|
14
|
+
* and infer a verdict:
|
|
15
|
+
*
|
|
16
|
+
* - Any `P1` → `blocking`
|
|
17
|
+
* - Else any `P2` → `concerns`
|
|
18
|
+
* - Else (P3 or nothing) → `pass`
|
|
19
|
+
*
|
|
20
|
+
* This is a text-parse, not a schema consumer. Codex does not expose a
|
|
21
|
+
* structured review schema through the JSONL event stream today (only
|
|
22
|
+
* `--output-schema` on plain `codex exec` does that). When the plugin
|
|
23
|
+
* ecosystem catches up we can swap the parser without touching the gate.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Parse Codex review prose into structured findings. The parser is
|
|
27
|
+
* conservative — lines that don't start with a severity marker are folded
|
|
28
|
+
* into the previous finding's body. Unknown markers (`[P4]`, `[P0]`) are
|
|
29
|
+
* ignored; we only recognize P1/P2/P3.
|
|
30
|
+
*
|
|
31
|
+
* Expected marker shapes, matched line-by-line:
|
|
32
|
+
*
|
|
33
|
+
* - [P1] Title goes here — path/to/file.ts:42
|
|
34
|
+
* - [P1] Title — path/to/file.ts
|
|
35
|
+
* - [P1] Title
|
|
36
|
+
*
|
|
37
|
+
* The dash/bullet prefix is optional (Codex emits both `- [P1]` and bare
|
|
38
|
+
* `[P1]` depending on model and prompt). Whitespace around the severity
|
|
39
|
+
* marker is tolerated.
|
|
40
|
+
*/
|
|
41
|
+
export function parseFindings(reviewText) {
|
|
42
|
+
const lines = reviewText.split(/\r?\n/);
|
|
43
|
+
const out = [];
|
|
44
|
+
let current = null;
|
|
45
|
+
// Anchored at the start of a trimmed line — an inline `[P1]` in the
|
|
46
|
+
// middle of a sentence is not a finding marker. The `^` excludes the
|
|
47
|
+
// all-text prefix inside the match itself; we trim before testing.
|
|
48
|
+
const MARKER_RE = /^(?:[-*]\s*)?\[(P[123])\]\s+(.+?)\s*$/;
|
|
49
|
+
for (const rawLine of lines) {
|
|
50
|
+
const trimmed = rawLine.trim();
|
|
51
|
+
const match = MARKER_RE.exec(trimmed);
|
|
52
|
+
if (match !== null) {
|
|
53
|
+
if (current !== null)
|
|
54
|
+
out.push(current);
|
|
55
|
+
const severity = match[1];
|
|
56
|
+
const titleWithLocation = match[2] ?? '';
|
|
57
|
+
const { title, file, line } = splitTitleLocation(titleWithLocation);
|
|
58
|
+
current = {
|
|
59
|
+
severity,
|
|
60
|
+
title,
|
|
61
|
+
body: rawLine,
|
|
62
|
+
...(file !== undefined ? { file } : {}),
|
|
63
|
+
...(line !== undefined ? { line } : {}),
|
|
64
|
+
};
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (current !== null) {
|
|
68
|
+
current.body = current.body.length > 0 ? `${current.body}\n${rawLine}` : rawLine;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (current !== null)
|
|
72
|
+
out.push(current);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Split "Title — file.ts:42" or "Title - file.ts" or bare "Title" into
|
|
77
|
+
* constituent parts. Codex emits an em-dash (`—`) as the separator in the
|
|
78
|
+
* default review prompt but we also accept `--` and `-` for robustness.
|
|
79
|
+
*/
|
|
80
|
+
function splitTitleLocation(raw) {
|
|
81
|
+
// Try em-dash first (Codex default), then double-dash, then single-dash
|
|
82
|
+
// surrounded by whitespace. A plain `-` inside a title (e.g. "pre-push")
|
|
83
|
+
// is preserved because we require surrounding whitespace.
|
|
84
|
+
let splitIdx = raw.indexOf(' — ');
|
|
85
|
+
let sepLen = 3;
|
|
86
|
+
if (splitIdx < 0) {
|
|
87
|
+
splitIdx = raw.indexOf(' -- ');
|
|
88
|
+
sepLen = 4;
|
|
89
|
+
}
|
|
90
|
+
if (splitIdx < 0) {
|
|
91
|
+
const dashMatch = / - /.exec(raw);
|
|
92
|
+
if (dashMatch !== null) {
|
|
93
|
+
splitIdx = dashMatch.index;
|
|
94
|
+
sepLen = 3;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (splitIdx < 0) {
|
|
98
|
+
return { title: raw.trim() };
|
|
99
|
+
}
|
|
100
|
+
const title = raw.slice(0, splitIdx).trim();
|
|
101
|
+
const locationRaw = raw.slice(splitIdx + sepLen).trim();
|
|
102
|
+
// `path/to/file.ts:42` or `path/to/file.ts:42-48` or bare `path/to/file.ts`.
|
|
103
|
+
const locMatch = /^([^\s:]+?)(?::(\d+)(?:-\d+)?)?$/.exec(locationRaw);
|
|
104
|
+
if (locMatch === null) {
|
|
105
|
+
// Location we can't parse — keep the whole thing as the title so we
|
|
106
|
+
// don't silently drop it.
|
|
107
|
+
return { title: raw.trim() };
|
|
108
|
+
}
|
|
109
|
+
const file = locMatch[1];
|
|
110
|
+
const lineStr = locMatch[2];
|
|
111
|
+
const result = { title };
|
|
112
|
+
if (file !== undefined && file.length > 0)
|
|
113
|
+
result.file = file;
|
|
114
|
+
if (lineStr !== undefined && lineStr.length > 0) {
|
|
115
|
+
const n = Number.parseInt(lineStr, 10);
|
|
116
|
+
if (Number.isFinite(n) && n > 0)
|
|
117
|
+
result.line = n;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Map a finding array to a single verdict. Safe-fail order: any P1 wins,
|
|
123
|
+
* then any P2, else pass.
|
|
124
|
+
*/
|
|
125
|
+
export function inferVerdict(findings) {
|
|
126
|
+
if (findings.some((f) => f.severity === 'P1'))
|
|
127
|
+
return 'blocking';
|
|
128
|
+
if (findings.some((f) => f.severity === 'P2'))
|
|
129
|
+
return 'concerns';
|
|
130
|
+
return 'pass';
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Convenience: parse + infer in one call.
|
|
134
|
+
*/
|
|
135
|
+
export function summarizeReview(reviewText) {
|
|
136
|
+
const findings = parseFindings(reviewText);
|
|
137
|
+
return {
|
|
138
|
+
verdict: inferVerdict(findings),
|
|
139
|
+
findings,
|
|
140
|
+
reviewText,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HALT kill-switch reader for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* The push-gate is a pure composition (see `./index.ts`). `readHalt()` is the
|
|
5
|
+
* only side-effectful probe that must run before policy is even consulted —
|
|
6
|
+
* HALT overrides every other signal, including `review.codex_required: false`.
|
|
7
|
+
*
|
|
8
|
+
* `.rea/HALT` is a short plain-text file. Content is not structured — the
|
|
9
|
+
* first non-empty line is the "reason" we surface to the operator. Absence
|
|
10
|
+
* of the file means "not halted"; presence means "block every gated
|
|
11
|
+
* operation until `rea unfreeze`".
|
|
12
|
+
*/
|
|
13
|
+
export interface HaltState {
|
|
14
|
+
halted: boolean;
|
|
15
|
+
/** Present only when `halted === true`. Trimmed first line. */
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read `.rea/HALT` from `baseDir`. Never throws — filesystem errors collapse
|
|
20
|
+
* to `{ halted: false }` so a corrupted read does not silently block the
|
|
21
|
+
* operator. The fail-closed posture lives in the caller (`runPushGate`) when
|
|
22
|
+
* the gate is asked to assess HALT and cannot.
|
|
23
|
+
*
|
|
24
|
+
* We explicitly do NOT reuse `src/cli/freeze.ts`'s reader — that one prompts
|
|
25
|
+
* via clack for unfreeze confirmation. The hook path must stay dependency-
|
|
26
|
+
* free and deterministic.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readHalt(baseDir: string): HaltState;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HALT kill-switch reader for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* The push-gate is a pure composition (see `./index.ts`). `readHalt()` is the
|
|
5
|
+
* only side-effectful probe that must run before policy is even consulted —
|
|
6
|
+
* HALT overrides every other signal, including `review.codex_required: false`.
|
|
7
|
+
*
|
|
8
|
+
* `.rea/HALT` is a short plain-text file. Content is not structured — the
|
|
9
|
+
* first non-empty line is the "reason" we surface to the operator. Absence
|
|
10
|
+
* of the file means "not halted"; presence means "block every gated
|
|
11
|
+
* operation until `rea unfreeze`".
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* Read `.rea/HALT` from `baseDir`. Never throws — filesystem errors collapse
|
|
17
|
+
* to `{ halted: false }` so a corrupted read does not silently block the
|
|
18
|
+
* operator. The fail-closed posture lives in the caller (`runPushGate`) when
|
|
19
|
+
* the gate is asked to assess HALT and cannot.
|
|
20
|
+
*
|
|
21
|
+
* We explicitly do NOT reuse `src/cli/freeze.ts`'s reader — that one prompts
|
|
22
|
+
* via clack for unfreeze confirmation. The hook path must stay dependency-
|
|
23
|
+
* free and deterministic.
|
|
24
|
+
*/
|
|
25
|
+
export function readHalt(baseDir) {
|
|
26
|
+
const p = path.join(baseDir, '.rea', 'HALT');
|
|
27
|
+
if (!fs.existsSync(p)) {
|
|
28
|
+
return { halted: false };
|
|
29
|
+
}
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = fs.readFileSync(p, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Unreadable HALT file is treated as "halted with unknown reason" — the
|
|
36
|
+
// file exists, so the operator intended to halt; we just can't read the
|
|
37
|
+
// message. Surfacing a generic reason preserves the kill-switch
|
|
38
|
+
// semantics without silently passing.
|
|
39
|
+
return { halted: true, reason: 'unknown (HALT file unreadable)' };
|
|
40
|
+
}
|
|
41
|
+
const firstLine = raw
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((l) => l.trim())
|
|
44
|
+
.find((l) => l.length > 0);
|
|
45
|
+
return {
|
|
46
|
+
halted: true,
|
|
47
|
+
reason: firstLine !== undefined && firstLine.length > 0 ? firstLine : 'unknown',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate composition — the pure orchestrator that `rea hook push-gate`
|
|
3
|
+
* calls.
|
|
4
|
+
*
|
|
5
|
+
* Contract: `runPushGate(deps)` returns a `GateResult` with an `exitCode`
|
|
6
|
+
* the CLI wrapper hands back to `git`. Exit codes:
|
|
7
|
+
*
|
|
8
|
+
* - `0` push proceeds (pass, disabled, skipped, empty-diff)
|
|
9
|
+
* - `1` HALT kill-switch active — rea unfreeze required
|
|
10
|
+
* - `2` blocked — blocking verdict, timeout, or protocol error
|
|
11
|
+
*
|
|
12
|
+
* The happy path is a single call: resolve policy → resolve base → spawn
|
|
13
|
+
* codex exec review → parse findings → write last-review.json → emit audit
|
|
14
|
+
* record → return exit code. No cache lookups, no SHA matching, no
|
|
15
|
+
* attestation gymnastics. Every push runs codex afresh; Codex is the
|
|
16
|
+
* source of truth.
|
|
17
|
+
*
|
|
18
|
+
* The function is pure-compositional: every external dependency (git,
|
|
19
|
+
* codex, halt, policy) is injected via `PushGateDeps`, which is the
|
|
20
|
+
* affordance tests use to replace subprocess calls with deterministic
|
|
21
|
+
* fakes. `runPushGate` never reaches for `process.env` or `process.cwd`
|
|
22
|
+
* directly — `deps.env` and `deps.baseDir` are the only ambient state.
|
|
23
|
+
*/
|
|
24
|
+
import { appendAuditRecord } from '../../audit/append.js';
|
|
25
|
+
import { type ResolvedReviewPolicy } from './policy.js';
|
|
26
|
+
import { type HaltState } from './halt.js';
|
|
27
|
+
import { runCodexReview, type GitExecutor } from './codex-runner.js';
|
|
28
|
+
import { type Verdict } from './findings.js';
|
|
29
|
+
import { writeLastReview } from './report.js';
|
|
30
|
+
export type GateStatus = 'pass' | 'concerns' | 'blocking' | 'halted' | 'disabled' | 'skipped' | 'empty-diff' | 'error';
|
|
31
|
+
export interface GateResult {
|
|
32
|
+
status: GateStatus;
|
|
33
|
+
exitCode: 0 | 1 | 2;
|
|
34
|
+
/** Human-readable summary suitable for the audit record `metadata.summary`. */
|
|
35
|
+
summary: string;
|
|
36
|
+
/** Non-empty only for 'pass' | 'concerns' | 'blocking'. */
|
|
37
|
+
verdict?: Verdict;
|
|
38
|
+
findingCount?: number;
|
|
39
|
+
baseRef?: string;
|
|
40
|
+
headSha?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A single refspec the pre-push stdin contract yields. Git passes one line
|
|
44
|
+
* per refspec being pushed: `<local_ref> <local_sha> <remote_ref> <remote_sha>`.
|
|
45
|
+
* See githooks(5) — Hook "pre-push".
|
|
46
|
+
*/
|
|
47
|
+
export interface PrePushRefspec {
|
|
48
|
+
localRef: string;
|
|
49
|
+
localSha: string;
|
|
50
|
+
remoteRef: string;
|
|
51
|
+
remoteSha: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse the raw pre-push stdin text into refspecs. Each line is four
|
|
55
|
+
* whitespace-separated fields. Blank lines and malformed lines are
|
|
56
|
+
* silently dropped — the empty result then falls through to the
|
|
57
|
+
* upstream-resolver path in `runPushGate`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parsePrePushStdin(raw: string): PrePushRefspec[];
|
|
60
|
+
export interface PushGateDeps {
|
|
61
|
+
baseDir: string;
|
|
62
|
+
env: NodeJS.ProcessEnv;
|
|
63
|
+
stderr: (line: string) => void;
|
|
64
|
+
/** Override via `--base <ref>`. Absent → auto-resolve. */
|
|
65
|
+
explicitBase?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Pre-push refspecs from git's stdin. Empty when invoked outside a
|
|
68
|
+
* pre-push context (manual `rea hook push-gate` from the CLI). When
|
|
69
|
+
* non-empty, the gate diffs each refspec's (remote_sha..local_sha) and
|
|
70
|
+
* reviews against the actual push target — matters when the operator
|
|
71
|
+
* does `git push origin HEAD:release/1.0` and the tracking branch is
|
|
72
|
+
* a different branch entirely.
|
|
73
|
+
*/
|
|
74
|
+
refspecs?: PrePushRefspec[];
|
|
75
|
+
/** Test seams; production wires these to the real implementations. */
|
|
76
|
+
git?: GitExecutor;
|
|
77
|
+
resolvePolicy?: (baseDir: string) => Promise<ResolvedReviewPolicy>;
|
|
78
|
+
readHalt?: (baseDir: string) => HaltState;
|
|
79
|
+
runCodex?: typeof runCodexReview;
|
|
80
|
+
writeLastReview?: typeof writeLastReview;
|
|
81
|
+
appendAudit?: typeof appendAuditRecord;
|
|
82
|
+
now?: () => Date;
|
|
83
|
+
}
|
|
84
|
+
export declare function runPushGate(deps: PushGateDeps): Promise<GateResult>;
|
|
85
|
+
export { resolvePushGatePolicy } from './policy.js';
|
|
86
|
+
export { readHalt } from './halt.js';
|
|
87
|
+
export { resolveBaseRef } from './base.js';
|
|
88
|
+
export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
|
|
89
|
+
export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
|
|
90
|
+
export { writeLastReview, renderBanner } from './report.js';
|