@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.
Files changed (77) hide show
  1. package/.husky/pre-push +48 -162
  2. package/README.md +834 -552
  3. package/agents/codex-adversarial.md +5 -3
  4. package/commands/codex-review.md +3 -5
  5. package/dist/audit/append.d.ts +7 -32
  6. package/dist/audit/append.js +7 -35
  7. package/dist/cli/audit.d.ts +0 -31
  8. package/dist/cli/audit.js +5 -74
  9. package/dist/cli/doctor.d.ts +12 -0
  10. package/dist/cli/doctor.js +96 -17
  11. package/dist/cli/hook.d.ts +55 -0
  12. package/dist/cli/hook.js +138 -0
  13. package/dist/cli/index.js +5 -80
  14. package/dist/cli/init.js +1 -1
  15. package/dist/cli/install/gitignore.d.ts +2 -2
  16. package/dist/cli/install/gitignore.js +3 -3
  17. package/dist/cli/install/pre-push.d.ts +158 -272
  18. package/dist/cli/install/pre-push.js +491 -2633
  19. package/dist/cli/install/settings-merge.d.ts +17 -0
  20. package/dist/cli/install/settings-merge.js +48 -1
  21. package/dist/cli/upgrade.js +131 -3
  22. package/dist/config/tier-map.js +18 -25
  23. package/dist/hooks/push-gate/base.d.ts +104 -0
  24. package/dist/hooks/push-gate/base.js +198 -0
  25. package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
  26. package/dist/hooks/push-gate/codex-runner.js +223 -0
  27. package/dist/hooks/push-gate/findings.d.ts +68 -0
  28. package/dist/hooks/push-gate/findings.js +142 -0
  29. package/dist/hooks/push-gate/halt.d.ts +28 -0
  30. package/dist/hooks/push-gate/halt.js +49 -0
  31. package/dist/hooks/push-gate/index.d.ts +98 -0
  32. package/dist/hooks/push-gate/index.js +416 -0
  33. package/dist/hooks/push-gate/policy.d.ts +55 -0
  34. package/dist/hooks/push-gate/policy.js +64 -0
  35. package/dist/hooks/push-gate/report.d.ts +89 -0
  36. package/dist/hooks/push-gate/report.js +140 -0
  37. package/dist/policy/loader.d.ts +15 -10
  38. package/dist/policy/loader.js +8 -6
  39. package/dist/policy/types.d.ts +73 -22
  40. package/package.json +1 -1
  41. package/scripts/tarball-smoke.sh +7 -2
  42. package/dist/cache/review-cache.d.ts +0 -115
  43. package/dist/cache/review-cache.js +0 -200
  44. package/dist/cli/cache.d.ts +0 -84
  45. package/dist/cli/cache.js +0 -150
  46. package/dist/hooks/review-gate/args.d.ts +0 -126
  47. package/dist/hooks/review-gate/args.js +0 -315
  48. package/dist/hooks/review-gate/audit.d.ts +0 -131
  49. package/dist/hooks/review-gate/audit.js +0 -181
  50. package/dist/hooks/review-gate/banner.d.ts +0 -97
  51. package/dist/hooks/review-gate/banner.js +0 -172
  52. package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
  53. package/dist/hooks/review-gate/base-resolve.js +0 -247
  54. package/dist/hooks/review-gate/cache-key.d.ts +0 -55
  55. package/dist/hooks/review-gate/cache-key.js +0 -41
  56. package/dist/hooks/review-gate/cache.d.ts +0 -108
  57. package/dist/hooks/review-gate/cache.js +0 -120
  58. package/dist/hooks/review-gate/constants.d.ts +0 -26
  59. package/dist/hooks/review-gate/constants.js +0 -34
  60. package/dist/hooks/review-gate/diff.d.ts +0 -181
  61. package/dist/hooks/review-gate/diff.js +0 -232
  62. package/dist/hooks/review-gate/errors.d.ts +0 -72
  63. package/dist/hooks/review-gate/errors.js +0 -100
  64. package/dist/hooks/review-gate/hash.d.ts +0 -43
  65. package/dist/hooks/review-gate/hash.js +0 -46
  66. package/dist/hooks/review-gate/index.d.ts +0 -31
  67. package/dist/hooks/review-gate/index.js +0 -35
  68. package/dist/hooks/review-gate/metadata.d.ts +0 -98
  69. package/dist/hooks/review-gate/metadata.js +0 -158
  70. package/dist/hooks/review-gate/policy.d.ts +0 -55
  71. package/dist/hooks/review-gate/policy.js +0 -71
  72. package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
  73. package/dist/hooks/review-gate/protected-paths.js +0 -76
  74. package/hooks/_lib/push-review-core.sh +0 -1250
  75. package/hooks/commit-review-gate.sh +0 -330
  76. package/hooks/push-review-gate-git.sh +0 -94
  77. package/hooks/push-review-gate.sh +0 -92
