diffowl 0.3.0 → 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 +9 -6
- package/dist/cli.js +332 -127
- package/dist/cli.js.map +1 -1
- package/package.json +8 -15
package/dist/cli.js
CHANGED
|
@@ -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
|
}
|
|
@@ -213,6 +216,9 @@ async function ensureServer(port) {
|
|
|
213
216
|
return baseUrl;
|
|
214
217
|
}
|
|
215
218
|
}
|
|
219
|
+
if (await stopUnhealthyServerListener(port)) {
|
|
220
|
+
await waitUntilPortFree(port);
|
|
221
|
+
}
|
|
216
222
|
await spawnServer(port);
|
|
217
223
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
218
224
|
await sleep(STARTUP_WAIT_MS / MAX_RETRIES);
|
|
@@ -323,6 +329,19 @@ async function stopManagedServer() {
|
|
|
323
329
|
}
|
|
324
330
|
return true;
|
|
325
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");
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
await cleanupPidFile();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
326
345
|
async function cleanupPidFile() {
|
|
327
346
|
const pidFile = join2(getDiffOwlDir(), "server.pid");
|
|
328
347
|
if (!existsSync2(pidFile)) {
|
|
@@ -376,12 +395,12 @@ async function findOpencodeListenerPidWindows(port) {
|
|
|
376
395
|
async function waitUntilPortFree(port) {
|
|
377
396
|
const deadline = Date.now() + PORT_RELEASE_WAIT_MS;
|
|
378
397
|
while (Date.now() < deadline) {
|
|
379
|
-
if (
|
|
398
|
+
if (await findOpencodeListenerPid(port) === null) {
|
|
380
399
|
return;
|
|
381
400
|
}
|
|
382
401
|
await sleep(PORT_RELEASE_POLL_MS);
|
|
383
402
|
}
|
|
384
|
-
if (await
|
|
403
|
+
if (await findOpencodeListenerPid(port) !== null) {
|
|
385
404
|
throw new Error(
|
|
386
405
|
`OpenCode server on port ${port} did not stop within ${PORT_RELEASE_WAIT_MS}ms. Retry: diffowl server stop && diffowl server start`
|
|
387
406
|
);
|
|
@@ -1005,6 +1024,59 @@ function listAvailableModels(payload) {
|
|
|
1005
1024
|
).sort();
|
|
1006
1025
|
}
|
|
1007
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
|
+
|
|
1008
1080
|
// src/opencode/client.ts
|
|
1009
1081
|
var ReviewCancelledError = class extends Error {
|
|
1010
1082
|
name = "ReviewCancelledError";
|
|
@@ -1012,6 +1084,18 @@ var ReviewCancelledError = class extends Error {
|
|
|
1012
1084
|
function isReviewCancellation(error) {
|
|
1013
1085
|
return error instanceof ReviewCancelledError;
|
|
1014
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
|
+
}
|
|
1015
1099
|
function normalizeOpenCodeEvent(event, expectedSessionId) {
|
|
1016
1100
|
if (!event || typeof event !== "object") return void 0;
|
|
1017
1101
|
const payload = event.payload;
|
|
@@ -1093,11 +1177,13 @@ function normalizeAssistantMessage(info, expectedSessionId) {
|
|
|
1093
1177
|
if (value["role"] !== "assistant" || typeof value["sessionID"] !== "string" || typeof value["id"] !== "string" || expectedSessionId !== void 0 && value["sessionID"] !== expectedSessionId) {
|
|
1094
1178
|
return void 0;
|
|
1095
1179
|
}
|
|
1180
|
+
const usage = parseAssistantUsage(value);
|
|
1096
1181
|
return {
|
|
1097
1182
|
type: "assistant-message",
|
|
1098
1183
|
sessionId: value["sessionID"],
|
|
1099
1184
|
messageId: value["id"],
|
|
1100
|
-
...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {}
|
|
1185
|
+
...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {},
|
|
1186
|
+
...usage ? { usage } : {}
|
|
1101
1187
|
};
|
|
1102
1188
|
}
|
|
1103
1189
|
async function runReview(options) {
|
|
@@ -1133,14 +1219,14 @@ async function runReview(options) {
|
|
|
1133
1219
|
const tools = await buildToolPolicy(client, depth);
|
|
1134
1220
|
recordTiming(timings, onProgress, "tool-policy", "OpenCode tool policy", toolPolicyStart);
|
|
1135
1221
|
const promptStart = performance.now();
|
|
1136
|
-
const prompt =
|
|
1222
|
+
const { system, user: prompt } = resolveReviewPrompts({
|
|
1137
1223
|
target,
|
|
1138
|
-
config
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
);
|
|
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
|
+
});
|
|
1144
1230
|
recordTiming(timings, onProgress, "prompt-build", "Review prompt build", promptStart);
|
|
1145
1231
|
const parts = config.model.split("/");
|
|
1146
1232
|
const providerID = parts[0];
|
|
@@ -1166,6 +1252,7 @@ async function runReview(options) {
|
|
|
1166
1252
|
})
|
|
1167
1253
|
);
|
|
1168
1254
|
recordTiming(timings, onProgress, "event-stream", "OpenCode event stream connection", eventStart);
|
|
1255
|
+
const usageByMessageId = /* @__PURE__ */ new Map();
|
|
1169
1256
|
const responsePromise = handledAwaitable(
|
|
1170
1257
|
new Promise((resolve2, reject) => {
|
|
1171
1258
|
const assistantMessageIds = /* @__PURE__ */ new Set();
|
|
@@ -1222,6 +1309,9 @@ async function runReview(options) {
|
|
|
1222
1309
|
break;
|
|
1223
1310
|
case "assistant-message": {
|
|
1224
1311
|
assistantMessageIds.add(normalized.messageId);
|
|
1312
|
+
if (normalized.usage) {
|
|
1313
|
+
usageByMessageId.set(normalized.messageId, normalized.usage);
|
|
1314
|
+
}
|
|
1225
1315
|
const text = textPartsByMessageId.get(normalized.messageId);
|
|
1226
1316
|
settlement.acceptAssistantMessage({ text, error: normalized.error });
|
|
1227
1317
|
break;
|
|
@@ -1284,7 +1374,7 @@ async function runReview(options) {
|
|
|
1284
1374
|
path: { id: sessionId },
|
|
1285
1375
|
...directoryOptions,
|
|
1286
1376
|
body: {
|
|
1287
|
-
system
|
|
1377
|
+
system,
|
|
1288
1378
|
model: { providerID, modelID },
|
|
1289
1379
|
tools,
|
|
1290
1380
|
...reasoning.variant ? { variant: reasoning.variant } : {},
|
|
@@ -1304,9 +1394,16 @@ async function runReview(options) {
|
|
|
1304
1394
|
const report = parseStructuredReview(raw);
|
|
1305
1395
|
recordTiming(timings, onProgress, "parse-review", "Review JSON parsing", parseStart);
|
|
1306
1396
|
const diagnostics = [...report.diagnostics ?? [], ...reasoning.diagnostics];
|
|
1397
|
+
const usage = aggregateReviewUsage([...usageByMessageId.values()]);
|
|
1307
1398
|
return {
|
|
1308
|
-
report: {
|
|
1309
|
-
|
|
1399
|
+
report: {
|
|
1400
|
+
...report,
|
|
1401
|
+
...diagnostics.length > 0 ? { diagnostics } : {},
|
|
1402
|
+
timings,
|
|
1403
|
+
...usage ? { usage } : {}
|
|
1404
|
+
},
|
|
1405
|
+
sessionId,
|
|
1406
|
+
...usage ? { usage } : {}
|
|
1310
1407
|
};
|
|
1311
1408
|
} finally {
|
|
1312
1409
|
signal?.removeEventListener("abort", cancelReview);
|
|
@@ -1515,13 +1612,6 @@ function getOpenCodeFailureGuidance(message) {
|
|
|
1515
1612
|
if (normalized.includes("timed out") || normalized.includes("timeout")) {
|
|
1516
1613
|
return ["Retry with less context: diffowl review --depth shallow"];
|
|
1517
1614
|
}
|
|
1518
|
-
if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
|
|
1519
|
-
return [
|
|
1520
|
-
"Native module ABI mismatch. Rebuild for your active Node: pnpm rebuild better-sqlite3",
|
|
1521
|
-
"Reinstall the hook so it uses the same Node as the CLI: diffowl hook install",
|
|
1522
|
-
"Compare the required NODE_MODULE_VERSION with the Hook Node ABI from `diffowl hook install`."
|
|
1523
|
-
];
|
|
1524
|
-
}
|
|
1525
1615
|
if (normalized.includes("session_message.seq") || normalized.includes("not null constraint failed") && normalized.includes("seq")) {
|
|
1526
1616
|
return [
|
|
1527
1617
|
"OpenCode server version may be stale. Check: diffowl server status",
|
|
@@ -1562,7 +1652,7 @@ import {
|
|
|
1562
1652
|
writeFileSync,
|
|
1563
1653
|
writeSync
|
|
1564
1654
|
} from "fs";
|
|
1565
|
-
import { dirname as dirname2, join as join3 } from "path";
|
|
1655
|
+
import { basename, dirname as dirname2, join as join3 } from "path";
|
|
1566
1656
|
import { fileURLToPath } from "url";
|
|
1567
1657
|
import { execa as execa2 } from "execa";
|
|
1568
1658
|
import { z as z4 } from "zod";
|
|
@@ -1599,10 +1689,16 @@ async function getHooksDir() {
|
|
|
1599
1689
|
}
|
|
1600
1690
|
return hooksDir;
|
|
1601
1691
|
}
|
|
1602
|
-
async function
|
|
1692
|
+
async function getHookPath() {
|
|
1603
1693
|
const hooksDir = await getHooksDir();
|
|
1604
|
-
|
|
1605
|
-
|
|
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 });
|
|
1606
1702
|
const command = await resolveHookCommand();
|
|
1607
1703
|
if (existsSync3(hookPath)) {
|
|
1608
1704
|
const existing = await readFile4(hookPath, "utf-8");
|
|
@@ -1619,8 +1715,7 @@ ${hookSection}` : generateHookScript(command);
|
|
|
1619
1715
|
return hookPath;
|
|
1620
1716
|
}
|
|
1621
1717
|
async function uninstallHook() {
|
|
1622
|
-
const
|
|
1623
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1718
|
+
const hookPath = await getHookPath();
|
|
1624
1719
|
if (!existsSync3(hookPath)) return false;
|
|
1625
1720
|
const content = await readFile4(hookPath, "utf-8");
|
|
1626
1721
|
if (!content.includes(HOOK_MARKER)) return false;
|
|
@@ -1633,8 +1728,7 @@ async function uninstallHook() {
|
|
|
1633
1728
|
return true;
|
|
1634
1729
|
}
|
|
1635
1730
|
async function isHookInstalled() {
|
|
1636
|
-
const
|
|
1637
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1731
|
+
const hookPath = await getHookPath();
|
|
1638
1732
|
if (!existsSync3(hookPath)) return false;
|
|
1639
1733
|
const content = await readFile4(hookPath, "utf-8");
|
|
1640
1734
|
return content.includes(HOOK_MARKER);
|
|
@@ -1732,9 +1826,6 @@ function isHookQueueStopFailure(message) {
|
|
|
1732
1826
|
if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
|
|
1733
1827
|
return true;
|
|
1734
1828
|
}
|
|
1735
|
-
if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
|
|
1736
|
-
return true;
|
|
1737
|
-
}
|
|
1738
1829
|
if (normalized.includes("opencode not found") || normalized.includes("opencode: command not found") || normalized.includes("enoent") && normalized.includes("opencode")) {
|
|
1739
1830
|
return true;
|
|
1740
1831
|
}
|
|
@@ -1970,13 +2061,12 @@ function isHookReviewLockActive(lockFile) {
|
|
|
1970
2061
|
}
|
|
1971
2062
|
}
|
|
1972
2063
|
async function checkHookStale() {
|
|
1973
|
-
let
|
|
2064
|
+
let hookPath;
|
|
1974
2065
|
try {
|
|
1975
|
-
|
|
2066
|
+
hookPath = await getHookPath();
|
|
1976
2067
|
} catch {
|
|
1977
2068
|
return { installed: false, stale: false, reason: "Not a git repository" };
|
|
1978
2069
|
}
|
|
1979
|
-
const hookPath = join3(hooksDir, "post-commit");
|
|
1980
2070
|
if (!existsSync3(hookPath)) {
|
|
1981
2071
|
return { installed: false, stale: false, reason: "No post-commit hook found" };
|
|
1982
2072
|
}
|
|
@@ -2125,57 +2215,64 @@ function shellQuote(value) {
|
|
|
2125
2215
|
|
|
2126
2216
|
// src/git/diff.ts
|
|
2127
2217
|
import { execa as execa3 } from "execa";
|
|
2128
|
-
import { basename, extname } from "path";
|
|
2218
|
+
import { basename as basename2, extname } from "path";
|
|
2129
2219
|
var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
2130
|
-
async function getResolvedCommitDiff(commit) {
|
|
2131
|
-
const raw = await collectGitDiff(
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
+
);
|
|
2143
2236
|
return parseDiff(raw.stdout, raw.diagnostics);
|
|
2144
2237
|
}
|
|
2145
|
-
async function resolveCommitRef(ref) {
|
|
2238
|
+
async function resolveCommitRef(ref, cwd) {
|
|
2146
2239
|
const trimmed = ref.trim();
|
|
2147
2240
|
if (trimmed === "") {
|
|
2148
2241
|
throw new Error("Commit ref must not be empty.");
|
|
2149
2242
|
}
|
|
2150
2243
|
try {
|
|
2151
|
-
const { stdout } = await execa3(
|
|
2152
|
-
"
|
|
2153
|
-
"--verify",
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
`${trimmed}^{commit}`
|
|
2157
|
-
]);
|
|
2244
|
+
const { stdout } = await execa3(
|
|
2245
|
+
"git",
|
|
2246
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", `${trimmed}^{commit}`],
|
|
2247
|
+
cwd ? { cwd } : {}
|
|
2248
|
+
);
|
|
2158
2249
|
return stdout.trim();
|
|
2159
2250
|
} catch {
|
|
2160
2251
|
throw new Error(`Invalid commit ref: ${ref}`);
|
|
2161
2252
|
}
|
|
2162
2253
|
}
|
|
2163
|
-
async function getStagedDiff() {
|
|
2164
|
-
const raw = await collectGitDiff(
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
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
|
+
);
|
|
2174
2268
|
return parseDiff(raw.stdout, raw.diagnostics);
|
|
2175
2269
|
}
|
|
2176
|
-
async function collectGitDiff(args) {
|
|
2270
|
+
async function collectGitDiff(args, cwd) {
|
|
2177
2271
|
try {
|
|
2178
|
-
const { stdout } = await execa3("git", args, {
|
|
2272
|
+
const { stdout } = await execa3("git", args, {
|
|
2273
|
+
maxBuffer: MAX_DIFF_OUTPUT_BYTES,
|
|
2274
|
+
...cwd ? { cwd } : {}
|
|
2275
|
+
});
|
|
2179
2276
|
return { stdout, diagnostics: [] };
|
|
2180
2277
|
} catch (err) {
|
|
2181
2278
|
if (isMaxBufferError(err)) {
|
|
@@ -2418,7 +2515,7 @@ var DOC_BASENAME_PATTERNS = [
|
|
|
2418
2515
|
/^TODO/i
|
|
2419
2516
|
];
|
|
2420
2517
|
function isDocFile(path) {
|
|
2421
|
-
const base =
|
|
2518
|
+
const base = basename2(path);
|
|
2422
2519
|
const extension = extname(base).toLowerCase();
|
|
2423
2520
|
if (DOC_EXTENSIONS.has(extension)) return true;
|
|
2424
2521
|
if (extension) return false;
|
|
@@ -2429,7 +2526,7 @@ function isDocOnlyDiff(diff) {
|
|
|
2429
2526
|
}
|
|
2430
2527
|
|
|
2431
2528
|
// src/review/context.ts
|
|
2432
|
-
import { basename as
|
|
2529
|
+
import { basename as basename4, dirname as dirname3, extname as extname5, join as join6 } from "path";
|
|
2433
2530
|
import picomatch from "picomatch";
|
|
2434
2531
|
|
|
2435
2532
|
// src/review/ast/index.ts
|
|
@@ -2629,7 +2726,7 @@ function isCodePath(path) {
|
|
|
2629
2726
|
}
|
|
2630
2727
|
|
|
2631
2728
|
// src/review/context-references.ts
|
|
2632
|
-
import { basename as
|
|
2729
|
+
import { basename as basename3, extname as extname3 } from "path";
|
|
2633
2730
|
var MAX_REFERENCES_PER_TERM = 8;
|
|
2634
2731
|
var MAX_REFERENCE_TERMS = 8;
|
|
2635
2732
|
var MAX_REFERENCE_LINE_CHARS = 220;
|
|
@@ -2644,7 +2741,7 @@ async function buildReferenceContexts(source, changedFiles, skippedFiles, diagno
|
|
|
2644
2741
|
...skippedFiles.map((file) => file.path)
|
|
2645
2742
|
]);
|
|
2646
2743
|
for (const file of changedFiles) {
|
|
2647
|
-
terms.add(
|
|
2744
|
+
terms.add(basename3(file.file.path, extname3(file.file.path)));
|
|
2648
2745
|
for (const symbol of file.symbols.slice(0, 4)) {
|
|
2649
2746
|
terms.add(symbol);
|
|
2650
2747
|
}
|
|
@@ -3063,24 +3160,24 @@ async function loadReviewSnapshot(root, target) {
|
|
|
3063
3160
|
return {
|
|
3064
3161
|
root,
|
|
3065
3162
|
target,
|
|
3066
|
-
diff: await getStagedDiff(),
|
|
3163
|
+
diff: await getStagedDiff(root),
|
|
3067
3164
|
source: createGitContextSource(root, { kind: "staged" })
|
|
3068
3165
|
};
|
|
3069
3166
|
case "commit": {
|
|
3070
|
-
const sha = await resolveCommitRef(target.ref);
|
|
3167
|
+
const sha = await resolveCommitRef(target.ref, root);
|
|
3071
3168
|
return {
|
|
3072
3169
|
root,
|
|
3073
3170
|
target,
|
|
3074
|
-
diff: await getResolvedCommitDiff(sha),
|
|
3171
|
+
diff: await getResolvedCommitDiff(sha, root),
|
|
3075
3172
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
3076
3173
|
};
|
|
3077
3174
|
}
|
|
3078
3175
|
case "last-commit": {
|
|
3079
|
-
const sha = await resolveCommitRef("HEAD");
|
|
3176
|
+
const sha = await resolveCommitRef("HEAD", root);
|
|
3080
3177
|
return {
|
|
3081
3178
|
root,
|
|
3082
3179
|
target,
|
|
3083
|
-
diff: await getResolvedCommitDiff(sha),
|
|
3180
|
+
diff: await getResolvedCommitDiff(sha, root),
|
|
3084
3181
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
3085
3182
|
};
|
|
3086
3183
|
}
|
|
@@ -3186,7 +3283,7 @@ async function buildRelatedFileContexts(source, files) {
|
|
|
3186
3283
|
return related;
|
|
3187
3284
|
}
|
|
3188
3285
|
function shouldReviewFile(path, config) {
|
|
3189
|
-
if (LOCKFILE_EXCLUDES.has(
|
|
3286
|
+
if (LOCKFILE_EXCLUDES.has(basename4(path))) return false;
|
|
3190
3287
|
const include = config.include.length > 0 ? config.include : ["**/*"];
|
|
3191
3288
|
if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
|
|
3192
3289
|
return false;
|
|
@@ -3307,7 +3404,7 @@ function getChangedLinesByFile(rawDiff) {
|
|
|
3307
3404
|
function testCandidates(path) {
|
|
3308
3405
|
const dir = dirname3(path);
|
|
3309
3406
|
const ext = extname5(path);
|
|
3310
|
-
const base =
|
|
3407
|
+
const base = basename4(path, ext);
|
|
3311
3408
|
return [
|
|
3312
3409
|
join6(dir, `${base}.test${ext}`),
|
|
3313
3410
|
join6(dir, `${base}.spec${ext}`),
|
|
@@ -3334,6 +3431,29 @@ function addUniqueDiagnostics(target, diagnostics) {
|
|
|
3334
3431
|
}
|
|
3335
3432
|
}
|
|
3336
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
|
+
|
|
3337
3457
|
// src/review/formatter.ts
|
|
3338
3458
|
import chalk from "chalk";
|
|
3339
3459
|
import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
|
|
@@ -3560,7 +3680,7 @@ function formatExcludedCandidateSummary(belowConfidence, outsideChangedFiles) {
|
|
|
3560
3680
|
|
|
3561
3681
|
// src/review/report-path.ts
|
|
3562
3682
|
import { readFile as readFile6, readdir as readdir2 } from "fs/promises";
|
|
3563
|
-
import { basename as
|
|
3683
|
+
import { basename as basename5, isAbsolute, join as join8, resolve } from "path";
|
|
3564
3684
|
function resolveReviewReportPath(report) {
|
|
3565
3685
|
if (isAbsolute(report)) return report;
|
|
3566
3686
|
if (report.includes("/") || report.includes("\\")) {
|
|
@@ -3574,7 +3694,7 @@ async function listReviewReportPaths() {
|
|
|
3574
3694
|
listMarkdownFiles(reviews),
|
|
3575
3695
|
listMarkdownFiles(join8(reviews, "resolved"))
|
|
3576
3696
|
]);
|
|
3577
|
-
return entries.flat().filter((path) =>
|
|
3697
|
+
return entries.flat().filter((path) => basename5(path) !== "latest.md").sort((a, b) => basename5(b).localeCompare(basename5(a)));
|
|
3578
3698
|
}
|
|
3579
3699
|
function canSelectReviewInteractively(inputIsTTY, outputIsTTY) {
|
|
3580
3700
|
return inputIsTTY === true && outputIsTTY === true;
|
|
@@ -3609,7 +3729,6 @@ import { createHash as createHash2 } from "crypto";
|
|
|
3609
3729
|
// src/state/db.ts
|
|
3610
3730
|
import { mkdir as mkdir4 } from "fs/promises";
|
|
3611
3731
|
import { join as join9 } from "path";
|
|
3612
|
-
import Database from "better-sqlite3";
|
|
3613
3732
|
|
|
3614
3733
|
// src/state/migrations/001-initial-schema.ts
|
|
3615
3734
|
var MIGRATION_001_INITIAL_SCHEMA = `
|
|
@@ -3681,6 +3800,117 @@ CREATE TABLE finding_events (
|
|
|
3681
3800
|
CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
|
|
3682
3801
|
`;
|
|
3683
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
|
+
|
|
3684
3914
|
// src/state/types.ts
|
|
3685
3915
|
import { randomUUID } from "crypto";
|
|
3686
3916
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
@@ -3708,7 +3938,7 @@ function getStateDbPath(diffOwlDir) {
|
|
|
3708
3938
|
async function openStateDatabase(diffOwlDir) {
|
|
3709
3939
|
await mkdir4(diffOwlDir, { recursive: true });
|
|
3710
3940
|
const path = getStateDbPath(diffOwlDir);
|
|
3711
|
-
const db =
|
|
3941
|
+
const db = await openSqliteDatabase(path);
|
|
3712
3942
|
try {
|
|
3713
3943
|
configureDatabase(db);
|
|
3714
3944
|
assertCompatibleSchema(db);
|
|
@@ -4647,7 +4877,8 @@ function buildReviewJsonDocument(input) {
|
|
|
4647
4877
|
below_confidence: input.suppressed.belowConfidence
|
|
4648
4878
|
},
|
|
4649
4879
|
diagnostics: input.review.diagnostics,
|
|
4650
|
-
timings: input.timings ?? input.review.timings
|
|
4880
|
+
timings: input.timings ?? input.review.timings,
|
|
4881
|
+
...input.usage !== void 0 ? { usage: input.usage } : {}
|
|
4651
4882
|
};
|
|
4652
4883
|
}
|
|
4653
4884
|
function renderReviewJsonDocument(document) {
|
|
@@ -5110,13 +5341,13 @@ function toFindingDetail(db, finding) {
|
|
|
5110
5341
|
|
|
5111
5342
|
// src/cli.ts
|
|
5112
5343
|
import { readFile as readFile7 } from "fs/promises";
|
|
5113
|
-
import { basename as
|
|
5344
|
+
import { basename as basename6, dirname as dirname4 } from "path";
|
|
5114
5345
|
import { execa as execa5 } from "execa";
|
|
5115
5346
|
|
|
5116
5347
|
// package.json
|
|
5117
5348
|
var package_default = {
|
|
5118
5349
|
name: "diffowl",
|
|
5119
|
-
version: "0.3.
|
|
5350
|
+
version: "0.3.1",
|
|
5120
5351
|
description: "Local AI code review agent powered by OpenCode",
|
|
5121
5352
|
keywords: [
|
|
5122
5353
|
"ai",
|
|
@@ -5142,14 +5373,14 @@ var package_default = {
|
|
|
5142
5373
|
],
|
|
5143
5374
|
type: "module",
|
|
5144
5375
|
scripts: {
|
|
5145
|
-
"check:
|
|
5146
|
-
prebuild: "pnpm run check:
|
|
5376
|
+
"check:runtime": "node scripts/check-runtime.mjs",
|
|
5377
|
+
prebuild: "pnpm run check:runtime",
|
|
5147
5378
|
build: "tsup",
|
|
5148
|
-
predev: "pnpm run check:
|
|
5379
|
+
predev: "pnpm run check:runtime",
|
|
5149
5380
|
dev: "tsup --watch",
|
|
5150
|
-
pretest: "pnpm run check:
|
|
5381
|
+
pretest: "pnpm run check:runtime",
|
|
5151
5382
|
test: "vitest run",
|
|
5152
|
-
pretypecheck: "pnpm run check:
|
|
5383
|
+
pretypecheck: "pnpm run check:runtime",
|
|
5153
5384
|
typecheck: "tsc --noEmit",
|
|
5154
5385
|
lint: "oxlint . && pnpm run typecheck",
|
|
5155
5386
|
format: "oxfmt --write .",
|
|
@@ -5159,7 +5390,6 @@ var package_default = {
|
|
|
5159
5390
|
},
|
|
5160
5391
|
dependencies: {
|
|
5161
5392
|
"@opencode-ai/sdk": "^1.15.11",
|
|
5162
|
-
"better-sqlite3": "^12.10.0",
|
|
5163
5393
|
chalk: "^5.6.2",
|
|
5164
5394
|
commander: "^14.0.3",
|
|
5165
5395
|
execa: "^9.6.1",
|
|
@@ -5169,7 +5399,6 @@ var package_default = {
|
|
|
5169
5399
|
zod: "^4.4.3"
|
|
5170
5400
|
},
|
|
5171
5401
|
devDependencies: {
|
|
5172
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
5173
5402
|
"@types/node": "^25.9.1",
|
|
5174
5403
|
"@types/picomatch": "^4.0.3",
|
|
5175
5404
|
oxfmt: "^0.52.0",
|
|
@@ -5179,14 +5408,9 @@ var package_default = {
|
|
|
5179
5408
|
vitest: "^4.1.7"
|
|
5180
5409
|
},
|
|
5181
5410
|
engines: {
|
|
5182
|
-
node: ">=22.14.0
|
|
5411
|
+
node: ">=22.14.0"
|
|
5183
5412
|
},
|
|
5184
|
-
packageManager: "pnpm@10.15.1"
|
|
5185
|
-
pnpm: {
|
|
5186
|
-
onlyBuiltDependencies: [
|
|
5187
|
-
"better-sqlite3"
|
|
5188
|
-
]
|
|
5189
|
-
}
|
|
5413
|
+
packageManager: "pnpm@10.15.1"
|
|
5190
5414
|
};
|
|
5191
5415
|
|
|
5192
5416
|
// src/cli.ts
|
|
@@ -5512,7 +5736,8 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
5512
5736
|
belowConfidence: confidenceFilter.dropped
|
|
5513
5737
|
},
|
|
5514
5738
|
verbose,
|
|
5515
|
-
timings: [...timings, ...report.timings ?? []]
|
|
5739
|
+
timings: [...timings, ...report.timings ?? []],
|
|
5740
|
+
usage: reviewResult.usage ?? null
|
|
5516
5741
|
});
|
|
5517
5742
|
} else {
|
|
5518
5743
|
console.log(colorizeMarkdown(markdown));
|
|
@@ -5603,9 +5828,9 @@ async function selectReviewInteractively() {
|
|
|
5603
5828
|
}
|
|
5604
5829
|
console.log(chalk3.bold("\nSelect a review:\n"));
|
|
5605
5830
|
for (const [index, report] of reports.entries()) {
|
|
5606
|
-
const resolved =
|
|
5831
|
+
const resolved = basename6(dirname4(report)) === "resolved";
|
|
5607
5832
|
console.log(
|
|
5608
|
-
` ${chalk3.cyan(`${index + 1}.`)} ${
|
|
5833
|
+
` ${chalk3.cyan(`${index + 1}.`)} ${basename6(report)}${resolved ? chalk3.dim(" (resolved)") : ""}`
|
|
5609
5834
|
);
|
|
5610
5835
|
}
|
|
5611
5836
|
const rl = createInterface({
|
|
@@ -6031,27 +6256,6 @@ async function loadConfigOrExit() {
|
|
|
6031
6256
|
process.exit(1);
|
|
6032
6257
|
}
|
|
6033
6258
|
}
|
|
6034
|
-
function filterFindingsByConfidence(findings, minConfidence) {
|
|
6035
|
-
const levels = ["low", "medium", "high"];
|
|
6036
|
-
const minIndex = levels.indexOf(minConfidence);
|
|
6037
|
-
const kept = findings.filter((f) => {
|
|
6038
|
-
const idx = levels.indexOf(f.confidence.toLowerCase());
|
|
6039
|
-
return idx >= minIndex;
|
|
6040
|
-
});
|
|
6041
|
-
return { findings: kept, dropped: findings.length - kept.length };
|
|
6042
|
-
}
|
|
6043
|
-
function filterFindingsByChangedFiles(findings, changedFiles) {
|
|
6044
|
-
const kept = [];
|
|
6045
|
-
const suppressed = [];
|
|
6046
|
-
for (const finding of findings) {
|
|
6047
|
-
if (changedFiles.has(finding.file)) {
|
|
6048
|
-
kept.push(finding);
|
|
6049
|
-
} else {
|
|
6050
|
-
suppressed.push(finding);
|
|
6051
|
-
}
|
|
6052
|
-
}
|
|
6053
|
-
return { findings: kept, suppressed };
|
|
6054
|
-
}
|
|
6055
6259
|
function buildDocOnlySkipMarkdown(diff) {
|
|
6056
6260
|
const lines = [];
|
|
6057
6261
|
lines.push("### Summary");
|
|
@@ -6133,7 +6337,8 @@ async function emitReviewJsonSuccess(input) {
|
|
|
6133
6337
|
belowConfidence: input.suppressed.belowConfidence
|
|
6134
6338
|
},
|
|
6135
6339
|
verbose: input.verbose,
|
|
6136
|
-
...input.timings ? { timings: input.timings } : {}
|
|
6340
|
+
...input.timings ? { timings: input.timings } : {},
|
|
6341
|
+
...input.usage !== void 0 ? { usage: input.usage } : {}
|
|
6137
6342
|
});
|
|
6138
6343
|
writeReviewJsonSuccess(document);
|
|
6139
6344
|
}
|