diffowl 0.2.1 → 0.3.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 +59 -10
- package/dist/cli.js +2439 -251
- package/dist/cli.js.map +1 -1
- package/package.json +16 -3
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk3 from "chalk";
|
|
6
6
|
import ora from "ora";
|
|
7
7
|
import { createInterface } from "readline/promises";
|
|
8
8
|
|
|
@@ -160,7 +160,9 @@ import { join as join2 } from "path";
|
|
|
160
160
|
var HEALTH_TIMEOUT_MS = 2e3;
|
|
161
161
|
var STARTUP_WAIT_MS = 3e3;
|
|
162
162
|
var MAX_RETRIES = 10;
|
|
163
|
-
|
|
163
|
+
var PORT_RELEASE_WAIT_MS = 5e3;
|
|
164
|
+
var PORT_RELEASE_POLL_MS = 200;
|
|
165
|
+
async function getServerHealth(port) {
|
|
164
166
|
try {
|
|
165
167
|
const controller = new AbortController();
|
|
166
168
|
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
@@ -168,15 +170,48 @@ async function isServerRunning(port) {
|
|
|
168
170
|
signal: controller.signal
|
|
169
171
|
});
|
|
170
172
|
clearTimeout(timeout);
|
|
171
|
-
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const body = await res.json();
|
|
177
|
+
const health = { healthy: body.healthy === true };
|
|
178
|
+
if (typeof body.version === "string") {
|
|
179
|
+
health.version = body.version;
|
|
180
|
+
}
|
|
181
|
+
return health;
|
|
172
182
|
} catch {
|
|
173
|
-
return
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function getInstalledOpencodeVersion() {
|
|
187
|
+
try {
|
|
188
|
+
const { stdout } = await execa("opencode", ["--version"], { timeout: 5e3 });
|
|
189
|
+
const trimmed = stdout.trim();
|
|
190
|
+
if (!trimmed) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const match = trimmed.match(/(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/);
|
|
194
|
+
return match?.[1] ?? trimmed;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
174
197
|
}
|
|
175
198
|
}
|
|
199
|
+
async function isServerRunning(port) {
|
|
200
|
+
const health = await getServerHealth(port);
|
|
201
|
+
return health?.healthy === true;
|
|
202
|
+
}
|
|
176
203
|
async function ensureServer(port) {
|
|
177
204
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
const health = await getServerHealth(port);
|
|
206
|
+
if (health?.healthy) {
|
|
207
|
+
const cliVersion = await getInstalledOpencodeVersion();
|
|
208
|
+
if (health.version && cliVersion && health.version !== cliVersion) {
|
|
209
|
+
if (!await stopServer(port)) {
|
|
210
|
+
throw new Error(`Could not locate or stop stale OpenCode server on port ${port}.`);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
return baseUrl;
|
|
214
|
+
}
|
|
180
215
|
}
|
|
181
216
|
await spawnServer(port);
|
|
182
217
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
@@ -224,10 +259,33 @@ async function spawnServer(port) {
|
|
|
224
259
|
}
|
|
225
260
|
subprocess.unref();
|
|
226
261
|
}
|
|
227
|
-
async function stopServer() {
|
|
262
|
+
async function stopServer(port) {
|
|
263
|
+
if (await stopManagedServer()) {
|
|
264
|
+
await waitUntilPortFree(port);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const listenerPid = await findOpencodeListenerPid(port);
|
|
268
|
+
if (listenerPid === null) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (!await isOpencodeProcess(listenerPid)) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
process.kill(listenerPid, "SIGTERM");
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
await cleanupPidFile();
|
|
280
|
+
await waitUntilPortFree(port);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
async function stopManagedServer() {
|
|
228
284
|
const dir = getDiffOwlDir();
|
|
229
285
|
const pidFile = join2(dir, "server.pid");
|
|
230
|
-
if (!existsSync2(pidFile))
|
|
286
|
+
if (!existsSync2(pidFile)) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
231
289
|
let pid;
|
|
232
290
|
try {
|
|
233
291
|
pid = parseInt(await readFile2(pidFile, "utf-8"), 10);
|
|
@@ -256,46 +314,112 @@ async function stopServer() {
|
|
|
256
314
|
}
|
|
257
315
|
try {
|
|
258
316
|
process.kill(pid, "SIGTERM");
|
|
259
|
-
await unlink(pidFile);
|
|
260
|
-
return true;
|
|
261
317
|
} catch {
|
|
262
318
|
return false;
|
|
263
319
|
}
|
|
320
|
+
try {
|
|
321
|
+
await unlink(pidFile);
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
async function cleanupPidFile() {
|
|
327
|
+
const pidFile = join2(getDiffOwlDir(), "server.pid");
|
|
328
|
+
if (!existsSync2(pidFile)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await unlink(pidFile);
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function findOpencodeListenerPid(port) {
|
|
337
|
+
if (process.platform === "win32") {
|
|
338
|
+
return findOpencodeListenerPidWindows(port);
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const { stdout } = await execa("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
|
|
342
|
+
timeout: 5e3
|
|
343
|
+
});
|
|
344
|
+
const pids = stdout.trim().split(/\s+/).map((value) => parseInt(value, 10)).filter((value) => Number.isInteger(value) && value > 0);
|
|
345
|
+
for (const pid of pids) {
|
|
346
|
+
if (await isOpencodeProcess(pid)) {
|
|
347
|
+
return pid;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
async function findOpencodeListenerPidWindows(port) {
|
|
355
|
+
try {
|
|
356
|
+
const { stdout } = await execa("netstat", ["-ano"], { timeout: 5e3 });
|
|
357
|
+
const portToken = `:${port}`;
|
|
358
|
+
const lines = stdout.split(/\r?\n/);
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
if (!line.includes("LISTENING") || !line.includes(portToken)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const parts = line.trim().split(/\s+/);
|
|
364
|
+
const pid = parseInt(parts[parts.length - 1] ?? "", 10);
|
|
365
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (await isOpencodeProcess(pid)) {
|
|
369
|
+
return pid;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
async function waitUntilPortFree(port) {
|
|
377
|
+
const deadline = Date.now() + PORT_RELEASE_WAIT_MS;
|
|
378
|
+
while (Date.now() < deadline) {
|
|
379
|
+
if (!await isServerRunning(port)) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await sleep(PORT_RELEASE_POLL_MS);
|
|
383
|
+
}
|
|
384
|
+
if (await isServerRunning(port)) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`OpenCode server on port ${port} did not stop within ${PORT_RELEASE_WAIT_MS}ms. Retry: diffowl server stop && diffowl server start`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
264
389
|
}
|
|
265
390
|
async function isOpencodeProcess(pid) {
|
|
266
391
|
const isWin = process.platform === "win32";
|
|
267
392
|
try {
|
|
268
393
|
if (isWin) {
|
|
269
394
|
try {
|
|
270
|
-
const { stdout:
|
|
395
|
+
const { stdout: stdout3 } = await execa("powershell", [
|
|
271
396
|
"-NoProfile",
|
|
272
397
|
"-Command",
|
|
273
398
|
`Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`
|
|
274
399
|
]);
|
|
275
|
-
if (
|
|
400
|
+
if (stdout3.toLowerCase().includes("opencode")) {
|
|
276
401
|
return true;
|
|
277
402
|
}
|
|
278
403
|
} catch {
|
|
279
404
|
}
|
|
280
405
|
try {
|
|
281
|
-
const { stdout:
|
|
406
|
+
const { stdout: stdout3 } = await execa("wmic", [
|
|
282
407
|
"process",
|
|
283
408
|
"where",
|
|
284
409
|
`ProcessId=${pid}`,
|
|
285
410
|
"get",
|
|
286
411
|
"CommandLine"
|
|
287
412
|
]);
|
|
288
|
-
if (
|
|
413
|
+
if (stdout3.toLowerCase().includes("opencode")) {
|
|
289
414
|
return true;
|
|
290
415
|
}
|
|
291
416
|
} catch {
|
|
292
417
|
}
|
|
293
|
-
const { stdout } = await execa("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
|
|
294
|
-
return
|
|
295
|
-
} else {
|
|
296
|
-
const { stdout } = await execa("ps", ["-p", String(pid), "-o", "command="]);
|
|
297
|
-
return stdout.toLowerCase().includes("opencode");
|
|
418
|
+
const { stdout: stdout2 } = await execa("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
|
|
419
|
+
return stdout2.toLowerCase().includes("opencode");
|
|
298
420
|
}
|
|
421
|
+
const { stdout } = await execa("ps", ["-p", String(pid), "-o", "command="]);
|
|
422
|
+
return stdout.toLowerCase().includes("opencode");
|
|
299
423
|
} catch {
|
|
300
424
|
return false;
|
|
301
425
|
}
|
|
@@ -838,6 +962,27 @@ function parseProviderPayload(response) {
|
|
|
838
962
|
return ProviderPayloadSchema.safeParse(response.data).data;
|
|
839
963
|
}
|
|
840
964
|
|
|
965
|
+
// src/opencode/quota.ts
|
|
966
|
+
var QUOTA_PATTERNS = [
|
|
967
|
+
/\b429\b/,
|
|
968
|
+
/rate.?limit/,
|
|
969
|
+
/usage limit/,
|
|
970
|
+
/insufficient[_ -]?quota/,
|
|
971
|
+
/quota (exceeded|reached)/,
|
|
972
|
+
/(exceeded|reached) (your )?(current )?quota/,
|
|
973
|
+
/resource.?exhausted/,
|
|
974
|
+
/too many requests/,
|
|
975
|
+
/overloaded/,
|
|
976
|
+
/insufficient capacity/,
|
|
977
|
+
/billing[_ -]?(hard[_ -]?)?limit/,
|
|
978
|
+
/tokens per (min|day)/,
|
|
979
|
+
/requests per (min|day)/
|
|
980
|
+
];
|
|
981
|
+
function isQuotaOrRateLimitError(message) {
|
|
982
|
+
const normalized = message.toLowerCase();
|
|
983
|
+
return QUOTA_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
984
|
+
}
|
|
985
|
+
|
|
841
986
|
// src/opencode/models.ts
|
|
842
987
|
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
843
988
|
async function getAvailableModels(port, options = {}) {
|
|
@@ -861,6 +1006,12 @@ function listAvailableModels(payload) {
|
|
|
861
1006
|
}
|
|
862
1007
|
|
|
863
1008
|
// src/opencode/client.ts
|
|
1009
|
+
var ReviewCancelledError = class extends Error {
|
|
1010
|
+
name = "ReviewCancelledError";
|
|
1011
|
+
};
|
|
1012
|
+
function isReviewCancellation(error) {
|
|
1013
|
+
return error instanceof ReviewCancelledError;
|
|
1014
|
+
}
|
|
864
1015
|
function normalizeOpenCodeEvent(event, expectedSessionId) {
|
|
865
1016
|
if (!event || typeof event !== "object") return void 0;
|
|
866
1017
|
const payload = event.payload;
|
|
@@ -950,7 +1101,10 @@ function normalizeAssistantMessage(info, expectedSessionId) {
|
|
|
950
1101
|
};
|
|
951
1102
|
}
|
|
952
1103
|
async function runReview(options) {
|
|
953
|
-
const { target, directory, config, localContext, depth, onProgress } = options;
|
|
1104
|
+
const { target, directory, config, localContext, depth, onProgress, signal } = options;
|
|
1105
|
+
if (signal?.aborted) {
|
|
1106
|
+
throw new ReviewCancelledError("Review cancelled by user.");
|
|
1107
|
+
}
|
|
954
1108
|
const port = config.server.port;
|
|
955
1109
|
const directoryOptions = opencodeDirectoryOptions(directory);
|
|
956
1110
|
const timings = [];
|
|
@@ -999,6 +1153,10 @@ async function runReview(options) {
|
|
|
999
1153
|
);
|
|
1000
1154
|
let fullResponse = "";
|
|
1001
1155
|
const eventsController = new AbortController();
|
|
1156
|
+
const cancelReview = () => {
|
|
1157
|
+
eventsController.abort();
|
|
1158
|
+
};
|
|
1159
|
+
signal?.addEventListener("abort", cancelReview, { once: true });
|
|
1002
1160
|
const eventStart = performance.now();
|
|
1003
1161
|
const sseResult = await withOpenCodeDiagnostics(
|
|
1004
1162
|
"event-stream-connect",
|
|
@@ -1068,10 +1226,28 @@ async function runReview(options) {
|
|
|
1068
1226
|
settlement.acceptAssistantMessage({ text, error: normalized.error });
|
|
1069
1227
|
break;
|
|
1070
1228
|
}
|
|
1071
|
-
case "session-status":
|
|
1072
|
-
|
|
1073
|
-
|
|
1229
|
+
case "session-status": {
|
|
1230
|
+
if (normalized.status === "retry") {
|
|
1231
|
+
const retryMessage = normalized.message ?? "unknown error";
|
|
1232
|
+
onProgress?.({
|
|
1233
|
+
type: "session",
|
|
1234
|
+
message: `OpenCode retrying: ${retryMessage}`,
|
|
1235
|
+
sessionId
|
|
1236
|
+
});
|
|
1237
|
+
if (isQuotaOrRateLimitError(retryMessage)) {
|
|
1238
|
+
settlement.reject(
|
|
1239
|
+
new Error(`Provider quota or rate limit reached: ${retryMessage}`)
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
} else {
|
|
1243
|
+
onProgress?.({
|
|
1244
|
+
type: "session",
|
|
1245
|
+
message: `OpenCode session ${normalized.status}.`,
|
|
1246
|
+
sessionId
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1074
1249
|
break;
|
|
1250
|
+
}
|
|
1075
1251
|
case "session-idle":
|
|
1076
1252
|
if (fullResponse.length === 0) break;
|
|
1077
1253
|
onProgress?.({ type: "idle", message: "OpenCode session is idle." });
|
|
@@ -1084,48 +1260,57 @@ async function runReview(options) {
|
|
|
1084
1260
|
settlement.finish();
|
|
1085
1261
|
}
|
|
1086
1262
|
} catch (streamErr) {
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1263
|
+
if (settlement.isSettled()) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (eventsController.signal.aborted) {
|
|
1267
|
+
settlement.reject(new ReviewCancelledError("Review cancelled by user."));
|
|
1268
|
+
return;
|
|
1091
1269
|
}
|
|
1270
|
+
settlement.reject(
|
|
1271
|
+
describeOpenCodeError(streamErr, "event-stream-read", { port, sessionId })
|
|
1272
|
+
);
|
|
1092
1273
|
}
|
|
1093
1274
|
})();
|
|
1094
1275
|
})
|
|
1095
1276
|
);
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1277
|
+
try {
|
|
1278
|
+
onProgress?.({ type: "session", message: "Sending review prompt.", sessionId });
|
|
1279
|
+
const promptSendStart = performance.now();
|
|
1280
|
+
await withOpenCodeDiagnostics(
|
|
1281
|
+
"prompt-send",
|
|
1282
|
+
{ port, sessionId },
|
|
1283
|
+
() => client.session.promptAsync({
|
|
1284
|
+
path: { id: sessionId },
|
|
1285
|
+
...directoryOptions,
|
|
1286
|
+
body: {
|
|
1287
|
+
system: REVIEW_AGENT_PROMPT,
|
|
1288
|
+
model: { providerID, modelID },
|
|
1289
|
+
tools,
|
|
1290
|
+
...reasoning.variant ? { variant: reasoning.variant } : {},
|
|
1291
|
+
parts: [{ type: "text", text: prompt }]
|
|
1292
|
+
}
|
|
1293
|
+
})
|
|
1294
|
+
);
|
|
1295
|
+
recordTiming(timings, onProgress, "prompt-send", "OpenCode prompt request", promptSendStart);
|
|
1296
|
+
const agentWaitStart = performance.now();
|
|
1297
|
+
const raw = await withOpenCodeDiagnostics(
|
|
1298
|
+
"agent-wait",
|
|
1299
|
+
{ port, sessionId },
|
|
1300
|
+
() => responsePromise
|
|
1301
|
+
);
|
|
1302
|
+
recordTiming(timings, onProgress, "agent-wait", "OpenCode review generation", agentWaitStart);
|
|
1303
|
+
const parseStart = performance.now();
|
|
1304
|
+
const report = parseStructuredReview(raw);
|
|
1305
|
+
recordTiming(timings, onProgress, "parse-review", "Review JSON parsing", parseStart);
|
|
1306
|
+
const diagnostics = [...report.diagnostics ?? [], ...reasoning.diagnostics];
|
|
1307
|
+
return {
|
|
1308
|
+
report: { ...report, ...diagnostics.length > 0 ? { diagnostics } : {}, timings },
|
|
1309
|
+
sessionId
|
|
1310
|
+
};
|
|
1311
|
+
} finally {
|
|
1312
|
+
signal?.removeEventListener("abort", cancelReview);
|
|
1313
|
+
}
|
|
1129
1314
|
}
|
|
1130
1315
|
function extractSessionMessageResult(response) {
|
|
1131
1316
|
if (!response || typeof response !== "object") return { kind: "empty" };
|
|
@@ -1320,9 +1505,30 @@ function getOpenCodeFailureGuidance(message) {
|
|
|
1320
1505
|
if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
|
|
1321
1506
|
return ["Start the managed server: diffowl server start", "Then retry the DiffOwl command."];
|
|
1322
1507
|
}
|
|
1508
|
+
if (isQuotaOrRateLimitError(normalized)) {
|
|
1509
|
+
return [
|
|
1510
|
+
"Provider quota or rate limit reached. Wait a few minutes and retry.",
|
|
1511
|
+
"If persistent, check your provider dashboard for usage limits and billing.",
|
|
1512
|
+
"You can also try a different model: diffowl review --model <model>"
|
|
1513
|
+
];
|
|
1514
|
+
}
|
|
1323
1515
|
if (normalized.includes("timed out") || normalized.includes("timeout")) {
|
|
1324
1516
|
return ["Retry with less context: diffowl review --depth shallow"];
|
|
1325
1517
|
}
|
|
1518
|
+
if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
|
|
1519
|
+
return [
|
|
1520
|
+
"Native module ABI mismatch. Rebuild for your active Node: pnpm rebuild better-sqlite3",
|
|
1521
|
+
"Reinstall the hook so it uses the same Node as the CLI: diffowl hook install",
|
|
1522
|
+
"Compare the required NODE_MODULE_VERSION with the Hook Node ABI from `diffowl hook install`."
|
|
1523
|
+
];
|
|
1524
|
+
}
|
|
1525
|
+
if (normalized.includes("session_message.seq") || normalized.includes("not null constraint failed") && normalized.includes("seq")) {
|
|
1526
|
+
return [
|
|
1527
|
+
"OpenCode server version may be stale. Check: diffowl server status",
|
|
1528
|
+
"Restart the server: diffowl server stop && diffowl server start",
|
|
1529
|
+
"Confirm server and CLI versions match: opencode --version"
|
|
1530
|
+
];
|
|
1531
|
+
}
|
|
1326
1532
|
return [];
|
|
1327
1533
|
}
|
|
1328
1534
|
|
|
@@ -1512,6 +1718,28 @@ Retry:
|
|
|
1512
1718
|
diffowl review --commit ${failure.commit}
|
|
1513
1719
|
diffowl review --commit ${failure.commit} --depth shallow`;
|
|
1514
1720
|
}
|
|
1721
|
+
function isHookQueueStopFailure(message) {
|
|
1722
|
+
if (!message || message === "Review started.") {
|
|
1723
|
+
return false;
|
|
1724
|
+
}
|
|
1725
|
+
const normalized = message.toLowerCase();
|
|
1726
|
+
if (isQuotaOrRateLimitError(normalized)) {
|
|
1727
|
+
return true;
|
|
1728
|
+
}
|
|
1729
|
+
if (normalized.includes("unauthorized") || normalized.includes("authentication") || normalized.includes("invalid api key") || normalized.includes("missing api key") || /\b(401|403)\b/.test(normalized) || normalized.includes("no active provider") || normalized.includes("no connected provider") || normalized.includes("model not found") || normalized.includes("unknown model")) {
|
|
1730
|
+
return true;
|
|
1731
|
+
}
|
|
1732
|
+
if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
|
|
1733
|
+
return true;
|
|
1734
|
+
}
|
|
1735
|
+
if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
if (normalized.includes("opencode not found") || normalized.includes("opencode: command not found") || normalized.includes("enoent") && normalized.includes("opencode")) {
|
|
1739
|
+
return true;
|
|
1740
|
+
}
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1515
1743
|
async function runHookReview() {
|
|
1516
1744
|
const dir = await ensureDiffOwlDir();
|
|
1517
1745
|
const logFile = join3(dir, "hook.log");
|
|
@@ -1536,7 +1764,7 @@ async function runHookReview() {
|
|
|
1536
1764
|
const prefix = command.pathDirs?.join(":");
|
|
1537
1765
|
const existingPath = process.env["PATH"] ?? "";
|
|
1538
1766
|
const envPath = prefix ? `${prefix}:${existingPath}` : existingPath;
|
|
1539
|
-
const subprocess = execa2(
|
|
1767
|
+
const subprocess = execa2(command.node, [fileURLToPath(import.meta.url), "hook-worker"], {
|
|
1540
1768
|
detached: true,
|
|
1541
1769
|
cleanup: false,
|
|
1542
1770
|
cwd: process.cwd(),
|
|
@@ -1604,6 +1832,18 @@ async function runPendingHookReviews() {
|
|
|
1604
1832
|
if (status?.exitCode !== 0 || status.message) {
|
|
1605
1833
|
if (status && status.exitCode !== 0) {
|
|
1606
1834
|
await writeHookStatus(status.exitCode, status.commit, status.message, null, dir);
|
|
1835
|
+
if (isHookQueueStopFailure(status.message)) {
|
|
1836
|
+
const remaining = (await listPendingReviews(dir)).filter((item) => item.sha !== next.sha);
|
|
1837
|
+
if (remaining.length > 0) {
|
|
1838
|
+
await appendFile(
|
|
1839
|
+
logFile,
|
|
1840
|
+
`diffowl: stopping hook queue after ${next.sha}; ${remaining.length} pending review(s) left for later (${status.message})
|
|
1841
|
+
`,
|
|
1842
|
+
"utf-8"
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1607
1847
|
}
|
|
1608
1848
|
continue;
|
|
1609
1849
|
}
|
|
@@ -1781,6 +2021,9 @@ function extractManagedSection(content) {
|
|
|
1781
2021
|
if (ourEnd === -1) return void 0;
|
|
1782
2022
|
return lines.slice(ourStart, ourEnd + 1).join("\n");
|
|
1783
2023
|
}
|
|
2024
|
+
async function getHookCommand() {
|
|
2025
|
+
return resolveHookCommand();
|
|
2026
|
+
}
|
|
1784
2027
|
async function resolveHookCommand() {
|
|
1785
2028
|
const diffowl = await resolveCommand("diffowl");
|
|
1786
2029
|
const opencode = await resolveCommand("opencode");
|
|
@@ -1845,11 +2088,13 @@ function generateManagedSection(command) {
|
|
|
1845
2088
|
const diffowlPathFallback = isPath ? `elif [ -x ${quotedDiffOwl} ]; then
|
|
1846
2089
|
${quotedDiffOwl} hook-run
|
|
1847
2090
|
` : "";
|
|
1848
|
-
const
|
|
2091
|
+
const nodeCliRun = `if [ -x ${quotedNode} ] && [ -f ${quotedCli} ]; then
|
|
1849
2092
|
${quotedNode} ${quotedCli} hook-run
|
|
1850
|
-
|
|
2093
|
+
`;
|
|
2094
|
+
const commandRun = `elif command -v diffowl >/dev/null 2>&1; then
|
|
1851
2095
|
diffowl hook-run
|
|
1852
|
-
|
|
2096
|
+
`;
|
|
2097
|
+
const runBlock = `${nodeCliRun}${diffowlPathFallback}${commandRun}else
|
|
1853
2098
|
echo "diffowl: review not started; diffowl command not found or not executable; log: $DIFFOWL_LOG_FILE"
|
|
1854
2099
|
echo "diffowl: review not started at $(date); diffowl command not found or not executable" >>"$DIFFOWL_LOG_FILE"
|
|
1855
2100
|
fi`;
|
|
@@ -3095,6 +3340,21 @@ import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
|
|
|
3095
3340
|
import { existsSync as existsSync4 } from "fs";
|
|
3096
3341
|
import { join as join7 } from "path";
|
|
3097
3342
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3343
|
+
var REPORT_SCHEMA_VERSION = 1;
|
|
3344
|
+
function formatFindingHeading(index, finding) {
|
|
3345
|
+
const ordinal = `Finding ${index + 1}`;
|
|
3346
|
+
if (!finding.durable) {
|
|
3347
|
+
return `#### ${ordinal}`;
|
|
3348
|
+
}
|
|
3349
|
+
const classification = formatFindingClassification(finding.durable);
|
|
3350
|
+
return `#### ${ordinal} (\`${finding.durable.id}\`) \u2014 ${classification}`;
|
|
3351
|
+
}
|
|
3352
|
+
function formatFindingClassification(durable) {
|
|
3353
|
+
if (durable.lifecycleSuppressed) {
|
|
3354
|
+
return `**suppressed (${durable.status})**`;
|
|
3355
|
+
}
|
|
3356
|
+
return `**${durable.classification}**`;
|
|
3357
|
+
}
|
|
3098
3358
|
function renderMarkdown(report) {
|
|
3099
3359
|
const lines = [];
|
|
3100
3360
|
lines.push("### Summary");
|
|
@@ -3105,7 +3365,7 @@ function renderMarkdown(report) {
|
|
|
3105
3365
|
lines.push("No issues were reported.");
|
|
3106
3366
|
} else {
|
|
3107
3367
|
for (const [index, finding] of report.findings.entries()) {
|
|
3108
|
-
lines.push(
|
|
3368
|
+
lines.push(formatFindingHeading(index, finding));
|
|
3109
3369
|
lines.push(`**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}**`);
|
|
3110
3370
|
lines.push(finding.title.trim());
|
|
3111
3371
|
lines.push("");
|
|
@@ -3120,10 +3380,10 @@ function renderMarkdown(report) {
|
|
|
3120
3380
|
if (report.suppressedFindings && report.suppressedFindings.length > 0) {
|
|
3121
3381
|
lines.push("");
|
|
3122
3382
|
lines.push("### Suppressed Findings");
|
|
3123
|
-
lines.push("These findings
|
|
3383
|
+
lines.push("These findings were excluded from the actionable review set.");
|
|
3124
3384
|
lines.push("");
|
|
3125
3385
|
for (const [index, finding] of report.suppressedFindings.entries()) {
|
|
3126
|
-
lines.push(
|
|
3386
|
+
lines.push(formatFindingHeading(report.findings.length + index, finding));
|
|
3127
3387
|
lines.push(
|
|
3128
3388
|
`**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}** (${finding.confidence} confidence)`
|
|
3129
3389
|
);
|
|
@@ -3177,10 +3437,22 @@ function parseReviewMetadata(content) {
|
|
|
3177
3437
|
if (!diffowl || typeof diffowl !== "object") return void 0;
|
|
3178
3438
|
const sessionId = diffowl.session_id;
|
|
3179
3439
|
const projectRoot = diffowl.project_root;
|
|
3440
|
+
const schemaVersion = diffowl.schema_version;
|
|
3441
|
+
const reviewId = diffowl.review_id;
|
|
3180
3442
|
if (typeof sessionId !== "string" || sessionId.trim() === "" || typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
3181
3443
|
return void 0;
|
|
3182
3444
|
}
|
|
3183
|
-
|
|
3445
|
+
const metadata = {
|
|
3446
|
+
session_id: sessionId,
|
|
3447
|
+
project_root: projectRoot
|
|
3448
|
+
};
|
|
3449
|
+
if (typeof schemaVersion === "number" && Number.isInteger(schemaVersion) && schemaVersion > 0) {
|
|
3450
|
+
metadata.schema_version = schemaVersion;
|
|
3451
|
+
}
|
|
3452
|
+
if (typeof reviewId === "string" && reviewId.trim() !== "") {
|
|
3453
|
+
metadata.review_id = reviewId;
|
|
3454
|
+
}
|
|
3455
|
+
return metadata;
|
|
3184
3456
|
}
|
|
3185
3457
|
function renderReviewFrontmatter(metadata) {
|
|
3186
3458
|
return `---
|
|
@@ -3331,107 +3603,1628 @@ async function listMarkdownFiles(dir) {
|
|
|
3331
3603
|
return reports.filter((path) => path !== void 0);
|
|
3332
3604
|
}
|
|
3333
3605
|
|
|
3334
|
-
// src/
|
|
3335
|
-
import {
|
|
3336
|
-
import { basename as basename5, dirname as dirname4 } from "path";
|
|
3337
|
-
import { execa as execa5 } from "execa";
|
|
3606
|
+
// src/state/persist.ts
|
|
3607
|
+
import { createHash as createHash2 } from "crypto";
|
|
3338
3608
|
|
|
3339
|
-
//
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
description: "Local AI code review agent powered by OpenCode",
|
|
3344
|
-
keywords: [
|
|
3345
|
-
"ai",
|
|
3346
|
-
"code-review",
|
|
3347
|
-
"git",
|
|
3348
|
-
"opencode",
|
|
3349
|
-
"pre-commit"
|
|
3350
|
-
],
|
|
3351
|
-
homepage: "https://github.com/gutierrezje/diffowl#readme",
|
|
3352
|
-
bugs: {
|
|
3353
|
-
url: "https://github.com/gutierrezje/diffowl/issues"
|
|
3354
|
-
},
|
|
3355
|
-
license: "MIT",
|
|
3356
|
-
repository: {
|
|
3357
|
-
type: "git",
|
|
3358
|
-
url: "git+https://github.com/gutierrezje/diffowl.git"
|
|
3359
|
-
},
|
|
3360
|
-
bin: {
|
|
3361
|
-
diffowl: "dist/cli.js"
|
|
3362
|
-
},
|
|
3363
|
-
files: [
|
|
3364
|
-
"dist"
|
|
3365
|
-
],
|
|
3366
|
-
type: "module",
|
|
3367
|
-
scripts: {
|
|
3368
|
-
build: "tsup",
|
|
3369
|
-
dev: "tsup --watch",
|
|
3370
|
-
test: "vitest run",
|
|
3371
|
-
typecheck: "tsc --noEmit",
|
|
3372
|
-
lint: "oxlint . && pnpm run typecheck",
|
|
3373
|
-
format: "oxfmt --write .",
|
|
3374
|
-
"format:check": "oxfmt --check .",
|
|
3375
|
-
prepack: "npm run build"
|
|
3376
|
-
},
|
|
3377
|
-
dependencies: {
|
|
3378
|
-
"@opencode-ai/sdk": "^1.15.11",
|
|
3379
|
-
chalk: "^5.6.2",
|
|
3380
|
-
commander: "^14.0.3",
|
|
3381
|
-
execa: "^9.6.1",
|
|
3382
|
-
ora: "^9.4.0",
|
|
3383
|
-
picomatch: "^4.0.4",
|
|
3384
|
-
yaml: "^2.9.0",
|
|
3385
|
-
zod: "^4.4.3"
|
|
3386
|
-
},
|
|
3387
|
-
devDependencies: {
|
|
3388
|
-
"@types/node": "^25.9.1",
|
|
3389
|
-
"@types/picomatch": "^4.0.3",
|
|
3390
|
-
oxfmt: "^0.52.0",
|
|
3391
|
-
oxlint: "^1.67.0",
|
|
3392
|
-
tsup: "^8.5.1",
|
|
3393
|
-
typescript: "^6.0.3",
|
|
3394
|
-
vitest: "^4.1.7"
|
|
3395
|
-
},
|
|
3396
|
-
engines: {
|
|
3397
|
-
node: ">=20"
|
|
3398
|
-
},
|
|
3399
|
-
packageManager: "pnpm@10.15.1"
|
|
3400
|
-
};
|
|
3609
|
+
// src/state/db.ts
|
|
3610
|
+
import { mkdir as mkdir4 } from "fs/promises";
|
|
3611
|
+
import { join as join9 } from "path";
|
|
3612
|
+
import Database from "better-sqlite3";
|
|
3401
3613
|
|
|
3402
|
-
// src/
|
|
3403
|
-
var
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3614
|
+
// src/state/migrations/001-initial-schema.ts
|
|
3615
|
+
var MIGRATION_001_INITIAL_SCHEMA = `
|
|
3616
|
+
CREATE TABLE schema_migrations (
|
|
3617
|
+
version INTEGER PRIMARY KEY,
|
|
3618
|
+
applied_at TEXT NOT NULL
|
|
3619
|
+
);
|
|
3620
|
+
|
|
3621
|
+
CREATE TABLE reviews (
|
|
3622
|
+
id TEXT PRIMARY KEY,
|
|
3623
|
+
created_at TEXT NOT NULL,
|
|
3624
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('staged', 'commit', 'last-commit')),
|
|
3625
|
+
target_ref TEXT,
|
|
3626
|
+
target_commit TEXT,
|
|
3627
|
+
diff_hash TEXT NOT NULL,
|
|
3628
|
+
model TEXT NOT NULL,
|
|
3629
|
+
reasoning TEXT NOT NULL,
|
|
3630
|
+
depth TEXT NOT NULL,
|
|
3631
|
+
session_id TEXT NOT NULL,
|
|
3632
|
+
summary TEXT NOT NULL,
|
|
3633
|
+
report_path TEXT,
|
|
3634
|
+
diagnostics_json TEXT NOT NULL DEFAULT '[]',
|
|
3635
|
+
timings_json TEXT NOT NULL DEFAULT '[]',
|
|
3636
|
+
skipped_reason TEXT
|
|
3637
|
+
);
|
|
3638
|
+
|
|
3639
|
+
CREATE TABLE findings (
|
|
3640
|
+
id TEXT PRIMARY KEY,
|
|
3641
|
+
fingerprint TEXT NOT NULL UNIQUE,
|
|
3642
|
+
status TEXT NOT NULL CHECK (status IN ('open', 'deferred', 'dismissed', 'fixed', 'regressed')),
|
|
3643
|
+
first_review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3644
|
+
last_review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3645
|
+
created_at TEXT NOT NULL,
|
|
3646
|
+
updated_at TEXT NOT NULL
|
|
3647
|
+
);
|
|
3648
|
+
|
|
3649
|
+
CREATE INDEX idx_findings_status ON findings(status);
|
|
3650
|
+
|
|
3651
|
+
CREATE TABLE finding_observations (
|
|
3652
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3653
|
+
review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3654
|
+
finding_id TEXT NOT NULL REFERENCES findings(id),
|
|
3655
|
+
file TEXT NOT NULL,
|
|
3656
|
+
line INTEGER NOT NULL,
|
|
3657
|
+
severity TEXT NOT NULL CHECK (severity IN ('error', 'warning', 'info')),
|
|
3658
|
+
confidence TEXT NOT NULL CHECK (confidence IN ('low', 'medium', 'high')),
|
|
3659
|
+
title TEXT NOT NULL,
|
|
3660
|
+
body TEXT NOT NULL,
|
|
3661
|
+
evidence TEXT,
|
|
3662
|
+
ordinal INTEGER NOT NULL,
|
|
3663
|
+
classification TEXT NOT NULL CHECK (classification IN ('new', 'existing', 'regressed')),
|
|
3664
|
+
UNIQUE (review_id, finding_id)
|
|
3665
|
+
);
|
|
3666
|
+
|
|
3667
|
+
CREATE TABLE finding_events (
|
|
3668
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3669
|
+
finding_id TEXT NOT NULL REFERENCES findings(id),
|
|
3670
|
+
review_id TEXT REFERENCES reviews(id),
|
|
3671
|
+
event_type TEXT NOT NULL CHECK (
|
|
3672
|
+
event_type IN ('observed', 'dismissed', 'deferred', 'fixed', 'reopened', 'regressed')
|
|
3673
|
+
),
|
|
3674
|
+
actor TEXT NOT NULL CHECK (actor IN ('user', 'agent')),
|
|
3675
|
+
reason TEXT,
|
|
3676
|
+
commit_ref TEXT,
|
|
3677
|
+
verification_json TEXT,
|
|
3678
|
+
created_at TEXT NOT NULL
|
|
3679
|
+
);
|
|
3680
|
+
|
|
3681
|
+
CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
|
|
3682
|
+
`;
|
|
3683
|
+
|
|
3684
|
+
// src/state/types.ts
|
|
3685
|
+
import { randomUUID } from "crypto";
|
|
3686
|
+
var CURRENT_SCHEMA_VERSION = 1;
|
|
3687
|
+
function createReviewId() {
|
|
3688
|
+
return `rev_${randomUUID()}`;
|
|
3689
|
+
}
|
|
3690
|
+
function createFindingId() {
|
|
3691
|
+
return `fnd_${randomUUID()}`;
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// src/state/db.ts
|
|
3695
|
+
var BUSY_TIMEOUT_MS = 5e3;
|
|
3696
|
+
var MIGRATIONS = {
|
|
3697
|
+
1: MIGRATION_001_INITIAL_SCHEMA
|
|
3698
|
+
};
|
|
3699
|
+
var StateDatabaseError = class extends Error {
|
|
3700
|
+
name = "StateDatabaseError";
|
|
3701
|
+
};
|
|
3702
|
+
var InvalidFindingTransitionError = class extends StateDatabaseError {
|
|
3703
|
+
name = "InvalidFindingTransitionError";
|
|
3704
|
+
};
|
|
3705
|
+
function getStateDbPath(diffOwlDir) {
|
|
3706
|
+
return join9(diffOwlDir, "state.db");
|
|
3707
|
+
}
|
|
3708
|
+
async function openStateDatabase(diffOwlDir) {
|
|
3709
|
+
await mkdir4(diffOwlDir, { recursive: true });
|
|
3710
|
+
const path = getStateDbPath(diffOwlDir);
|
|
3711
|
+
const db = new Database(path);
|
|
3712
|
+
try {
|
|
3713
|
+
configureDatabase(db);
|
|
3714
|
+
assertCompatibleSchema(db);
|
|
3715
|
+
applyMigrations(db, CURRENT_SCHEMA_VERSION);
|
|
3716
|
+
return { db, path };
|
|
3717
|
+
} catch (error) {
|
|
3718
|
+
try {
|
|
3719
|
+
closeDatabaseConnection(db);
|
|
3720
|
+
} catch {
|
|
3721
|
+
}
|
|
3722
|
+
throw error;
|
|
3413
3723
|
}
|
|
3414
|
-
|
|
3415
|
-
|
|
3724
|
+
}
|
|
3725
|
+
function closeDatabaseConnection(db) {
|
|
3726
|
+
if (!db.open) {
|
|
3727
|
+
return;
|
|
3416
3728
|
}
|
|
3417
|
-
|
|
3418
|
-
|
|
3729
|
+
try {
|
|
3730
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
3731
|
+
} finally {
|
|
3732
|
+
db.close();
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
function closeStateDatabase(state) {
|
|
3736
|
+
closeDatabaseConnection(state.db);
|
|
3737
|
+
}
|
|
3738
|
+
function configureDatabase(db) {
|
|
3739
|
+
db.pragma("journal_mode = WAL");
|
|
3740
|
+
db.pragma("foreign_keys = ON");
|
|
3741
|
+
db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
3742
|
+
}
|
|
3743
|
+
function assertCompatibleSchema(db) {
|
|
3744
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
3745
|
+
if (!table) {
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
const row = db.prepare("SELECT MAX(version) AS maxVersion FROM schema_migrations").get();
|
|
3749
|
+
const maxVersion = row?.maxVersion ?? 0;
|
|
3750
|
+
if (maxVersion > CURRENT_SCHEMA_VERSION) {
|
|
3751
|
+
throw new StateDatabaseError(
|
|
3752
|
+
`Database schema version ${maxVersion} is newer than supported version ${CURRENT_SCHEMA_VERSION}`
|
|
3753
|
+
);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
function applyMigrations(db, targetVersion, migrations = MIGRATIONS) {
|
|
3757
|
+
assertCompatibleSchema(db);
|
|
3758
|
+
const appliedVersions = listAppliedMigrationVersions(db);
|
|
3759
|
+
for (let version = 1; version <= targetVersion; version++) {
|
|
3760
|
+
if (appliedVersions.includes(version)) {
|
|
3761
|
+
continue;
|
|
3762
|
+
}
|
|
3763
|
+
const sql = migrations[version];
|
|
3764
|
+
if (!sql) {
|
|
3765
|
+
throw new StateDatabaseError(`Missing migration for schema version ${version}`);
|
|
3766
|
+
}
|
|
3767
|
+
const migrate = db.transaction(() => {
|
|
3768
|
+
db.exec(sql);
|
|
3769
|
+
db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(
|
|
3770
|
+
version,
|
|
3771
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
3772
|
+
);
|
|
3773
|
+
});
|
|
3774
|
+
migrate();
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
function listAppliedMigrationVersions(db) {
|
|
3778
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
3779
|
+
if (!table) {
|
|
3780
|
+
return [];
|
|
3781
|
+
}
|
|
3782
|
+
const rows = db.prepare("SELECT version FROM schema_migrations ORDER BY version ASC").all();
|
|
3783
|
+
return rows.map((row) => row.version);
|
|
3784
|
+
}
|
|
3785
|
+
function runInTransaction(db, fn) {
|
|
3786
|
+
const transaction = db.transaction(fn);
|
|
3787
|
+
return transaction();
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// src/state/fingerprint.ts
|
|
3791
|
+
import { createHash } from "crypto";
|
|
3792
|
+
var FINGERPRINT_VERSION = 1;
|
|
3793
|
+
function normalizeFingerprintText(text) {
|
|
3794
|
+
return text.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " ");
|
|
3795
|
+
}
|
|
3796
|
+
function computeFindingFingerprint(input) {
|
|
3797
|
+
const file = normalizeFingerprintText(input.file);
|
|
3798
|
+
const title = normalizeFingerprintText(input.title);
|
|
3799
|
+
const identitySource = input.evidence?.trim() ? input.evidence : input.body;
|
|
3800
|
+
const identity = normalizeFingerprintText(identitySource);
|
|
3801
|
+
const payload = `v${FINGERPRINT_VERSION}|${file}|${title}|${identity}`;
|
|
3802
|
+
const digest = createHash("sha256").update(payload, "utf8").digest("hex");
|
|
3803
|
+
return `v${FINGERPRINT_VERSION}:${digest}`;
|
|
3804
|
+
}
|
|
3805
|
+
function deduplicateFindingCandidates(candidates) {
|
|
3806
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3807
|
+
const deduped = [];
|
|
3808
|
+
for (const candidate of candidates) {
|
|
3809
|
+
const fingerprint = computeFindingFingerprint(candidate);
|
|
3810
|
+
if (seen.has(fingerprint)) {
|
|
3811
|
+
continue;
|
|
3812
|
+
}
|
|
3813
|
+
seen.add(fingerprint);
|
|
3814
|
+
deduped.push(candidate);
|
|
3815
|
+
}
|
|
3816
|
+
return deduped;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
// src/state/repositories/events.ts
|
|
3820
|
+
var insertEventStatement = (db) => db.prepare(`
|
|
3821
|
+
INSERT INTO finding_events (
|
|
3822
|
+
finding_id,
|
|
3823
|
+
review_id,
|
|
3824
|
+
event_type,
|
|
3825
|
+
actor,
|
|
3826
|
+
reason,
|
|
3827
|
+
commit_ref,
|
|
3828
|
+
verification_json,
|
|
3829
|
+
created_at
|
|
3830
|
+
) VALUES (
|
|
3831
|
+
@findingId,
|
|
3832
|
+
@reviewId,
|
|
3833
|
+
@eventType,
|
|
3834
|
+
@actor,
|
|
3835
|
+
@reason,
|
|
3836
|
+
@commitRef,
|
|
3837
|
+
@verificationJson,
|
|
3838
|
+
@createdAt
|
|
3839
|
+
)
|
|
3840
|
+
`);
|
|
3841
|
+
var getEventStatement = (db) => db.prepare(`
|
|
3842
|
+
SELECT
|
|
3843
|
+
id,
|
|
3844
|
+
finding_id AS findingId,
|
|
3845
|
+
review_id AS reviewId,
|
|
3846
|
+
event_type AS eventType,
|
|
3847
|
+
actor,
|
|
3848
|
+
reason,
|
|
3849
|
+
commit_ref AS commitRef,
|
|
3850
|
+
verification_json AS verificationJson,
|
|
3851
|
+
created_at AS createdAt
|
|
3852
|
+
FROM finding_events
|
|
3853
|
+
WHERE id = ?
|
|
3854
|
+
`);
|
|
3855
|
+
function insertFindingEvent(db, input) {
|
|
3856
|
+
const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3857
|
+
const result = insertEventStatement(db).run({
|
|
3858
|
+
findingId: input.findingId,
|
|
3859
|
+
reviewId: input.reviewId ?? null,
|
|
3860
|
+
eventType: input.eventType,
|
|
3861
|
+
actor: input.actor,
|
|
3862
|
+
reason: input.reason ?? null,
|
|
3863
|
+
commitRef: input.commitRef ?? null,
|
|
3864
|
+
verificationJson: JSON.stringify(input.verification ?? []),
|
|
3865
|
+
createdAt
|
|
3866
|
+
});
|
|
3867
|
+
const row = getEventStatement(db).get(Number(result.lastInsertRowid));
|
|
3868
|
+
if (!row) {
|
|
3869
|
+
throw new Error(`Failed to load finding event ${String(result.lastInsertRowid)}.`);
|
|
3870
|
+
}
|
|
3871
|
+
return mapEventRow(row);
|
|
3872
|
+
}
|
|
3873
|
+
function listFindingEvents(db, findingId) {
|
|
3874
|
+
const rows = db.prepare(`
|
|
3875
|
+
SELECT
|
|
3876
|
+
id,
|
|
3877
|
+
finding_id AS findingId,
|
|
3878
|
+
review_id AS reviewId,
|
|
3879
|
+
event_type AS eventType,
|
|
3880
|
+
actor,
|
|
3881
|
+
reason,
|
|
3882
|
+
commit_ref AS commitRef,
|
|
3883
|
+
verification_json AS verificationJson,
|
|
3884
|
+
created_at AS createdAt
|
|
3885
|
+
FROM finding_events
|
|
3886
|
+
WHERE finding_id = ?
|
|
3887
|
+
ORDER BY id ASC
|
|
3888
|
+
`).all(findingId);
|
|
3889
|
+
return rows.map(mapEventRow);
|
|
3890
|
+
}
|
|
3891
|
+
function mapEventRow(row) {
|
|
3892
|
+
return {
|
|
3893
|
+
id: row.id,
|
|
3894
|
+
findingId: row.findingId,
|
|
3895
|
+
reviewId: row.reviewId,
|
|
3896
|
+
eventType: row.eventType,
|
|
3897
|
+
actor: row.actor,
|
|
3898
|
+
reason: row.reason,
|
|
3899
|
+
commitRef: row.commitRef,
|
|
3900
|
+
verification: JSON.parse(row.verificationJson),
|
|
3901
|
+
createdAt: row.createdAt
|
|
3902
|
+
};
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// src/state/repositories/findings.ts
|
|
3906
|
+
var insertFindingStatement = (db) => db.prepare(`
|
|
3907
|
+
INSERT INTO findings (
|
|
3908
|
+
id,
|
|
3909
|
+
fingerprint,
|
|
3910
|
+
status,
|
|
3911
|
+
first_review_id,
|
|
3912
|
+
last_review_id,
|
|
3913
|
+
created_at,
|
|
3914
|
+
updated_at
|
|
3915
|
+
) VALUES (
|
|
3916
|
+
@id,
|
|
3917
|
+
@fingerprint,
|
|
3918
|
+
@status,
|
|
3919
|
+
@firstReviewId,
|
|
3920
|
+
@lastReviewId,
|
|
3921
|
+
@createdAt,
|
|
3922
|
+
@updatedAt
|
|
3923
|
+
)
|
|
3924
|
+
`);
|
|
3925
|
+
var getFindingByIdStatement = (db) => db.prepare(`
|
|
3926
|
+
SELECT
|
|
3927
|
+
id,
|
|
3928
|
+
fingerprint,
|
|
3929
|
+
status,
|
|
3930
|
+
first_review_id AS firstReviewId,
|
|
3931
|
+
last_review_id AS lastReviewId,
|
|
3932
|
+
created_at AS createdAt,
|
|
3933
|
+
updated_at AS updatedAt
|
|
3934
|
+
FROM findings
|
|
3935
|
+
WHERE id = ?
|
|
3936
|
+
`);
|
|
3937
|
+
var getFindingByFingerprintStatement = (db) => db.prepare(`
|
|
3938
|
+
SELECT
|
|
3939
|
+
id,
|
|
3940
|
+
fingerprint,
|
|
3941
|
+
status,
|
|
3942
|
+
first_review_id AS firstReviewId,
|
|
3943
|
+
last_review_id AS lastReviewId,
|
|
3944
|
+
created_at AS createdAt,
|
|
3945
|
+
updated_at AS updatedAt
|
|
3946
|
+
FROM findings
|
|
3947
|
+
WHERE fingerprint = ?
|
|
3948
|
+
`);
|
|
3949
|
+
function insertFinding(db, input) {
|
|
3950
|
+
const timestamp = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3951
|
+
const record = {
|
|
3952
|
+
id: input.id ?? createFindingId(),
|
|
3953
|
+
fingerprint: input.fingerprint,
|
|
3954
|
+
status: input.status,
|
|
3955
|
+
firstReviewId: input.firstReviewId,
|
|
3956
|
+
lastReviewId: input.lastReviewId,
|
|
3957
|
+
createdAt: timestamp,
|
|
3958
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
3959
|
+
};
|
|
3960
|
+
insertFindingStatement(db).run({
|
|
3961
|
+
id: record.id,
|
|
3962
|
+
fingerprint: record.fingerprint,
|
|
3963
|
+
status: record.status,
|
|
3964
|
+
firstReviewId: record.firstReviewId,
|
|
3965
|
+
lastReviewId: record.lastReviewId,
|
|
3966
|
+
createdAt: record.createdAt,
|
|
3967
|
+
updatedAt: record.updatedAt
|
|
3968
|
+
});
|
|
3969
|
+
return record;
|
|
3970
|
+
}
|
|
3971
|
+
function getFindingById(db, id) {
|
|
3972
|
+
const row = getFindingByIdStatement(db).get(id);
|
|
3973
|
+
return row;
|
|
3974
|
+
}
|
|
3975
|
+
function getFindingByFingerprint(db, fingerprint) {
|
|
3976
|
+
const row = getFindingByFingerprintStatement(db).get(fingerprint);
|
|
3977
|
+
return row;
|
|
3978
|
+
}
|
|
3979
|
+
function listFindingsByStatuses(db, statuses) {
|
|
3980
|
+
if (statuses.length === 0) {
|
|
3981
|
+
return [];
|
|
3982
|
+
}
|
|
3983
|
+
const placeholders = statuses.map(() => "?").join(", ");
|
|
3984
|
+
return db.prepare(`
|
|
3985
|
+
SELECT
|
|
3986
|
+
id,
|
|
3987
|
+
fingerprint,
|
|
3988
|
+
status,
|
|
3989
|
+
first_review_id AS firstReviewId,
|
|
3990
|
+
last_review_id AS lastReviewId,
|
|
3991
|
+
created_at AS createdAt,
|
|
3992
|
+
updated_at AS updatedAt
|
|
3993
|
+
FROM findings
|
|
3994
|
+
WHERE status IN (${placeholders})
|
|
3995
|
+
ORDER BY updated_at DESC, id ASC
|
|
3996
|
+
`).all(...statuses);
|
|
3997
|
+
}
|
|
3998
|
+
function listAllFindings(db) {
|
|
3999
|
+
return db.prepare(`
|
|
4000
|
+
SELECT
|
|
4001
|
+
id,
|
|
4002
|
+
fingerprint,
|
|
4003
|
+
status,
|
|
4004
|
+
first_review_id AS firstReviewId,
|
|
4005
|
+
last_review_id AS lastReviewId,
|
|
4006
|
+
created_at AS createdAt,
|
|
4007
|
+
updated_at AS updatedAt
|
|
4008
|
+
FROM findings
|
|
4009
|
+
ORDER BY updated_at DESC, id ASC
|
|
4010
|
+
`).all();
|
|
4011
|
+
}
|
|
4012
|
+
var updateFindingStatement = (db) => db.prepare(`
|
|
4013
|
+
UPDATE findings
|
|
4014
|
+
SET
|
|
4015
|
+
status = @status,
|
|
4016
|
+
last_review_id = @lastReviewId,
|
|
4017
|
+
updated_at = @updatedAt
|
|
4018
|
+
WHERE id = @id
|
|
4019
|
+
`);
|
|
4020
|
+
function updateFinding(db, id, updates) {
|
|
4021
|
+
const existing = getFindingById(db, id);
|
|
4022
|
+
if (!existing) {
|
|
4023
|
+
throw new StateDatabaseError(`Finding ${id} was not found.`);
|
|
4024
|
+
}
|
|
4025
|
+
const updatedAt = updates.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4026
|
+
updateFindingStatement(db).run({
|
|
4027
|
+
id,
|
|
4028
|
+
status: updates.status,
|
|
4029
|
+
lastReviewId: updates.lastReviewId,
|
|
4030
|
+
updatedAt
|
|
4031
|
+
});
|
|
4032
|
+
return {
|
|
4033
|
+
...existing,
|
|
4034
|
+
status: updates.status,
|
|
4035
|
+
lastReviewId: updates.lastReviewId,
|
|
4036
|
+
updatedAt
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
// src/state/repositories/observations.ts
|
|
4041
|
+
var insertObservationStatement = (db) => db.prepare(`
|
|
4042
|
+
INSERT INTO finding_observations (
|
|
4043
|
+
review_id,
|
|
4044
|
+
finding_id,
|
|
4045
|
+
file,
|
|
4046
|
+
line,
|
|
4047
|
+
severity,
|
|
4048
|
+
confidence,
|
|
4049
|
+
title,
|
|
4050
|
+
body,
|
|
4051
|
+
evidence,
|
|
4052
|
+
ordinal,
|
|
4053
|
+
classification
|
|
4054
|
+
) VALUES (
|
|
4055
|
+
@reviewId,
|
|
4056
|
+
@findingId,
|
|
4057
|
+
@file,
|
|
4058
|
+
@line,
|
|
4059
|
+
@severity,
|
|
4060
|
+
@confidence,
|
|
4061
|
+
@title,
|
|
4062
|
+
@body,
|
|
4063
|
+
@evidence,
|
|
4064
|
+
@ordinal,
|
|
4065
|
+
@classification
|
|
4066
|
+
)
|
|
4067
|
+
`);
|
|
4068
|
+
var getObservationStatement = (db) => db.prepare(`
|
|
4069
|
+
SELECT
|
|
4070
|
+
id,
|
|
4071
|
+
review_id AS reviewId,
|
|
4072
|
+
finding_id AS findingId,
|
|
4073
|
+
file,
|
|
4074
|
+
line,
|
|
4075
|
+
severity,
|
|
4076
|
+
confidence,
|
|
4077
|
+
title,
|
|
4078
|
+
body,
|
|
4079
|
+
evidence,
|
|
4080
|
+
ordinal,
|
|
4081
|
+
classification
|
|
4082
|
+
FROM finding_observations
|
|
4083
|
+
WHERE review_id = ? AND finding_id = ?
|
|
4084
|
+
`);
|
|
4085
|
+
function insertObservation(db, input) {
|
|
4086
|
+
insertObservationStatement(db).run({
|
|
4087
|
+
reviewId: input.reviewId,
|
|
4088
|
+
findingId: input.findingId,
|
|
4089
|
+
file: input.file,
|
|
4090
|
+
line: input.line,
|
|
4091
|
+
severity: input.severity,
|
|
4092
|
+
confidence: input.confidence,
|
|
4093
|
+
title: input.title,
|
|
4094
|
+
body: input.body,
|
|
4095
|
+
evidence: input.evidence ?? null,
|
|
4096
|
+
ordinal: input.ordinal,
|
|
4097
|
+
classification: input.classification
|
|
4098
|
+
});
|
|
4099
|
+
const observation = getObservationStatement(db).get(input.reviewId, input.findingId);
|
|
4100
|
+
if (!observation) {
|
|
4101
|
+
throw new Error(`Failed to load observation for review ${input.reviewId}.`);
|
|
4102
|
+
}
|
|
4103
|
+
return observation;
|
|
4104
|
+
}
|
|
4105
|
+
function listObservationsForReview(db, reviewId) {
|
|
4106
|
+
return db.prepare(`
|
|
4107
|
+
SELECT
|
|
4108
|
+
id,
|
|
4109
|
+
review_id AS reviewId,
|
|
4110
|
+
finding_id AS findingId,
|
|
4111
|
+
file,
|
|
4112
|
+
line,
|
|
4113
|
+
severity,
|
|
4114
|
+
confidence,
|
|
4115
|
+
title,
|
|
4116
|
+
body,
|
|
4117
|
+
evidence,
|
|
4118
|
+
ordinal,
|
|
4119
|
+
classification
|
|
4120
|
+
FROM finding_observations
|
|
4121
|
+
WHERE review_id = ?
|
|
4122
|
+
ORDER BY ordinal ASC
|
|
4123
|
+
`).all(reviewId);
|
|
4124
|
+
}
|
|
4125
|
+
function countObservationsByFindingIds(db, findingIds) {
|
|
4126
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4127
|
+
if (findingIds.length === 0) {
|
|
4128
|
+
return counts;
|
|
4129
|
+
}
|
|
4130
|
+
const placeholders = findingIds.map(() => "?").join(", ");
|
|
4131
|
+
const rows = db.prepare(`
|
|
4132
|
+
SELECT finding_id AS findingId, COUNT(*) AS count
|
|
4133
|
+
FROM finding_observations
|
|
4134
|
+
WHERE finding_id IN (${placeholders})
|
|
4135
|
+
GROUP BY finding_id
|
|
4136
|
+
`).all(...findingIds);
|
|
4137
|
+
for (const row of rows) {
|
|
4138
|
+
counts.set(row.findingId, row.count);
|
|
4139
|
+
}
|
|
4140
|
+
return counts;
|
|
4141
|
+
}
|
|
4142
|
+
function getLatestObservationForFinding(db, findingId) {
|
|
4143
|
+
return db.prepare(`
|
|
4144
|
+
SELECT
|
|
4145
|
+
id,
|
|
4146
|
+
review_id AS reviewId,
|
|
4147
|
+
finding_id AS findingId,
|
|
4148
|
+
file,
|
|
4149
|
+
line,
|
|
4150
|
+
severity,
|
|
4151
|
+
confidence,
|
|
4152
|
+
title,
|
|
4153
|
+
body,
|
|
4154
|
+
evidence,
|
|
4155
|
+
ordinal,
|
|
4156
|
+
classification
|
|
4157
|
+
FROM finding_observations
|
|
4158
|
+
WHERE finding_id = ?
|
|
4159
|
+
ORDER BY id DESC
|
|
4160
|
+
LIMIT 1
|
|
4161
|
+
`).get(findingId);
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// src/state/reconcile.ts
|
|
4165
|
+
function reconcileReviewFindings(db, reviewId, candidates) {
|
|
4166
|
+
const observations = [];
|
|
4167
|
+
const suppressedCounts = { dismissed: 0, deferred: 0 };
|
|
4168
|
+
const uniqueCandidates = deduplicateFindingCandidates(candidates);
|
|
4169
|
+
for (const [index, candidate] of uniqueCandidates.entries()) {
|
|
4170
|
+
const fingerprint = computeFindingFingerprint(candidate);
|
|
4171
|
+
const existing = getFindingByFingerprint(db, fingerprint);
|
|
4172
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
4173
|
+
let finding = existing;
|
|
4174
|
+
let classification;
|
|
4175
|
+
let suppressed = false;
|
|
4176
|
+
if (!finding) {
|
|
4177
|
+
finding = insertFinding(db, {
|
|
4178
|
+
fingerprint,
|
|
4179
|
+
status: "open",
|
|
4180
|
+
firstReviewId: reviewId,
|
|
4181
|
+
lastReviewId: reviewId,
|
|
4182
|
+
createdAt: timestamp,
|
|
4183
|
+
updatedAt: timestamp
|
|
4184
|
+
});
|
|
4185
|
+
classification = "new";
|
|
4186
|
+
} else if (finding.status === "open" || finding.status === "regressed") {
|
|
4187
|
+
finding = updateFinding(db, finding.id, {
|
|
4188
|
+
status: finding.status,
|
|
4189
|
+
lastReviewId: reviewId,
|
|
4190
|
+
updatedAt: timestamp
|
|
4191
|
+
});
|
|
4192
|
+
classification = "existing";
|
|
4193
|
+
} else if (finding.status === "deferred" || finding.status === "dismissed") {
|
|
4194
|
+
finding = updateFinding(db, finding.id, {
|
|
4195
|
+
status: finding.status,
|
|
4196
|
+
lastReviewId: reviewId,
|
|
4197
|
+
updatedAt: timestamp
|
|
4198
|
+
});
|
|
4199
|
+
classification = "existing";
|
|
4200
|
+
suppressed = true;
|
|
4201
|
+
if (finding.status === "dismissed") {
|
|
4202
|
+
suppressedCounts.dismissed++;
|
|
4203
|
+
} else {
|
|
4204
|
+
suppressedCounts.deferred++;
|
|
4205
|
+
}
|
|
4206
|
+
} else {
|
|
4207
|
+
finding = updateFinding(db, finding.id, {
|
|
4208
|
+
status: "regressed",
|
|
4209
|
+
lastReviewId: reviewId,
|
|
4210
|
+
updatedAt: timestamp
|
|
4211
|
+
});
|
|
4212
|
+
classification = "regressed";
|
|
4213
|
+
insertFindingEvent(db, {
|
|
4214
|
+
findingId: finding.id,
|
|
4215
|
+
reviewId,
|
|
4216
|
+
eventType: "regressed",
|
|
4217
|
+
actor: "agent",
|
|
4218
|
+
reason: "Finding reappeared after being marked fixed."
|
|
4219
|
+
});
|
|
4220
|
+
}
|
|
4221
|
+
insertFindingEvent(db, {
|
|
4222
|
+
findingId: finding.id,
|
|
4223
|
+
reviewId,
|
|
4224
|
+
eventType: "observed",
|
|
4225
|
+
actor: "agent"
|
|
4226
|
+
});
|
|
4227
|
+
const observation = insertObservation(db, {
|
|
4228
|
+
reviewId,
|
|
4229
|
+
findingId: finding.id,
|
|
4230
|
+
file: candidate.file,
|
|
4231
|
+
line: candidate.line,
|
|
4232
|
+
severity: candidate.severity,
|
|
4233
|
+
confidence: candidate.confidence,
|
|
4234
|
+
title: candidate.title,
|
|
4235
|
+
body: candidate.body,
|
|
4236
|
+
evidence: candidate.evidence ?? null,
|
|
4237
|
+
ordinal: index + 1,
|
|
4238
|
+
classification
|
|
4239
|
+
});
|
|
4240
|
+
observations.push({
|
|
4241
|
+
observation,
|
|
4242
|
+
finding,
|
|
4243
|
+
fingerprint,
|
|
4244
|
+
suppressed
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
return { observations, suppressedCounts };
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
// src/state/repositories/reviews.ts
|
|
4251
|
+
var insertReviewStatement = (db) => db.prepare(`
|
|
4252
|
+
INSERT INTO reviews (
|
|
4253
|
+
id,
|
|
4254
|
+
created_at,
|
|
4255
|
+
target_kind,
|
|
4256
|
+
target_ref,
|
|
4257
|
+
target_commit,
|
|
4258
|
+
diff_hash,
|
|
4259
|
+
model,
|
|
4260
|
+
reasoning,
|
|
4261
|
+
depth,
|
|
4262
|
+
session_id,
|
|
4263
|
+
summary,
|
|
4264
|
+
report_path,
|
|
4265
|
+
diagnostics_json,
|
|
4266
|
+
timings_json,
|
|
4267
|
+
skipped_reason
|
|
4268
|
+
) VALUES (
|
|
4269
|
+
@id,
|
|
4270
|
+
@createdAt,
|
|
4271
|
+
@targetKind,
|
|
4272
|
+
@targetRef,
|
|
4273
|
+
@targetCommit,
|
|
4274
|
+
@diffHash,
|
|
4275
|
+
@model,
|
|
4276
|
+
@reasoning,
|
|
4277
|
+
@depth,
|
|
4278
|
+
@sessionId,
|
|
4279
|
+
@summary,
|
|
4280
|
+
@reportPath,
|
|
4281
|
+
@diagnosticsJson,
|
|
4282
|
+
@timingsJson,
|
|
4283
|
+
@skippedReason
|
|
4284
|
+
)
|
|
4285
|
+
`);
|
|
4286
|
+
var getReviewByIdStatement = (db) => db.prepare(`
|
|
4287
|
+
SELECT
|
|
4288
|
+
id,
|
|
4289
|
+
created_at AS createdAt,
|
|
4290
|
+
target_kind AS targetKind,
|
|
4291
|
+
target_ref AS targetRef,
|
|
4292
|
+
target_commit AS targetCommit,
|
|
4293
|
+
diff_hash AS diffHash,
|
|
4294
|
+
model,
|
|
4295
|
+
reasoning,
|
|
4296
|
+
depth,
|
|
4297
|
+
session_id AS sessionId,
|
|
4298
|
+
summary,
|
|
4299
|
+
report_path AS reportPath,
|
|
4300
|
+
diagnostics_json AS diagnosticsJson,
|
|
4301
|
+
timings_json AS timingsJson,
|
|
4302
|
+
skipped_reason AS skippedReason
|
|
4303
|
+
FROM reviews
|
|
4304
|
+
WHERE id = ?
|
|
4305
|
+
`);
|
|
4306
|
+
function insertReview(db, input) {
|
|
4307
|
+
const record = {
|
|
4308
|
+
id: input.id ?? createReviewId(),
|
|
4309
|
+
createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
4310
|
+
targetKind: input.targetKind,
|
|
4311
|
+
targetRef: input.targetRef ?? null,
|
|
4312
|
+
targetCommit: input.targetCommit ?? null,
|
|
4313
|
+
diffHash: input.diffHash,
|
|
4314
|
+
model: input.model,
|
|
4315
|
+
reasoning: input.reasoning,
|
|
4316
|
+
depth: input.depth,
|
|
4317
|
+
sessionId: input.sessionId,
|
|
4318
|
+
summary: input.summary,
|
|
4319
|
+
reportPath: input.reportPath ?? null,
|
|
4320
|
+
diagnostics: input.diagnostics ?? [],
|
|
4321
|
+
timings: input.timings ?? [],
|
|
4322
|
+
skippedReason: input.skippedReason ?? null
|
|
4323
|
+
};
|
|
4324
|
+
insertReviewStatement(db).run({
|
|
4325
|
+
id: record.id,
|
|
4326
|
+
createdAt: record.createdAt,
|
|
4327
|
+
targetKind: record.targetKind,
|
|
4328
|
+
targetRef: record.targetRef,
|
|
4329
|
+
targetCommit: record.targetCommit,
|
|
4330
|
+
diffHash: record.diffHash,
|
|
4331
|
+
model: record.model,
|
|
4332
|
+
reasoning: record.reasoning,
|
|
4333
|
+
depth: record.depth,
|
|
4334
|
+
sessionId: record.sessionId,
|
|
4335
|
+
summary: record.summary,
|
|
4336
|
+
reportPath: record.reportPath,
|
|
4337
|
+
diagnosticsJson: JSON.stringify(record.diagnostics),
|
|
4338
|
+
timingsJson: JSON.stringify(record.timings),
|
|
4339
|
+
skippedReason: record.skippedReason
|
|
4340
|
+
});
|
|
4341
|
+
return record;
|
|
4342
|
+
}
|
|
4343
|
+
function getReviewById(db, id) {
|
|
4344
|
+
const row = getReviewByIdStatement(db).get(id);
|
|
4345
|
+
if (!row) {
|
|
4346
|
+
return void 0;
|
|
4347
|
+
}
|
|
4348
|
+
return mapReviewRow(row);
|
|
4349
|
+
}
|
|
4350
|
+
function getLatestReview(db) {
|
|
4351
|
+
const row = db.prepare(`
|
|
4352
|
+
SELECT
|
|
4353
|
+
id,
|
|
4354
|
+
created_at AS createdAt,
|
|
4355
|
+
target_kind AS targetKind,
|
|
4356
|
+
target_ref AS targetRef,
|
|
4357
|
+
target_commit AS targetCommit,
|
|
4358
|
+
diff_hash AS diffHash,
|
|
4359
|
+
model,
|
|
4360
|
+
reasoning,
|
|
4361
|
+
depth,
|
|
4362
|
+
session_id AS sessionId,
|
|
4363
|
+
summary,
|
|
4364
|
+
report_path AS reportPath,
|
|
4365
|
+
diagnostics_json AS diagnosticsJson,
|
|
4366
|
+
timings_json AS timingsJson,
|
|
4367
|
+
skipped_reason AS skippedReason
|
|
4368
|
+
FROM reviews
|
|
4369
|
+
ORDER BY created_at DESC, id DESC
|
|
4370
|
+
LIMIT 1
|
|
4371
|
+
`).get();
|
|
4372
|
+
if (!row) {
|
|
4373
|
+
return void 0;
|
|
4374
|
+
}
|
|
4375
|
+
return mapReviewRow(row);
|
|
4376
|
+
}
|
|
4377
|
+
function updateReview(db, id, input) {
|
|
4378
|
+
const existing = getReviewById(db, id);
|
|
4379
|
+
if (!existing) {
|
|
4380
|
+
throw new StateDatabaseError(`Review ${id} was not found.`);
|
|
4381
|
+
}
|
|
4382
|
+
const record = {
|
|
4383
|
+
...existing,
|
|
4384
|
+
reportPath: input.reportPath === void 0 ? existing.reportPath : input.reportPath,
|
|
4385
|
+
diagnostics: input.diagnostics ?? existing.diagnostics
|
|
4386
|
+
};
|
|
4387
|
+
db.prepare(`
|
|
4388
|
+
UPDATE reviews
|
|
4389
|
+
SET report_path = @reportPath,
|
|
4390
|
+
diagnostics_json = @diagnosticsJson
|
|
4391
|
+
WHERE id = @id
|
|
4392
|
+
`).run({
|
|
4393
|
+
id: record.id,
|
|
4394
|
+
reportPath: record.reportPath,
|
|
4395
|
+
diagnosticsJson: JSON.stringify(record.diagnostics)
|
|
4396
|
+
});
|
|
4397
|
+
return record;
|
|
4398
|
+
}
|
|
4399
|
+
function mapReviewRow(row) {
|
|
4400
|
+
return {
|
|
4401
|
+
id: row.id,
|
|
4402
|
+
createdAt: row.createdAt,
|
|
4403
|
+
targetKind: row.targetKind,
|
|
4404
|
+
targetRef: row.targetRef,
|
|
4405
|
+
targetCommit: row.targetCommit,
|
|
4406
|
+
diffHash: row.diffHash,
|
|
4407
|
+
model: row.model,
|
|
4408
|
+
reasoning: row.reasoning,
|
|
4409
|
+
depth: row.depth,
|
|
4410
|
+
sessionId: row.sessionId,
|
|
4411
|
+
summary: row.summary,
|
|
4412
|
+
reportPath: row.reportPath,
|
|
4413
|
+
diagnostics: parseReviewJsonField(row.diagnosticsJson, "diagnostics_json", row.id),
|
|
4414
|
+
timings: parseReviewJsonField(row.timingsJson, "timings_json", row.id),
|
|
4415
|
+
skippedReason: row.skippedReason
|
|
4416
|
+
};
|
|
4417
|
+
}
|
|
4418
|
+
function parseReviewJsonField(raw, field, reviewId) {
|
|
4419
|
+
try {
|
|
4420
|
+
return JSON.parse(raw);
|
|
4421
|
+
} catch {
|
|
4422
|
+
throw new StateDatabaseError(`Review ${reviewId} contains invalid JSON in ${field}.`);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
// src/state/persist.ts
|
|
4427
|
+
function computeDiffHash(raw) {
|
|
4428
|
+
return createHash2("sha256").update(raw, "utf8").digest("hex");
|
|
4429
|
+
}
|
|
4430
|
+
function deduplicateReviewFindings(findings) {
|
|
4431
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4432
|
+
const deduped = [];
|
|
4433
|
+
for (const finding of findings) {
|
|
4434
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4435
|
+
if (seen.has(fingerprint)) {
|
|
4436
|
+
continue;
|
|
4437
|
+
}
|
|
4438
|
+
seen.add(fingerprint);
|
|
4439
|
+
deduped.push(finding);
|
|
4440
|
+
}
|
|
4441
|
+
return deduped;
|
|
4442
|
+
}
|
|
4443
|
+
function toFindingCandidate(finding) {
|
|
4444
|
+
const candidate = {
|
|
4445
|
+
file: finding.file,
|
|
4446
|
+
line: finding.line,
|
|
4447
|
+
severity: finding.severity,
|
|
4448
|
+
confidence: finding.confidence,
|
|
4449
|
+
title: finding.title,
|
|
4450
|
+
body: finding.body
|
|
4451
|
+
};
|
|
4452
|
+
if (finding.evidence !== void 0) {
|
|
4453
|
+
candidate.evidence = finding.evidence;
|
|
4454
|
+
}
|
|
4455
|
+
return candidate;
|
|
4456
|
+
}
|
|
4457
|
+
function formatLifecycleSuppressedSummary(counts) {
|
|
4458
|
+
const parts = [];
|
|
4459
|
+
if (counts.dismissed > 0) {
|
|
4460
|
+
parts.push(`${counts.dismissed} dismissed`);
|
|
4461
|
+
}
|
|
4462
|
+
if (counts.deferred > 0) {
|
|
4463
|
+
parts.push(`${counts.deferred} deferred`);
|
|
4464
|
+
}
|
|
4465
|
+
if (parts.length === 0) {
|
|
4466
|
+
return null;
|
|
4467
|
+
}
|
|
4468
|
+
return `Suppressed ${parts.join(" and ")} previously resolved finding(s).`;
|
|
4469
|
+
}
|
|
4470
|
+
function splitFindingsByLifecycleSuppression(findings, reconcile) {
|
|
4471
|
+
const fingerprintedFindings = fingerprintUniqueReviewFindings(findings);
|
|
4472
|
+
const findingsByFingerprint = /* @__PURE__ */ new Map();
|
|
4473
|
+
for (const { finding, fingerprint } of fingerprintedFindings) {
|
|
4474
|
+
findingsByFingerprint.set(fingerprint, finding);
|
|
4475
|
+
}
|
|
4476
|
+
const actionableFindings = [];
|
|
4477
|
+
const lifecycleSuppressedFindings = [];
|
|
4478
|
+
const matchedFingerprints = /* @__PURE__ */ new Set();
|
|
4479
|
+
for (const observation of reconcile.observations) {
|
|
4480
|
+
const finding = findingsByFingerprint.get(observation.fingerprint);
|
|
4481
|
+
if (!finding) {
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4484
|
+
matchedFingerprints.add(observation.fingerprint);
|
|
4485
|
+
if (observation.suppressed) {
|
|
4486
|
+
lifecycleSuppressedFindings.push(finding);
|
|
4487
|
+
} else {
|
|
4488
|
+
actionableFindings.push(finding);
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
for (const { finding, fingerprint } of fingerprintedFindings) {
|
|
4492
|
+
if (!matchedFingerprints.has(fingerprint)) {
|
|
4493
|
+
actionableFindings.push(finding);
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
return { actionableFindings, lifecycleSuppressedFindings };
|
|
4497
|
+
}
|
|
4498
|
+
function fingerprintUniqueReviewFindings(findings) {
|
|
4499
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4500
|
+
const fingerprinted = [];
|
|
4501
|
+
for (const finding of findings) {
|
|
4502
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4503
|
+
if (seen.has(fingerprint)) {
|
|
4504
|
+
continue;
|
|
4505
|
+
}
|
|
4506
|
+
seen.add(fingerprint);
|
|
4507
|
+
fingerprinted.push({ finding, fingerprint });
|
|
4508
|
+
}
|
|
4509
|
+
return fingerprinted;
|
|
4510
|
+
}
|
|
4511
|
+
function enrichReviewFindingsWithDurableMetadata(findings, reconcile) {
|
|
4512
|
+
const observationsByFingerprint = new Map(
|
|
4513
|
+
reconcile.observations.map((item) => [item.fingerprint, item])
|
|
4514
|
+
);
|
|
4515
|
+
return findings.map((finding) => {
|
|
4516
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4517
|
+
const observation = observationsByFingerprint.get(fingerprint);
|
|
4518
|
+
if (!observation) {
|
|
4519
|
+
return finding;
|
|
4520
|
+
}
|
|
4521
|
+
return {
|
|
4522
|
+
...finding,
|
|
4523
|
+
durable: {
|
|
4524
|
+
id: observation.finding.id,
|
|
4525
|
+
classification: observation.observation.classification,
|
|
4526
|
+
status: observation.finding.status,
|
|
4527
|
+
lifecycleSuppressed: observation.suppressed
|
|
4528
|
+
}
|
|
4529
|
+
};
|
|
4530
|
+
});
|
|
4531
|
+
}
|
|
4532
|
+
async function persistReviewRun(diffOwlDir, input) {
|
|
4533
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4534
|
+
try {
|
|
4535
|
+
return runInTransaction(state.db, () => {
|
|
4536
|
+
const review = insertReview(state.db, {
|
|
4537
|
+
targetKind: input.targetKind,
|
|
4538
|
+
targetRef: input.targetRef,
|
|
4539
|
+
targetCommit: input.targetCommit,
|
|
4540
|
+
diffHash: input.diffHash,
|
|
4541
|
+
model: input.model,
|
|
4542
|
+
reasoning: input.reasoning,
|
|
4543
|
+
depth: input.depth,
|
|
4544
|
+
sessionId: input.sessionId,
|
|
4545
|
+
summary: input.summary,
|
|
4546
|
+
diagnostics: input.diagnostics,
|
|
4547
|
+
timings: input.timings,
|
|
4548
|
+
skippedReason: input.skippedReason ?? null
|
|
4549
|
+
});
|
|
4550
|
+
const findings = deduplicateReviewFindings(input.findings);
|
|
4551
|
+
const candidates = findings.map(toFindingCandidate);
|
|
4552
|
+
const reconcile = reconcileReviewFindings(state.db, review.id, candidates);
|
|
4553
|
+
const { actionableFindings, lifecycleSuppressedFindings } = splitFindingsByLifecycleSuppression(findings, reconcile);
|
|
4554
|
+
return {
|
|
4555
|
+
reviewId: review.id,
|
|
4556
|
+
reconcile,
|
|
4557
|
+
actionableFindings,
|
|
4558
|
+
lifecycleSuppressedFindings
|
|
4559
|
+
};
|
|
4560
|
+
});
|
|
4561
|
+
} finally {
|
|
4562
|
+
closeStateDatabase(state);
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
async function updatePersistedReview(diffOwlDir, reviewId, input) {
|
|
4566
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4567
|
+
try {
|
|
4568
|
+
runInTransaction(state.db, () => {
|
|
4569
|
+
updateReview(state.db, reviewId, {
|
|
4570
|
+
...input.reportPath !== void 0 ? { reportPath: input.reportPath } : {},
|
|
4571
|
+
...input.diagnostics !== void 0 ? { diagnostics: input.diagnostics } : {}
|
|
4572
|
+
});
|
|
4573
|
+
});
|
|
4574
|
+
} finally {
|
|
4575
|
+
closeStateDatabase(state);
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
4578
|
+
async function getPersistedReview(diffOwlDir, reviewId) {
|
|
4579
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4580
|
+
try {
|
|
4581
|
+
return getReviewById(state.db, reviewId);
|
|
4582
|
+
} finally {
|
|
4583
|
+
closeStateDatabase(state);
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
async function loadFindingOccurrenceCounts(diffOwlDir, findingIds) {
|
|
4587
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4588
|
+
try {
|
|
4589
|
+
return countObservationsByFindingIds(state.db, findingIds);
|
|
4590
|
+
} finally {
|
|
4591
|
+
closeStateDatabase(state);
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
function mapReviewTarget(target) {
|
|
4595
|
+
switch (target.kind) {
|
|
4596
|
+
case "staged":
|
|
4597
|
+
return { targetKind: "staged", targetRef: null };
|
|
4598
|
+
case "last-commit":
|
|
4599
|
+
return { targetKind: "last-commit", targetRef: null };
|
|
4600
|
+
case "commit":
|
|
4601
|
+
return { targetKind: "commit", targetRef: target.ref ?? null };
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
// src/output/json.ts
|
|
4606
|
+
var JSON_OUTPUT_SCHEMA_VERSION = 1;
|
|
4607
|
+
function parseReviewOutputFormat(value) {
|
|
4608
|
+
if (value === void 0 || value === "text") {
|
|
4609
|
+
return "text";
|
|
4610
|
+
}
|
|
4611
|
+
if (value === "json") {
|
|
4612
|
+
return "json";
|
|
4613
|
+
}
|
|
4614
|
+
throw new Error(`Invalid output format: ${String(value)}. Expected text or json.`);
|
|
4615
|
+
}
|
|
4616
|
+
function buildReviewJsonDocument(input) {
|
|
4617
|
+
const observations = selectJsonObservations(
|
|
4618
|
+
input.persisted.reconcile.observations,
|
|
4619
|
+
input.verbose
|
|
4620
|
+
);
|
|
4621
|
+
const actionableCount = input.persisted.reconcile.observations.filter(
|
|
4622
|
+
(item) => !item.suppressed
|
|
4623
|
+
).length;
|
|
4624
|
+
return {
|
|
4625
|
+
schema_version: JSON_OUTPUT_SCHEMA_VERSION,
|
|
4626
|
+
review: {
|
|
4627
|
+
id: input.review.id,
|
|
4628
|
+
created_at: input.review.createdAt,
|
|
4629
|
+
target: {
|
|
4630
|
+
kind: input.review.targetKind,
|
|
4631
|
+
ref: input.review.targetRef,
|
|
4632
|
+
commit: input.review.targetCommit
|
|
4633
|
+
},
|
|
4634
|
+
model: input.review.model,
|
|
4635
|
+
reasoning: input.review.reasoning,
|
|
4636
|
+
depth: input.review.depth,
|
|
4637
|
+
session_id: input.review.sessionId,
|
|
4638
|
+
summary: input.review.summary,
|
|
4639
|
+
status: resolveReviewJsonStatus(input.review, actionableCount),
|
|
4640
|
+
report_path: input.review.reportPath,
|
|
4641
|
+
skipped_reason: input.review.skippedReason
|
|
4642
|
+
},
|
|
4643
|
+
findings: observations.map((item) => mapJsonFinding(item, input.occurrenceCounts)),
|
|
4644
|
+
suppressed: {
|
|
4645
|
+
lifecycle: input.persisted.reconcile.suppressedCounts,
|
|
4646
|
+
outside_changed_files: input.suppressed.outsideChangedFiles,
|
|
4647
|
+
below_confidence: input.suppressed.belowConfidence
|
|
4648
|
+
},
|
|
4649
|
+
diagnostics: input.review.diagnostics,
|
|
4650
|
+
timings: input.timings ?? input.review.timings
|
|
4651
|
+
};
|
|
4652
|
+
}
|
|
4653
|
+
function renderReviewJsonDocument(document) {
|
|
4654
|
+
return `${JSON.stringify(document)}
|
|
4655
|
+
`;
|
|
4656
|
+
}
|
|
4657
|
+
function renderJsonErrorDocument(message) {
|
|
4658
|
+
const document = {
|
|
4659
|
+
schema_version: JSON_OUTPUT_SCHEMA_VERSION,
|
|
4660
|
+
error: { message }
|
|
4661
|
+
};
|
|
4662
|
+
return `${JSON.stringify(document)}
|
|
4663
|
+
`;
|
|
4664
|
+
}
|
|
4665
|
+
function writeReviewJsonSuccess(document) {
|
|
4666
|
+
process.stdout.write(renderReviewJsonDocument(document));
|
|
4667
|
+
}
|
|
4668
|
+
function writeJsonError(message) {
|
|
4669
|
+
process.stderr.write(renderJsonErrorDocument(message));
|
|
4670
|
+
}
|
|
4671
|
+
function selectJsonObservations(observations, verbose = false) {
|
|
4672
|
+
if (verbose) {
|
|
4673
|
+
return observations;
|
|
4674
|
+
}
|
|
4675
|
+
return observations.filter((item) => !item.suppressed);
|
|
4676
|
+
}
|
|
4677
|
+
function resolveReviewJsonStatus(review, actionableCount) {
|
|
4678
|
+
if (review.skippedReason) {
|
|
4679
|
+
return "skipped";
|
|
4680
|
+
}
|
|
4681
|
+
return actionableCount > 0 ? "open" : "resolved";
|
|
4682
|
+
}
|
|
4683
|
+
function mapJsonFinding(item, occurrenceCounts) {
|
|
4684
|
+
const { observation, finding, fingerprint, suppressed } = item;
|
|
4685
|
+
return {
|
|
4686
|
+
id: finding.id,
|
|
4687
|
+
fingerprint,
|
|
4688
|
+
status: finding.status,
|
|
4689
|
+
classification: observation.classification,
|
|
4690
|
+
suppressed,
|
|
4691
|
+
location: {
|
|
4692
|
+
file: observation.file,
|
|
4693
|
+
line: observation.line
|
|
4694
|
+
},
|
|
4695
|
+
content: {
|
|
4696
|
+
title: observation.title,
|
|
4697
|
+
body: observation.body,
|
|
4698
|
+
evidence: observation.evidence
|
|
4699
|
+
},
|
|
4700
|
+
severity: observation.severity,
|
|
4701
|
+
confidence: observation.confidence,
|
|
4702
|
+
created_at: finding.createdAt,
|
|
4703
|
+
updated_at: finding.updatedAt,
|
|
4704
|
+
occurrence_count: occurrenceCounts.get(finding.id) ?? 1
|
|
4705
|
+
};
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
// src/output/findings.ts
|
|
4709
|
+
import chalk2 from "chalk";
|
|
4710
|
+
var ID_WIDTH = 12;
|
|
4711
|
+
var STATUS_WIDTH = 10;
|
|
4712
|
+
var SEVERITY_WIDTH = 8;
|
|
4713
|
+
var SEEN_WIDTH = 4;
|
|
4714
|
+
var COLUMN_GAP = 2;
|
|
4715
|
+
var MIN_LOCATION_WIDTH = 10;
|
|
4716
|
+
var MIN_TITLE_WIDTH = 12;
|
|
4717
|
+
var MAX_LOCATION_SHARE = 0.4;
|
|
4718
|
+
function shortenFindingId(id) {
|
|
4719
|
+
if (id.length <= 16) {
|
|
4720
|
+
return id;
|
|
4721
|
+
}
|
|
4722
|
+
return id.slice(0, 12);
|
|
4723
|
+
}
|
|
4724
|
+
function formatFindingList(items, options = {}) {
|
|
4725
|
+
if (items.length === 0) {
|
|
4726
|
+
return "";
|
|
4727
|
+
}
|
|
4728
|
+
const columns = options.columns ?? process.stdout.columns ?? 100;
|
|
4729
|
+
const color = options.color ?? chalk2.level > 0;
|
|
4730
|
+
const layout = computeListLayout(columns);
|
|
4731
|
+
const lines = [];
|
|
4732
|
+
lines.push(
|
|
4733
|
+
color ? chalk2.bold(`Open findings: ${items.length}`) : `Open findings: ${items.length}`
|
|
4734
|
+
);
|
|
4735
|
+
lines.push(
|
|
4736
|
+
color ? chalk2.dim("Durable backlog; absence from later reviews does not auto-fix findings.") : "Durable backlog; absence from later reviews does not auto-fix findings."
|
|
4737
|
+
);
|
|
4738
|
+
lines.push("");
|
|
4739
|
+
lines.push(
|
|
4740
|
+
color ? chalk2.bold(
|
|
4741
|
+
[
|
|
4742
|
+
padEnd("ID", ID_WIDTH),
|
|
4743
|
+
padEnd("Status", STATUS_WIDTH),
|
|
4744
|
+
padEnd("Severity", SEVERITY_WIDTH),
|
|
4745
|
+
padEnd("Seen", SEEN_WIDTH),
|
|
4746
|
+
padEnd("Location", layout.locationWidth),
|
|
4747
|
+
"Title"
|
|
4748
|
+
].join(" ".repeat(COLUMN_GAP))
|
|
4749
|
+
) : [
|
|
4750
|
+
padEnd("ID", ID_WIDTH),
|
|
4751
|
+
padEnd("Status", STATUS_WIDTH),
|
|
4752
|
+
padEnd("Severity", SEVERITY_WIDTH),
|
|
4753
|
+
padEnd("Seen", SEEN_WIDTH),
|
|
4754
|
+
padEnd("Location", layout.locationWidth),
|
|
4755
|
+
"Title"
|
|
4756
|
+
].join(" ".repeat(COLUMN_GAP))
|
|
4757
|
+
);
|
|
4758
|
+
for (const item of items) {
|
|
4759
|
+
lines.push(...formatFindingListRow(item, layout));
|
|
4760
|
+
}
|
|
4761
|
+
lines.push("");
|
|
4762
|
+
lines.push(
|
|
4763
|
+
color ? chalk2.dim("Mark resolved: diffowl findings fix <id> --note <text> --verified-by <command>") : "Mark resolved: diffowl findings fix <id> --note <text> --verified-by <command>"
|
|
4764
|
+
);
|
|
4765
|
+
return lines.join("\n");
|
|
4766
|
+
}
|
|
4767
|
+
function formatFindingDetail(detail) {
|
|
4768
|
+
const lines = [
|
|
4769
|
+
`ID: ${detail.finding.id}`,
|
|
4770
|
+
`Status: ${detail.finding.status}`,
|
|
4771
|
+
`Fingerprint: ${detail.finding.fingerprint}`,
|
|
4772
|
+
`Occurrences: ${detail.occurrence_count}`,
|
|
4773
|
+
`Created: ${detail.finding.createdAt}`,
|
|
4774
|
+
`Updated: ${detail.finding.updatedAt}`
|
|
4775
|
+
];
|
|
4776
|
+
if (detail.observation) {
|
|
4777
|
+
lines.push(
|
|
4778
|
+
"",
|
|
4779
|
+
`Location: ${detail.observation.file}:${detail.observation.line}`,
|
|
4780
|
+
`Severity: ${detail.observation.severity}`,
|
|
4781
|
+
`Confidence: ${detail.observation.confidence}`,
|
|
4782
|
+
`Classification: ${detail.observation.classification}`,
|
|
4783
|
+
"",
|
|
4784
|
+
detail.observation.title,
|
|
4785
|
+
detail.observation.body
|
|
4786
|
+
);
|
|
4787
|
+
if (detail.observation.evidence) {
|
|
4788
|
+
lines.push("", `Evidence: ${detail.observation.evidence}`);
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
if (detail.events.length > 0) {
|
|
4792
|
+
lines.push("", "Events:");
|
|
4793
|
+
for (const event of detail.events) {
|
|
4794
|
+
const reason = event.reason ? ` \u2014 ${event.reason}` : "";
|
|
4795
|
+
lines.push(` - ${event.createdAt} ${event.eventType} (${event.actor})${reason}`);
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
return lines.join("\n");
|
|
4799
|
+
}
|
|
4800
|
+
function renderFindingDetailJson(detail) {
|
|
4801
|
+
return `${JSON.stringify(detail, null, 2)}
|
|
4802
|
+
`;
|
|
4803
|
+
}
|
|
4804
|
+
function computeListLayout(columns) {
|
|
4805
|
+
const fixedWidth = fixedPrefixWidth();
|
|
4806
|
+
const flexibleWidth = Math.max(
|
|
4807
|
+
columns - fixedWidth,
|
|
4808
|
+
MIN_LOCATION_WIDTH + COLUMN_GAP + MIN_TITLE_WIDTH
|
|
4809
|
+
);
|
|
4810
|
+
let locationWidth = Math.max(
|
|
4811
|
+
MIN_LOCATION_WIDTH,
|
|
4812
|
+
Math.min(
|
|
4813
|
+
Math.floor(flexibleWidth * MAX_LOCATION_SHARE),
|
|
4814
|
+
flexibleWidth - COLUMN_GAP - MIN_TITLE_WIDTH
|
|
4815
|
+
)
|
|
4816
|
+
);
|
|
4817
|
+
let titleWidth = flexibleWidth - locationWidth - COLUMN_GAP;
|
|
4818
|
+
if (titleWidth < MIN_TITLE_WIDTH) {
|
|
4819
|
+
titleWidth = MIN_TITLE_WIDTH;
|
|
4820
|
+
locationWidth = Math.max(MIN_LOCATION_WIDTH, flexibleWidth - COLUMN_GAP - titleWidth);
|
|
4821
|
+
}
|
|
4822
|
+
return {
|
|
4823
|
+
locationWidth,
|
|
4824
|
+
titleWidth,
|
|
4825
|
+
titleIndent: fixedWidth + locationWidth + COLUMN_GAP
|
|
4826
|
+
};
|
|
4827
|
+
}
|
|
4828
|
+
function formatFindingListRow(item, layout) {
|
|
4829
|
+
const id = shortenFindingId(item.finding.id);
|
|
4830
|
+
const status = item.finding.status;
|
|
4831
|
+
const severity = item.observation?.severity ?? "unknown";
|
|
4832
|
+
const seen = `${item.occurrence_count}x`;
|
|
4833
|
+
const location = formatLocation(item);
|
|
4834
|
+
const title = normalizeDisplayWhitespace(item.observation?.title ?? "(no observation)");
|
|
4835
|
+
const titleLines = wrapText(title, layout.titleWidth);
|
|
4836
|
+
const locationCell = truncateEnd(location, layout.locationWidth);
|
|
4837
|
+
const prefix = [
|
|
4838
|
+
padEnd(id, ID_WIDTH),
|
|
4839
|
+
padEnd(status, STATUS_WIDTH),
|
|
4840
|
+
padEnd(severity, SEVERITY_WIDTH),
|
|
4841
|
+
padEnd(seen, SEEN_WIDTH),
|
|
4842
|
+
padEnd(locationCell, layout.locationWidth)
|
|
4843
|
+
].join(" ".repeat(COLUMN_GAP));
|
|
4844
|
+
const lines = [`${prefix}${" ".repeat(COLUMN_GAP)}${titleLines[0] ?? ""}`];
|
|
4845
|
+
for (let index = 1; index < titleLines.length; index += 1) {
|
|
4846
|
+
lines.push(`${" ".repeat(layout.titleIndent)}${titleLines[index]}`);
|
|
4847
|
+
}
|
|
4848
|
+
return lines;
|
|
4849
|
+
}
|
|
4850
|
+
function formatLocation(item) {
|
|
4851
|
+
if (!item.observation) {
|
|
4852
|
+
return "unknown";
|
|
4853
|
+
}
|
|
4854
|
+
if (!item.observation.file) {
|
|
4855
|
+
return "unknown";
|
|
4856
|
+
}
|
|
4857
|
+
return `${item.observation.file}:${item.observation.line}`;
|
|
4858
|
+
}
|
|
4859
|
+
function normalizeDisplayWhitespace(text) {
|
|
4860
|
+
return text.trim().replace(/\s+/g, " ");
|
|
4861
|
+
}
|
|
4862
|
+
function wrapText(text, width) {
|
|
4863
|
+
if (width <= 0) {
|
|
4864
|
+
return [text];
|
|
4865
|
+
}
|
|
4866
|
+
if (text.length <= width) {
|
|
4867
|
+
return [text];
|
|
4868
|
+
}
|
|
4869
|
+
const lines = [];
|
|
4870
|
+
let remaining = text;
|
|
4871
|
+
while (remaining.length > width) {
|
|
4872
|
+
let breakAt = remaining.lastIndexOf(" ", width);
|
|
4873
|
+
if (breakAt <= 0) {
|
|
4874
|
+
breakAt = width;
|
|
4875
|
+
}
|
|
4876
|
+
lines.push(remaining.slice(0, breakAt).trimEnd());
|
|
4877
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
4878
|
+
}
|
|
4879
|
+
if (remaining.length > 0) {
|
|
4880
|
+
lines.push(remaining);
|
|
4881
|
+
}
|
|
4882
|
+
return lines.length > 0 ? lines : [""];
|
|
4883
|
+
}
|
|
4884
|
+
function truncateEnd(text, width) {
|
|
4885
|
+
if (text.length <= width) {
|
|
4886
|
+
return text;
|
|
4887
|
+
}
|
|
4888
|
+
if (width <= 3) {
|
|
4889
|
+
return text.slice(0, width);
|
|
4890
|
+
}
|
|
4891
|
+
return `${text.slice(0, width - 3)}...`;
|
|
4892
|
+
}
|
|
4893
|
+
function padEnd(text, width) {
|
|
4894
|
+
if (text.length >= width) {
|
|
4895
|
+
return text.slice(0, width);
|
|
4896
|
+
}
|
|
4897
|
+
return `${text}${" ".repeat(width - text.length)}`;
|
|
4898
|
+
}
|
|
4899
|
+
function fixedPrefixWidth() {
|
|
4900
|
+
return ID_WIDTH + COLUMN_GAP + STATUS_WIDTH + COLUMN_GAP + SEVERITY_WIDTH + COLUMN_GAP + SEEN_WIDTH + COLUMN_GAP;
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
// src/output/locator.ts
|
|
4904
|
+
var LocatorNotFoundError = class extends Error {
|
|
4905
|
+
name = "LocatorNotFoundError";
|
|
4906
|
+
};
|
|
4907
|
+
var LocatorAmbiguousError = class extends Error {
|
|
4908
|
+
constructor(locator, matches) {
|
|
4909
|
+
super(`Locator ${locator} is ambiguous (${matches.length} matches).`);
|
|
4910
|
+
this.locator = locator;
|
|
4911
|
+
this.matches = matches;
|
|
4912
|
+
}
|
|
4913
|
+
locator;
|
|
4914
|
+
matches;
|
|
4915
|
+
name = "LocatorAmbiguousError";
|
|
4916
|
+
};
|
|
4917
|
+
function parseLatestOrdinalLocator(locator) {
|
|
4918
|
+
const match = /^latest:(\d+)$/i.exec(locator.trim());
|
|
4919
|
+
if (!match) {
|
|
4920
|
+
return null;
|
|
4921
|
+
}
|
|
4922
|
+
const ordinal = Number.parseInt(match[1] ?? "", 10);
|
|
4923
|
+
if (!Number.isInteger(ordinal) || ordinal < 1) {
|
|
4924
|
+
throw new LocatorNotFoundError(`Invalid latest locator: ${locator}`);
|
|
4925
|
+
}
|
|
4926
|
+
return ordinal;
|
|
4927
|
+
}
|
|
4928
|
+
function resolveFindingIdFromCandidates(locator, candidates) {
|
|
4929
|
+
const trimmed = locator.trim();
|
|
4930
|
+
const exact = candidates.find((finding) => finding.id === trimmed);
|
|
4931
|
+
if (exact) {
|
|
4932
|
+
return exact.id;
|
|
4933
|
+
}
|
|
4934
|
+
const prefixMatches = candidates.filter((finding) => finding.id.startsWith(trimmed));
|
|
4935
|
+
if (prefixMatches.length === 1) {
|
|
4936
|
+
return prefixMatches[0].id;
|
|
4937
|
+
}
|
|
4938
|
+
if (prefixMatches.length > 1) {
|
|
4939
|
+
throw new LocatorAmbiguousError(
|
|
4940
|
+
trimmed,
|
|
4941
|
+
prefixMatches.map((finding) => finding.id)
|
|
4942
|
+
);
|
|
4943
|
+
}
|
|
4944
|
+
throw new LocatorNotFoundError(`Finding locator not found: ${trimmed}`);
|
|
4945
|
+
}
|
|
4946
|
+
function resolveLatestOrdinalFindingId(ordinal, observations) {
|
|
4947
|
+
const match = observations.find((observation) => observation.ordinal === ordinal);
|
|
4948
|
+
if (!match) {
|
|
4949
|
+
throw new LocatorNotFoundError(`Finding ${ordinal} was not found in the latest review.`);
|
|
4950
|
+
}
|
|
4951
|
+
return match.findingId;
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
// src/state/lifecycle.ts
|
|
4955
|
+
function dismissFinding(db, findingId, input) {
|
|
4956
|
+
return transitionFinding(db, findingId, {
|
|
4957
|
+
allowedFrom: ["open", "regressed"],
|
|
4958
|
+
to: "dismissed",
|
|
4959
|
+
eventType: "dismissed",
|
|
4960
|
+
actor: input.actor,
|
|
4961
|
+
reason: input.reason
|
|
4962
|
+
});
|
|
4963
|
+
}
|
|
4964
|
+
function deferFinding(db, findingId, input) {
|
|
4965
|
+
return transitionFinding(db, findingId, {
|
|
4966
|
+
allowedFrom: ["open", "regressed"],
|
|
4967
|
+
to: "deferred",
|
|
4968
|
+
eventType: "deferred",
|
|
4969
|
+
actor: input.actor,
|
|
4970
|
+
reason: input.reason
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4973
|
+
function fixFinding(db, findingId, input) {
|
|
4974
|
+
const finding = requireFinding(db, findingId);
|
|
4975
|
+
assertTransition(finding.status, ["open", "regressed"], "fixed");
|
|
4976
|
+
const updated = updateFinding(db, findingId, {
|
|
4977
|
+
status: "fixed",
|
|
4978
|
+
lastReviewId: finding.lastReviewId
|
|
4979
|
+
});
|
|
4980
|
+
insertFindingEvent(db, {
|
|
4981
|
+
findingId,
|
|
4982
|
+
eventType: "fixed",
|
|
4983
|
+
actor: input.actor,
|
|
4984
|
+
reason: input.note,
|
|
4985
|
+
commitRef: input.commitRef ?? null,
|
|
4986
|
+
verification: input.verifiedBy
|
|
4987
|
+
});
|
|
4988
|
+
return updated;
|
|
4989
|
+
}
|
|
4990
|
+
function reopenFinding(db, findingId, input) {
|
|
4991
|
+
return transitionFinding(db, findingId, {
|
|
4992
|
+
allowedFrom: ["fixed"],
|
|
4993
|
+
to: "open",
|
|
4994
|
+
eventType: "reopened",
|
|
4995
|
+
actor: input.actor,
|
|
4996
|
+
reason: input.reason
|
|
4997
|
+
});
|
|
4998
|
+
}
|
|
4999
|
+
function transitionFinding(db, findingId, options) {
|
|
5000
|
+
const finding = requireFinding(db, findingId);
|
|
5001
|
+
assertTransition(finding.status, options.allowedFrom, options.to);
|
|
5002
|
+
const updated = updateFinding(db, findingId, {
|
|
5003
|
+
status: options.to,
|
|
5004
|
+
lastReviewId: finding.lastReviewId
|
|
5005
|
+
});
|
|
5006
|
+
insertFindingEvent(db, {
|
|
5007
|
+
findingId,
|
|
5008
|
+
eventType: options.eventType,
|
|
5009
|
+
actor: options.actor,
|
|
5010
|
+
reason: options.reason
|
|
5011
|
+
});
|
|
5012
|
+
return updated;
|
|
5013
|
+
}
|
|
5014
|
+
function requireFinding(db, findingId) {
|
|
5015
|
+
const finding = getFindingById(db, findingId);
|
|
5016
|
+
if (!finding) {
|
|
5017
|
+
throw new InvalidFindingTransitionError(`Finding ${findingId} was not found.`);
|
|
5018
|
+
}
|
|
5019
|
+
return finding;
|
|
5020
|
+
}
|
|
5021
|
+
function assertTransition(current, allowedFrom, target) {
|
|
5022
|
+
if (!allowedFrom.includes(current)) {
|
|
5023
|
+
throw new InvalidFindingTransitionError(
|
|
5024
|
+
`Cannot transition finding from ${current} to ${target}.`
|
|
5025
|
+
);
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
|
|
5029
|
+
// src/state/findings-query.ts
|
|
5030
|
+
async function withFindingDatabase(diffOwlDir, fn) {
|
|
5031
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
5032
|
+
try {
|
|
5033
|
+
return fn(state.db);
|
|
5034
|
+
} finally {
|
|
5035
|
+
closeStateDatabase(state);
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
function listUnresolvedFindings(db) {
|
|
5039
|
+
return listFindingsByStatuses(db, ["open", "regressed"]).map(
|
|
5040
|
+
(finding) => toFindingListItem(db, finding)
|
|
5041
|
+
);
|
|
5042
|
+
}
|
|
5043
|
+
function getFindingDetail(db, findingId) {
|
|
5044
|
+
const finding = getFindingById(db, findingId);
|
|
5045
|
+
if (!finding) {
|
|
5046
|
+
return void 0;
|
|
5047
|
+
}
|
|
5048
|
+
return toFindingDetail(db, finding);
|
|
5049
|
+
}
|
|
5050
|
+
function resolveFindingLocator(db, locator) {
|
|
5051
|
+
const latestOrdinal = parseLatestOrdinalLocator(locator);
|
|
5052
|
+
if (latestOrdinal !== null) {
|
|
5053
|
+
const latestReview = getLatestReview(db);
|
|
5054
|
+
if (!latestReview) {
|
|
5055
|
+
throw new LocatorNotFoundError("No reviews found for latest locator resolution.");
|
|
5056
|
+
}
|
|
5057
|
+
const observations = listObservationsForReview(db, latestReview.id);
|
|
5058
|
+
return resolveLatestOrdinalFindingId(latestOrdinal, observations);
|
|
5059
|
+
}
|
|
5060
|
+
return resolveFindingIdFromCandidates(locator, listAllFindings(db));
|
|
5061
|
+
}
|
|
5062
|
+
function requireFindingDetail(db, locator) {
|
|
5063
|
+
const findingId = resolveFindingLocator(db, locator);
|
|
5064
|
+
const detail = getFindingDetail(db, findingId);
|
|
5065
|
+
if (!detail) {
|
|
5066
|
+
throw new LocatorNotFoundError(`Finding ${findingId} was not found.`);
|
|
5067
|
+
}
|
|
5068
|
+
return detail;
|
|
5069
|
+
}
|
|
5070
|
+
function mutateFinding(db, locator, mutation) {
|
|
5071
|
+
return runInTransaction(db, () => {
|
|
5072
|
+
const findingId = resolveFindingLocator(db, locator);
|
|
5073
|
+
const updated = mutation(findingId);
|
|
5074
|
+
const detail = getFindingDetail(db, updated.id);
|
|
5075
|
+
if (!detail) {
|
|
5076
|
+
throw new LocatorNotFoundError(`Finding ${updated.id} was not found after mutation.`);
|
|
5077
|
+
}
|
|
5078
|
+
return detail;
|
|
5079
|
+
});
|
|
5080
|
+
}
|
|
5081
|
+
function dismissFindingByLocator(db, locator, input) {
|
|
5082
|
+
return mutateFinding(db, locator, (findingId) => dismissFinding(db, findingId, input));
|
|
5083
|
+
}
|
|
5084
|
+
function deferFindingByLocator(db, locator, input) {
|
|
5085
|
+
return mutateFinding(db, locator, (findingId) => deferFinding(db, findingId, input));
|
|
5086
|
+
}
|
|
5087
|
+
function fixFindingByLocator(db, locator, input) {
|
|
5088
|
+
return mutateFinding(db, locator, (findingId) => fixFinding(db, findingId, input));
|
|
5089
|
+
}
|
|
5090
|
+
function reopenFindingByLocator(db, locator, input) {
|
|
5091
|
+
return mutateFinding(db, locator, (findingId) => reopenFinding(db, findingId, input));
|
|
5092
|
+
}
|
|
5093
|
+
function toFindingListItem(db, finding) {
|
|
5094
|
+
const counts = countObservationsByFindingIds(db, [finding.id]);
|
|
5095
|
+
return {
|
|
5096
|
+
finding,
|
|
5097
|
+
observation: getLatestObservationForFinding(db, finding.id) ?? null,
|
|
5098
|
+
occurrence_count: counts.get(finding.id) ?? 0
|
|
5099
|
+
};
|
|
5100
|
+
}
|
|
5101
|
+
function toFindingDetail(db, finding) {
|
|
5102
|
+
const counts = countObservationsByFindingIds(db, [finding.id]);
|
|
5103
|
+
return {
|
|
5104
|
+
finding,
|
|
5105
|
+
observation: getLatestObservationForFinding(db, finding.id) ?? null,
|
|
5106
|
+
events: listFindingEvents(db, finding.id),
|
|
5107
|
+
occurrence_count: counts.get(finding.id) ?? 0
|
|
5108
|
+
};
|
|
5109
|
+
}
|
|
5110
|
+
|
|
5111
|
+
// src/cli.ts
|
|
5112
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
5113
|
+
import { basename as basename5, dirname as dirname4 } from "path";
|
|
5114
|
+
import { execa as execa5 } from "execa";
|
|
5115
|
+
|
|
5116
|
+
// package.json
|
|
5117
|
+
var package_default = {
|
|
5118
|
+
name: "diffowl",
|
|
5119
|
+
version: "0.3.0",
|
|
5120
|
+
description: "Local AI code review agent powered by OpenCode",
|
|
5121
|
+
keywords: [
|
|
5122
|
+
"ai",
|
|
5123
|
+
"code-review",
|
|
5124
|
+
"git",
|
|
5125
|
+
"opencode",
|
|
5126
|
+
"pre-commit"
|
|
5127
|
+
],
|
|
5128
|
+
homepage: "https://github.com/gutierrezje/diffowl#readme",
|
|
5129
|
+
bugs: {
|
|
5130
|
+
url: "https://github.com/gutierrezje/diffowl/issues"
|
|
5131
|
+
},
|
|
5132
|
+
license: "MIT",
|
|
5133
|
+
repository: {
|
|
5134
|
+
type: "git",
|
|
5135
|
+
url: "git+https://github.com/gutierrezje/diffowl.git"
|
|
5136
|
+
},
|
|
5137
|
+
bin: {
|
|
5138
|
+
diffowl: "dist/cli.js"
|
|
5139
|
+
},
|
|
5140
|
+
files: [
|
|
5141
|
+
"dist"
|
|
5142
|
+
],
|
|
5143
|
+
type: "module",
|
|
5144
|
+
scripts: {
|
|
5145
|
+
"check:native-runtime": "node scripts/check-native-runtime.mjs",
|
|
5146
|
+
prebuild: "pnpm run check:native-runtime",
|
|
5147
|
+
build: "tsup",
|
|
5148
|
+
predev: "pnpm run check:native-runtime",
|
|
5149
|
+
dev: "tsup --watch",
|
|
5150
|
+
pretest: "pnpm run check:native-runtime",
|
|
5151
|
+
test: "vitest run",
|
|
5152
|
+
pretypecheck: "pnpm run check:native-runtime",
|
|
5153
|
+
typecheck: "tsc --noEmit",
|
|
5154
|
+
lint: "oxlint . && pnpm run typecheck",
|
|
5155
|
+
format: "oxfmt --write .",
|
|
5156
|
+
"format:check": "oxfmt --check .",
|
|
5157
|
+
"dogfood:0.3": "pnpm run build && node scripts/dogfood-0.3.mjs",
|
|
5158
|
+
prepack: "npm run build"
|
|
5159
|
+
},
|
|
5160
|
+
dependencies: {
|
|
5161
|
+
"@opencode-ai/sdk": "^1.15.11",
|
|
5162
|
+
"better-sqlite3": "^12.10.0",
|
|
5163
|
+
chalk: "^5.6.2",
|
|
5164
|
+
commander: "^14.0.3",
|
|
5165
|
+
execa: "^9.6.1",
|
|
5166
|
+
ora: "^9.4.0",
|
|
5167
|
+
picomatch: "^4.0.4",
|
|
5168
|
+
yaml: "^2.9.0",
|
|
5169
|
+
zod: "^4.4.3"
|
|
5170
|
+
},
|
|
5171
|
+
devDependencies: {
|
|
5172
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
5173
|
+
"@types/node": "^25.9.1",
|
|
5174
|
+
"@types/picomatch": "^4.0.3",
|
|
5175
|
+
oxfmt: "^0.52.0",
|
|
5176
|
+
oxlint: "^1.67.0",
|
|
5177
|
+
tsup: "^8.5.1",
|
|
5178
|
+
typescript: "^6.0.3",
|
|
5179
|
+
vitest: "^4.1.7"
|
|
5180
|
+
},
|
|
5181
|
+
engines: {
|
|
5182
|
+
node: ">=22.14.0 <23"
|
|
5183
|
+
},
|
|
5184
|
+
packageManager: "pnpm@10.15.1",
|
|
5185
|
+
pnpm: {
|
|
5186
|
+
onlyBuiltDependencies: [
|
|
5187
|
+
"better-sqlite3"
|
|
5188
|
+
]
|
|
5189
|
+
}
|
|
5190
|
+
};
|
|
5191
|
+
|
|
5192
|
+
// src/cli.ts
|
|
5193
|
+
var program = new Command();
|
|
5194
|
+
program.name("diffowl").description("Local AI code review agent powered by OpenCode").version(package_default.version);
|
|
5195
|
+
program.command("review", { isDefault: true }).description("Review the last commit or staged changes").option("--staged", "Review staged changes instead of last commit").option("--commit <ref>", "Review a specific commit ref instead of HEAD").option("--hook", "Running from git hook (non-blocking mode)").option("--depth <depth>", "Review context depth: shallow or default").option(
|
|
5196
|
+
"--reasoning <effort>",
|
|
5197
|
+
"Reasoning variant: auto, none, minimal, low, medium, high, max, or xhigh"
|
|
5198
|
+
).option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
|
|
5199
|
+
const format = resolveReviewOutputFormat(options.format);
|
|
5200
|
+
const jsonMode = format === "json";
|
|
5201
|
+
const hookCommit = options.hook && options.commit ? String(options.commit) : void 0;
|
|
5202
|
+
const hookLock = options.hook ? process.env["DIFFOWL_HOOK_LOCK"] : void 0;
|
|
5203
|
+
if (hookLock) {
|
|
5204
|
+
process.once("exit", () => releaseHookReviewLock(hookLock));
|
|
5205
|
+
}
|
|
5206
|
+
if (options.hook) {
|
|
5207
|
+
await writeHookStatus(0, hookCommit, "Review started.");
|
|
5208
|
+
}
|
|
5209
|
+
const totalStart = performance.now();
|
|
5210
|
+
const timings = [];
|
|
3419
5211
|
const gitRepoStart = performance.now();
|
|
3420
5212
|
const isRepo = await isGitRepo();
|
|
3421
5213
|
recordCliTiming(timings, "git-repo-check", "Git repository check", gitRepoStart);
|
|
3422
5214
|
if (!isRepo) {
|
|
3423
|
-
|
|
3424
|
-
process.exit(1);
|
|
5215
|
+
await failReview(format, "Not a git repository", { hook: options.hook, hookCommit });
|
|
3425
5216
|
}
|
|
3426
5217
|
if (!configExists()) {
|
|
3427
|
-
console.log(
|
|
5218
|
+
console.log(chalk3.yellow("No .diffowl.yml found. Running first-time setup...\n"));
|
|
3428
5219
|
await runInit();
|
|
3429
5220
|
}
|
|
3430
5221
|
const config = await loadConfigOrExit();
|
|
3431
5222
|
const projectRoot = getProjectRoot();
|
|
3432
5223
|
if (options.staged && options.commit) {
|
|
3433
|
-
|
|
3434
|
-
|
|
5224
|
+
await failReview(format, "Cannot use --staged and --commit together", {
|
|
5225
|
+
hook: options.hook,
|
|
5226
|
+
hookCommit
|
|
5227
|
+
});
|
|
3435
5228
|
}
|
|
3436
5229
|
const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : { kind: "last-commit" };
|
|
3437
5230
|
const depth = resolveReviewDepth(options.depth, config);
|
|
@@ -3442,51 +5235,130 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3442
5235
|
const commitsExist = await hasCommits();
|
|
3443
5236
|
recordCliTiming(timings, "git-commit-check", "Git commit check", hasCommitsStart);
|
|
3444
5237
|
if (!commitsExist) {
|
|
3445
|
-
|
|
3446
|
-
|
|
5238
|
+
await failReview(format, "No commits found in this repository", {
|
|
5239
|
+
hook: options.hook,
|
|
5240
|
+
hookCommit
|
|
5241
|
+
});
|
|
3447
5242
|
}
|
|
3448
5243
|
}
|
|
3449
|
-
|
|
5244
|
+
if (!jsonMode) {
|
|
5245
|
+
printHeader();
|
|
5246
|
+
}
|
|
3450
5247
|
const hookFailure = await checkRecentHookFailure();
|
|
3451
|
-
if (hookFailure) {
|
|
3452
|
-
console.log(
|
|
5248
|
+
if (hookFailure && !jsonMode) {
|
|
5249
|
+
console.log(chalk3.yellow(`\u26A0 ${formatHookFailure(hookFailure)}`));
|
|
3453
5250
|
console.log();
|
|
3454
5251
|
}
|
|
3455
|
-
const spinner = ora({
|
|
5252
|
+
const spinner = jsonMode ? null : ora({
|
|
3456
5253
|
text: "Building local review context...",
|
|
3457
5254
|
color: "cyan",
|
|
3458
5255
|
discardStdin: false
|
|
3459
5256
|
}).start();
|
|
5257
|
+
const cancelController = new AbortController();
|
|
3460
5258
|
process.once("SIGINT", () => {
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
5259
|
+
handleReviewInterrupt({
|
|
5260
|
+
cancelController,
|
|
5261
|
+
spinner,
|
|
5262
|
+
jsonMode,
|
|
5263
|
+
message: "Review cancelled by user (Ctrl+C).",
|
|
5264
|
+
exitCode: 130,
|
|
5265
|
+
hook: options.hook,
|
|
5266
|
+
hookCommit
|
|
5267
|
+
});
|
|
3467
5268
|
});
|
|
3468
5269
|
process.once("SIGTSTP", () => {
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
5270
|
+
handleReviewInterrupt({
|
|
5271
|
+
cancelController,
|
|
5272
|
+
spinner,
|
|
5273
|
+
jsonMode,
|
|
5274
|
+
message: "Review cancelled by user (Ctrl+Z).",
|
|
5275
|
+
exitCode: 146,
|
|
5276
|
+
hook: options.hook,
|
|
5277
|
+
hookCommit
|
|
5278
|
+
});
|
|
3475
5279
|
});
|
|
3476
5280
|
try {
|
|
3477
5281
|
const snapshot = await loadReviewSnapshot(projectRoot, target);
|
|
3478
5282
|
const { diff } = snapshot;
|
|
3479
5283
|
if (target.kind === "staged" && diff.files.length === 0) {
|
|
3480
|
-
spinner
|
|
3481
|
-
|
|
5284
|
+
spinner?.stop();
|
|
5285
|
+
if (jsonMode) {
|
|
5286
|
+
const persisted2 = await persistReviewRun(getDiffOwlDir(), {
|
|
5287
|
+
...mapReviewTarget(target),
|
|
5288
|
+
targetCommit: null,
|
|
5289
|
+
diffHash: computeDiffHash(diff.raw),
|
|
5290
|
+
model: config.model,
|
|
5291
|
+
reasoning: config.reasoning.effort,
|
|
5292
|
+
depth,
|
|
5293
|
+
sessionId: "",
|
|
5294
|
+
summary: "No staged changes to review.",
|
|
5295
|
+
diagnostics: [],
|
|
5296
|
+
timings,
|
|
5297
|
+
findings: [],
|
|
5298
|
+
skippedReason: "empty-diff"
|
|
5299
|
+
});
|
|
5300
|
+
await emitReviewJsonSuccess({
|
|
5301
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5302
|
+
reviewId: persisted2.reviewId,
|
|
5303
|
+
persisted: persisted2,
|
|
5304
|
+
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
5305
|
+
verbose,
|
|
5306
|
+
timings
|
|
5307
|
+
});
|
|
5308
|
+
process.exit(0);
|
|
5309
|
+
}
|
|
5310
|
+
console.log(chalk3.yellow("No staged changes to review"));
|
|
3482
5311
|
process.exit(0);
|
|
3483
5312
|
}
|
|
3484
5313
|
if (config.skip_doc_only && isDocOnlyDiff(diff)) {
|
|
3485
|
-
spinner
|
|
3486
|
-
|
|
5314
|
+
spinner?.stop();
|
|
5315
|
+
if (!jsonMode) {
|
|
5316
|
+
console.warn(chalk3.yellow("Documentation-only changes detected. Skipping review."));
|
|
5317
|
+
}
|
|
3487
5318
|
const skipContent = buildDocOnlySkipMarkdown(diff);
|
|
3488
|
-
const
|
|
3489
|
-
|
|
5319
|
+
const diffHash2 = computeDiffHash(diff.raw);
|
|
5320
|
+
const targetFields2 = mapReviewTarget(target);
|
|
5321
|
+
const targetCommit2 = await resolveTargetCommit(target);
|
|
5322
|
+
const persisted2 = await persistReviewRun(getDiffOwlDir(), {
|
|
5323
|
+
...targetFields2,
|
|
5324
|
+
targetCommit: targetCommit2,
|
|
5325
|
+
diffHash: diffHash2,
|
|
5326
|
+
model: config.model,
|
|
5327
|
+
reasoning: config.reasoning.effort,
|
|
5328
|
+
depth,
|
|
5329
|
+
sessionId: "",
|
|
5330
|
+
summary: "Documentation-only changes detected. No code review performed.",
|
|
5331
|
+
diagnostics: [],
|
|
5332
|
+
timings,
|
|
5333
|
+
findings: [],
|
|
5334
|
+
skippedReason: "documentation-only"
|
|
5335
|
+
});
|
|
5336
|
+
let reportPath2;
|
|
5337
|
+
try {
|
|
5338
|
+
reportPath2 = await writeMarkdownReport(skipContent);
|
|
5339
|
+
await updatePersistedReview(getDiffOwlDir(), persisted2.reviewId, {
|
|
5340
|
+
reportPath: reportPath2
|
|
5341
|
+
});
|
|
5342
|
+
} catch (err) {
|
|
5343
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5344
|
+
await updatePersistedReview(getDiffOwlDir(), persisted2.reviewId, {
|
|
5345
|
+
reportPath: null,
|
|
5346
|
+
diagnostics: [`Report write failed: ${message}`]
|
|
5347
|
+
});
|
|
5348
|
+
throw err;
|
|
5349
|
+
}
|
|
5350
|
+
if (jsonMode) {
|
|
5351
|
+
await emitReviewJsonSuccess({
|
|
5352
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5353
|
+
reviewId: persisted2.reviewId,
|
|
5354
|
+
persisted: persisted2,
|
|
5355
|
+
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
5356
|
+
verbose,
|
|
5357
|
+
timings
|
|
5358
|
+
});
|
|
5359
|
+
} else {
|
|
5360
|
+
console.log(chalk3.dim(`Report saved: ${reportPath2}`));
|
|
5361
|
+
}
|
|
3490
5362
|
if (options.hook) {
|
|
3491
5363
|
await writeHookStatus(0, hookCommit);
|
|
3492
5364
|
}
|
|
@@ -3498,19 +5370,23 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3498
5370
|
const contextRenderStart = performance.now();
|
|
3499
5371
|
const localContext = renderReviewContext(reviewContext, { depth });
|
|
3500
5372
|
recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
|
|
3501
|
-
if (reviewContext.diagnostics.length > 0) {
|
|
5373
|
+
if (reviewContext.diagnostics.length > 0 && spinner) {
|
|
3502
5374
|
spinner.warn("Local review context built with warnings.");
|
|
3503
5375
|
for (const diagnostic of reviewContext.diagnostics) {
|
|
3504
|
-
console.log(
|
|
5376
|
+
console.log(chalk3.yellow(` - ${diagnostic}`));
|
|
3505
5377
|
}
|
|
3506
5378
|
console.log();
|
|
3507
5379
|
spinner.start("Connecting to OpenCode...");
|
|
3508
5380
|
}
|
|
3509
|
-
spinner
|
|
5381
|
+
if (spinner) {
|
|
5382
|
+
spinner.text = "Connecting to OpenCode...";
|
|
5383
|
+
}
|
|
3510
5384
|
const serverStart = performance.now();
|
|
3511
5385
|
await prepareReviewServer(config);
|
|
3512
5386
|
recordCliTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
|
|
3513
|
-
spinner
|
|
5387
|
+
if (spinner) {
|
|
5388
|
+
spinner.text = "Reviewing changes...";
|
|
5389
|
+
}
|
|
3514
5390
|
const reviewStart = performance.now();
|
|
3515
5391
|
const reviewResult = await runReview({
|
|
3516
5392
|
target,
|
|
@@ -3518,14 +5394,19 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3518
5394
|
config,
|
|
3519
5395
|
localContext,
|
|
3520
5396
|
depth,
|
|
5397
|
+
signal: cancelController.signal,
|
|
3521
5398
|
onProgress: (event) => {
|
|
3522
|
-
|
|
5399
|
+
if (spinner) {
|
|
5400
|
+
spinner.text = formatReviewProgress(event);
|
|
5401
|
+
}
|
|
3523
5402
|
}
|
|
3524
5403
|
});
|
|
3525
5404
|
const report = reviewResult.report;
|
|
3526
5405
|
recordCliTiming(timings, "review-run", "OpenCode review run", reviewStart);
|
|
3527
|
-
spinner
|
|
3528
|
-
|
|
5406
|
+
spinner?.succeed("Review complete.");
|
|
5407
|
+
if (!jsonMode) {
|
|
5408
|
+
console.log();
|
|
5409
|
+
}
|
|
3529
5410
|
const diagnostics = report.diagnostics ?? [];
|
|
3530
5411
|
const confidenceFilter = filterFindingsByConfidence(report.findings, config.min_confidence);
|
|
3531
5412
|
report.findings = confidenceFilter.findings;
|
|
@@ -3551,30 +5432,118 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3551
5432
|
if (diagnostics.length > 0) {
|
|
3552
5433
|
report.diagnostics = diagnostics;
|
|
3553
5434
|
}
|
|
5435
|
+
const diffHash = computeDiffHash(diff.raw);
|
|
5436
|
+
const targetFields = mapReviewTarget(target);
|
|
5437
|
+
const targetCommit = await resolveTargetCommit(target);
|
|
5438
|
+
const persistStart = performance.now();
|
|
5439
|
+
const persisted = await persistReviewRun(getDiffOwlDir(), {
|
|
5440
|
+
...targetFields,
|
|
5441
|
+
targetCommit,
|
|
5442
|
+
diffHash,
|
|
5443
|
+
model: config.model,
|
|
5444
|
+
reasoning: config.reasoning.effort,
|
|
5445
|
+
depth,
|
|
5446
|
+
sessionId: reviewResult.sessionId,
|
|
5447
|
+
summary: report.summary,
|
|
5448
|
+
diagnostics,
|
|
5449
|
+
timings: [...timings, ...report.timings ?? []],
|
|
5450
|
+
findings: report.findings
|
|
5451
|
+
});
|
|
5452
|
+
recordCliTiming(timings, "persist-state", "Persist review state", persistStart);
|
|
5453
|
+
report.findings = persisted.actionableFindings;
|
|
5454
|
+
const lifecycleSummary = formatLifecycleSuppressedSummary(
|
|
5455
|
+
persisted.reconcile.suppressedCounts
|
|
5456
|
+
);
|
|
5457
|
+
if (lifecycleSummary) {
|
|
5458
|
+
diagnostics.push(lifecycleSummary);
|
|
5459
|
+
report.diagnostics = diagnostics;
|
|
5460
|
+
}
|
|
5461
|
+
if (verbose && persisted.lifecycleSuppressedFindings.length > 0) {
|
|
5462
|
+
report.suppressedFindings = [
|
|
5463
|
+
...report.suppressedFindings ?? [],
|
|
5464
|
+
...persisted.lifecycleSuppressedFindings
|
|
5465
|
+
];
|
|
5466
|
+
}
|
|
5467
|
+
report.findings = enrichReviewFindingsWithDurableMetadata(
|
|
5468
|
+
report.findings,
|
|
5469
|
+
persisted.reconcile
|
|
5470
|
+
);
|
|
5471
|
+
if (report.suppressedFindings) {
|
|
5472
|
+
report.suppressedFindings = enrichReviewFindingsWithDurableMetadata(
|
|
5473
|
+
report.suppressedFindings,
|
|
5474
|
+
persisted.reconcile
|
|
5475
|
+
);
|
|
5476
|
+
}
|
|
3554
5477
|
const renderStart = performance.now();
|
|
3555
5478
|
const markdown = renderMarkdown(report);
|
|
3556
5479
|
recordCliTiming(timings, "render-report", "Markdown render", renderStart);
|
|
3557
5480
|
const writeStart = performance.now();
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
5481
|
+
let reportPath;
|
|
5482
|
+
try {
|
|
5483
|
+
reportPath = await writeMarkdownReport(markdown, {
|
|
5484
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
5485
|
+
review_id: persisted.reviewId,
|
|
5486
|
+
session_id: reviewResult.sessionId,
|
|
5487
|
+
project_root: projectRoot
|
|
5488
|
+
});
|
|
5489
|
+
await updatePersistedReview(getDiffOwlDir(), persisted.reviewId, {
|
|
5490
|
+
reportPath,
|
|
5491
|
+
diagnostics
|
|
5492
|
+
});
|
|
5493
|
+
} catch (err) {
|
|
5494
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5495
|
+
diagnostics.push(`Report write failed: ${message}`);
|
|
5496
|
+
report.diagnostics = diagnostics;
|
|
5497
|
+
await updatePersistedReview(getDiffOwlDir(), persisted.reviewId, {
|
|
5498
|
+
reportPath: null,
|
|
5499
|
+
diagnostics
|
|
5500
|
+
});
|
|
5501
|
+
throw err;
|
|
5502
|
+
}
|
|
3562
5503
|
recordCliTiming(timings, "write-report", "Report write", writeStart);
|
|
3563
5504
|
recordCliTiming(timings, "total", "Total review command", totalStart);
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
5505
|
+
if (jsonMode) {
|
|
5506
|
+
await emitReviewJsonSuccess({
|
|
5507
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5508
|
+
reviewId: persisted.reviewId,
|
|
5509
|
+
persisted,
|
|
5510
|
+
suppressed: {
|
|
5511
|
+
outsideChangedFiles: changedFileFilter.suppressed.length,
|
|
5512
|
+
belowConfidence: confidenceFilter.dropped
|
|
5513
|
+
},
|
|
5514
|
+
verbose,
|
|
5515
|
+
timings: [...timings, ...report.timings ?? []]
|
|
5516
|
+
});
|
|
5517
|
+
} else {
|
|
5518
|
+
console.log(colorizeMarkdown(markdown));
|
|
5519
|
+
printFooter(report, reportPath);
|
|
5520
|
+
printTimingSummary([...timings, ...report.timings ?? []]);
|
|
5521
|
+
}
|
|
3567
5522
|
if (options.hook) {
|
|
3568
5523
|
await writeHookStatus(0, hookCommit);
|
|
3569
5524
|
process.exit(0);
|
|
3570
5525
|
}
|
|
3571
5526
|
} catch (err) {
|
|
3572
|
-
spinner
|
|
5527
|
+
spinner?.stop();
|
|
5528
|
+
if (cancelController.signal.aborted || isReviewCancellation(err)) {
|
|
5529
|
+
if (options.hook) {
|
|
5530
|
+
await writeHookStatus(1, hookCommit, "Review cancelled by user.");
|
|
5531
|
+
process.exit(0);
|
|
5532
|
+
}
|
|
5533
|
+
if (!cancelController.signal.aborted) {
|
|
5534
|
+
process.exit(130);
|
|
5535
|
+
}
|
|
5536
|
+
return;
|
|
5537
|
+
}
|
|
3573
5538
|
const message = err instanceof Error ? err.message : String(err);
|
|
3574
|
-
|
|
5539
|
+
if (jsonMode) {
|
|
5540
|
+
writeJsonError(message);
|
|
5541
|
+
} else {
|
|
5542
|
+
console.error(chalk3.red(`
|
|
3575
5543
|
Review failed: ${message}`));
|
|
3576
|
-
|
|
3577
|
-
|
|
5544
|
+
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
5545
|
+
console.log(chalk3.dim(line));
|
|
5546
|
+
}
|
|
3578
5547
|
}
|
|
3579
5548
|
if (options.hook) {
|
|
3580
5549
|
await writeHookStatus(1, hookCommit, message);
|
|
@@ -3589,19 +5558,19 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3589
5558
|
try {
|
|
3590
5559
|
content = await readFile7(reportPath, "utf-8");
|
|
3591
5560
|
} catch {
|
|
3592
|
-
console.error(
|
|
5561
|
+
console.error(chalk3.red(`Review report not found: ${reportPath}`));
|
|
3593
5562
|
process.exit(1);
|
|
3594
5563
|
}
|
|
3595
5564
|
let metadata;
|
|
3596
5565
|
try {
|
|
3597
5566
|
metadata = parseReviewMetadata(content);
|
|
3598
5567
|
} catch {
|
|
3599
|
-
console.error(
|
|
5568
|
+
console.error(chalk3.red(`Invalid review metadata: ${reportPath}`));
|
|
3600
5569
|
process.exit(1);
|
|
3601
5570
|
}
|
|
3602
5571
|
if (!metadata) {
|
|
3603
5572
|
console.error(
|
|
3604
|
-
|
|
5573
|
+
chalk3.red(`Review report does not contain chat session metadata: ${reportPath}`)
|
|
3605
5574
|
);
|
|
3606
5575
|
process.exit(1);
|
|
3607
5576
|
}
|
|
@@ -3611,9 +5580,9 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3611
5580
|
});
|
|
3612
5581
|
} catch (err) {
|
|
3613
5582
|
const message = err instanceof Error ? err.message : String(err);
|
|
3614
|
-
console.error(
|
|
5583
|
+
console.error(chalk3.red(`Failed to open review session: ${message}`));
|
|
3615
5584
|
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
3616
|
-
console.log(
|
|
5585
|
+
console.log(chalk3.dim(line));
|
|
3617
5586
|
}
|
|
3618
5587
|
process.exit(1);
|
|
3619
5588
|
}
|
|
@@ -3621,7 +5590,7 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3621
5590
|
async function selectReviewInteractively() {
|
|
3622
5591
|
if (!canSelectReviewInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
|
|
3623
5592
|
console.error(
|
|
3624
|
-
|
|
5593
|
+
chalk3.red(
|
|
3625
5594
|
"Interactive review selection requires a terminal. Pass a report filename or path instead."
|
|
3626
5595
|
)
|
|
3627
5596
|
);
|
|
@@ -3629,14 +5598,14 @@ async function selectReviewInteractively() {
|
|
|
3629
5598
|
}
|
|
3630
5599
|
const reports = await listReviewReportPaths();
|
|
3631
5600
|
if (reports.length === 0) {
|
|
3632
|
-
console.error(
|
|
5601
|
+
console.error(chalk3.red("No review reports available. Run `diffowl review` first."));
|
|
3633
5602
|
process.exit(1);
|
|
3634
5603
|
}
|
|
3635
|
-
console.log(
|
|
5604
|
+
console.log(chalk3.bold("\nSelect a review:\n"));
|
|
3636
5605
|
for (const [index, report] of reports.entries()) {
|
|
3637
5606
|
const resolved = basename5(dirname4(report)) === "resolved";
|
|
3638
5607
|
console.log(
|
|
3639
|
-
` ${
|
|
5608
|
+
` ${chalk3.cyan(`${index + 1}.`)} ${basename5(report)}${resolved ? chalk3.dim(" (resolved)") : ""}`
|
|
3640
5609
|
);
|
|
3641
5610
|
}
|
|
3642
5611
|
const rl = createInterface({
|
|
@@ -3647,10 +5616,10 @@ async function selectReviewInteractively() {
|
|
|
3647
5616
|
while (true) {
|
|
3648
5617
|
const selected = selectReviewReportPath(
|
|
3649
5618
|
reports,
|
|
3650
|
-
await rl.question(
|
|
5619
|
+
await rl.question(chalk3.yellow("\nReview number: "))
|
|
3651
5620
|
);
|
|
3652
5621
|
if (selected) return selected;
|
|
3653
|
-
console.log(
|
|
5622
|
+
console.log(chalk3.red(`Enter a number between 1 and ${reports.length}.`));
|
|
3654
5623
|
}
|
|
3655
5624
|
} finally {
|
|
3656
5625
|
rl.close();
|
|
@@ -3670,6 +5639,20 @@ function formatReviewProgress(event) {
|
|
|
3670
5639
|
return event.message;
|
|
3671
5640
|
}
|
|
3672
5641
|
}
|
|
5642
|
+
async function describeNodeRuntime(node) {
|
|
5643
|
+
try {
|
|
5644
|
+
const { stdout } = await execa5(node, [
|
|
5645
|
+
"-p",
|
|
5646
|
+
"JSON.stringify({ version: process.version, modules: process.versions.modules })"
|
|
5647
|
+
]);
|
|
5648
|
+
const parsed = JSON.parse(stdout);
|
|
5649
|
+
if (typeof parsed.version === "string" && typeof parsed.modules === "string") {
|
|
5650
|
+
return `${node} (${parsed.version}, ABI ${parsed.modules})`;
|
|
5651
|
+
}
|
|
5652
|
+
} catch {
|
|
5653
|
+
}
|
|
5654
|
+
return node;
|
|
5655
|
+
}
|
|
3673
5656
|
function resolveReviewDepth(value, config) {
|
|
3674
5657
|
if (value === void 0) {
|
|
3675
5658
|
return config.context.depth;
|
|
@@ -3677,8 +5660,8 @@ function resolveReviewDepth(value, config) {
|
|
|
3677
5660
|
try {
|
|
3678
5661
|
return parseReviewContextDepth(value);
|
|
3679
5662
|
} catch {
|
|
3680
|
-
console.error(
|
|
3681
|
-
console.error(
|
|
5663
|
+
console.error(chalk3.red(`Invalid review depth: ${String(value)}`));
|
|
5664
|
+
console.error(chalk3.dim("Expected one of: shallow, default"));
|
|
3682
5665
|
process.exit(1);
|
|
3683
5666
|
}
|
|
3684
5667
|
}
|
|
@@ -3689,8 +5672,8 @@ function resolveReasoningEffort(value, config) {
|
|
|
3689
5672
|
try {
|
|
3690
5673
|
return parseReasoningEffort(value);
|
|
3691
5674
|
} catch {
|
|
3692
|
-
console.error(
|
|
3693
|
-
console.error(
|
|
5675
|
+
console.error(chalk3.red(`Invalid reasoning effort: ${String(value)}`));
|
|
5676
|
+
console.error(chalk3.dim("Expected one of: auto, none, minimal, low, medium, high, max, xhigh"));
|
|
3694
5677
|
process.exit(1);
|
|
3695
5678
|
}
|
|
3696
5679
|
}
|
|
@@ -3703,9 +5686,9 @@ function printTimingSummary(timings) {
|
|
|
3703
5686
|
...timings.filter((timing) => timing.phase !== "total"),
|
|
3704
5687
|
...timings.filter((timing) => timing.phase === "total")
|
|
3705
5688
|
];
|
|
3706
|
-
console.log(
|
|
5689
|
+
console.log(chalk3.dim("Timing:"));
|
|
3707
5690
|
for (const timing of ordered) {
|
|
3708
|
-
console.log(
|
|
5691
|
+
console.log(chalk3.dim(` ${timing.label}: ${formatDuration2(timing.ms)}`));
|
|
3709
5692
|
}
|
|
3710
5693
|
console.log();
|
|
3711
5694
|
}
|
|
@@ -3729,14 +5712,14 @@ program.command("init").description("Set up DiffOwl for this project").action(as
|
|
|
3729
5712
|
await runInit();
|
|
3730
5713
|
});
|
|
3731
5714
|
async function runInit() {
|
|
3732
|
-
console.log(
|
|
5715
|
+
console.log(chalk3.bold("DiffOwl Setup\n"));
|
|
3733
5716
|
const config = await loadConfigOrExit();
|
|
3734
5717
|
await selectModelInteractively(config, { allowKeepCurrent: false });
|
|
3735
5718
|
}
|
|
3736
5719
|
program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode-go/big-pickle)").action(async (model) => {
|
|
3737
5720
|
const config = await loadConfigOrExit();
|
|
3738
5721
|
if (!model) {
|
|
3739
|
-
console.log(
|
|
5722
|
+
console.log(chalk3.bold("Current model: ") + chalk3.cyan(config.model));
|
|
3740
5723
|
await selectModelInteractively(config, { allowKeepCurrent: true });
|
|
3741
5724
|
return;
|
|
3742
5725
|
}
|
|
@@ -3744,16 +5727,16 @@ program.command("model").description("View or change the AI model").argument("[m
|
|
|
3744
5727
|
try {
|
|
3745
5728
|
parsedModel = parseModel(model);
|
|
3746
5729
|
} catch {
|
|
3747
|
-
console.error(
|
|
5730
|
+
console.error(chalk3.red(`Invalid model: ${model}`));
|
|
3748
5731
|
console.error(
|
|
3749
|
-
|
|
5732
|
+
chalk3.dim("Expected provider/model format, for example opencode-go/big-pickle")
|
|
3750
5733
|
);
|
|
3751
5734
|
process.exit(1);
|
|
3752
5735
|
}
|
|
3753
5736
|
config.model = parsedModel;
|
|
3754
5737
|
const configPath = await saveConfig(config);
|
|
3755
|
-
console.log(
|
|
3756
|
-
console.log(
|
|
5738
|
+
console.log(chalk3.green(`\u2713 Model set to ${chalk3.cyan(parsedModel)}`));
|
|
5739
|
+
console.log(chalk3.dim(`Config: ${configPath}`));
|
|
3757
5740
|
});
|
|
3758
5741
|
async function selectModelInteractively(config, options) {
|
|
3759
5742
|
const spinner = ora("Querying available models from OpenCode...").start();
|
|
@@ -3767,7 +5750,7 @@ async function selectModelInteractively(config, options) {
|
|
|
3767
5750
|
const message = err instanceof Error ? err.message : String(err);
|
|
3768
5751
|
spinner.fail(`Failed to query models: ${message}`);
|
|
3769
5752
|
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
3770
|
-
console.error(
|
|
5753
|
+
console.error(chalk3.dim(line));
|
|
3771
5754
|
}
|
|
3772
5755
|
process.exit(1);
|
|
3773
5756
|
}
|
|
@@ -3775,19 +5758,19 @@ async function selectModelInteractively(config, options) {
|
|
|
3775
5758
|
if (models.length > 0) {
|
|
3776
5759
|
if (!canSelectModelInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
|
|
3777
5760
|
console.error(
|
|
3778
|
-
|
|
5761
|
+
chalk3.red(
|
|
3779
5762
|
"Interactive model selection requires a terminal. Pass a model explicitly, for example `diffowl model provider/model`."
|
|
3780
5763
|
)
|
|
3781
5764
|
);
|
|
3782
5765
|
process.exit(1);
|
|
3783
5766
|
}
|
|
3784
5767
|
console.log(
|
|
3785
|
-
|
|
5768
|
+
chalk3.bold(
|
|
3786
5769
|
options.allowKeepCurrent ? "\nAvailable models configured in OpenCode:" : "Available models configured in OpenCode:"
|
|
3787
5770
|
)
|
|
3788
5771
|
);
|
|
3789
5772
|
models.forEach((m, idx) => {
|
|
3790
|
-
console.log(` ${
|
|
5773
|
+
console.log(` ${chalk3.cyan(idx + 1)}. ${m}`);
|
|
3791
5774
|
});
|
|
3792
5775
|
console.log();
|
|
3793
5776
|
const rl = createInterface({
|
|
@@ -3800,7 +5783,7 @@ async function selectModelInteractively(config, options) {
|
|
|
3800
5783
|
const selection = selectModel(
|
|
3801
5784
|
models,
|
|
3802
5785
|
config.model,
|
|
3803
|
-
await rl.question(
|
|
5786
|
+
await rl.question(chalk3.yellow(promptText)),
|
|
3804
5787
|
options.allowKeepCurrent
|
|
3805
5788
|
);
|
|
3806
5789
|
if (selection.type === "kept") break;
|
|
@@ -3808,27 +5791,27 @@ async function selectModelInteractively(config, options) {
|
|
|
3808
5791
|
selectedModel = selection.model;
|
|
3809
5792
|
break;
|
|
3810
5793
|
}
|
|
3811
|
-
console.log(
|
|
5794
|
+
console.log(chalk3.red("Invalid selection. Please enter a valid number."));
|
|
3812
5795
|
}
|
|
3813
5796
|
} finally {
|
|
3814
5797
|
rl.close();
|
|
3815
5798
|
}
|
|
3816
5799
|
} else {
|
|
3817
|
-
console.log(
|
|
5800
|
+
console.log(chalk3.yellow("\nNo active/connected providers found in OpenCode."));
|
|
3818
5801
|
console.log(
|
|
3819
|
-
|
|
5802
|
+
chalk3.dim("Make sure you run ") + chalk3.cyan("opencode") + chalk3.dim(" to authenticate and set up your providers/keys first.")
|
|
3820
5803
|
);
|
|
3821
|
-
console.log(
|
|
5804
|
+
console.log(chalk3.dim("Using fallback default model: ") + chalk3.cyan(config.model));
|
|
3822
5805
|
console.log();
|
|
3823
5806
|
}
|
|
3824
5807
|
if (selectedModel !== config.model || !options.allowKeepCurrent) {
|
|
3825
5808
|
config.model = selectedModel;
|
|
3826
5809
|
const configPath = await saveConfig(config);
|
|
3827
5810
|
if (options.allowKeepCurrent) {
|
|
3828
|
-
console.log(
|
|
5811
|
+
console.log(chalk3.green(`\u2713 Model set to ${chalk3.cyan(selectedModel)}`));
|
|
3829
5812
|
} else {
|
|
3830
|
-
console.log(
|
|
3831
|
-
console.log(
|
|
5813
|
+
console.log(chalk3.green(`\u2713 Config saved to ${configPath}`));
|
|
5814
|
+
console.log(chalk3.dim(`Model set to: `) + chalk3.cyan(selectedModel));
|
|
3832
5815
|
}
|
|
3833
5816
|
console.log();
|
|
3834
5817
|
}
|
|
@@ -3836,37 +5819,40 @@ async function selectModelInteractively(config, options) {
|
|
|
3836
5819
|
var hookCmd = program.command("hook").description("Manage git hooks");
|
|
3837
5820
|
hookCmd.command("install").description("Install post-commit hook (non-blocking review)").action(async () => {
|
|
3838
5821
|
if (!await isGitRepo()) {
|
|
3839
|
-
console.error(
|
|
5822
|
+
console.error(chalk3.red("Not a git repository"));
|
|
3840
5823
|
process.exit(1);
|
|
3841
5824
|
}
|
|
3842
5825
|
const alreadyInstalled = await isHookInstalled();
|
|
3843
5826
|
const hookPath = await installHook();
|
|
5827
|
+
const command = await getHookCommand();
|
|
3844
5828
|
const action = alreadyInstalled ? "updated" : "installed";
|
|
3845
|
-
console.log(
|
|
3846
|
-
console.log(
|
|
5829
|
+
console.log(chalk3.green(`\u2713 Post-commit hook ${action}: ${hookPath}`));
|
|
5830
|
+
console.log(chalk3.dim(`Hook Node: ${await describeNodeRuntime(command.node)}`));
|
|
5831
|
+
console.log(chalk3.dim(`Hook Entrypoint: ${command.cli}`));
|
|
5832
|
+
console.log(chalk3.dim("Reviews will run automatically after each commit (non-blocking)"));
|
|
3847
5833
|
console.log(
|
|
3848
|
-
|
|
5834
|
+
chalk3.dim("Hook output: .diffowl/hook.log; latest report: .diffowl/reviews/latest.md")
|
|
3849
5835
|
);
|
|
3850
5836
|
});
|
|
3851
5837
|
hookCmd.command("status").description("Check if the post-commit hook is installed and up to date").action(async () => {
|
|
3852
5838
|
const status = await checkHookStale();
|
|
3853
5839
|
if (!status.installed) {
|
|
3854
|
-
console.log(
|
|
5840
|
+
console.log(chalk3.yellow(`\u2717 ${status.reason ?? "Hook not installed"}`));
|
|
3855
5841
|
return;
|
|
3856
5842
|
}
|
|
3857
5843
|
if (status.stale) {
|
|
3858
|
-
console.log(
|
|
3859
|
-
console.log(
|
|
3860
|
-
console.log(
|
|
5844
|
+
console.log(chalk3.yellow("\u26A0 Hook is installed but stale"));
|
|
5845
|
+
console.log(chalk3.dim(`Reason: ${status.reason}`));
|
|
5846
|
+
console.log(chalk3.dim("Run `diffowl hook install` to update it."));
|
|
3861
5847
|
return;
|
|
3862
5848
|
}
|
|
3863
|
-
console.log(
|
|
5849
|
+
console.log(chalk3.green("\u2713 Hook is installed and up to date"));
|
|
3864
5850
|
});
|
|
3865
5851
|
hookCmd.command("uninstall").description("Remove the post-commit hook").action(async () => {
|
|
3866
5852
|
if (await uninstallHook()) {
|
|
3867
|
-
console.log(
|
|
5853
|
+
console.log(chalk3.green("\u2713 Hook removed"));
|
|
3868
5854
|
} else {
|
|
3869
|
-
console.log(
|
|
5855
|
+
console.log(chalk3.yellow("No diffowl hook found"));
|
|
3870
5856
|
}
|
|
3871
5857
|
});
|
|
3872
5858
|
program.command("hook-run", { hidden: true }).description("Spawn a non-blocking hook review").action(async () => {
|
|
@@ -3892,28 +5878,156 @@ serverCmd.command("start").description("Start the OpenCode server").action(async
|
|
|
3892
5878
|
}
|
|
3893
5879
|
});
|
|
3894
5880
|
serverCmd.command("stop").description("Stop the OpenCode server").action(async () => {
|
|
3895
|
-
|
|
3896
|
-
|
|
5881
|
+
const config = await loadConfigOrExit();
|
|
5882
|
+
if (await stopServer(config.server.port)) {
|
|
5883
|
+
console.log(chalk3.green("\u2713 Server stopped"));
|
|
3897
5884
|
} else {
|
|
3898
|
-
console.log(
|
|
5885
|
+
console.log(chalk3.yellow(`No OpenCode server found on port ${config.server.port}`));
|
|
3899
5886
|
}
|
|
3900
5887
|
});
|
|
3901
5888
|
serverCmd.command("status").description("Check if the OpenCode server is running").action(async () => {
|
|
3902
5889
|
const config = await loadConfigOrExit();
|
|
3903
|
-
const
|
|
3904
|
-
if (
|
|
3905
|
-
console.log(
|
|
3906
|
-
|
|
3907
|
-
|
|
5890
|
+
const health = await getServerHealth(config.server.port);
|
|
5891
|
+
if (!health?.healthy) {
|
|
5892
|
+
console.log(chalk3.yellow(`\u2717 No server on port ${config.server.port}`));
|
|
5893
|
+
return;
|
|
5894
|
+
}
|
|
5895
|
+
console.log(chalk3.green(`\u2713 Server running on port ${config.server.port}`));
|
|
5896
|
+
const cliVersion = await getInstalledOpencodeVersion();
|
|
5897
|
+
if (health.version) {
|
|
5898
|
+
console.log(` Server version: ${health.version}`);
|
|
5899
|
+
}
|
|
5900
|
+
if (cliVersion) {
|
|
5901
|
+
console.log(` CLI version: ${cliVersion}`);
|
|
5902
|
+
}
|
|
5903
|
+
if (health.version && cliVersion && health.version !== cliVersion) {
|
|
5904
|
+
console.log(
|
|
5905
|
+
chalk3.yellow(
|
|
5906
|
+
"\u26A0 Version mismatch. Restart with: diffowl server stop && diffowl server start"
|
|
5907
|
+
)
|
|
5908
|
+
);
|
|
3908
5909
|
}
|
|
3909
5910
|
});
|
|
5911
|
+
var findingsCmd = program.command("findings").description("Inspect and manage durable findings");
|
|
5912
|
+
findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").action(async () => {
|
|
5913
|
+
await loadConfigOrExit();
|
|
5914
|
+
const items = await withFindingDatabase(getDiffOwlDir(), listUnresolvedFindings);
|
|
5915
|
+
if (items.length === 0) {
|
|
5916
|
+
console.log(chalk3.green("No unresolved findings."));
|
|
5917
|
+
return;
|
|
5918
|
+
}
|
|
5919
|
+
console.log(formatFindingList(items));
|
|
5920
|
+
});
|
|
5921
|
+
findingsCmd.command("show").description("Show one finding by locator").argument("<locator>", "Finding id, id prefix, or latest:N").option("--format <format>", "Output format: text or json", "text").action(async (locator, options) => {
|
|
5922
|
+
await loadConfigOrExit();
|
|
5923
|
+
const format = resolveReviewOutputFormat(options.format);
|
|
5924
|
+
try {
|
|
5925
|
+
const detail = await withFindingDatabase(
|
|
5926
|
+
getDiffOwlDir(),
|
|
5927
|
+
(db) => requireFindingDetail(db, locator)
|
|
5928
|
+
);
|
|
5929
|
+
if (format === "json") {
|
|
5930
|
+
process.stdout.write(renderFindingDetailJson(detail));
|
|
5931
|
+
return;
|
|
5932
|
+
}
|
|
5933
|
+
console.log(formatFindingDetail(detail));
|
|
5934
|
+
} catch (err) {
|
|
5935
|
+
failFindingsCommand(format, err);
|
|
5936
|
+
}
|
|
5937
|
+
});
|
|
5938
|
+
findingsCmd.command("dismiss").description("Dismiss a finding").argument("<locator>", "Finding id, id prefix, or latest:N").requiredOption("--reason <text>", "Dismissal reason").option("--actor <actor>", "Actor: user or agent", "user").option("--format <format>", "Output format: text or json", "json").action(async (locator, options) => {
|
|
5939
|
+
await runFindingMutation(
|
|
5940
|
+
locator,
|
|
5941
|
+
options.format,
|
|
5942
|
+
(db) => dismissFindingByLocator(db, locator, {
|
|
5943
|
+
actor: parseFindingActor(options.actor),
|
|
5944
|
+
reason: options.reason
|
|
5945
|
+
})
|
|
5946
|
+
);
|
|
5947
|
+
});
|
|
5948
|
+
findingsCmd.command("defer").description("Defer a finding").argument("<locator>", "Finding id, id prefix, or latest:N").requiredOption("--reason <text>", "Deferral reason").option("--actor <actor>", "Actor: user or agent", "user").option("--format <format>", "Output format: text or json", "json").action(async (locator, options) => {
|
|
5949
|
+
await runFindingMutation(
|
|
5950
|
+
locator,
|
|
5951
|
+
options.format,
|
|
5952
|
+
(db) => deferFindingByLocator(db, locator, {
|
|
5953
|
+
actor: parseFindingActor(options.actor),
|
|
5954
|
+
reason: options.reason
|
|
5955
|
+
})
|
|
5956
|
+
);
|
|
5957
|
+
});
|
|
5958
|
+
findingsCmd.command("fix").description("Mark a finding fixed").argument("<locator>", "Finding id, id prefix, or latest:N").requiredOption("--note <text>", "Fix note").option("--verified-by <command>", "Verification command (repeatable)", collectValues, []).option("--commit <ref>", "Commit reference").option("--actor <actor>", "Actor: user or agent", "user").option("--format <format>", "Output format: text or json", "json").action(
|
|
5959
|
+
async (locator, options) => {
|
|
5960
|
+
const verifiedBy = options.verifiedBy ?? [];
|
|
5961
|
+
if (verifiedBy.length === 0) {
|
|
5962
|
+
failFindingsCommand(
|
|
5963
|
+
resolveReviewOutputFormat(options.format),
|
|
5964
|
+
new Error("At least one --verified-by command is required.")
|
|
5965
|
+
);
|
|
5966
|
+
}
|
|
5967
|
+
await runFindingMutation(
|
|
5968
|
+
locator,
|
|
5969
|
+
options.format,
|
|
5970
|
+
(db) => fixFindingByLocator(db, locator, {
|
|
5971
|
+
actor: parseFindingActor(options.actor),
|
|
5972
|
+
note: options.note,
|
|
5973
|
+
verifiedBy,
|
|
5974
|
+
...options.commit ? { commitRef: options.commit } : {}
|
|
5975
|
+
})
|
|
5976
|
+
);
|
|
5977
|
+
}
|
|
5978
|
+
);
|
|
5979
|
+
findingsCmd.command("reopen").description("Reopen a fixed finding").argument("<locator>", "Finding id, id prefix, or latest:N").requiredOption("--reason <text>", "Reopen reason").option("--actor <actor>", "Actor: user or agent", "user").option("--format <format>", "Output format: text or json", "json").action(async (locator, options) => {
|
|
5980
|
+
await runFindingMutation(
|
|
5981
|
+
locator,
|
|
5982
|
+
options.format,
|
|
5983
|
+
(db) => reopenFindingByLocator(db, locator, {
|
|
5984
|
+
actor: parseFindingActor(options.actor),
|
|
5985
|
+
reason: options.reason
|
|
5986
|
+
})
|
|
5987
|
+
);
|
|
5988
|
+
});
|
|
3910
5989
|
program.parse();
|
|
5990
|
+
async function runFindingMutation(_locator, formatValue, mutate) {
|
|
5991
|
+
await loadConfigOrExit();
|
|
5992
|
+
const format = resolveReviewOutputFormat(formatValue);
|
|
5993
|
+
try {
|
|
5994
|
+
const detail = await withFindingDatabase(getDiffOwlDir(), mutate);
|
|
5995
|
+
if (format === "json") {
|
|
5996
|
+
process.stdout.write(renderFindingDetailJson(detail));
|
|
5997
|
+
return;
|
|
5998
|
+
}
|
|
5999
|
+
console.log(formatFindingDetail(detail));
|
|
6000
|
+
} catch (err) {
|
|
6001
|
+
failFindingsCommand(format, err);
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
function failFindingsCommand(format, err) {
|
|
6005
|
+
const message = err instanceof LocatorNotFoundError || err instanceof LocatorAmbiguousError || err instanceof InvalidFindingTransitionError ? err.message : err instanceof Error ? err.message : String(err);
|
|
6006
|
+
if (format === "json") {
|
|
6007
|
+
writeJsonError(message);
|
|
6008
|
+
} else {
|
|
6009
|
+
console.error(chalk3.red(message));
|
|
6010
|
+
}
|
|
6011
|
+
process.exit(1);
|
|
6012
|
+
}
|
|
6013
|
+
function parseFindingActor(value) {
|
|
6014
|
+
if (value === void 0 || value === "user") {
|
|
6015
|
+
return "user";
|
|
6016
|
+
}
|
|
6017
|
+
if (value === "agent") {
|
|
6018
|
+
return "agent";
|
|
6019
|
+
}
|
|
6020
|
+
throw new Error(`Invalid actor: ${value}. Expected user or agent.`);
|
|
6021
|
+
}
|
|
6022
|
+
function collectValues(value, previous) {
|
|
6023
|
+
return [...previous, value];
|
|
6024
|
+
}
|
|
3911
6025
|
async function loadConfigOrExit() {
|
|
3912
6026
|
try {
|
|
3913
6027
|
return await loadConfig();
|
|
3914
6028
|
} catch (err) {
|
|
3915
6029
|
const message = err instanceof Error ? err.message : String(err);
|
|
3916
|
-
console.error(
|
|
6030
|
+
console.error(chalk3.red(`Config error: ${message}`));
|
|
3917
6031
|
process.exit(1);
|
|
3918
6032
|
}
|
|
3919
6033
|
}
|
|
@@ -3949,4 +6063,78 @@ function buildDocOnlySkipMarkdown(diff) {
|
|
|
3949
6063
|
}
|
|
3950
6064
|
return lines.join("\n");
|
|
3951
6065
|
}
|
|
6066
|
+
async function resolveTargetCommit(target) {
|
|
6067
|
+
switch (target.kind) {
|
|
6068
|
+
case "staged":
|
|
6069
|
+
return null;
|
|
6070
|
+
case "last-commit":
|
|
6071
|
+
return resolveCommitRef("HEAD");
|
|
6072
|
+
case "commit":
|
|
6073
|
+
return resolveCommitRef(target.ref);
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
function handleReviewInterrupt(input) {
|
|
6077
|
+
input.cancelController.abort();
|
|
6078
|
+
try {
|
|
6079
|
+
input.spinner?.stop();
|
|
6080
|
+
} catch {
|
|
6081
|
+
}
|
|
6082
|
+
if (input.jsonMode) {
|
|
6083
|
+
writeJsonError(input.message);
|
|
6084
|
+
} else {
|
|
6085
|
+
console.log(chalk3.yellow(`
|
|
6086
|
+
${input.message}`));
|
|
6087
|
+
}
|
|
6088
|
+
if (input.hook) {
|
|
6089
|
+
const forceExit = setTimeout(() => process.exit(0), 2e3);
|
|
6090
|
+
void writeHookStatus(1, input.hookCommit, input.message).finally(() => {
|
|
6091
|
+
clearTimeout(forceExit);
|
|
6092
|
+
process.exit(0);
|
|
6093
|
+
});
|
|
6094
|
+
return;
|
|
6095
|
+
}
|
|
6096
|
+
setTimeout(() => process.exit(input.exitCode), 750).unref();
|
|
6097
|
+
}
|
|
6098
|
+
function resolveReviewOutputFormat(value) {
|
|
6099
|
+
try {
|
|
6100
|
+
return parseReviewOutputFormat(value);
|
|
6101
|
+
} catch (err) {
|
|
6102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6103
|
+
console.error(chalk3.red(message));
|
|
6104
|
+
process.exit(1);
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
async function failReview(format, message, options = {}) {
|
|
6108
|
+
const exitCode = options.exitCode ?? 1;
|
|
6109
|
+
if (format === "json") {
|
|
6110
|
+
writeJsonError(message);
|
|
6111
|
+
} else {
|
|
6112
|
+
console.error(chalk3.red(message));
|
|
6113
|
+
}
|
|
6114
|
+
if (options.hook) {
|
|
6115
|
+
await writeHookStatus(1, options.hookCommit, message);
|
|
6116
|
+
process.exit(0);
|
|
6117
|
+
}
|
|
6118
|
+
process.exit(exitCode);
|
|
6119
|
+
}
|
|
6120
|
+
async function emitReviewJsonSuccess(input) {
|
|
6121
|
+
const review = await getPersistedReview(input.diffOwlDir, input.reviewId);
|
|
6122
|
+
if (!review) {
|
|
6123
|
+
throw new Error(`Review ${input.reviewId} was not found in state database.`);
|
|
6124
|
+
}
|
|
6125
|
+
const findingIds = input.persisted.reconcile.observations.map((item) => item.finding.id);
|
|
6126
|
+
const occurrenceCounts = await loadFindingOccurrenceCounts(input.diffOwlDir, findingIds);
|
|
6127
|
+
const document = buildReviewJsonDocument({
|
|
6128
|
+
review,
|
|
6129
|
+
persisted: input.persisted,
|
|
6130
|
+
occurrenceCounts,
|
|
6131
|
+
suppressed: {
|
|
6132
|
+
outsideChangedFiles: input.suppressed.outsideChangedFiles,
|
|
6133
|
+
belowConfidence: input.suppressed.belowConfidence
|
|
6134
|
+
},
|
|
6135
|
+
verbose: input.verbose,
|
|
6136
|
+
...input.timings ? { timings: input.timings } : {}
|
|
6137
|
+
});
|
|
6138
|
+
writeReviewJsonSuccess(document);
|
|
6139
|
+
}
|
|
3952
6140
|
//# sourceMappingURL=cli.js.map
|