happy-stacks 0.3.0 → 0.5.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,85 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { formatTriageMarkdown, parseCodeRabbitPlainOutput, parseCodexReviewText } from './findings.mjs';
5
+
6
+ test('parseCodeRabbitPlainOutput parses CodeRabbit plain blocks', () => {
7
+ const out = [
8
+ '============================================================================',
9
+ 'File: cli/src/utils/spawnHappyCLI.invocation.test.ts',
10
+ 'Line: 17 to 31',
11
+ 'Type: potential_issue',
12
+ '',
13
+ 'Comment:',
14
+ 'Dynamic imports may be cached, causing test isolation issues.',
15
+ '',
16
+ 'Some more details.',
17
+ '',
18
+ 'Prompt for AI Agent:',
19
+ 'Do the thing.',
20
+ '',
21
+ '============================================================================',
22
+ 'File: expo-app/sources/app/(app)/_layout.tsx',
23
+ 'Line: 29 to 35',
24
+ 'Type: potential_issue',
25
+ '',
26
+ 'Comment:',
27
+ "Hooks order violation: useUnistyles() called after conditional return.",
28
+ '',
29
+ 'More details.',
30
+ ].join('\n');
31
+
32
+ const findings = parseCodeRabbitPlainOutput(out);
33
+ assert.equal(findings.length, 2);
34
+ assert.equal(findings[0].file, 'cli/src/utils/spawnHappyCLI.invocation.test.ts');
35
+ assert.deepEqual(findings[0].lines, { start: 17, end: 31 });
36
+ assert.equal(findings[0].type, 'potential_issue');
37
+ assert.equal(findings[0].title, 'Dynamic imports may be cached, causing test isolation issues.');
38
+ assert.match(findings[0].comment, /Some more details/);
39
+ assert.match(findings[0].prompt, /Do the thing/);
40
+ });
41
+
42
+ test('parseCodexReviewText extracts findings JSON trailer', () => {
43
+ const review = [
44
+ 'Overall verdict: looks good.',
45
+ '',
46
+ '===FINDINGS_JSON===',
47
+ JSON.stringify(
48
+ [
49
+ {
50
+ severity: 'major',
51
+ file: 'server/sources/main.light.ts',
52
+ title: 'Do not exit after startup',
53
+ recommendation: 'Remove process.exit(0) on success.',
54
+ },
55
+ ],
56
+ null,
57
+ 2
58
+ ),
59
+ ].join('\n');
60
+
61
+ const findings = parseCodexReviewText(review);
62
+ assert.equal(findings.length, 1);
63
+ assert.equal(findings[0].file, 'server/sources/main.light.ts');
64
+ assert.equal(findings[0].severity, 'major');
65
+ });
66
+
67
+ test('formatTriageMarkdown includes required workflow fields', () => {
68
+ const md = formatTriageMarkdown({
69
+ runLabel: 'review-123',
70
+ baseRef: 'upstream/main',
71
+ findings: [
72
+ {
73
+ reviewer: 'coderabbit',
74
+ id: 'CR-001',
75
+ file: 'cli/src/x.ts',
76
+ title: 'Thing',
77
+ type: 'potential_issue',
78
+ },
79
+ ],
80
+ });
81
+ assert.match(md, /Final decision: \*\*TBD\*\*/);
82
+ assert.match(md, /Verified in validation worktree:/);
83
+ assert.match(md, /Commit:/);
84
+ });
85
+
@@ -0,0 +1,153 @@
1
+ import { runCapture } from '../proc/proc.mjs';
2
+
3
+ function normalizePath(p) {
4
+ return String(p ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
5
+ }
6
+
7
+ function parseNameStatusZ(buf) {
8
+ const raw = String(buf ?? '');
9
+ if (!raw) return [];
10
+ const parts = raw.split('\0').filter((x) => x.length);
11
+ const entries = [];
12
+ let i = 0;
13
+ while (i < parts.length) {
14
+ const status = parts[i++];
15
+ const code = status[0] ?? '';
16
+ if (!code) break;
17
+ if (code === 'R' || code === 'C') {
18
+ const from = parts[i++] ?? '';
19
+ const to = parts[i++] ?? '';
20
+ entries.push({ code, status, from, to });
21
+ continue;
22
+ }
23
+ const path = parts[i++] ?? '';
24
+ entries.push({ code, status, path });
25
+ }
26
+ return entries;
27
+ }
28
+
29
+ export async function getChangedOps({ cwd, baseRef, headRef = 'HEAD', env = process.env } = {}) {
30
+ const out = await runCapture('git', ['diff', '--name-status', '--find-renames', '-z', `${baseRef}...${headRef}`], { cwd, env });
31
+ const entries = parseNameStatusZ(out);
32
+ const checkout = new Set();
33
+ const remove = new Set();
34
+ for (const e of entries) {
35
+ if (e.code === 'A' || e.code === 'M' || e.code === 'T') {
36
+ checkout.add(normalizePath(e.path));
37
+ continue;
38
+ }
39
+ if (e.code === 'D') {
40
+ remove.add(normalizePath(e.path));
41
+ continue;
42
+ }
43
+ if (e.code === 'R' || e.code === 'C') {
44
+ if (e.from) remove.add(normalizePath(e.from));
45
+ if (e.to) checkout.add(normalizePath(e.to));
46
+ continue;
47
+ }
48
+ }
49
+ const all = new Set([...checkout, ...remove]);
50
+ return { checkout, remove, all };
51
+ }
52
+
53
+ function subset(set, allowed) {
54
+ const out = new Set();
55
+ for (const v of set) {
56
+ if (allowed.has(v)) out.add(v);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function difference(set, blocked) {
62
+ const out = new Set();
63
+ for (const v of set) {
64
+ if (!blocked.has(v)) out.add(v);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ async function batched(args, batchSize, fn) {
70
+ const list = Array.from(args);
71
+ for (let i = 0; i < list.length; i += batchSize) {
72
+ // eslint-disable-next-line no-await-in-loop
73
+ await fn(list.slice(i, i + batchSize));
74
+ }
75
+ }
76
+
77
+ async function gitCommit({ cwd, env, message }) {
78
+ await runCapture(
79
+ 'git',
80
+ [
81
+ '-c',
82
+ 'user.name=Happy Review',
83
+ '-c',
84
+ 'user.email=review@happy.local',
85
+ '-c',
86
+ 'commit.gpgsign=false',
87
+ 'commit',
88
+ '-q',
89
+ '--no-verify',
90
+ '-m',
91
+ message,
92
+ ],
93
+ { cwd, env }
94
+ );
95
+ const sha = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd, env })).trim();
96
+ return sha;
97
+ }
98
+
99
+ async function applyOpsFromHead({ cwd, env, headCommit, checkoutPaths, removePaths }) {
100
+ if (removePaths.size) {
101
+ await batched(Array.from(removePaths), 200, async (batch) => {
102
+ await runCapture('git', ['rm', '-q', '--ignore-unmatch', '--', ...batch], { cwd, env });
103
+ });
104
+ }
105
+ if (checkoutPaths.size) {
106
+ // Prefer batching over pathspec-from-file to maximize compatibility.
107
+ await batched(Array.from(checkoutPaths), 100, async (batch) => {
108
+ await runCapture('git', ['checkout', headCommit, '--', ...batch], { cwd, env });
109
+ });
110
+ }
111
+ // Stage all changes introduced by the operations.
112
+ await runCapture('git', ['add', '-A'], { cwd, env });
113
+ }
114
+
115
+ /**
116
+ * Create two local commits inside an ephemeral worktree:
117
+ * - baseSliceCommit: baseRef plus all NON-slice changes from headCommit
118
+ * - headSliceCommit: baseSliceCommit plus slice changes from headCommit (resulting tree equals headCommit)
119
+ *
120
+ * These commits are intended solely for review tooling (CodeRabbit/Codex) so the reviewer sees:
121
+ * - full, final code at HEAD
122
+ * - a focused diff for the slice (baseSliceCommit..headSliceCommit)
123
+ */
124
+ export async function createHeadSliceCommits({
125
+ cwd,
126
+ env = process.env,
127
+ baseRef,
128
+ headCommit,
129
+ ops,
130
+ slicePaths,
131
+ label = 'slice',
132
+ } = {}) {
133
+ const sliceSet = new Set((Array.isArray(slicePaths) ? slicePaths : []).map(normalizePath).filter(Boolean));
134
+ const sliceCheckout = subset(ops.checkout, sliceSet);
135
+ const sliceRemove = subset(ops.remove, sliceSet);
136
+ const nonSliceCheckout = difference(ops.checkout, sliceSet);
137
+ const nonSliceRemove = difference(ops.remove, sliceSet);
138
+
139
+ // Start from baseRef.
140
+ await runCapture('git', ['checkout', '-q', '--detach', baseRef], { cwd, env });
141
+
142
+ // Commit non-slice changes.
143
+ await applyOpsFromHead({ cwd, env, headCommit, checkoutPaths: nonSliceCheckout, removePaths: nonSliceRemove });
144
+ const baseSliceCommit = await gitCommit({ cwd, env, message: `chore(review): base for ${label}` });
145
+
146
+ // Commit slice changes.
147
+ await applyOpsFromHead({ cwd, env, headCommit, checkoutPaths: sliceCheckout, removePaths: sliceRemove });
148
+ const headSliceCommit = await gitCommit({ cwd, env, message: `chore(review): ${label}` });
149
+
150
+ // Ensure working tree is at the head slice commit for downstream tools.
151
+ await runCapture('git', ['checkout', '-q', headSliceCommit], { cwd, env });
152
+ return { baseSliceCommit, headSliceCommit };
153
+ }
@@ -0,0 +1,91 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { run, runCapture } from '../proc/proc.mjs';
7
+ import { createHeadSliceCommits, getChangedOps } from './head_slice.mjs';
8
+
9
+ function gitEnv() {
10
+ const clean = {};
11
+ for (const [k, v] of Object.entries(process.env)) {
12
+ if (k.startsWith('HAPPY_STACKS_') || k.startsWith('HAPPY_LOCAL_')) continue;
13
+ clean[k] = v;
14
+ }
15
+ return {
16
+ ...clean,
17
+ GIT_AUTHOR_NAME: 'Test',
18
+ GIT_AUTHOR_EMAIL: 'test@example.com',
19
+ GIT_COMMITTER_NAME: 'Test',
20
+ GIT_COMMITTER_EMAIL: 'test@example.com',
21
+ };
22
+ }
23
+
24
+ test('createHeadSliceCommits produces a focused diff while keeping full HEAD code', async (t) => {
25
+ const repo = await mkdtemp(join(tmpdir(), 'happy-review-head-slice-'));
26
+ const env = gitEnv();
27
+
28
+ const wt = join(repo, 'wt');
29
+ try {
30
+ await run('git', ['init', '-q'], { cwd: repo, env });
31
+ await run('git', ['checkout', '-q', '-b', 'main'], { cwd: repo, env });
32
+ await mkdir(join(repo, 'expo-app'), { recursive: true });
33
+ await mkdir(join(repo, 'cli'), { recursive: true });
34
+ await mkdir(join(repo, 'server'), { recursive: true });
35
+ await writeFile(join(repo, 'expo-app', 'a.txt'), 'base-a\n', 'utf-8');
36
+ await writeFile(join(repo, 'cli', 'c.txt'), 'base-c\n', 'utf-8');
37
+ await writeFile(join(repo, 'server', 'b.txt'), 'base-b\n', 'utf-8');
38
+ await run('git', ['add', '.'], { cwd: repo, env });
39
+ await run('git', ['commit', '-q', '-m', 'chore: base'], { cwd: repo, env });
40
+
41
+ // HEAD commit with mixed changes across areas.
42
+ await writeFile(join(repo, 'expo-app', 'a.txt'), 'head-a\n', 'utf-8');
43
+ await writeFile(join(repo, 'expo-app', 'new.txt'), 'new\n', 'utf-8');
44
+ await writeFile(join(repo, 'cli', 'c.txt'), 'head-c\n', 'utf-8');
45
+ await rm(join(repo, 'server', 'b.txt'));
46
+ await run('git', ['add', '-A'], { cwd: repo, env });
47
+ await run('git', ['commit', '-q', '-m', 'feat: head'], { cwd: repo, env });
48
+
49
+ const headCommit = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: repo, env })).trim();
50
+ const baseCommit = (await runCapture('git', ['rev-parse', 'HEAD^'], { cwd: repo, env })).trim();
51
+
52
+ // Create an ephemeral worktree to run the slice commit builder in isolation.
53
+ await run('git', ['worktree', 'add', '--detach', wt, baseCommit], { cwd: repo, env });
54
+
55
+ const ops = await getChangedOps({ cwd: repo, baseRef: baseCommit, headRef: headCommit, env });
56
+ const { baseSliceCommit, headSliceCommit } = await createHeadSliceCommits({
57
+ cwd: wt,
58
+ env,
59
+ baseRef: baseCommit,
60
+ headCommit,
61
+ ops,
62
+ slicePaths: ['expo-app/a.txt', 'expo-app/new.txt'],
63
+ label: 'expo-app',
64
+ });
65
+
66
+ // Working tree should match full HEAD.
67
+ const a = await readFile(join(wt, 'expo-app', 'a.txt'), 'utf-8');
68
+ const c = await readFile(join(wt, 'cli', 'c.txt'), 'utf-8');
69
+ assert.equal(a, 'head-a\n');
70
+ assert.equal(c, 'head-c\n');
71
+ await assert.rejects(async () => await readFile(join(wt, 'server', 'b.txt'), 'utf-8'));
72
+
73
+ // Diff between slice commits should include only expo-app changes.
74
+ const diffNames = (
75
+ await runCapture('git', ['diff', '--name-only', `${baseSliceCommit}...${headSliceCommit}`], { cwd: wt, env })
76
+ )
77
+ .trim()
78
+ .split('\n')
79
+ .filter(Boolean)
80
+ .sort();
81
+ assert.deepEqual(diffNames, ['expo-app/a.txt', 'expo-app/new.txt']);
82
+ } finally {
83
+ try {
84
+ await run('git', ['worktree', 'remove', '--force', wt], { cwd: repo, env });
85
+ await run('git', ['worktree', 'prune'], { cwd: repo, env });
86
+ } catch {
87
+ // ignore cleanup errors (best-effort)
88
+ }
89
+ await rm(repo, { recursive: true, force: true });
90
+ }
91
+ });
@@ -0,0 +1,20 @@
1
+ You are running a deep, long-form code review.
2
+
3
+ Goals:
4
+ - Find correctness bugs, edge cases, and regressions vs upstream/main.
5
+ - Find performance problems (big-O, unnecessary allocations, redundant work) and reliability issues.
6
+ - Find security and safety issues (filesystem access, env handling, process spawning, injection risks).
7
+ - Find maintainability issues (duplication, unclear ownership boundaries, inconsistent patterns).
8
+ - Ensure i18n coverage is complete: do not introduce hardcoded user-visible strings.
9
+
10
+ Constraints:
11
+ - Prefer fixes that are unified/coherent and avoid duplicating logic.
12
+ - Avoid “brittle” tests that assert on wording/phrasing or hardcoded text; test real behavior and observable outcomes.
13
+ - Do not suggest broad refactors unless clearly justified and low-risk.
14
+ - Treat every recommendation as a suggestion: validate against best practices and the existing codebase patterns; do not propose changes that conflict with project invariants.
15
+ - If a recommendation is uncertain, depends on product/UX decisions, or might have hidden tradeoffs, explicitly mark it as "needs discussion".
16
+
17
+ Output:
18
+ - Provide specific, actionable recommendations with file paths and a brief rationale.
19
+ - Call out any items that are uncertain or require product/UX decisions separately.
20
+ - Be exhaustive: include all findings you notice, not only the highest-signal ones.
@@ -0,0 +1,61 @@
1
+ import { runCaptureResult } from '../../proc/proc.mjs';
2
+ import { join } from 'node:path';
3
+
4
+ function normalizeType(raw) {
5
+ const t = String(raw ?? '').trim().toLowerCase();
6
+ if (!t) return 'committed';
7
+ if (t === 'all' || t === 'committed' || t === 'uncommitted') return t;
8
+ throw new Error(`[review] invalid coderabbit type: ${raw} (expected: all|committed|uncommitted)`);
9
+ }
10
+
11
+ export function buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles }) {
12
+ const args = ['review', '--plain', '--no-color', '--type', normalizeType(type), '--cwd', repoDir];
13
+ const base = String(baseRef ?? '').trim();
14
+ const bc = String(baseCommit ?? '').trim();
15
+ if (base && bc) {
16
+ throw new Error('[review] coderabbit: baseRef and baseCommit are mutually exclusive');
17
+ }
18
+ if (base) args.push('--base', base);
19
+ if (bc) args.push('--base-commit', bc);
20
+ const files = Array.isArray(configFiles) ? configFiles.filter(Boolean) : [];
21
+ if (files.length) args.push('--config', ...files);
22
+ return args;
23
+ }
24
+
25
+ export function buildCodeRabbitEnv({ env, homeDir }) {
26
+ const merged = { ...(env ?? {}) };
27
+ const dir = String(homeDir ?? '').trim();
28
+ if (!dir) return merged;
29
+
30
+ merged.HOME = dir;
31
+ merged.USERPROFILE = dir;
32
+ merged.CODERABBIT_HOME = join(dir, '.coderabbit');
33
+ merged.XDG_CONFIG_HOME = join(dir, '.config');
34
+ merged.XDG_CACHE_HOME = join(dir, '.cache');
35
+ merged.XDG_STATE_HOME = join(dir, '.local', 'state');
36
+ merged.XDG_DATA_HOME = join(dir, '.local', 'share');
37
+ return merged;
38
+ }
39
+
40
+ export async function runCodeRabbitReview({
41
+ repoDir,
42
+ baseRef,
43
+ baseCommit,
44
+ env,
45
+ type = 'committed',
46
+ configFiles = [],
47
+ streamLabel,
48
+ teeFile,
49
+ teeLabel,
50
+ }) {
51
+ const homeDir = (env?.HAPPY_STACKS_CODERABBIT_HOME_DIR ?? env?.HAPPY_LOCAL_CODERABBIT_HOME_DIR ?? '').toString().trim();
52
+ const args = buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles });
53
+ const res = await runCaptureResult('coderabbit', args, {
54
+ cwd: repoDir,
55
+ env: buildCodeRabbitEnv({ env, homeDir }),
56
+ streamLabel,
57
+ teeFile,
58
+ teeLabel,
59
+ });
60
+ return { ...res, stdout: res.out, stderr: res.err };
61
+ }
@@ -0,0 +1,59 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+
5
+ import { buildCodeRabbitEnv, buildCodeRabbitReviewArgs } from './coderabbit.mjs';
6
+
7
+ test('buildCodeRabbitReviewArgs builds committed review args by default', () => {
8
+ const repoDir = '/tmp/repo';
9
+ const args = buildCodeRabbitReviewArgs({ repoDir, baseRef: 'upstream/main', type: undefined, configFiles: [] });
10
+ assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base', 'upstream/main']);
11
+ });
12
+
13
+ test('buildCodeRabbitReviewArgs uses --base-commit when provided', () => {
14
+ const repoDir = '/tmp/repo';
15
+ const args = buildCodeRabbitReviewArgs({ repoDir, baseCommit: 'abc123', type: 'committed', configFiles: [] });
16
+ assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base-commit', 'abc123']);
17
+ });
18
+
19
+ test('buildCodeRabbitReviewArgs rejects providing both baseRef and baseCommit', () => {
20
+ assert.throws(
21
+ () => buildCodeRabbitReviewArgs({ repoDir: '/tmp/repo', baseRef: 'upstream/main', baseCommit: 'abc123', type: 'committed', configFiles: [] }),
22
+ /mutually exclusive/
23
+ );
24
+ });
25
+
26
+ test('buildCodeRabbitReviewArgs includes --config when files are provided', () => {
27
+ const repoDir = '/tmp/repo';
28
+ const args = buildCodeRabbitReviewArgs({
29
+ repoDir,
30
+ baseRef: 'upstream/main',
31
+ type: 'committed',
32
+ configFiles: ['/tmp/a.md', '/tmp/b.md'],
33
+ });
34
+ assert.deepEqual(args, [
35
+ 'review',
36
+ '--plain',
37
+ '--no-color',
38
+ '--type',
39
+ 'committed',
40
+ '--cwd',
41
+ repoDir,
42
+ '--base',
43
+ 'upstream/main',
44
+ '--config',
45
+ '/tmp/a.md',
46
+ '/tmp/b.md',
47
+ ]);
48
+ });
49
+
50
+ test('buildCodeRabbitEnv overrides HOME/XDG paths when a homeDir is provided', () => {
51
+ const env = buildCodeRabbitEnv({ env: { PATH: '/bin' }, homeDir: '/tmp/cr-home' });
52
+ assert.equal(env.PATH, '/bin');
53
+ assert.equal(env.HOME, '/tmp/cr-home');
54
+ assert.equal(env.CODERABBIT_HOME, join('/tmp/cr-home', '.coderabbit'));
55
+ assert.equal(env.XDG_CONFIG_HOME, join('/tmp/cr-home', '.config'));
56
+ assert.equal(env.XDG_CACHE_HOME, join('/tmp/cr-home', '.cache'));
57
+ assert.equal(env.XDG_STATE_HOME, join('/tmp/cr-home', '.local', 'state'));
58
+ assert.equal(env.XDG_DATA_HOME, join('/tmp/cr-home', '.local', 'share'));
59
+ });
@@ -0,0 +1,61 @@
1
+ import { runCaptureResult } from '../../proc/proc.mjs';
2
+
3
+ export function extractCodexReviewFromJsonl(jsonlText) {
4
+ const lines = String(jsonlText ?? '')
5
+ .split('\n')
6
+ .map((l) => l.trim())
7
+ .filter(Boolean);
8
+
9
+ // JSONL events typically look like: { "type": "...", "payload": {...} } or similar.
10
+ // We keep this resilient by searching for keys matching the exec output format.
11
+ for (const line of lines) {
12
+ let obj = null;
13
+ try {
14
+ obj = JSON.parse(line);
15
+ } catch {
16
+ continue;
17
+ }
18
+ const candidates = [obj, obj?.msg, obj?.payload, obj?.event, obj?.data, obj?.result].filter(Boolean);
19
+ for (const c of candidates) {
20
+ const exited =
21
+ c?.ExitedReviewMode ??
22
+ (c?.type === 'ExitedReviewMode' ? c : null) ??
23
+ (c?.event?.type === 'ExitedReviewMode' ? c.event : null) ??
24
+ (c?.payload?.type === 'ExitedReviewMode' ? c.payload : null);
25
+
26
+ const reviewOutput = exited?.review_output ?? exited?.reviewOutput ?? null;
27
+ if (reviewOutput) return reviewOutput;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function buildCodexReviewArgs({ baseRef, jsonMode, prompt }) {
34
+ const args = ['exec', 'review', '--dangerously-bypass-approvals-and-sandbox'];
35
+
36
+ // Codex review targets are mutually exclusive:
37
+ // - --base / --commit / --uncommitted are distinct "targets"
38
+ // - Providing a PROMPT switches to the "custom instructions" target and cannot be combined with the above.
39
+ // Therefore, when reviewing a target (base/commit/uncommitted), we do not pass a prompt.
40
+ if (baseRef) args.push('--base', baseRef);
41
+
42
+ if (jsonMode) {
43
+ args.push('--json');
44
+ }
45
+
46
+ const p = String(prompt ?? '').trim();
47
+ if (!baseRef && p) args.push(p);
48
+ if (!baseRef && !p) args.push('--uncommitted');
49
+ return args;
50
+ }
51
+
52
+ export async function runCodexReview({ repoDir, baseRef, env, jsonMode, streamLabel, teeFile, teeLabel, prompt }) {
53
+ const merged = { ...(env ?? {}) };
54
+ const codexHome =
55
+ (merged.HAPPY_STACKS_CODEX_HOME_DIR ?? merged.HAPPY_LOCAL_CODEX_HOME_DIR ?? merged.CODEX_HOME ?? '').toString().trim();
56
+ if (codexHome) merged.CODEX_HOME = codexHome;
57
+
58
+ const args = buildCodexReviewArgs({ baseRef, jsonMode, prompt });
59
+ const res = await runCaptureResult('codex', args, { cwd: repoDir, env: merged, streamLabel, teeFile, teeLabel });
60
+ return { ...res, stdout: res.out, stderr: res.err };
61
+ }
@@ -0,0 +1,35 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildCodexReviewArgs, extractCodexReviewFromJsonl } from './codex.mjs';
5
+
6
+ test('buildCodexReviewArgs uses --base and avoids --cd', () => {
7
+ const args = buildCodexReviewArgs({ baseRef: 'upstream/main', jsonMode: false });
8
+ assert.equal(args.includes('--cd'), false);
9
+ assert.deepEqual(args, ['exec', 'review', '--dangerously-bypass-approvals-and-sandbox', '--base', 'upstream/main']);
10
+ });
11
+
12
+ test('buildCodexReviewArgs uses --experimental-json when jsonMode is true', () => {
13
+ const args = buildCodexReviewArgs({ baseRef: 'upstream/main', jsonMode: true });
14
+ assert.deepEqual(args, ['exec', 'review', '--dangerously-bypass-approvals-and-sandbox', '--base', 'upstream/main', '--json']);
15
+ });
16
+
17
+ test('buildCodexReviewArgs appends a prompt when provided', () => {
18
+ const args = buildCodexReviewArgs({ baseRef: null, jsonMode: false, prompt: 'be thorough' });
19
+ assert.deepEqual(args, ['exec', 'review', '--dangerously-bypass-approvals-and-sandbox', 'be thorough']);
20
+ });
21
+
22
+ test('extractCodexReviewFromJsonl finds review_output in multiple event shapes', () => {
23
+ const out1 = extractCodexReviewFromJsonl(
24
+ JSON.stringify({ msg: { ExitedReviewMode: { review_output: { a: 1 } } } }) + '\n'
25
+ );
26
+ assert.deepEqual(out1, { a: 1 });
27
+
28
+ const out2 = extractCodexReviewFromJsonl(JSON.stringify({ type: 'ExitedReviewMode', review_output: { b: 2 } }) + '\n');
29
+ assert.deepEqual(out2, { b: 2 });
30
+
31
+ const out3 = extractCodexReviewFromJsonl(
32
+ JSON.stringify({ event: { type: 'ExitedReviewMode', reviewOutput: { c: 3 } } }) + '\n'
33
+ );
34
+ assert.deepEqual(out3, { c: 3 });
35
+ });