adversarial-review-gate 2.0.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/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +201 -0
- package/README.md +589 -0
- package/bin/adversarial-review.js +14 -0
- package/package.json +43 -0
- package/src/cli/check.js +74 -0
- package/src/cli/doctor.js +261 -0
- package/src/cli/fail-closed.js +74 -0
- package/src/cli/hook.js +267 -0
- package/src/cli/host-map.js +59 -0
- package/src/cli/install.js +503 -0
- package/src/cli/main.js +48 -0
- package/src/cli/run.js +178 -0
- package/src/core/classify.js +65 -0
- package/src/core/config.js +158 -0
- package/src/core/diff.js +443 -0
- package/src/core/gate.js +753 -0
- package/src/core/git.js +66 -0
- package/src/core/hash.js +27 -0
- package/src/core/load-config.js +133 -0
- package/src/core/paths.js +33 -0
- package/src/core/policy.js +77 -0
- package/src/core/process.js +158 -0
- package/src/core/secrets.js +46 -0
- package/src/core/state.js +107 -0
- package/src/core/transcript.js +381 -0
- package/src/core/verdict.js +67 -0
- package/src/hosts/claude-code.js +77 -0
- package/src/hosts/index.js +60 -0
- package/src/hosts/wrapper.js +37 -0
- package/src/integrations/claude-code/hooks.json +28 -0
- package/src/prompts/adversarial-review-orchestrator.md +219 -0
- package/src/prompts/external-brief.md +167 -0
- package/src/reviewers/codex.js +297 -0
- package/src/reviewers/custom.js +269 -0
- package/src/reviewers/index.js +121 -0
- package/src/reviewers/opencode.js +360 -0
package/src/core/diff.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { readFile, readdir, stat, readlink } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { sha256 } from "./hash.js";
|
|
6
|
+
import { git, isGitRepo } from "./git.js";
|
|
7
|
+
|
|
8
|
+
// Directories never walked by the filesystem snapshot: VCS internals, caches,
|
|
9
|
+
// and dependency trees. Walking these would be slow and would pollute the diff
|
|
10
|
+
// with churn unrelated to the agent's change.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: build outputs (dist/build/.next) are intentionally NOT skipped. A
|
|
13
|
+
// committed bundle is the code that actually ships, so it must be reviewable.
|
|
14
|
+
const SKIP_DIRS = new Set([
|
|
15
|
+
".git",
|
|
16
|
+
"node_modules",
|
|
17
|
+
".adversarial-review",
|
|
18
|
+
"coverage",
|
|
19
|
+
".cache",
|
|
20
|
+
".venv",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Baseline capture
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Capture the "before" state of the workspace so a later buildReviewDiff() can
|
|
29
|
+
// compute what changed. Git repos record HEAD; non-git workspaces record a full
|
|
30
|
+
// content snapshot (see snapshotWorkspace) so the gate cannot be bypassed by
|
|
31
|
+
// simply not using git.
|
|
32
|
+
export async function captureBaseline(cwd) {
|
|
33
|
+
if (await isGitRepo(cwd)) {
|
|
34
|
+
const head = await git(["rev-parse", "HEAD"], cwd);
|
|
35
|
+
return { type: "git", head: head.stdout.trim() || null, cwd };
|
|
36
|
+
}
|
|
37
|
+
const { files, truncated } = await snapshotWorkspace(cwd);
|
|
38
|
+
return {
|
|
39
|
+
type: "filesystem",
|
|
40
|
+
cwd,
|
|
41
|
+
capturedAt: Date.now(),
|
|
42
|
+
// Serialize the Map to a plain object so the baseline is JSON-persistable
|
|
43
|
+
// by later tasks (the wrapper writes baselines to disk between turns).
|
|
44
|
+
snapshot: Object.fromEntries(files),
|
|
45
|
+
truncated,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Filesystem snapshot
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk the directory tree under `cwd` and return a content snapshot.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @param {number} [options.maxFileBytes=1000000] - per-file read cap.
|
|
59
|
+
* @param {number} [options.maxFiles=20000] - global file-count guard.
|
|
60
|
+
* @returns {Promise<{ files: Map<string, {hash:string,size:number,binary:boolean,symlink?:boolean}>, truncated: boolean }>}
|
|
61
|
+
*
|
|
62
|
+
* Paths are relative to `cwd` and POSIX-normalized. The change-detection `hash`
|
|
63
|
+
* is ALWAYS computed from a file's actual content via a streaming sha256 (so
|
|
64
|
+
* memory stays bounded for huge/binary files); the `maxFileBytes` cap only
|
|
65
|
+
* limits how much content is later inlined into the diff TEXT, never hashing.
|
|
66
|
+
* Symlinks are recorded by their target (without being followed) so a repointed
|
|
67
|
+
* link is detected as a change.
|
|
68
|
+
*/
|
|
69
|
+
export async function snapshotWorkspace(cwd, options = {}) {
|
|
70
|
+
const maxFileBytes = options.maxFileBytes || 1_000_000;
|
|
71
|
+
const maxFiles = options.maxFiles || 20_000;
|
|
72
|
+
const files = new Map();
|
|
73
|
+
let truncated = false;
|
|
74
|
+
|
|
75
|
+
// Iterative stack walk to avoid deep recursion on large trees.
|
|
76
|
+
const stack = [cwd];
|
|
77
|
+
while (stack.length > 0) {
|
|
78
|
+
if (files.size >= maxFiles) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
const dir = stack.pop();
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// Unreadable directory (permissions, race): skip it.
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const absolute = path.join(dir, entry.name);
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
94
|
+
stack.push(absolute);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Symlinks: record the target (without following) so repointing a link is
|
|
98
|
+
// detected. Do NOT recurse into or read through the link, which would risk
|
|
99
|
+
// escaping the workspace or hanging on a cyclic/dangling target.
|
|
100
|
+
if (entry.isSymbolicLink()) {
|
|
101
|
+
if (files.size >= maxFiles) {
|
|
102
|
+
truncated = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const rel = toPosixRel(cwd, absolute);
|
|
106
|
+
files.set(rel, await snapshotSymlink(absolute, rel));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Treat remaining non-regular files (FIFOs, devices, sockets) as
|
|
110
|
+
// non-reviewable: skip to avoid hanging.
|
|
111
|
+
if (!entry.isFile()) continue;
|
|
112
|
+
if (files.size >= maxFiles) {
|
|
113
|
+
truncated = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const rel = toPosixRel(cwd, absolute);
|
|
117
|
+
files.set(rel, await snapshotFile(absolute, rel, maxFileBytes));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { files, truncated };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Snapshot a single regular file. The change-detection `hash` is ALWAYS the
|
|
125
|
+
// streaming sha256 of the file's full content (any size, text or binary) so a
|
|
126
|
+
// same-size content edit can never be missed. `binary` (NUL byte in the first
|
|
127
|
+
// chunk) and `size` are tracked separately. The `maxFileBytes` cap is NOT used
|
|
128
|
+
// here; it only bounds how much content is later inlined into the diff text.
|
|
129
|
+
async function snapshotFile(absolute, rel, maxFileBytes) {
|
|
130
|
+
let size = 0;
|
|
131
|
+
try {
|
|
132
|
+
const info = await stat(absolute);
|
|
133
|
+
size = info.size;
|
|
134
|
+
} catch {
|
|
135
|
+
// File vanished between readdir and stat: record it as an empty entry so a
|
|
136
|
+
// later snapshot that finds it present registers a change.
|
|
137
|
+
return { hash: sha256(`${rel}:0`), size: 0, binary: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const { hash, binary } = await streamHashAndProbe(absolute);
|
|
142
|
+
return { hash, size, binary };
|
|
143
|
+
} catch {
|
|
144
|
+
// Unreadable content (permissions, race): fall back to a metadata hash so
|
|
145
|
+
// at least a size change is still detected rather than dropping the file.
|
|
146
|
+
return { hash: sha256(`${rel}:${size}`), size, binary: true };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Snapshot a symlink by hashing its target string (without following it). A
|
|
151
|
+
// changed target yields a different hash -> reported as modified.
|
|
152
|
+
async function snapshotSymlink(absolute, rel) {
|
|
153
|
+
let target = "";
|
|
154
|
+
try {
|
|
155
|
+
target = await readlink(absolute);
|
|
156
|
+
} catch {
|
|
157
|
+
// Dangling/unreadable link: still record an entry so it is reviewable.
|
|
158
|
+
target = "";
|
|
159
|
+
}
|
|
160
|
+
return { symlink: true, size: 0, binary: false, hash: sha256(`symlink:${target}`) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Stream a file through sha256 while probing the first chunk for a NUL byte
|
|
164
|
+
// (binary heuristic). Memory stays bounded regardless of file size because the
|
|
165
|
+
// content is consumed chunk-by-chunk and never fully buffered.
|
|
166
|
+
function streamHashAndProbe(absolute) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const hash = createHash("sha256");
|
|
169
|
+
let binary = false;
|
|
170
|
+
let probed = false;
|
|
171
|
+
const stream = createReadStream(absolute);
|
|
172
|
+
stream.on("data", (chunk) => {
|
|
173
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
174
|
+
if (!probed) {
|
|
175
|
+
probed = true;
|
|
176
|
+
binary = bufferHasNul(buf);
|
|
177
|
+
}
|
|
178
|
+
hash.update(buf);
|
|
179
|
+
});
|
|
180
|
+
stream.on("error", reject);
|
|
181
|
+
stream.on("end", () => resolve({ hash: hash.digest("hex"), binary }));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Binary heuristic: a NUL byte in the inspected prefix marks the file binary.
|
|
186
|
+
function bufferHasNul(buf) {
|
|
187
|
+
const limit = Math.min(buf.length, 8000);
|
|
188
|
+
for (let i = 0; i < limit; i += 1) {
|
|
189
|
+
if (buf[i] === 0) return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Normalize an absolute path to a POSIX-style path relative to `cwd`.
|
|
195
|
+
function toPosixRel(cwd, absolute) {
|
|
196
|
+
return path.relative(cwd, absolute).split(path.sep).join("/");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Review diff
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
// Build the authoritative "what changed since baseline" diff. Git repos use
|
|
204
|
+
// git plumbing; non-git workspaces compare a fresh snapshot against the
|
|
205
|
+
// baseline snapshot. INVARIANT: if any file changed, both `text` and
|
|
206
|
+
// `changedFiles` are non-empty — never a vacuous empty diff.
|
|
207
|
+
export async function buildReviewDiff(cwd, baseline) {
|
|
208
|
+
if (baseline?.type === "git" && baseline.head) {
|
|
209
|
+
const committed = await git(["diff", "--binary", baseline.head, "HEAD"], cwd);
|
|
210
|
+
const working = await git(["diff", "--binary", "HEAD"], cwd);
|
|
211
|
+
const staged = await git(["diff", "--binary", "--cached"], cwd);
|
|
212
|
+
const chunks = [
|
|
213
|
+
withTruncationMarker(committed),
|
|
214
|
+
withTruncationMarker(working),
|
|
215
|
+
withTruncationMarker(staged),
|
|
216
|
+
];
|
|
217
|
+
// Gather untracked files WITHOUT --exclude-standard so gitignored-but-present
|
|
218
|
+
// runtime files cannot hide from review; SKIP_DIRS filtering keeps
|
|
219
|
+
// node_modules etc. out. This matches the filesystem walk's coverage.
|
|
220
|
+
for (const rel of await gitUntrackedFiles(cwd)) {
|
|
221
|
+
chunks.push(await synthesizeNewFileDiff(cwd, rel));
|
|
222
|
+
}
|
|
223
|
+
const text = chunks.filter(Boolean).join("\n");
|
|
224
|
+
return { text, diffHash: sha256(text), changedFiles: await changedFiles(cwd, baseline) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (baseline?.type === "filesystem") {
|
|
228
|
+
return buildFilesystemReviewDiff(cwd, baseline);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Unknown baseline shape: no comparison possible. This is not the bypass case
|
|
232
|
+
// (there is no recorded snapshot to compare against).
|
|
233
|
+
return { text: "", diffHash: sha256(""), changedFiles: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Compute a real diff for non-git workspaces by comparing the current snapshot
|
|
237
|
+
// against the baseline snapshot.
|
|
238
|
+
async function buildFilesystemReviewDiff(cwd, baseline) {
|
|
239
|
+
const { files: current } = await snapshotWorkspace(cwd, baseline.options || {});
|
|
240
|
+
const baselineSnapshot = baseline.snapshot || {};
|
|
241
|
+
const blocks = [];
|
|
242
|
+
const changed = [];
|
|
243
|
+
|
|
244
|
+
const maxFileBytes = (baseline.options && baseline.options.maxFileBytes) || 1_000_000;
|
|
245
|
+
|
|
246
|
+
// ADDED + MODIFIED: iterate the current snapshot.
|
|
247
|
+
for (const [rel, info] of current) {
|
|
248
|
+
const prior = baselineSnapshot[rel];
|
|
249
|
+
if (!prior) {
|
|
250
|
+
blocks.push(await addedBlock(cwd, rel, info, maxFileBytes));
|
|
251
|
+
changed.push({ path: rel, status: "A" });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (prior.hash !== info.hash) {
|
|
255
|
+
blocks.push(await modifiedBlock(cwd, rel, prior, info, maxFileBytes));
|
|
256
|
+
changed.push({ path: rel, status: "M" });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// DELETED: in baseline but not in the current snapshot.
|
|
261
|
+
for (const rel of Object.keys(baselineSnapshot)) {
|
|
262
|
+
if (!current.has(rel)) {
|
|
263
|
+
blocks.push(deletionBlock(rel));
|
|
264
|
+
changed.push({ path: rel, status: "D" });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const text = blocks.filter(Boolean).join("\n");
|
|
269
|
+
return { text, diffHash: sha256(text), changedFiles: changed };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Diff block synthesizers
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
// Choose the right block for an ADDED entry based on its kind.
|
|
277
|
+
async function addedBlock(cwd, rel, info, maxFileBytes) {
|
|
278
|
+
if (info.symlink) return symlinkBlock(cwd, rel, "new");
|
|
279
|
+
if (info.binary) return binaryMetaBlock(rel, null, info.size, "new");
|
|
280
|
+
return synthesizeNewFileDiff(cwd, rel, maxFileBytes);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Choose the right block for a MODIFIED entry based on its (current) kind.
|
|
284
|
+
async function modifiedBlock(cwd, rel, prior, info, maxFileBytes) {
|
|
285
|
+
if (info.symlink || prior.symlink) return symlinkBlock(cwd, rel, "modified");
|
|
286
|
+
if (info.binary || prior.binary) return binaryMetaBlock(rel, prior.size, info.size, "modified");
|
|
287
|
+
return synthesizeModifiedFileDiff(cwd, rel, maxFileBytes);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Synthesize a unified-diff-style block for a brand-new file (used for git
|
|
291
|
+
// untracked files and filesystem ADDED text files). The inlined content is
|
|
292
|
+
// capped at `maxFileBytes`; the file is still fully hashed elsewhere, so this
|
|
293
|
+
// truncation is a diff-text coverage limitation only, marked explicitly.
|
|
294
|
+
export async function synthesizeNewFileDiff(cwd, rel, maxFileBytes = 1_000_000) {
|
|
295
|
+
const absolute = path.resolve(cwd, rel);
|
|
296
|
+
const body = await readFile(absolute, "utf8").catch(() => "");
|
|
297
|
+
if (!body) {
|
|
298
|
+
return `diff --git a/${rel} b/${rel}\nnew file mode 100644\nBinary or unreadable file: ${rel}\n`;
|
|
299
|
+
}
|
|
300
|
+
const { text, marker } = capForDiff(body, maxFileBytes);
|
|
301
|
+
const lines = text.split(/\r?\n/).map((line) => `+${line}`).join("\n");
|
|
302
|
+
return `diff --git a/${rel} b/${rel}\nnew file mode 100644\n--- /dev/null\n+++ b/${rel}\n${lines}\n${marker}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Synthesize a whole-file-replacement block for a MODIFIED text file. A precise
|
|
306
|
+
// line-level diff is not required; the block must be clearly marked modified and
|
|
307
|
+
// be non-empty so the gate always sees the change. Inlined content is capped.
|
|
308
|
+
async function synthesizeModifiedFileDiff(cwd, rel, maxFileBytes = 1_000_000) {
|
|
309
|
+
const absolute = path.resolve(cwd, rel);
|
|
310
|
+
const body = await readFile(absolute, "utf8").catch(() => "");
|
|
311
|
+
const { text, marker } = capForDiff(body, maxFileBytes);
|
|
312
|
+
const lines = text.split(/\r?\n/).map((line) => `+${line}`).join("\n");
|
|
313
|
+
return `diff --git a/${rel} b/${rel}\nmodified file mode 100644\n--- a/${rel}\n+++ b/${rel}\n${lines}\n${marker}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Cap inlined diff content at `maxFileBytes`. Returns the (possibly truncated)
|
|
317
|
+
// text plus an explicit marker line noting how many bytes were withheld — this
|
|
318
|
+
// is a diff-text coverage limitation, not a missed change (the full file is
|
|
319
|
+
// always hashed for change detection).
|
|
320
|
+
function capForDiff(body, maxFileBytes) {
|
|
321
|
+
const totalBytes = Buffer.byteLength(body, "utf8");
|
|
322
|
+
if (totalBytes <= maxFileBytes) return { text: body, marker: "" };
|
|
323
|
+
// Slice on the byte buffer to honor the cap, then decode back to a string.
|
|
324
|
+
const truncated = Buffer.from(body, "utf8").subarray(0, maxFileBytes).toString("utf8");
|
|
325
|
+
const notShown = totalBytes - Buffer.byteLength(truncated, "utf8");
|
|
326
|
+
const marker =
|
|
327
|
+
`... [truncated: ${notShown} bytes not shown] ...\n` +
|
|
328
|
+
`(coverage limitation: diff text capped at ${maxFileBytes} bytes; full content was hashed for change detection)\n`;
|
|
329
|
+
return { text: truncated, marker };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Small text block for an added/modified symlink, noting its target.
|
|
333
|
+
async function symlinkBlock(cwd, rel, kind) {
|
|
334
|
+
const absolute = path.resolve(cwd, rel);
|
|
335
|
+
let target = "";
|
|
336
|
+
try {
|
|
337
|
+
target = await readlink(absolute);
|
|
338
|
+
} catch {
|
|
339
|
+
target = "<unreadable>";
|
|
340
|
+
}
|
|
341
|
+
const mode = kind === "new" ? "new file mode 120000" : "modified file mode 120000";
|
|
342
|
+
return `diff --git a/${rel} b/${rel}\n${mode}\nSymlink ${kind}: ${rel} -> ${target}\n`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Metadata-only block for a binary file (added or modified). Non-empty by
|
|
346
|
+
// construction so binary changes are never silently dropped.
|
|
347
|
+
function binaryMetaBlock(rel, oldSize, newSize, kind) {
|
|
348
|
+
const mode = kind === "new" ? "new file mode 100644" : "modified file mode 100644";
|
|
349
|
+
const sizeLine =
|
|
350
|
+
oldSize === null
|
|
351
|
+
? `Binary file added: size ${newSize} bytes`
|
|
352
|
+
: `Binary file ${kind}: size ${oldSize} -> ${newSize} bytes`;
|
|
353
|
+
return `diff --git a/${rel} b/${rel}\n${mode}\nBinary files a/${rel} and b/${rel} differ\n${sizeLine}\n`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Deletion marker block for a removed file.
|
|
357
|
+
function deletionBlock(rel) {
|
|
358
|
+
return `diff --git a/${rel} b/${rel}\ndeleted file mode 100644\n--- a/${rel}\n+++ /dev/null\n`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Changed file list (git)
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
// Build the list of changed files for a git baseline by unioning name-status
|
|
366
|
+
// across committed / working / staged ranges plus untracked files. Renames
|
|
367
|
+
// (status R) contribute both the old and new path entries.
|
|
368
|
+
export async function changedFiles(cwd, baseline) {
|
|
369
|
+
const map = new Map(); // path -> status (first writer wins ordering, last status wins)
|
|
370
|
+
|
|
371
|
+
const ranges = [
|
|
372
|
+
["diff", "--name-status", baseline.head, "HEAD"],
|
|
373
|
+
["diff", "--name-status", "HEAD"],
|
|
374
|
+
["diff", "--cached", "--name-status"],
|
|
375
|
+
];
|
|
376
|
+
for (const args of ranges) {
|
|
377
|
+
const result = await git(args, cwd);
|
|
378
|
+
if (result.code !== 0) continue;
|
|
379
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
380
|
+
parseNameStatusLine(line, map);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Gather untracked files WITHOUT --exclude-standard (then SKIP_DIRS-filter) so
|
|
385
|
+
// gitignored-but-present files are still surfaced as additions.
|
|
386
|
+
for (const rel of await gitUntrackedFiles(cwd)) {
|
|
387
|
+
map.set(toPosixSlashes(rel), "A");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return [...map.entries()].map(([p, status]) => ({ path: p, status }));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// List untracked files via git, INCLUDING ignored-but-present ones (no
|
|
394
|
+
// --exclude-standard), then drop any path that lives under a SKIP_DIRS segment
|
|
395
|
+
// (e.g. node_modules) so the review still ignores dependency/cache trees.
|
|
396
|
+
async function gitUntrackedFiles(cwd) {
|
|
397
|
+
const result = await git(["ls-files", "--others"], cwd);
|
|
398
|
+
if (result.code !== 0) return [];
|
|
399
|
+
return result.stdout
|
|
400
|
+
.split(/\r?\n/)
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.map(toPosixSlashes)
|
|
403
|
+
.filter((rel) => !isUnderSkipDir(rel));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// True if any path segment of `rel` is in SKIP_DIRS.
|
|
407
|
+
function isUnderSkipDir(rel) {
|
|
408
|
+
return rel.split("/").some((segment) => SKIP_DIRS.has(segment));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// If a git() result was truncated by the stdout cap, append an explicit coverage
|
|
412
|
+
// limitation marker so the gate treats it as a limitation rather than dropping
|
|
413
|
+
// the tail silently. Returns the (possibly annotated) stdout string.
|
|
414
|
+
function withTruncationMarker(result) {
|
|
415
|
+
if (!result || !result.truncated) return result ? result.stdout : "";
|
|
416
|
+
return (
|
|
417
|
+
`${result.stdout}\n` +
|
|
418
|
+
`... [git output truncated: exceeded buffer cap; diff is incomplete] ...\n` +
|
|
419
|
+
`(coverage limitation: review this change manually — output was too large to capture in full)\n`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Parse a single `git diff --name-status` line into the path->status map.
|
|
424
|
+
// Handles tab-separated columns; rename/copy lines (Rxxx / Cxxx) carry both old
|
|
425
|
+
// and new paths and emit two entries.
|
|
426
|
+
function parseNameStatusLine(line, map) {
|
|
427
|
+
const parts = line.split("\t");
|
|
428
|
+
const code = parts[0];
|
|
429
|
+
const status = code[0];
|
|
430
|
+
if (status === "R" || status === "C") {
|
|
431
|
+
const oldPath = parts[1];
|
|
432
|
+
const newPath = parts[2];
|
|
433
|
+
if (oldPath) map.set(toPosixSlashes(oldPath), "D");
|
|
434
|
+
if (newPath) map.set(toPosixSlashes(newPath), "A");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const target = parts[1];
|
|
438
|
+
if (target) map.set(toPosixSlashes(target), status);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function toPosixSlashes(p) {
|
|
442
|
+
return p.split("\\").join("/");
|
|
443
|
+
}
|