@@ -41,6 +41,23 @@ export interface MergeResult {
41
41
  * hooks and returns the merged settings plus a list of warnings. Does NOT
42
42
  * touch disk.
43
43
  */
44
+ /**
45
+ * Remove hook entries whose `command` contains any of the listed
46
+ * substrings. Returns a deep-cloned settings object plus the count of
47
+ * entries removed. Empty hook arrays and empty groups are pruned too so
48
+ * the resulting file doesn't accumulate empty sentinel objects after
49
+ * repeated upgrade runs.
50
+ *
51
+ * Used by `rea upgrade` to purge references to hooks we delete in a
52
+ * release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
53
+ * Without this, `mergeSettings()` is additive-only and consumer
54
+ * `.claude/settings.json` would keep executing stale commands that
55
+ * point at files we just deleted from `.claude/hooks/`.
56
+ */
57
+ export declare function pruneHookCommands(existing: Record<string, unknown>, staleSubstrings: readonly string[]): {
58
+ merged: Record<string, unknown>;
59
+ removedCount: number;
60
+ };
44
61
  export declare function mergeSettings(existing: Record<string, unknown>, desired: DesiredHookGroup[]): MergeResult;
45
62
  /**
46
63
  * Atomic write via tmp-file + rename.
@@ -39,6 +39,54 @@ function keyFor(matcher, command) {
39
39
  * hooks and returns the merged settings plus a list of warnings. Does NOT
40
40
  * touch disk.
41
41
  */
42
+ /**
43
+ * Remove hook entries whose `command` contains any of the listed
44
+ * substrings. Returns a deep-cloned settings object plus the count of
45
+ * entries removed. Empty hook arrays and empty groups are pruned too so
46
+ * the resulting file doesn't accumulate empty sentinel objects after
47
+ * repeated upgrade runs.
48
+ *
49
+ * Used by `rea upgrade` to purge references to hooks we delete in a
50
+ * release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
51
+ * Without this, `mergeSettings()` is additive-only and consumer
52
+ * `.claude/settings.json` would keep executing stale commands that
53
+ * point at files we just deleted from `.claude/hooks/`.
54
+ */
55
+ export function pruneHookCommands(existing, staleSubstrings) {
56
+ const merged = deepClone(existing);
57
+ const hooks = ensureHooksShape(merged);
58
+ let removedCount = 0;
59
+ for (const event of Object.keys(hooks)) {
60
+ const groups = hooks[event];
61
+ if (!Array.isArray(groups))
62
+ continue;
63
+ for (const group of groups) {
64
+ const entries = group.hooks ?? [];
65
+ if (!Array.isArray(entries))
66
+ continue;
67
+ const kept = [];
68
+ for (const entry of entries) {
69
+ const cmd = typeof entry.command === 'string' ? entry.command : '';
70
+ if (staleSubstrings.some((s) => cmd.includes(s))) {
71
+ removedCount += 1;
72
+ continue;
73
+ }
74
+ kept.push(entry);
75
+ }
76
+ group.hooks = kept;
77
+ }
78
+ // Drop groups whose hooks list is now empty.
79
+ hooks[event] = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
80
+ }
81
+ // Drop events whose group list is now empty.
82
+ for (const event of Object.keys(hooks)) {
83
+ const groups = hooks[event];
84
+ if (Array.isArray(groups) && groups.length === 0) {
85
+ delete hooks[event];
86
+ }
87
+ }
88
+ return { merged, removedCount };
89
+ }
42
90
  export function mergeSettings(existing, desired) {
43
91
  const merged = deepClone(existing);
44
92
  const hooks = ensureHooksShape(merged);
@@ -215,7 +263,6 @@ export function defaultDesiredHooks() {
215
263
  { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
216
264
  { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
217
265
  { type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
218
- { type: 'command', command: `${base}/push-review-gate.sh`, timeout: 30000, statusMessage: 'Running push review gate...' },
219
266
  ],
220
267
  },
221
268
  {
@@ -44,11 +44,103 @@ import { loadPolicy } from '../policy/loader.js';
44
44
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
45
45
  import { buildFragment, extractFragment, } from './install/claude-md.js';
46
46
  import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
- import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
47
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
48
  import { ensureReaGitignore } from './install/gitignore.js';
49
49
  import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
50
50
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
51
51
  import { err, getPkgVersion, log, warn } from './utils.js';
52
+ // ---------------------------------------------------------------------------
53
+ // 0.11.0 policy migration
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Strip 0.10.x review.* fields that 0.11.0 rejects at schema load, and
57
+ * backfill `concerns_blocks: true` if the `review:` block exists without
58
+ * it. Writes a timestamped backup to `.rea/policy.yaml.bak-<ts>` before
59
+ * mutating.
60
+ *
61
+ * Surgical text rewrite, not YAML round-trip. YAML round-tripping loses
62
+ * comments and reformats quoting; operators care about their comments.
63
+ * Line-by-line deletion of the two removed keys keeps every other line
64
+ * byte-identical. This is safe because the keys we strip are simple
65
+ * scalars (`cache_max_age_seconds: 3600`, `allow_skip_in_ci: false`) that
66
+ * cannot span multiple lines in any reasonable policy file.
67
+ */
68
+ async function migrateReviewPolicyFor0110(resolvedRoot, opts) {
69
+ const policyPath = path.join(resolvedRoot, '.rea', 'policy.yaml');
70
+ let raw;
71
+ try {
72
+ raw = await fsPromises.readFile(policyPath, 'utf8');
73
+ }
74
+ catch {
75
+ return; // no policy to migrate
76
+ }
77
+ const lines = raw.split(/\r?\n/);
78
+ const REVIEW_RE = /^review:\s*(?:#.*)?$/;
79
+ const REMOVED_KEY_RE = /^(\s+)(cache_max_age_seconds|allow_skip_in_ci):\s*\S.*$/;
80
+ const INDENT_RE = /^(\s+)\S/;
81
+ let reviewStart = -1;
82
+ let reviewIndent = '';
83
+ const linesToDrop = new Set();
84
+ let hasConcernsBlocks = false;
85
+ let reviewChildIndent = '';
86
+ for (let i = 0; i < lines.length; i += 1) {
87
+ const line = lines[i] ?? '';
88
+ if (reviewStart < 0 && REVIEW_RE.test(line.trimEnd())) {
89
+ reviewStart = i;
90
+ const indentMatch = INDENT_RE.exec(line);
91
+ reviewIndent = indentMatch?.[1] ?? '';
92
+ continue;
93
+ }
94
+ if (reviewStart < 0)
95
+ continue;
96
+ // Inside the review: block as long as indent is deeper than reviewIndent.
97
+ if (line.trim() === '')
98
+ continue;
99
+ const indentMatch = INDENT_RE.exec(line);
100
+ const lineIndent = indentMatch?.[1] ?? '';
101
+ if (lineIndent.length <= reviewIndent.length) {
102
+ // Left the block.
103
+ break;
104
+ }
105
+ if (reviewChildIndent.length === 0)
106
+ reviewChildIndent = lineIndent;
107
+ if (/^\s+concerns_blocks:/.test(line))
108
+ hasConcernsBlocks = true;
109
+ if (REMOVED_KEY_RE.test(line)) {
110
+ linesToDrop.add(i);
111
+ }
112
+ }
113
+ const addConcernsBlocks = reviewStart >= 0 && !hasConcernsBlocks;
114
+ if (linesToDrop.size === 0 && !addConcernsBlocks)
115
+ return;
116
+ const newLines = [];
117
+ for (let i = 0; i < lines.length; i += 1) {
118
+ if (linesToDrop.has(i))
119
+ continue;
120
+ newLines.push(lines[i] ?? '');
121
+ // Append `concerns_blocks: true` right after the `review:` header line
122
+ // when the block exists but lacks the key.
123
+ if (i === reviewStart && addConcernsBlocks) {
124
+ const indent = reviewChildIndent.length > 0 ? reviewChildIndent : reviewIndent + ' ';
125
+ newLines.push(`${indent}concerns_blocks: true`);
126
+ }
127
+ }
128
+ const updated = newLines.join('\n');
129
+ const droppedSummary = [];
130
+ if (linesToDrop.size > 0)
131
+ droppedSummary.push(`dropped ${linesToDrop.size} removed-field line(s)`);
132
+ if (addConcernsBlocks)
133
+ droppedSummary.push('added concerns_blocks: true');
134
+ if (opts.dryRun) {
135
+ log(`[dry-run] would migrate .rea/policy.yaml: ${droppedSummary.join(', ')}`);
136
+ return;
137
+ }
138
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
139
+ const backupPath = `${policyPath}.bak-${ts}`;
140
+ await fsPromises.copyFile(policyPath, backupPath);
141
+ await atomicReplaceFile(policyPath, updated);
142
+ warn(`migrated .rea/policy.yaml for 0.11.0 (${droppedSummary.join(', ')}); backup at ${path.basename(backupPath)}`);
143
+ }
52
144
  /**
53
145
  * Hard cap for `showDiff` reads. Canonical files are all tiny (<64KB) but a
54
146
  * consumer could have replaced a hook with a 500MB log; refuse to slurp the
@@ -282,19 +374,50 @@ async function upgradeClaudeMdFragment(resolvedRoot, opts) {
282
374
  await atomicReplaceFile(claudeMdPath, next);
283
375
  return { sha: newSha, action: 'written' };
284
376
  }
377
+ /**
378
+ * Hook commands deleted in 0.11.0. `upgradeSettings` prunes any entries
379
+ * whose `command` string contains one of these tokens so consumer
380
+ * `.claude/settings.json` files don't keep invoking missing scripts
381
+ * (which would fail every matched tool call until the operator edited
382
+ * settings by hand).
383
+ *
384
+ * 0.11.0 removals:
385
+ * - push-review-gate.sh (replaced by husky stub → rea hook push-gate)
386
+ * - commit-review-gate.sh (intentionally unregistered; source-of-truth deleted)
387
+ * - push-review-gate-git.sh (native-git adapter; deleted)
388
+ *
389
+ * Add future removals here rather than baking the list into
390
+ * `pruneHookCommands` itself — the removal list is release history,
391
+ * not a static setting.
392
+ */
393
+ const STALE_HOOK_COMMAND_TOKENS = [
394
+ 'push-review-gate.sh',
395
+ 'commit-review-gate.sh',
396
+ 'push-review-gate-git.sh',
397
+ ];
285
398
  async function upgradeSettings(baseDir, opts) {
286
399
  const desired = defaultDesiredHooks();
287
400
  const sha = canonicalSettingsSubsetHash(desired);
288
401
  const { settings, settingsPath } = readSettings(baseDir);
289
- const mergeResult = mergeSettings(settings, desired);
402
+ // PRUNE FIRST (remove 0.11.0-deleted hook references), THEN MERGE
403
+ // (add any new hooks). Order matters: merging first would re-add the
404
+ // stale entry only to have the prune re-delete it on the next line —
405
+ // pointless work. Pruning first means the merge sees a clean baseline.
406
+ const pruned = pruneHookCommands(settings, STALE_HOOK_COMMAND_TOKENS);
407
+ const mergeResult = mergeSettings(pruned.merged, desired);
290
408
  if (opts.dryRun !== true) {
291
409
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
292
410
  }
411
+ const warnings = [...mergeResult.warnings];
412
+ if (pruned.removedCount > 0) {
413
+ warnings.push(`pruned ${pruned.removedCount} stale 0.10.x hook entr${pruned.removedCount === 1 ? 'y' : 'ies'} from .claude/settings.json (removed in 0.11.0)`);
414
+ }
293
415
  return {
294
416
  sha,
295
417
  addedCount: mergeResult.addedCount,
296
418
  skippedCount: mergeResult.skippedCount,
297
- warnings: mergeResult.warnings,
419
+ removedCount: pruned.removedCount,
420
+ warnings,
298
421
  };
299
422
  }
300
423
  /** Re-hash a file we just wrote. Source and on-disk bytes should match, but
@@ -321,6 +444,11 @@ export async function runUpgrade(options = {}) {
321
444
  if (options.force === true && !dryRun) {
322
445
  warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
323
446
  }
447
+ // 0.11.0 migration — strip removed review.* fields and backfill the new
448
+ // concerns_blocks default. Runs before canonical file reconciliation so a
449
+ // policy that fails strict schema load (which happens on upgrade from
450
+ // 0.10.x the moment we re-read `.rea/policy.yaml`) is cleaned up first.
451
+ await migrateReviewPolicyFor0110(resolvedRoot, { dryRun });
324
452
  const canonicalFiles = await enumerateCanonicalFiles();
325
453
  if (canonicalFiles.length === 0) {
326
454
  err('no canonical files found in package — is the build complete?');
@@ -110,28 +110,20 @@ export function isToolBlocked(toolName, serverName, gatewayConfig) {
110
110
  * Classify a `rea <subcommand>` Bash invocation by its own semantics rather
111
111
  * than the generic Bash default.
112
112
  *
113
- * Defect E (rea#78): REA's own governance CLI must not be denied by REA's own
114
- * middleware. The gate's error messages literally say "Run `rea cache set
115
- * <sha> pass --branch <x> --base <y>`" then the agent is denied at autonomy
116
- * L1 because `Bash` is classified Write and the downstream middleware can't
117
- * see that the Write is just appending a line to `.rea/review-cache.jsonl`.
113
+ * REA's own governance CLI must not be denied by REA's own middleware. This
114
+ * helper returns the tier appropriate to the rea subcommand when the command
115
+ * parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the command is
116
+ * not a rea invocation callers then fall back to the generic Bash tier.
118
117
  *
119
- * This helper returns the tier appropriate to the rea subcommand when the
120
- * command parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the
121
- * command is not a rea invocation callers then fall back to the generic
122
- * Bash tier.
123
- *
124
- * Tier mapping:
125
- * - Read: `cache check|list|get`, `audit verify`,
126
- * `audit record codex-review`, `check`, `doctor`, `status`
127
- * - Write: `cache set|clear`, `audit rotate`, `init`,
128
- * `serve`, `upgrade`, `unfreeze`
118
+ * Tier mapping (0.11.0):
119
+ * - Read: `audit verify`, `check`, `doctor`, `status`,
120
+ * `hook push-gate` (exec-onlyno state mutation)
121
+ * - Write: `audit rotate`, `init`, `serve`, `upgrade`, `unfreeze`
129
122
  * - Destructive: `freeze` (writes `.rea/HALT`, suspends the session)
130
123
  *
131
- * `audit record codex-review` is Read-tier because it is REA's own append-only
132
- * audit surface the whole point of the command is to let an L1 agent satisfy
133
- * the push-review gate without a human in the loop. Write-tier here would
134
- * reintroduce exactly the deadlock Defect D/E close.
124
+ * `rea cache` and `rea audit record codex-review` were removed in 0.11.0
125
+ * the stateless push-gate needs neither. Any lingering reference to those
126
+ * subcommands falls through to `Tier.Write` (safe default).
135
127
  *
136
128
  * SECURITY: returns `null` for any command containing shell metacharacters
137
129
  * that would let an attacker piggyback arbitrary commands onto an allowed
@@ -275,18 +267,19 @@ export function reaCommandTier(command) {
275
267
  case 'doctor':
276
268
  case 'status':
277
269
  return Tier.Read;
278
- case 'cache': {
279
- if (sub2 === 'check' || sub2 === 'list' || sub2 === 'get')
270
+ case 'hook': {
271
+ // `rea hook push-gate` is execution-only it runs codex exec review
272
+ // and writes `.rea/last-review.json` + an audit record, but the
273
+ // user-visible effect is an exit code. Classify Read so the pre-push
274
+ // hook is not blocked at L1 (symmetric to the 0.10.x reasoning for
275
+ // `audit record codex-review`).
276
+ if (sub2 === 'push-gate')
280
277
  return Tier.Read;
281
- if (sub2 === 'set' || sub2 === 'clear')
282
- return Tier.Write;
283
278
  return Tier.Write;
284
279
  }
285
280
  case 'audit': {
286
281
  if (sub2 === 'verify')
287
282
  return Tier.Read;
288
- if (sub2 === 'record')
289
- return Tier.Read;
290
283
  if (sub2 === 'rotate')
291
284
  return Tier.Write;
292
285
  return Tier.Write;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Base-branch resolution for the push-gate.
3
+ *
4
+ * Codex `exec review --base <ref>` needs a git ref to diff against. The
5
+ * caller may provide one via `rea hook push-gate --base <ref>`; if they
6
+ * don't, we resolve it in priority order:
7
+ *
8
+ * 1. Current branch's upstream (`@{upstream}`) — the branch the operator
9
+ * configured as their tracking ref. Most accurate since it matches
10
+ * what `git push` is about to compare against.
11
+ * 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
12
+ * automatically by `git clone` and `git remote set-head`.
13
+ * 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
14
+ * 4. Local `main` / `master` — last resort when the clone has no remote
15
+ * tracking refs yet (freshly initialized sibling project, mirror
16
+ * clone).
17
+ *
18
+ * On total failure we surface the sentinel `empty-tree` SHA
19
+ * (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
20
+ * file in HEAD. First pushes to a fresh remote still get reviewed — the
21
+ * 0.10.x fail-open at this resolution step was defect J.
22
+ *
23
+ * All git commands are invoked via the injected `GitExecutor` — tests
24
+ * replace it with a fake to avoid shelling out.
25
+ */
26
+ import type { GitExecutor } from './codex-runner.js';
27
+ export interface BaseResolution {
28
+ /**
29
+ * Resolved ref (branch, remote-tracking ref, or 40-char SHA). Always
30
+ * usable as the `--base` argument to `codex exec review`.
31
+ */
32
+ ref: string;
33
+ /** Where the ref came from — surfaces in audit records and stderr. */
34
+ source: 'explicit' | 'last-n-commits' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
35
+ /**
36
+ * Set when `last-n-commits` was requested but `<headRef>~N` did not
37
+ * resolve at the requested depth (shallower-than-N clone, or N larger
38
+ * than the branch history). The resolver clamps to the deepest
39
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
40
+ * resolve) and surfaces both numbers so the caller can emit a stderr
41
+ * warning ("requested N=50; clamped to K=12 (oldest reachable)").
42
+ * Present on both `last-n-commits` results (when clamped) and
43
+ * `empty-tree` results (when even `~1` was unreachable — orphan or
44
+ * single-commit branch).
45
+ */
46
+ lastNCommitsRequested?: number;
47
+ /**
48
+ * The N value actually used. When source is `last-n-commits`, this is
49
+ * the depth that resolved (equals `lastNCommitsRequested` on full
50
+ * resolution; smaller when clamped to a shallow clone). Surfaces in
51
+ * audit metadata so operators can grep their audit log for narrowed
52
+ * reviews.
53
+ */
54
+ lastNCommits?: number;
55
+ }
56
+ /**
57
+ * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
58
+ * installation carries this object implicitly. Using it as a fallback lets
59
+ * a review on a clone with no tracking refs still exercise the entire HEAD
60
+ * tree diff.
61
+ */
62
+ export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
63
+ export interface ResolveBaseOptions {
64
+ /**
65
+ * Explicit ref from `--base <ref>` or equivalent. When set, the resolver
66
+ * returns it unchanged with `source: 'explicit'` — the caller is
67
+ * responsible for its correctness. We do NOT validate that it exists;
68
+ * that's Codex's job (it'll error clearly if the ref is bad).
69
+ */
70
+ explicit?: string;
71
+ /**
72
+ * Resolve the base to `HEAD~N` instead of running the upstream ladder.
73
+ * `--last-n-commits N` flag and `policy.review.last_n_commits` map
74
+ * here. Ignored when `explicit` is set (explicit ref wins). When
75
+ * `<headRef>~N` does not resolve (shallower-than-N clone, or branch
76
+ * history shorter than N), the resolver CLAMPS to the deepest
77
+ * reachable commit (`<headRef>~K` for the largest `K <= N` that does
78
+ * resolve) — i.e. it diffs against the oldest commit on the branch.
79
+ * It does NOT fall back to the empty-tree sentinel; that would
80
+ * silently expand "the last N commits" to "the entire repository
81
+ * snapshot" on a normal repo with a short feature branch, flooding
82
+ * Codex with unchanged base-branch files. The resolver only emits
83
+ * `source: 'empty-tree'` when even `<headRef>~1` cannot be resolved
84
+ * (orphan branch, single-commit history); in that case
85
+ * `lastNCommitsRequested: N` is set so the caller can warn.
86
+ */
87
+ lastNCommits?: number;
88
+ /**
89
+ * The head ref the gate is reviewing. Defaults to literal "HEAD" — i.e.
90
+ * the local checkout's tip. When the gate is invoked via pre-push and
91
+ * the pushed ref is not the current branch (e.g.
92
+ * `git push origin some-other-branch`), the caller passes the pushed
93
+ * `<sha>` here so `last-n-commits` resolves `<sha>~N` rather than
94
+ * `HEAD~N`. Without this thread-through the review walks back N commits
95
+ * from the local checkout, which can be a different branch entirely.
96
+ */
97
+ headRef?: string;
98
+ }
99
+ /**
100
+ * Resolve the base ref using the configured priority order. Never throws —
101
+ * every failure mode degrades to the next step, and the worst case (no
102
+ * tracking refs, no local main/master) is `empty-tree`.
103
+ */
104
+ export declare function resolveBaseRef(git: GitExecutor, options?: ResolveBaseOptions): BaseResolution;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Base-branch resolution for the push-gate.
3
+ *
4
+ * Codex `exec review --base <ref>` needs a git ref to diff against. The
5
+ * caller may provide one via `rea hook push-gate --base <ref>`; if they
6
+ * don't, we resolve it in priority order:
7
+ *
8
+ * 1. Current branch's upstream (`@{upstream}`) — the branch the operator
9
+ * configured as their tracking ref. Most accurate since it matches
10
+ * what `git push` is about to compare against.
11
+ * 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
12
+ * automatically by `git clone` and `git remote set-head`.
13
+ * 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
14
+ * 4. Local `main` / `master` — last resort when the clone has no remote
15
+ * tracking refs yet (freshly initialized sibling project, mirror
16
+ * clone).
17
+ *
18
+ * On total failure we surface the sentinel `empty-tree` SHA
19
+ * (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
20
+ * file in HEAD. First pushes to a fresh remote still get reviewed — the
21
+ * 0.10.x fail-open at this resolution step was defect J.
22
+ *
23
+ * All git commands are invoked via the injected `GitExecutor` — tests
24
+ * replace it with a fake to avoid shelling out.
25
+ */
26
+ /**
27
+ * Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
28
+ * installation carries this object implicitly. Using it as a fallback lets
29
+ * a review on a clone with no tracking refs still exercise the entire HEAD
30
+ * tree diff.
31
+ */
32
+ export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
33
+ /**
34
+ * Resolve the base ref using the configured priority order. Never throws —
35
+ * every failure mode degrades to the next step, and the worst case (no
36
+ * tracking refs, no local main/master) is `empty-tree`.
37
+ */
38
+ export function resolveBaseRef(git, options = {}) {
39
+ if (options.explicit !== undefined && options.explicit.length > 0) {
40
+ return { ref: options.explicit, source: 'explicit' };
41
+ }
42
+ // 0. Last-N-commits override. Caller (CLI flag or policy key) requested
43
+ // diffing against `HEAD~N` directly. Resolves to a 40-char SHA via
44
+ // `git rev-parse HEAD~N`; on failure (shallower-than-N clone, or N
45
+ // larger than the branch's history depth) we fall back to the
46
+ // empty-tree sentinel and surface `lastNCommitsRequested` so the
47
+ // caller can emit a stderr warning. We deliberately resolve to a
48
+ // SHA rather than passing `HEAD~N` through to Codex — Codex shells
49
+ // out to `git diff` itself, but a SHA is unambiguous regardless of
50
+ // intermediate ref churn.
51
+ if (options.lastNCommits !== undefined && options.lastNCommits > 0) {
52
+ // Walk back N commits from the actual head being reviewed (defaults to
53
+ // local HEAD when the caller didn't thread a pushed ref through). Using
54
+ // a literal "HEAD" here would be wrong for `git push origin
55
+ // some-other-branch` invocations, where the local checkout's HEAD is a
56
+ // different branch entirely and the resulting diff would compare the
57
+ // wrong commits.
58
+ const headRef = options.headRef !== undefined && options.headRef.length > 0
59
+ ? options.headRef
60
+ : 'HEAD';
61
+ const requested = options.lastNCommits;
62
+ const tryDepth = (k) => git.tryRevParse(['--verify', '--quiet', `${headRef}~${k}^{commit}`]).trim();
63
+ // Fast path: requested depth resolves directly.
64
+ const direct = tryDepth(requested);
65
+ if (direct.length > 0) {
66
+ return {
67
+ ref: direct,
68
+ source: 'last-n-commits',
69
+ lastNCommits: requested,
70
+ };
71
+ }
72
+ // Clamp: `<headRef>~N` did not resolve. Two distinct causes need
73
+ // different handling — and the difference matters because the wrong
74
+ // choice silently inflates the review:
75
+ //
76
+ // (i) Branch is genuinely shorter than N (full clone). The
77
+ // deepest resolvable ancestor `<headRef>~K` IS the root
78
+ // commit (parent-less). Diffing against `<headRef>~K` would
79
+ // EXCLUDE the root commit's changes (`git diff base..head`
80
+ // excludes `base`), so we diff against EMPTY_TREE_SHA to
81
+ // include them. Report lastNCommits = K + 1 (every commit on
82
+ // the branch was reviewed).
83
+ //
84
+ // (ii) Repo is a shallow clone — `<headRef>~K` resolves but
85
+ // `<headRef>~K`'s parent simply isn't fetched locally. The
86
+ // commit isn't actually the root; older history exists on
87
+ // the remote. Diffing against EMPTY_TREE_SHA would balloon
88
+ // the review to "every tracked file in the checkout"
89
+ // (including all unchanged base-branch files), defeating
90
+ // the entire point of last-n-commits. So in the shallow
91
+ // case we diff against `<headRef>~K` itself, accepting that
92
+ // the K-th commit's changes are excluded — the operator
93
+ // chose a shallow clone and the deepest reachable commit is
94
+ // the best base we have. Report lastNCommits = K (the K
95
+ // ancestors we DID reach).
96
+ //
97
+ // `git rev-parse --is-shallow-repository` distinguishes the two
98
+ // cases (returns "true" / "false"). On unknown / errored output we
99
+ // assume FULL (the safer default for case (i): we'd rather review
100
+ // the root commit and risk a slightly larger diff than silently
101
+ // drop changes).
102
+ //
103
+ // Both Codex [P1] findings 2026-04-29 (initial empty-tree-on-clamp
104
+ // dropping root commit, then shallow-clone empty-tree expanding to
105
+ // full repo) drove this two-branch design.
106
+ const oneSha = tryDepth(1);
107
+ if (oneSha.length === 0) {
108
+ // Even `<headRef>~1` does not resolve — single-commit history
109
+ // (full clone with one commit) OR a shallow clone fetched at
110
+ // depth=1. In both cases the only locally-resolvable commit is
111
+ // headRef itself; there's no useful intermediate base. Fall back
112
+ // to empty-tree (matches case (i) of single commit review) and
113
+ // report lastNCommits = 1.
114
+ return {
115
+ ref: EMPTY_TREE_SHA,
116
+ source: 'empty-tree',
117
+ lastNCommits: 1,
118
+ lastNCommitsRequested: requested,
119
+ };
120
+ }
121
+ // Binary search for the deepest K < N where `<headRef>~K` resolves.
122
+ // Invariant: tryDepth(lo) resolves; tryDepth(hi+1) does not. We
123
+ // narrow until lo > hi; bestDepth carries the highest K seen.
124
+ let lo = 1;
125
+ let hi = requested - 1;
126
+ let bestDepth = 1;
127
+ while (lo <= hi) {
128
+ const mid = lo + Math.floor((hi - lo) / 2);
129
+ const sha = tryDepth(mid);
130
+ if (sha.length > 0) {
131
+ bestDepth = mid;
132
+ lo = mid + 1;
133
+ }
134
+ else {
135
+ hi = mid - 1;
136
+ }
137
+ }
138
+ const shallowFlag = git.tryRevParse(['--is-shallow-repository']).trim();
139
+ if (shallowFlag === 'true') {
140
+ // Case (ii): shallow clone. Diff against the deepest reachable
141
+ // ancestor SHA — its parent exists on the remote but isn't
142
+ // locally available, so empty-tree would over-review. Accept that
143
+ // the K-th commit's content is excluded; that's the cost of the
144
+ // shallow clone the operator chose.
145
+ const bestSha = tryDepth(bestDepth);
146
+ return {
147
+ ref: bestSha,
148
+ source: 'last-n-commits',
149
+ lastNCommits: bestDepth,
150
+ lastNCommitsRequested: requested,
151
+ };
152
+ }
153
+ // Case (i): full clone, branch genuinely shorter than N. The
154
+ // deepest resolvable ancestor IS the root. Diff against empty-tree
155
+ // to include the root commit's changes; reviewed count = K + 1.
156
+ return {
157
+ ref: EMPTY_TREE_SHA,
158
+ source: 'last-n-commits',
159
+ lastNCommits: bestDepth + 1,
160
+ lastNCommitsRequested: requested,
161
+ };
162
+ }
163
+ // 1. Upstream of current branch. `@{upstream}` resolves to the configured
164
+ // tracking ref (typically `refs/remotes/origin/<branch>`). Returns
165
+ // empty on branches without an upstream — which is normal for a brand
166
+ // new feature branch; fall through.
167
+ const upstream = git
168
+ .tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
169
+ .trim();
170
+ if (upstream.length > 0) {
171
+ return { ref: upstream, source: 'upstream' };
172
+ }
173
+ // 2. origin/HEAD symbolic ref. Set by `git clone` to point at the remote's
174
+ // default branch. `git symbolic-ref` returns the full ref path; we
175
+ // hand that to Codex directly (it's `refs/remotes/origin/<name>`).
176
+ const originHead = git.trySymbolicRef('refs/remotes/origin/HEAD').trim();
177
+ if (originHead.length > 0) {
178
+ return { ref: originHead, source: 'origin-head' };
179
+ }
180
+ // 3. Explicit probes for the two most common default-branch names.
181
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/main']).length > 0) {
182
+ return { ref: 'refs/remotes/origin/main', source: 'origin-main' };
183
+ }
184
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/master']).length > 0) {
185
+ return { ref: 'refs/remotes/origin/master', source: 'origin-master' };
186
+ }
187
+ // 4. Local branches. `main` and `master` without `refs/remotes/` prefix
188
+ // resolve via `refs/heads/`. Order matches priority 3.
189
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/main']).length > 0) {
190
+ return { ref: 'refs/heads/main', source: 'local-main' };
191
+ }
192
+ if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/master']).length > 0) {
193
+ return { ref: 'refs/heads/master', source: 'local-master' };
194
+ }
195
+ // 5. Last resort: diff against the empty-tree SHA. Covers every file in
196
+ // HEAD; expensive for large repos but correct.
197
+ return { ref: EMPTY_TREE_SHA, source: 'empty-tree' };
198
+ }