@bookedsolid/rea 0.10.2 → 0.10.3

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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Git-subprocess wrappers the review gate needs.
3
+ *
4
+ * ## Why a dedicated module
5
+ *
6
+ * The bash core spawns git via inline `$(cd "$REA_ROOT" && git ... 2>/dev/null)`
7
+ * subshells. Each invocation carries a handful of concerns the TS port
8
+ * separates cleanly:
9
+ *
10
+ * - Args passed as an ARRAY so the shell never interprets them
11
+ * (push-review-ts-port design §9 security posture: no argument-injection
12
+ * CVEs around refspec names like `main;rm -rf /`).
13
+ * - `cwd` always set to the resolved repo root — the caller never has to
14
+ * remember to `cd` first.
15
+ * - stdout/stderr captured separately. Git's error text goes to stderr in
16
+ * normal modes, and callers often want stderr for diagnostics while
17
+ * still deciding based on exit code.
18
+ * - A single shared timeout (10s) catches a hung `git` process. The bash
19
+ * core had no timeout — an upstream `git` stuck on NFS could wedge the
20
+ * whole push indefinitely.
21
+ *
22
+ * ## Mockability
23
+ *
24
+ * Every exported function takes a `GitRunner` as its first positional so
25
+ * tests can stub the subprocess layer. The default runner spawns `git`;
26
+ * tests supply a recording runner and assert over the command history. This
27
+ * is how `base-resolve.test.ts` and `diff.test.ts` avoid needing a real
28
+ * git repo.
29
+ *
30
+ * ## Defect carry-forwards
31
+ *
32
+ * - Two-dot `A..B` for diff inputs, NEVER three-dot `A...B`. The bash
33
+ * core's comment on push-review-core.sh §1053-1060 covers this: three-dot
34
+ * computes an implicit merge-base which FAILS when A is the empty-tree
35
+ * SHA (a valid bootstrap anchor in `base-resolve.ts`). Two-dot accepts
36
+ * any revision on the left.
37
+ * - `git diff --name-status` output is tab-separated, one line per change;
38
+ * `protected-paths.ts` owns the parse.
39
+ * - `git cat-file -e <sha>^{commit}` is the "object is locally resolvable"
40
+ * probe. Bash used a bare exit-code check; we preserve that.
41
+ */
42
+ import { spawnSync } from 'node:child_process';
43
+ /** Hard cap on git invocation runtime. Bash core had no cap; 10s is generous for a hot-cache repo. */
44
+ const GIT_TIMEOUT_MS = 10_000;
45
+ /**
46
+ * Default production git runner. Spawns `git` with the supplied args, cwd,
47
+ * and a fixed timeout. `encoding: 'utf8'` so the returned strings are
48
+ * decoded, matching the bash `$()` shape.
49
+ *
50
+ * Security: args is always an array; no shell string ever participates in
51
+ * the invocation. Refspec names that happen to contain shell metacharacters
52
+ * are inert.
53
+ */
54
+ export function spawnGit(args, cwd) {
55
+ const opts = {
56
+ cwd,
57
+ encoding: 'utf8',
58
+ timeout: GIT_TIMEOUT_MS,
59
+ // Explicitly drop stdin — some git subcommands try to read (e.g. `git
60
+ // commit` prompting for a message); we never want that.
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ // Never use a shell. Args are an array; spawn does argv[0] execve
63
+ // directly, so `git` is looked up on PATH with no shell parsing.
64
+ shell: false,
65
+ };
66
+ const result = spawnSync('git', args, opts);
67
+ const stdout = typeof result.stdout === 'string' ? result.stdout.replace(/\n+$/, '') : '';
68
+ const stderr = typeof result.stderr === 'string' ? result.stderr.replace(/\n+$/, '') : '';
69
+ // On timeout/signal kill, spawnSync returns status=null and populates
70
+ // `signal`. Treat as a non-zero exit so callers fall through the normal
71
+ // error paths.
72
+ const status = typeof result.status === 'number' ? result.status : 1;
73
+ return { status, stdout, stderr };
74
+ }
75
+ /**
76
+ * SHA validator. Bash uses `=~ ^[0-9a-f]{40}$`; we match exactly.
77
+ */
78
+ const SHA_HEX_40 = /^[0-9a-f]{40}$/;
79
+ /**
80
+ * Return the current branch name (empty string when detached or on failure).
81
+ * Bash-core parity (push-review-core.sh §687): `git branch --show-current`.
82
+ */
83
+ export function currentBranch(runner, cwd) {
84
+ const r = runner(['branch', '--show-current'], cwd);
85
+ if (r.status !== 0)
86
+ return '';
87
+ return r.stdout;
88
+ }
89
+ /**
90
+ * Resolve `HEAD` to a commit SHA, or return the empty string when the repo
91
+ * has no commits / the rev-parse fails. Bash-core parity
92
+ * (push-review-core.sh §134 and §412): `git rev-parse HEAD`.
93
+ */
94
+ export function resolveHead(runner, cwd) {
95
+ const r = runner(['rev-parse', 'HEAD'], cwd);
96
+ if (r.status !== 0)
97
+ return '';
98
+ const sha = r.stdout;
99
+ return SHA_HEX_40.test(sha) ? sha : '';
100
+ }
101
+ /**
102
+ * Resolve a ref (e.g. `feature/foo`) to a commit SHA via
103
+ * `git rev-parse --verify <ref>^{commit}`, or return null on failure.
104
+ * Bash-core parity (push-review-core.sh §187): the `^{commit}` suffix
105
+ * forces resolution to the commit even for annotated-tag refs.
106
+ */
107
+ export function resolveRefToSha(runner, cwd, ref) {
108
+ const r = runner(['rev-parse', '--verify', `${ref}^{commit}`], cwd);
109
+ if (r.status !== 0)
110
+ return null;
111
+ const sha = r.stdout;
112
+ return SHA_HEX_40.test(sha) ? sha : null;
113
+ }
114
+ /**
115
+ * Return the short-name upstream for the current branch (e.g. `origin/main`)
116
+ * or null if no upstream is set. Bash-core parity (push-review-core.sh §129
117
+ * and §601): `git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'`.
118
+ */
119
+ export function resolveUpstream(runner, cwd) {
120
+ const r = runner(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], cwd);
121
+ if (r.status !== 0)
122
+ return null;
123
+ const out = r.stdout;
124
+ return out.length > 0 ? out : null;
125
+ }
126
+ /**
127
+ * Check whether a commit object is locally resolvable. Bash-core parity
128
+ * (push-review-core.sh §743): `git cat-file -e <sha>^{commit}`. Returns
129
+ * true on exit 0, false otherwise. Used before merge-base computation on
130
+ * the non-new-branch path — if the remote ref isn't fetched, `git merge-
131
+ * base` would silently return an unrelated base and the gate would diff
132
+ * against the wrong anchor.
133
+ */
134
+ export function hasCommitLocally(runner, cwd, sha) {
135
+ if (!SHA_HEX_40.test(sha))
136
+ return false;
137
+ const r = runner(['cat-file', '-e', `${sha}^{commit}`], cwd);
138
+ return r.status === 0;
139
+ }
140
+ /**
141
+ * Compute the merge-base between two refs. Returns the SHA on success,
142
+ * null on failure or empty output. Bash-core parity
143
+ * (push-review-core.sh §756 and §860): `git merge-base <a> <b>`.
144
+ */
145
+ export function mergeBase(runner, cwd, a, b) {
146
+ const r = runner(['merge-base', a, b], cwd);
147
+ if (r.status !== 0)
148
+ return null;
149
+ const sha = r.stdout;
150
+ return SHA_HEX_40.test(sha) ? sha : null;
151
+ }
152
+ /**
153
+ * True iff `ref` is a resolvable rev (commit, tag, or branch). Bash-core
154
+ * parity (push-review-core.sh §815 and §835): `git rev-parse --verify
155
+ * --quiet <ref>` with stdout suppressed and stderr ignored.
156
+ */
157
+ export function refExists(runner, cwd, ref) {
158
+ const r = runner(['rev-parse', '--verify', '--quiet', ref], cwd);
159
+ return r.status === 0;
160
+ }
161
+ /**
162
+ * Read a git config value (e.g. `branch.<name>.base`). Returns the value
163
+ * or the empty string when the config entry is absent or `git config` fails.
164
+ * Bash-core parity (push-review-core.sh §808): `git config --get <key>`.
165
+ */
166
+ export function readGitConfig(runner, cwd, key) {
167
+ const r = runner(['config', '--get', key], cwd);
168
+ if (r.status !== 0)
169
+ return '';
170
+ return r.stdout;
171
+ }
172
+ /**
173
+ * Resolve `refs/remotes/<remote>/HEAD` to its symbolic target (e.g.
174
+ * `refs/remotes/origin/main`). Returns null on failure. Bash-core parity
175
+ * (push-review-core.sh §826): `git symbolic-ref refs/remotes/<remote>/HEAD`.
176
+ */
177
+ export function resolveRemoteDefaultRef(runner, cwd, remote) {
178
+ const r = runner(['symbolic-ref', `refs/remotes/${remote}/HEAD`], cwd);
179
+ if (r.status !== 0)
180
+ return null;
181
+ const out = r.stdout;
182
+ return out.length > 0 ? out : null;
183
+ }
184
+ export function fullDiff(runner, cwd, a, b) {
185
+ const r = runner(['diff', `${a}..${b}`], cwd);
186
+ return { diff: r.stdout, status: r.status, stderr: r.stderr };
187
+ }
188
+ export function diffNameStatus(runner, cwd, a, b) {
189
+ const r = runner(['diff', '--name-status', `${a}..${b}`], cwd);
190
+ return { output: r.stdout, status: r.status, stderr: r.stderr };
191
+ }
192
+ /**
193
+ * Commit count between two refs. Bash-core parity
194
+ * (push-review-core.sh §996): `git rev-list --count <a>..<b>`. Returns -1
195
+ * on error so callers can distinguish "git failed" (exit 2) from "zero
196
+ * commits" (legitimate, usually a same-ref push).
197
+ */
198
+ export function revListCount(runner, cwd, a, b) {
199
+ const r = runner(['rev-list', '--count', `${a}..${b}`], cwd);
200
+ if (r.status !== 0)
201
+ return -1;
202
+ const n = Number.parseInt(r.stdout, 10);
203
+ return Number.isFinite(n) && n >= 0 ? n : 0;
204
+ }
205
+ /**
206
+ * Resolve the repository's common-dir (the path to `.git` or to a
207
+ * shared worktree parent). Returns the absolute path or null when not in
208
+ * a git repo. Bash-core parity (push-review-core.sh §272-273):
209
+ * `git rev-parse --path-format=absolute --git-common-dir`.
210
+ *
211
+ * Used by the cross-repo guard in §1a to distinguish two checkouts of the
212
+ * same repo (linked worktrees share a common-dir) from two unrelated
213
+ * repos. Phase 2a ships the primitive; composition happens in Phase 2b.
214
+ */
215
+ export function gitCommonDir(runner, cwd) {
216
+ const r = runner(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
217
+ if (r.status !== 0)
218
+ return null;
219
+ const out = r.stdout;
220
+ return out.length > 0 ? out : null;
221
+ }
222
+ /**
223
+ * Read git's user email + name fallback for skip-audit actor attribution.
224
+ * Bash-core parity (push-review-core.sh §393-396 and §563-566): prefer
225
+ * email; fall back to name if email is empty; empty string if both missing.
226
+ */
227
+ export function readGitActor(runner, cwd) {
228
+ const email = readGitConfig(runner, cwd, 'user.email');
229
+ if (email.length > 0)
230
+ return email;
231
+ return readGitConfig(runner, cwd, 'user.name');
232
+ }
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Public entry point for the review-gate TypeScript port (G).
3
3
  *
4
- * Phase 1 scope: expose the pure primitives (args, hash, banner, metadata,
5
- * policy, protected-paths, cache-key, errors, constants) so they can be
6
- * unit-tested and composed by phase 2's `runPushReviewGate` /
7
- * `runCommitReviewGate`. No behavioral surface is registered here yet —
8
- * the bash core in `hooks/_lib/push-review-core.sh` continues to run in
9
- * production until phase 4.
4
+ * ## Scope after Phase 2a (0.10.3)
5
+ *
6
+ * - Phase 1 primitives (args, banner, cache-key, constants, errors,
7
+ * hash, metadata, policy, protected-paths) pure, dependency-free.
8
+ * - Phase 2a supporting modules (base-resolve, diff, audit, cache)
9
+ * wrap git subprocesses, wrap audit append/scan, wrap the review-
10
+ * cache lookup. No top-level gate yet; composition lands in Phase 2b.
11
+ *
12
+ * The bash core in `hooks/_lib/push-review-core.sh` continues to run in
13
+ * production until phase 4. These exports are library-level primitives
14
+ * that tests and Phase 2b compose; no behavioral surface is registered
15
+ * for external callers here.
10
16
  *
11
17
  * See `docs/design/push-review-ts-port.md` for the full plan.
12
18
  */
@@ -19,3 +25,7 @@ export * from './hash.js';
19
25
  export * from './metadata.js';
20
26
  export * from './policy.js';
21
27
  export * from './protected-paths.js';
28
+ export { currentBranch, diffNameStatus, fullDiff, gitCommonDir, hasCommitLocally, mergeBase, readGitActor, readGitConfig, refExists, resolveHead, resolveRefToSha, resolveRemoteDefaultRef, resolveUpstream, revListCount, spawnGit, type DiffResult, type GitRunResult, type GitRunner, type NameStatusResult, } from './diff.js';
29
+ export { computeInitialTargetLabel, resolveBaseForRefspec, resolveNewBranchBase, stripRefsHeadsOnly, type ResolveBaseDeps, type ResolvedBase, } from './base-resolve.js';
30
+ export { CODEX_REVIEW_SKIPPED_TOOL, ESCAPE_HATCH_SERVER, PUSH_REVIEW_CACHE_ERROR_TOOL, PUSH_REVIEW_CACHE_HIT_TOOL, PUSH_REVIEW_SERVER, PUSH_REVIEW_SKIPPED_TOOL, emitCodexReviewSkipped, emitPushReviewSkipped, hasValidCodexReview, isQualifyingCodexReview, type SkipCodexReviewAuditInput, type SkipPushReviewAuditInput, } from './audit.js';
31
+ export { checkReviewCache, type CacheOutcome, type CheckReviewCacheInput, } from './cache.js';
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * Public entry point for the review-gate TypeScript port (G).
3
3
  *
4
- * Phase 1 scope: expose the pure primitives (args, hash, banner, metadata,
5
- * policy, protected-paths, cache-key, errors, constants) so they can be
6
- * unit-tested and composed by phase 2's `runPushReviewGate` /
7
- * `runCommitReviewGate`. No behavioral surface is registered here yet —
8
- * the bash core in `hooks/_lib/push-review-core.sh` continues to run in
9
- * production until phase 4.
4
+ * ## Scope after Phase 2a (0.10.3)
5
+ *
6
+ * - Phase 1 primitives (args, banner, cache-key, constants, errors,
7
+ * hash, metadata, policy, protected-paths) pure, dependency-free.
8
+ * - Phase 2a supporting modules (base-resolve, diff, audit, cache)
9
+ * wrap git subprocesses, wrap audit append/scan, wrap the review-
10
+ * cache lookup. No top-level gate yet; composition lands in Phase 2b.
11
+ *
12
+ * The bash core in `hooks/_lib/push-review-core.sh` continues to run in
13
+ * production until phase 4. These exports are library-level primitives
14
+ * that tests and Phase 2b compose; no behavioral surface is registered
15
+ * for external callers here.
10
16
  *
11
17
  * See `docs/design/push-review-ts-port.md` for the full plan.
12
18
  */
19
+ // Phase 1 primitives
13
20
  export * from './args.js';
14
21
  export * from './banner.js';
15
22
  export * from './cache-key.js';
@@ -19,3 +26,10 @@ export * from './hash.js';
19
26
  export * from './metadata.js';
20
27
  export * from './policy.js';
21
28
  export * from './protected-paths.js';
29
+ // Phase 2a supporting modules — re-export explicit names to avoid
30
+ // double-exporting `computeCacheKey` (which lives in both cache-key.ts
31
+ // and cache.ts; cache.ts's is a strict re-export of Phase 1's).
32
+ export { currentBranch, diffNameStatus, fullDiff, gitCommonDir, hasCommitLocally, mergeBase, readGitActor, readGitConfig, refExists, resolveHead, resolveRefToSha, resolveRemoteDefaultRef, resolveUpstream, revListCount, spawnGit, } from './diff.js';
33
+ export { computeInitialTargetLabel, resolveBaseForRefspec, resolveNewBranchBase, stripRefsHeadsOnly, } from './base-resolve.js';
34
+ export { CODEX_REVIEW_SKIPPED_TOOL, ESCAPE_HATCH_SERVER, PUSH_REVIEW_CACHE_ERROR_TOOL, PUSH_REVIEW_CACHE_HIT_TOOL, PUSH_REVIEW_SERVER, PUSH_REVIEW_SKIPPED_TOOL, emitCodexReviewSkipped, emitPushReviewSkipped, hasValidCodexReview, isQualifyingCodexReview, } from './audit.js';
35
+ export { checkReviewCache, } from './cache.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",