@bookedsolid/rea 0.10.3 → 0.12.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 +48 -162
- package/README.md +834 -552
- 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.d.ts +12 -0
- package/dist/cli/doctor.js +96 -17
- package/dist/cli/hook.d.ts +55 -0
- package/dist/cli/hook.js +138 -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 +158 -272
- package/dist/cli/install/pre-push.js +491 -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 +104 -0
- package/dist/hooks/push-gate/base.js +198 -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 +98 -0
- package/dist/hooks/push-gate/index.js +416 -0
- package/dist/hooks/push-gate/policy.d.ts +55 -0
- package/dist/hooks/push-gate/policy.js +64 -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 +15 -10
- package/dist/policy/loader.js +8 -6
- package/dist/policy/types.d.ts +73 -22
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
- 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,98 @@
|
|
|
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
|
+
* Override from the `--last-n-commits N` CLI flag. When set, the gate
|
|
68
|
+
* diffs against `HEAD~N` instead of running the upstream ladder. Wins
|
|
69
|
+
* over `policy.review.last_n_commits` but loses to `explicitBase`. When
|
|
70
|
+
* both `explicitBase` and this are set, `explicitBase` is used and a
|
|
71
|
+
* stderr warning is emitted noting the conflict.
|
|
72
|
+
*/
|
|
73
|
+
lastNCommits?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Pre-push refspecs from git's stdin. Empty when invoked outside a
|
|
76
|
+
* pre-push context (manual `rea hook push-gate` from the CLI). When
|
|
77
|
+
* non-empty, the gate diffs each refspec's (remote_sha..local_sha) and
|
|
78
|
+
* reviews against the actual push target — matters when the operator
|
|
79
|
+
* does `git push origin HEAD:release/1.0` and the tracking branch is
|
|
80
|
+
* a different branch entirely.
|
|
81
|
+
*/
|
|
82
|
+
refspecs?: PrePushRefspec[];
|
|
83
|
+
/** Test seams; production wires these to the real implementations. */
|
|
84
|
+
git?: GitExecutor;
|
|
85
|
+
resolvePolicy?: (baseDir: string) => Promise<ResolvedReviewPolicy>;
|
|
86
|
+
readHalt?: (baseDir: string) => HaltState;
|
|
87
|
+
runCodex?: typeof runCodexReview;
|
|
88
|
+
writeLastReview?: typeof writeLastReview;
|
|
89
|
+
appendAudit?: typeof appendAuditRecord;
|
|
90
|
+
now?: () => Date;
|
|
91
|
+
}
|
|
92
|
+
export declare function runPushGate(deps: PushGateDeps): Promise<GateResult>;
|
|
93
|
+
export { resolvePushGatePolicy } from './policy.js';
|
|
94
|
+
export { readHalt } from './halt.js';
|
|
95
|
+
export { resolveBaseRef } from './base.js';
|
|
96
|
+
export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
|
|
97
|
+
export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
|
|
98
|
+
export { writeLastReview, renderBanner } from './report.js';
|
|
@@ -0,0 +1,416 @@
|
|
|
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 path from 'node:path';
|
|
25
|
+
import { appendAuditRecord } from '../../audit/append.js';
|
|
26
|
+
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
27
|
+
import { resolvePushGatePolicy, } from './policy.js';
|
|
28
|
+
import { readHalt } from './halt.js';
|
|
29
|
+
import { resolveBaseRef } from './base.js';
|
|
30
|
+
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
|
|
31
|
+
import { summarizeReview } from './findings.js';
|
|
32
|
+
import { renderBanner, writeLastReview } from './report.js';
|
|
33
|
+
/**
|
|
34
|
+
* Parse the raw pre-push stdin text into refspecs. Each line is four
|
|
35
|
+
* whitespace-separated fields. Blank lines and malformed lines are
|
|
36
|
+
* silently dropped — the empty result then falls through to the
|
|
37
|
+
* upstream-resolver path in `runPushGate`.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePrePushStdin(raw) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (trimmed.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
const fields = trimmed.split(/\s+/);
|
|
46
|
+
if (fields.length !== 4)
|
|
47
|
+
continue;
|
|
48
|
+
const [localRef, localSha, remoteRef, remoteSha] = fields;
|
|
49
|
+
if (typeof localRef !== 'string' ||
|
|
50
|
+
typeof localSha !== 'string' ||
|
|
51
|
+
typeof remoteRef !== 'string' ||
|
|
52
|
+
typeof remoteSha !== 'string') {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push({ localRef, localSha, remoteRef, remoteSha });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Well-known "null SHA" in git's wire format. Pre-push sends this as
|
|
61
|
+
* `remote_sha` for a fresh remote ref (the branch doesn't exist yet on
|
|
62
|
+
* the remote) and as `local_sha` for a branch deletion.
|
|
63
|
+
*/
|
|
64
|
+
const NULL_SHA = '0000000000000000000000000000000000000000';
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Audit event names (advisory — no gate ever reads these back)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
const AUDIT_SERVER_NAME = 'rea';
|
|
69
|
+
const EVT_REVIEWED = 'rea.push_gate.reviewed';
|
|
70
|
+
const EVT_HALTED = 'rea.push_gate.halted';
|
|
71
|
+
const EVT_DISABLED = 'rea.push_gate.disabled';
|
|
72
|
+
const EVT_SKIPPED = 'rea.push_gate.skipped';
|
|
73
|
+
const EVT_EMPTY = 'rea.push_gate.empty_diff';
|
|
74
|
+
const EVT_ERROR = 'rea.push_gate.error';
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Composer
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export async function runPushGate(deps) {
|
|
79
|
+
const stderr = deps.stderr;
|
|
80
|
+
const env = deps.env;
|
|
81
|
+
const readHaltFn = deps.readHalt ?? readHalt;
|
|
82
|
+
const resolvePolicyFn = deps.resolvePolicy ?? resolvePushGatePolicy;
|
|
83
|
+
const writeLastReviewFn = deps.writeLastReview ?? writeLastReview;
|
|
84
|
+
const runCodexFn = deps.runCodex ?? runCodexReview;
|
|
85
|
+
const appendAuditFn = deps.appendAudit ?? appendAuditRecord;
|
|
86
|
+
const git = deps.git ?? createRealGitExecutor(deps.baseDir);
|
|
87
|
+
// 1. HALT wins over everything, including `review.codex_required: false`.
|
|
88
|
+
// Reading it before policy also means a corrupted policy.yaml doesn't
|
|
89
|
+
// prevent the kill-switch from firing.
|
|
90
|
+
const halt = readHaltFn(deps.baseDir);
|
|
91
|
+
if (halt.halted) {
|
|
92
|
+
stderr(`REA HALT: ${halt.reason ?? 'unknown'}\nAll push operations suspended. Run: rea unfreeze\n`);
|
|
93
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, {
|
|
94
|
+
reason: halt.reason ?? 'unknown',
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
status: 'halted',
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
summary: `HALT active: ${halt.reason ?? 'unknown'}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// 2. Load policy. A malformed policy.yaml surfaces as a thrown zod error;
|
|
103
|
+
// we catch it, audit, and exit 2 rather than silently bypass.
|
|
104
|
+
let policy;
|
|
105
|
+
try {
|
|
106
|
+
policy = await resolvePolicyFn(deps.baseDir);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
110
|
+
stderr(`PUSH BLOCKED: failed to load .rea/policy.yaml — ${msg}\n`);
|
|
111
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, {
|
|
112
|
+
kind: 'policy-load',
|
|
113
|
+
error: msg,
|
|
114
|
+
});
|
|
115
|
+
return { status: 'error', exitCode: 2, summary: `policy-load error: ${msg}` };
|
|
116
|
+
}
|
|
117
|
+
if (!policy.codex_required) {
|
|
118
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, {
|
|
119
|
+
policy_missing: policy.policyMissing,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
status: 'disabled',
|
|
123
|
+
exitCode: 0,
|
|
124
|
+
summary: 'review.codex_required is false — push-gate skipped',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// 3. Value-carrying skip waivers. HALT-wins ordering means these are
|
|
128
|
+
// checked AFTER halt (step 1) and AFTER codex_required=false
|
|
129
|
+
// short-circuit (step 2). Both of those should hold anyway; this is
|
|
130
|
+
// for the case where codex IS required but the operator wants to
|
|
131
|
+
// skip for a narrow, documented reason.
|
|
132
|
+
//
|
|
133
|
+
// Two equivalent env vars are honored — gate behavior is identical;
|
|
134
|
+
// only the audit metadata's `skip_var` differs so operators can grep
|
|
135
|
+
// their audit log to see which variant agents used:
|
|
136
|
+
//
|
|
137
|
+
// - REA_SKIP_PUSH_GATE — the original 0.11.0 var
|
|
138
|
+
// - REA_SKIP_CODEX_REVIEW — added in 0.12.0 to match the variant
|
|
139
|
+
// documented elsewhere in the codebase
|
|
140
|
+
// (gateway/reviewers, codex-probe). Prior
|
|
141
|
+
// to 0.12.0 this string only worked at
|
|
142
|
+
// the gateway tier; agents who set it on
|
|
143
|
+
// a `git push` got no skip and codex still
|
|
144
|
+
// ran. The mismatch surfaced during the
|
|
145
|
+
// helixir migration session 2026-04-26.
|
|
146
|
+
//
|
|
147
|
+
// Precedence on simultaneous set: REA_SKIP_PUSH_GATE wins (it was the
|
|
148
|
+
// canonical name) and REA_SKIP_CODEX_REVIEW is logged but not used.
|
|
149
|
+
// Either var alone with non-empty reason short-circuits.
|
|
150
|
+
const skipPush = (env.REA_SKIP_PUSH_GATE ?? '').trim();
|
|
151
|
+
const skipCodex = (env.REA_SKIP_CODEX_REVIEW ?? '').trim();
|
|
152
|
+
if (skipPush.length > 0 || skipCodex.length > 0) {
|
|
153
|
+
const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
|
|
154
|
+
const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
|
|
155
|
+
stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
|
|
156
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
|
|
157
|
+
reason: skipReason,
|
|
158
|
+
skip_var: skipVar,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
status: 'skipped',
|
|
162
|
+
exitCode: 0,
|
|
163
|
+
summary: `${skipVar} waiver: ${skipReason}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// 4. Resolve (base_ref, head_sha) for the actual review.
|
|
167
|
+
//
|
|
168
|
+
// Precedence (highest first):
|
|
169
|
+
// a) `--base <ref>` CLI flag (deps.explicitBase) — explicit ref the
|
|
170
|
+
// operator named; we trust it.
|
|
171
|
+
// b) `--last-n-commits N` CLI flag (deps.lastNCommits) — diff
|
|
172
|
+
// against HEAD~N. Wins over the policy key.
|
|
173
|
+
// c) `policy.review.last_n_commits` — same effect as (b), but
|
|
174
|
+
// configured in `.rea/policy.yaml`. Persistent narrow-window.
|
|
175
|
+
// d) Active refspec from pre-push stdin — what git is about to
|
|
176
|
+
// push. Critical for `git push origin HEAD:release/1.0`.
|
|
177
|
+
// e) Upstream → origin/HEAD → main/master ladder.
|
|
178
|
+
//
|
|
179
|
+
// When (a) collides with (b) or (c), (a) wins and we warn — explicit
|
|
180
|
+
// ref beats relative count.
|
|
181
|
+
const policyLastN = policy.last_n_commits;
|
|
182
|
+
const explicitBaseSet = deps.explicitBase !== undefined && deps.explicitBase.length > 0;
|
|
183
|
+
const lastNFromFlag = deps.lastNCommits;
|
|
184
|
+
const effectiveLastN = lastNFromFlag !== undefined ? lastNFromFlag : policyLastN;
|
|
185
|
+
if (explicitBaseSet && effectiveLastN !== undefined) {
|
|
186
|
+
const source = lastNFromFlag !== undefined ? '--last-n-commits' : 'policy.review.last_n_commits';
|
|
187
|
+
stderr(`rea: --base ${deps.explicitBase} overrides ${source}=${effectiveLastN}; using explicit ref.\n`);
|
|
188
|
+
}
|
|
189
|
+
const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
|
|
190
|
+
let base;
|
|
191
|
+
let headSha;
|
|
192
|
+
if (explicitBaseSet) {
|
|
193
|
+
// (a) explicit base wins absolutely.
|
|
194
|
+
base = resolveBaseRef(git, { explicit: deps.explicitBase });
|
|
195
|
+
headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
|
|
196
|
+
}
|
|
197
|
+
else if (effectiveLastN !== undefined && effectiveLastN > 0) {
|
|
198
|
+
// (b) / (c) last-n-commits. Resolves to a SHA via `git rev-parse
|
|
199
|
+
// <headRef>~N`. Compute headSha FIRST so the resolver walks back N
|
|
200
|
+
// commits from the pushed ref rather than the local HEAD — critical
|
|
201
|
+
// for `git push origin some-other-branch` where the active refspec's
|
|
202
|
+
// localSha is a different branch entirely from the checkout's HEAD.
|
|
203
|
+
headSha = activeRefspec !== undefined ? activeRefspec.localSha : git.headSha();
|
|
204
|
+
base = resolveBaseRef(git, {
|
|
205
|
+
lastNCommits: effectiveLastN,
|
|
206
|
+
headRef: headSha,
|
|
207
|
+
});
|
|
208
|
+
if (base.lastNCommitsRequested !== undefined &&
|
|
209
|
+
base.lastNCommits !== undefined &&
|
|
210
|
+
base.lastNCommits < base.lastNCommitsRequested) {
|
|
211
|
+
// Clamp warning: the resolver couldn't go back N commits, so it
|
|
212
|
+
// clamped to the entire branch history (diff vs empty-tree, K+1
|
|
213
|
+
// commits reviewed) — `base.lastNCommits` carries the actual K+1.
|
|
214
|
+
// This warning fires both when source is 'last-n-commits' (clamped
|
|
215
|
+
// mid-branch, root commit included via empty-tree) and when source
|
|
216
|
+
// is 'empty-tree' (single-commit branch). The user-facing message
|
|
217
|
+
// is identical: we wanted N, got K, here's what we reviewed.
|
|
218
|
+
stderr(`rea: ${headSha.slice(0, 12)}~${base.lastNCommitsRequested} not reachable; reviewing all ${base.lastNCommits} commits on this branch instead.\n`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (activeRefspec !== undefined) {
|
|
222
|
+
// (d) refspec-aware base — use what git is about to push.
|
|
223
|
+
headSha = activeRefspec.localSha;
|
|
224
|
+
if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
|
|
225
|
+
// New remote ref — no existing commits to diff against. Fall back to
|
|
226
|
+
// the resolver ladder so we still get a meaningful review (e.g. vs
|
|
227
|
+
// origin/main) rather than an empty-tree diff of everything.
|
|
228
|
+
base = resolveBaseRef(git);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
base = { ref: activeRefspec.remoteSha, source: 'explicit' };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// (e) upstream ladder.
|
|
236
|
+
base = resolveBaseRef(git);
|
|
237
|
+
headSha = git.headSha();
|
|
238
|
+
}
|
|
239
|
+
if (headSha.length === 0) {
|
|
240
|
+
stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
|
|
241
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
|
|
242
|
+
return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
|
|
243
|
+
}
|
|
244
|
+
// 5. Empty-diff short-circuit. An initial push against the empty-tree
|
|
245
|
+
// sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
|
|
246
|
+
// short-circuit only fires when the feature branch really is a
|
|
247
|
+
// no-op relative to base.
|
|
248
|
+
const diff = git.diffNames(base.ref, headSha);
|
|
249
|
+
if (diff.length === 0) {
|
|
250
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, {
|
|
251
|
+
base_ref: base.ref,
|
|
252
|
+
base_source: base.source,
|
|
253
|
+
head_sha: headSha,
|
|
254
|
+
last_n_commits: base.lastNCommits,
|
|
255
|
+
last_n_commits_requested: base.lastNCommitsRequested,
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
status: 'empty-diff',
|
|
259
|
+
exitCode: 0,
|
|
260
|
+
summary: 'empty diff — nothing to review',
|
|
261
|
+
baseRef: base.ref,
|
|
262
|
+
headSha,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// 6. Run Codex. Typed errors translate to exit 2 with distinct stderr.
|
|
266
|
+
try {
|
|
267
|
+
const codexResult = await runCodexFn({
|
|
268
|
+
baseRef: base.ref,
|
|
269
|
+
cwd: deps.baseDir,
|
|
270
|
+
timeoutMs: policy.timeout_ms,
|
|
271
|
+
env,
|
|
272
|
+
});
|
|
273
|
+
const summary = summarizeReview(codexResult.reviewText);
|
|
274
|
+
const blocked = summary.verdict === 'blocking'
|
|
275
|
+
|| (summary.verdict === 'concerns'
|
|
276
|
+
&& policy.concerns_blocks
|
|
277
|
+
&& !isConcernsOverrideSet(env));
|
|
278
|
+
const lastReviewPath = path.join(deps.baseDir, '.rea', 'last-review.json');
|
|
279
|
+
const payload = writeLastReviewFn({
|
|
280
|
+
baseDir: deps.baseDir,
|
|
281
|
+
summary,
|
|
282
|
+
baseRef: base.ref,
|
|
283
|
+
headSha,
|
|
284
|
+
eventCount: codexResult.eventCount,
|
|
285
|
+
durationSeconds: codexResult.durationSeconds,
|
|
286
|
+
...(deps.now !== undefined ? { now: deps.now() } : {}),
|
|
287
|
+
});
|
|
288
|
+
stderr(renderBanner({
|
|
289
|
+
payload,
|
|
290
|
+
baseSource: base.source,
|
|
291
|
+
blocked,
|
|
292
|
+
lastReviewPath,
|
|
293
|
+
}));
|
|
294
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
|
|
295
|
+
verdict: summary.verdict,
|
|
296
|
+
finding_count: summary.findings.length,
|
|
297
|
+
base_ref: base.ref,
|
|
298
|
+
base_source: base.source,
|
|
299
|
+
head_sha: headSha,
|
|
300
|
+
blocked,
|
|
301
|
+
duration_seconds: codexResult.durationSeconds,
|
|
302
|
+
event_count: codexResult.eventCount,
|
|
303
|
+
concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
|
|
304
|
+
last_n_commits: base.lastNCommits,
|
|
305
|
+
last_n_commits_requested: base.lastNCommitsRequested,
|
|
306
|
+
});
|
|
307
|
+
if (blocked) {
|
|
308
|
+
return {
|
|
309
|
+
status: summary.verdict === 'blocking' ? 'blocking' : 'concerns',
|
|
310
|
+
exitCode: 2,
|
|
311
|
+
summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
|
|
312
|
+
verdict: summary.verdict,
|
|
313
|
+
findingCount: summary.findings.length,
|
|
314
|
+
baseRef: base.ref,
|
|
315
|
+
headSha,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
status: summary.verdict === 'blocking'
|
|
320
|
+
? 'blocking'
|
|
321
|
+
: summary.verdict === 'concerns'
|
|
322
|
+
? 'concerns'
|
|
323
|
+
: 'pass',
|
|
324
|
+
exitCode: 0,
|
|
325
|
+
summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
|
|
326
|
+
verdict: summary.verdict,
|
|
327
|
+
findingCount: summary.findings.length,
|
|
328
|
+
baseRef: base.ref,
|
|
329
|
+
headSha,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
return handleCodexError(e, deps, base, headSha, appendAuditFn);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function isConcernsOverrideSet(env) {
|
|
337
|
+
const raw = env.REA_ALLOW_CONCERNS;
|
|
338
|
+
if (raw === undefined)
|
|
339
|
+
return false;
|
|
340
|
+
const normalized = raw.trim().toLowerCase();
|
|
341
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
342
|
+
}
|
|
343
|
+
async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
|
|
344
|
+
const stderr = deps.stderr;
|
|
345
|
+
const runError = classifyCodexError(e);
|
|
346
|
+
const metadata = {
|
|
347
|
+
base_ref: base.ref,
|
|
348
|
+
base_source: base.source,
|
|
349
|
+
head_sha: headSha,
|
|
350
|
+
kind: runError.kind,
|
|
351
|
+
};
|
|
352
|
+
if (runError.message.length > 0)
|
|
353
|
+
metadata.error = runError.message;
|
|
354
|
+
stderr(`PUSH BLOCKED: ${runError.message}\n`);
|
|
355
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
|
|
356
|
+
return {
|
|
357
|
+
status: 'error',
|
|
358
|
+
exitCode: 2,
|
|
359
|
+
summary: `codex error (${runError.kind}): ${runError.message}`,
|
|
360
|
+
baseRef: base.ref,
|
|
361
|
+
headSha,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function classifyCodexError(e) {
|
|
365
|
+
if (e instanceof CodexNotInstalledError)
|
|
366
|
+
return { kind: 'not-installed', message: e.message };
|
|
367
|
+
if (e instanceof CodexTimeoutError)
|
|
368
|
+
return { kind: 'timeout', message: e.message };
|
|
369
|
+
if (e instanceof CodexProtocolError)
|
|
370
|
+
return { kind: 'protocol', message: e.message };
|
|
371
|
+
if (e instanceof CodexSubprocessError)
|
|
372
|
+
return { kind: 'subprocess', message: e.message };
|
|
373
|
+
if (e instanceof Error)
|
|
374
|
+
return { kind: 'unknown', message: e.message };
|
|
375
|
+
return { kind: 'unknown', message: String(e) };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Audit-record helper. Never throws — audit failures are themselves audited
|
|
379
|
+
* (best-effort warn to stderr) but must not prevent the gate from returning
|
|
380
|
+
* its primary result. The hash chain remains intact if this succeeds; on
|
|
381
|
+
* failure we've already made the gate decision based on the actual review.
|
|
382
|
+
*/
|
|
383
|
+
async function safeAppend(appendFn, baseDir, toolName, metadata) {
|
|
384
|
+
try {
|
|
385
|
+
// Prune undefined values — the audit record schema's `metadata` is an
|
|
386
|
+
// arbitrary map, but `undefined` values cause JSON.stringify to emit
|
|
387
|
+
// missing keys which breaks round-trips on some readers.
|
|
388
|
+
const cleanMeta = {};
|
|
389
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
390
|
+
if (v !== undefined)
|
|
391
|
+
cleanMeta[k] = v;
|
|
392
|
+
}
|
|
393
|
+
await appendFn(baseDir, {
|
|
394
|
+
tool_name: toolName,
|
|
395
|
+
server_name: AUDIT_SERVER_NAME,
|
|
396
|
+
tier: Tier.Read,
|
|
397
|
+
status: InvocationStatus.Allowed,
|
|
398
|
+
...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
// Audit persistence failure should never cascade into a push block when
|
|
403
|
+
// the gate itself decided to pass — but we do want operator visibility.
|
|
404
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
405
|
+
// Use the deps.stderr is unavailable here (different stack frame); write
|
|
406
|
+
// directly to process.stderr as a fallback.
|
|
407
|
+
process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Re-exports for the CLI wrapper so it can construct dependency defaults.
|
|
411
|
+
export { resolvePushGatePolicy } from './policy.js';
|
|
412
|
+
export { readHalt } from './halt.js';
|
|
413
|
+
export { resolveBaseRef } from './base.js';
|
|
414
|
+
export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
|
|
415
|
+
export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
|
|
416
|
+
export { writeLastReview, renderBanner } from './report.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate policy resolution.
|
|
3
|
+
*
|
|
4
|
+
* Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
|
|
5
|
+
* gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
|
|
6
|
+
* (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
|
|
7
|
+
* gate composition in `./index.ts` inspects them directly after policy load
|
|
8
|
+
* so the audit trail can distinguish "policy says skip" from "env says
|
|
9
|
+
* skip". This module is pure policy.
|
|
10
|
+
*
|
|
11
|
+
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
|
|
15
|
+
* previous 10-minute default after the
|
|
16
|
+
* helixir migration session 2026-04-26
|
|
17
|
+
* showed realistic feature-branch
|
|
18
|
+
* reviews routinely exceeded 10 minutes
|
|
19
|
+
* on large diffs. Operators who pin
|
|
20
|
+
* `timeout_ms:` in policy.yaml are
|
|
21
|
+
* unaffected by this change.)
|
|
22
|
+
*
|
|
23
|
+
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
24
|
+
* operator may not have run `rea init` yet, and the gate's behavior
|
|
25
|
+
* should match the most protective stance available. The caller is free
|
|
26
|
+
* to treat `policyMissing: true` as a doctor finding.
|
|
27
|
+
*/
|
|
28
|
+
export interface ResolvedReviewPolicy {
|
|
29
|
+
codex_required: boolean;
|
|
30
|
+
concerns_blocks: boolean;
|
|
31
|
+
timeout_ms: number;
|
|
32
|
+
/**
|
|
33
|
+
* When set, the gate resolves the diff base to `HEAD~N` (see Fix D in
|
|
34
|
+
* 0.12.0). The CLI flag `--last-n-commits N` overrides this; the
|
|
35
|
+
* policy key surfaces here as a runtime knob with the same effect.
|
|
36
|
+
* `undefined` when unset (default-untouched behavior).
|
|
37
|
+
*/
|
|
38
|
+
last_n_commits: number | undefined;
|
|
39
|
+
/** `true` when `.rea/policy.yaml` was absent; defaults apply. */
|
|
40
|
+
policyMissing: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1800000;
|
|
43
|
+
export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
44
|
+
export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
47
|
+
* policy file surfaces as a typed error via the underlying zod validator,
|
|
48
|
+
* which we re-raise. The gate's `runPushGate()` catches that and returns
|
|
49
|
+
* `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
|
|
50
|
+
*
|
|
51
|
+
* Returning a fully-populated object (no `undefined` knobs) means every
|
|
52
|
+
* downstream module can treat the policy as total — no `?? default` dance
|
|
53
|
+
* at each call site.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolvePushGatePolicy(baseDir: string): Promise<ResolvedReviewPolicy>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate policy resolution.
|
|
3
|
+
*
|
|
4
|
+
* Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
|
|
5
|
+
* gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
|
|
6
|
+
* (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
|
|
7
|
+
* gate composition in `./index.ts` inspects them directly after policy load
|
|
8
|
+
* so the audit trail can distinguish "policy says skip" from "env says
|
|
9
|
+
* skip". This module is pure policy.
|
|
10
|
+
*
|
|
11
|
+
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 1_800_000 (30 minutes — raised in 0.12.0 from the
|
|
15
|
+
* previous 10-minute default after the
|
|
16
|
+
* helixir migration session 2026-04-26
|
|
17
|
+
* showed realistic feature-branch
|
|
18
|
+
* reviews routinely exceeded 10 minutes
|
|
19
|
+
* on large diffs. Operators who pin
|
|
20
|
+
* `timeout_ms:` in policy.yaml are
|
|
21
|
+
* unaffected by this change.)
|
|
22
|
+
*
|
|
23
|
+
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
24
|
+
* operator may not have run `rea init` yet, and the gate's behavior
|
|
25
|
+
* should match the most protective stance available. The caller is free
|
|
26
|
+
* to treat `policyMissing: true` as a doctor finding.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
31
|
+
export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 1_800_000;
|
|
32
|
+
export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
33
|
+
export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
36
|
+
* policy file surfaces as a typed error via the underlying zod validator,
|
|
37
|
+
* which we re-raise. The gate's `runPushGate()` catches that and returns
|
|
38
|
+
* `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
|
|
39
|
+
*
|
|
40
|
+
* Returning a fully-populated object (no `undefined` knobs) means every
|
|
41
|
+
* downstream module can treat the policy as total — no `?? default` dance
|
|
42
|
+
* at each call site.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolvePushGatePolicy(baseDir) {
|
|
45
|
+
const policyPath = path.join(baseDir, '.rea', 'policy.yaml');
|
|
46
|
+
if (!fs.existsSync(policyPath)) {
|
|
47
|
+
return {
|
|
48
|
+
codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
49
|
+
concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
50
|
+
timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
51
|
+
last_n_commits: undefined,
|
|
52
|
+
policyMissing: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const policy = await loadPolicyAsync(baseDir);
|
|
56
|
+
const review = policy.review ?? {};
|
|
57
|
+
return {
|
|
58
|
+
codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
59
|
+
concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
60
|
+
timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
61
|
+
last_n_commits: review.last_n_commits,
|
|
62
|
+
policyMissing: false,
|
|
63
|
+
};
|
|
64
|
+
}
|