@expo/code-review-cli 0.2.3 → 0.4.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/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- package/build/core/coordinator.js +5 -5
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +25 -25
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/config.jsonc +10 -0
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +58 -23
package/build/core/auth.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import path from
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
4
|
/** Env var each provider's SDK reads for an API key (x-api-key style). */
|
|
5
5
|
const PROVIDER_KEY_ENV = {
|
|
6
|
-
anthropic:
|
|
7
|
-
openai:
|
|
8
|
-
google:
|
|
9
|
-
openrouter:
|
|
6
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
7
|
+
openai: "OPENAI_API_KEY",
|
|
8
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
9
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
12
|
* Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names
|
|
@@ -18,20 +18,84 @@ const PROVIDER_KEY_ENV = {
|
|
|
18
18
|
* Defense-in-depth alongside loading config only from the trusted base ref.
|
|
19
19
|
*/
|
|
20
20
|
const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
"GITHUB_TOKEN",
|
|
22
|
+
"GH_TOKEN",
|
|
23
|
+
"ACTIONS_RUNTIME_TOKEN",
|
|
24
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
25
|
+
"AWS_ACCESS_KEY_ID",
|
|
26
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
27
|
+
"AWS_SESSION_TOKEN",
|
|
28
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
29
|
+
"GCP_SERVICE_ACCOUNT_KEY",
|
|
30
|
+
"NPM_TOKEN",
|
|
31
|
+
"NODE_AUTH_TOKEN",
|
|
32
|
+
"SSH_PRIVATE_KEY",
|
|
33
33
|
]);
|
|
34
34
|
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether the configured model provider has a usable credential, WITHOUT
|
|
37
|
+
* mutating the environment. Shared by `prepareAuth` (fail fast before spinning up
|
|
38
|
+
* the server and every pass) and `doctor` (report), so the two never drift.
|
|
39
|
+
*
|
|
40
|
+
* We only report `ok: false` when we're confident there is no credential — a
|
|
41
|
+
* missing OAuth token, a forbidden tokenEnv, or an api-key run with neither the
|
|
42
|
+
* configured tokenEnv nor the provider's own key env set. When nothing is
|
|
43
|
+
* configured and no known key env is present, we assume OpenCode's own login may
|
|
44
|
+
* cover it and don't hard-fail. `REVIEWER_MODEL` bypasses provider auth entirely.
|
|
45
|
+
*/
|
|
46
|
+
export function checkProviderAuth(config, env = process.env) {
|
|
47
|
+
const { mode, provider, tokenEnv } = config.auth;
|
|
48
|
+
if (env.REVIEWER_MODEL) {
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
detail: `auth.tokenEnv is "${tokenEnv}", a well-known non-provider secret; refusing to ` +
|
|
58
|
+
`forward it to the model provider (that would leak it). Point auth.tokenEnv at a ` +
|
|
59
|
+
`token minted for the provider instead.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (mode === "oauth") {
|
|
63
|
+
if (!tokenEnv) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
detail: 'auth.mode "oauth" requires auth.tokenEnv to name the env var holding the OAuth token.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (!env[tokenEnv]) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
detail: `auth is oauth for ${provider} but token env "${tokenEnv}" is not set.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
|
76
|
+
}
|
|
77
|
+
// api-key: usable if the configured tokenEnv is set, or the provider's own key
|
|
78
|
+
// env is already present in the environment.
|
|
79
|
+
const providerKeyEnv = PROVIDER_KEY_ENV[provider];
|
|
80
|
+
if (tokenEnv && env[tokenEnv]) {
|
|
81
|
+
return { ok: true, detail: `api-key for ${provider}; token env ${tokenEnv} is set` };
|
|
82
|
+
}
|
|
83
|
+
if (providerKeyEnv && env[providerKeyEnv]) {
|
|
84
|
+
return { ok: true, detail: `api-key for ${provider}; ${providerKeyEnv} is set` };
|
|
85
|
+
}
|
|
86
|
+
if (!tokenEnv && !providerKeyEnv) {
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
detail: `api-key for ${provider}; no tokenEnv configured and no known key env — relying on OpenCode's own login`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const names = [tokenEnv, providerKeyEnv].filter(Boolean).join(" or ");
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
detail: `configured api-key for ${provider} but no credential is set — set ${names}, or set ` +
|
|
96
|
+
`REVIEWER_MODEL to a model you're already logged into.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
35
99
|
/**
|
|
36
100
|
* Prepare model credentials for the OpenCode server based on the repo's auth mode.
|
|
37
101
|
* Must run before the server starts (it mutates env). Returns a cleanup handle.
|
|
@@ -53,17 +117,18 @@ export async function prepareAuth(config) {
|
|
|
53
117
|
if (process.env.REVIEWER_MODEL) {
|
|
54
118
|
return noop;
|
|
55
119
|
}
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
120
|
+
// Fail fast, before starting the server and every pass, if the configured
|
|
121
|
+
// provider has no usable credential — otherwise it surfaces as N failed passes
|
|
122
|
+
// mid-run. This is the same readiness check `doctor` reports, and it also covers
|
|
123
|
+
// the forbidden-secret guard (refusing to forward a well-known unrelated secret).
|
|
124
|
+
const readiness = checkProviderAuth(config);
|
|
125
|
+
if (!readiness.ok) {
|
|
126
|
+
throw new Error(readiness.detail);
|
|
62
127
|
}
|
|
63
|
-
if (mode ===
|
|
128
|
+
if (mode === "api-key") {
|
|
64
129
|
if (tokenEnv) {
|
|
65
130
|
const value = process.env[tokenEnv];
|
|
66
|
-
const target = PROVIDER_KEY_ENV[provider] ??
|
|
131
|
+
const target = PROVIDER_KEY_ENV[provider] ?? "ANTHROPIC_API_KEY";
|
|
67
132
|
// The explicitly-configured tokenEnv is authoritative — set it even if the
|
|
68
133
|
// provider env is already present, so config wins over ambient env.
|
|
69
134
|
if (value) {
|
|
@@ -72,27 +137,25 @@ export async function prepareAuth(config) {
|
|
|
72
137
|
}
|
|
73
138
|
return noop;
|
|
74
139
|
}
|
|
75
|
-
// oauth
|
|
76
|
-
if
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
const token = process.env[tokenEnv];
|
|
140
|
+
// oauth — checkProviderAuth guarantees tokenEnv is set and present; read
|
|
141
|
+
// defensively so TypeScript narrows and this stays correct if called directly.
|
|
142
|
+
const token = tokenEnv ? process.env[tokenEnv] : undefined;
|
|
80
143
|
if (!token) {
|
|
81
|
-
throw new Error(
|
|
144
|
+
throw new Error('auth.mode "oauth" requires auth.tokenEnv to name a set OAuth token env.');
|
|
82
145
|
}
|
|
83
|
-
const dir = await mkdtemp(path.join(tmpdir(),
|
|
84
|
-
await mkdir(path.join(dir,
|
|
146
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ecr-auth-"));
|
|
147
|
+
await mkdir(path.join(dir, "opencode"), { recursive: true });
|
|
85
148
|
const authJson = {
|
|
86
149
|
[provider]: {
|
|
87
|
-
type:
|
|
150
|
+
type: "oauth",
|
|
88
151
|
access: token,
|
|
89
|
-
refresh:
|
|
152
|
+
refresh: "",
|
|
90
153
|
// Far-future expiry so OpenCode uses the token as-is and does not try to
|
|
91
154
|
// refresh it (setup-token tokens are long-lived and carry no refresh).
|
|
92
155
|
expires: Date.now() + YEAR_MS,
|
|
93
156
|
},
|
|
94
157
|
};
|
|
95
|
-
await writeFile(path.join(dir,
|
|
158
|
+
await writeFile(path.join(dir, "opencode", "auth.json"), JSON.stringify(authJson), "utf8");
|
|
96
159
|
process.env.XDG_DATA_HOME = dir;
|
|
97
160
|
return {
|
|
98
161
|
cleanup: async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { promptAndParse } from
|
|
2
|
-
import { buildCoordinatorSystem, buildCoordinatorTask } from
|
|
3
|
-
import { parseCoordinatorOutput } from
|
|
1
|
+
import { promptAndParse } from "./opencode.js";
|
|
2
|
+
import { buildCoordinatorSystem, buildCoordinatorTask } from "./prompts.js";
|
|
3
|
+
import { parseCoordinatorOutput } from "./schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* Single LLM call that dedupes, re-judges severity, and decides. Structured so it
|
|
6
6
|
* could later own a spawn tool, but for now stays a plain consolidation pass.
|
|
@@ -13,10 +13,10 @@ export async function coordinate(handle, config, metadata, agentFindings, covera
|
|
|
13
13
|
const system = buildCoordinatorSystem(config);
|
|
14
14
|
const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes);
|
|
15
15
|
const { value, cost, tokens, truncated } = await promptAndParse(handle, {
|
|
16
|
-
agent:
|
|
16
|
+
agent: "coordinator",
|
|
17
17
|
system,
|
|
18
18
|
text,
|
|
19
|
-
title:
|
|
19
|
+
title: "review-coordinator",
|
|
20
20
|
maxWaitMs: COORDINATOR_TIMEOUT_MS,
|
|
21
21
|
finalizeOnTimeout: true,
|
|
22
22
|
}, parseCoordinatorOutput);
|
package/build/core/diff.js
CHANGED
|
@@ -8,20 +8,20 @@ export function parseUnifiedDiff(diffText) {
|
|
|
8
8
|
return [];
|
|
9
9
|
}
|
|
10
10
|
const entries = [];
|
|
11
|
-
const lines = diffText.split(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
50
|
-
status =
|
|
49
|
+
else if (line.startsWith("new file mode")) {
|
|
50
|
+
status = "A";
|
|
51
51
|
}
|
|
52
|
-
else if (line.startsWith(
|
|
53
|
-
status =
|
|
52
|
+
else if (line.startsWith("deleted file mode")) {
|
|
53
|
+
status = "D";
|
|
54
54
|
}
|
|
55
|
-
else if (line.startsWith(
|
|
56
|
-
status =
|
|
55
|
+
else if (line.startsWith("rename ")) {
|
|
56
|
+
status = "R";
|
|
57
57
|
}
|
|
58
|
-
else if (line.startsWith(
|
|
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 !==
|
|
65
|
-
if (!path || path ===
|
|
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 ??
|
|
71
|
+
return { path, patch, status: status ?? "M", binary };
|
|
72
72
|
}
|
|
73
73
|
function stripDiffPathPrefix(raw) {
|
|
74
74
|
const value = raw.trim();
|
|
75
|
-
if (value ===
|
|
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\/(.+)$/);
|
package/build/core/exec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { execFile } from
|
|
2
|
-
import { promisify } from
|
|
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:
|
|
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 ??
|
|
21
|
+
return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.code ?? 1 };
|
|
22
22
|
}
|
|
23
|
-
throw new Error(`Command failed: ${command} ${args.join(
|
|
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(
|
|
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(
|
|
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(
|
|
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([
|
|
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 ===
|
|
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
|
|
2
|
-
import path from
|
|
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`,
|
|
9
|
+
await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
10
10
|
}
|
package/build/core/noise.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { mkdir, open, writeFile } from
|
|
2
|
-
import path from
|
|
3
|
-
const LOCKFILES = new Set([
|
|
4
|
-
const NOISE_EXTENSIONS = [
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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(
|
|
49
|
-
return
|
|
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
|
|
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
|
|
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 +=
|
|
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(
|
|
104
|
-
.filter(line => 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(
|
|
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,
|
|
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(
|
|
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(
|
|
135
|
-
if (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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
167
|
-
await writeFile(manifestPath, renderManifest(files, metadata),
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
`Base: ${metadata.baseRef ||
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 ??
|
|
182
|
+
lines.push(`- \`${file.path}\` (${file.status ?? "M"}) — patch: \`${file.patchPath}\``);
|
|
183
183
|
}
|
|
184
|
-
lines.push(
|
|
185
|
-
return lines.join(
|
|
184
|
+
lines.push("");
|
|
185
|
+
return lines.join("\n");
|
|
186
186
|
}
|