diffowl 0.2.1 → 0.3.1
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 +67 -15
- package/dist/cli.js +2705 -312
- package/dist/cli.js.map +1 -1
- package/package.json +8 -2
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
|
|
|
@@ -112,7 +112,10 @@ function findConfigPath() {
|
|
|
112
112
|
return join(process.cwd(), CONFIG_FILENAME);
|
|
113
113
|
}
|
|
114
114
|
async function loadConfig() {
|
|
115
|
-
|
|
115
|
+
return loadConfigFromRoot(dirname(findConfigPath()));
|
|
116
|
+
}
|
|
117
|
+
async function loadConfigFromRoot(root) {
|
|
118
|
+
const configPath = join(root, CONFIG_FILENAME);
|
|
116
119
|
if (!existsSync(configPath)) {
|
|
117
120
|
return parseConfigInput({});
|
|
118
121
|
}
|
|
@@ -160,7 +163,9 @@ import { join as join2 } from "path";
|
|
|
160
163
|
var HEALTH_TIMEOUT_MS = 2e3;
|
|
161
164
|
var STARTUP_WAIT_MS = 3e3;
|
|
162
165
|
var MAX_RETRIES = 10;
|
|
163
|
-
|
|
166
|
+
var PORT_RELEASE_WAIT_MS = 5e3;
|
|
167
|
+
var PORT_RELEASE_POLL_MS = 200;
|
|
168
|
+
async function getServerHealth(port) {
|
|
164
169
|
try {
|
|
165
170
|
const controller = new AbortController();
|
|
166
171
|
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
@@ -168,15 +173,51 @@ async function isServerRunning(port) {
|
|
|
168
173
|
signal: controller.signal
|
|
169
174
|
});
|
|
170
175
|
clearTimeout(timeout);
|
|
171
|
-
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const body = await res.json();
|
|
180
|
+
const health = { healthy: body.healthy === true };
|
|
181
|
+
if (typeof body.version === "string") {
|
|
182
|
+
health.version = body.version;
|
|
183
|
+
}
|
|
184
|
+
return health;
|
|
172
185
|
} catch {
|
|
173
|
-
return
|
|
186
|
+
return null;
|
|
174
187
|
}
|
|
175
188
|
}
|
|
189
|
+
async function getInstalledOpencodeVersion() {
|
|
190
|
+
try {
|
|
191
|
+
const { stdout } = await execa("opencode", ["--version"], { timeout: 5e3 });
|
|
192
|
+
const trimmed = stdout.trim();
|
|
193
|
+
if (!trimmed) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const match = trimmed.match(/(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/);
|
|
197
|
+
return match?.[1] ?? trimmed;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function isServerRunning(port) {
|
|
203
|
+
const health = await getServerHealth(port);
|
|
204
|
+
return health?.healthy === true;
|
|
205
|
+
}
|
|
176
206
|
async function ensureServer(port) {
|
|
177
207
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
const health = await getServerHealth(port);
|
|
209
|
+
if (health?.healthy) {
|
|
210
|
+
const cliVersion = await getInstalledOpencodeVersion();
|
|
211
|
+
if (health.version && cliVersion && health.version !== cliVersion) {
|
|
212
|
+
if (!await stopServer(port)) {
|
|
213
|
+
throw new Error(`Could not locate or stop stale OpenCode server on port ${port}.`);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
return baseUrl;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (await stopUnhealthyServerListener(port)) {
|
|
220
|
+
await waitUntilPortFree(port);
|
|
180
221
|
}
|
|
181
222
|
await spawnServer(port);
|
|
182
223
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
@@ -224,10 +265,33 @@ async function spawnServer(port) {
|
|
|
224
265
|
}
|
|
225
266
|
subprocess.unref();
|
|
226
267
|
}
|
|
227
|
-
async function stopServer() {
|
|
268
|
+
async function stopServer(port) {
|
|
269
|
+
if (await stopManagedServer()) {
|
|
270
|
+
await waitUntilPortFree(port);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const listenerPid = await findOpencodeListenerPid(port);
|
|
274
|
+
if (listenerPid === null) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
if (!await isOpencodeProcess(listenerPid)) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
process.kill(listenerPid, "SIGTERM");
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
await cleanupPidFile();
|
|
286
|
+
await waitUntilPortFree(port);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
async function stopManagedServer() {
|
|
228
290
|
const dir = getDiffOwlDir();
|
|
229
291
|
const pidFile = join2(dir, "server.pid");
|
|
230
|
-
if (!existsSync2(pidFile))
|
|
292
|
+
if (!existsSync2(pidFile)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
231
295
|
let pid;
|
|
232
296
|
try {
|
|
233
297
|
pid = parseInt(await readFile2(pidFile, "utf-8"), 10);
|
|
@@ -256,46 +320,125 @@ async function stopServer() {
|
|
|
256
320
|
}
|
|
257
321
|
try {
|
|
258
322
|
process.kill(pid, "SIGTERM");
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
259
327
|
await unlink(pidFile);
|
|
260
|
-
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
async function stopUnhealthyServerListener(port) {
|
|
333
|
+
const listenerPid = await findOpencodeListenerPid(port);
|
|
334
|
+
if (listenerPid === null) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
process.kill(listenerPid, "SIGTERM");
|
|
261
339
|
} catch {
|
|
262
340
|
return false;
|
|
263
341
|
}
|
|
342
|
+
await cleanupPidFile();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
async function cleanupPidFile() {
|
|
346
|
+
const pidFile = join2(getDiffOwlDir(), "server.pid");
|
|
347
|
+
if (!existsSync2(pidFile)) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
await unlink(pidFile);
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function findOpencodeListenerPid(port) {
|
|
356
|
+
if (process.platform === "win32") {
|
|
357
|
+
return findOpencodeListenerPidWindows(port);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { stdout } = await execa("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
|
|
361
|
+
timeout: 5e3
|
|
362
|
+
});
|
|
363
|
+
const pids = stdout.trim().split(/\s+/).map((value) => parseInt(value, 10)).filter((value) => Number.isInteger(value) && value > 0);
|
|
364
|
+
for (const pid of pids) {
|
|
365
|
+
if (await isOpencodeProcess(pid)) {
|
|
366
|
+
return pid;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
async function findOpencodeListenerPidWindows(port) {
|
|
374
|
+
try {
|
|
375
|
+
const { stdout } = await execa("netstat", ["-ano"], { timeout: 5e3 });
|
|
376
|
+
const portToken = `:${port}`;
|
|
377
|
+
const lines = stdout.split(/\r?\n/);
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
if (!line.includes("LISTENING") || !line.includes(portToken)) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const parts = line.trim().split(/\s+/);
|
|
383
|
+
const pid = parseInt(parts[parts.length - 1] ?? "", 10);
|
|
384
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (await isOpencodeProcess(pid)) {
|
|
388
|
+
return pid;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
async function waitUntilPortFree(port) {
|
|
396
|
+
const deadline = Date.now() + PORT_RELEASE_WAIT_MS;
|
|
397
|
+
while (Date.now() < deadline) {
|
|
398
|
+
if (await findOpencodeListenerPid(port) === null) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
await sleep(PORT_RELEASE_POLL_MS);
|
|
402
|
+
}
|
|
403
|
+
if (await findOpencodeListenerPid(port) !== null) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`OpenCode server on port ${port} did not stop within ${PORT_RELEASE_WAIT_MS}ms. Retry: diffowl server stop && diffowl server start`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
264
408
|
}
|
|
265
409
|
async function isOpencodeProcess(pid) {
|
|
266
410
|
const isWin = process.platform === "win32";
|
|
267
411
|
try {
|
|
268
412
|
if (isWin) {
|
|
269
413
|
try {
|
|
270
|
-
const { stdout:
|
|
414
|
+
const { stdout: stdout3 } = await execa("powershell", [
|
|
271
415
|
"-NoProfile",
|
|
272
416
|
"-Command",
|
|
273
417
|
`Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`
|
|
274
418
|
]);
|
|
275
|
-
if (
|
|
419
|
+
if (stdout3.toLowerCase().includes("opencode")) {
|
|
276
420
|
return true;
|
|
277
421
|
}
|
|
278
422
|
} catch {
|
|
279
423
|
}
|
|
280
424
|
try {
|
|
281
|
-
const { stdout:
|
|
425
|
+
const { stdout: stdout3 } = await execa("wmic", [
|
|
282
426
|
"process",
|
|
283
427
|
"where",
|
|
284
428
|
`ProcessId=${pid}`,
|
|
285
429
|
"get",
|
|
286
430
|
"CommandLine"
|
|
287
431
|
]);
|
|
288
|
-
if (
|
|
432
|
+
if (stdout3.toLowerCase().includes("opencode")) {
|
|
289
433
|
return true;
|
|
290
434
|
}
|
|
291
435
|
} catch {
|
|
292
436
|
}
|
|
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");
|
|
437
|
+
const { stdout: stdout2 } = await execa("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
|
|
438
|
+
return stdout2.toLowerCase().includes("opencode");
|
|
298
439
|
}
|
|
440
|
+
const { stdout } = await execa("ps", ["-p", String(pid), "-o", "command="]);
|
|
441
|
+
return stdout.toLowerCase().includes("opencode");
|
|
299
442
|
} catch {
|
|
300
443
|
return false;
|
|
301
444
|
}
|
|
@@ -838,6 +981,27 @@ function parseProviderPayload(response) {
|
|
|
838
981
|
return ProviderPayloadSchema.safeParse(response.data).data;
|
|
839
982
|
}
|
|
840
983
|
|
|
984
|
+
// src/opencode/quota.ts
|
|
985
|
+
var QUOTA_PATTERNS = [
|
|
986
|
+
/\b429\b/,
|
|
987
|
+
/rate.?limit/,
|
|
988
|
+
/usage limit/,
|
|
989
|
+
/insufficient[_ -]?quota/,
|
|
990
|
+
/quota (exceeded|reached)/,
|
|
991
|
+
/(exceeded|reached) (your )?(current )?quota/,
|
|
992
|
+
/resource.?exhausted/,
|
|
993
|
+
/too many requests/,
|
|
994
|
+
/overloaded/,
|
|
995
|
+
/insufficient capacity/,
|
|
996
|
+
/billing[_ -]?(hard[_ -]?)?limit/,
|
|
997
|
+
/tokens per (min|day)/,
|
|
998
|
+
/requests per (min|day)/
|
|
999
|
+
];
|
|
1000
|
+
function isQuotaOrRateLimitError(message) {
|
|
1001
|
+
const normalized = message.toLowerCase();
|
|
1002
|
+
return QUOTA_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
841
1005
|
// src/opencode/models.ts
|
|
842
1006
|
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
843
1007
|
async function getAvailableModels(port, options = {}) {
|
|
@@ -860,7 +1024,78 @@ function listAvailableModels(payload) {
|
|
|
860
1024
|
).sort();
|
|
861
1025
|
}
|
|
862
1026
|
|
|
1027
|
+
// src/review/usage.ts
|
|
1028
|
+
function parseAssistantUsage(info) {
|
|
1029
|
+
if (!info || typeof info !== "object") return void 0;
|
|
1030
|
+
const value = info;
|
|
1031
|
+
if (value["role"] !== "assistant") return void 0;
|
|
1032
|
+
const tokens = parseUsageTokens(value["tokens"]);
|
|
1033
|
+
if (!tokens) return void 0;
|
|
1034
|
+
const cost = typeof value["cost"] === "number" ? value["cost"] : null;
|
|
1035
|
+
return { tokens, cost };
|
|
1036
|
+
}
|
|
1037
|
+
function aggregateReviewUsage(entries) {
|
|
1038
|
+
if (entries.length === 0) return void 0;
|
|
1039
|
+
const tokens = {
|
|
1040
|
+
input: 0,
|
|
1041
|
+
output: 0,
|
|
1042
|
+
reasoning: 0,
|
|
1043
|
+
cache: { read: 0, write: 0 }
|
|
1044
|
+
};
|
|
1045
|
+
let costSum = 0;
|
|
1046
|
+
let hasCost = false;
|
|
1047
|
+
for (const entry of entries) {
|
|
1048
|
+
tokens.input += entry.tokens.input;
|
|
1049
|
+
tokens.output += entry.tokens.output;
|
|
1050
|
+
tokens.reasoning += entry.tokens.reasoning;
|
|
1051
|
+
tokens.cache.read += entry.tokens.cache.read;
|
|
1052
|
+
tokens.cache.write += entry.tokens.cache.write;
|
|
1053
|
+
if (entry.cost !== null) {
|
|
1054
|
+
costSum += entry.cost;
|
|
1055
|
+
hasCost = true;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { tokens, cost: hasCost ? costSum : null };
|
|
1059
|
+
}
|
|
1060
|
+
function parseUsageTokens(value) {
|
|
1061
|
+
if (!value || typeof value !== "object") return void 0;
|
|
1062
|
+
const tokens = value;
|
|
1063
|
+
const cache = tokens["cache"];
|
|
1064
|
+
if (!cache || typeof cache !== "object") return void 0;
|
|
1065
|
+
const cacheValue = cache;
|
|
1066
|
+
if (typeof tokens["input"] !== "number" || typeof tokens["output"] !== "number" || typeof tokens["reasoning"] !== "number" || typeof cacheValue["read"] !== "number" || typeof cacheValue["write"] !== "number") {
|
|
1067
|
+
return void 0;
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
input: tokens["input"],
|
|
1071
|
+
output: tokens["output"],
|
|
1072
|
+
reasoning: tokens["reasoning"],
|
|
1073
|
+
cache: {
|
|
1074
|
+
read: cacheValue["read"],
|
|
1075
|
+
write: cacheValue["write"]
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
863
1080
|
// src/opencode/client.ts
|
|
1081
|
+
var ReviewCancelledError = class extends Error {
|
|
1082
|
+
name = "ReviewCancelledError";
|
|
1083
|
+
};
|
|
1084
|
+
function isReviewCancellation(error) {
|
|
1085
|
+
return error instanceof ReviewCancelledError;
|
|
1086
|
+
}
|
|
1087
|
+
function resolveReviewPrompts(options) {
|
|
1088
|
+
const user = options.userPrompt ?? buildReviewPrompt(
|
|
1089
|
+
options.target,
|
|
1090
|
+
options.config.rules,
|
|
1091
|
+
options.config.include,
|
|
1092
|
+
options.config.exclude,
|
|
1093
|
+
options.localContext,
|
|
1094
|
+
options.depth
|
|
1095
|
+
);
|
|
1096
|
+
const system = options.systemPrompt ?? REVIEW_AGENT_PROMPT;
|
|
1097
|
+
return { system, user };
|
|
1098
|
+
}
|
|
864
1099
|
function normalizeOpenCodeEvent(event, expectedSessionId) {
|
|
865
1100
|
if (!event || typeof event !== "object") return void 0;
|
|
866
1101
|
const payload = event.payload;
|
|
@@ -942,15 +1177,20 @@ function normalizeAssistantMessage(info, expectedSessionId) {
|
|
|
942
1177
|
if (value["role"] !== "assistant" || typeof value["sessionID"] !== "string" || typeof value["id"] !== "string" || expectedSessionId !== void 0 && value["sessionID"] !== expectedSessionId) {
|
|
943
1178
|
return void 0;
|
|
944
1179
|
}
|
|
1180
|
+
const usage = parseAssistantUsage(value);
|
|
945
1181
|
return {
|
|
946
1182
|
type: "assistant-message",
|
|
947
1183
|
sessionId: value["sessionID"],
|
|
948
1184
|
messageId: value["id"],
|
|
949
|
-
...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {}
|
|
1185
|
+
...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {},
|
|
1186
|
+
...usage ? { usage } : {}
|
|
950
1187
|
};
|
|
951
1188
|
}
|
|
952
1189
|
async function runReview(options) {
|
|
953
|
-
const { target, directory, config, localContext, depth, onProgress } = options;
|
|
1190
|
+
const { target, directory, config, localContext, depth, onProgress, signal } = options;
|
|
1191
|
+
if (signal?.aborted) {
|
|
1192
|
+
throw new ReviewCancelledError("Review cancelled by user.");
|
|
1193
|
+
}
|
|
954
1194
|
const port = config.server.port;
|
|
955
1195
|
const directoryOptions = opencodeDirectoryOptions(directory);
|
|
956
1196
|
const timings = [];
|
|
@@ -979,14 +1219,14 @@ async function runReview(options) {
|
|
|
979
1219
|
const tools = await buildToolPolicy(client, depth);
|
|
980
1220
|
recordTiming(timings, onProgress, "tool-policy", "OpenCode tool policy", toolPolicyStart);
|
|
981
1221
|
const promptStart = performance.now();
|
|
982
|
-
const prompt =
|
|
1222
|
+
const { system, user: prompt } = resolveReviewPrompts({
|
|
983
1223
|
target,
|
|
984
|
-
config
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
);
|
|
1224
|
+
config,
|
|
1225
|
+
depth,
|
|
1226
|
+
...localContext !== void 0 ? { localContext } : {},
|
|
1227
|
+
...options.systemPrompt !== void 0 ? { systemPrompt: options.systemPrompt } : {},
|
|
1228
|
+
...options.userPrompt !== void 0 ? { userPrompt: options.userPrompt } : {}
|
|
1229
|
+
});
|
|
990
1230
|
recordTiming(timings, onProgress, "prompt-build", "Review prompt build", promptStart);
|
|
991
1231
|
const parts = config.model.split("/");
|
|
992
1232
|
const providerID = parts[0];
|
|
@@ -999,6 +1239,10 @@ async function runReview(options) {
|
|
|
999
1239
|
);
|
|
1000
1240
|
let fullResponse = "";
|
|
1001
1241
|
const eventsController = new AbortController();
|
|
1242
|
+
const cancelReview = () => {
|
|
1243
|
+
eventsController.abort();
|
|
1244
|
+
};
|
|
1245
|
+
signal?.addEventListener("abort", cancelReview, { once: true });
|
|
1002
1246
|
const eventStart = performance.now();
|
|
1003
1247
|
const sseResult = await withOpenCodeDiagnostics(
|
|
1004
1248
|
"event-stream-connect",
|
|
@@ -1008,6 +1252,7 @@ async function runReview(options) {
|
|
|
1008
1252
|
})
|
|
1009
1253
|
);
|
|
1010
1254
|
recordTiming(timings, onProgress, "event-stream", "OpenCode event stream connection", eventStart);
|
|
1255
|
+
const usageByMessageId = /* @__PURE__ */ new Map();
|
|
1011
1256
|
const responsePromise = handledAwaitable(
|
|
1012
1257
|
new Promise((resolve2, reject) => {
|
|
1013
1258
|
const assistantMessageIds = /* @__PURE__ */ new Set();
|
|
@@ -1064,14 +1309,35 @@ async function runReview(options) {
|
|
|
1064
1309
|
break;
|
|
1065
1310
|
case "assistant-message": {
|
|
1066
1311
|
assistantMessageIds.add(normalized.messageId);
|
|
1312
|
+
if (normalized.usage) {
|
|
1313
|
+
usageByMessageId.set(normalized.messageId, normalized.usage);
|
|
1314
|
+
}
|
|
1067
1315
|
const text = textPartsByMessageId.get(normalized.messageId);
|
|
1068
1316
|
settlement.acceptAssistantMessage({ text, error: normalized.error });
|
|
1069
1317
|
break;
|
|
1070
1318
|
}
|
|
1071
|
-
case "session-status":
|
|
1072
|
-
|
|
1073
|
-
|
|
1319
|
+
case "session-status": {
|
|
1320
|
+
if (normalized.status === "retry") {
|
|
1321
|
+
const retryMessage = normalized.message ?? "unknown error";
|
|
1322
|
+
onProgress?.({
|
|
1323
|
+
type: "session",
|
|
1324
|
+
message: `OpenCode retrying: ${retryMessage}`,
|
|
1325
|
+
sessionId
|
|
1326
|
+
});
|
|
1327
|
+
if (isQuotaOrRateLimitError(retryMessage)) {
|
|
1328
|
+
settlement.reject(
|
|
1329
|
+
new Error(`Provider quota or rate limit reached: ${retryMessage}`)
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
onProgress?.({
|
|
1334
|
+
type: "session",
|
|
1335
|
+
message: `OpenCode session ${normalized.status}.`,
|
|
1336
|
+
sessionId
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1074
1339
|
break;
|
|
1340
|
+
}
|
|
1075
1341
|
case "session-idle":
|
|
1076
1342
|
if (fullResponse.length === 0) break;
|
|
1077
1343
|
onProgress?.({ type: "idle", message: "OpenCode session is idle." });
|
|
@@ -1084,48 +1350,64 @@ async function runReview(options) {
|
|
|
1084
1350
|
settlement.finish();
|
|
1085
1351
|
}
|
|
1086
1352
|
} catch (streamErr) {
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1353
|
+
if (settlement.isSettled()) {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (eventsController.signal.aborted) {
|
|
1357
|
+
settlement.reject(new ReviewCancelledError("Review cancelled by user."));
|
|
1358
|
+
return;
|
|
1091
1359
|
}
|
|
1360
|
+
settlement.reject(
|
|
1361
|
+
describeOpenCodeError(streamErr, "event-stream-read", { port, sessionId })
|
|
1362
|
+
);
|
|
1092
1363
|
}
|
|
1093
1364
|
})();
|
|
1094
1365
|
})
|
|
1095
1366
|
);
|
|
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
|
-
|
|
1367
|
+
try {
|
|
1368
|
+
onProgress?.({ type: "session", message: "Sending review prompt.", sessionId });
|
|
1369
|
+
const promptSendStart = performance.now();
|
|
1370
|
+
await withOpenCodeDiagnostics(
|
|
1371
|
+
"prompt-send",
|
|
1372
|
+
{ port, sessionId },
|
|
1373
|
+
() => client.session.promptAsync({
|
|
1374
|
+
path: { id: sessionId },
|
|
1375
|
+
...directoryOptions,
|
|
1376
|
+
body: {
|
|
1377
|
+
system,
|
|
1378
|
+
model: { providerID, modelID },
|
|
1379
|
+
tools,
|
|
1380
|
+
...reasoning.variant ? { variant: reasoning.variant } : {},
|
|
1381
|
+
parts: [{ type: "text", text: prompt }]
|
|
1382
|
+
}
|
|
1383
|
+
})
|
|
1384
|
+
);
|
|
1385
|
+
recordTiming(timings, onProgress, "prompt-send", "OpenCode prompt request", promptSendStart);
|
|
1386
|
+
const agentWaitStart = performance.now();
|
|
1387
|
+
const raw = await withOpenCodeDiagnostics(
|
|
1388
|
+
"agent-wait",
|
|
1389
|
+
{ port, sessionId },
|
|
1390
|
+
() => responsePromise
|
|
1391
|
+
);
|
|
1392
|
+
recordTiming(timings, onProgress, "agent-wait", "OpenCode review generation", agentWaitStart);
|
|
1393
|
+
const parseStart = performance.now();
|
|
1394
|
+
const report = parseStructuredReview(raw);
|
|
1395
|
+
recordTiming(timings, onProgress, "parse-review", "Review JSON parsing", parseStart);
|
|
1396
|
+
const diagnostics = [...report.diagnostics ?? [], ...reasoning.diagnostics];
|
|
1397
|
+
const usage = aggregateReviewUsage([...usageByMessageId.values()]);
|
|
1398
|
+
return {
|
|
1399
|
+
report: {
|
|
1400
|
+
...report,
|
|
1401
|
+
...diagnostics.length > 0 ? { diagnostics } : {},
|
|
1402
|
+
timings,
|
|
1403
|
+
...usage ? { usage } : {}
|
|
1404
|
+
},
|
|
1405
|
+
sessionId,
|
|
1406
|
+
...usage ? { usage } : {}
|
|
1407
|
+
};
|
|
1408
|
+
} finally {
|
|
1409
|
+
signal?.removeEventListener("abort", cancelReview);
|
|
1410
|
+
}
|
|
1129
1411
|
}
|
|
1130
1412
|
function extractSessionMessageResult(response) {
|
|
1131
1413
|
if (!response || typeof response !== "object") return { kind: "empty" };
|
|
@@ -1320,9 +1602,23 @@ function getOpenCodeFailureGuidance(message) {
|
|
|
1320
1602
|
if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
|
|
1321
1603
|
return ["Start the managed server: diffowl server start", "Then retry the DiffOwl command."];
|
|
1322
1604
|
}
|
|
1605
|
+
if (isQuotaOrRateLimitError(normalized)) {
|
|
1606
|
+
return [
|
|
1607
|
+
"Provider quota or rate limit reached. Wait a few minutes and retry.",
|
|
1608
|
+
"If persistent, check your provider dashboard for usage limits and billing.",
|
|
1609
|
+
"You can also try a different model: diffowl review --model <model>"
|
|
1610
|
+
];
|
|
1611
|
+
}
|
|
1323
1612
|
if (normalized.includes("timed out") || normalized.includes("timeout")) {
|
|
1324
1613
|
return ["Retry with less context: diffowl review --depth shallow"];
|
|
1325
1614
|
}
|
|
1615
|
+
if (normalized.includes("session_message.seq") || normalized.includes("not null constraint failed") && normalized.includes("seq")) {
|
|
1616
|
+
return [
|
|
1617
|
+
"OpenCode server version may be stale. Check: diffowl server status",
|
|
1618
|
+
"Restart the server: diffowl server stop && diffowl server start",
|
|
1619
|
+
"Confirm server and CLI versions match: opencode --version"
|
|
1620
|
+
];
|
|
1621
|
+
}
|
|
1326
1622
|
return [];
|
|
1327
1623
|
}
|
|
1328
1624
|
|
|
@@ -1356,7 +1652,7 @@ import {
|
|
|
1356
1652
|
writeFileSync,
|
|
1357
1653
|
writeSync
|
|
1358
1654
|
} from "fs";
|
|
1359
|
-
import { dirname as dirname2, join as join3 } from "path";
|
|
1655
|
+
import { basename, dirname as dirname2, join as join3 } from "path";
|
|
1360
1656
|
import { fileURLToPath } from "url";
|
|
1361
1657
|
import { execa as execa2 } from "execa";
|
|
1362
1658
|
import { z as z4 } from "zod";
|
|
@@ -1393,10 +1689,16 @@ async function getHooksDir() {
|
|
|
1393
1689
|
}
|
|
1394
1690
|
return hooksDir;
|
|
1395
1691
|
}
|
|
1396
|
-
async function
|
|
1692
|
+
async function getHookPath() {
|
|
1397
1693
|
const hooksDir = await getHooksDir();
|
|
1398
|
-
|
|
1399
|
-
|
|
1694
|
+
if (basename(hooksDir) === "_" && basename(dirname2(hooksDir)) === ".husky") {
|
|
1695
|
+
return join3(dirname2(hooksDir), "post-commit");
|
|
1696
|
+
}
|
|
1697
|
+
return join3(hooksDir, "post-commit");
|
|
1698
|
+
}
|
|
1699
|
+
async function installHook() {
|
|
1700
|
+
const hookPath = await getHookPath();
|
|
1701
|
+
await mkdir2(dirname2(hookPath), { recursive: true });
|
|
1400
1702
|
const command = await resolveHookCommand();
|
|
1401
1703
|
if (existsSync3(hookPath)) {
|
|
1402
1704
|
const existing = await readFile4(hookPath, "utf-8");
|
|
@@ -1413,8 +1715,7 @@ ${hookSection}` : generateHookScript(command);
|
|
|
1413
1715
|
return hookPath;
|
|
1414
1716
|
}
|
|
1415
1717
|
async function uninstallHook() {
|
|
1416
|
-
const
|
|
1417
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1718
|
+
const hookPath = await getHookPath();
|
|
1418
1719
|
if (!existsSync3(hookPath)) return false;
|
|
1419
1720
|
const content = await readFile4(hookPath, "utf-8");
|
|
1420
1721
|
if (!content.includes(HOOK_MARKER)) return false;
|
|
@@ -1427,8 +1728,7 @@ async function uninstallHook() {
|
|
|
1427
1728
|
return true;
|
|
1428
1729
|
}
|
|
1429
1730
|
async function isHookInstalled() {
|
|
1430
|
-
const
|
|
1431
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1731
|
+
const hookPath = await getHookPath();
|
|
1432
1732
|
if (!existsSync3(hookPath)) return false;
|
|
1433
1733
|
const content = await readFile4(hookPath, "utf-8");
|
|
1434
1734
|
return content.includes(HOOK_MARKER);
|
|
@@ -1512,6 +1812,25 @@ Retry:
|
|
|
1512
1812
|
diffowl review --commit ${failure.commit}
|
|
1513
1813
|
diffowl review --commit ${failure.commit} --depth shallow`;
|
|
1514
1814
|
}
|
|
1815
|
+
function isHookQueueStopFailure(message) {
|
|
1816
|
+
if (!message || message === "Review started.") {
|
|
1817
|
+
return false;
|
|
1818
|
+
}
|
|
1819
|
+
const normalized = message.toLowerCase();
|
|
1820
|
+
if (isQuotaOrRateLimitError(normalized)) {
|
|
1821
|
+
return true;
|
|
1822
|
+
}
|
|
1823
|
+
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")) {
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
if (normalized.includes("opencode not found") || normalized.includes("opencode: command not found") || normalized.includes("enoent") && normalized.includes("opencode")) {
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
return false;
|
|
1833
|
+
}
|
|
1515
1834
|
async function runHookReview() {
|
|
1516
1835
|
const dir = await ensureDiffOwlDir();
|
|
1517
1836
|
const logFile = join3(dir, "hook.log");
|
|
@@ -1536,7 +1855,7 @@ async function runHookReview() {
|
|
|
1536
1855
|
const prefix = command.pathDirs?.join(":");
|
|
1537
1856
|
const existingPath = process.env["PATH"] ?? "";
|
|
1538
1857
|
const envPath = prefix ? `${prefix}:${existingPath}` : existingPath;
|
|
1539
|
-
const subprocess = execa2(
|
|
1858
|
+
const subprocess = execa2(command.node, [fileURLToPath(import.meta.url), "hook-worker"], {
|
|
1540
1859
|
detached: true,
|
|
1541
1860
|
cleanup: false,
|
|
1542
1861
|
cwd: process.cwd(),
|
|
@@ -1604,6 +1923,18 @@ async function runPendingHookReviews() {
|
|
|
1604
1923
|
if (status?.exitCode !== 0 || status.message) {
|
|
1605
1924
|
if (status && status.exitCode !== 0) {
|
|
1606
1925
|
await writeHookStatus(status.exitCode, status.commit, status.message, null, dir);
|
|
1926
|
+
if (isHookQueueStopFailure(status.message)) {
|
|
1927
|
+
const remaining = (await listPendingReviews(dir)).filter((item) => item.sha !== next.sha);
|
|
1928
|
+
if (remaining.length > 0) {
|
|
1929
|
+
await appendFile(
|
|
1930
|
+
logFile,
|
|
1931
|
+
`diffowl: stopping hook queue after ${next.sha}; ${remaining.length} pending review(s) left for later (${status.message})
|
|
1932
|
+
`,
|
|
1933
|
+
"utf-8"
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1607
1938
|
}
|
|
1608
1939
|
continue;
|
|
1609
1940
|
}
|
|
@@ -1730,13 +2061,12 @@ function isHookReviewLockActive(lockFile) {
|
|
|
1730
2061
|
}
|
|
1731
2062
|
}
|
|
1732
2063
|
async function checkHookStale() {
|
|
1733
|
-
let
|
|
2064
|
+
let hookPath;
|
|
1734
2065
|
try {
|
|
1735
|
-
|
|
2066
|
+
hookPath = await getHookPath();
|
|
1736
2067
|
} catch {
|
|
1737
2068
|
return { installed: false, stale: false, reason: "Not a git repository" };
|
|
1738
2069
|
}
|
|
1739
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1740
2070
|
if (!existsSync3(hookPath)) {
|
|
1741
2071
|
return { installed: false, stale: false, reason: "No post-commit hook found" };
|
|
1742
2072
|
}
|
|
@@ -1781,6 +2111,9 @@ function extractManagedSection(content) {
|
|
|
1781
2111
|
if (ourEnd === -1) return void 0;
|
|
1782
2112
|
return lines.slice(ourStart, ourEnd + 1).join("\n");
|
|
1783
2113
|
}
|
|
2114
|
+
async function getHookCommand() {
|
|
2115
|
+
return resolveHookCommand();
|
|
2116
|
+
}
|
|
1784
2117
|
async function resolveHookCommand() {
|
|
1785
2118
|
const diffowl = await resolveCommand("diffowl");
|
|
1786
2119
|
const opencode = await resolveCommand("opencode");
|
|
@@ -1845,11 +2178,13 @@ function generateManagedSection(command) {
|
|
|
1845
2178
|
const diffowlPathFallback = isPath ? `elif [ -x ${quotedDiffOwl} ]; then
|
|
1846
2179
|
${quotedDiffOwl} hook-run
|
|
1847
2180
|
` : "";
|
|
1848
|
-
const
|
|
2181
|
+
const nodeCliRun = `if [ -x ${quotedNode} ] && [ -f ${quotedCli} ]; then
|
|
1849
2182
|
${quotedNode} ${quotedCli} hook-run
|
|
1850
|
-
|
|
2183
|
+
`;
|
|
2184
|
+
const commandRun = `elif command -v diffowl >/dev/null 2>&1; then
|
|
1851
2185
|
diffowl hook-run
|
|
1852
|
-
|
|
2186
|
+
`;
|
|
2187
|
+
const runBlock = `${nodeCliRun}${diffowlPathFallback}${commandRun}else
|
|
1853
2188
|
echo "diffowl: review not started; diffowl command not found or not executable; log: $DIFFOWL_LOG_FILE"
|
|
1854
2189
|
echo "diffowl: review not started at $(date); diffowl command not found or not executable" >>"$DIFFOWL_LOG_FILE"
|
|
1855
2190
|
fi`;
|
|
@@ -1880,57 +2215,64 @@ function shellQuote(value) {
|
|
|
1880
2215
|
|
|
1881
2216
|
// src/git/diff.ts
|
|
1882
2217
|
import { execa as execa3 } from "execa";
|
|
1883
|
-
import { basename, extname } from "path";
|
|
2218
|
+
import { basename as basename2, extname } from "path";
|
|
1884
2219
|
var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
1885
|
-
async function getResolvedCommitDiff(commit) {
|
|
1886
|
-
const raw = await collectGitDiff(
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2220
|
+
async function getResolvedCommitDiff(commit, cwd) {
|
|
2221
|
+
const raw = await collectGitDiff(
|
|
2222
|
+
[
|
|
2223
|
+
"-c",
|
|
2224
|
+
"diff.noprefix=false",
|
|
2225
|
+
"-c",
|
|
2226
|
+
"diff.mnemonicprefix=false",
|
|
2227
|
+
"show",
|
|
2228
|
+
"--format=",
|
|
2229
|
+
"--diff-merges=combined",
|
|
2230
|
+
"--stat",
|
|
2231
|
+
"--patch",
|
|
2232
|
+
commit
|
|
2233
|
+
],
|
|
2234
|
+
cwd
|
|
2235
|
+
);
|
|
1898
2236
|
return parseDiff(raw.stdout, raw.diagnostics);
|
|
1899
2237
|
}
|
|
1900
|
-
async function resolveCommitRef(ref) {
|
|
2238
|
+
async function resolveCommitRef(ref, cwd) {
|
|
1901
2239
|
const trimmed = ref.trim();
|
|
1902
2240
|
if (trimmed === "") {
|
|
1903
2241
|
throw new Error("Commit ref must not be empty.");
|
|
1904
2242
|
}
|
|
1905
2243
|
try {
|
|
1906
|
-
const { stdout } = await execa3(
|
|
1907
|
-
"
|
|
1908
|
-
"--verify",
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
`${trimmed}^{commit}`
|
|
1912
|
-
]);
|
|
2244
|
+
const { stdout } = await execa3(
|
|
2245
|
+
"git",
|
|
2246
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", `${trimmed}^{commit}`],
|
|
2247
|
+
cwd ? { cwd } : {}
|
|
2248
|
+
);
|
|
1913
2249
|
return stdout.trim();
|
|
1914
2250
|
} catch {
|
|
1915
2251
|
throw new Error(`Invalid commit ref: ${ref}`);
|
|
1916
2252
|
}
|
|
1917
2253
|
}
|
|
1918
|
-
async function getStagedDiff() {
|
|
1919
|
-
const raw = await collectGitDiff(
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2254
|
+
async function getStagedDiff(cwd) {
|
|
2255
|
+
const raw = await collectGitDiff(
|
|
2256
|
+
[
|
|
2257
|
+
"-c",
|
|
2258
|
+
"diff.noprefix=false",
|
|
2259
|
+
"-c",
|
|
2260
|
+
"diff.mnemonicprefix=false",
|
|
2261
|
+
"diff",
|
|
2262
|
+
"--staged",
|
|
2263
|
+
"--stat",
|
|
2264
|
+
"--patch"
|
|
2265
|
+
],
|
|
2266
|
+
cwd
|
|
2267
|
+
);
|
|
1929
2268
|
return parseDiff(raw.stdout, raw.diagnostics);
|
|
1930
2269
|
}
|
|
1931
|
-
async function collectGitDiff(args) {
|
|
2270
|
+
async function collectGitDiff(args, cwd) {
|
|
1932
2271
|
try {
|
|
1933
|
-
const { stdout } = await execa3("git", args, {
|
|
2272
|
+
const { stdout } = await execa3("git", args, {
|
|
2273
|
+
maxBuffer: MAX_DIFF_OUTPUT_BYTES,
|
|
2274
|
+
...cwd ? { cwd } : {}
|
|
2275
|
+
});
|
|
1934
2276
|
return { stdout, diagnostics: [] };
|
|
1935
2277
|
} catch (err) {
|
|
1936
2278
|
if (isMaxBufferError(err)) {
|
|
@@ -2173,7 +2515,7 @@ var DOC_BASENAME_PATTERNS = [
|
|
|
2173
2515
|
/^TODO/i
|
|
2174
2516
|
];
|
|
2175
2517
|
function isDocFile(path) {
|
|
2176
|
-
const base =
|
|
2518
|
+
const base = basename2(path);
|
|
2177
2519
|
const extension = extname(base).toLowerCase();
|
|
2178
2520
|
if (DOC_EXTENSIONS.has(extension)) return true;
|
|
2179
2521
|
if (extension) return false;
|
|
@@ -2184,7 +2526,7 @@ function isDocOnlyDiff(diff) {
|
|
|
2184
2526
|
}
|
|
2185
2527
|
|
|
2186
2528
|
// src/review/context.ts
|
|
2187
|
-
import { basename as
|
|
2529
|
+
import { basename as basename4, dirname as dirname3, extname as extname5, join as join6 } from "path";
|
|
2188
2530
|
import picomatch from "picomatch";
|
|
2189
2531
|
|
|
2190
2532
|
// src/review/ast/index.ts
|
|
@@ -2384,7 +2726,7 @@ function isCodePath(path) {
|
|
|
2384
2726
|
}
|
|
2385
2727
|
|
|
2386
2728
|
// src/review/context-references.ts
|
|
2387
|
-
import { basename as
|
|
2729
|
+
import { basename as basename3, extname as extname3 } from "path";
|
|
2388
2730
|
var MAX_REFERENCES_PER_TERM = 8;
|
|
2389
2731
|
var MAX_REFERENCE_TERMS = 8;
|
|
2390
2732
|
var MAX_REFERENCE_LINE_CHARS = 220;
|
|
@@ -2399,7 +2741,7 @@ async function buildReferenceContexts(source, changedFiles, skippedFiles, diagno
|
|
|
2399
2741
|
...skippedFiles.map((file) => file.path)
|
|
2400
2742
|
]);
|
|
2401
2743
|
for (const file of changedFiles) {
|
|
2402
|
-
terms.add(
|
|
2744
|
+
terms.add(basename3(file.file.path, extname3(file.file.path)));
|
|
2403
2745
|
for (const symbol of file.symbols.slice(0, 4)) {
|
|
2404
2746
|
terms.add(symbol);
|
|
2405
2747
|
}
|
|
@@ -2818,24 +3160,24 @@ async function loadReviewSnapshot(root, target) {
|
|
|
2818
3160
|
return {
|
|
2819
3161
|
root,
|
|
2820
3162
|
target,
|
|
2821
|
-
diff: await getStagedDiff(),
|
|
3163
|
+
diff: await getStagedDiff(root),
|
|
2822
3164
|
source: createGitContextSource(root, { kind: "staged" })
|
|
2823
3165
|
};
|
|
2824
3166
|
case "commit": {
|
|
2825
|
-
const sha = await resolveCommitRef(target.ref);
|
|
3167
|
+
const sha = await resolveCommitRef(target.ref, root);
|
|
2826
3168
|
return {
|
|
2827
3169
|
root,
|
|
2828
3170
|
target,
|
|
2829
|
-
diff: await getResolvedCommitDiff(sha),
|
|
3171
|
+
diff: await getResolvedCommitDiff(sha, root),
|
|
2830
3172
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
2831
3173
|
};
|
|
2832
3174
|
}
|
|
2833
3175
|
case "last-commit": {
|
|
2834
|
-
const sha = await resolveCommitRef("HEAD");
|
|
3176
|
+
const sha = await resolveCommitRef("HEAD", root);
|
|
2835
3177
|
return {
|
|
2836
3178
|
root,
|
|
2837
3179
|
target,
|
|
2838
|
-
diff: await getResolvedCommitDiff(sha),
|
|
3180
|
+
diff: await getResolvedCommitDiff(sha, root),
|
|
2839
3181
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
2840
3182
|
};
|
|
2841
3183
|
}
|
|
@@ -2941,7 +3283,7 @@ async function buildRelatedFileContexts(source, files) {
|
|
|
2941
3283
|
return related;
|
|
2942
3284
|
}
|
|
2943
3285
|
function shouldReviewFile(path, config) {
|
|
2944
|
-
if (LOCKFILE_EXCLUDES.has(
|
|
3286
|
+
if (LOCKFILE_EXCLUDES.has(basename4(path))) return false;
|
|
2945
3287
|
const include = config.include.length > 0 ? config.include : ["**/*"];
|
|
2946
3288
|
if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
|
|
2947
3289
|
return false;
|
|
@@ -3062,7 +3404,7 @@ function getChangedLinesByFile(rawDiff) {
|
|
|
3062
3404
|
function testCandidates(path) {
|
|
3063
3405
|
const dir = dirname3(path);
|
|
3064
3406
|
const ext = extname5(path);
|
|
3065
|
-
const base =
|
|
3407
|
+
const base = basename4(path, ext);
|
|
3066
3408
|
return [
|
|
3067
3409
|
join6(dir, `${base}.test${ext}`),
|
|
3068
3410
|
join6(dir, `${base}.spec${ext}`),
|
|
@@ -3089,12 +3431,50 @@ function addUniqueDiagnostics(target, diagnostics) {
|
|
|
3089
3431
|
}
|
|
3090
3432
|
}
|
|
3091
3433
|
|
|
3434
|
+
// src/review/filters.ts
|
|
3435
|
+
function filterFindingsByConfidence(findings, minConfidence) {
|
|
3436
|
+
const levels = ["low", "medium", "high"];
|
|
3437
|
+
const minIndex = levels.indexOf(minConfidence);
|
|
3438
|
+
const kept = findings.filter((finding) => {
|
|
3439
|
+
const index = levels.indexOf(finding.confidence.toLowerCase());
|
|
3440
|
+
return index >= minIndex;
|
|
3441
|
+
});
|
|
3442
|
+
return { findings: kept, dropped: findings.length - kept.length };
|
|
3443
|
+
}
|
|
3444
|
+
function filterFindingsByChangedFiles(findings, changedFiles) {
|
|
3445
|
+
const kept = [];
|
|
3446
|
+
const suppressed = [];
|
|
3447
|
+
for (const finding of findings) {
|
|
3448
|
+
if (changedFiles.has(finding.file)) {
|
|
3449
|
+
kept.push(finding);
|
|
3450
|
+
} else {
|
|
3451
|
+
suppressed.push(finding);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
return { findings: kept, suppressed };
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3092
3457
|
// src/review/formatter.ts
|
|
3093
3458
|
import chalk from "chalk";
|
|
3094
3459
|
import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
|
|
3095
3460
|
import { existsSync as existsSync4 } from "fs";
|
|
3096
3461
|
import { join as join7 } from "path";
|
|
3097
3462
|
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
3463
|
+
var REPORT_SCHEMA_VERSION = 1;
|
|
3464
|
+
function formatFindingHeading(index, finding) {
|
|
3465
|
+
const ordinal = `Finding ${index + 1}`;
|
|
3466
|
+
if (!finding.durable) {
|
|
3467
|
+
return `#### ${ordinal}`;
|
|
3468
|
+
}
|
|
3469
|
+
const classification = formatFindingClassification(finding.durable);
|
|
3470
|
+
return `#### ${ordinal} (\`${finding.durable.id}\`) \u2014 ${classification}`;
|
|
3471
|
+
}
|
|
3472
|
+
function formatFindingClassification(durable) {
|
|
3473
|
+
if (durable.lifecycleSuppressed) {
|
|
3474
|
+
return `**suppressed (${durable.status})**`;
|
|
3475
|
+
}
|
|
3476
|
+
return `**${durable.classification}**`;
|
|
3477
|
+
}
|
|
3098
3478
|
function renderMarkdown(report) {
|
|
3099
3479
|
const lines = [];
|
|
3100
3480
|
lines.push("### Summary");
|
|
@@ -3105,7 +3485,7 @@ function renderMarkdown(report) {
|
|
|
3105
3485
|
lines.push("No issues were reported.");
|
|
3106
3486
|
} else {
|
|
3107
3487
|
for (const [index, finding] of report.findings.entries()) {
|
|
3108
|
-
lines.push(
|
|
3488
|
+
lines.push(formatFindingHeading(index, finding));
|
|
3109
3489
|
lines.push(`**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}**`);
|
|
3110
3490
|
lines.push(finding.title.trim());
|
|
3111
3491
|
lines.push("");
|
|
@@ -3120,10 +3500,10 @@ function renderMarkdown(report) {
|
|
|
3120
3500
|
if (report.suppressedFindings && report.suppressedFindings.length > 0) {
|
|
3121
3501
|
lines.push("");
|
|
3122
3502
|
lines.push("### Suppressed Findings");
|
|
3123
|
-
lines.push("These findings
|
|
3503
|
+
lines.push("These findings were excluded from the actionable review set.");
|
|
3124
3504
|
lines.push("");
|
|
3125
3505
|
for (const [index, finding] of report.suppressedFindings.entries()) {
|
|
3126
|
-
lines.push(
|
|
3506
|
+
lines.push(formatFindingHeading(report.findings.length + index, finding));
|
|
3127
3507
|
lines.push(
|
|
3128
3508
|
`**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}** (${finding.confidence} confidence)`
|
|
3129
3509
|
);
|
|
@@ -3177,10 +3557,22 @@ function parseReviewMetadata(content) {
|
|
|
3177
3557
|
if (!diffowl || typeof diffowl !== "object") return void 0;
|
|
3178
3558
|
const sessionId = diffowl.session_id;
|
|
3179
3559
|
const projectRoot = diffowl.project_root;
|
|
3560
|
+
const schemaVersion = diffowl.schema_version;
|
|
3561
|
+
const reviewId = diffowl.review_id;
|
|
3180
3562
|
if (typeof sessionId !== "string" || sessionId.trim() === "" || typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
3181
3563
|
return void 0;
|
|
3182
3564
|
}
|
|
3183
|
-
|
|
3565
|
+
const metadata = {
|
|
3566
|
+
session_id: sessionId,
|
|
3567
|
+
project_root: projectRoot
|
|
3568
|
+
};
|
|
3569
|
+
if (typeof schemaVersion === "number" && Number.isInteger(schemaVersion) && schemaVersion > 0) {
|
|
3570
|
+
metadata.schema_version = schemaVersion;
|
|
3571
|
+
}
|
|
3572
|
+
if (typeof reviewId === "string" && reviewId.trim() !== "") {
|
|
3573
|
+
metadata.review_id = reviewId;
|
|
3574
|
+
}
|
|
3575
|
+
return metadata;
|
|
3184
3576
|
}
|
|
3185
3577
|
function renderReviewFrontmatter(metadata) {
|
|
3186
3578
|
return `---
|
|
@@ -3288,7 +3680,7 @@ function formatExcludedCandidateSummary(belowConfidence, outsideChangedFiles) {
|
|
|
3288
3680
|
|
|
3289
3681
|
// src/review/report-path.ts
|
|
3290
3682
|
import { readFile as readFile6, readdir as readdir2 } from "fs/promises";
|
|
3291
|
-
import { basename as
|
|
3683
|
+
import { basename as basename5, isAbsolute, join as join8, resolve } from "path";
|
|
3292
3684
|
function resolveReviewReportPath(report) {
|
|
3293
3685
|
if (isAbsolute(report)) return report;
|
|
3294
3686
|
if (report.includes("/") || report.includes("\\")) {
|
|
@@ -3302,7 +3694,7 @@ async function listReviewReportPaths() {
|
|
|
3302
3694
|
listMarkdownFiles(reviews),
|
|
3303
3695
|
listMarkdownFiles(join8(reviews, "resolved"))
|
|
3304
3696
|
]);
|
|
3305
|
-
return entries.flat().filter((path) =>
|
|
3697
|
+
return entries.flat().filter((path) => basename5(path) !== "latest.md").sort((a, b) => basename5(b).localeCompare(basename5(a)));
|
|
3306
3698
|
}
|
|
3307
3699
|
function canSelectReviewInteractively(inputIsTTY, outputIsTTY) {
|
|
3308
3700
|
return inputIsTTY === true && outputIsTTY === true;
|
|
@@ -3331,58 +3723,1680 @@ async function listMarkdownFiles(dir) {
|
|
|
3331
3723
|
return reports.filter((path) => path !== void 0);
|
|
3332
3724
|
}
|
|
3333
3725
|
|
|
3334
|
-
// src/
|
|
3335
|
-
import {
|
|
3336
|
-
import { basename as basename5, dirname as dirname4 } from "path";
|
|
3337
|
-
import { execa as execa5 } from "execa";
|
|
3726
|
+
// src/state/persist.ts
|
|
3727
|
+
import { createHash as createHash2 } from "crypto";
|
|
3338
3728
|
|
|
3339
|
-
//
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3729
|
+
// src/state/db.ts
|
|
3730
|
+
import { mkdir as mkdir4 } from "fs/promises";
|
|
3731
|
+
import { join as join9 } from "path";
|
|
3732
|
+
|
|
3733
|
+
// src/state/migrations/001-initial-schema.ts
|
|
3734
|
+
var MIGRATION_001_INITIAL_SCHEMA = `
|
|
3735
|
+
CREATE TABLE schema_migrations (
|
|
3736
|
+
version INTEGER PRIMARY KEY,
|
|
3737
|
+
applied_at TEXT NOT NULL
|
|
3738
|
+
);
|
|
3739
|
+
|
|
3740
|
+
CREATE TABLE reviews (
|
|
3741
|
+
id TEXT PRIMARY KEY,
|
|
3742
|
+
created_at TEXT NOT NULL,
|
|
3743
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('staged', 'commit', 'last-commit')),
|
|
3744
|
+
target_ref TEXT,
|
|
3745
|
+
target_commit TEXT,
|
|
3746
|
+
diff_hash TEXT NOT NULL,
|
|
3747
|
+
model TEXT NOT NULL,
|
|
3748
|
+
reasoning TEXT NOT NULL,
|
|
3749
|
+
depth TEXT NOT NULL,
|
|
3750
|
+
session_id TEXT NOT NULL,
|
|
3751
|
+
summary TEXT NOT NULL,
|
|
3752
|
+
report_path TEXT,
|
|
3753
|
+
diagnostics_json TEXT NOT NULL DEFAULT '[]',
|
|
3754
|
+
timings_json TEXT NOT NULL DEFAULT '[]',
|
|
3755
|
+
skipped_reason TEXT
|
|
3756
|
+
);
|
|
3757
|
+
|
|
3758
|
+
CREATE TABLE findings (
|
|
3759
|
+
id TEXT PRIMARY KEY,
|
|
3760
|
+
fingerprint TEXT NOT NULL UNIQUE,
|
|
3761
|
+
status TEXT NOT NULL CHECK (status IN ('open', 'deferred', 'dismissed', 'fixed', 'regressed')),
|
|
3762
|
+
first_review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3763
|
+
last_review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3764
|
+
created_at TEXT NOT NULL,
|
|
3765
|
+
updated_at TEXT NOT NULL
|
|
3766
|
+
);
|
|
3767
|
+
|
|
3768
|
+
CREATE INDEX idx_findings_status ON findings(status);
|
|
3769
|
+
|
|
3770
|
+
CREATE TABLE finding_observations (
|
|
3771
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3772
|
+
review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
3773
|
+
finding_id TEXT NOT NULL REFERENCES findings(id),
|
|
3774
|
+
file TEXT NOT NULL,
|
|
3775
|
+
line INTEGER NOT NULL,
|
|
3776
|
+
severity TEXT NOT NULL CHECK (severity IN ('error', 'warning', 'info')),
|
|
3777
|
+
confidence TEXT NOT NULL CHECK (confidence IN ('low', 'medium', 'high')),
|
|
3778
|
+
title TEXT NOT NULL,
|
|
3779
|
+
body TEXT NOT NULL,
|
|
3780
|
+
evidence TEXT,
|
|
3781
|
+
ordinal INTEGER NOT NULL,
|
|
3782
|
+
classification TEXT NOT NULL CHECK (classification IN ('new', 'existing', 'regressed')),
|
|
3783
|
+
UNIQUE (review_id, finding_id)
|
|
3784
|
+
);
|
|
3785
|
+
|
|
3786
|
+
CREATE TABLE finding_events (
|
|
3787
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3788
|
+
finding_id TEXT NOT NULL REFERENCES findings(id),
|
|
3789
|
+
review_id TEXT REFERENCES reviews(id),
|
|
3790
|
+
event_type TEXT NOT NULL CHECK (
|
|
3791
|
+
event_type IN ('observed', 'dismissed', 'deferred', 'fixed', 'reopened', 'regressed')
|
|
3792
|
+
),
|
|
3793
|
+
actor TEXT NOT NULL CHECK (actor IN ('user', 'agent')),
|
|
3794
|
+
reason TEXT,
|
|
3795
|
+
commit_ref TEXT,
|
|
3796
|
+
verification_json TEXT,
|
|
3797
|
+
created_at TEXT NOT NULL
|
|
3798
|
+
);
|
|
3799
|
+
|
|
3800
|
+
CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
|
|
3801
|
+
`;
|
|
3802
|
+
|
|
3803
|
+
// src/state/sqlite.ts
|
|
3804
|
+
var sqliteModule;
|
|
3805
|
+
async function openSqliteDatabase(path) {
|
|
3806
|
+
const { DatabaseSync } = await loadSqliteModule();
|
|
3807
|
+
return new NodeSqliteDatabase(new DatabaseSync(path));
|
|
3808
|
+
}
|
|
3809
|
+
async function loadSqliteModule() {
|
|
3810
|
+
sqliteModule ??= importNodeSqliteWithoutWarning();
|
|
3811
|
+
return sqliteModule;
|
|
3812
|
+
}
|
|
3813
|
+
async function importNodeSqliteWithoutWarning() {
|
|
3814
|
+
const emitWarning = process.emitWarning;
|
|
3815
|
+
process.emitWarning = function suppressNodeSqliteExperimentalWarning(warning, ...args) {
|
|
3816
|
+
const message = typeof warning === "string" ? warning : warning.message;
|
|
3817
|
+
if (message.includes("SQLite is an experimental feature")) {
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
emitWarning.call(process, warning, ...args);
|
|
3821
|
+
};
|
|
3822
|
+
try {
|
|
3823
|
+
const nodeSqlite = ["node", "sqlite"].join(":");
|
|
3824
|
+
return await import(nodeSqlite);
|
|
3825
|
+
} finally {
|
|
3826
|
+
process.emitWarning = emitWarning;
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
var NodeSqliteDatabase = class {
|
|
3830
|
+
constructor(db) {
|
|
3831
|
+
this.db = db;
|
|
3832
|
+
}
|
|
3833
|
+
db;
|
|
3834
|
+
#open = true;
|
|
3835
|
+
#transactionDepth = 0;
|
|
3836
|
+
get open() {
|
|
3837
|
+
return this.#open;
|
|
3838
|
+
}
|
|
3839
|
+
exec(sql) {
|
|
3840
|
+
this.db.exec(sql);
|
|
3841
|
+
}
|
|
3842
|
+
prepare(sql) {
|
|
3843
|
+
return new NodeSqliteStatement(this.db.prepare(sql));
|
|
3844
|
+
}
|
|
3845
|
+
pragma(sql, options = {}) {
|
|
3846
|
+
const rows = this.prepare(`PRAGMA ${sql}`).all();
|
|
3847
|
+
if (!options.simple) {
|
|
3848
|
+
return rows;
|
|
3849
|
+
}
|
|
3850
|
+
const first = rows[0];
|
|
3851
|
+
if (!first || typeof first !== "object") {
|
|
3852
|
+
return void 0;
|
|
3853
|
+
}
|
|
3854
|
+
return Object.values(first)[0];
|
|
3855
|
+
}
|
|
3856
|
+
transaction(fn) {
|
|
3857
|
+
return ((...args) => {
|
|
3858
|
+
const depth = this.#transactionDepth;
|
|
3859
|
+
const savepoint = `diffowl_tx_${depth}`;
|
|
3860
|
+
this.#transactionDepth++;
|
|
3861
|
+
try {
|
|
3862
|
+
this.exec(depth === 0 ? "BEGIN" : `SAVEPOINT ${savepoint}`);
|
|
3863
|
+
const result = fn(...args);
|
|
3864
|
+
this.exec(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`);
|
|
3865
|
+
return result;
|
|
3866
|
+
} catch (error) {
|
|
3867
|
+
try {
|
|
3868
|
+
this.exec(depth === 0 ? "ROLLBACK" : `ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
3869
|
+
if (depth > 0) {
|
|
3870
|
+
this.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
3871
|
+
}
|
|
3872
|
+
} catch {
|
|
3873
|
+
}
|
|
3874
|
+
throw error;
|
|
3875
|
+
} finally {
|
|
3876
|
+
this.#transactionDepth--;
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
close() {
|
|
3881
|
+
this.db.close();
|
|
3882
|
+
this.#open = false;
|
|
3883
|
+
}
|
|
3884
|
+
};
|
|
3885
|
+
var NodeSqliteStatement = class {
|
|
3886
|
+
constructor(statement) {
|
|
3887
|
+
this.statement = statement;
|
|
3888
|
+
}
|
|
3889
|
+
statement;
|
|
3890
|
+
get(...params) {
|
|
3891
|
+
return normalizeRow(this.namedStatement.get(...params));
|
|
3892
|
+
}
|
|
3893
|
+
all(...params) {
|
|
3894
|
+
return this.namedStatement.all(...params).map(normalizeRow);
|
|
3895
|
+
}
|
|
3896
|
+
run(...params) {
|
|
3897
|
+
const result = this.namedStatement.run(...params);
|
|
3898
|
+
return {
|
|
3899
|
+
changes: Number(result.changes),
|
|
3900
|
+
lastInsertRowid: result.lastInsertRowid
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
get namedStatement() {
|
|
3904
|
+
return this.statement;
|
|
3905
|
+
}
|
|
3906
|
+
};
|
|
3907
|
+
function normalizeRow(row) {
|
|
3908
|
+
if (!row || typeof row !== "object" || Object.getPrototypeOf(row) !== null) {
|
|
3909
|
+
return row;
|
|
3910
|
+
}
|
|
3911
|
+
return { ...row };
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
// src/state/types.ts
|
|
3915
|
+
import { randomUUID } from "crypto";
|
|
3916
|
+
var CURRENT_SCHEMA_VERSION = 1;
|
|
3917
|
+
function createReviewId() {
|
|
3918
|
+
return `rev_${randomUUID()}`;
|
|
3919
|
+
}
|
|
3920
|
+
function createFindingId() {
|
|
3921
|
+
return `fnd_${randomUUID()}`;
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
// src/state/db.ts
|
|
3925
|
+
var BUSY_TIMEOUT_MS = 5e3;
|
|
3926
|
+
var MIGRATIONS = {
|
|
3927
|
+
1: MIGRATION_001_INITIAL_SCHEMA
|
|
3928
|
+
};
|
|
3929
|
+
var StateDatabaseError = class extends Error {
|
|
3930
|
+
name = "StateDatabaseError";
|
|
3931
|
+
};
|
|
3932
|
+
var InvalidFindingTransitionError = class extends StateDatabaseError {
|
|
3933
|
+
name = "InvalidFindingTransitionError";
|
|
3934
|
+
};
|
|
3935
|
+
function getStateDbPath(diffOwlDir) {
|
|
3936
|
+
return join9(diffOwlDir, "state.db");
|
|
3937
|
+
}
|
|
3938
|
+
async function openStateDatabase(diffOwlDir) {
|
|
3939
|
+
await mkdir4(diffOwlDir, { recursive: true });
|
|
3940
|
+
const path = getStateDbPath(diffOwlDir);
|
|
3941
|
+
const db = await openSqliteDatabase(path);
|
|
3942
|
+
try {
|
|
3943
|
+
configureDatabase(db);
|
|
3944
|
+
assertCompatibleSchema(db);
|
|
3945
|
+
applyMigrations(db, CURRENT_SCHEMA_VERSION);
|
|
3946
|
+
return { db, path };
|
|
3947
|
+
} catch (error) {
|
|
3948
|
+
try {
|
|
3949
|
+
closeDatabaseConnection(db);
|
|
3950
|
+
} catch {
|
|
3951
|
+
}
|
|
3952
|
+
throw error;
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
function closeDatabaseConnection(db) {
|
|
3956
|
+
if (!db.open) {
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
try {
|
|
3960
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
3961
|
+
} finally {
|
|
3962
|
+
db.close();
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
function closeStateDatabase(state) {
|
|
3966
|
+
closeDatabaseConnection(state.db);
|
|
3967
|
+
}
|
|
3968
|
+
function configureDatabase(db) {
|
|
3969
|
+
db.pragma("journal_mode = WAL");
|
|
3970
|
+
db.pragma("foreign_keys = ON");
|
|
3971
|
+
db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
3972
|
+
}
|
|
3973
|
+
function assertCompatibleSchema(db) {
|
|
3974
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
3975
|
+
if (!table) {
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
const row = db.prepare("SELECT MAX(version) AS maxVersion FROM schema_migrations").get();
|
|
3979
|
+
const maxVersion = row?.maxVersion ?? 0;
|
|
3980
|
+
if (maxVersion > CURRENT_SCHEMA_VERSION) {
|
|
3981
|
+
throw new StateDatabaseError(
|
|
3982
|
+
`Database schema version ${maxVersion} is newer than supported version ${CURRENT_SCHEMA_VERSION}`
|
|
3983
|
+
);
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
function applyMigrations(db, targetVersion, migrations = MIGRATIONS) {
|
|
3987
|
+
assertCompatibleSchema(db);
|
|
3988
|
+
const appliedVersions = listAppliedMigrationVersions(db);
|
|
3989
|
+
for (let version = 1; version <= targetVersion; version++) {
|
|
3990
|
+
if (appliedVersions.includes(version)) {
|
|
3991
|
+
continue;
|
|
3992
|
+
}
|
|
3993
|
+
const sql = migrations[version];
|
|
3994
|
+
if (!sql) {
|
|
3995
|
+
throw new StateDatabaseError(`Missing migration for schema version ${version}`);
|
|
3996
|
+
}
|
|
3997
|
+
const migrate = db.transaction(() => {
|
|
3998
|
+
db.exec(sql);
|
|
3999
|
+
db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(
|
|
4000
|
+
version,
|
|
4001
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
4002
|
+
);
|
|
4003
|
+
});
|
|
4004
|
+
migrate();
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
function listAppliedMigrationVersions(db) {
|
|
4008
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
4009
|
+
if (!table) {
|
|
4010
|
+
return [];
|
|
4011
|
+
}
|
|
4012
|
+
const rows = db.prepare("SELECT version FROM schema_migrations ORDER BY version ASC").all();
|
|
4013
|
+
return rows.map((row) => row.version);
|
|
4014
|
+
}
|
|
4015
|
+
function runInTransaction(db, fn) {
|
|
4016
|
+
const transaction = db.transaction(fn);
|
|
4017
|
+
return transaction();
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
// src/state/fingerprint.ts
|
|
4021
|
+
import { createHash } from "crypto";
|
|
4022
|
+
var FINGERPRINT_VERSION = 1;
|
|
4023
|
+
function normalizeFingerprintText(text) {
|
|
4024
|
+
return text.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " ");
|
|
4025
|
+
}
|
|
4026
|
+
function computeFindingFingerprint(input) {
|
|
4027
|
+
const file = normalizeFingerprintText(input.file);
|
|
4028
|
+
const title = normalizeFingerprintText(input.title);
|
|
4029
|
+
const identitySource = input.evidence?.trim() ? input.evidence : input.body;
|
|
4030
|
+
const identity = normalizeFingerprintText(identitySource);
|
|
4031
|
+
const payload = `v${FINGERPRINT_VERSION}|${file}|${title}|${identity}`;
|
|
4032
|
+
const digest = createHash("sha256").update(payload, "utf8").digest("hex");
|
|
4033
|
+
return `v${FINGERPRINT_VERSION}:${digest}`;
|
|
4034
|
+
}
|
|
4035
|
+
function deduplicateFindingCandidates(candidates) {
|
|
4036
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4037
|
+
const deduped = [];
|
|
4038
|
+
for (const candidate of candidates) {
|
|
4039
|
+
const fingerprint = computeFindingFingerprint(candidate);
|
|
4040
|
+
if (seen.has(fingerprint)) {
|
|
4041
|
+
continue;
|
|
4042
|
+
}
|
|
4043
|
+
seen.add(fingerprint);
|
|
4044
|
+
deduped.push(candidate);
|
|
4045
|
+
}
|
|
4046
|
+
return deduped;
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
// src/state/repositories/events.ts
|
|
4050
|
+
var insertEventStatement = (db) => db.prepare(`
|
|
4051
|
+
INSERT INTO finding_events (
|
|
4052
|
+
finding_id,
|
|
4053
|
+
review_id,
|
|
4054
|
+
event_type,
|
|
4055
|
+
actor,
|
|
4056
|
+
reason,
|
|
4057
|
+
commit_ref,
|
|
4058
|
+
verification_json,
|
|
4059
|
+
created_at
|
|
4060
|
+
) VALUES (
|
|
4061
|
+
@findingId,
|
|
4062
|
+
@reviewId,
|
|
4063
|
+
@eventType,
|
|
4064
|
+
@actor,
|
|
4065
|
+
@reason,
|
|
4066
|
+
@commitRef,
|
|
4067
|
+
@verificationJson,
|
|
4068
|
+
@createdAt
|
|
4069
|
+
)
|
|
4070
|
+
`);
|
|
4071
|
+
var getEventStatement = (db) => db.prepare(`
|
|
4072
|
+
SELECT
|
|
4073
|
+
id,
|
|
4074
|
+
finding_id AS findingId,
|
|
4075
|
+
review_id AS reviewId,
|
|
4076
|
+
event_type AS eventType,
|
|
4077
|
+
actor,
|
|
4078
|
+
reason,
|
|
4079
|
+
commit_ref AS commitRef,
|
|
4080
|
+
verification_json AS verificationJson,
|
|
4081
|
+
created_at AS createdAt
|
|
4082
|
+
FROM finding_events
|
|
4083
|
+
WHERE id = ?
|
|
4084
|
+
`);
|
|
4085
|
+
function insertFindingEvent(db, input) {
|
|
4086
|
+
const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4087
|
+
const result = insertEventStatement(db).run({
|
|
4088
|
+
findingId: input.findingId,
|
|
4089
|
+
reviewId: input.reviewId ?? null,
|
|
4090
|
+
eventType: input.eventType,
|
|
4091
|
+
actor: input.actor,
|
|
4092
|
+
reason: input.reason ?? null,
|
|
4093
|
+
commitRef: input.commitRef ?? null,
|
|
4094
|
+
verificationJson: JSON.stringify(input.verification ?? []),
|
|
4095
|
+
createdAt
|
|
4096
|
+
});
|
|
4097
|
+
const row = getEventStatement(db).get(Number(result.lastInsertRowid));
|
|
4098
|
+
if (!row) {
|
|
4099
|
+
throw new Error(`Failed to load finding event ${String(result.lastInsertRowid)}.`);
|
|
4100
|
+
}
|
|
4101
|
+
return mapEventRow(row);
|
|
4102
|
+
}
|
|
4103
|
+
function listFindingEvents(db, findingId) {
|
|
4104
|
+
const rows = db.prepare(`
|
|
4105
|
+
SELECT
|
|
4106
|
+
id,
|
|
4107
|
+
finding_id AS findingId,
|
|
4108
|
+
review_id AS reviewId,
|
|
4109
|
+
event_type AS eventType,
|
|
4110
|
+
actor,
|
|
4111
|
+
reason,
|
|
4112
|
+
commit_ref AS commitRef,
|
|
4113
|
+
verification_json AS verificationJson,
|
|
4114
|
+
created_at AS createdAt
|
|
4115
|
+
FROM finding_events
|
|
4116
|
+
WHERE finding_id = ?
|
|
4117
|
+
ORDER BY id ASC
|
|
4118
|
+
`).all(findingId);
|
|
4119
|
+
return rows.map(mapEventRow);
|
|
4120
|
+
}
|
|
4121
|
+
function mapEventRow(row) {
|
|
4122
|
+
return {
|
|
4123
|
+
id: row.id,
|
|
4124
|
+
findingId: row.findingId,
|
|
4125
|
+
reviewId: row.reviewId,
|
|
4126
|
+
eventType: row.eventType,
|
|
4127
|
+
actor: row.actor,
|
|
4128
|
+
reason: row.reason,
|
|
4129
|
+
commitRef: row.commitRef,
|
|
4130
|
+
verification: JSON.parse(row.verificationJson),
|
|
4131
|
+
createdAt: row.createdAt
|
|
4132
|
+
};
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
// src/state/repositories/findings.ts
|
|
4136
|
+
var insertFindingStatement = (db) => db.prepare(`
|
|
4137
|
+
INSERT INTO findings (
|
|
4138
|
+
id,
|
|
4139
|
+
fingerprint,
|
|
4140
|
+
status,
|
|
4141
|
+
first_review_id,
|
|
4142
|
+
last_review_id,
|
|
4143
|
+
created_at,
|
|
4144
|
+
updated_at
|
|
4145
|
+
) VALUES (
|
|
4146
|
+
@id,
|
|
4147
|
+
@fingerprint,
|
|
4148
|
+
@status,
|
|
4149
|
+
@firstReviewId,
|
|
4150
|
+
@lastReviewId,
|
|
4151
|
+
@createdAt,
|
|
4152
|
+
@updatedAt
|
|
4153
|
+
)
|
|
4154
|
+
`);
|
|
4155
|
+
var getFindingByIdStatement = (db) => db.prepare(`
|
|
4156
|
+
SELECT
|
|
4157
|
+
id,
|
|
4158
|
+
fingerprint,
|
|
4159
|
+
status,
|
|
4160
|
+
first_review_id AS firstReviewId,
|
|
4161
|
+
last_review_id AS lastReviewId,
|
|
4162
|
+
created_at AS createdAt,
|
|
4163
|
+
updated_at AS updatedAt
|
|
4164
|
+
FROM findings
|
|
4165
|
+
WHERE id = ?
|
|
4166
|
+
`);
|
|
4167
|
+
var getFindingByFingerprintStatement = (db) => db.prepare(`
|
|
4168
|
+
SELECT
|
|
4169
|
+
id,
|
|
4170
|
+
fingerprint,
|
|
4171
|
+
status,
|
|
4172
|
+
first_review_id AS firstReviewId,
|
|
4173
|
+
last_review_id AS lastReviewId,
|
|
4174
|
+
created_at AS createdAt,
|
|
4175
|
+
updated_at AS updatedAt
|
|
4176
|
+
FROM findings
|
|
4177
|
+
WHERE fingerprint = ?
|
|
4178
|
+
`);
|
|
4179
|
+
function insertFinding(db, input) {
|
|
4180
|
+
const timestamp = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4181
|
+
const record = {
|
|
4182
|
+
id: input.id ?? createFindingId(),
|
|
4183
|
+
fingerprint: input.fingerprint,
|
|
4184
|
+
status: input.status,
|
|
4185
|
+
firstReviewId: input.firstReviewId,
|
|
4186
|
+
lastReviewId: input.lastReviewId,
|
|
4187
|
+
createdAt: timestamp,
|
|
4188
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
4189
|
+
};
|
|
4190
|
+
insertFindingStatement(db).run({
|
|
4191
|
+
id: record.id,
|
|
4192
|
+
fingerprint: record.fingerprint,
|
|
4193
|
+
status: record.status,
|
|
4194
|
+
firstReviewId: record.firstReviewId,
|
|
4195
|
+
lastReviewId: record.lastReviewId,
|
|
4196
|
+
createdAt: record.createdAt,
|
|
4197
|
+
updatedAt: record.updatedAt
|
|
4198
|
+
});
|
|
4199
|
+
return record;
|
|
4200
|
+
}
|
|
4201
|
+
function getFindingById(db, id) {
|
|
4202
|
+
const row = getFindingByIdStatement(db).get(id);
|
|
4203
|
+
return row;
|
|
4204
|
+
}
|
|
4205
|
+
function getFindingByFingerprint(db, fingerprint) {
|
|
4206
|
+
const row = getFindingByFingerprintStatement(db).get(fingerprint);
|
|
4207
|
+
return row;
|
|
4208
|
+
}
|
|
4209
|
+
function listFindingsByStatuses(db, statuses) {
|
|
4210
|
+
if (statuses.length === 0) {
|
|
4211
|
+
return [];
|
|
4212
|
+
}
|
|
4213
|
+
const placeholders = statuses.map(() => "?").join(", ");
|
|
4214
|
+
return db.prepare(`
|
|
4215
|
+
SELECT
|
|
4216
|
+
id,
|
|
4217
|
+
fingerprint,
|
|
4218
|
+
status,
|
|
4219
|
+
first_review_id AS firstReviewId,
|
|
4220
|
+
last_review_id AS lastReviewId,
|
|
4221
|
+
created_at AS createdAt,
|
|
4222
|
+
updated_at AS updatedAt
|
|
4223
|
+
FROM findings
|
|
4224
|
+
WHERE status IN (${placeholders})
|
|
4225
|
+
ORDER BY updated_at DESC, id ASC
|
|
4226
|
+
`).all(...statuses);
|
|
4227
|
+
}
|
|
4228
|
+
function listAllFindings(db) {
|
|
4229
|
+
return db.prepare(`
|
|
4230
|
+
SELECT
|
|
4231
|
+
id,
|
|
4232
|
+
fingerprint,
|
|
4233
|
+
status,
|
|
4234
|
+
first_review_id AS firstReviewId,
|
|
4235
|
+
last_review_id AS lastReviewId,
|
|
4236
|
+
created_at AS createdAt,
|
|
4237
|
+
updated_at AS updatedAt
|
|
4238
|
+
FROM findings
|
|
4239
|
+
ORDER BY updated_at DESC, id ASC
|
|
4240
|
+
`).all();
|
|
4241
|
+
}
|
|
4242
|
+
var updateFindingStatement = (db) => db.prepare(`
|
|
4243
|
+
UPDATE findings
|
|
4244
|
+
SET
|
|
4245
|
+
status = @status,
|
|
4246
|
+
last_review_id = @lastReviewId,
|
|
4247
|
+
updated_at = @updatedAt
|
|
4248
|
+
WHERE id = @id
|
|
4249
|
+
`);
|
|
4250
|
+
function updateFinding(db, id, updates) {
|
|
4251
|
+
const existing = getFindingById(db, id);
|
|
4252
|
+
if (!existing) {
|
|
4253
|
+
throw new StateDatabaseError(`Finding ${id} was not found.`);
|
|
4254
|
+
}
|
|
4255
|
+
const updatedAt = updates.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4256
|
+
updateFindingStatement(db).run({
|
|
4257
|
+
id,
|
|
4258
|
+
status: updates.status,
|
|
4259
|
+
lastReviewId: updates.lastReviewId,
|
|
4260
|
+
updatedAt
|
|
4261
|
+
});
|
|
4262
|
+
return {
|
|
4263
|
+
...existing,
|
|
4264
|
+
status: updates.status,
|
|
4265
|
+
lastReviewId: updates.lastReviewId,
|
|
4266
|
+
updatedAt
|
|
4267
|
+
};
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
// src/state/repositories/observations.ts
|
|
4271
|
+
var insertObservationStatement = (db) => db.prepare(`
|
|
4272
|
+
INSERT INTO finding_observations (
|
|
4273
|
+
review_id,
|
|
4274
|
+
finding_id,
|
|
4275
|
+
file,
|
|
4276
|
+
line,
|
|
4277
|
+
severity,
|
|
4278
|
+
confidence,
|
|
4279
|
+
title,
|
|
4280
|
+
body,
|
|
4281
|
+
evidence,
|
|
4282
|
+
ordinal,
|
|
4283
|
+
classification
|
|
4284
|
+
) VALUES (
|
|
4285
|
+
@reviewId,
|
|
4286
|
+
@findingId,
|
|
4287
|
+
@file,
|
|
4288
|
+
@line,
|
|
4289
|
+
@severity,
|
|
4290
|
+
@confidence,
|
|
4291
|
+
@title,
|
|
4292
|
+
@body,
|
|
4293
|
+
@evidence,
|
|
4294
|
+
@ordinal,
|
|
4295
|
+
@classification
|
|
4296
|
+
)
|
|
4297
|
+
`);
|
|
4298
|
+
var getObservationStatement = (db) => db.prepare(`
|
|
4299
|
+
SELECT
|
|
4300
|
+
id,
|
|
4301
|
+
review_id AS reviewId,
|
|
4302
|
+
finding_id AS findingId,
|
|
4303
|
+
file,
|
|
4304
|
+
line,
|
|
4305
|
+
severity,
|
|
4306
|
+
confidence,
|
|
4307
|
+
title,
|
|
4308
|
+
body,
|
|
4309
|
+
evidence,
|
|
4310
|
+
ordinal,
|
|
4311
|
+
classification
|
|
4312
|
+
FROM finding_observations
|
|
4313
|
+
WHERE review_id = ? AND finding_id = ?
|
|
4314
|
+
`);
|
|
4315
|
+
function insertObservation(db, input) {
|
|
4316
|
+
insertObservationStatement(db).run({
|
|
4317
|
+
reviewId: input.reviewId,
|
|
4318
|
+
findingId: input.findingId,
|
|
4319
|
+
file: input.file,
|
|
4320
|
+
line: input.line,
|
|
4321
|
+
severity: input.severity,
|
|
4322
|
+
confidence: input.confidence,
|
|
4323
|
+
title: input.title,
|
|
4324
|
+
body: input.body,
|
|
4325
|
+
evidence: input.evidence ?? null,
|
|
4326
|
+
ordinal: input.ordinal,
|
|
4327
|
+
classification: input.classification
|
|
4328
|
+
});
|
|
4329
|
+
const observation = getObservationStatement(db).get(input.reviewId, input.findingId);
|
|
4330
|
+
if (!observation) {
|
|
4331
|
+
throw new Error(`Failed to load observation for review ${input.reviewId}.`);
|
|
4332
|
+
}
|
|
4333
|
+
return observation;
|
|
4334
|
+
}
|
|
4335
|
+
function listObservationsForReview(db, reviewId) {
|
|
4336
|
+
return db.prepare(`
|
|
4337
|
+
SELECT
|
|
4338
|
+
id,
|
|
4339
|
+
review_id AS reviewId,
|
|
4340
|
+
finding_id AS findingId,
|
|
4341
|
+
file,
|
|
4342
|
+
line,
|
|
4343
|
+
severity,
|
|
4344
|
+
confidence,
|
|
4345
|
+
title,
|
|
4346
|
+
body,
|
|
4347
|
+
evidence,
|
|
4348
|
+
ordinal,
|
|
4349
|
+
classification
|
|
4350
|
+
FROM finding_observations
|
|
4351
|
+
WHERE review_id = ?
|
|
4352
|
+
ORDER BY ordinal ASC
|
|
4353
|
+
`).all(reviewId);
|
|
4354
|
+
}
|
|
4355
|
+
function countObservationsByFindingIds(db, findingIds) {
|
|
4356
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4357
|
+
if (findingIds.length === 0) {
|
|
4358
|
+
return counts;
|
|
4359
|
+
}
|
|
4360
|
+
const placeholders = findingIds.map(() => "?").join(", ");
|
|
4361
|
+
const rows = db.prepare(`
|
|
4362
|
+
SELECT finding_id AS findingId, COUNT(*) AS count
|
|
4363
|
+
FROM finding_observations
|
|
4364
|
+
WHERE finding_id IN (${placeholders})
|
|
4365
|
+
GROUP BY finding_id
|
|
4366
|
+
`).all(...findingIds);
|
|
4367
|
+
for (const row of rows) {
|
|
4368
|
+
counts.set(row.findingId, row.count);
|
|
4369
|
+
}
|
|
4370
|
+
return counts;
|
|
4371
|
+
}
|
|
4372
|
+
function getLatestObservationForFinding(db, findingId) {
|
|
4373
|
+
return db.prepare(`
|
|
4374
|
+
SELECT
|
|
4375
|
+
id,
|
|
4376
|
+
review_id AS reviewId,
|
|
4377
|
+
finding_id AS findingId,
|
|
4378
|
+
file,
|
|
4379
|
+
line,
|
|
4380
|
+
severity,
|
|
4381
|
+
confidence,
|
|
4382
|
+
title,
|
|
4383
|
+
body,
|
|
4384
|
+
evidence,
|
|
4385
|
+
ordinal,
|
|
4386
|
+
classification
|
|
4387
|
+
FROM finding_observations
|
|
4388
|
+
WHERE finding_id = ?
|
|
4389
|
+
ORDER BY id DESC
|
|
4390
|
+
LIMIT 1
|
|
4391
|
+
`).get(findingId);
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
// src/state/reconcile.ts
|
|
4395
|
+
function reconcileReviewFindings(db, reviewId, candidates) {
|
|
4396
|
+
const observations = [];
|
|
4397
|
+
const suppressedCounts = { dismissed: 0, deferred: 0 };
|
|
4398
|
+
const uniqueCandidates = deduplicateFindingCandidates(candidates);
|
|
4399
|
+
for (const [index, candidate] of uniqueCandidates.entries()) {
|
|
4400
|
+
const fingerprint = computeFindingFingerprint(candidate);
|
|
4401
|
+
const existing = getFindingByFingerprint(db, fingerprint);
|
|
4402
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
4403
|
+
let finding = existing;
|
|
4404
|
+
let classification;
|
|
4405
|
+
let suppressed = false;
|
|
4406
|
+
if (!finding) {
|
|
4407
|
+
finding = insertFinding(db, {
|
|
4408
|
+
fingerprint,
|
|
4409
|
+
status: "open",
|
|
4410
|
+
firstReviewId: reviewId,
|
|
4411
|
+
lastReviewId: reviewId,
|
|
4412
|
+
createdAt: timestamp,
|
|
4413
|
+
updatedAt: timestamp
|
|
4414
|
+
});
|
|
4415
|
+
classification = "new";
|
|
4416
|
+
} else if (finding.status === "open" || finding.status === "regressed") {
|
|
4417
|
+
finding = updateFinding(db, finding.id, {
|
|
4418
|
+
status: finding.status,
|
|
4419
|
+
lastReviewId: reviewId,
|
|
4420
|
+
updatedAt: timestamp
|
|
4421
|
+
});
|
|
4422
|
+
classification = "existing";
|
|
4423
|
+
} else if (finding.status === "deferred" || finding.status === "dismissed") {
|
|
4424
|
+
finding = updateFinding(db, finding.id, {
|
|
4425
|
+
status: finding.status,
|
|
4426
|
+
lastReviewId: reviewId,
|
|
4427
|
+
updatedAt: timestamp
|
|
4428
|
+
});
|
|
4429
|
+
classification = "existing";
|
|
4430
|
+
suppressed = true;
|
|
4431
|
+
if (finding.status === "dismissed") {
|
|
4432
|
+
suppressedCounts.dismissed++;
|
|
4433
|
+
} else {
|
|
4434
|
+
suppressedCounts.deferred++;
|
|
4435
|
+
}
|
|
4436
|
+
} else {
|
|
4437
|
+
finding = updateFinding(db, finding.id, {
|
|
4438
|
+
status: "regressed",
|
|
4439
|
+
lastReviewId: reviewId,
|
|
4440
|
+
updatedAt: timestamp
|
|
4441
|
+
});
|
|
4442
|
+
classification = "regressed";
|
|
4443
|
+
insertFindingEvent(db, {
|
|
4444
|
+
findingId: finding.id,
|
|
4445
|
+
reviewId,
|
|
4446
|
+
eventType: "regressed",
|
|
4447
|
+
actor: "agent",
|
|
4448
|
+
reason: "Finding reappeared after being marked fixed."
|
|
4449
|
+
});
|
|
4450
|
+
}
|
|
4451
|
+
insertFindingEvent(db, {
|
|
4452
|
+
findingId: finding.id,
|
|
4453
|
+
reviewId,
|
|
4454
|
+
eventType: "observed",
|
|
4455
|
+
actor: "agent"
|
|
4456
|
+
});
|
|
4457
|
+
const observation = insertObservation(db, {
|
|
4458
|
+
reviewId,
|
|
4459
|
+
findingId: finding.id,
|
|
4460
|
+
file: candidate.file,
|
|
4461
|
+
line: candidate.line,
|
|
4462
|
+
severity: candidate.severity,
|
|
4463
|
+
confidence: candidate.confidence,
|
|
4464
|
+
title: candidate.title,
|
|
4465
|
+
body: candidate.body,
|
|
4466
|
+
evidence: candidate.evidence ?? null,
|
|
4467
|
+
ordinal: index + 1,
|
|
4468
|
+
classification
|
|
4469
|
+
});
|
|
4470
|
+
observations.push({
|
|
4471
|
+
observation,
|
|
4472
|
+
finding,
|
|
4473
|
+
fingerprint,
|
|
4474
|
+
suppressed
|
|
4475
|
+
});
|
|
4476
|
+
}
|
|
4477
|
+
return { observations, suppressedCounts };
|
|
4478
|
+
}
|
|
4479
|
+
|
|
4480
|
+
// src/state/repositories/reviews.ts
|
|
4481
|
+
var insertReviewStatement = (db) => db.prepare(`
|
|
4482
|
+
INSERT INTO reviews (
|
|
4483
|
+
id,
|
|
4484
|
+
created_at,
|
|
4485
|
+
target_kind,
|
|
4486
|
+
target_ref,
|
|
4487
|
+
target_commit,
|
|
4488
|
+
diff_hash,
|
|
4489
|
+
model,
|
|
4490
|
+
reasoning,
|
|
4491
|
+
depth,
|
|
4492
|
+
session_id,
|
|
4493
|
+
summary,
|
|
4494
|
+
report_path,
|
|
4495
|
+
diagnostics_json,
|
|
4496
|
+
timings_json,
|
|
4497
|
+
skipped_reason
|
|
4498
|
+
) VALUES (
|
|
4499
|
+
@id,
|
|
4500
|
+
@createdAt,
|
|
4501
|
+
@targetKind,
|
|
4502
|
+
@targetRef,
|
|
4503
|
+
@targetCommit,
|
|
4504
|
+
@diffHash,
|
|
4505
|
+
@model,
|
|
4506
|
+
@reasoning,
|
|
4507
|
+
@depth,
|
|
4508
|
+
@sessionId,
|
|
4509
|
+
@summary,
|
|
4510
|
+
@reportPath,
|
|
4511
|
+
@diagnosticsJson,
|
|
4512
|
+
@timingsJson,
|
|
4513
|
+
@skippedReason
|
|
4514
|
+
)
|
|
4515
|
+
`);
|
|
4516
|
+
var getReviewByIdStatement = (db) => db.prepare(`
|
|
4517
|
+
SELECT
|
|
4518
|
+
id,
|
|
4519
|
+
created_at AS createdAt,
|
|
4520
|
+
target_kind AS targetKind,
|
|
4521
|
+
target_ref AS targetRef,
|
|
4522
|
+
target_commit AS targetCommit,
|
|
4523
|
+
diff_hash AS diffHash,
|
|
4524
|
+
model,
|
|
4525
|
+
reasoning,
|
|
4526
|
+
depth,
|
|
4527
|
+
session_id AS sessionId,
|
|
4528
|
+
summary,
|
|
4529
|
+
report_path AS reportPath,
|
|
4530
|
+
diagnostics_json AS diagnosticsJson,
|
|
4531
|
+
timings_json AS timingsJson,
|
|
4532
|
+
skipped_reason AS skippedReason
|
|
4533
|
+
FROM reviews
|
|
4534
|
+
WHERE id = ?
|
|
4535
|
+
`);
|
|
4536
|
+
function insertReview(db, input) {
|
|
4537
|
+
const record = {
|
|
4538
|
+
id: input.id ?? createReviewId(),
|
|
4539
|
+
createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
4540
|
+
targetKind: input.targetKind,
|
|
4541
|
+
targetRef: input.targetRef ?? null,
|
|
4542
|
+
targetCommit: input.targetCommit ?? null,
|
|
4543
|
+
diffHash: input.diffHash,
|
|
4544
|
+
model: input.model,
|
|
4545
|
+
reasoning: input.reasoning,
|
|
4546
|
+
depth: input.depth,
|
|
4547
|
+
sessionId: input.sessionId,
|
|
4548
|
+
summary: input.summary,
|
|
4549
|
+
reportPath: input.reportPath ?? null,
|
|
4550
|
+
diagnostics: input.diagnostics ?? [],
|
|
4551
|
+
timings: input.timings ?? [],
|
|
4552
|
+
skippedReason: input.skippedReason ?? null
|
|
4553
|
+
};
|
|
4554
|
+
insertReviewStatement(db).run({
|
|
4555
|
+
id: record.id,
|
|
4556
|
+
createdAt: record.createdAt,
|
|
4557
|
+
targetKind: record.targetKind,
|
|
4558
|
+
targetRef: record.targetRef,
|
|
4559
|
+
targetCommit: record.targetCommit,
|
|
4560
|
+
diffHash: record.diffHash,
|
|
4561
|
+
model: record.model,
|
|
4562
|
+
reasoning: record.reasoning,
|
|
4563
|
+
depth: record.depth,
|
|
4564
|
+
sessionId: record.sessionId,
|
|
4565
|
+
summary: record.summary,
|
|
4566
|
+
reportPath: record.reportPath,
|
|
4567
|
+
diagnosticsJson: JSON.stringify(record.diagnostics),
|
|
4568
|
+
timingsJson: JSON.stringify(record.timings),
|
|
4569
|
+
skippedReason: record.skippedReason
|
|
4570
|
+
});
|
|
4571
|
+
return record;
|
|
4572
|
+
}
|
|
4573
|
+
function getReviewById(db, id) {
|
|
4574
|
+
const row = getReviewByIdStatement(db).get(id);
|
|
4575
|
+
if (!row) {
|
|
4576
|
+
return void 0;
|
|
4577
|
+
}
|
|
4578
|
+
return mapReviewRow(row);
|
|
4579
|
+
}
|
|
4580
|
+
function getLatestReview(db) {
|
|
4581
|
+
const row = db.prepare(`
|
|
4582
|
+
SELECT
|
|
4583
|
+
id,
|
|
4584
|
+
created_at AS createdAt,
|
|
4585
|
+
target_kind AS targetKind,
|
|
4586
|
+
target_ref AS targetRef,
|
|
4587
|
+
target_commit AS targetCommit,
|
|
4588
|
+
diff_hash AS diffHash,
|
|
4589
|
+
model,
|
|
4590
|
+
reasoning,
|
|
4591
|
+
depth,
|
|
4592
|
+
session_id AS sessionId,
|
|
4593
|
+
summary,
|
|
4594
|
+
report_path AS reportPath,
|
|
4595
|
+
diagnostics_json AS diagnosticsJson,
|
|
4596
|
+
timings_json AS timingsJson,
|
|
4597
|
+
skipped_reason AS skippedReason
|
|
4598
|
+
FROM reviews
|
|
4599
|
+
ORDER BY created_at DESC, id DESC
|
|
4600
|
+
LIMIT 1
|
|
4601
|
+
`).get();
|
|
4602
|
+
if (!row) {
|
|
4603
|
+
return void 0;
|
|
4604
|
+
}
|
|
4605
|
+
return mapReviewRow(row);
|
|
4606
|
+
}
|
|
4607
|
+
function updateReview(db, id, input) {
|
|
4608
|
+
const existing = getReviewById(db, id);
|
|
4609
|
+
if (!existing) {
|
|
4610
|
+
throw new StateDatabaseError(`Review ${id} was not found.`);
|
|
4611
|
+
}
|
|
4612
|
+
const record = {
|
|
4613
|
+
...existing,
|
|
4614
|
+
reportPath: input.reportPath === void 0 ? existing.reportPath : input.reportPath,
|
|
4615
|
+
diagnostics: input.diagnostics ?? existing.diagnostics
|
|
4616
|
+
};
|
|
4617
|
+
db.prepare(`
|
|
4618
|
+
UPDATE reviews
|
|
4619
|
+
SET report_path = @reportPath,
|
|
4620
|
+
diagnostics_json = @diagnosticsJson
|
|
4621
|
+
WHERE id = @id
|
|
4622
|
+
`).run({
|
|
4623
|
+
id: record.id,
|
|
4624
|
+
reportPath: record.reportPath,
|
|
4625
|
+
diagnosticsJson: JSON.stringify(record.diagnostics)
|
|
4626
|
+
});
|
|
4627
|
+
return record;
|
|
4628
|
+
}
|
|
4629
|
+
function mapReviewRow(row) {
|
|
4630
|
+
return {
|
|
4631
|
+
id: row.id,
|
|
4632
|
+
createdAt: row.createdAt,
|
|
4633
|
+
targetKind: row.targetKind,
|
|
4634
|
+
targetRef: row.targetRef,
|
|
4635
|
+
targetCommit: row.targetCommit,
|
|
4636
|
+
diffHash: row.diffHash,
|
|
4637
|
+
model: row.model,
|
|
4638
|
+
reasoning: row.reasoning,
|
|
4639
|
+
depth: row.depth,
|
|
4640
|
+
sessionId: row.sessionId,
|
|
4641
|
+
summary: row.summary,
|
|
4642
|
+
reportPath: row.reportPath,
|
|
4643
|
+
diagnostics: parseReviewJsonField(row.diagnosticsJson, "diagnostics_json", row.id),
|
|
4644
|
+
timings: parseReviewJsonField(row.timingsJson, "timings_json", row.id),
|
|
4645
|
+
skippedReason: row.skippedReason
|
|
4646
|
+
};
|
|
4647
|
+
}
|
|
4648
|
+
function parseReviewJsonField(raw, field, reviewId) {
|
|
4649
|
+
try {
|
|
4650
|
+
return JSON.parse(raw);
|
|
4651
|
+
} catch {
|
|
4652
|
+
throw new StateDatabaseError(`Review ${reviewId} contains invalid JSON in ${field}.`);
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
// src/state/persist.ts
|
|
4657
|
+
function computeDiffHash(raw) {
|
|
4658
|
+
return createHash2("sha256").update(raw, "utf8").digest("hex");
|
|
4659
|
+
}
|
|
4660
|
+
function deduplicateReviewFindings(findings) {
|
|
4661
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4662
|
+
const deduped = [];
|
|
4663
|
+
for (const finding of findings) {
|
|
4664
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4665
|
+
if (seen.has(fingerprint)) {
|
|
4666
|
+
continue;
|
|
4667
|
+
}
|
|
4668
|
+
seen.add(fingerprint);
|
|
4669
|
+
deduped.push(finding);
|
|
4670
|
+
}
|
|
4671
|
+
return deduped;
|
|
4672
|
+
}
|
|
4673
|
+
function toFindingCandidate(finding) {
|
|
4674
|
+
const candidate = {
|
|
4675
|
+
file: finding.file,
|
|
4676
|
+
line: finding.line,
|
|
4677
|
+
severity: finding.severity,
|
|
4678
|
+
confidence: finding.confidence,
|
|
4679
|
+
title: finding.title,
|
|
4680
|
+
body: finding.body
|
|
4681
|
+
};
|
|
4682
|
+
if (finding.evidence !== void 0) {
|
|
4683
|
+
candidate.evidence = finding.evidence;
|
|
4684
|
+
}
|
|
4685
|
+
return candidate;
|
|
4686
|
+
}
|
|
4687
|
+
function formatLifecycleSuppressedSummary(counts) {
|
|
4688
|
+
const parts = [];
|
|
4689
|
+
if (counts.dismissed > 0) {
|
|
4690
|
+
parts.push(`${counts.dismissed} dismissed`);
|
|
4691
|
+
}
|
|
4692
|
+
if (counts.deferred > 0) {
|
|
4693
|
+
parts.push(`${counts.deferred} deferred`);
|
|
4694
|
+
}
|
|
4695
|
+
if (parts.length === 0) {
|
|
4696
|
+
return null;
|
|
4697
|
+
}
|
|
4698
|
+
return `Suppressed ${parts.join(" and ")} previously resolved finding(s).`;
|
|
4699
|
+
}
|
|
4700
|
+
function splitFindingsByLifecycleSuppression(findings, reconcile) {
|
|
4701
|
+
const fingerprintedFindings = fingerprintUniqueReviewFindings(findings);
|
|
4702
|
+
const findingsByFingerprint = /* @__PURE__ */ new Map();
|
|
4703
|
+
for (const { finding, fingerprint } of fingerprintedFindings) {
|
|
4704
|
+
findingsByFingerprint.set(fingerprint, finding);
|
|
4705
|
+
}
|
|
4706
|
+
const actionableFindings = [];
|
|
4707
|
+
const lifecycleSuppressedFindings = [];
|
|
4708
|
+
const matchedFingerprints = /* @__PURE__ */ new Set();
|
|
4709
|
+
for (const observation of reconcile.observations) {
|
|
4710
|
+
const finding = findingsByFingerprint.get(observation.fingerprint);
|
|
4711
|
+
if (!finding) {
|
|
4712
|
+
continue;
|
|
4713
|
+
}
|
|
4714
|
+
matchedFingerprints.add(observation.fingerprint);
|
|
4715
|
+
if (observation.suppressed) {
|
|
4716
|
+
lifecycleSuppressedFindings.push(finding);
|
|
4717
|
+
} else {
|
|
4718
|
+
actionableFindings.push(finding);
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
for (const { finding, fingerprint } of fingerprintedFindings) {
|
|
4722
|
+
if (!matchedFingerprints.has(fingerprint)) {
|
|
4723
|
+
actionableFindings.push(finding);
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
return { actionableFindings, lifecycleSuppressedFindings };
|
|
4727
|
+
}
|
|
4728
|
+
function fingerprintUniqueReviewFindings(findings) {
|
|
4729
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4730
|
+
const fingerprinted = [];
|
|
4731
|
+
for (const finding of findings) {
|
|
4732
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4733
|
+
if (seen.has(fingerprint)) {
|
|
4734
|
+
continue;
|
|
4735
|
+
}
|
|
4736
|
+
seen.add(fingerprint);
|
|
4737
|
+
fingerprinted.push({ finding, fingerprint });
|
|
4738
|
+
}
|
|
4739
|
+
return fingerprinted;
|
|
4740
|
+
}
|
|
4741
|
+
function enrichReviewFindingsWithDurableMetadata(findings, reconcile) {
|
|
4742
|
+
const observationsByFingerprint = new Map(
|
|
4743
|
+
reconcile.observations.map((item) => [item.fingerprint, item])
|
|
4744
|
+
);
|
|
4745
|
+
return findings.map((finding) => {
|
|
4746
|
+
const fingerprint = computeFindingFingerprint(toFindingCandidate(finding));
|
|
4747
|
+
const observation = observationsByFingerprint.get(fingerprint);
|
|
4748
|
+
if (!observation) {
|
|
4749
|
+
return finding;
|
|
4750
|
+
}
|
|
4751
|
+
return {
|
|
4752
|
+
...finding,
|
|
4753
|
+
durable: {
|
|
4754
|
+
id: observation.finding.id,
|
|
4755
|
+
classification: observation.observation.classification,
|
|
4756
|
+
status: observation.finding.status,
|
|
4757
|
+
lifecycleSuppressed: observation.suppressed
|
|
4758
|
+
}
|
|
4759
|
+
};
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
async function persistReviewRun(diffOwlDir, input) {
|
|
4763
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4764
|
+
try {
|
|
4765
|
+
return runInTransaction(state.db, () => {
|
|
4766
|
+
const review = insertReview(state.db, {
|
|
4767
|
+
targetKind: input.targetKind,
|
|
4768
|
+
targetRef: input.targetRef,
|
|
4769
|
+
targetCommit: input.targetCommit,
|
|
4770
|
+
diffHash: input.diffHash,
|
|
4771
|
+
model: input.model,
|
|
4772
|
+
reasoning: input.reasoning,
|
|
4773
|
+
depth: input.depth,
|
|
4774
|
+
sessionId: input.sessionId,
|
|
4775
|
+
summary: input.summary,
|
|
4776
|
+
diagnostics: input.diagnostics,
|
|
4777
|
+
timings: input.timings,
|
|
4778
|
+
skippedReason: input.skippedReason ?? null
|
|
4779
|
+
});
|
|
4780
|
+
const findings = deduplicateReviewFindings(input.findings);
|
|
4781
|
+
const candidates = findings.map(toFindingCandidate);
|
|
4782
|
+
const reconcile = reconcileReviewFindings(state.db, review.id, candidates);
|
|
4783
|
+
const { actionableFindings, lifecycleSuppressedFindings } = splitFindingsByLifecycleSuppression(findings, reconcile);
|
|
4784
|
+
return {
|
|
4785
|
+
reviewId: review.id,
|
|
4786
|
+
reconcile,
|
|
4787
|
+
actionableFindings,
|
|
4788
|
+
lifecycleSuppressedFindings
|
|
4789
|
+
};
|
|
4790
|
+
});
|
|
4791
|
+
} finally {
|
|
4792
|
+
closeStateDatabase(state);
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
async function updatePersistedReview(diffOwlDir, reviewId, input) {
|
|
4796
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4797
|
+
try {
|
|
4798
|
+
runInTransaction(state.db, () => {
|
|
4799
|
+
updateReview(state.db, reviewId, {
|
|
4800
|
+
...input.reportPath !== void 0 ? { reportPath: input.reportPath } : {},
|
|
4801
|
+
...input.diagnostics !== void 0 ? { diagnostics: input.diagnostics } : {}
|
|
4802
|
+
});
|
|
4803
|
+
});
|
|
4804
|
+
} finally {
|
|
4805
|
+
closeStateDatabase(state);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
async function getPersistedReview(diffOwlDir, reviewId) {
|
|
4809
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4810
|
+
try {
|
|
4811
|
+
return getReviewById(state.db, reviewId);
|
|
4812
|
+
} finally {
|
|
4813
|
+
closeStateDatabase(state);
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
async function loadFindingOccurrenceCounts(diffOwlDir, findingIds) {
|
|
4817
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
4818
|
+
try {
|
|
4819
|
+
return countObservationsByFindingIds(state.db, findingIds);
|
|
4820
|
+
} finally {
|
|
4821
|
+
closeStateDatabase(state);
|
|
4822
|
+
}
|
|
4823
|
+
}
|
|
4824
|
+
function mapReviewTarget(target) {
|
|
4825
|
+
switch (target.kind) {
|
|
4826
|
+
case "staged":
|
|
4827
|
+
return { targetKind: "staged", targetRef: null };
|
|
4828
|
+
case "last-commit":
|
|
4829
|
+
return { targetKind: "last-commit", targetRef: null };
|
|
4830
|
+
case "commit":
|
|
4831
|
+
return { targetKind: "commit", targetRef: target.ref ?? null };
|
|
4832
|
+
}
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
// src/output/json.ts
|
|
4836
|
+
var JSON_OUTPUT_SCHEMA_VERSION = 1;
|
|
4837
|
+
function parseReviewOutputFormat(value) {
|
|
4838
|
+
if (value === void 0 || value === "text") {
|
|
4839
|
+
return "text";
|
|
4840
|
+
}
|
|
4841
|
+
if (value === "json") {
|
|
4842
|
+
return "json";
|
|
4843
|
+
}
|
|
4844
|
+
throw new Error(`Invalid output format: ${String(value)}. Expected text or json.`);
|
|
4845
|
+
}
|
|
4846
|
+
function buildReviewJsonDocument(input) {
|
|
4847
|
+
const observations = selectJsonObservations(
|
|
4848
|
+
input.persisted.reconcile.observations,
|
|
4849
|
+
input.verbose
|
|
4850
|
+
);
|
|
4851
|
+
const actionableCount = input.persisted.reconcile.observations.filter(
|
|
4852
|
+
(item) => !item.suppressed
|
|
4853
|
+
).length;
|
|
4854
|
+
return {
|
|
4855
|
+
schema_version: JSON_OUTPUT_SCHEMA_VERSION,
|
|
4856
|
+
review: {
|
|
4857
|
+
id: input.review.id,
|
|
4858
|
+
created_at: input.review.createdAt,
|
|
4859
|
+
target: {
|
|
4860
|
+
kind: input.review.targetKind,
|
|
4861
|
+
ref: input.review.targetRef,
|
|
4862
|
+
commit: input.review.targetCommit
|
|
4863
|
+
},
|
|
4864
|
+
model: input.review.model,
|
|
4865
|
+
reasoning: input.review.reasoning,
|
|
4866
|
+
depth: input.review.depth,
|
|
4867
|
+
session_id: input.review.sessionId,
|
|
4868
|
+
summary: input.review.summary,
|
|
4869
|
+
status: resolveReviewJsonStatus(input.review, actionableCount),
|
|
4870
|
+
report_path: input.review.reportPath,
|
|
4871
|
+
skipped_reason: input.review.skippedReason
|
|
4872
|
+
},
|
|
4873
|
+
findings: observations.map((item) => mapJsonFinding(item, input.occurrenceCounts)),
|
|
4874
|
+
suppressed: {
|
|
4875
|
+
lifecycle: input.persisted.reconcile.suppressedCounts,
|
|
4876
|
+
outside_changed_files: input.suppressed.outsideChangedFiles,
|
|
4877
|
+
below_confidence: input.suppressed.belowConfidence
|
|
4878
|
+
},
|
|
4879
|
+
diagnostics: input.review.diagnostics,
|
|
4880
|
+
timings: input.timings ?? input.review.timings,
|
|
4881
|
+
...input.usage !== void 0 ? { usage: input.usage } : {}
|
|
4882
|
+
};
|
|
4883
|
+
}
|
|
4884
|
+
function renderReviewJsonDocument(document) {
|
|
4885
|
+
return `${JSON.stringify(document)}
|
|
4886
|
+
`;
|
|
4887
|
+
}
|
|
4888
|
+
function renderJsonErrorDocument(message) {
|
|
4889
|
+
const document = {
|
|
4890
|
+
schema_version: JSON_OUTPUT_SCHEMA_VERSION,
|
|
4891
|
+
error: { message }
|
|
4892
|
+
};
|
|
4893
|
+
return `${JSON.stringify(document)}
|
|
4894
|
+
`;
|
|
4895
|
+
}
|
|
4896
|
+
function writeReviewJsonSuccess(document) {
|
|
4897
|
+
process.stdout.write(renderReviewJsonDocument(document));
|
|
4898
|
+
}
|
|
4899
|
+
function writeJsonError(message) {
|
|
4900
|
+
process.stderr.write(renderJsonErrorDocument(message));
|
|
4901
|
+
}
|
|
4902
|
+
function selectJsonObservations(observations, verbose = false) {
|
|
4903
|
+
if (verbose) {
|
|
4904
|
+
return observations;
|
|
4905
|
+
}
|
|
4906
|
+
return observations.filter((item) => !item.suppressed);
|
|
4907
|
+
}
|
|
4908
|
+
function resolveReviewJsonStatus(review, actionableCount) {
|
|
4909
|
+
if (review.skippedReason) {
|
|
4910
|
+
return "skipped";
|
|
4911
|
+
}
|
|
4912
|
+
return actionableCount > 0 ? "open" : "resolved";
|
|
4913
|
+
}
|
|
4914
|
+
function mapJsonFinding(item, occurrenceCounts) {
|
|
4915
|
+
const { observation, finding, fingerprint, suppressed } = item;
|
|
4916
|
+
return {
|
|
4917
|
+
id: finding.id,
|
|
4918
|
+
fingerprint,
|
|
4919
|
+
status: finding.status,
|
|
4920
|
+
classification: observation.classification,
|
|
4921
|
+
suppressed,
|
|
4922
|
+
location: {
|
|
4923
|
+
file: observation.file,
|
|
4924
|
+
line: observation.line
|
|
4925
|
+
},
|
|
4926
|
+
content: {
|
|
4927
|
+
title: observation.title,
|
|
4928
|
+
body: observation.body,
|
|
4929
|
+
evidence: observation.evidence
|
|
4930
|
+
},
|
|
4931
|
+
severity: observation.severity,
|
|
4932
|
+
confidence: observation.confidence,
|
|
4933
|
+
created_at: finding.createdAt,
|
|
4934
|
+
updated_at: finding.updatedAt,
|
|
4935
|
+
occurrence_count: occurrenceCounts.get(finding.id) ?? 1
|
|
4936
|
+
};
|
|
4937
|
+
}
|
|
4938
|
+
|
|
4939
|
+
// src/output/findings.ts
|
|
4940
|
+
import chalk2 from "chalk";
|
|
4941
|
+
var ID_WIDTH = 12;
|
|
4942
|
+
var STATUS_WIDTH = 10;
|
|
4943
|
+
var SEVERITY_WIDTH = 8;
|
|
4944
|
+
var SEEN_WIDTH = 4;
|
|
4945
|
+
var COLUMN_GAP = 2;
|
|
4946
|
+
var MIN_LOCATION_WIDTH = 10;
|
|
4947
|
+
var MIN_TITLE_WIDTH = 12;
|
|
4948
|
+
var MAX_LOCATION_SHARE = 0.4;
|
|
4949
|
+
function shortenFindingId(id) {
|
|
4950
|
+
if (id.length <= 16) {
|
|
4951
|
+
return id;
|
|
4952
|
+
}
|
|
4953
|
+
return id.slice(0, 12);
|
|
4954
|
+
}
|
|
4955
|
+
function formatFindingList(items, options = {}) {
|
|
4956
|
+
if (items.length === 0) {
|
|
4957
|
+
return "";
|
|
4958
|
+
}
|
|
4959
|
+
const columns = options.columns ?? process.stdout.columns ?? 100;
|
|
4960
|
+
const color = options.color ?? chalk2.level > 0;
|
|
4961
|
+
const layout = computeListLayout(columns);
|
|
4962
|
+
const lines = [];
|
|
4963
|
+
lines.push(
|
|
4964
|
+
color ? chalk2.bold(`Open findings: ${items.length}`) : `Open findings: ${items.length}`
|
|
4965
|
+
);
|
|
4966
|
+
lines.push(
|
|
4967
|
+
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."
|
|
4968
|
+
);
|
|
4969
|
+
lines.push("");
|
|
4970
|
+
lines.push(
|
|
4971
|
+
color ? chalk2.bold(
|
|
4972
|
+
[
|
|
4973
|
+
padEnd("ID", ID_WIDTH),
|
|
4974
|
+
padEnd("Status", STATUS_WIDTH),
|
|
4975
|
+
padEnd("Severity", SEVERITY_WIDTH),
|
|
4976
|
+
padEnd("Seen", SEEN_WIDTH),
|
|
4977
|
+
padEnd("Location", layout.locationWidth),
|
|
4978
|
+
"Title"
|
|
4979
|
+
].join(" ".repeat(COLUMN_GAP))
|
|
4980
|
+
) : [
|
|
4981
|
+
padEnd("ID", ID_WIDTH),
|
|
4982
|
+
padEnd("Status", STATUS_WIDTH),
|
|
4983
|
+
padEnd("Severity", SEVERITY_WIDTH),
|
|
4984
|
+
padEnd("Seen", SEEN_WIDTH),
|
|
4985
|
+
padEnd("Location", layout.locationWidth),
|
|
4986
|
+
"Title"
|
|
4987
|
+
].join(" ".repeat(COLUMN_GAP))
|
|
4988
|
+
);
|
|
4989
|
+
for (const item of items) {
|
|
4990
|
+
lines.push(...formatFindingListRow(item, layout));
|
|
4991
|
+
}
|
|
4992
|
+
lines.push("");
|
|
4993
|
+
lines.push(
|
|
4994
|
+
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>"
|
|
4995
|
+
);
|
|
4996
|
+
return lines.join("\n");
|
|
4997
|
+
}
|
|
4998
|
+
function formatFindingDetail(detail) {
|
|
4999
|
+
const lines = [
|
|
5000
|
+
`ID: ${detail.finding.id}`,
|
|
5001
|
+
`Status: ${detail.finding.status}`,
|
|
5002
|
+
`Fingerprint: ${detail.finding.fingerprint}`,
|
|
5003
|
+
`Occurrences: ${detail.occurrence_count}`,
|
|
5004
|
+
`Created: ${detail.finding.createdAt}`,
|
|
5005
|
+
`Updated: ${detail.finding.updatedAt}`
|
|
5006
|
+
];
|
|
5007
|
+
if (detail.observation) {
|
|
5008
|
+
lines.push(
|
|
5009
|
+
"",
|
|
5010
|
+
`Location: ${detail.observation.file}:${detail.observation.line}`,
|
|
5011
|
+
`Severity: ${detail.observation.severity}`,
|
|
5012
|
+
`Confidence: ${detail.observation.confidence}`,
|
|
5013
|
+
`Classification: ${detail.observation.classification}`,
|
|
5014
|
+
"",
|
|
5015
|
+
detail.observation.title,
|
|
5016
|
+
detail.observation.body
|
|
5017
|
+
);
|
|
5018
|
+
if (detail.observation.evidence) {
|
|
5019
|
+
lines.push("", `Evidence: ${detail.observation.evidence}`);
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
if (detail.events.length > 0) {
|
|
5023
|
+
lines.push("", "Events:");
|
|
5024
|
+
for (const event of detail.events) {
|
|
5025
|
+
const reason = event.reason ? ` \u2014 ${event.reason}` : "";
|
|
5026
|
+
lines.push(` - ${event.createdAt} ${event.eventType} (${event.actor})${reason}`);
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
return lines.join("\n");
|
|
5030
|
+
}
|
|
5031
|
+
function renderFindingDetailJson(detail) {
|
|
5032
|
+
return `${JSON.stringify(detail, null, 2)}
|
|
5033
|
+
`;
|
|
5034
|
+
}
|
|
5035
|
+
function computeListLayout(columns) {
|
|
5036
|
+
const fixedWidth = fixedPrefixWidth();
|
|
5037
|
+
const flexibleWidth = Math.max(
|
|
5038
|
+
columns - fixedWidth,
|
|
5039
|
+
MIN_LOCATION_WIDTH + COLUMN_GAP + MIN_TITLE_WIDTH
|
|
5040
|
+
);
|
|
5041
|
+
let locationWidth = Math.max(
|
|
5042
|
+
MIN_LOCATION_WIDTH,
|
|
5043
|
+
Math.min(
|
|
5044
|
+
Math.floor(flexibleWidth * MAX_LOCATION_SHARE),
|
|
5045
|
+
flexibleWidth - COLUMN_GAP - MIN_TITLE_WIDTH
|
|
5046
|
+
)
|
|
5047
|
+
);
|
|
5048
|
+
let titleWidth = flexibleWidth - locationWidth - COLUMN_GAP;
|
|
5049
|
+
if (titleWidth < MIN_TITLE_WIDTH) {
|
|
5050
|
+
titleWidth = MIN_TITLE_WIDTH;
|
|
5051
|
+
locationWidth = Math.max(MIN_LOCATION_WIDTH, flexibleWidth - COLUMN_GAP - titleWidth);
|
|
5052
|
+
}
|
|
5053
|
+
return {
|
|
5054
|
+
locationWidth,
|
|
5055
|
+
titleWidth,
|
|
5056
|
+
titleIndent: fixedWidth + locationWidth + COLUMN_GAP
|
|
5057
|
+
};
|
|
5058
|
+
}
|
|
5059
|
+
function formatFindingListRow(item, layout) {
|
|
5060
|
+
const id = shortenFindingId(item.finding.id);
|
|
5061
|
+
const status = item.finding.status;
|
|
5062
|
+
const severity = item.observation?.severity ?? "unknown";
|
|
5063
|
+
const seen = `${item.occurrence_count}x`;
|
|
5064
|
+
const location = formatLocation(item);
|
|
5065
|
+
const title = normalizeDisplayWhitespace(item.observation?.title ?? "(no observation)");
|
|
5066
|
+
const titleLines = wrapText(title, layout.titleWidth);
|
|
5067
|
+
const locationCell = truncateEnd(location, layout.locationWidth);
|
|
5068
|
+
const prefix = [
|
|
5069
|
+
padEnd(id, ID_WIDTH),
|
|
5070
|
+
padEnd(status, STATUS_WIDTH),
|
|
5071
|
+
padEnd(severity, SEVERITY_WIDTH),
|
|
5072
|
+
padEnd(seen, SEEN_WIDTH),
|
|
5073
|
+
padEnd(locationCell, layout.locationWidth)
|
|
5074
|
+
].join(" ".repeat(COLUMN_GAP));
|
|
5075
|
+
const lines = [`${prefix}${" ".repeat(COLUMN_GAP)}${titleLines[0] ?? ""}`];
|
|
5076
|
+
for (let index = 1; index < titleLines.length; index += 1) {
|
|
5077
|
+
lines.push(`${" ".repeat(layout.titleIndent)}${titleLines[index]}`);
|
|
5078
|
+
}
|
|
5079
|
+
return lines;
|
|
5080
|
+
}
|
|
5081
|
+
function formatLocation(item) {
|
|
5082
|
+
if (!item.observation) {
|
|
5083
|
+
return "unknown";
|
|
5084
|
+
}
|
|
5085
|
+
if (!item.observation.file) {
|
|
5086
|
+
return "unknown";
|
|
5087
|
+
}
|
|
5088
|
+
return `${item.observation.file}:${item.observation.line}`;
|
|
5089
|
+
}
|
|
5090
|
+
function normalizeDisplayWhitespace(text) {
|
|
5091
|
+
return text.trim().replace(/\s+/g, " ");
|
|
5092
|
+
}
|
|
5093
|
+
function wrapText(text, width) {
|
|
5094
|
+
if (width <= 0) {
|
|
5095
|
+
return [text];
|
|
5096
|
+
}
|
|
5097
|
+
if (text.length <= width) {
|
|
5098
|
+
return [text];
|
|
5099
|
+
}
|
|
5100
|
+
const lines = [];
|
|
5101
|
+
let remaining = text;
|
|
5102
|
+
while (remaining.length > width) {
|
|
5103
|
+
let breakAt = remaining.lastIndexOf(" ", width);
|
|
5104
|
+
if (breakAt <= 0) {
|
|
5105
|
+
breakAt = width;
|
|
5106
|
+
}
|
|
5107
|
+
lines.push(remaining.slice(0, breakAt).trimEnd());
|
|
5108
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
5109
|
+
}
|
|
5110
|
+
if (remaining.length > 0) {
|
|
5111
|
+
lines.push(remaining);
|
|
5112
|
+
}
|
|
5113
|
+
return lines.length > 0 ? lines : [""];
|
|
5114
|
+
}
|
|
5115
|
+
function truncateEnd(text, width) {
|
|
5116
|
+
if (text.length <= width) {
|
|
5117
|
+
return text;
|
|
5118
|
+
}
|
|
5119
|
+
if (width <= 3) {
|
|
5120
|
+
return text.slice(0, width);
|
|
5121
|
+
}
|
|
5122
|
+
return `${text.slice(0, width - 3)}...`;
|
|
5123
|
+
}
|
|
5124
|
+
function padEnd(text, width) {
|
|
5125
|
+
if (text.length >= width) {
|
|
5126
|
+
return text.slice(0, width);
|
|
5127
|
+
}
|
|
5128
|
+
return `${text}${" ".repeat(width - text.length)}`;
|
|
5129
|
+
}
|
|
5130
|
+
function fixedPrefixWidth() {
|
|
5131
|
+
return ID_WIDTH + COLUMN_GAP + STATUS_WIDTH + COLUMN_GAP + SEVERITY_WIDTH + COLUMN_GAP + SEEN_WIDTH + COLUMN_GAP;
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5134
|
+
// src/output/locator.ts
|
|
5135
|
+
var LocatorNotFoundError = class extends Error {
|
|
5136
|
+
name = "LocatorNotFoundError";
|
|
5137
|
+
};
|
|
5138
|
+
var LocatorAmbiguousError = class extends Error {
|
|
5139
|
+
constructor(locator, matches) {
|
|
5140
|
+
super(`Locator ${locator} is ambiguous (${matches.length} matches).`);
|
|
5141
|
+
this.locator = locator;
|
|
5142
|
+
this.matches = matches;
|
|
5143
|
+
}
|
|
5144
|
+
locator;
|
|
5145
|
+
matches;
|
|
5146
|
+
name = "LocatorAmbiguousError";
|
|
5147
|
+
};
|
|
5148
|
+
function parseLatestOrdinalLocator(locator) {
|
|
5149
|
+
const match = /^latest:(\d+)$/i.exec(locator.trim());
|
|
5150
|
+
if (!match) {
|
|
5151
|
+
return null;
|
|
5152
|
+
}
|
|
5153
|
+
const ordinal = Number.parseInt(match[1] ?? "", 10);
|
|
5154
|
+
if (!Number.isInteger(ordinal) || ordinal < 1) {
|
|
5155
|
+
throw new LocatorNotFoundError(`Invalid latest locator: ${locator}`);
|
|
5156
|
+
}
|
|
5157
|
+
return ordinal;
|
|
5158
|
+
}
|
|
5159
|
+
function resolveFindingIdFromCandidates(locator, candidates) {
|
|
5160
|
+
const trimmed = locator.trim();
|
|
5161
|
+
const exact = candidates.find((finding) => finding.id === trimmed);
|
|
5162
|
+
if (exact) {
|
|
5163
|
+
return exact.id;
|
|
5164
|
+
}
|
|
5165
|
+
const prefixMatches = candidates.filter((finding) => finding.id.startsWith(trimmed));
|
|
5166
|
+
if (prefixMatches.length === 1) {
|
|
5167
|
+
return prefixMatches[0].id;
|
|
5168
|
+
}
|
|
5169
|
+
if (prefixMatches.length > 1) {
|
|
5170
|
+
throw new LocatorAmbiguousError(
|
|
5171
|
+
trimmed,
|
|
5172
|
+
prefixMatches.map((finding) => finding.id)
|
|
5173
|
+
);
|
|
5174
|
+
}
|
|
5175
|
+
throw new LocatorNotFoundError(`Finding locator not found: ${trimmed}`);
|
|
5176
|
+
}
|
|
5177
|
+
function resolveLatestOrdinalFindingId(ordinal, observations) {
|
|
5178
|
+
const match = observations.find((observation) => observation.ordinal === ordinal);
|
|
5179
|
+
if (!match) {
|
|
5180
|
+
throw new LocatorNotFoundError(`Finding ${ordinal} was not found in the latest review.`);
|
|
5181
|
+
}
|
|
5182
|
+
return match.findingId;
|
|
5183
|
+
}
|
|
5184
|
+
|
|
5185
|
+
// src/state/lifecycle.ts
|
|
5186
|
+
function dismissFinding(db, findingId, input) {
|
|
5187
|
+
return transitionFinding(db, findingId, {
|
|
5188
|
+
allowedFrom: ["open", "regressed"],
|
|
5189
|
+
to: "dismissed",
|
|
5190
|
+
eventType: "dismissed",
|
|
5191
|
+
actor: input.actor,
|
|
5192
|
+
reason: input.reason
|
|
5193
|
+
});
|
|
5194
|
+
}
|
|
5195
|
+
function deferFinding(db, findingId, input) {
|
|
5196
|
+
return transitionFinding(db, findingId, {
|
|
5197
|
+
allowedFrom: ["open", "regressed"],
|
|
5198
|
+
to: "deferred",
|
|
5199
|
+
eventType: "deferred",
|
|
5200
|
+
actor: input.actor,
|
|
5201
|
+
reason: input.reason
|
|
5202
|
+
});
|
|
5203
|
+
}
|
|
5204
|
+
function fixFinding(db, findingId, input) {
|
|
5205
|
+
const finding = requireFinding(db, findingId);
|
|
5206
|
+
assertTransition(finding.status, ["open", "regressed"], "fixed");
|
|
5207
|
+
const updated = updateFinding(db, findingId, {
|
|
5208
|
+
status: "fixed",
|
|
5209
|
+
lastReviewId: finding.lastReviewId
|
|
5210
|
+
});
|
|
5211
|
+
insertFindingEvent(db, {
|
|
5212
|
+
findingId,
|
|
5213
|
+
eventType: "fixed",
|
|
5214
|
+
actor: input.actor,
|
|
5215
|
+
reason: input.note,
|
|
5216
|
+
commitRef: input.commitRef ?? null,
|
|
5217
|
+
verification: input.verifiedBy
|
|
5218
|
+
});
|
|
5219
|
+
return updated;
|
|
5220
|
+
}
|
|
5221
|
+
function reopenFinding(db, findingId, input) {
|
|
5222
|
+
return transitionFinding(db, findingId, {
|
|
5223
|
+
allowedFrom: ["fixed"],
|
|
5224
|
+
to: "open",
|
|
5225
|
+
eventType: "reopened",
|
|
5226
|
+
actor: input.actor,
|
|
5227
|
+
reason: input.reason
|
|
5228
|
+
});
|
|
5229
|
+
}
|
|
5230
|
+
function transitionFinding(db, findingId, options) {
|
|
5231
|
+
const finding = requireFinding(db, findingId);
|
|
5232
|
+
assertTransition(finding.status, options.allowedFrom, options.to);
|
|
5233
|
+
const updated = updateFinding(db, findingId, {
|
|
5234
|
+
status: options.to,
|
|
5235
|
+
lastReviewId: finding.lastReviewId
|
|
5236
|
+
});
|
|
5237
|
+
insertFindingEvent(db, {
|
|
5238
|
+
findingId,
|
|
5239
|
+
eventType: options.eventType,
|
|
5240
|
+
actor: options.actor,
|
|
5241
|
+
reason: options.reason
|
|
5242
|
+
});
|
|
5243
|
+
return updated;
|
|
5244
|
+
}
|
|
5245
|
+
function requireFinding(db, findingId) {
|
|
5246
|
+
const finding = getFindingById(db, findingId);
|
|
5247
|
+
if (!finding) {
|
|
5248
|
+
throw new InvalidFindingTransitionError(`Finding ${findingId} was not found.`);
|
|
5249
|
+
}
|
|
5250
|
+
return finding;
|
|
5251
|
+
}
|
|
5252
|
+
function assertTransition(current, allowedFrom, target) {
|
|
5253
|
+
if (!allowedFrom.includes(current)) {
|
|
5254
|
+
throw new InvalidFindingTransitionError(
|
|
5255
|
+
`Cannot transition finding from ${current} to ${target}.`
|
|
5256
|
+
);
|
|
5257
|
+
}
|
|
5258
|
+
}
|
|
5259
|
+
|
|
5260
|
+
// src/state/findings-query.ts
|
|
5261
|
+
async function withFindingDatabase(diffOwlDir, fn) {
|
|
5262
|
+
const state = await openStateDatabase(diffOwlDir);
|
|
5263
|
+
try {
|
|
5264
|
+
return fn(state.db);
|
|
5265
|
+
} finally {
|
|
5266
|
+
closeStateDatabase(state);
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
function listUnresolvedFindings(db) {
|
|
5270
|
+
return listFindingsByStatuses(db, ["open", "regressed"]).map(
|
|
5271
|
+
(finding) => toFindingListItem(db, finding)
|
|
5272
|
+
);
|
|
5273
|
+
}
|
|
5274
|
+
function getFindingDetail(db, findingId) {
|
|
5275
|
+
const finding = getFindingById(db, findingId);
|
|
5276
|
+
if (!finding) {
|
|
5277
|
+
return void 0;
|
|
5278
|
+
}
|
|
5279
|
+
return toFindingDetail(db, finding);
|
|
5280
|
+
}
|
|
5281
|
+
function resolveFindingLocator(db, locator) {
|
|
5282
|
+
const latestOrdinal = parseLatestOrdinalLocator(locator);
|
|
5283
|
+
if (latestOrdinal !== null) {
|
|
5284
|
+
const latestReview = getLatestReview(db);
|
|
5285
|
+
if (!latestReview) {
|
|
5286
|
+
throw new LocatorNotFoundError("No reviews found for latest locator resolution.");
|
|
5287
|
+
}
|
|
5288
|
+
const observations = listObservationsForReview(db, latestReview.id);
|
|
5289
|
+
return resolveLatestOrdinalFindingId(latestOrdinal, observations);
|
|
5290
|
+
}
|
|
5291
|
+
return resolveFindingIdFromCandidates(locator, listAllFindings(db));
|
|
5292
|
+
}
|
|
5293
|
+
function requireFindingDetail(db, locator) {
|
|
5294
|
+
const findingId = resolveFindingLocator(db, locator);
|
|
5295
|
+
const detail = getFindingDetail(db, findingId);
|
|
5296
|
+
if (!detail) {
|
|
5297
|
+
throw new LocatorNotFoundError(`Finding ${findingId} was not found.`);
|
|
5298
|
+
}
|
|
5299
|
+
return detail;
|
|
5300
|
+
}
|
|
5301
|
+
function mutateFinding(db, locator, mutation) {
|
|
5302
|
+
return runInTransaction(db, () => {
|
|
5303
|
+
const findingId = resolveFindingLocator(db, locator);
|
|
5304
|
+
const updated = mutation(findingId);
|
|
5305
|
+
const detail = getFindingDetail(db, updated.id);
|
|
5306
|
+
if (!detail) {
|
|
5307
|
+
throw new LocatorNotFoundError(`Finding ${updated.id} was not found after mutation.`);
|
|
5308
|
+
}
|
|
5309
|
+
return detail;
|
|
5310
|
+
});
|
|
5311
|
+
}
|
|
5312
|
+
function dismissFindingByLocator(db, locator, input) {
|
|
5313
|
+
return mutateFinding(db, locator, (findingId) => dismissFinding(db, findingId, input));
|
|
5314
|
+
}
|
|
5315
|
+
function deferFindingByLocator(db, locator, input) {
|
|
5316
|
+
return mutateFinding(db, locator, (findingId) => deferFinding(db, findingId, input));
|
|
5317
|
+
}
|
|
5318
|
+
function fixFindingByLocator(db, locator, input) {
|
|
5319
|
+
return mutateFinding(db, locator, (findingId) => fixFinding(db, findingId, input));
|
|
5320
|
+
}
|
|
5321
|
+
function reopenFindingByLocator(db, locator, input) {
|
|
5322
|
+
return mutateFinding(db, locator, (findingId) => reopenFinding(db, findingId, input));
|
|
5323
|
+
}
|
|
5324
|
+
function toFindingListItem(db, finding) {
|
|
5325
|
+
const counts = countObservationsByFindingIds(db, [finding.id]);
|
|
5326
|
+
return {
|
|
5327
|
+
finding,
|
|
5328
|
+
observation: getLatestObservationForFinding(db, finding.id) ?? null,
|
|
5329
|
+
occurrence_count: counts.get(finding.id) ?? 0
|
|
5330
|
+
};
|
|
5331
|
+
}
|
|
5332
|
+
function toFindingDetail(db, finding) {
|
|
5333
|
+
const counts = countObservationsByFindingIds(db, [finding.id]);
|
|
5334
|
+
return {
|
|
5335
|
+
finding,
|
|
5336
|
+
observation: getLatestObservationForFinding(db, finding.id) ?? null,
|
|
5337
|
+
events: listFindingEvents(db, finding.id),
|
|
5338
|
+
occurrence_count: counts.get(finding.id) ?? 0
|
|
5339
|
+
};
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
// src/cli.ts
|
|
5343
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
5344
|
+
import { basename as basename6, dirname as dirname4 } from "path";
|
|
5345
|
+
import { execa as execa5 } from "execa";
|
|
5346
|
+
|
|
5347
|
+
// package.json
|
|
5348
|
+
var package_default = {
|
|
5349
|
+
name: "diffowl",
|
|
5350
|
+
version: "0.3.1",
|
|
5351
|
+
description: "Local AI code review agent powered by OpenCode",
|
|
5352
|
+
keywords: [
|
|
5353
|
+
"ai",
|
|
5354
|
+
"code-review",
|
|
5355
|
+
"git",
|
|
5356
|
+
"opencode",
|
|
5357
|
+
"pre-commit"
|
|
5358
|
+
],
|
|
5359
|
+
homepage: "https://github.com/gutierrezje/diffowl#readme",
|
|
5360
|
+
bugs: {
|
|
5361
|
+
url: "https://github.com/gutierrezje/diffowl/issues"
|
|
5362
|
+
},
|
|
5363
|
+
license: "MIT",
|
|
5364
|
+
repository: {
|
|
5365
|
+
type: "git",
|
|
5366
|
+
url: "git+https://github.com/gutierrezje/diffowl.git"
|
|
5367
|
+
},
|
|
5368
|
+
bin: {
|
|
5369
|
+
diffowl: "dist/cli.js"
|
|
5370
|
+
},
|
|
5371
|
+
files: [
|
|
5372
|
+
"dist"
|
|
5373
|
+
],
|
|
5374
|
+
type: "module",
|
|
5375
|
+
scripts: {
|
|
5376
|
+
"check:runtime": "node scripts/check-runtime.mjs",
|
|
5377
|
+
prebuild: "pnpm run check:runtime",
|
|
5378
|
+
build: "tsup",
|
|
5379
|
+
predev: "pnpm run check:runtime",
|
|
5380
|
+
dev: "tsup --watch",
|
|
5381
|
+
pretest: "pnpm run check:runtime",
|
|
5382
|
+
test: "vitest run",
|
|
5383
|
+
pretypecheck: "pnpm run check:runtime",
|
|
5384
|
+
typecheck: "tsc --noEmit",
|
|
5385
|
+
lint: "oxlint . && pnpm run typecheck",
|
|
5386
|
+
format: "oxfmt --write .",
|
|
5387
|
+
"format:check": "oxfmt --check .",
|
|
5388
|
+
"dogfood:0.3": "pnpm run build && node scripts/dogfood-0.3.mjs",
|
|
5389
|
+
prepack: "npm run build"
|
|
5390
|
+
},
|
|
5391
|
+
dependencies: {
|
|
5392
|
+
"@opencode-ai/sdk": "^1.15.11",
|
|
5393
|
+
chalk: "^5.6.2",
|
|
5394
|
+
commander: "^14.0.3",
|
|
5395
|
+
execa: "^9.6.1",
|
|
5396
|
+
ora: "^9.4.0",
|
|
5397
|
+
picomatch: "^4.0.4",
|
|
5398
|
+
yaml: "^2.9.0",
|
|
5399
|
+
zod: "^4.4.3"
|
|
3386
5400
|
},
|
|
3387
5401
|
devDependencies: {
|
|
3388
5402
|
"@types/node": "^25.9.1",
|
|
@@ -3394,7 +5408,7 @@ var package_default = {
|
|
|
3394
5408
|
vitest: "^4.1.7"
|
|
3395
5409
|
},
|
|
3396
5410
|
engines: {
|
|
3397
|
-
node: ">=
|
|
5411
|
+
node: ">=22.14.0"
|
|
3398
5412
|
},
|
|
3399
5413
|
packageManager: "pnpm@10.15.1"
|
|
3400
5414
|
};
|
|
@@ -3405,7 +5419,9 @@ program.name("diffowl").description("Local AI code review agent powered by OpenC
|
|
|
3405
5419
|
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(
|
|
3406
5420
|
"--reasoning <effort>",
|
|
3407
5421
|
"Reasoning variant: auto, none, minimal, low, medium, high, max, or xhigh"
|
|
3408
|
-
).option("--verbose", "Include suppressed findings and extra review details").action(async (options) => {
|
|
5422
|
+
).option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
|
|
5423
|
+
const format = resolveReviewOutputFormat(options.format);
|
|
5424
|
+
const jsonMode = format === "json";
|
|
3409
5425
|
const hookCommit = options.hook && options.commit ? String(options.commit) : void 0;
|
|
3410
5426
|
const hookLock = options.hook ? process.env["DIFFOWL_HOOK_LOCK"] : void 0;
|
|
3411
5427
|
if (hookLock) {
|
|
@@ -3420,18 +5436,19 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3420
5436
|
const isRepo = await isGitRepo();
|
|
3421
5437
|
recordCliTiming(timings, "git-repo-check", "Git repository check", gitRepoStart);
|
|
3422
5438
|
if (!isRepo) {
|
|
3423
|
-
|
|
3424
|
-
process.exit(1);
|
|
5439
|
+
await failReview(format, "Not a git repository", { hook: options.hook, hookCommit });
|
|
3425
5440
|
}
|
|
3426
5441
|
if (!configExists()) {
|
|
3427
|
-
console.log(
|
|
5442
|
+
console.log(chalk3.yellow("No .diffowl.yml found. Running first-time setup...\n"));
|
|
3428
5443
|
await runInit();
|
|
3429
5444
|
}
|
|
3430
5445
|
const config = await loadConfigOrExit();
|
|
3431
5446
|
const projectRoot = getProjectRoot();
|
|
3432
5447
|
if (options.staged && options.commit) {
|
|
3433
|
-
|
|
3434
|
-
|
|
5448
|
+
await failReview(format, "Cannot use --staged and --commit together", {
|
|
5449
|
+
hook: options.hook,
|
|
5450
|
+
hookCommit
|
|
5451
|
+
});
|
|
3435
5452
|
}
|
|
3436
5453
|
const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : { kind: "last-commit" };
|
|
3437
5454
|
const depth = resolveReviewDepth(options.depth, config);
|
|
@@ -3442,51 +5459,130 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3442
5459
|
const commitsExist = await hasCommits();
|
|
3443
5460
|
recordCliTiming(timings, "git-commit-check", "Git commit check", hasCommitsStart);
|
|
3444
5461
|
if (!commitsExist) {
|
|
3445
|
-
|
|
3446
|
-
|
|
5462
|
+
await failReview(format, "No commits found in this repository", {
|
|
5463
|
+
hook: options.hook,
|
|
5464
|
+
hookCommit
|
|
5465
|
+
});
|
|
3447
5466
|
}
|
|
3448
5467
|
}
|
|
3449
|
-
|
|
5468
|
+
if (!jsonMode) {
|
|
5469
|
+
printHeader();
|
|
5470
|
+
}
|
|
3450
5471
|
const hookFailure = await checkRecentHookFailure();
|
|
3451
|
-
if (hookFailure) {
|
|
3452
|
-
console.log(
|
|
5472
|
+
if (hookFailure && !jsonMode) {
|
|
5473
|
+
console.log(chalk3.yellow(`\u26A0 ${formatHookFailure(hookFailure)}`));
|
|
3453
5474
|
console.log();
|
|
3454
5475
|
}
|
|
3455
|
-
const spinner = ora({
|
|
5476
|
+
const spinner = jsonMode ? null : ora({
|
|
3456
5477
|
text: "Building local review context...",
|
|
3457
5478
|
color: "cyan",
|
|
3458
5479
|
discardStdin: false
|
|
3459
5480
|
}).start();
|
|
5481
|
+
const cancelController = new AbortController();
|
|
3460
5482
|
process.once("SIGINT", () => {
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
5483
|
+
handleReviewInterrupt({
|
|
5484
|
+
cancelController,
|
|
5485
|
+
spinner,
|
|
5486
|
+
jsonMode,
|
|
5487
|
+
message: "Review cancelled by user (Ctrl+C).",
|
|
5488
|
+
exitCode: 130,
|
|
5489
|
+
hook: options.hook,
|
|
5490
|
+
hookCommit
|
|
5491
|
+
});
|
|
3467
5492
|
});
|
|
3468
5493
|
process.once("SIGTSTP", () => {
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
5494
|
+
handleReviewInterrupt({
|
|
5495
|
+
cancelController,
|
|
5496
|
+
spinner,
|
|
5497
|
+
jsonMode,
|
|
5498
|
+
message: "Review cancelled by user (Ctrl+Z).",
|
|
5499
|
+
exitCode: 146,
|
|
5500
|
+
hook: options.hook,
|
|
5501
|
+
hookCommit
|
|
5502
|
+
});
|
|
3475
5503
|
});
|
|
3476
5504
|
try {
|
|
3477
5505
|
const snapshot = await loadReviewSnapshot(projectRoot, target);
|
|
3478
5506
|
const { diff } = snapshot;
|
|
3479
5507
|
if (target.kind === "staged" && diff.files.length === 0) {
|
|
3480
|
-
spinner
|
|
3481
|
-
|
|
5508
|
+
spinner?.stop();
|
|
5509
|
+
if (jsonMode) {
|
|
5510
|
+
const persisted2 = await persistReviewRun(getDiffOwlDir(), {
|
|
5511
|
+
...mapReviewTarget(target),
|
|
5512
|
+
targetCommit: null,
|
|
5513
|
+
diffHash: computeDiffHash(diff.raw),
|
|
5514
|
+
model: config.model,
|
|
5515
|
+
reasoning: config.reasoning.effort,
|
|
5516
|
+
depth,
|
|
5517
|
+
sessionId: "",
|
|
5518
|
+
summary: "No staged changes to review.",
|
|
5519
|
+
diagnostics: [],
|
|
5520
|
+
timings,
|
|
5521
|
+
findings: [],
|
|
5522
|
+
skippedReason: "empty-diff"
|
|
5523
|
+
});
|
|
5524
|
+
await emitReviewJsonSuccess({
|
|
5525
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5526
|
+
reviewId: persisted2.reviewId,
|
|
5527
|
+
persisted: persisted2,
|
|
5528
|
+
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
5529
|
+
verbose,
|
|
5530
|
+
timings
|
|
5531
|
+
});
|
|
5532
|
+
process.exit(0);
|
|
5533
|
+
}
|
|
5534
|
+
console.log(chalk3.yellow("No staged changes to review"));
|
|
3482
5535
|
process.exit(0);
|
|
3483
5536
|
}
|
|
3484
5537
|
if (config.skip_doc_only && isDocOnlyDiff(diff)) {
|
|
3485
|
-
spinner
|
|
3486
|
-
|
|
5538
|
+
spinner?.stop();
|
|
5539
|
+
if (!jsonMode) {
|
|
5540
|
+
console.warn(chalk3.yellow("Documentation-only changes detected. Skipping review."));
|
|
5541
|
+
}
|
|
3487
5542
|
const skipContent = buildDocOnlySkipMarkdown(diff);
|
|
3488
|
-
const
|
|
3489
|
-
|
|
5543
|
+
const diffHash2 = computeDiffHash(diff.raw);
|
|
5544
|
+
const targetFields2 = mapReviewTarget(target);
|
|
5545
|
+
const targetCommit2 = await resolveTargetCommit(target);
|
|
5546
|
+
const persisted2 = await persistReviewRun(getDiffOwlDir(), {
|
|
5547
|
+
...targetFields2,
|
|
5548
|
+
targetCommit: targetCommit2,
|
|
5549
|
+
diffHash: diffHash2,
|
|
5550
|
+
model: config.model,
|
|
5551
|
+
reasoning: config.reasoning.effort,
|
|
5552
|
+
depth,
|
|
5553
|
+
sessionId: "",
|
|
5554
|
+
summary: "Documentation-only changes detected. No code review performed.",
|
|
5555
|
+
diagnostics: [],
|
|
5556
|
+
timings,
|
|
5557
|
+
findings: [],
|
|
5558
|
+
skippedReason: "documentation-only"
|
|
5559
|
+
});
|
|
5560
|
+
let reportPath2;
|
|
5561
|
+
try {
|
|
5562
|
+
reportPath2 = await writeMarkdownReport(skipContent);
|
|
5563
|
+
await updatePersistedReview(getDiffOwlDir(), persisted2.reviewId, {
|
|
5564
|
+
reportPath: reportPath2
|
|
5565
|
+
});
|
|
5566
|
+
} catch (err) {
|
|
5567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5568
|
+
await updatePersistedReview(getDiffOwlDir(), persisted2.reviewId, {
|
|
5569
|
+
reportPath: null,
|
|
5570
|
+
diagnostics: [`Report write failed: ${message}`]
|
|
5571
|
+
});
|
|
5572
|
+
throw err;
|
|
5573
|
+
}
|
|
5574
|
+
if (jsonMode) {
|
|
5575
|
+
await emitReviewJsonSuccess({
|
|
5576
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5577
|
+
reviewId: persisted2.reviewId,
|
|
5578
|
+
persisted: persisted2,
|
|
5579
|
+
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
5580
|
+
verbose,
|
|
5581
|
+
timings
|
|
5582
|
+
});
|
|
5583
|
+
} else {
|
|
5584
|
+
console.log(chalk3.dim(`Report saved: ${reportPath2}`));
|
|
5585
|
+
}
|
|
3490
5586
|
if (options.hook) {
|
|
3491
5587
|
await writeHookStatus(0, hookCommit);
|
|
3492
5588
|
}
|
|
@@ -3498,19 +5594,23 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3498
5594
|
const contextRenderStart = performance.now();
|
|
3499
5595
|
const localContext = renderReviewContext(reviewContext, { depth });
|
|
3500
5596
|
recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
|
|
3501
|
-
if (reviewContext.diagnostics.length > 0) {
|
|
5597
|
+
if (reviewContext.diagnostics.length > 0 && spinner) {
|
|
3502
5598
|
spinner.warn("Local review context built with warnings.");
|
|
3503
5599
|
for (const diagnostic of reviewContext.diagnostics) {
|
|
3504
|
-
console.log(
|
|
5600
|
+
console.log(chalk3.yellow(` - ${diagnostic}`));
|
|
3505
5601
|
}
|
|
3506
5602
|
console.log();
|
|
3507
5603
|
spinner.start("Connecting to OpenCode...");
|
|
3508
5604
|
}
|
|
3509
|
-
spinner
|
|
5605
|
+
if (spinner) {
|
|
5606
|
+
spinner.text = "Connecting to OpenCode...";
|
|
5607
|
+
}
|
|
3510
5608
|
const serverStart = performance.now();
|
|
3511
5609
|
await prepareReviewServer(config);
|
|
3512
5610
|
recordCliTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
|
|
3513
|
-
spinner
|
|
5611
|
+
if (spinner) {
|
|
5612
|
+
spinner.text = "Reviewing changes...";
|
|
5613
|
+
}
|
|
3514
5614
|
const reviewStart = performance.now();
|
|
3515
5615
|
const reviewResult = await runReview({
|
|
3516
5616
|
target,
|
|
@@ -3518,14 +5618,19 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3518
5618
|
config,
|
|
3519
5619
|
localContext,
|
|
3520
5620
|
depth,
|
|
5621
|
+
signal: cancelController.signal,
|
|
3521
5622
|
onProgress: (event) => {
|
|
3522
|
-
|
|
5623
|
+
if (spinner) {
|
|
5624
|
+
spinner.text = formatReviewProgress(event);
|
|
5625
|
+
}
|
|
3523
5626
|
}
|
|
3524
5627
|
});
|
|
3525
5628
|
const report = reviewResult.report;
|
|
3526
5629
|
recordCliTiming(timings, "review-run", "OpenCode review run", reviewStart);
|
|
3527
|
-
spinner
|
|
3528
|
-
|
|
5630
|
+
spinner?.succeed("Review complete.");
|
|
5631
|
+
if (!jsonMode) {
|
|
5632
|
+
console.log();
|
|
5633
|
+
}
|
|
3529
5634
|
const diagnostics = report.diagnostics ?? [];
|
|
3530
5635
|
const confidenceFilter = filterFindingsByConfidence(report.findings, config.min_confidence);
|
|
3531
5636
|
report.findings = confidenceFilter.findings;
|
|
@@ -3551,30 +5656,119 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
3551
5656
|
if (diagnostics.length > 0) {
|
|
3552
5657
|
report.diagnostics = diagnostics;
|
|
3553
5658
|
}
|
|
5659
|
+
const diffHash = computeDiffHash(diff.raw);
|
|
5660
|
+
const targetFields = mapReviewTarget(target);
|
|
5661
|
+
const targetCommit = await resolveTargetCommit(target);
|
|
5662
|
+
const persistStart = performance.now();
|
|
5663
|
+
const persisted = await persistReviewRun(getDiffOwlDir(), {
|
|
5664
|
+
...targetFields,
|
|
5665
|
+
targetCommit,
|
|
5666
|
+
diffHash,
|
|
5667
|
+
model: config.model,
|
|
5668
|
+
reasoning: config.reasoning.effort,
|
|
5669
|
+
depth,
|
|
5670
|
+
sessionId: reviewResult.sessionId,
|
|
5671
|
+
summary: report.summary,
|
|
5672
|
+
diagnostics,
|
|
5673
|
+
timings: [...timings, ...report.timings ?? []],
|
|
5674
|
+
findings: report.findings
|
|
5675
|
+
});
|
|
5676
|
+
recordCliTiming(timings, "persist-state", "Persist review state", persistStart);
|
|
5677
|
+
report.findings = persisted.actionableFindings;
|
|
5678
|
+
const lifecycleSummary = formatLifecycleSuppressedSummary(
|
|
5679
|
+
persisted.reconcile.suppressedCounts
|
|
5680
|
+
);
|
|
5681
|
+
if (lifecycleSummary) {
|
|
5682
|
+
diagnostics.push(lifecycleSummary);
|
|
5683
|
+
report.diagnostics = diagnostics;
|
|
5684
|
+
}
|
|
5685
|
+
if (verbose && persisted.lifecycleSuppressedFindings.length > 0) {
|
|
5686
|
+
report.suppressedFindings = [
|
|
5687
|
+
...report.suppressedFindings ?? [],
|
|
5688
|
+
...persisted.lifecycleSuppressedFindings
|
|
5689
|
+
];
|
|
5690
|
+
}
|
|
5691
|
+
report.findings = enrichReviewFindingsWithDurableMetadata(
|
|
5692
|
+
report.findings,
|
|
5693
|
+
persisted.reconcile
|
|
5694
|
+
);
|
|
5695
|
+
if (report.suppressedFindings) {
|
|
5696
|
+
report.suppressedFindings = enrichReviewFindingsWithDurableMetadata(
|
|
5697
|
+
report.suppressedFindings,
|
|
5698
|
+
persisted.reconcile
|
|
5699
|
+
);
|
|
5700
|
+
}
|
|
3554
5701
|
const renderStart = performance.now();
|
|
3555
5702
|
const markdown = renderMarkdown(report);
|
|
3556
5703
|
recordCliTiming(timings, "render-report", "Markdown render", renderStart);
|
|
3557
5704
|
const writeStart = performance.now();
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
5705
|
+
let reportPath;
|
|
5706
|
+
try {
|
|
5707
|
+
reportPath = await writeMarkdownReport(markdown, {
|
|
5708
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
5709
|
+
review_id: persisted.reviewId,
|
|
5710
|
+
session_id: reviewResult.sessionId,
|
|
5711
|
+
project_root: projectRoot
|
|
5712
|
+
});
|
|
5713
|
+
await updatePersistedReview(getDiffOwlDir(), persisted.reviewId, {
|
|
5714
|
+
reportPath,
|
|
5715
|
+
diagnostics
|
|
5716
|
+
});
|
|
5717
|
+
} catch (err) {
|
|
5718
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5719
|
+
diagnostics.push(`Report write failed: ${message}`);
|
|
5720
|
+
report.diagnostics = diagnostics;
|
|
5721
|
+
await updatePersistedReview(getDiffOwlDir(), persisted.reviewId, {
|
|
5722
|
+
reportPath: null,
|
|
5723
|
+
diagnostics
|
|
5724
|
+
});
|
|
5725
|
+
throw err;
|
|
5726
|
+
}
|
|
3562
5727
|
recordCliTiming(timings, "write-report", "Report write", writeStart);
|
|
3563
5728
|
recordCliTiming(timings, "total", "Total review command", totalStart);
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
5729
|
+
if (jsonMode) {
|
|
5730
|
+
await emitReviewJsonSuccess({
|
|
5731
|
+
diffOwlDir: getDiffOwlDir(),
|
|
5732
|
+
reviewId: persisted.reviewId,
|
|
5733
|
+
persisted,
|
|
5734
|
+
suppressed: {
|
|
5735
|
+
outsideChangedFiles: changedFileFilter.suppressed.length,
|
|
5736
|
+
belowConfidence: confidenceFilter.dropped
|
|
5737
|
+
},
|
|
5738
|
+
verbose,
|
|
5739
|
+
timings: [...timings, ...report.timings ?? []],
|
|
5740
|
+
usage: reviewResult.usage ?? null
|
|
5741
|
+
});
|
|
5742
|
+
} else {
|
|
5743
|
+
console.log(colorizeMarkdown(markdown));
|
|
5744
|
+
printFooter(report, reportPath);
|
|
5745
|
+
printTimingSummary([...timings, ...report.timings ?? []]);
|
|
5746
|
+
}
|
|
3567
5747
|
if (options.hook) {
|
|
3568
5748
|
await writeHookStatus(0, hookCommit);
|
|
3569
5749
|
process.exit(0);
|
|
3570
5750
|
}
|
|
3571
5751
|
} catch (err) {
|
|
3572
|
-
spinner
|
|
5752
|
+
spinner?.stop();
|
|
5753
|
+
if (cancelController.signal.aborted || isReviewCancellation(err)) {
|
|
5754
|
+
if (options.hook) {
|
|
5755
|
+
await writeHookStatus(1, hookCommit, "Review cancelled by user.");
|
|
5756
|
+
process.exit(0);
|
|
5757
|
+
}
|
|
5758
|
+
if (!cancelController.signal.aborted) {
|
|
5759
|
+
process.exit(130);
|
|
5760
|
+
}
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
3573
5763
|
const message = err instanceof Error ? err.message : String(err);
|
|
3574
|
-
|
|
5764
|
+
if (jsonMode) {
|
|
5765
|
+
writeJsonError(message);
|
|
5766
|
+
} else {
|
|
5767
|
+
console.error(chalk3.red(`
|
|
3575
5768
|
Review failed: ${message}`));
|
|
3576
|
-
|
|
3577
|
-
|
|
5769
|
+
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
5770
|
+
console.log(chalk3.dim(line));
|
|
5771
|
+
}
|
|
3578
5772
|
}
|
|
3579
5773
|
if (options.hook) {
|
|
3580
5774
|
await writeHookStatus(1, hookCommit, message);
|
|
@@ -3589,19 +5783,19 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3589
5783
|
try {
|
|
3590
5784
|
content = await readFile7(reportPath, "utf-8");
|
|
3591
5785
|
} catch {
|
|
3592
|
-
console.error(
|
|
5786
|
+
console.error(chalk3.red(`Review report not found: ${reportPath}`));
|
|
3593
5787
|
process.exit(1);
|
|
3594
5788
|
}
|
|
3595
5789
|
let metadata;
|
|
3596
5790
|
try {
|
|
3597
5791
|
metadata = parseReviewMetadata(content);
|
|
3598
5792
|
} catch {
|
|
3599
|
-
console.error(
|
|
5793
|
+
console.error(chalk3.red(`Invalid review metadata: ${reportPath}`));
|
|
3600
5794
|
process.exit(1);
|
|
3601
5795
|
}
|
|
3602
5796
|
if (!metadata) {
|
|
3603
5797
|
console.error(
|
|
3604
|
-
|
|
5798
|
+
chalk3.red(`Review report does not contain chat session metadata: ${reportPath}`)
|
|
3605
5799
|
);
|
|
3606
5800
|
process.exit(1);
|
|
3607
5801
|
}
|
|
@@ -3611,9 +5805,9 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3611
5805
|
});
|
|
3612
5806
|
} catch (err) {
|
|
3613
5807
|
const message = err instanceof Error ? err.message : String(err);
|
|
3614
|
-
console.error(
|
|
5808
|
+
console.error(chalk3.red(`Failed to open review session: ${message}`));
|
|
3615
5809
|
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
3616
|
-
console.log(
|
|
5810
|
+
console.log(chalk3.dim(line));
|
|
3617
5811
|
}
|
|
3618
5812
|
process.exit(1);
|
|
3619
5813
|
}
|
|
@@ -3621,7 +5815,7 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
3621
5815
|
async function selectReviewInteractively() {
|
|
3622
5816
|
if (!canSelectReviewInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
|
|
3623
5817
|
console.error(
|
|
3624
|
-
|
|
5818
|
+
chalk3.red(
|
|
3625
5819
|
"Interactive review selection requires a terminal. Pass a report filename or path instead."
|
|
3626
5820
|
)
|
|
3627
5821
|
);
|
|
@@ -3629,14 +5823,14 @@ async function selectReviewInteractively() {
|
|
|
3629
5823
|
}
|
|
3630
5824
|
const reports = await listReviewReportPaths();
|
|
3631
5825
|
if (reports.length === 0) {
|
|
3632
|
-
console.error(
|
|
5826
|
+
console.error(chalk3.red("No review reports available. Run `diffowl review` first."));
|
|
3633
5827
|
process.exit(1);
|
|
3634
5828
|
}
|
|
3635
|
-
console.log(
|
|
5829
|
+
console.log(chalk3.bold("\nSelect a review:\n"));
|
|
3636
5830
|
for (const [index, report] of reports.entries()) {
|
|
3637
|
-
const resolved =
|
|
5831
|
+
const resolved = basename6(dirname4(report)) === "resolved";
|
|
3638
5832
|
console.log(
|
|
3639
|
-
` ${
|
|
5833
|
+
` ${chalk3.cyan(`${index + 1}.`)} ${basename6(report)}${resolved ? chalk3.dim(" (resolved)") : ""}`
|
|
3640
5834
|
);
|
|
3641
5835
|
}
|
|
3642
5836
|
const rl = createInterface({
|
|
@@ -3647,10 +5841,10 @@ async function selectReviewInteractively() {
|
|
|
3647
5841
|
while (true) {
|
|
3648
5842
|
const selected = selectReviewReportPath(
|
|
3649
5843
|
reports,
|
|
3650
|
-
await rl.question(
|
|
5844
|
+
await rl.question(chalk3.yellow("\nReview number: "))
|
|
3651
5845
|
);
|
|
3652
5846
|
if (selected) return selected;
|
|
3653
|
-
console.log(
|
|
5847
|
+
console.log(chalk3.red(`Enter a number between 1 and ${reports.length}.`));
|
|
3654
5848
|
}
|
|
3655
5849
|
} finally {
|
|
3656
5850
|
rl.close();
|
|
@@ -3670,6 +5864,20 @@ function formatReviewProgress(event) {
|
|
|
3670
5864
|
return event.message;
|
|
3671
5865
|
}
|
|
3672
5866
|
}
|
|
5867
|
+
async function describeNodeRuntime(node) {
|
|
5868
|
+
try {
|
|
5869
|
+
const { stdout } = await execa5(node, [
|
|
5870
|
+
"-p",
|
|
5871
|
+
"JSON.stringify({ version: process.version, modules: process.versions.modules })"
|
|
5872
|
+
]);
|
|
5873
|
+
const parsed = JSON.parse(stdout);
|
|
5874
|
+
if (typeof parsed.version === "string" && typeof parsed.modules === "string") {
|
|
5875
|
+
return `${node} (${parsed.version}, ABI ${parsed.modules})`;
|
|
5876
|
+
}
|
|
5877
|
+
} catch {
|
|
5878
|
+
}
|
|
5879
|
+
return node;
|
|
5880
|
+
}
|
|
3673
5881
|
function resolveReviewDepth(value, config) {
|
|
3674
5882
|
if (value === void 0) {
|
|
3675
5883
|
return config.context.depth;
|
|
@@ -3677,8 +5885,8 @@ function resolveReviewDepth(value, config) {
|
|
|
3677
5885
|
try {
|
|
3678
5886
|
return parseReviewContextDepth(value);
|
|
3679
5887
|
} catch {
|
|
3680
|
-
console.error(
|
|
3681
|
-
console.error(
|
|
5888
|
+
console.error(chalk3.red(`Invalid review depth: ${String(value)}`));
|
|
5889
|
+
console.error(chalk3.dim("Expected one of: shallow, default"));
|
|
3682
5890
|
process.exit(1);
|
|
3683
5891
|
}
|
|
3684
5892
|
}
|
|
@@ -3689,8 +5897,8 @@ function resolveReasoningEffort(value, config) {
|
|
|
3689
5897
|
try {
|
|
3690
5898
|
return parseReasoningEffort(value);
|
|
3691
5899
|
} catch {
|
|
3692
|
-
console.error(
|
|
3693
|
-
console.error(
|
|
5900
|
+
console.error(chalk3.red(`Invalid reasoning effort: ${String(value)}`));
|
|
5901
|
+
console.error(chalk3.dim("Expected one of: auto, none, minimal, low, medium, high, max, xhigh"));
|
|
3694
5902
|
process.exit(1);
|
|
3695
5903
|
}
|
|
3696
5904
|
}
|
|
@@ -3703,9 +5911,9 @@ function printTimingSummary(timings) {
|
|
|
3703
5911
|
...timings.filter((timing) => timing.phase !== "total"),
|
|
3704
5912
|
...timings.filter((timing) => timing.phase === "total")
|
|
3705
5913
|
];
|
|
3706
|
-
console.log(
|
|
5914
|
+
console.log(chalk3.dim("Timing:"));
|
|
3707
5915
|
for (const timing of ordered) {
|
|
3708
|
-
console.log(
|
|
5916
|
+
console.log(chalk3.dim(` ${timing.label}: ${formatDuration2(timing.ms)}`));
|
|
3709
5917
|
}
|
|
3710
5918
|
console.log();
|
|
3711
5919
|
}
|
|
@@ -3729,14 +5937,14 @@ program.command("init").description("Set up DiffOwl for this project").action(as
|
|
|
3729
5937
|
await runInit();
|
|
3730
5938
|
});
|
|
3731
5939
|
async function runInit() {
|
|
3732
|
-
console.log(
|
|
5940
|
+
console.log(chalk3.bold("DiffOwl Setup\n"));
|
|
3733
5941
|
const config = await loadConfigOrExit();
|
|
3734
5942
|
await selectModelInteractively(config, { allowKeepCurrent: false });
|
|
3735
5943
|
}
|
|
3736
5944
|
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
5945
|
const config = await loadConfigOrExit();
|
|
3738
5946
|
if (!model) {
|
|
3739
|
-
console.log(
|
|
5947
|
+
console.log(chalk3.bold("Current model: ") + chalk3.cyan(config.model));
|
|
3740
5948
|
await selectModelInteractively(config, { allowKeepCurrent: true });
|
|
3741
5949
|
return;
|
|
3742
5950
|
}
|
|
@@ -3744,16 +5952,16 @@ program.command("model").description("View or change the AI model").argument("[m
|
|
|
3744
5952
|
try {
|
|
3745
5953
|
parsedModel = parseModel(model);
|
|
3746
5954
|
} catch {
|
|
3747
|
-
console.error(
|
|
5955
|
+
console.error(chalk3.red(`Invalid model: ${model}`));
|
|
3748
5956
|
console.error(
|
|
3749
|
-
|
|
5957
|
+
chalk3.dim("Expected provider/model format, for example opencode-go/big-pickle")
|
|
3750
5958
|
);
|
|
3751
5959
|
process.exit(1);
|
|
3752
5960
|
}
|
|
3753
5961
|
config.model = parsedModel;
|
|
3754
5962
|
const configPath = await saveConfig(config);
|
|
3755
|
-
console.log(
|
|
3756
|
-
console.log(
|
|
5963
|
+
console.log(chalk3.green(`\u2713 Model set to ${chalk3.cyan(parsedModel)}`));
|
|
5964
|
+
console.log(chalk3.dim(`Config: ${configPath}`));
|
|
3757
5965
|
});
|
|
3758
5966
|
async function selectModelInteractively(config, options) {
|
|
3759
5967
|
const spinner = ora("Querying available models from OpenCode...").start();
|
|
@@ -3767,7 +5975,7 @@ async function selectModelInteractively(config, options) {
|
|
|
3767
5975
|
const message = err instanceof Error ? err.message : String(err);
|
|
3768
5976
|
spinner.fail(`Failed to query models: ${message}`);
|
|
3769
5977
|
for (const line of getOpenCodeFailureGuidance(message)) {
|
|
3770
|
-
console.error(
|
|
5978
|
+
console.error(chalk3.dim(line));
|
|
3771
5979
|
}
|
|
3772
5980
|
process.exit(1);
|
|
3773
5981
|
}
|
|
@@ -3775,19 +5983,19 @@ async function selectModelInteractively(config, options) {
|
|
|
3775
5983
|
if (models.length > 0) {
|
|
3776
5984
|
if (!canSelectModelInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
|
|
3777
5985
|
console.error(
|
|
3778
|
-
|
|
5986
|
+
chalk3.red(
|
|
3779
5987
|
"Interactive model selection requires a terminal. Pass a model explicitly, for example `diffowl model provider/model`."
|
|
3780
5988
|
)
|
|
3781
5989
|
);
|
|
3782
5990
|
process.exit(1);
|
|
3783
5991
|
}
|
|
3784
5992
|
console.log(
|
|
3785
|
-
|
|
5993
|
+
chalk3.bold(
|
|
3786
5994
|
options.allowKeepCurrent ? "\nAvailable models configured in OpenCode:" : "Available models configured in OpenCode:"
|
|
3787
5995
|
)
|
|
3788
5996
|
);
|
|
3789
5997
|
models.forEach((m, idx) => {
|
|
3790
|
-
console.log(` ${
|
|
5998
|
+
console.log(` ${chalk3.cyan(idx + 1)}. ${m}`);
|
|
3791
5999
|
});
|
|
3792
6000
|
console.log();
|
|
3793
6001
|
const rl = createInterface({
|
|
@@ -3800,7 +6008,7 @@ async function selectModelInteractively(config, options) {
|
|
|
3800
6008
|
const selection = selectModel(
|
|
3801
6009
|
models,
|
|
3802
6010
|
config.model,
|
|
3803
|
-
await rl.question(
|
|
6011
|
+
await rl.question(chalk3.yellow(promptText)),
|
|
3804
6012
|
options.allowKeepCurrent
|
|
3805
6013
|
);
|
|
3806
6014
|
if (selection.type === "kept") break;
|
|
@@ -3808,27 +6016,27 @@ async function selectModelInteractively(config, options) {
|
|
|
3808
6016
|
selectedModel = selection.model;
|
|
3809
6017
|
break;
|
|
3810
6018
|
}
|
|
3811
|
-
console.log(
|
|
6019
|
+
console.log(chalk3.red("Invalid selection. Please enter a valid number."));
|
|
3812
6020
|
}
|
|
3813
6021
|
} finally {
|
|
3814
6022
|
rl.close();
|
|
3815
6023
|
}
|
|
3816
6024
|
} else {
|
|
3817
|
-
console.log(
|
|
6025
|
+
console.log(chalk3.yellow("\nNo active/connected providers found in OpenCode."));
|
|
3818
6026
|
console.log(
|
|
3819
|
-
|
|
6027
|
+
chalk3.dim("Make sure you run ") + chalk3.cyan("opencode") + chalk3.dim(" to authenticate and set up your providers/keys first.")
|
|
3820
6028
|
);
|
|
3821
|
-
console.log(
|
|
6029
|
+
console.log(chalk3.dim("Using fallback default model: ") + chalk3.cyan(config.model));
|
|
3822
6030
|
console.log();
|
|
3823
6031
|
}
|
|
3824
6032
|
if (selectedModel !== config.model || !options.allowKeepCurrent) {
|
|
3825
6033
|
config.model = selectedModel;
|
|
3826
6034
|
const configPath = await saveConfig(config);
|
|
3827
6035
|
if (options.allowKeepCurrent) {
|
|
3828
|
-
console.log(
|
|
6036
|
+
console.log(chalk3.green(`\u2713 Model set to ${chalk3.cyan(selectedModel)}`));
|
|
3829
6037
|
} else {
|
|
3830
|
-
console.log(
|
|
3831
|
-
console.log(
|
|
6038
|
+
console.log(chalk3.green(`\u2713 Config saved to ${configPath}`));
|
|
6039
|
+
console.log(chalk3.dim(`Model set to: `) + chalk3.cyan(selectedModel));
|
|
3832
6040
|
}
|
|
3833
6041
|
console.log();
|
|
3834
6042
|
}
|
|
@@ -3836,37 +6044,40 @@ async function selectModelInteractively(config, options) {
|
|
|
3836
6044
|
var hookCmd = program.command("hook").description("Manage git hooks");
|
|
3837
6045
|
hookCmd.command("install").description("Install post-commit hook (non-blocking review)").action(async () => {
|
|
3838
6046
|
if (!await isGitRepo()) {
|
|
3839
|
-
console.error(
|
|
6047
|
+
console.error(chalk3.red("Not a git repository"));
|
|
3840
6048
|
process.exit(1);
|
|
3841
6049
|
}
|
|
3842
6050
|
const alreadyInstalled = await isHookInstalled();
|
|
3843
6051
|
const hookPath = await installHook();
|
|
6052
|
+
const command = await getHookCommand();
|
|
3844
6053
|
const action = alreadyInstalled ? "updated" : "installed";
|
|
3845
|
-
console.log(
|
|
3846
|
-
console.log(
|
|
6054
|
+
console.log(chalk3.green(`\u2713 Post-commit hook ${action}: ${hookPath}`));
|
|
6055
|
+
console.log(chalk3.dim(`Hook Node: ${await describeNodeRuntime(command.node)}`));
|
|
6056
|
+
console.log(chalk3.dim(`Hook Entrypoint: ${command.cli}`));
|
|
6057
|
+
console.log(chalk3.dim("Reviews will run automatically after each commit (non-blocking)"));
|
|
3847
6058
|
console.log(
|
|
3848
|
-
|
|
6059
|
+
chalk3.dim("Hook output: .diffowl/hook.log; latest report: .diffowl/reviews/latest.md")
|
|
3849
6060
|
);
|
|
3850
6061
|
});
|
|
3851
6062
|
hookCmd.command("status").description("Check if the post-commit hook is installed and up to date").action(async () => {
|
|
3852
6063
|
const status = await checkHookStale();
|
|
3853
6064
|
if (!status.installed) {
|
|
3854
|
-
console.log(
|
|
6065
|
+
console.log(chalk3.yellow(`\u2717 ${status.reason ?? "Hook not installed"}`));
|
|
3855
6066
|
return;
|
|
3856
6067
|
}
|
|
3857
6068
|
if (status.stale) {
|
|
3858
|
-
console.log(
|
|
3859
|
-
console.log(
|
|
3860
|
-
console.log(
|
|
6069
|
+
console.log(chalk3.yellow("\u26A0 Hook is installed but stale"));
|
|
6070
|
+
console.log(chalk3.dim(`Reason: ${status.reason}`));
|
|
6071
|
+
console.log(chalk3.dim("Run `diffowl hook install` to update it."));
|
|
3861
6072
|
return;
|
|
3862
6073
|
}
|
|
3863
|
-
console.log(
|
|
6074
|
+
console.log(chalk3.green("\u2713 Hook is installed and up to date"));
|
|
3864
6075
|
});
|
|
3865
6076
|
hookCmd.command("uninstall").description("Remove the post-commit hook").action(async () => {
|
|
3866
6077
|
if (await uninstallHook()) {
|
|
3867
|
-
console.log(
|
|
6078
|
+
console.log(chalk3.green("\u2713 Hook removed"));
|
|
3868
6079
|
} else {
|
|
3869
|
-
console.log(
|
|
6080
|
+
console.log(chalk3.yellow("No diffowl hook found"));
|
|
3870
6081
|
}
|
|
3871
6082
|
});
|
|
3872
6083
|
program.command("hook-run", { hidden: true }).description("Spawn a non-blocking hook review").action(async () => {
|
|
@@ -3892,52 +6103,159 @@ serverCmd.command("start").description("Start the OpenCode server").action(async
|
|
|
3892
6103
|
}
|
|
3893
6104
|
});
|
|
3894
6105
|
serverCmd.command("stop").description("Stop the OpenCode server").action(async () => {
|
|
3895
|
-
|
|
3896
|
-
|
|
6106
|
+
const config = await loadConfigOrExit();
|
|
6107
|
+
if (await stopServer(config.server.port)) {
|
|
6108
|
+
console.log(chalk3.green("\u2713 Server stopped"));
|
|
3897
6109
|
} else {
|
|
3898
|
-
console.log(
|
|
6110
|
+
console.log(chalk3.yellow(`No OpenCode server found on port ${config.server.port}`));
|
|
3899
6111
|
}
|
|
3900
6112
|
});
|
|
3901
6113
|
serverCmd.command("status").description("Check if the OpenCode server is running").action(async () => {
|
|
3902
6114
|
const config = await loadConfigOrExit();
|
|
3903
|
-
const
|
|
3904
|
-
if (
|
|
3905
|
-
console.log(
|
|
3906
|
-
|
|
3907
|
-
|
|
6115
|
+
const health = await getServerHealth(config.server.port);
|
|
6116
|
+
if (!health?.healthy) {
|
|
6117
|
+
console.log(chalk3.yellow(`\u2717 No server on port ${config.server.port}`));
|
|
6118
|
+
return;
|
|
6119
|
+
}
|
|
6120
|
+
console.log(chalk3.green(`\u2713 Server running on port ${config.server.port}`));
|
|
6121
|
+
const cliVersion = await getInstalledOpencodeVersion();
|
|
6122
|
+
if (health.version) {
|
|
6123
|
+
console.log(` Server version: ${health.version}`);
|
|
6124
|
+
}
|
|
6125
|
+
if (cliVersion) {
|
|
6126
|
+
console.log(` CLI version: ${cliVersion}`);
|
|
6127
|
+
}
|
|
6128
|
+
if (health.version && cliVersion && health.version !== cliVersion) {
|
|
6129
|
+
console.log(
|
|
6130
|
+
chalk3.yellow(
|
|
6131
|
+
"\u26A0 Version mismatch. Restart with: diffowl server stop && diffowl server start"
|
|
6132
|
+
)
|
|
6133
|
+
);
|
|
6134
|
+
}
|
|
6135
|
+
});
|
|
6136
|
+
var findingsCmd = program.command("findings").description("Inspect and manage durable findings");
|
|
6137
|
+
findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").action(async () => {
|
|
6138
|
+
await loadConfigOrExit();
|
|
6139
|
+
const items = await withFindingDatabase(getDiffOwlDir(), listUnresolvedFindings);
|
|
6140
|
+
if (items.length === 0) {
|
|
6141
|
+
console.log(chalk3.green("No unresolved findings."));
|
|
6142
|
+
return;
|
|
6143
|
+
}
|
|
6144
|
+
console.log(formatFindingList(items));
|
|
6145
|
+
});
|
|
6146
|
+
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) => {
|
|
6147
|
+
await loadConfigOrExit();
|
|
6148
|
+
const format = resolveReviewOutputFormat(options.format);
|
|
6149
|
+
try {
|
|
6150
|
+
const detail = await withFindingDatabase(
|
|
6151
|
+
getDiffOwlDir(),
|
|
6152
|
+
(db) => requireFindingDetail(db, locator)
|
|
6153
|
+
);
|
|
6154
|
+
if (format === "json") {
|
|
6155
|
+
process.stdout.write(renderFindingDetailJson(detail));
|
|
6156
|
+
return;
|
|
6157
|
+
}
|
|
6158
|
+
console.log(formatFindingDetail(detail));
|
|
6159
|
+
} catch (err) {
|
|
6160
|
+
failFindingsCommand(format, err);
|
|
6161
|
+
}
|
|
6162
|
+
});
|
|
6163
|
+
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) => {
|
|
6164
|
+
await runFindingMutation(
|
|
6165
|
+
locator,
|
|
6166
|
+
options.format,
|
|
6167
|
+
(db) => dismissFindingByLocator(db, locator, {
|
|
6168
|
+
actor: parseFindingActor(options.actor),
|
|
6169
|
+
reason: options.reason
|
|
6170
|
+
})
|
|
6171
|
+
);
|
|
6172
|
+
});
|
|
6173
|
+
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) => {
|
|
6174
|
+
await runFindingMutation(
|
|
6175
|
+
locator,
|
|
6176
|
+
options.format,
|
|
6177
|
+
(db) => deferFindingByLocator(db, locator, {
|
|
6178
|
+
actor: parseFindingActor(options.actor),
|
|
6179
|
+
reason: options.reason
|
|
6180
|
+
})
|
|
6181
|
+
);
|
|
6182
|
+
});
|
|
6183
|
+
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(
|
|
6184
|
+
async (locator, options) => {
|
|
6185
|
+
const verifiedBy = options.verifiedBy ?? [];
|
|
6186
|
+
if (verifiedBy.length === 0) {
|
|
6187
|
+
failFindingsCommand(
|
|
6188
|
+
resolveReviewOutputFormat(options.format),
|
|
6189
|
+
new Error("At least one --verified-by command is required.")
|
|
6190
|
+
);
|
|
6191
|
+
}
|
|
6192
|
+
await runFindingMutation(
|
|
6193
|
+
locator,
|
|
6194
|
+
options.format,
|
|
6195
|
+
(db) => fixFindingByLocator(db, locator, {
|
|
6196
|
+
actor: parseFindingActor(options.actor),
|
|
6197
|
+
note: options.note,
|
|
6198
|
+
verifiedBy,
|
|
6199
|
+
...options.commit ? { commitRef: options.commit } : {}
|
|
6200
|
+
})
|
|
6201
|
+
);
|
|
3908
6202
|
}
|
|
6203
|
+
);
|
|
6204
|
+
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) => {
|
|
6205
|
+
await runFindingMutation(
|
|
6206
|
+
locator,
|
|
6207
|
+
options.format,
|
|
6208
|
+
(db) => reopenFindingByLocator(db, locator, {
|
|
6209
|
+
actor: parseFindingActor(options.actor),
|
|
6210
|
+
reason: options.reason
|
|
6211
|
+
})
|
|
6212
|
+
);
|
|
3909
6213
|
});
|
|
3910
6214
|
program.parse();
|
|
6215
|
+
async function runFindingMutation(_locator, formatValue, mutate) {
|
|
6216
|
+
await loadConfigOrExit();
|
|
6217
|
+
const format = resolveReviewOutputFormat(formatValue);
|
|
6218
|
+
try {
|
|
6219
|
+
const detail = await withFindingDatabase(getDiffOwlDir(), mutate);
|
|
6220
|
+
if (format === "json") {
|
|
6221
|
+
process.stdout.write(renderFindingDetailJson(detail));
|
|
6222
|
+
return;
|
|
6223
|
+
}
|
|
6224
|
+
console.log(formatFindingDetail(detail));
|
|
6225
|
+
} catch (err) {
|
|
6226
|
+
failFindingsCommand(format, err);
|
|
6227
|
+
}
|
|
6228
|
+
}
|
|
6229
|
+
function failFindingsCommand(format, err) {
|
|
6230
|
+
const message = err instanceof LocatorNotFoundError || err instanceof LocatorAmbiguousError || err instanceof InvalidFindingTransitionError ? err.message : err instanceof Error ? err.message : String(err);
|
|
6231
|
+
if (format === "json") {
|
|
6232
|
+
writeJsonError(message);
|
|
6233
|
+
} else {
|
|
6234
|
+
console.error(chalk3.red(message));
|
|
6235
|
+
}
|
|
6236
|
+
process.exit(1);
|
|
6237
|
+
}
|
|
6238
|
+
function parseFindingActor(value) {
|
|
6239
|
+
if (value === void 0 || value === "user") {
|
|
6240
|
+
return "user";
|
|
6241
|
+
}
|
|
6242
|
+
if (value === "agent") {
|
|
6243
|
+
return "agent";
|
|
6244
|
+
}
|
|
6245
|
+
throw new Error(`Invalid actor: ${value}. Expected user or agent.`);
|
|
6246
|
+
}
|
|
6247
|
+
function collectValues(value, previous) {
|
|
6248
|
+
return [...previous, value];
|
|
6249
|
+
}
|
|
3911
6250
|
async function loadConfigOrExit() {
|
|
3912
6251
|
try {
|
|
3913
6252
|
return await loadConfig();
|
|
3914
6253
|
} catch (err) {
|
|
3915
6254
|
const message = err instanceof Error ? err.message : String(err);
|
|
3916
|
-
console.error(
|
|
6255
|
+
console.error(chalk3.red(`Config error: ${message}`));
|
|
3917
6256
|
process.exit(1);
|
|
3918
6257
|
}
|
|
3919
6258
|
}
|
|
3920
|
-
function filterFindingsByConfidence(findings, minConfidence) {
|
|
3921
|
-
const levels = ["low", "medium", "high"];
|
|
3922
|
-
const minIndex = levels.indexOf(minConfidence);
|
|
3923
|
-
const kept = findings.filter((f) => {
|
|
3924
|
-
const idx = levels.indexOf(f.confidence.toLowerCase());
|
|
3925
|
-
return idx >= minIndex;
|
|
3926
|
-
});
|
|
3927
|
-
return { findings: kept, dropped: findings.length - kept.length };
|
|
3928
|
-
}
|
|
3929
|
-
function filterFindingsByChangedFiles(findings, changedFiles) {
|
|
3930
|
-
const kept = [];
|
|
3931
|
-
const suppressed = [];
|
|
3932
|
-
for (const finding of findings) {
|
|
3933
|
-
if (changedFiles.has(finding.file)) {
|
|
3934
|
-
kept.push(finding);
|
|
3935
|
-
} else {
|
|
3936
|
-
suppressed.push(finding);
|
|
3937
|
-
}
|
|
3938
|
-
}
|
|
3939
|
-
return { findings: kept, suppressed };
|
|
3940
|
-
}
|
|
3941
6259
|
function buildDocOnlySkipMarkdown(diff) {
|
|
3942
6260
|
const lines = [];
|
|
3943
6261
|
lines.push("### Summary");
|
|
@@ -3949,4 +6267,79 @@ function buildDocOnlySkipMarkdown(diff) {
|
|
|
3949
6267
|
}
|
|
3950
6268
|
return lines.join("\n");
|
|
3951
6269
|
}
|
|
6270
|
+
async function resolveTargetCommit(target) {
|
|
6271
|
+
switch (target.kind) {
|
|
6272
|
+
case "staged":
|
|
6273
|
+
return null;
|
|
6274
|
+
case "last-commit":
|
|
6275
|
+
return resolveCommitRef("HEAD");
|
|
6276
|
+
case "commit":
|
|
6277
|
+
return resolveCommitRef(target.ref);
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
function handleReviewInterrupt(input) {
|
|
6281
|
+
input.cancelController.abort();
|
|
6282
|
+
try {
|
|
6283
|
+
input.spinner?.stop();
|
|
6284
|
+
} catch {
|
|
6285
|
+
}
|
|
6286
|
+
if (input.jsonMode) {
|
|
6287
|
+
writeJsonError(input.message);
|
|
6288
|
+
} else {
|
|
6289
|
+
console.log(chalk3.yellow(`
|
|
6290
|
+
${input.message}`));
|
|
6291
|
+
}
|
|
6292
|
+
if (input.hook) {
|
|
6293
|
+
const forceExit = setTimeout(() => process.exit(0), 2e3);
|
|
6294
|
+
void writeHookStatus(1, input.hookCommit, input.message).finally(() => {
|
|
6295
|
+
clearTimeout(forceExit);
|
|
6296
|
+
process.exit(0);
|
|
6297
|
+
});
|
|
6298
|
+
return;
|
|
6299
|
+
}
|
|
6300
|
+
setTimeout(() => process.exit(input.exitCode), 750).unref();
|
|
6301
|
+
}
|
|
6302
|
+
function resolveReviewOutputFormat(value) {
|
|
6303
|
+
try {
|
|
6304
|
+
return parseReviewOutputFormat(value);
|
|
6305
|
+
} catch (err) {
|
|
6306
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6307
|
+
console.error(chalk3.red(message));
|
|
6308
|
+
process.exit(1);
|
|
6309
|
+
}
|
|
6310
|
+
}
|
|
6311
|
+
async function failReview(format, message, options = {}) {
|
|
6312
|
+
const exitCode = options.exitCode ?? 1;
|
|
6313
|
+
if (format === "json") {
|
|
6314
|
+
writeJsonError(message);
|
|
6315
|
+
} else {
|
|
6316
|
+
console.error(chalk3.red(message));
|
|
6317
|
+
}
|
|
6318
|
+
if (options.hook) {
|
|
6319
|
+
await writeHookStatus(1, options.hookCommit, message);
|
|
6320
|
+
process.exit(0);
|
|
6321
|
+
}
|
|
6322
|
+
process.exit(exitCode);
|
|
6323
|
+
}
|
|
6324
|
+
async function emitReviewJsonSuccess(input) {
|
|
6325
|
+
const review = await getPersistedReview(input.diffOwlDir, input.reviewId);
|
|
6326
|
+
if (!review) {
|
|
6327
|
+
throw new Error(`Review ${input.reviewId} was not found in state database.`);
|
|
6328
|
+
}
|
|
6329
|
+
const findingIds = input.persisted.reconcile.observations.map((item) => item.finding.id);
|
|
6330
|
+
const occurrenceCounts = await loadFindingOccurrenceCounts(input.diffOwlDir, findingIds);
|
|
6331
|
+
const document = buildReviewJsonDocument({
|
|
6332
|
+
review,
|
|
6333
|
+
persisted: input.persisted,
|
|
6334
|
+
occurrenceCounts,
|
|
6335
|
+
suppressed: {
|
|
6336
|
+
outsideChangedFiles: input.suppressed.outsideChangedFiles,
|
|
6337
|
+
belowConfidence: input.suppressed.belowConfidence
|
|
6338
|
+
},
|
|
6339
|
+
verbose: input.verbose,
|
|
6340
|
+
...input.timings ? { timings: input.timings } : {},
|
|
6341
|
+
...input.usage !== void 0 ? { usage: input.usage } : {}
|
|
6342
|
+
});
|
|
6343
|
+
writeReviewJsonSuccess(document);
|
|
6344
|
+
}
|
|
3952
6345
|
//# sourceMappingURL=cli.js.map
|