@lyupro/codex-bridge 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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything about invoking the `codex` CLI: which flags a run gets, whether the CLI is
|
|
3
|
+
* there at all, and how its output reaches raw.log.
|
|
4
|
+
*
|
|
5
|
+
* The flag sets below are the ones the three agent files carried before, verbatim.
|
|
6
|
+
* Sandboxes especially must not drift: codex-scout is read-only through
|
|
7
|
+
* --ignore-user-config, codex-build needs workspace-write and must NOT get
|
|
8
|
+
* --ignore-user-config, codex-review needs an explicit --sandbox read-only before the
|
|
9
|
+
* `review` subcommand.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { writeFailure } from '../write-meta.mjs';
|
|
15
|
+
import { MAX_LOG } from './git-state.mjs';
|
|
16
|
+
import { CLEAN_ENV, RUN_ENV } from './run-env.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
|
|
20
|
+
* expands variables even inside quotes. The launcher asks first, so the refusal costs
|
|
21
|
+
* nothing; the worker asks again on its own copy, because a process whose stderr goes
|
|
22
|
+
* nowhere must not be the one to discover this.
|
|
23
|
+
*/
|
|
24
|
+
export const unsafeForCmd = (args) =>
|
|
25
|
+
['codex', ...args].find((a) => a.includes('%') || a.includes('"'));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stop the process that owns the Codex invocation. On Windows that process is cmd.exe, so
|
|
29
|
+
* child.kill() would reap only the shell and leave Codex spending quota as its grandchild —
|
|
30
|
+
* the exact orphan left by the killed caller on 2026-07-31. taskkill's tree flag is required;
|
|
31
|
+
* POSIX has no shell wrapper here, so killing the child itself is the honest boundary.
|
|
32
|
+
*/
|
|
33
|
+
export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
34
|
+
if (!onWindows) {
|
|
35
|
+
child.kill('SIGKILL');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const killed = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
if (killed.error || killed.status !== 0) child.kill();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Windows npm ships `codex` as codex.cmd, and Node refuses to spawn .cmd without a
|
|
47
|
+
* shell. One command line through cmd.exe keeps a single code path; every argument is
|
|
48
|
+
* quoted here, and unsafeForCmd() has already refused the ones that cannot be.
|
|
49
|
+
*
|
|
50
|
+
* The log is streamed, not collected: spawnSync kept every byte in memory and wrote the file
|
|
51
|
+
* only on return, so a worker killed at minute twelve left raw.log missing entirely and the
|
|
52
|
+
* run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said is
|
|
53
|
+
* on disk, and the task text is pushed into stdin by hand since there is no `input` option
|
|
54
|
+
* outside spawnSync. MAX_LOG stays the ceiling; past it the log is cut with a note rather
|
|
55
|
+
* than the run being killed — a huge log is a diagnosis, not a reason to lose the work.
|
|
56
|
+
*/
|
|
57
|
+
export function runCodex(args, taskText, logPath, budgetMinutes) {
|
|
58
|
+
const onWindows = process.platform === 'win32';
|
|
59
|
+
const deadlineMs =
|
|
60
|
+
typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
|
|
61
|
+
? budgetMinutes * 60 * 1000
|
|
62
|
+
: null;
|
|
63
|
+
const log = fs.createWriteStream(logPath, { flags: 'a' });
|
|
64
|
+
let written = 0;
|
|
65
|
+
let cut = false;
|
|
66
|
+
const append = (chunk) => {
|
|
67
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
68
|
+
if (written >= MAX_LOG) {
|
|
69
|
+
if (!cut) {
|
|
70
|
+
cut = true;
|
|
71
|
+
log.write(`\nrun-codex: log truncated at ${MAX_LOG} bytes; further output is not saved\n`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const room = MAX_LOG - written;
|
|
76
|
+
const slice = buf.length > room ? buf.subarray(0, room) : buf;
|
|
77
|
+
written += slice.length;
|
|
78
|
+
log.write(slice);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let child;
|
|
82
|
+
if (onWindows) {
|
|
83
|
+
// Thrown rather than died: in the worker a bare exit would leave the run open, while a
|
|
84
|
+
// throw goes through the crash handler and closes it with meta.json like any failure.
|
|
85
|
+
const bad = unsafeForCmd(args);
|
|
86
|
+
if (bad) throw new Error(`argument unsafe for cmd.exe (contains % or "): ${bad}`);
|
|
87
|
+
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
88
|
+
child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${cmdline}"`], {
|
|
89
|
+
windowsVerbatimArguments: true,
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
child = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
let failed = false;
|
|
99
|
+
let deadlineTimer;
|
|
100
|
+
const startedAt = Date.now();
|
|
101
|
+
child.stdout.on('data', append);
|
|
102
|
+
child.stderr.on('data', append);
|
|
103
|
+
child.on('error', (err) => {
|
|
104
|
+
failed = true;
|
|
105
|
+
append(`\nrun-codex: ${err.message}\n`);
|
|
106
|
+
});
|
|
107
|
+
// Codex that dies before reading the order leaves an EPIPE here; the exit code and the
|
|
108
|
+
// log already say what happened, and an unhandled stream error would replace that with
|
|
109
|
+
// a crash of the runner.
|
|
110
|
+
child.stdin.on('error', () => {});
|
|
111
|
+
child.stdin.end(Buffer.from(taskText, 'utf8'));
|
|
112
|
+
if (deadlineMs !== null) {
|
|
113
|
+
deadlineTimer = setTimeout(() => {
|
|
114
|
+
const elapsed = Date.now() - startedAt;
|
|
115
|
+
log.write(
|
|
116
|
+
`\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
|
|
117
|
+
);
|
|
118
|
+
stopCodex(child, onWindows);
|
|
119
|
+
}, deadlineMs);
|
|
120
|
+
}
|
|
121
|
+
child.on('close', (code) => {
|
|
122
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
123
|
+
log.end(() => resolve(failed || code === null ? 1 : code));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
130
|
+
* Runs after the folder exists so this failure is recorded like any other.
|
|
131
|
+
*/
|
|
132
|
+
export function requireCodex(runDir, agent) {
|
|
133
|
+
const probe = spawnSync(
|
|
134
|
+
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
135
|
+
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
136
|
+
{ encoding: 'utf8' },
|
|
137
|
+
);
|
|
138
|
+
if (probe.error || probe.status !== 0) {
|
|
139
|
+
const why = (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
140
|
+
const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
|
|
141
|
+
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
142
|
+
]);
|
|
143
|
+
console.log(reply);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Which model runs a mode, and how deep it reasons.
|
|
150
|
+
*
|
|
151
|
+
* Three sources, in this order: what the dispatcher asked for this one task, the mode's
|
|
152
|
+
* configured profile, and finally a depth to fall back on. The order is the whole point —
|
|
153
|
+
* pinning a model without its depth leaves every run at the fallback, which is how a mode
|
|
154
|
+
* configured for maximum reasoning would quietly keep working at the shallowest setting.
|
|
155
|
+
*/
|
|
156
|
+
const FALLBACK_EFFORT = 'medium';
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A run is one worker, not a team it recruits for itself.
|
|
160
|
+
*
|
|
161
|
+
* Codex can spawn subagents when a prompt asks it to, and their edits land in the same
|
|
162
|
+
* worktree — so they arrive in the snapshot as the run's own work, outside the scope check
|
|
163
|
+
* that grades it, on quota nobody budgeted. The prompts say not to; this is why they cannot.
|
|
164
|
+
*/
|
|
165
|
+
const NO_SUBAGENTS = ['-c', 'agents.enabled=false'];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which configured mode an agent is. One table, because the launcher needs the same answer to
|
|
169
|
+
* pick a run's time budget: a second copy of this mapping is a second place to forget a mode.
|
|
170
|
+
*/
|
|
171
|
+
export const runMode = (agent) =>
|
|
172
|
+
({
|
|
173
|
+
'codex-scout': 'scout',
|
|
174
|
+
'codex-build': 'build',
|
|
175
|
+
'codex-review': 'review',
|
|
176
|
+
})[agent];
|
|
177
|
+
|
|
178
|
+
function runProfile(opts) {
|
|
179
|
+
const mode = runMode(opts.agent);
|
|
180
|
+
// opts.models is how tests state a configuration; a real run reads the one loaded for it.
|
|
181
|
+
const configured = (opts.models || RUN_ENV?.models || {})[mode] || {};
|
|
182
|
+
return {
|
|
183
|
+
model: configured.model || '',
|
|
184
|
+
effort: opts.effort || configured.effort || FALLBACK_EFFORT,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function codexArgs(opts, runDir, isGitRepo) {
|
|
189
|
+
const schema = path.join(runDir, 'schema.json');
|
|
190
|
+
const profile = runProfile(opts);
|
|
191
|
+
const effort = `model_reasoning_effort=${profile.effort}`;
|
|
192
|
+
const modelArgs = profile.model ? ['-m', profile.model] : [];
|
|
193
|
+
if (opts.agent === 'codex-scout') {
|
|
194
|
+
return [
|
|
195
|
+
'exec',
|
|
196
|
+
...CLEAN_ENV,
|
|
197
|
+
'--ignore-user-config',
|
|
198
|
+
'-c',
|
|
199
|
+
effort,
|
|
200
|
+
...NO_SUBAGENTS,
|
|
201
|
+
...modelArgs,
|
|
202
|
+
'--sandbox',
|
|
203
|
+
'read-only',
|
|
204
|
+
'--skip-git-repo-check',
|
|
205
|
+
'-C',
|
|
206
|
+
opts.repo,
|
|
207
|
+
'--output-schema',
|
|
208
|
+
schema,
|
|
209
|
+
'-o',
|
|
210
|
+
path.join(runDir, 'result.json'),
|
|
211
|
+
'-',
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
if (opts.agent === 'codex-build') {
|
|
215
|
+
// --ignore-user-config is deliberately absent: with it Codex forces read-only and
|
|
216
|
+
// every edit is rejected ("writing is blocked by read-only sandbox").
|
|
217
|
+
return [
|
|
218
|
+
'exec',
|
|
219
|
+
...CLEAN_ENV,
|
|
220
|
+
'-c',
|
|
221
|
+
effort,
|
|
222
|
+
...NO_SUBAGENTS,
|
|
223
|
+
...modelArgs,
|
|
224
|
+
'--sandbox',
|
|
225
|
+
'workspace-write',
|
|
226
|
+
'-C',
|
|
227
|
+
opts.repo,
|
|
228
|
+
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
229
|
+
'--output-schema',
|
|
230
|
+
schema,
|
|
231
|
+
'-o',
|
|
232
|
+
path.join(runDir, 'result.json'),
|
|
233
|
+
'-',
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
// codex-review deliberately does NOT use the `review` subcommand. Two of its traits
|
|
237
|
+
// break the contract this design rests on: it rejects a scope flag together with a
|
|
238
|
+
// prompt ("the argument '--uncommitted' cannot be used with '[PROMPT]'"), so task.md
|
|
239
|
+
// was silently discarded and the review rules never arrived; and it ignores
|
|
240
|
+
// --output-schema, writing prose into -o, so the old JSON parsing of review.json could
|
|
241
|
+
// only ever fail — which is what pushed a dispatcher into reviewing the diff itself.
|
|
242
|
+
//
|
|
243
|
+
// Plain `codex exec` honours the schema (proven by codex-scout), so the review comes
|
|
244
|
+
// back as machine-checkable JSON. Scope lives in the prompt as a file list plus the
|
|
245
|
+
// exact diff command computed by reviewScope(). Read-only through
|
|
246
|
+
// --ignore-user-config, same as scout: review never needs write access.
|
|
247
|
+
return [
|
|
248
|
+
'exec',
|
|
249
|
+
...CLEAN_ENV,
|
|
250
|
+
'--ignore-user-config',
|
|
251
|
+
'-c',
|
|
252
|
+
effort,
|
|
253
|
+
...NO_SUBAGENTS,
|
|
254
|
+
...modelArgs,
|
|
255
|
+
'--sandbox',
|
|
256
|
+
'read-only',
|
|
257
|
+
'-C',
|
|
258
|
+
opts.repo,
|
|
259
|
+
'--output-schema',
|
|
260
|
+
schema,
|
|
261
|
+
'-o',
|
|
262
|
+
path.join(runDir, 'review.json'),
|
|
263
|
+
'-',
|
|
264
|
+
];
|
|
265
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the repository a run works in: what the tree looks like, what changed, what is
|
|
3
|
+
* under review.
|
|
4
|
+
*
|
|
5
|
+
* Everything here answers a question about the repository and nothing here decides anything
|
|
6
|
+
* about the run. The launcher takes the "before" answers, the worker takes the "after" ones,
|
|
7
|
+
* and write-meta.mjs compares them — which only works while both halves ask git in exactly
|
|
8
|
+
* the same words, so they ask it here.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { runsRoot } from './runs-root.mjs';
|
|
14
|
+
|
|
15
|
+
export const MAX_LOG = 256 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export const git = (repo, args) =>
|
|
18
|
+
spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: MAX_LOG });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Prefix of the run folders as seen from inside the repository, or null when they live
|
|
22
|
+
* outside it. ~/.claude hosts both the dispatchers and every run folder, so a run against
|
|
23
|
+
* ~/.claude sees the runner's own artifacts as work: one failed with “out-of-scope changes”
|
|
24
|
+
* listing its own git-after.txt. The snapshot has to skip them — they are the measuring
|
|
25
|
+
* instrument, not the measurement.
|
|
26
|
+
*/
|
|
27
|
+
export function runsPrefixInside(repo) {
|
|
28
|
+
const rel = path.relative(repo, runsRoot());
|
|
29
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
30
|
+
return `${rel.split(path.sep).join('/')}/`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* State of the worktree in terms of actual content, not porcelain letters: line counts
|
|
35
|
+
* per tracked file plus sizes of untracked ones. A porcelain code stays ` M` when Codex
|
|
36
|
+
* edits an already-modified file, so comparing codes would report "0 files changed" for
|
|
37
|
+
* a run that did real work.
|
|
38
|
+
*/
|
|
39
|
+
export function worktreeSnapshot(repo) {
|
|
40
|
+
const skip = runsPrefixInside(repo);
|
|
41
|
+
// The path is the third tab-separated field in both git outputs used here.
|
|
42
|
+
const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
|
|
43
|
+
const tracked = (git(repo, ['diff', 'HEAD', '--numstat']).stdout || '')
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => line.trim() && !mine(line))
|
|
46
|
+
.join('\n')
|
|
47
|
+
.trim();
|
|
48
|
+
const untracked = (git(repo, ['ls-files', '-o', '--exclude-standard']).stdout || '')
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.filter((file) => !(skip && `${file}/`.startsWith(skip)))
|
|
52
|
+
.map((file) => {
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
try {
|
|
55
|
+
bytes = fs.statSync(path.join(repo, file)).size;
|
|
56
|
+
} catch {
|
|
57
|
+
// Vanished between listing and stat: size 0 still marks it as present.
|
|
58
|
+
}
|
|
59
|
+
return `U\t${bytes}\t${file}`;
|
|
60
|
+
});
|
|
61
|
+
return [tracked, untracked.join('\n')].filter(Boolean).join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The commit a run starts and ends on. A delegated run is forbidden to commit — the
|
|
66
|
+
* orchestrator has to see the edits uncommitted in order to accept them — and comparing
|
|
67
|
+
* HEAD either side is the only way to know whether that held. Empty for a repo without
|
|
68
|
+
* commits, which write-meta.mjs reads as "nothing to compare", not as a violation.
|
|
69
|
+
*/
|
|
70
|
+
export const headSha = (repo) => (git(repo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The branch a run starts and ends on. Empty means detached HEAD, which is data to compare,
|
|
74
|
+
* not an error: a run may begin detached and stay that way, or leave the repository detached.
|
|
75
|
+
*/
|
|
76
|
+
export const branchName = (repo) =>
|
|
77
|
+
(git(repo, ['symbolic-ref', '--short', '-q', 'HEAD']).stdout || '').trim();
|
|
78
|
+
|
|
79
|
+
const FAKE_DONE_RE =
|
|
80
|
+
/TODO|FIXME|test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Traces of fake completion. `git diff` carries nothing for untracked files, so a brand
|
|
84
|
+
* new file full of TODOs would otherwise pass as "Flags: none" — the one case the check
|
|
85
|
+
* exists for.
|
|
86
|
+
*/
|
|
87
|
+
export function findFakeDone(repo) {
|
|
88
|
+
const skip = runsPrefixInside(repo);
|
|
89
|
+
const hits = (git(repo, ['diff', '-U0']).stdout || '')
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.filter((l) => l.startsWith('+') && FAKE_DONE_RE.test(l));
|
|
92
|
+
for (const file of (git(repo, ['ls-files', '-o', '--exclude-standard']).stdout || '')
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
// Same reason as in worktreeSnapshot: inside ~/.claude the run folder is part of the
|
|
96
|
+
// worktree, and task.md spells out the very words this scans for ("do not leave TODOs,
|
|
97
|
+
// test.skip"). A run flagged itself for quoting its own instructions.
|
|
98
|
+
.filter((file) => !(skip && `${file}/`.startsWith(skip)))) {
|
|
99
|
+
const full = path.join(repo, file);
|
|
100
|
+
try {
|
|
101
|
+
if (fs.statSync(full).size > 1024 * 1024) continue;
|
|
102
|
+
fs.readFileSync(full, 'utf8')
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.forEach((l, i) => {
|
|
105
|
+
if (FAKE_DONE_RE.test(l)) hits.push(`${file}:${i + 1}: ${l.trim()}`);
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
// Binary or unreadable: nothing to flag.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return hits.length ? `${hits.slice(0, 20).join('\n')}\n` : '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What exactly is under review, resolved from git rather than from wording. */
|
|
115
|
+
export function reviewScope(repo, mode) {
|
|
116
|
+
if (mode.startsWith('base:')) {
|
|
117
|
+
const base = mode.slice(5);
|
|
118
|
+
return {
|
|
119
|
+
label: `branch changes against base ${base}`,
|
|
120
|
+
diffCommand: `git diff ${base}...HEAD`,
|
|
121
|
+
files: (git(repo, ['diff', '--name-only', `${base}...HEAD`]).stdout || '')
|
|
122
|
+
.split(/\r?\n/)
|
|
123
|
+
.filter(Boolean),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (mode.startsWith('commit:')) {
|
|
127
|
+
const sha = mode.slice(7);
|
|
128
|
+
return {
|
|
129
|
+
label: `commit ${sha}`,
|
|
130
|
+
diffCommand: `git show ${sha}`,
|
|
131
|
+
files: (git(repo, ['show', '--name-only', '--format=', sha]).stdout || '')
|
|
132
|
+
.split(/\r?\n/)
|
|
133
|
+
.filter(Boolean),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
label: 'uncommitted changes (staged, unstaged, untracked)',
|
|
138
|
+
diffCommand: 'git status --porcelain && git diff HEAD',
|
|
139
|
+
files: (git(repo, ['status', '--porcelain']).stdout || '')
|
|
140
|
+
.split(/\r?\n/)
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((l) => l.slice(3).trim()),
|
|
143
|
+
};
|
|
144
|
+
}
|