@expo/code-review-cli 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 (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -8,20 +8,20 @@ export function parseUnifiedDiff(diffText) {
8
8
  return [];
9
9
  }
10
10
  const entries = [];
11
- const lines = diffText.split('\n');
11
+ const lines = diffText.split("\n");
12
12
  let current = null;
13
13
  const flush = () => {
14
14
  if (!current || current.length === 0) {
15
15
  return;
16
16
  }
17
- const entry = patchToEntry(current.join('\n'));
17
+ const entry = patchToEntry(current.join("\n"));
18
18
  if (entry) {
19
19
  entries.push(entry);
20
20
  }
21
21
  current = null;
22
22
  };
23
23
  for (const line of lines) {
24
- if (line.startsWith('diff --git ')) {
24
+ if (line.startsWith("diff --git ")) {
25
25
  flush();
26
26
  current = [line];
27
27
  }
@@ -33,49 +33,49 @@ export function parseUnifiedDiff(diffText) {
33
33
  return entries;
34
34
  }
35
35
  function patchToEntry(patch) {
36
- const lines = patch.split('\n');
37
- const header = lines[0] ?? '';
36
+ const lines = patch.split("\n");
37
+ const header = lines[0] ?? "";
38
38
  let newPath = null;
39
39
  let oldPath = null;
40
40
  let status;
41
41
  let binary = false;
42
42
  for (const line of lines) {
43
- if (line.startsWith('+++ ')) {
43
+ if (line.startsWith("+++ ")) {
44
44
  newPath = stripDiffPathPrefix(line.slice(4));
45
45
  }
46
- else if (line.startsWith('--- ')) {
46
+ else if (line.startsWith("--- ")) {
47
47
  oldPath = stripDiffPathPrefix(line.slice(4));
48
48
  }
49
- else if (line.startsWith('new file mode')) {
50
- status = 'A';
49
+ else if (line.startsWith("new file mode")) {
50
+ status = "A";
51
51
  }
52
- else if (line.startsWith('deleted file mode')) {
53
- status = 'D';
52
+ else if (line.startsWith("deleted file mode")) {
53
+ status = "D";
54
54
  }
55
- else if (line.startsWith('rename ')) {
56
- status = 'R';
55
+ else if (line.startsWith("rename ")) {
56
+ status = "R";
57
57
  }
58
- else if (line.startsWith('Binary files ') || line === 'GIT binary patch') {
58
+ else if (line.startsWith("Binary files ") || line === "GIT binary patch") {
59
59
  // git emits one of these instead of +++/---/@@ hunks for a binary file.
60
60
  // There is no textual diff to review; flag it so noise filtering drops it.
61
61
  binary = true;
62
62
  }
63
63
  }
64
- let path = newPath && newPath !== '/dev/null' ? newPath : oldPath;
65
- if (!path || path === '/dev/null') {
64
+ let path = newPath && newPath !== "/dev/null" ? newPath : oldPath;
65
+ if (!path || path === "/dev/null") {
66
66
  path = pathFromHeader(header);
67
67
  }
68
68
  if (!path) {
69
69
  return null;
70
70
  }
71
- return { path, patch, status: status ?? 'M', binary };
71
+ return { path, patch, status: status ?? "M", binary };
72
72
  }
73
73
  function stripDiffPathPrefix(raw) {
74
74
  const value = raw.trim();
75
- if (value === '/dev/null') {
75
+ if (value === "/dev/null") {
76
76
  return value;
77
77
  }
78
- return value.replace(/^[ab]\//, '');
78
+ return value.replace(/^[ab]\//, "");
79
79
  }
80
80
  function pathFromHeader(header) {
81
81
  const match = header.match(/^diff --git a\/(.+?) b\/(.+)$/);
@@ -1,5 +1,5 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
4
  /**
5
5
  * Run a command capturing stdout/stderr. Never interpolates a shell, so
@@ -11,26 +11,26 @@ export async function run(command, args, options = {}) {
11
11
  const { stdout, stderr } = await execFileAsync(command, args, {
12
12
  cwd: options.cwd,
13
13
  maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
14
- encoding: 'utf8',
14
+ encoding: "utf8",
15
15
  });
16
16
  return { stdout, stderr, code: 0 };
17
17
  }
18
18
  catch (error) {
19
19
  const err = error;
20
20
  if (!check) {
21
- return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', code: err.code ?? 1 };
21
+ return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.code ?? 1 };
22
22
  }
23
- throw new Error(`Command failed: ${command} ${args.join(' ')}\n${err.stderr ?? err.message ?? ''}`.trim());
23
+ throw new Error(`Command failed: ${command} ${args.join(" ")}\n${err.stderr ?? err.message ?? ""}`.trim());
24
24
  }
25
25
  }
26
26
  export async function git(args, cwd) {
27
- const { stdout } = await run('git', args, { cwd });
27
+ const { stdout } = await run("git", args, { cwd });
28
28
  return stdout;
29
29
  }
30
30
  /** Resolve owner/repo from the current checkout via gh (for PR-targeting commands). */
31
31
  export async function resolveRepo(cwd) {
32
32
  try {
33
- const { stdout } = await run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], {
33
+ const { stdout } = await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
34
34
  cwd,
35
35
  });
36
36
  const repo = stdout.trim();
@@ -41,12 +41,12 @@ export async function resolveRepo(cwd) {
41
41
  catch {
42
42
  // fall through to a clear error
43
43
  }
44
- throw new Error('Could not determine the repository; pass --repo owner/repo.');
44
+ throw new Error("Could not determine the repository; pass --repo owner/repo.");
45
45
  }
46
46
  /** Absolute path of the git working-tree root, or null if not in a repo. */
47
47
  export async function repoRoot(cwd) {
48
48
  try {
49
- return (await git(['rev-parse', '--show-toplevel'], cwd)).trim() || null;
49
+ return (await git(["rev-parse", "--show-toplevel"], cwd)).trim() || null;
50
50
  }
51
51
  catch {
52
52
  return null;
@@ -54,7 +54,7 @@ export async function repoRoot(cwd) {
54
54
  }
55
55
  /** Whether an executable is resolvable on PATH. */
56
56
  export async function onPath(command) {
57
- const { code } = await run(process.platform === 'win32' ? 'where' : 'which', [command], {
57
+ const { code } = await run(process.platform === "win32" ? "where" : "which", [command], {
58
58
  check: false,
59
59
  });
60
60
  return code === 0;
package/build/core/log.js CHANGED
@@ -1,10 +1,10 @@
1
- import { appendFile, mkdir } from 'node:fs/promises';
2
- import path from 'node:path';
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
3
  /**
4
4
  * Append one JSON line per review run. Keeps inputs, findings, decision, and
5
5
  * cost together so runs are auditable and cost/latency can be measured later.
6
6
  */
7
7
  export async function writeRunLog(logPath, record) {
8
8
  await mkdir(path.dirname(logPath), { recursive: true });
9
- await appendFile(logPath, `${JSON.stringify(record)}\n`, 'utf8');
9
+ await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8");
10
10
  }
@@ -1,16 +1,16 @@
1
- import { mkdir, open, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- const LOCKFILES = new Set(['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml', 'bun.lock']);
4
- const NOISE_EXTENSIONS = ['.min.js', '.min.css', '.bundle.js', '.map'];
1
+ import { mkdir, open, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const LOCKFILES = new Set(["yarn.lock", "package-lock.json", "pnpm-lock.yaml", "bun.lock"]);
4
+ const NOISE_EXTENSIONS = [".min.js", ".min.css", ".bundle.js", ".map"];
5
5
  const DEFAULT_MARKERS = [
6
- '@generated',
7
- '@codegen',
8
- 'code generated by',
9
- 'this file was generated',
10
- 'this file is generated',
11
- 'auto-generated',
12
- 'autogenerated',
13
- 'do not edit',
6
+ "@generated",
7
+ "@codegen",
8
+ "code generated by",
9
+ "this file was generated",
10
+ "this file is generated",
11
+ "auto-generated",
12
+ "autogenerated",
13
+ "do not edit",
14
14
  ];
15
15
  /**
16
16
  * Strip files that add no signal (lockfiles, generated bundles/maps, snapshots,
@@ -34,37 +34,37 @@ export async function filterNoise(entries, options = {}, cwd = process.cwd()) {
34
34
  }
35
35
  async function noiseReason(entry, options, cwd) {
36
36
  if (entry.binary) {
37
- return 'binary file (no textual diff)';
37
+ return "binary file (no textual diff)";
38
38
  }
39
39
  const base = path.basename(entry.path);
40
40
  if (LOCKFILES.has(base)) {
41
- return 'lockfile';
41
+ return "lockfile";
42
42
  }
43
43
  for (const ext of NOISE_EXTENSIONS) {
44
44
  if (entry.path.endsWith(ext)) {
45
45
  return `generated asset (${ext})`;
46
46
  }
47
47
  }
48
- if (entry.path.includes('__snapshots__/') && entry.path.endsWith('.snap')) {
49
- return 'jest snapshot';
48
+ if (entry.path.includes("__snapshots__/") && entry.path.endsWith(".snap")) {
49
+ return "jest snapshot";
50
50
  }
51
51
  for (const pattern of options.additionalIgnores ?? []) {
52
52
  if (matchesIgnore(entry.path, pattern)) {
53
53
  return `repo-ignored (${pattern})`;
54
54
  }
55
55
  }
56
- const markers = [...DEFAULT_MARKERS, ...(options.additionalMarkers ?? [])].map(marker => marker.toLowerCase());
56
+ const markers = [...DEFAULT_MARKERS, ...(options.additionalMarkers ?? [])].map((marker) => marker.toLowerCase());
57
57
  // A generation marker only counts as a HEADER — real generated files carry it
58
58
  // in their first few lines (e.g. `// @generated`, `# ... DO NOT EDIT.`). Only
59
59
  // checking the top avoids false positives on hand-written files that merely
60
60
  // mention these strings (e.g. this module lists them as DEFAULT_MARKERS, and a
61
61
  // config comment references "@generated"), which were being wrongly filtered.
62
62
  if (hasMarkerHeaderInPatch(entry.patch, markers)) {
63
- return 'generated file header';
63
+ return "generated file header";
64
64
  }
65
65
  const head = await readFileHead(path.resolve(cwd, entry.path));
66
66
  if (head && hasMarkerInHead(head, markers)) {
67
- return 'generated file header';
67
+ return "generated file header";
68
68
  }
69
69
  return null;
70
70
  }
@@ -76,20 +76,20 @@ export function matchesIgnore(filePath, pattern) {
76
76
  // inline. We deliberately use NO placeholder character: an earlier version
77
77
  // stashed a literal NUL byte as a sentinel, which made git classify this
78
78
  // source file as binary (so its diff was invisible to reviewers).
79
- let out = '';
79
+ let out = "";
80
80
  for (let i = 0; i < pattern.length; i++) {
81
81
  const ch = pattern[i];
82
- if (ch === '*') {
83
- if (pattern[i + 1] === '*') {
84
- out += '.*';
82
+ if (ch === "*") {
83
+ if (pattern[i + 1] === "*") {
84
+ out += ".*";
85
85
  i++;
86
86
  }
87
87
  else {
88
- out += '[^/]*';
88
+ out += "[^/]*";
89
89
  }
90
90
  }
91
91
  else if (/[.+^${}()|[\]\\?]/.test(ch)) {
92
- out += '\\' + ch;
92
+ out += "\\" + ch;
93
93
  }
94
94
  else {
95
95
  out += ch;
@@ -100,25 +100,25 @@ export function matchesIgnore(filePath, pattern) {
100
100
  /** A generation marker in the first few ADDED lines (i.e. the top of a new file). */
101
101
  function hasMarkerHeaderInPatch(patch, markers) {
102
102
  const topAddedLines = patch
103
- .split('\n')
104
- .filter(line => line.startsWith('+') && !line.startsWith('+++'))
103
+ .split("\n")
104
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
105
105
  .slice(0, HEADER_LINES)
106
- .map(line => line.toLowerCase());
107
- return topAddedLines.some(line => markers.some(marker => line.includes(marker)));
106
+ .map((line) => line.toLowerCase());
107
+ return topAddedLines.some((line) => markers.some((marker) => line.includes(marker)));
108
108
  }
109
109
  /** A generation marker in the first few lines of the on-disk file. */
110
110
  function hasMarkerInHead(head, markers) {
111
- const topLines = head.split('\n').slice(0, HEADER_LINES).join('\n').toLowerCase();
112
- return markers.some(marker => topLines.includes(marker));
111
+ const topLines = head.split("\n").slice(0, HEADER_LINES).join("\n").toLowerCase();
112
+ return markers.some((marker) => topLines.includes(marker));
113
113
  }
114
114
  /** Read the first `bytes` of a file (default 4 KB) without loading the whole thing. */
115
115
  async function readFileHead(absPath, bytes = 4096) {
116
116
  try {
117
- const handle = await open(absPath, 'r');
117
+ const handle = await open(absPath, "r");
118
118
  try {
119
119
  const buffer = Buffer.alloc(bytes);
120
120
  const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
121
- return buffer.subarray(0, bytesRead).toString('utf8');
121
+ return buffer.subarray(0, bytesRead).toString("utf8");
122
122
  }
123
123
  finally {
124
124
  await handle.close();
@@ -131,11 +131,11 @@ async function readFileHead(absPath, bytes = 4096) {
131
131
  /** Count added + removed lines in a unified-diff patch (ignores +++/--- headers). */
132
132
  export function countChangedLines(patch) {
133
133
  let count = 0;
134
- for (const line of patch.split('\n')) {
135
- if (line.startsWith('+') && !line.startsWith('+++')) {
134
+ for (const line of patch.split("\n")) {
135
+ if (line.startsWith("+") && !line.startsWith("+++")) {
136
136
  count++;
137
137
  }
138
- else if (line.startsWith('-') && !line.startsWith('---')) {
138
+ else if (line.startsWith("-") && !line.startsWith("---")) {
139
139
  count++;
140
140
  }
141
141
  }
@@ -147,14 +147,14 @@ export function countChangedLines(patch) {
147
147
  * paths instead of having the full diff inlined into every prompt.
148
148
  */
149
149
  export async function writePatchWorkspace(kept, metadata, rootDir) {
150
- const patchDir = path.join(rootDir, 'patches');
150
+ const patchDir = path.join(rootDir, "patches");
151
151
  await mkdir(patchDir, { recursive: true });
152
152
  const files = [];
153
153
  for (let index = 0; index < kept.length; index++) {
154
154
  const entry = kept[index];
155
- const safeName = `${String(index).padStart(4, '0')}-${entry.path.replace(/[^a-zA-Z0-9._-]/g, '__')}.patch`;
155
+ const safeName = `${String(index).padStart(4, "0")}-${entry.path.replace(/[^a-zA-Z0-9._-]/g, "__")}.patch`;
156
156
  const patchPath = path.join(patchDir, safeName);
157
- await writeFile(patchPath, entry.patch, 'utf8');
157
+ await writeFile(patchPath, entry.patch, "utf8");
158
158
  files.push({
159
159
  path: entry.path,
160
160
  patchPath,
@@ -163,24 +163,24 @@ export async function writePatchWorkspace(kept, metadata, rootDir) {
163
163
  changedLines: countChangedLines(entry.patch),
164
164
  });
165
165
  }
166
- const manifestPath = path.join(rootDir, 'context.md');
167
- await writeFile(manifestPath, renderManifest(files, metadata), 'utf8');
166
+ const manifestPath = path.join(rootDir, "context.md");
167
+ await writeFile(manifestPath, renderManifest(files, metadata), "utf8");
168
168
  return { root: rootDir, manifestPath, files };
169
169
  }
170
170
  function renderManifest(files, metadata) {
171
171
  const lines = [
172
- '# Changed files',
173
- '',
174
- `Base: ${metadata.baseRef || '(unknown)'} Head: ${metadata.headRef || '(unknown)'}`,
175
- '',
176
- 'Each entry lists the changed file (path relative to repo root) and a patch',
177
- 'file containing its unified diff. Read the patch to see what changed, then',
178
- 'read the surrounding source in the repo to confirm findings in context.',
179
- '',
172
+ "# Changed files",
173
+ "",
174
+ `Base: ${metadata.baseRef || "(unknown)"} Head: ${metadata.headRef || "(unknown)"}`,
175
+ "",
176
+ "Each entry lists the changed file (path relative to repo root) and a patch",
177
+ "file containing its unified diff. Read the patch to see what changed, then",
178
+ "read the surrounding source in the repo to confirm findings in context.",
179
+ "",
180
180
  ];
181
181
  for (const file of files) {
182
- lines.push(`- \`${file.path}\` (${file.status ?? 'M'}) — patch: \`${file.patchPath}\``);
182
+ lines.push(`- \`${file.path}\` (${file.status ?? "M"}) — patch: \`${file.patchPath}\``);
183
183
  }
184
- lines.push('');
185
- return lines.join('\n');
184
+ lines.push("");
185
+ return lines.join("\n");
186
186
  }