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