diffowl 0.3.2 → 0.3.3
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 +37 -7
- package/dist/cli.js +1147 -783
- package/dist/cli.js.map +1 -1
- package/package.json +1 -2
package/dist/cli.js
CHANGED
|
@@ -129,7 +129,8 @@ async function loadConfigFromRoot(root) {
|
|
|
129
129
|
}
|
|
130
130
|
async function saveConfig(config) {
|
|
131
131
|
const configPath = findConfigPath();
|
|
132
|
-
const
|
|
132
|
+
const { model: _legacyModel, ...projectConfig } = DiffOwlConfigSchema.parse(config);
|
|
133
|
+
const content = stringify(projectConfig, { lineWidth: 0 });
|
|
133
134
|
await writeFile(configPath, content, "utf-8");
|
|
134
135
|
return configPath;
|
|
135
136
|
}
|
|
@@ -152,14 +153,191 @@ function configExists() {
|
|
|
152
153
|
return existsSync(findConfigPath());
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
// src/model-preference.ts
|
|
157
|
+
import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile as writeFile2 } from "fs/promises";
|
|
158
|
+
import { join as join3 } from "path";
|
|
159
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
160
|
+
import { z as z2 } from "zod";
|
|
161
|
+
|
|
162
|
+
// src/git/state-root.ts
|
|
163
|
+
import { existsSync as existsSync2 } from "fs";
|
|
164
|
+
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve } from "path";
|
|
165
|
+
import { execa } from "execa";
|
|
166
|
+
var sharedDiffOwlDirPromise;
|
|
167
|
+
var warnedStateMove = false;
|
|
168
|
+
async function getSharedDiffOwlDir() {
|
|
169
|
+
if (!sharedDiffOwlDirPromise) {
|
|
170
|
+
sharedDiffOwlDirPromise = resolveSharedDiffOwlDir();
|
|
171
|
+
}
|
|
172
|
+
const resolution = sharedDiffOwlDirPromise;
|
|
173
|
+
try {
|
|
174
|
+
return await resolution;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (sharedDiffOwlDirPromise === resolution) {
|
|
177
|
+
sharedDiffOwlDirPromise = void 0;
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function resolveSharedDiffOwlDir() {
|
|
183
|
+
const projectRoot = getProjectRoot();
|
|
184
|
+
const localDir = getDiffOwlDir();
|
|
185
|
+
let insideWorkTree;
|
|
186
|
+
try {
|
|
187
|
+
({ stdout: insideWorkTree } = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
188
|
+
cwd: projectRoot
|
|
189
|
+
}));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (isRecoverableGitLookupError(error)) {
|
|
192
|
+
return localDir;
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
if (insideWorkTree.trim() !== "true") {
|
|
197
|
+
return localDir;
|
|
198
|
+
}
|
|
199
|
+
let toplevelRaw;
|
|
200
|
+
let commonRaw;
|
|
201
|
+
try {
|
|
202
|
+
[{ stdout: toplevelRaw }, { stdout: commonRaw }] = await Promise.all([
|
|
203
|
+
gitRevParse(projectRoot, ["--show-toplevel"]),
|
|
204
|
+
gitRevParse(projectRoot, ["--git-common-dir"])
|
|
205
|
+
]);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (isRecoverableGitLookupError(error)) {
|
|
208
|
+
return localDir;
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
const toplevel = resolveGitPath(projectRoot, toplevelRaw);
|
|
213
|
+
const commonDir = resolveGitPath(projectRoot, commonRaw);
|
|
214
|
+
const rel = relative(toplevel, projectRoot);
|
|
215
|
+
if (rel.startsWith("..")) {
|
|
216
|
+
return localDir;
|
|
217
|
+
}
|
|
218
|
+
let sharedDiffOwlDir;
|
|
219
|
+
if (basename(commonDir) !== ".git") {
|
|
220
|
+
sharedDiffOwlDir = join2(commonDir, "diffowl", rel, ".diffowl");
|
|
221
|
+
} else {
|
|
222
|
+
sharedDiffOwlDir = join2(dirname2(commonDir), rel, ".diffowl");
|
|
223
|
+
}
|
|
224
|
+
warnIfIgnoringLocalState(localDir, sharedDiffOwlDir);
|
|
225
|
+
return sharedDiffOwlDir;
|
|
226
|
+
}
|
|
227
|
+
function warnIfIgnoringLocalState(localDir, sharedDir) {
|
|
228
|
+
if (warnedStateMove || localDir === sharedDir) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const localDb = join2(localDir, "state.db");
|
|
232
|
+
if (!existsSync2(localDb)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
warnedStateMove = true;
|
|
236
|
+
console.warn(
|
|
237
|
+
`DiffOwl state moved: using ${join2(
|
|
238
|
+
sharedDir,
|
|
239
|
+
"state.db"
|
|
240
|
+
)}; ignoring checkout-local ${localDb}. Delete the checkout-local database if this worktree should use only the shared state.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
async function gitRevParse(projectRoot, args) {
|
|
244
|
+
try {
|
|
245
|
+
return await execa("git", ["rev-parse", "--path-format=absolute", ...args], {
|
|
246
|
+
cwd: projectRoot
|
|
247
|
+
});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!isUnsupportedGitOptionError(error)) {
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return await execa("git", ["rev-parse", ...args], { cwd: projectRoot });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function resolveGitPath(projectRoot, raw) {
|
|
256
|
+
const trimmed = raw.trim();
|
|
257
|
+
return isAbsolute(trimmed) ? trimmed : resolve(projectRoot, trimmed);
|
|
258
|
+
}
|
|
259
|
+
function isRecoverableGitLookupError(error) {
|
|
260
|
+
if (!error || typeof error !== "object") {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
const err = error;
|
|
264
|
+
return err.code === "ENOENT" || err.exitCode === 128;
|
|
265
|
+
}
|
|
266
|
+
function isUnsupportedGitOptionError(error) {
|
|
267
|
+
if (!error || typeof error !== "object") {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
const err = error;
|
|
271
|
+
if (err.exitCode !== 129 && err.exitCode !== 128) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
const stderr = err.stderr ?? "";
|
|
275
|
+
return stderr.includes("path-format") || stderr.includes("unknown option");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/model-preference.ts
|
|
279
|
+
var ModelPreferenceSchema = z2.object({ model: ModelSchema }).strict();
|
|
280
|
+
var PREFERENCES_FILENAME = "preferences.yml";
|
|
281
|
+
async function loadModelPreference() {
|
|
282
|
+
const path = join3(await getSharedDiffOwlDir(), PREFERENCES_FILENAME);
|
|
283
|
+
try {
|
|
284
|
+
return ModelPreferenceSchema.parse(parse2(await readFile2(path, "utf8"))).model;
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (isMissingFile(err)) return void 0;
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
throw new Error(`Failed to load ${path}: ${message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function saveModelPreference(model) {
|
|
292
|
+
const dir = await getSharedDiffOwlDir();
|
|
293
|
+
const path = join3(dir, PREFERENCES_FILENAME);
|
|
294
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
295
|
+
const preference = ModelPreferenceSchema.parse({ model });
|
|
296
|
+
await mkdir2(dir, { recursive: true });
|
|
297
|
+
await writeFile2(temporaryPath, stringify2(preference), { encoding: "utf8", mode: 384 });
|
|
298
|
+
await rename(temporaryPath, path);
|
|
299
|
+
return path;
|
|
300
|
+
}
|
|
301
|
+
async function resetModelPreference() {
|
|
302
|
+
await rm(join3(await getSharedDiffOwlDir(), PREFERENCES_FILENAME), { force: true });
|
|
303
|
+
}
|
|
304
|
+
function isMissingFile(err) {
|
|
305
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/effective-config.ts
|
|
309
|
+
var MissingModelError = class extends Error {
|
|
310
|
+
constructor() {
|
|
311
|
+
super("No model selected. Run `diffowl model <provider/model>`.");
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
async function loadEffectiveConfig(commandModel, env = process.env) {
|
|
315
|
+
const config = await loadConfig();
|
|
316
|
+
const environmentModel = env["DIFFOWL_MODEL"]?.trim() || void 0;
|
|
317
|
+
if (commandModel !== void 0) {
|
|
318
|
+
config.model = parseModel(commandModel);
|
|
319
|
+
return { config, modelSource: "command" };
|
|
320
|
+
}
|
|
321
|
+
if (environmentModel !== void 0) {
|
|
322
|
+
config.model = parseModel(environmentModel);
|
|
323
|
+
return { config, modelSource: "environment" };
|
|
324
|
+
}
|
|
325
|
+
const localModel = await loadModelPreference();
|
|
326
|
+
if (localModel !== void 0) {
|
|
327
|
+
config.model = localModel;
|
|
328
|
+
return { config, modelSource: "local" };
|
|
329
|
+
}
|
|
330
|
+
throw new MissingModelError();
|
|
331
|
+
}
|
|
332
|
+
|
|
155
333
|
// src/opencode/client.ts
|
|
156
334
|
import { createOpencodeClient as createOpencodeClient2 } from "@opencode-ai/sdk";
|
|
157
335
|
|
|
158
336
|
// src/opencode/server.ts
|
|
159
|
-
import { execa } from "execa";
|
|
160
|
-
import { existsSync as
|
|
161
|
-
import { readFile as
|
|
162
|
-
import { join as
|
|
337
|
+
import { execa as execa2 } from "execa";
|
|
338
|
+
import { existsSync as existsSync3 } from "fs";
|
|
339
|
+
import { readFile as readFile3, writeFile as writeFile3, unlink } from "fs/promises";
|
|
340
|
+
import { join as join4 } from "path";
|
|
163
341
|
var HEALTH_TIMEOUT_MS = 2e3;
|
|
164
342
|
var STARTUP_WAIT_MS = 3e3;
|
|
165
343
|
var MAX_RETRIES = 10;
|
|
@@ -188,7 +366,7 @@ async function getServerHealth(port) {
|
|
|
188
366
|
}
|
|
189
367
|
async function getInstalledOpencodeVersion() {
|
|
190
368
|
try {
|
|
191
|
-
const { stdout } = await
|
|
369
|
+
const { stdout } = await execa2("opencode", ["--version"], { timeout: 5e3 });
|
|
192
370
|
const trimmed = stdout.trim();
|
|
193
371
|
if (!trimmed) {
|
|
194
372
|
return null;
|
|
@@ -243,10 +421,10 @@ async function checkOpencodeInstalled() {
|
|
|
243
421
|
const isWin = process.platform === "win32";
|
|
244
422
|
const checkCmd = isWin ? "where" : "which";
|
|
245
423
|
try {
|
|
246
|
-
await
|
|
424
|
+
await execa2(checkCmd, ["opencode"]);
|
|
247
425
|
} catch {
|
|
248
426
|
try {
|
|
249
|
-
await
|
|
427
|
+
await execa2("opencode", ["--version"], { timeout: 5e3 });
|
|
250
428
|
} catch {
|
|
251
429
|
throw new Error(
|
|
252
430
|
"opencode not found. Install it: npm i -g opencode-ai\nSee: https://opencode.ai/docs/"
|
|
@@ -256,9 +434,9 @@ async function checkOpencodeInstalled() {
|
|
|
256
434
|
}
|
|
257
435
|
async function spawnServer(port) {
|
|
258
436
|
const dir = await ensureDiffOwlDir();
|
|
259
|
-
const pidFile =
|
|
437
|
+
const pidFile = join4(dir, "server.pid");
|
|
260
438
|
await checkOpencodeInstalled();
|
|
261
|
-
const subprocess =
|
|
439
|
+
const subprocess = execa2("opencode", ["serve", "--port", String(port)], {
|
|
262
440
|
detached: true,
|
|
263
441
|
stdio: "ignore",
|
|
264
442
|
cleanup: false
|
|
@@ -266,7 +444,7 @@ async function spawnServer(port) {
|
|
|
266
444
|
void subprocess.catch(() => {
|
|
267
445
|
});
|
|
268
446
|
if (subprocess.pid) {
|
|
269
|
-
await
|
|
447
|
+
await writeFile3(pidFile, String(subprocess.pid), "utf-8");
|
|
270
448
|
}
|
|
271
449
|
subprocess.unref();
|
|
272
450
|
}
|
|
@@ -293,13 +471,13 @@ async function stopServer(port) {
|
|
|
293
471
|
}
|
|
294
472
|
async function stopManagedServer() {
|
|
295
473
|
const dir = getDiffOwlDir();
|
|
296
|
-
const pidFile =
|
|
297
|
-
if (!
|
|
474
|
+
const pidFile = join4(dir, "server.pid");
|
|
475
|
+
if (!existsSync3(pidFile)) {
|
|
298
476
|
return false;
|
|
299
477
|
}
|
|
300
478
|
let pid;
|
|
301
479
|
try {
|
|
302
|
-
pid = parseInt(await
|
|
480
|
+
pid = parseInt(await readFile3(pidFile, "utf-8"), 10);
|
|
303
481
|
} catch {
|
|
304
482
|
try {
|
|
305
483
|
await unlink(pidFile);
|
|
@@ -353,8 +531,8 @@ async function stopUnhealthyServerListener(port) {
|
|
|
353
531
|
return true;
|
|
354
532
|
}
|
|
355
533
|
async function cleanupPidFile() {
|
|
356
|
-
const pidFile =
|
|
357
|
-
if (!
|
|
534
|
+
const pidFile = join4(getDiffOwlDir(), "server.pid");
|
|
535
|
+
if (!existsSync3(pidFile)) {
|
|
358
536
|
return;
|
|
359
537
|
}
|
|
360
538
|
try {
|
|
@@ -379,7 +557,7 @@ async function findListenerPids(port) {
|
|
|
379
557
|
return findListenerPidsWindows(port);
|
|
380
558
|
}
|
|
381
559
|
try {
|
|
382
|
-
const { stdout } = await
|
|
560
|
+
const { stdout } = await execa2("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
|
|
383
561
|
timeout: 5e3
|
|
384
562
|
});
|
|
385
563
|
return parsePids(stdout);
|
|
@@ -389,7 +567,7 @@ async function findListenerPids(port) {
|
|
|
389
567
|
}
|
|
390
568
|
async function findListenerPidsWindows(port) {
|
|
391
569
|
try {
|
|
392
|
-
const { stdout } = await
|
|
570
|
+
const { stdout } = await execa2("netstat", ["-ano"], { timeout: 5e3 });
|
|
393
571
|
const portToken = `:${port}`;
|
|
394
572
|
const lines = stdout.split(/\r?\n/);
|
|
395
573
|
const pids = /* @__PURE__ */ new Set();
|
|
@@ -431,7 +609,7 @@ async function isOpencodeProcess(pid) {
|
|
|
431
609
|
try {
|
|
432
610
|
if (isWin) {
|
|
433
611
|
try {
|
|
434
|
-
const { stdout: stdout3 } = await
|
|
612
|
+
const { stdout: stdout3 } = await execa2("powershell", [
|
|
435
613
|
"-NoProfile",
|
|
436
614
|
"-Command",
|
|
437
615
|
`Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`
|
|
@@ -442,7 +620,7 @@ async function isOpencodeProcess(pid) {
|
|
|
442
620
|
} catch {
|
|
443
621
|
}
|
|
444
622
|
try {
|
|
445
|
-
const { stdout: stdout3 } = await
|
|
623
|
+
const { stdout: stdout3 } = await execa2("wmic", [
|
|
446
624
|
"process",
|
|
447
625
|
"where",
|
|
448
626
|
`ProcessId=${pid}`,
|
|
@@ -454,10 +632,10 @@ async function isOpencodeProcess(pid) {
|
|
|
454
632
|
}
|
|
455
633
|
} catch {
|
|
456
634
|
}
|
|
457
|
-
const { stdout: stdout2 } = await
|
|
635
|
+
const { stdout: stdout2 } = await execa2("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
|
|
458
636
|
return stdout2.toLowerCase().includes("opencode");
|
|
459
637
|
}
|
|
460
|
-
const { stdout } = await
|
|
638
|
+
const { stdout } = await execa2("ps", ["-p", String(pid), "-o", "command="]);
|
|
461
639
|
return stdout.toLowerCase().includes("opencode");
|
|
462
640
|
} catch {
|
|
463
641
|
return false;
|
|
@@ -548,7 +726,7 @@ Do not force these passes onto unrelated changes; skip surfaces that are not pre
|
|
|
548
726
|
- Data filtering/loss: Look for data silently dropped, hidden, duplicated, parsed with a fallback, or reported inconsistently.
|
|
549
727
|
`;
|
|
550
728
|
function buildReviewPrompt(target, customRules, include, exclude, localContext, depth = "default") {
|
|
551
|
-
const modeInstruction = target.kind === "staged" ? "Review the currently staged changes." : target.kind === "commit" ? "Review the selected commit." : "Review the last commit.";
|
|
729
|
+
const modeInstruction = target.kind === "staged" ? "Review the currently staged changes." : target.kind === "commit" ? "Review the selected commit." : target.kind === "base" ? "Review the committed branch changes since the merge base." : "Review the last commit.";
|
|
552
730
|
let prompt = `${modeInstruction}
|
|
553
731
|
|
|
554
732
|
DiffOwl has already collected the diff and likely-relevant local context below. Use this context first.
|
|
@@ -606,39 +784,39 @@ function reviewDepthInstruction(depth) {
|
|
|
606
784
|
}
|
|
607
785
|
|
|
608
786
|
// src/opencode/review-parser.ts
|
|
609
|
-
import { z as
|
|
610
|
-
var ReviewSeveritySchema =
|
|
787
|
+
import { z as z3 } from "zod";
|
|
788
|
+
var ReviewSeveritySchema = z3.preprocess(
|
|
611
789
|
(value) => typeof value === "string" ? value.toLowerCase() : value,
|
|
612
|
-
|
|
790
|
+
z3.enum(["error", "warning", "info"])
|
|
613
791
|
);
|
|
614
|
-
var ReviewConfidenceSchema2 =
|
|
792
|
+
var ReviewConfidenceSchema2 = z3.preprocess(
|
|
615
793
|
(value) => typeof value === "string" ? value.toLowerCase() : value,
|
|
616
794
|
ReviewConfidenceSchema
|
|
617
795
|
).catch("low");
|
|
618
|
-
var ReviewFindingLineSchema =
|
|
796
|
+
var ReviewFindingLineSchema = z3.preprocess(
|
|
619
797
|
(value) => typeof value === "string" ? Number(value) : value,
|
|
620
|
-
|
|
798
|
+
z3.number().int().positive()
|
|
621
799
|
);
|
|
622
|
-
var ReviewFindingPathSchema =
|
|
800
|
+
var ReviewFindingPathSchema = z3.preprocess((value) => {
|
|
623
801
|
if (typeof value !== "string") return value;
|
|
624
802
|
const normalized = value.trim().replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
625
803
|
if (normalized === "" || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || normalized.split("/").includes("..")) {
|
|
626
804
|
return void 0;
|
|
627
805
|
}
|
|
628
806
|
return normalized;
|
|
629
|
-
},
|
|
630
|
-
var ReviewFindingSchema =
|
|
807
|
+
}, z3.string().min(1));
|
|
808
|
+
var ReviewFindingSchema = z3.object({
|
|
631
809
|
severity: ReviewSeveritySchema,
|
|
632
810
|
file: ReviewFindingPathSchema,
|
|
633
811
|
line: ReviewFindingLineSchema,
|
|
634
|
-
evidence:
|
|
635
|
-
title:
|
|
636
|
-
body:
|
|
812
|
+
evidence: z3.string().nullish(),
|
|
813
|
+
title: z3.string().trim().min(1),
|
|
814
|
+
body: z3.string().trim().min(1),
|
|
637
815
|
confidence: ReviewConfidenceSchema2
|
|
638
816
|
});
|
|
639
|
-
var ReviewJsonSchema =
|
|
640
|
-
summary:
|
|
641
|
-
findings:
|
|
817
|
+
var ReviewJsonSchema = z3.object({
|
|
818
|
+
summary: z3.string(),
|
|
819
|
+
findings: z3.array(z3.unknown())
|
|
642
820
|
});
|
|
643
821
|
function parseStructuredReview(raw) {
|
|
644
822
|
const marker = "FINAL_REVIEW_JSON";
|
|
@@ -977,19 +1155,19 @@ async function withTimeout(promise, ms) {
|
|
|
977
1155
|
}
|
|
978
1156
|
|
|
979
1157
|
// src/opencode/provider-payload.ts
|
|
980
|
-
import { z as
|
|
981
|
-
var ProviderModelSchema =
|
|
982
|
-
id:
|
|
983
|
-
status:
|
|
984
|
-
reasoning:
|
|
985
|
-
capabilities:
|
|
986
|
-
reasoning:
|
|
1158
|
+
import { z as z4 } from "zod";
|
|
1159
|
+
var ProviderModelSchema = z4.object({
|
|
1160
|
+
id: z4.string(),
|
|
1161
|
+
status: z4.string().nullish().transform((value) => value ?? void 0),
|
|
1162
|
+
reasoning: z4.boolean().nullish().transform((value) => value ?? void 0),
|
|
1163
|
+
capabilities: z4.object({
|
|
1164
|
+
reasoning: z4.boolean().nullish().transform((value) => value ?? void 0)
|
|
987
1165
|
}).nullish().transform((value) => value ?? void 0),
|
|
988
|
-
variants:
|
|
1166
|
+
variants: z4.record(z4.string(), z4.unknown()).nullish().transform((value) => value ?? void 0)
|
|
989
1167
|
}).passthrough();
|
|
990
|
-
var ProviderSchema =
|
|
991
|
-
id:
|
|
992
|
-
models:
|
|
1168
|
+
var ProviderSchema = z4.object({
|
|
1169
|
+
id: z4.string(),
|
|
1170
|
+
models: z4.record(z4.string(), z4.unknown()).nullish().transform((models) => {
|
|
993
1171
|
if (!models) return void 0;
|
|
994
1172
|
return Object.fromEntries(
|
|
995
1173
|
Object.entries(models).flatMap(([key, model]) => {
|
|
@@ -999,9 +1177,9 @@ var ProviderSchema = z3.object({
|
|
|
999
1177
|
);
|
|
1000
1178
|
})
|
|
1001
1179
|
}).passthrough();
|
|
1002
|
-
var ProviderPayloadSchema =
|
|
1003
|
-
connected:
|
|
1004
|
-
all:
|
|
1180
|
+
var ProviderPayloadSchema = z4.object({
|
|
1181
|
+
connected: z4.array(z4.string()).nullish().transform((value) => value ?? []),
|
|
1182
|
+
all: z4.array(z4.unknown()).nullish().transform(
|
|
1005
1183
|
(providers) => (providers ?? []).flatMap((provider) => {
|
|
1006
1184
|
const parsed = ProviderSchema.safeParse(provider);
|
|
1007
1185
|
return parsed.success ? [parsed.data] : [];
|
|
@@ -1057,19 +1235,19 @@ function listAvailableModels(payload) {
|
|
|
1057
1235
|
}
|
|
1058
1236
|
|
|
1059
1237
|
// src/review/usage.ts
|
|
1060
|
-
import { z as
|
|
1061
|
-
var ReviewUsageTokensSchema =
|
|
1062
|
-
input:
|
|
1063
|
-
output:
|
|
1064
|
-
reasoning:
|
|
1065
|
-
cache:
|
|
1066
|
-
read:
|
|
1067
|
-
write:
|
|
1238
|
+
import { z as z5 } from "zod";
|
|
1239
|
+
var ReviewUsageTokensSchema = z5.object({
|
|
1240
|
+
input: z5.number(),
|
|
1241
|
+
output: z5.number(),
|
|
1242
|
+
reasoning: z5.number(),
|
|
1243
|
+
cache: z5.object({
|
|
1244
|
+
read: z5.number(),
|
|
1245
|
+
write: z5.number()
|
|
1068
1246
|
})
|
|
1069
1247
|
});
|
|
1070
|
-
var ReviewUsageSchema =
|
|
1248
|
+
var ReviewUsageSchema = z5.object({
|
|
1071
1249
|
tokens: ReviewUsageTokensSchema,
|
|
1072
|
-
cost:
|
|
1250
|
+
cost: z5.number().nullable()
|
|
1073
1251
|
});
|
|
1074
1252
|
function parseAssistantUsage(info) {
|
|
1075
1253
|
if (!info || typeof info !== "object") return void 0;
|
|
@@ -1649,13 +1827,13 @@ function getOpenCodeFailureGuidance(message) {
|
|
|
1649
1827
|
}
|
|
1650
1828
|
|
|
1651
1829
|
// src/eval/command.ts
|
|
1652
|
-
import { readFile as
|
|
1653
|
-
import { join as
|
|
1830
|
+
import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
1831
|
+
import { join as join14 } from "path";
|
|
1654
1832
|
import chalk2 from "chalk";
|
|
1655
1833
|
import ora from "ora";
|
|
1656
1834
|
|
|
1657
1835
|
// src/eval/command-types.ts
|
|
1658
|
-
import { isAbsolute, join as
|
|
1836
|
+
import { isAbsolute as isAbsolute2, join as join5 } from "path";
|
|
1659
1837
|
function parseEvalOutputFormat(value) {
|
|
1660
1838
|
if (value === void 0 || value === "text") {
|
|
1661
1839
|
return "text";
|
|
@@ -1694,40 +1872,39 @@ function resolveEvalOutDir(cwd, out, timestamp) {
|
|
|
1694
1872
|
if (out) {
|
|
1695
1873
|
return joinPath(cwd, out);
|
|
1696
1874
|
}
|
|
1697
|
-
return joinPath(cwd,
|
|
1875
|
+
return joinPath(cwd, join5("eval", "results", timestamp));
|
|
1698
1876
|
}
|
|
1699
1877
|
function joinPath(cwd, target) {
|
|
1700
|
-
return
|
|
1878
|
+
return isAbsolute2(target) ? target : join5(cwd, target);
|
|
1701
1879
|
}
|
|
1702
1880
|
|
|
1703
1881
|
// src/eval/corpus.ts
|
|
1704
1882
|
import { createHash } from "crypto";
|
|
1705
|
-
import { cp, readdir, readFile as
|
|
1706
|
-
import { basename, join as
|
|
1707
|
-
import { execa as
|
|
1883
|
+
import { cp, readdir, readFile as readFile4, stat } from "fs/promises";
|
|
1884
|
+
import { basename as basename2, join as join6, relative as relative2, sep } from "path";
|
|
1885
|
+
import { execa as execa3 } from "execa";
|
|
1708
1886
|
|
|
1709
1887
|
// src/eval/case-types.ts
|
|
1710
|
-
import { z as
|
|
1711
|
-
var EvalCaseCategorySchema =
|
|
1712
|
-
var EvalCaseLanguageSchema =
|
|
1713
|
-
var EvalCaseTargetSchema =
|
|
1714
|
-
var EvalSeveritySchema =
|
|
1715
|
-
var EvalExpectedFindingSchema =
|
|
1716
|
-
file:
|
|
1717
|
-
line:
|
|
1718
|
-
line_tolerance:
|
|
1719
|
-
category: z5.string().trim().min(1).optional(),
|
|
1888
|
+
import { z as z6 } from "zod";
|
|
1889
|
+
var EvalCaseCategorySchema = z6.enum(["bug", "clean", "mixed"]);
|
|
1890
|
+
var EvalCaseLanguageSchema = z6.enum(["typescript"]);
|
|
1891
|
+
var EvalCaseTargetSchema = z6.enum(["staged", "commit"]);
|
|
1892
|
+
var EvalSeveritySchema = z6.enum(["error", "warning", "info"]);
|
|
1893
|
+
var EvalExpectedFindingSchema = z6.object({
|
|
1894
|
+
file: z6.string().trim().min(1),
|
|
1895
|
+
line: z6.number().int().positive(),
|
|
1896
|
+
line_tolerance: z6.number().int().nonnegative().default(2),
|
|
1720
1897
|
min_severity: EvalSeveritySchema.default("warning"),
|
|
1721
|
-
must_detect:
|
|
1722
|
-
});
|
|
1723
|
-
var EvalCaseJsonSchema =
|
|
1724
|
-
id:
|
|
1898
|
+
must_detect: z6.boolean().default(true)
|
|
1899
|
+
}).strict();
|
|
1900
|
+
var EvalCaseJsonSchema = z6.object({
|
|
1901
|
+
id: z6.string().trim().min(1),
|
|
1725
1902
|
category: EvalCaseCategorySchema,
|
|
1726
1903
|
language: EvalCaseLanguageSchema,
|
|
1727
|
-
description:
|
|
1904
|
+
description: z6.string().trim().min(1),
|
|
1728
1905
|
target: EvalCaseTargetSchema.default("commit"),
|
|
1729
|
-
expected:
|
|
1730
|
-
tags:
|
|
1906
|
+
expected: z6.array(EvalExpectedFindingSchema).default([]),
|
|
1907
|
+
tags: z6.array(z6.string().trim().min(1)).default([])
|
|
1731
1908
|
});
|
|
1732
1909
|
function parseEvalCaseJson(raw) {
|
|
1733
1910
|
return EvalCaseJsonSchema.parse(raw);
|
|
@@ -1746,13 +1923,13 @@ function validateEvalCaseSemantics(caseJson) {
|
|
|
1746
1923
|
|
|
1747
1924
|
// src/eval/corpus.ts
|
|
1748
1925
|
async function loadEvalCase(caseDir) {
|
|
1749
|
-
const caseJsonPath =
|
|
1750
|
-
const baseDir =
|
|
1751
|
-
const patchPath =
|
|
1752
|
-
const id =
|
|
1926
|
+
const caseJsonPath = join6(caseDir, "case.json");
|
|
1927
|
+
const baseDir = join6(caseDir, "base");
|
|
1928
|
+
const patchPath = join6(caseDir, "change.patch");
|
|
1929
|
+
const id = basename2(caseDir);
|
|
1753
1930
|
let raw;
|
|
1754
1931
|
try {
|
|
1755
|
-
raw = JSON.parse(await
|
|
1932
|
+
raw = JSON.parse(await readFile4(caseJsonPath, "utf8"));
|
|
1756
1933
|
} catch (error) {
|
|
1757
1934
|
throw new Error(`Failed to read case.json for "${id}": ${describeError2(error)}`);
|
|
1758
1935
|
}
|
|
@@ -1788,7 +1965,7 @@ async function copyCaseBase(evalCase, workDir) {
|
|
|
1788
1965
|
}
|
|
1789
1966
|
async function applyCasePatch(evalCase, workDir) {
|
|
1790
1967
|
try {
|
|
1791
|
-
await
|
|
1968
|
+
await execa3("git", ["apply", "--whitespace=nowarn", evalCase.patchPath], {
|
|
1792
1969
|
cwd: workDir,
|
|
1793
1970
|
reject: false
|
|
1794
1971
|
}).then((result) => {
|
|
@@ -1801,8 +1978,8 @@ async function applyCasePatch(evalCase, workDir) {
|
|
|
1801
1978
|
}
|
|
1802
1979
|
}
|
|
1803
1980
|
async function hashCase(caseDir) {
|
|
1804
|
-
const caseJson = await
|
|
1805
|
-
const patch = await
|
|
1981
|
+
const caseJson = await readFile4(join6(caseDir, "case.json"));
|
|
1982
|
+
const patch = await readFile4(join6(caseDir, "change.patch"));
|
|
1806
1983
|
return {
|
|
1807
1984
|
caseJsonHash: hashBuffer(caseJson),
|
|
1808
1985
|
patchHash: hashBuffer(patch)
|
|
@@ -1813,7 +1990,7 @@ async function hashCorpus(corpusDir) {
|
|
|
1813
1990
|
const lines = [];
|
|
1814
1991
|
const posixFiles = files.map((filePath) => filePath.split(sep).join("/")).sort();
|
|
1815
1992
|
for (const filePath of posixFiles) {
|
|
1816
|
-
const content = await
|
|
1993
|
+
const content = await readFile4(join6(corpusDir, filePath));
|
|
1817
1994
|
lines.push(`${filePath}:${hashBuffer(content)}`);
|
|
1818
1995
|
}
|
|
1819
1996
|
return hashText(lines.join("\n"));
|
|
@@ -1823,27 +2000,27 @@ async function listCaseDirectories(corpusDir) {
|
|
|
1823
2000
|
const caseDirs = [];
|
|
1824
2001
|
for (const entry of entries) {
|
|
1825
2002
|
if (!entry.isDirectory()) continue;
|
|
1826
|
-
const caseDir =
|
|
2003
|
+
const caseDir = join6(corpusDir, entry.name);
|
|
1827
2004
|
try {
|
|
1828
|
-
await stat(
|
|
2005
|
+
await stat(join6(caseDir, "case.json"));
|
|
1829
2006
|
caseDirs.push(caseDir);
|
|
1830
2007
|
} catch {
|
|
1831
2008
|
continue;
|
|
1832
2009
|
}
|
|
1833
2010
|
}
|
|
1834
|
-
return caseDirs.sort((left, right) =>
|
|
2011
|
+
return caseDirs.sort((left, right) => basename2(left).localeCompare(basename2(right)));
|
|
1835
2012
|
}
|
|
1836
2013
|
async function listFilesRecursive(rootDir, currentDir = rootDir) {
|
|
1837
2014
|
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
1838
2015
|
const files = [];
|
|
1839
2016
|
for (const entry of entries) {
|
|
1840
|
-
const absolutePath =
|
|
2017
|
+
const absolutePath = join6(currentDir, entry.name);
|
|
1841
2018
|
if (entry.isDirectory()) {
|
|
1842
2019
|
files.push(...await listFilesRecursive(rootDir, absolutePath));
|
|
1843
2020
|
continue;
|
|
1844
2021
|
}
|
|
1845
2022
|
if (entry.isFile()) {
|
|
1846
|
-
files.push(
|
|
2023
|
+
files.push(relative2(rootDir, absolutePath));
|
|
1847
2024
|
}
|
|
1848
2025
|
}
|
|
1849
2026
|
return files;
|
|
@@ -1889,36 +2066,84 @@ function describeError2(error) {
|
|
|
1889
2066
|
}
|
|
1890
2067
|
|
|
1891
2068
|
// src/eval/gates-types.ts
|
|
1892
|
-
import { z as
|
|
1893
|
-
var EvalGateThresholdsSchema =
|
|
1894
|
-
min_precision:
|
|
1895
|
-
min_recall_must_detect:
|
|
1896
|
-
max_repeated_fp_rate:
|
|
1897
|
-
min_empty_on_clean_rate:
|
|
2069
|
+
import { z as z7 } from "zod";
|
|
2070
|
+
var EvalGateThresholdsSchema = z7.object({
|
|
2071
|
+
min_precision: z7.number().min(0).max(1).optional(),
|
|
2072
|
+
min_recall_must_detect: z7.number().min(0).max(1).optional(),
|
|
2073
|
+
max_repeated_fp_rate: z7.number().min(0).max(1).optional(),
|
|
2074
|
+
min_empty_on_clean_rate: z7.number().min(0).max(1).optional()
|
|
1898
2075
|
});
|
|
1899
|
-
var EvalGateResultSchema =
|
|
1900
|
-
passed:
|
|
1901
|
-
failures:
|
|
2076
|
+
var EvalGateResultSchema = z7.object({
|
|
2077
|
+
passed: z7.boolean(),
|
|
2078
|
+
failures: z7.array(z7.string())
|
|
1902
2079
|
});
|
|
1903
2080
|
function parseEvalGateThresholds(raw) {
|
|
1904
2081
|
return EvalGateThresholdsSchema.parse(raw);
|
|
1905
2082
|
}
|
|
1906
2083
|
|
|
1907
2084
|
// src/eval/manifest.ts
|
|
1908
|
-
import { readFile as
|
|
1909
|
-
import { join as
|
|
2085
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2086
|
+
import { join as join12 } from "path";
|
|
1910
2087
|
|
|
1911
2088
|
// src/eval/runner.ts
|
|
1912
2089
|
import { performance as performance2 } from "perf_hooks";
|
|
1913
2090
|
|
|
1914
2091
|
// src/review/context.ts
|
|
1915
|
-
import { basename as
|
|
2092
|
+
import { basename as basename5, dirname as dirname3, extname as extname5, join as join9 } from "path";
|
|
1916
2093
|
import picomatch from "picomatch";
|
|
1917
2094
|
|
|
1918
2095
|
// src/git/diff.ts
|
|
1919
|
-
import { execa as
|
|
1920
|
-
import { basename as
|
|
2096
|
+
import { execa as execa4 } from "execa";
|
|
2097
|
+
import { basename as basename3, extname } from "path";
|
|
1921
2098
|
var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
2099
|
+
async function getBranchDiff(baseRef, cwd) {
|
|
2100
|
+
const selectedBaseRef = baseRef ?? await resolveDefaultBranchRef(cwd);
|
|
2101
|
+
const baseCommit = await resolveCommitRef(selectedBaseRef, cwd);
|
|
2102
|
+
const headCommit = await resolveCommitRef("HEAD", cwd);
|
|
2103
|
+
const { stdout } = await execa4("git", ["merge-base", baseCommit, headCommit], cwd ? { cwd } : {});
|
|
2104
|
+
const mergeBaseCommit = stdout.trim();
|
|
2105
|
+
const raw = await collectGitDiff(
|
|
2106
|
+
[
|
|
2107
|
+
"-c",
|
|
2108
|
+
"diff.noprefix=false",
|
|
2109
|
+
"-c",
|
|
2110
|
+
"diff.mnemonicprefix=false",
|
|
2111
|
+
"diff",
|
|
2112
|
+
"--stat",
|
|
2113
|
+
"--patch",
|
|
2114
|
+
`${mergeBaseCommit}..${headCommit}`
|
|
2115
|
+
],
|
|
2116
|
+
cwd
|
|
2117
|
+
);
|
|
2118
|
+
return {
|
|
2119
|
+
baseRef: selectedBaseRef,
|
|
2120
|
+
baseCommit,
|
|
2121
|
+
mergeBaseCommit,
|
|
2122
|
+
headCommit,
|
|
2123
|
+
diff: parseDiff(raw.stdout, raw.diagnostics)
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
async function resolveDefaultBranchRef(cwd) {
|
|
2127
|
+
try {
|
|
2128
|
+
const { stdout } = await execa4(
|
|
2129
|
+
"git",
|
|
2130
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
2131
|
+
cwd ? { cwd } : {}
|
|
2132
|
+
);
|
|
2133
|
+
if (stdout.trim() !== "") return stdout.trim();
|
|
2134
|
+
} catch {
|
|
2135
|
+
}
|
|
2136
|
+
for (const ref of ["main", "master"]) {
|
|
2137
|
+
try {
|
|
2138
|
+
await resolveCommitRef(ref, cwd);
|
|
2139
|
+
return ref;
|
|
2140
|
+
} catch {
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
throw new Error(
|
|
2144
|
+
"Could not detect a default branch. Set origin/HEAD or pass an explicit base ref."
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
1922
2147
|
async function getResolvedCommitDiff(commit, cwd) {
|
|
1923
2148
|
const raw = await collectGitDiff(
|
|
1924
2149
|
[
|
|
@@ -1943,7 +2168,7 @@ async function resolveCommitRef(ref, cwd) {
|
|
|
1943
2168
|
throw new Error("Commit ref must not be empty.");
|
|
1944
2169
|
}
|
|
1945
2170
|
try {
|
|
1946
|
-
const { stdout } = await
|
|
2171
|
+
const { stdout } = await execa4(
|
|
1947
2172
|
"git",
|
|
1948
2173
|
["rev-parse", "--verify", "--quiet", "--end-of-options", `${trimmed}^{commit}`],
|
|
1949
2174
|
cwd ? { cwd } : {}
|
|
@@ -1971,7 +2196,7 @@ async function getStagedDiff(cwd) {
|
|
|
1971
2196
|
}
|
|
1972
2197
|
async function collectGitDiff(args, cwd) {
|
|
1973
2198
|
try {
|
|
1974
|
-
const { stdout } = await
|
|
2199
|
+
const { stdout } = await execa4("git", args, {
|
|
1975
2200
|
maxBuffer: MAX_DIFF_OUTPUT_BYTES,
|
|
1976
2201
|
...cwd ? { cwd } : {}
|
|
1977
2202
|
});
|
|
@@ -1990,7 +2215,7 @@ async function collectGitDiff(args, cwd) {
|
|
|
1990
2215
|
}
|
|
1991
2216
|
async function isGitRepo() {
|
|
1992
2217
|
try {
|
|
1993
|
-
await
|
|
2218
|
+
await execa4("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
1994
2219
|
return true;
|
|
1995
2220
|
} catch {
|
|
1996
2221
|
return false;
|
|
@@ -1998,7 +2223,7 @@ async function isGitRepo() {
|
|
|
1998
2223
|
}
|
|
1999
2224
|
async function hasCommits() {
|
|
2000
2225
|
try {
|
|
2001
|
-
await
|
|
2226
|
+
await execa4("git", ["rev-parse", "HEAD"]);
|
|
2002
2227
|
return true;
|
|
2003
2228
|
} catch {
|
|
2004
2229
|
return false;
|
|
@@ -2217,7 +2442,7 @@ var DOC_BASENAME_PATTERNS = [
|
|
|
2217
2442
|
/^TODO/i
|
|
2218
2443
|
];
|
|
2219
2444
|
function isDocFile(path) {
|
|
2220
|
-
const base =
|
|
2445
|
+
const base = basename3(path);
|
|
2221
2446
|
const extension = extname(base).toLowerCase();
|
|
2222
2447
|
if (DOC_EXTENSIONS.has(extension)) return true;
|
|
2223
2448
|
if (extension) return false;
|
|
@@ -2232,7 +2457,7 @@ import { extname as extname2 } from "path";
|
|
|
2232
2457
|
|
|
2233
2458
|
// src/review/ast/typescript.ts
|
|
2234
2459
|
import { createRequire } from "module";
|
|
2235
|
-
import { join as
|
|
2460
|
+
import { join as join7 } from "path";
|
|
2236
2461
|
import { pathToFileURL } from "url";
|
|
2237
2462
|
var MAX_AST_SYMBOL_CHARS = 8e3;
|
|
2238
2463
|
var cachedTs = null;
|
|
@@ -2249,7 +2474,7 @@ var typescriptAstParser = {
|
|
|
2249
2474
|
function tryLoadUserTypescript() {
|
|
2250
2475
|
if (cachedTs !== null) return cachedTs;
|
|
2251
2476
|
try {
|
|
2252
|
-
const require2 = createRequire(pathToFileURL(
|
|
2477
|
+
const require2 = createRequire(pathToFileURL(join7(process.cwd(), "package.json")));
|
|
2253
2478
|
cachedTs = require2("typescript");
|
|
2254
2479
|
} catch {
|
|
2255
2480
|
try {
|
|
@@ -2424,7 +2649,7 @@ function isCodePath(path) {
|
|
|
2424
2649
|
}
|
|
2425
2650
|
|
|
2426
2651
|
// src/review/context-references.ts
|
|
2427
|
-
import { basename as
|
|
2652
|
+
import { basename as basename4, extname as extname3 } from "path";
|
|
2428
2653
|
var MAX_REFERENCES_PER_TERM = 8;
|
|
2429
2654
|
var MAX_REFERENCE_TERMS = 8;
|
|
2430
2655
|
var MAX_REFERENCE_LINE_CHARS = 220;
|
|
@@ -2439,7 +2664,7 @@ async function buildReferenceContexts(source, changedFiles, skippedFiles, diagno
|
|
|
2439
2664
|
...skippedFiles.map((file) => file.path)
|
|
2440
2665
|
]);
|
|
2441
2666
|
for (const file of changedFiles) {
|
|
2442
|
-
terms.add(
|
|
2667
|
+
terms.add(basename4(file.file.path, extname3(file.file.path)));
|
|
2443
2668
|
for (const symbol of file.symbols.slice(0, 4)) {
|
|
2444
2669
|
terms.add(symbol);
|
|
2445
2670
|
}
|
|
@@ -2542,19 +2767,19 @@ function truncateSnippet(snippet) {
|
|
|
2542
2767
|
}
|
|
2543
2768
|
|
|
2544
2769
|
// src/review/context-source.ts
|
|
2545
|
-
import { readFile as
|
|
2546
|
-
import { join as
|
|
2547
|
-
import { execa as
|
|
2770
|
+
import { readFile as readFile5, stat as stat2 } from "fs/promises";
|
|
2771
|
+
import { join as join8 } from "path";
|
|
2772
|
+
import { execa as execa5 } from "execa";
|
|
2548
2773
|
var REFERENCE_SEARCH_TIMEOUT_MS = 5e3;
|
|
2549
2774
|
function createFilesystemContextSource(root) {
|
|
2550
2775
|
return {
|
|
2551
2776
|
async read(path, maxBytes) {
|
|
2552
2777
|
try {
|
|
2553
|
-
const absolutePath =
|
|
2778
|
+
const absolutePath = join8(root, path);
|
|
2554
2779
|
const info = await stat2(absolutePath);
|
|
2555
2780
|
if (!info.isFile()) return { status: "skipped", reason: "not a regular file" };
|
|
2556
2781
|
if (info.size > maxBytes) return tooLarge(info.size, maxBytes);
|
|
2557
|
-
return { status: "loaded", content: await
|
|
2782
|
+
return { status: "loaded", content: await readFile5(absolutePath, "utf-8") };
|
|
2558
2783
|
} catch (err) {
|
|
2559
2784
|
return { status: "skipped", reason: formatReadError(err) };
|
|
2560
2785
|
}
|
|
@@ -2570,12 +2795,12 @@ function createGitContextSource(root, target) {
|
|
|
2570
2795
|
async read(path, maxBytes) {
|
|
2571
2796
|
const object = `${treeish}${path}`;
|
|
2572
2797
|
try {
|
|
2573
|
-
const { stdout: sizeOutput } = await
|
|
2798
|
+
const { stdout: sizeOutput } = await execa5("git", ["cat-file", "-s", object], {
|
|
2574
2799
|
cwd: root
|
|
2575
2800
|
});
|
|
2576
2801
|
const size = Number(sizeOutput.trim());
|
|
2577
2802
|
if (Number.isFinite(size) && size > maxBytes) return tooLarge(size, maxBytes);
|
|
2578
|
-
const { stdout } = await
|
|
2803
|
+
const { stdout } = await execa5("git", ["show", object], {
|
|
2579
2804
|
cwd: root,
|
|
2580
2805
|
maxBuffer: maxBytes,
|
|
2581
2806
|
stripFinalNewline: false
|
|
@@ -2602,7 +2827,7 @@ async function runGitGrep(root, args, terms, commit) {
|
|
|
2602
2827
|
if (commit) args.push(commit);
|
|
2603
2828
|
args.push("--");
|
|
2604
2829
|
try {
|
|
2605
|
-
const { stdout } = await
|
|
2830
|
+
const { stdout } = await execa5("git", args, {
|
|
2606
2831
|
cwd: root,
|
|
2607
2832
|
timeout: REFERENCE_SEARCH_TIMEOUT_MS
|
|
2608
2833
|
});
|
|
@@ -2858,6 +3083,7 @@ async function loadReviewSnapshot(root, target) {
|
|
|
2858
3083
|
return {
|
|
2859
3084
|
root,
|
|
2860
3085
|
target,
|
|
3086
|
+
targetCommit: null,
|
|
2861
3087
|
diff: await getStagedDiff(root),
|
|
2862
3088
|
source: createGitContextSource(root, { kind: "staged" })
|
|
2863
3089
|
};
|
|
@@ -2866,6 +3092,7 @@ async function loadReviewSnapshot(root, target) {
|
|
|
2866
3092
|
return {
|
|
2867
3093
|
root,
|
|
2868
3094
|
target,
|
|
3095
|
+
targetCommit: sha,
|
|
2869
3096
|
diff: await getResolvedCommitDiff(sha, root),
|
|
2870
3097
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
2871
3098
|
};
|
|
@@ -2875,10 +3102,21 @@ async function loadReviewSnapshot(root, target) {
|
|
|
2875
3102
|
return {
|
|
2876
3103
|
root,
|
|
2877
3104
|
target,
|
|
3105
|
+
targetCommit: sha,
|
|
2878
3106
|
diff: await getResolvedCommitDiff(sha, root),
|
|
2879
3107
|
source: createGitContextSource(root, { kind: "commit", sha })
|
|
2880
3108
|
};
|
|
2881
3109
|
}
|
|
3110
|
+
case "base": {
|
|
3111
|
+
const branch = await getBranchDiff(target.ref, root);
|
|
3112
|
+
return {
|
|
3113
|
+
root,
|
|
3114
|
+
target: { kind: "base", ref: branch.baseRef },
|
|
3115
|
+
targetCommit: branch.headCommit,
|
|
3116
|
+
diff: branch.diff,
|
|
3117
|
+
source: createGitContextSource(root, { kind: "commit", sha: branch.headCommit })
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
2882
3120
|
}
|
|
2883
3121
|
}
|
|
2884
3122
|
async function buildReviewContextFromDiff(snapshot, config, depth = config.context.depth) {
|
|
@@ -2981,7 +3219,7 @@ async function buildRelatedFileContexts(source, files) {
|
|
|
2981
3219
|
return related;
|
|
2982
3220
|
}
|
|
2983
3221
|
function shouldReviewFile(path, config) {
|
|
2984
|
-
if (LOCKFILE_EXCLUDES.has(
|
|
3222
|
+
if (LOCKFILE_EXCLUDES.has(basename5(path))) return false;
|
|
2985
3223
|
const include = config.include.length > 0 ? config.include : ["**/*"];
|
|
2986
3224
|
if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
|
|
2987
3225
|
return false;
|
|
@@ -3100,14 +3338,14 @@ function getChangedLinesByFile(rawDiff) {
|
|
|
3100
3338
|
return changed;
|
|
3101
3339
|
}
|
|
3102
3340
|
function testCandidates(path) {
|
|
3103
|
-
const dir =
|
|
3341
|
+
const dir = dirname3(path);
|
|
3104
3342
|
const ext = extname5(path);
|
|
3105
|
-
const base =
|
|
3343
|
+
const base = basename5(path, ext);
|
|
3106
3344
|
return [
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3345
|
+
join9(dir, `${base}.test${ext}`),
|
|
3346
|
+
join9(dir, `${base}.spec${ext}`),
|
|
3347
|
+
join9(dir, "__tests__", `${base}.test${ext}`),
|
|
3348
|
+
join9(dir, "__tests__", `${base}.spec${ext}`)
|
|
3111
3349
|
];
|
|
3112
3350
|
}
|
|
3113
3351
|
function truncateText3(text, maxChars) {
|
|
@@ -3154,80 +3392,11 @@ function filterFindingsByChangedFiles(findings, changedFiles) {
|
|
|
3154
3392
|
|
|
3155
3393
|
// src/review/formatter.ts
|
|
3156
3394
|
import chalk from "chalk";
|
|
3157
|
-
import { writeFile as
|
|
3395
|
+
import { rename as rename2, unlink as unlink2, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
|
|
3396
|
+
import { randomUUID } from "crypto";
|
|
3158
3397
|
import { existsSync as existsSync4 } from "fs";
|
|
3159
|
-
import { join as
|
|
3160
|
-
import { parse as
|
|
3161
|
-
|
|
3162
|
-
// src/git/state-root.ts
|
|
3163
|
-
import { existsSync as existsSync3 } from "fs";
|
|
3164
|
-
import { basename as basename5, dirname as dirname3, join as join8, relative as relative2, resolve } from "path";
|
|
3165
|
-
import { execa as execa5 } from "execa";
|
|
3166
|
-
var sharedDiffOwlDirPromise;
|
|
3167
|
-
var warnedStateMove = false;
|
|
3168
|
-
async function getSharedDiffOwlDir() {
|
|
3169
|
-
if (!sharedDiffOwlDirPromise) {
|
|
3170
|
-
sharedDiffOwlDirPromise = resolveSharedDiffOwlDir();
|
|
3171
|
-
}
|
|
3172
|
-
return sharedDiffOwlDirPromise;
|
|
3173
|
-
}
|
|
3174
|
-
async function resolveSharedDiffOwlDir() {
|
|
3175
|
-
const projectRoot = getProjectRoot();
|
|
3176
|
-
const localDir = getDiffOwlDir();
|
|
3177
|
-
let insideWorkTree;
|
|
3178
|
-
try {
|
|
3179
|
-
({ stdout: insideWorkTree } = await execa5("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
3180
|
-
cwd: projectRoot
|
|
3181
|
-
}));
|
|
3182
|
-
} catch {
|
|
3183
|
-
return localDir;
|
|
3184
|
-
}
|
|
3185
|
-
if (insideWorkTree.trim() !== "true") {
|
|
3186
|
-
return localDir;
|
|
3187
|
-
}
|
|
3188
|
-
let toplevelRaw;
|
|
3189
|
-
let commonRaw;
|
|
3190
|
-
try {
|
|
3191
|
-
[{ stdout: toplevelRaw }, { stdout: commonRaw }] = await Promise.all([
|
|
3192
|
-
execa5("git", ["rev-parse", "--show-toplevel"], { cwd: projectRoot }),
|
|
3193
|
-
execa5("git", ["rev-parse", "--git-common-dir"], { cwd: projectRoot })
|
|
3194
|
-
]);
|
|
3195
|
-
} catch {
|
|
3196
|
-
return localDir;
|
|
3197
|
-
}
|
|
3198
|
-
const toplevel = resolve(projectRoot, toplevelRaw.trim());
|
|
3199
|
-
const commonDir = resolve(projectRoot, commonRaw.trim());
|
|
3200
|
-
const rel = relative2(toplevel, projectRoot);
|
|
3201
|
-
if (rel.startsWith("..")) {
|
|
3202
|
-
return localDir;
|
|
3203
|
-
}
|
|
3204
|
-
let sharedDiffOwlDir;
|
|
3205
|
-
if (basename5(commonDir) !== ".git") {
|
|
3206
|
-
sharedDiffOwlDir = join8(commonDir, "diffowl", rel, ".diffowl");
|
|
3207
|
-
} else {
|
|
3208
|
-
sharedDiffOwlDir = join8(dirname3(commonDir), rel, ".diffowl");
|
|
3209
|
-
}
|
|
3210
|
-
warnIfIgnoringLocalState(localDir, sharedDiffOwlDir);
|
|
3211
|
-
return sharedDiffOwlDir;
|
|
3212
|
-
}
|
|
3213
|
-
function warnIfIgnoringLocalState(localDir, sharedDir) {
|
|
3214
|
-
if (warnedStateMove || localDir === sharedDir) {
|
|
3215
|
-
return;
|
|
3216
|
-
}
|
|
3217
|
-
const localDb = join8(localDir, "state.db");
|
|
3218
|
-
if (!existsSync3(localDb)) {
|
|
3219
|
-
return;
|
|
3220
|
-
}
|
|
3221
|
-
warnedStateMove = true;
|
|
3222
|
-
console.warn(
|
|
3223
|
-
`DiffOwl state moved: using ${join8(
|
|
3224
|
-
sharedDir,
|
|
3225
|
-
"state.db"
|
|
3226
|
-
)}; ignoring checkout-local ${localDb} (delete it, or see plan 025).`
|
|
3227
|
-
);
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
// src/review/formatter.ts
|
|
3398
|
+
import { join as join10 } from "path";
|
|
3399
|
+
import { parse as parse3, stringify as stringify3 } from "yaml";
|
|
3231
3400
|
var REPORT_SCHEMA_VERSION = 1;
|
|
3232
3401
|
function formatFindingHeading(index, finding) {
|
|
3233
3402
|
const ordinal = `Finding ${index + 1}`;
|
|
@@ -3294,32 +3463,52 @@ function renderMarkdown(report) {
|
|
|
3294
3463
|
}
|
|
3295
3464
|
lines.push("");
|
|
3296
3465
|
lines.push("### Status");
|
|
3297
|
-
lines.push(report.findings
|
|
3466
|
+
lines.push(resolveMarkdownReviewStatus(report.findings));
|
|
3298
3467
|
return lines.join("\n");
|
|
3299
3468
|
}
|
|
3469
|
+
function resolveMarkdownReviewStatus(findings) {
|
|
3470
|
+
if (findings.length === 0) {
|
|
3471
|
+
return "Resolved";
|
|
3472
|
+
}
|
|
3473
|
+
if (findings.some((finding) => finding.severity === "error" || finding.severity === "warning")) {
|
|
3474
|
+
return "Open";
|
|
3475
|
+
}
|
|
3476
|
+
return "Advisory";
|
|
3477
|
+
}
|
|
3300
3478
|
async function writeMarkdownReport(review, metadata) {
|
|
3301
|
-
const dir =
|
|
3479
|
+
const dir = join10(await getSharedDiffOwlDir(), "reviews");
|
|
3302
3480
|
if (!existsSync4(dir)) {
|
|
3303
|
-
await
|
|
3481
|
+
await mkdir3(dir, { recursive: true });
|
|
3304
3482
|
}
|
|
3305
3483
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3306
3484
|
const filename = `review-${timestamp}.md`;
|
|
3307
|
-
const filepath =
|
|
3485
|
+
const filepath = join10(dir, filename);
|
|
3308
3486
|
const content = `${metadata ? renderReviewFrontmatter(metadata) : ""}# DiffOwl Review
|
|
3309
3487
|
_${(/* @__PURE__ */ new Date()).toLocaleString()}_
|
|
3310
3488
|
|
|
3311
3489
|
${review}
|
|
3312
3490
|
`;
|
|
3313
|
-
await
|
|
3314
|
-
const latestPath =
|
|
3315
|
-
await
|
|
3491
|
+
await writeFileAtomic(filepath, content);
|
|
3492
|
+
const latestPath = join10(dir, "latest.md");
|
|
3493
|
+
await writeFileAtomic(latestPath, content);
|
|
3316
3494
|
return filepath;
|
|
3317
3495
|
}
|
|
3496
|
+
async function writeFileAtomic(filepath, content) {
|
|
3497
|
+
const tempPath = `${filepath}.${process.pid}.${randomUUID()}.tmp`;
|
|
3498
|
+
await writeFile4(tempPath, content, "utf-8");
|
|
3499
|
+
try {
|
|
3500
|
+
await rename2(tempPath, filepath);
|
|
3501
|
+
} catch (error) {
|
|
3502
|
+
await unlink2(tempPath).catch(() => {
|
|
3503
|
+
});
|
|
3504
|
+
throw error;
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3318
3507
|
function parseReviewMetadata(content) {
|
|
3319
3508
|
if (!content.startsWith("---\n")) return void 0;
|
|
3320
3509
|
const end = content.indexOf("\n---\n", 4);
|
|
3321
3510
|
if (end === -1) return void 0;
|
|
3322
|
-
const parsed =
|
|
3511
|
+
const parsed = parse3(content.slice(4, end));
|
|
3323
3512
|
if (!parsed || typeof parsed !== "object") return void 0;
|
|
3324
3513
|
const diffowl = parsed.diffowl;
|
|
3325
3514
|
if (!diffowl || typeof diffowl !== "object") return void 0;
|
|
@@ -3344,7 +3533,7 @@ function parseReviewMetadata(content) {
|
|
|
3344
3533
|
}
|
|
3345
3534
|
function renderReviewFrontmatter(metadata) {
|
|
3346
3535
|
return `---
|
|
3347
|
-
${
|
|
3536
|
+
${stringify3({ diffowl: metadata }, { lineWidth: 0 })}---
|
|
3348
3537
|
|
|
3349
3538
|
`;
|
|
3350
3539
|
}
|
|
@@ -3387,38 +3576,6 @@ function printFooter(report, reportPath) {
|
|
|
3387
3576
|
}
|
|
3388
3577
|
console.log();
|
|
3389
3578
|
}
|
|
3390
|
-
function colorizeMarkdown(text) {
|
|
3391
|
-
const lines = text.split("\n");
|
|
3392
|
-
const colorizedLines = [];
|
|
3393
|
-
let inCodeBlock = false;
|
|
3394
|
-
for (const line of lines) {
|
|
3395
|
-
if (line.trim().startsWith("```")) {
|
|
3396
|
-
inCodeBlock = !inCodeBlock;
|
|
3397
|
-
colorizedLines.push(chalk.dim(line));
|
|
3398
|
-
continue;
|
|
3399
|
-
}
|
|
3400
|
-
if (inCodeBlock) {
|
|
3401
|
-
colorizedLines.push(line);
|
|
3402
|
-
} else {
|
|
3403
|
-
const colorized = line.replace(
|
|
3404
|
-
/\*\*\[(ERROR|WARNING|INFO)\]([^*]*)\*\*/g,
|
|
3405
|
-
(_match, label, rest) => `${colorizeSeverity(label)}${chalk.bold(rest)}`
|
|
3406
|
-
).replace(/^### (.*)/g, (_match, title) => chalk.bold.underline(title)).replace(/\*\*([^*]+)\*\*/g, (_match, content) => chalk.bold(content));
|
|
3407
|
-
colorizedLines.push(colorized);
|
|
3408
|
-
}
|
|
3409
|
-
}
|
|
3410
|
-
return colorizedLines.join("\n");
|
|
3411
|
-
}
|
|
3412
|
-
function colorizeSeverity(label) {
|
|
3413
|
-
switch (label) {
|
|
3414
|
-
case "ERROR":
|
|
3415
|
-
return chalk.red.bold(`[${label}]`);
|
|
3416
|
-
case "WARNING":
|
|
3417
|
-
return chalk.yellow.bold(`[${label}]`);
|
|
3418
|
-
default:
|
|
3419
|
-
return chalk.blue.bold(`[${label}]`);
|
|
3420
|
-
}
|
|
3421
|
-
}
|
|
3422
3579
|
function formatMarkdownCodeSpan(text) {
|
|
3423
3580
|
const trimmed = text.trim();
|
|
3424
3581
|
let maxRun = 0;
|
|
@@ -3541,14 +3698,14 @@ ${content.replaceAll("```", "'''")}
|
|
|
3541
3698
|
}
|
|
3542
3699
|
|
|
3543
3700
|
// src/eval/repo.ts
|
|
3544
|
-
import { mkdtemp, rm } from "fs/promises";
|
|
3701
|
+
import { mkdtemp, rm as rm2 } from "fs/promises";
|
|
3545
3702
|
import { tmpdir } from "os";
|
|
3546
|
-
import { join as
|
|
3703
|
+
import { join as join11 } from "path";
|
|
3547
3704
|
import { execa as execa6 } from "execa";
|
|
3548
3705
|
var CLEANUP_RETRIES = 5;
|
|
3549
3706
|
var CLEANUP_RETRY_DELAY_MS = 100;
|
|
3550
3707
|
async function materializeEvalCaseRepo(evalCase) {
|
|
3551
|
-
const workDir = await mkdtemp(
|
|
3708
|
+
const workDir = await mkdtemp(join11(tmpdir(), `diffowl-eval-${evalCase.id}-`));
|
|
3552
3709
|
try {
|
|
3553
3710
|
await copyCaseBase(evalCase, workDir);
|
|
3554
3711
|
await initEvalGitRepo(workDir);
|
|
@@ -3562,7 +3719,7 @@ async function materializeEvalCaseRepo(evalCase) {
|
|
|
3562
3719
|
}
|
|
3563
3720
|
}
|
|
3564
3721
|
async function cleanupMaterializedRepo(workDir) {
|
|
3565
|
-
await
|
|
3722
|
+
await rm2(workDir, {
|
|
3566
3723
|
recursive: true,
|
|
3567
3724
|
force: true,
|
|
3568
3725
|
maxRetries: CLEANUP_RETRIES,
|
|
@@ -3776,7 +3933,7 @@ async function buildEvalManifest(input) {
|
|
|
3776
3933
|
};
|
|
3777
3934
|
}
|
|
3778
3935
|
async function readDiffOwlVersion(rootDir = process.cwd()) {
|
|
3779
|
-
const packageJson = JSON.parse(await
|
|
3936
|
+
const packageJson = JSON.parse(await readFile6(join12(rootDir, "package.json"), "utf8"));
|
|
3780
3937
|
if (!packageJson.version) {
|
|
3781
3938
|
throw new Error("package.json is missing a version field.");
|
|
3782
3939
|
}
|
|
@@ -3784,18 +3941,18 @@ async function readDiffOwlVersion(rootDir = process.cwd()) {
|
|
|
3784
3941
|
}
|
|
3785
3942
|
|
|
3786
3943
|
// src/eval/report.ts
|
|
3787
|
-
import { mkdir as
|
|
3788
|
-
import { join as
|
|
3944
|
+
import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
|
|
3945
|
+
import { join as join13 } from "path";
|
|
3789
3946
|
|
|
3790
3947
|
// src/eval/delta.ts
|
|
3791
|
-
import { z as
|
|
3792
|
-
var EvalModeDeltaMetricSchema =
|
|
3793
|
-
diffowl:
|
|
3794
|
-
baseline:
|
|
3795
|
-
delta:
|
|
3948
|
+
import { z as z8 } from "zod";
|
|
3949
|
+
var EvalModeDeltaMetricSchema = z8.object({
|
|
3950
|
+
diffowl: z8.number().nullable(),
|
|
3951
|
+
baseline: z8.number().nullable(),
|
|
3952
|
+
delta: z8.number().nullable()
|
|
3796
3953
|
});
|
|
3797
|
-
var EvalCaseModeDeltaSchema =
|
|
3798
|
-
caseId:
|
|
3954
|
+
var EvalCaseModeDeltaSchema = z8.object({
|
|
3955
|
+
caseId: z8.string(),
|
|
3799
3956
|
precision: EvalModeDeltaMetricSchema,
|
|
3800
3957
|
recall: EvalModeDeltaMetricSchema,
|
|
3801
3958
|
fBeta: EvalModeDeltaMetricSchema,
|
|
@@ -3803,15 +3960,15 @@ var EvalCaseModeDeltaSchema = z7.object({
|
|
|
3803
3960
|
latencyP50: EvalModeDeltaMetricSchema,
|
|
3804
3961
|
usageMeanCost: EvalModeDeltaMetricSchema
|
|
3805
3962
|
});
|
|
3806
|
-
var EvalCorpusModeDeltaSchema =
|
|
3807
|
-
caseCount:
|
|
3963
|
+
var EvalCorpusModeDeltaSchema = z8.object({
|
|
3964
|
+
caseCount: z8.number(),
|
|
3808
3965
|
precision: EvalModeDeltaMetricSchema,
|
|
3809
3966
|
recall: EvalModeDeltaMetricSchema,
|
|
3810
3967
|
fBeta: EvalModeDeltaMetricSchema,
|
|
3811
3968
|
repeatedFpRate: EvalModeDeltaMetricSchema,
|
|
3812
3969
|
latencyP50: EvalModeDeltaMetricSchema,
|
|
3813
3970
|
usageMeanCost: EvalModeDeltaMetricSchema,
|
|
3814
|
-
cases:
|
|
3971
|
+
cases: z8.array(EvalCaseModeDeltaSchema)
|
|
3815
3972
|
});
|
|
3816
3973
|
function summaryMean(summary) {
|
|
3817
3974
|
return summary?.mean ?? null;
|
|
@@ -3958,70 +4115,70 @@ function evaluateEvalGates(doc, thresholds) {
|
|
|
3958
4115
|
}
|
|
3959
4116
|
|
|
3960
4117
|
// src/eval/metrics-types.ts
|
|
3961
|
-
import { z as
|
|
3962
|
-
var StatSummarySchema =
|
|
3963
|
-
mean:
|
|
3964
|
-
stddev:
|
|
3965
|
-
values:
|
|
4118
|
+
import { z as z9 } from "zod";
|
|
4119
|
+
var StatSummarySchema = z9.object({
|
|
4120
|
+
mean: z9.number(),
|
|
4121
|
+
stddev: z9.number(),
|
|
4122
|
+
values: z9.array(z9.number())
|
|
3966
4123
|
});
|
|
3967
4124
|
var DEFAULT_EVAL_METRICS_OPTIONS = {
|
|
3968
4125
|
beta: 1
|
|
3969
4126
|
};
|
|
3970
|
-
var EvalTrialMetricsSchema =
|
|
3971
|
-
trial:
|
|
3972
|
-
precision:
|
|
3973
|
-
recall:
|
|
3974
|
-
fBeta:
|
|
3975
|
-
durationMs:
|
|
3976
|
-
usageCost:
|
|
3977
|
-
totalTokens:
|
|
3978
|
-
emptyOnClean:
|
|
4127
|
+
var EvalTrialMetricsSchema = z9.object({
|
|
4128
|
+
trial: z9.number(),
|
|
4129
|
+
precision: z9.number(),
|
|
4130
|
+
recall: z9.number(),
|
|
4131
|
+
fBeta: z9.number(),
|
|
4132
|
+
durationMs: z9.number(),
|
|
4133
|
+
usageCost: z9.number().nullable(),
|
|
4134
|
+
totalTokens: z9.number().nullable(),
|
|
4135
|
+
emptyOnClean: z9.boolean()
|
|
3979
4136
|
});
|
|
3980
|
-
var EvalLatencyMetricsSchema =
|
|
3981
|
-
p50:
|
|
3982
|
-
p95:
|
|
3983
|
-
values:
|
|
4137
|
+
var EvalLatencyMetricsSchema = z9.object({
|
|
4138
|
+
p50: z9.number().nullable(),
|
|
4139
|
+
p95: z9.number().nullable(),
|
|
4140
|
+
values: z9.array(z9.number())
|
|
3984
4141
|
});
|
|
3985
|
-
var EvalUsageMetricsSchema =
|
|
3986
|
-
meanCost:
|
|
3987
|
-
totalCost:
|
|
3988
|
-
meanTokens:
|
|
3989
|
-
coverage:
|
|
4142
|
+
var EvalUsageMetricsSchema = z9.object({
|
|
4143
|
+
meanCost: z9.number().nullable(),
|
|
4144
|
+
totalCost: z9.number().nullable(),
|
|
4145
|
+
meanTokens: z9.number().nullable(),
|
|
4146
|
+
coverage: z9.number()
|
|
3990
4147
|
});
|
|
3991
|
-
var EvalCaseMetricsSchema =
|
|
3992
|
-
caseId:
|
|
4148
|
+
var EvalCaseMetricsSchema = z9.object({
|
|
4149
|
+
caseId: z9.string(),
|
|
3993
4150
|
category: EvalCaseCategorySchema,
|
|
3994
|
-
tags:
|
|
3995
|
-
trialCount:
|
|
4151
|
+
tags: z9.array(z9.string()),
|
|
4152
|
+
trialCount: z9.number(),
|
|
3996
4153
|
precision: StatSummarySchema.nullable(),
|
|
3997
4154
|
recall: StatSummarySchema.nullable(),
|
|
3998
4155
|
fBeta: StatSummarySchema.nullable(),
|
|
3999
|
-
repeatedFpRate:
|
|
4000
|
-
emptyOnCleanRate:
|
|
4156
|
+
repeatedFpRate: z9.number(),
|
|
4157
|
+
emptyOnCleanRate: z9.number().nullable(),
|
|
4001
4158
|
latencyMs: EvalLatencyMetricsSchema,
|
|
4002
4159
|
usage: EvalUsageMetricsSchema,
|
|
4003
|
-
trials:
|
|
4160
|
+
trials: z9.array(EvalTrialMetricsSchema)
|
|
4004
4161
|
});
|
|
4005
|
-
var EvalCategoryMetricsSchema =
|
|
4162
|
+
var EvalCategoryMetricsSchema = z9.object({
|
|
4006
4163
|
category: EvalCaseCategorySchema,
|
|
4007
|
-
caseCount:
|
|
4164
|
+
caseCount: z9.number(),
|
|
4008
4165
|
precision: StatSummarySchema.nullable(),
|
|
4009
4166
|
recall: StatSummarySchema.nullable(),
|
|
4010
4167
|
fBeta: StatSummarySchema.nullable(),
|
|
4011
|
-
repeatedFpRate:
|
|
4012
|
-
emptyOnCleanRate:
|
|
4168
|
+
repeatedFpRate: z9.number().nullable(),
|
|
4169
|
+
emptyOnCleanRate: z9.number().nullable()
|
|
4013
4170
|
});
|
|
4014
|
-
var EvalCorpusMetricsSchema =
|
|
4015
|
-
caseCount:
|
|
4016
|
-
trialCount:
|
|
4171
|
+
var EvalCorpusMetricsSchema = z9.object({
|
|
4172
|
+
caseCount: z9.number(),
|
|
4173
|
+
trialCount: z9.number(),
|
|
4017
4174
|
precision: StatSummarySchema.nullable(),
|
|
4018
4175
|
recall: StatSummarySchema.nullable(),
|
|
4019
4176
|
fBeta: StatSummarySchema.nullable(),
|
|
4020
|
-
repeatedFpRate:
|
|
4021
|
-
emptyOnCleanRate:
|
|
4177
|
+
repeatedFpRate: z9.number().nullable(),
|
|
4178
|
+
emptyOnCleanRate: z9.number().nullable(),
|
|
4022
4179
|
latencyMs: EvalLatencyMetricsSchema,
|
|
4023
4180
|
usage: EvalUsageMetricsSchema,
|
|
4024
|
-
byCategory:
|
|
4181
|
+
byCategory: z9.array(EvalCategoryMetricsSchema)
|
|
4025
4182
|
});
|
|
4026
4183
|
|
|
4027
4184
|
// src/eval/metrics.ts
|
|
@@ -4189,147 +4346,146 @@ function computeCorpusMetrics(caseMetrics) {
|
|
|
4189
4346
|
}
|
|
4190
4347
|
|
|
4191
4348
|
// src/eval/report-types.ts
|
|
4192
|
-
import { z as
|
|
4349
|
+
import { z as z14 } from "zod";
|
|
4193
4350
|
|
|
4194
4351
|
// src/eval/manifest-types.ts
|
|
4195
|
-
import { z as
|
|
4196
|
-
var EvalReportModeSchema =
|
|
4197
|
-
var EvalManifestCaseSchema =
|
|
4198
|
-
id:
|
|
4199
|
-
case_json_hash:
|
|
4200
|
-
patch_hash:
|
|
4352
|
+
import { z as z10 } from "zod";
|
|
4353
|
+
var EvalReportModeSchema = z10.enum(["diffowl", "baseline", "both"]);
|
|
4354
|
+
var EvalManifestCaseSchema = z10.object({
|
|
4355
|
+
id: z10.string(),
|
|
4356
|
+
case_json_hash: z10.string(),
|
|
4357
|
+
patch_hash: z10.string()
|
|
4201
4358
|
});
|
|
4202
|
-
var EvalRunManifestSchema =
|
|
4203
|
-
corpus_version:
|
|
4204
|
-
cases:
|
|
4205
|
-
model:
|
|
4359
|
+
var EvalRunManifestSchema = z10.object({
|
|
4360
|
+
corpus_version: z10.string(),
|
|
4361
|
+
cases: z10.array(EvalManifestCaseSchema),
|
|
4362
|
+
model: z10.string(),
|
|
4206
4363
|
reasoning: ReasoningEffortSchema,
|
|
4207
4364
|
depth: ReviewContextDepthSchema,
|
|
4208
4365
|
min_confidence: ReviewConfidenceSchema,
|
|
4209
|
-
trials:
|
|
4366
|
+
trials: z10.number().int().positive(),
|
|
4210
4367
|
mode: EvalReportModeSchema,
|
|
4211
|
-
diffowl_version:
|
|
4212
|
-
node_version:
|
|
4213
|
-
opencode_version:
|
|
4214
|
-
started_at:
|
|
4215
|
-
finished_at:
|
|
4368
|
+
diffowl_version: z10.string(),
|
|
4369
|
+
node_version: z10.string(),
|
|
4370
|
+
opencode_version: z10.string().nullable(),
|
|
4371
|
+
started_at: z10.string(),
|
|
4372
|
+
finished_at: z10.string()
|
|
4216
4373
|
});
|
|
4217
4374
|
|
|
4218
4375
|
// src/eval/runner-types.ts
|
|
4219
|
-
import { z as
|
|
4376
|
+
import { z as z12 } from "zod";
|
|
4220
4377
|
|
|
4221
4378
|
// src/review/types.ts
|
|
4222
|
-
import { z as
|
|
4223
|
-
var ReviewSeveritySchema2 =
|
|
4224
|
-
var DurableFindingMetadataSchema =
|
|
4225
|
-
id:
|
|
4226
|
-
classification:
|
|
4227
|
-
status:
|
|
4228
|
-
lifecycleSuppressed:
|
|
4379
|
+
import { z as z11 } from "zod";
|
|
4380
|
+
var ReviewSeveritySchema2 = z11.enum(["error", "warning", "info"]);
|
|
4381
|
+
var DurableFindingMetadataSchema = z11.object({
|
|
4382
|
+
id: z11.string(),
|
|
4383
|
+
classification: z11.enum(["new", "existing", "regressed"]),
|
|
4384
|
+
status: z11.enum(["open", "deferred", "dismissed", "fixed", "regressed"]),
|
|
4385
|
+
lifecycleSuppressed: z11.boolean().optional()
|
|
4229
4386
|
});
|
|
4230
|
-
var ReviewFindingSchema2 =
|
|
4387
|
+
var ReviewFindingSchema2 = z11.object({
|
|
4231
4388
|
severity: ReviewSeveritySchema2,
|
|
4232
|
-
file:
|
|
4233
|
-
line:
|
|
4234
|
-
evidence:
|
|
4235
|
-
title:
|
|
4236
|
-
body:
|
|
4389
|
+
file: z11.string(),
|
|
4390
|
+
line: z11.number(),
|
|
4391
|
+
evidence: z11.string().optional(),
|
|
4392
|
+
title: z11.string(),
|
|
4393
|
+
body: z11.string(),
|
|
4237
4394
|
confidence: ReviewConfidenceSchema,
|
|
4238
4395
|
durable: DurableFindingMetadataSchema.optional()
|
|
4239
4396
|
});
|
|
4240
|
-
var ReviewTimingSchema =
|
|
4241
|
-
phase:
|
|
4242
|
-
label:
|
|
4243
|
-
ms:
|
|
4397
|
+
var ReviewTimingSchema = z11.object({
|
|
4398
|
+
phase: z11.string(),
|
|
4399
|
+
label: z11.string(),
|
|
4400
|
+
ms: z11.number()
|
|
4244
4401
|
});
|
|
4245
4402
|
|
|
4246
4403
|
// src/eval/runner-types.ts
|
|
4247
|
-
var EvalRunModeSchema =
|
|
4248
|
-
var EvalTrialResultSchema =
|
|
4249
|
-
caseId:
|
|
4250
|
-
trial:
|
|
4404
|
+
var EvalRunModeSchema = z12.enum(["diffowl", "baseline"]);
|
|
4405
|
+
var EvalTrialResultSchema = z12.object({
|
|
4406
|
+
caseId: z12.string(),
|
|
4407
|
+
trial: z12.number(),
|
|
4251
4408
|
mode: EvalRunModeSchema,
|
|
4252
|
-
findings:
|
|
4253
|
-
timings:
|
|
4409
|
+
findings: z12.array(ReviewFindingSchema2),
|
|
4410
|
+
timings: z12.array(ReviewTimingSchema),
|
|
4254
4411
|
usage: ReviewUsageSchema.optional(),
|
|
4255
|
-
sessionId:
|
|
4256
|
-
summary:
|
|
4257
|
-
diagnostics:
|
|
4258
|
-
durationMs:
|
|
4259
|
-
error:
|
|
4412
|
+
sessionId: z12.string(),
|
|
4413
|
+
summary: z12.string(),
|
|
4414
|
+
diagnostics: z12.array(z12.string()),
|
|
4415
|
+
durationMs: z12.number(),
|
|
4416
|
+
error: z12.string().optional()
|
|
4260
4417
|
});
|
|
4261
|
-
var EvalCaseRunResultSchema =
|
|
4262
|
-
caseId:
|
|
4418
|
+
var EvalCaseRunResultSchema = z12.object({
|
|
4419
|
+
caseId: z12.string(),
|
|
4263
4420
|
mode: EvalRunModeSchema,
|
|
4264
|
-
trials:
|
|
4421
|
+
trials: z12.array(EvalTrialResultSchema)
|
|
4265
4422
|
});
|
|
4266
4423
|
|
|
4267
4424
|
// src/eval/score-types.ts
|
|
4268
|
-
import { z as
|
|
4425
|
+
import { z as z13 } from "zod";
|
|
4269
4426
|
var DEFAULT_EVAL_SCORE_OPTIONS = {
|
|
4270
|
-
categoryMatch: false,
|
|
4271
4427
|
fnMode: "must_detect",
|
|
4272
4428
|
repeatedFpThreshold: 2
|
|
4273
4429
|
};
|
|
4274
|
-
var EvalMatchSchema =
|
|
4275
|
-
expectedIndex:
|
|
4276
|
-
reportedIndex:
|
|
4277
|
-
lineDistance:
|
|
4430
|
+
var EvalMatchSchema = z13.object({
|
|
4431
|
+
expectedIndex: z13.number(),
|
|
4432
|
+
reportedIndex: z13.number(),
|
|
4433
|
+
lineDistance: z13.number()
|
|
4278
4434
|
});
|
|
4279
|
-
var EvalTrialScoreSchema =
|
|
4280
|
-
caseId:
|
|
4281
|
-
trial:
|
|
4282
|
-
truePositives:
|
|
4283
|
-
falsePositives:
|
|
4284
|
-
falseNegatives:
|
|
4285
|
-
redundancies:
|
|
4286
|
-
counts:
|
|
4287
|
-
tp:
|
|
4288
|
-
fp:
|
|
4289
|
-
fn:
|
|
4290
|
-
redundancy:
|
|
4435
|
+
var EvalTrialScoreSchema = z13.object({
|
|
4436
|
+
caseId: z13.string(),
|
|
4437
|
+
trial: z13.number(),
|
|
4438
|
+
truePositives: z13.array(EvalMatchSchema),
|
|
4439
|
+
falsePositives: z13.array(ReviewFindingSchema2),
|
|
4440
|
+
falseNegatives: z13.array(EvalExpectedFindingSchema),
|
|
4441
|
+
redundancies: z13.array(ReviewFindingSchema2),
|
|
4442
|
+
counts: z13.object({
|
|
4443
|
+
tp: z13.number(),
|
|
4444
|
+
fp: z13.number(),
|
|
4445
|
+
fn: z13.number(),
|
|
4446
|
+
redundancy: z13.number()
|
|
4291
4447
|
})
|
|
4292
4448
|
});
|
|
4293
|
-
var RepeatedFalsePositiveSchema =
|
|
4294
|
-
fingerprint:
|
|
4295
|
-
trialCount:
|
|
4449
|
+
var RepeatedFalsePositiveSchema = z13.object({
|
|
4450
|
+
fingerprint: z13.string(),
|
|
4451
|
+
trialCount: z13.number(),
|
|
4296
4452
|
example: ReviewFindingSchema2
|
|
4297
4453
|
});
|
|
4298
|
-
var EvalCaseScoreSchema =
|
|
4299
|
-
caseId:
|
|
4454
|
+
var EvalCaseScoreSchema = z13.object({
|
|
4455
|
+
caseId: z13.string(),
|
|
4300
4456
|
category: EvalCaseCategorySchema,
|
|
4301
|
-
tags:
|
|
4302
|
-
trials:
|
|
4303
|
-
repeatedFalsePositives:
|
|
4457
|
+
tags: z13.array(z13.string()),
|
|
4458
|
+
trials: z13.array(EvalTrialScoreSchema),
|
|
4459
|
+
repeatedFalsePositives: z13.array(RepeatedFalsePositiveSchema)
|
|
4304
4460
|
});
|
|
4305
4461
|
|
|
4306
4462
|
// src/eval/report-types.ts
|
|
4307
4463
|
var EVAL_RESULTS_SCHEMA_VERSION = 1;
|
|
4308
|
-
var EvalCaseModeResultV1Schema =
|
|
4464
|
+
var EvalCaseModeResultV1Schema = z14.object({
|
|
4309
4465
|
run: EvalCaseRunResultSchema,
|
|
4310
4466
|
score: EvalCaseScoreSchema,
|
|
4311
4467
|
metrics: EvalCaseMetricsSchema
|
|
4312
4468
|
});
|
|
4313
|
-
var EvalCaseResultV1Schema =
|
|
4314
|
-
id:
|
|
4469
|
+
var EvalCaseResultV1Schema = z14.object({
|
|
4470
|
+
id: z14.string(),
|
|
4315
4471
|
category: EvalCaseCategorySchema,
|
|
4316
|
-
tags:
|
|
4317
|
-
expected:
|
|
4318
|
-
case_json_hash:
|
|
4319
|
-
patch_hash:
|
|
4472
|
+
tags: z14.array(z14.string()),
|
|
4473
|
+
expected: z14.array(EvalExpectedFindingSchema),
|
|
4474
|
+
case_json_hash: z14.string(),
|
|
4475
|
+
patch_hash: z14.string(),
|
|
4320
4476
|
diffowl: EvalCaseModeResultV1Schema.optional(),
|
|
4321
4477
|
baseline: EvalCaseModeResultV1Schema.optional(),
|
|
4322
4478
|
delta: EvalCaseModeDeltaSchema.optional()
|
|
4323
4479
|
});
|
|
4324
|
-
var EvalResultsAggregateV1Schema =
|
|
4480
|
+
var EvalResultsAggregateV1Schema = z14.object({
|
|
4325
4481
|
diffowl: EvalCorpusMetricsSchema.optional(),
|
|
4326
4482
|
baseline: EvalCorpusMetricsSchema.optional(),
|
|
4327
4483
|
delta: EvalCorpusModeDeltaSchema.optional()
|
|
4328
4484
|
});
|
|
4329
|
-
var EvalResultsDocumentV1Schema =
|
|
4330
|
-
schema_version:
|
|
4485
|
+
var EvalResultsDocumentV1Schema = z14.object({
|
|
4486
|
+
schema_version: z14.literal(EVAL_RESULTS_SCHEMA_VERSION),
|
|
4331
4487
|
manifest: EvalRunManifestSchema,
|
|
4332
|
-
cases:
|
|
4488
|
+
cases: z14.array(EvalCaseResultV1Schema),
|
|
4333
4489
|
aggregate: EvalResultsAggregateV1Schema,
|
|
4334
4490
|
gates: EvalGateResultSchema.optional()
|
|
4335
4491
|
});
|
|
@@ -4383,20 +4539,11 @@ function severityRank(severity) {
|
|
|
4383
4539
|
}
|
|
4384
4540
|
function resolveScoreOptions(options) {
|
|
4385
4541
|
return {
|
|
4386
|
-
categoryMatch: options?.categoryMatch ?? DEFAULT_EVAL_SCORE_OPTIONS.categoryMatch,
|
|
4387
4542
|
fnMode: options?.fnMode ?? DEFAULT_EVAL_SCORE_OPTIONS.fnMode,
|
|
4388
4543
|
repeatedFpThreshold: options?.repeatedFpThreshold ?? DEFAULT_EVAL_SCORE_OPTIONS.repeatedFpThreshold
|
|
4389
4544
|
};
|
|
4390
4545
|
}
|
|
4391
|
-
function
|
|
4392
|
-
if (!expected.category) {
|
|
4393
|
-
return true;
|
|
4394
|
-
}
|
|
4395
|
-
const needle = expected.category.toLowerCase();
|
|
4396
|
-
const haystack = `${reported.title} ${reported.body}`.toLowerCase();
|
|
4397
|
-
return haystack.includes(needle);
|
|
4398
|
-
}
|
|
4399
|
-
function findingMatchesExpected(expected, reported, options) {
|
|
4546
|
+
function findingMatchesExpected(expected, reported) {
|
|
4400
4547
|
if (reported.file !== expected.file) {
|
|
4401
4548
|
return false;
|
|
4402
4549
|
}
|
|
@@ -4407,13 +4554,9 @@ function findingMatchesExpected(expected, reported, options) {
|
|
|
4407
4554
|
if (severityRank(reported.severity) < severityRank(expected.min_severity)) {
|
|
4408
4555
|
return false;
|
|
4409
4556
|
}
|
|
4410
|
-
const resolved = resolveScoreOptions(options);
|
|
4411
|
-
if (resolved.categoryMatch && !categoryMatches(expected, reported)) {
|
|
4412
|
-
return false;
|
|
4413
|
-
}
|
|
4414
4557
|
return true;
|
|
4415
4558
|
}
|
|
4416
|
-
function buildMatchCandidates(expected, reported
|
|
4559
|
+
function buildMatchCandidates(expected, reported) {
|
|
4417
4560
|
const candidates = [];
|
|
4418
4561
|
for (let expectedIndex = 0; expectedIndex < expected.length; expectedIndex++) {
|
|
4419
4562
|
const expectedEntry = expected[expectedIndex];
|
|
@@ -4425,7 +4568,7 @@ function buildMatchCandidates(expected, reported, options) {
|
|
|
4425
4568
|
if (!reportedFinding) {
|
|
4426
4569
|
continue;
|
|
4427
4570
|
}
|
|
4428
|
-
if (!findingMatchesExpected(expectedEntry, reportedFinding
|
|
4571
|
+
if (!findingMatchesExpected(expectedEntry, reportedFinding)) {
|
|
4429
4572
|
continue;
|
|
4430
4573
|
}
|
|
4431
4574
|
candidates.push({
|
|
@@ -4473,7 +4616,7 @@ function scoreEvalTrial(evalCase, trial, options) {
|
|
|
4473
4616
|
const resolved = resolveScoreOptions(options);
|
|
4474
4617
|
const reported = trial.findings;
|
|
4475
4618
|
const expected = evalCase.expected;
|
|
4476
|
-
const candidates = buildMatchCandidates(expected, reported
|
|
4619
|
+
const candidates = buildMatchCandidates(expected, reported);
|
|
4477
4620
|
const truePositives = assignTruePositives(candidates, reported);
|
|
4478
4621
|
const matchedExpected = new Set(truePositives.map((match) => match.expectedIndex));
|
|
4479
4622
|
const matchedReported = new Set(truePositives.map((match) => match.reportedIndex));
|
|
@@ -4487,7 +4630,7 @@ function scoreEvalTrial(evalCase, trial, options) {
|
|
|
4487
4630
|
continue;
|
|
4488
4631
|
}
|
|
4489
4632
|
const matchesAssignedExpected = expected.some(
|
|
4490
|
-
(entry, expectedIndex) => matchedExpected.has(expectedIndex) && findingMatchesExpected(entry, reportedFinding
|
|
4633
|
+
(entry, expectedIndex) => matchedExpected.has(expectedIndex) && findingMatchesExpected(entry, reportedFinding)
|
|
4491
4634
|
);
|
|
4492
4635
|
if (matchesAssignedExpected) {
|
|
4493
4636
|
redundancies.push(reportedFinding);
|
|
@@ -4497,7 +4640,7 @@ function scoreEvalTrial(evalCase, trial, options) {
|
|
|
4497
4640
|
if (!finding || matchedReported.has(reportedIndex)) {
|
|
4498
4641
|
return false;
|
|
4499
4642
|
}
|
|
4500
|
-
return !expected.some((entry) => findingMatchesExpected(entry, finding
|
|
4643
|
+
return !expected.some((entry) => findingMatchesExpected(entry, finding));
|
|
4501
4644
|
});
|
|
4502
4645
|
const falseNegatives = expected.filter(
|
|
4503
4646
|
(entry, expectedIndex) => !matchedExpected.has(expectedIndex) && expectedCountsAsFalseNegative(entry, resolved.fnMode)
|
|
@@ -4659,6 +4802,25 @@ function renderEvalSummary(document) {
|
|
|
4659
4802
|
const { manifest, aggregate } = document;
|
|
4660
4803
|
lines.push("# DiffOwl Eval Summary");
|
|
4661
4804
|
lines.push("");
|
|
4805
|
+
lines.push("## At a Glance");
|
|
4806
|
+
lines.push("");
|
|
4807
|
+
lines.push(`- Model: ${manifest.model}`);
|
|
4808
|
+
lines.push(`- Corpus: \`${manifest.corpus_version}\``);
|
|
4809
|
+
lines.push(`- Scope: ${document.cases.length} cases, ${manifest.trials} trial${manifest.trials === 1 ? "" : "s"}, ${manifest.mode} mode`);
|
|
4810
|
+
if (aggregate.diffowl) {
|
|
4811
|
+
lines.push(`- DiffOwl precision: ${formatStatSummary(aggregate.diffowl.precision)}`);
|
|
4812
|
+
lines.push(`- DiffOwl recall: ${formatStatSummary(aggregate.diffowl.recall)}`);
|
|
4813
|
+
lines.push(`- Repeated false-positive rate: ${formatNullableNumber(aggregate.diffowl.repeatedFpRate)}`);
|
|
4814
|
+
}
|
|
4815
|
+
if (aggregate.delta) {
|
|
4816
|
+
lines.push(`- F-beta delta vs baseline: ${formatDelta(aggregate.delta.fBeta.delta)}`);
|
|
4817
|
+
}
|
|
4818
|
+
if (document.gates) {
|
|
4819
|
+
lines.push(`- Gate check: ${document.gates.passed ? "passed" : "recorded with observations"}`);
|
|
4820
|
+
}
|
|
4821
|
+
lines.push("");
|
|
4822
|
+
lines.push("## Run Details");
|
|
4823
|
+
lines.push("");
|
|
4662
4824
|
lines.push(`- Corpus version: \`${manifest.corpus_version}\``);
|
|
4663
4825
|
lines.push(`- Mode: ${manifest.mode}`);
|
|
4664
4826
|
lines.push(`- Model: ${manifest.model}`);
|
|
@@ -4686,7 +4848,7 @@ function renderEvalSummary(document) {
|
|
|
4686
4848
|
`| Empty-on-clean rate | ${formatNullableNumber(aggregate.diffowl?.emptyOnCleanRate)} | ${formatNullableNumber(aggregate.baseline?.emptyOnCleanRate)} | n/a |`
|
|
4687
4849
|
);
|
|
4688
4850
|
lines.push(
|
|
4689
|
-
`| Latency p50 (ms) | ${formatNullableNumber(aggregate.diffowl?.latencyMs.p50)} | ${formatNullableNumber(aggregate.baseline?.latencyMs.p50)} | ${formatDelta(aggregate.delta?.latencyP50.delta)} |`
|
|
4851
|
+
`| Latency p50 (ms) | ${formatNullableNumber(aggregate.delta?.latencyP50.diffowl ?? aggregate.diffowl?.latencyMs.p50)} | ${formatNullableNumber(aggregate.delta?.latencyP50.baseline ?? aggregate.baseline?.latencyMs.p50)} | ${formatDelta(aggregate.delta?.latencyP50.delta)} |`
|
|
4690
4852
|
);
|
|
4691
4853
|
lines.push(
|
|
4692
4854
|
`| Usage mean cost | ${formatNullableNumber(aggregate.diffowl?.usage.meanCost)} | ${formatNullableNumber(aggregate.baseline?.usage.meanCost)} | ${formatDelta(aggregate.delta?.usageMeanCost.delta)} |`
|
|
@@ -4738,9 +4900,9 @@ function renderEvalSummary(document) {
|
|
|
4738
4900
|
}
|
|
4739
4901
|
lines.push("");
|
|
4740
4902
|
if (document.gates) {
|
|
4741
|
-
lines.push("##
|
|
4903
|
+
lines.push("## Gate Observations");
|
|
4742
4904
|
lines.push("");
|
|
4743
|
-
lines.push(document.gates.passed ? "Status: **passed**" : "Status: **
|
|
4905
|
+
lines.push(document.gates.passed ? "Status: **passed**" : "Status: **recorded**");
|
|
4744
4906
|
if (document.gates.failures.length > 0) {
|
|
4745
4907
|
lines.push("");
|
|
4746
4908
|
for (const failure of document.gates.failures) {
|
|
@@ -4755,13 +4917,45 @@ function renderEvalResultsJson(document) {
|
|
|
4755
4917
|
return `${JSON.stringify(document, null, 2)}
|
|
4756
4918
|
`;
|
|
4757
4919
|
}
|
|
4920
|
+
function renderEvalMetricsJson(document) {
|
|
4921
|
+
return `${JSON.stringify(toEvalMetricsDocument(document), null, 2)}
|
|
4922
|
+
`;
|
|
4923
|
+
}
|
|
4758
4924
|
async function writeEvalResults(outDir, document) {
|
|
4759
|
-
await
|
|
4760
|
-
const jsonPath =
|
|
4761
|
-
const
|
|
4762
|
-
|
|
4763
|
-
await
|
|
4764
|
-
|
|
4925
|
+
await mkdir4(outDir, { recursive: true });
|
|
4926
|
+
const jsonPath = join13(outDir, "eval-results.json");
|
|
4927
|
+
const metricsPath = join13(outDir, "eval-metrics.json");
|
|
4928
|
+
const summaryPath = join13(outDir, "eval-summary.md");
|
|
4929
|
+
await writeFile5(jsonPath, renderEvalResultsJson(document), "utf8");
|
|
4930
|
+
await writeFile5(metricsPath, renderEvalMetricsJson(document), "utf8");
|
|
4931
|
+
await writeFile5(summaryPath, renderEvalSummary(document), "utf8");
|
|
4932
|
+
return { jsonPath, metricsPath, summaryPath };
|
|
4933
|
+
}
|
|
4934
|
+
function toEvalMetricsDocument(document) {
|
|
4935
|
+
return {
|
|
4936
|
+
schema_version: document.schema_version,
|
|
4937
|
+
manifest: document.manifest,
|
|
4938
|
+
aggregate: document.aggregate,
|
|
4939
|
+
gates: document.gates,
|
|
4940
|
+
cases: document.cases.map((entry) => ({
|
|
4941
|
+
id: entry.id,
|
|
4942
|
+
category: entry.category,
|
|
4943
|
+
case_json_hash: entry.case_json_hash,
|
|
4944
|
+
patch_hash: entry.patch_hash,
|
|
4945
|
+
diffowl: entry.diffowl ? {
|
|
4946
|
+
metrics: entry.diffowl.metrics,
|
|
4947
|
+
error_count: countErrors(entry.diffowl.run.trials)
|
|
4948
|
+
} : void 0,
|
|
4949
|
+
baseline: entry.baseline ? {
|
|
4950
|
+
metrics: entry.baseline.metrics,
|
|
4951
|
+
error_count: countErrors(entry.baseline.run.trials)
|
|
4952
|
+
} : void 0,
|
|
4953
|
+
delta: entry.delta
|
|
4954
|
+
}))
|
|
4955
|
+
};
|
|
4956
|
+
}
|
|
4957
|
+
function countErrors(trials) {
|
|
4958
|
+
return trials.filter((trial) => trial.error).length;
|
|
4765
4959
|
}
|
|
4766
4960
|
function formatNullableNumber(value) {
|
|
4767
4961
|
if (value === null || value === void 0) {
|
|
@@ -4934,7 +5128,7 @@ function renderEvalComparisonSummary(comparison) {
|
|
|
4934
5128
|
for (const entry of comparison.aggregate.cases) {
|
|
4935
5129
|
lines.push(`- **${entry.caseId}** (${entry.category})`);
|
|
4936
5130
|
if (entry.recall.delta !== null) {
|
|
4937
|
-
lines.push(` -
|
|
5131
|
+
lines.push(` - Overall recall delta: ${formatSigned(entry.recall.delta)}`);
|
|
4938
5132
|
}
|
|
4939
5133
|
if (entry.regressions.length > 0) {
|
|
4940
5134
|
for (const regression of entry.regressions) {
|
|
@@ -5223,7 +5417,7 @@ async function runEvalCommand(rawOptions, dependencies = {}) {
|
|
|
5223
5417
|
const gatePassed = document.gates?.passed ?? true;
|
|
5224
5418
|
let comparison;
|
|
5225
5419
|
if (options.comparePath) {
|
|
5226
|
-
const referenceRaw = JSON.parse(await
|
|
5420
|
+
const referenceRaw = JSON.parse(await readFile7(options.comparePath, "utf8"));
|
|
5227
5421
|
const reference = parseEvalResultsDocument(referenceRaw);
|
|
5228
5422
|
comparison = compareEvalResults(reference, document);
|
|
5229
5423
|
}
|
|
@@ -5240,15 +5434,17 @@ async function runEvalCommand(rawOptions, dependencies = {}) {
|
|
|
5240
5434
|
const paths = await deps.writeResults(outDir, document);
|
|
5241
5435
|
if (comparison) {
|
|
5242
5436
|
const comparisonSummary = renderEvalComparisonSummary(comparison);
|
|
5243
|
-
await
|
|
5437
|
+
await writeFile6(join14(outDir, "eval-comparison.md"), comparisonSummary, "utf8");
|
|
5244
5438
|
}
|
|
5245
5439
|
spinner?.succeed(`Eval complete (${cases.length} case${cases.length === 1 ? "" : "s"})`);
|
|
5246
|
-
deps.stdoutWrite(`${chalk2.dim("
|
|
5440
|
+
deps.stdoutWrite(`${chalk2.dim("Metrics:")} ${paths.metricsPath}
|
|
5441
|
+
`);
|
|
5442
|
+
deps.stdoutWrite(`${chalk2.dim("Full report:")} ${paths.jsonPath}
|
|
5247
5443
|
`);
|
|
5248
5444
|
deps.stdoutWrite(`${chalk2.dim("Summary:")} ${paths.summaryPath}
|
|
5249
5445
|
`);
|
|
5250
5446
|
if (comparison) {
|
|
5251
|
-
deps.stdoutWrite(`${chalk2.dim("Comparison:")} ${
|
|
5447
|
+
deps.stdoutWrite(`${chalk2.dim("Comparison:")} ${join14(outDir, "eval-comparison.md")}
|
|
5252
5448
|
`);
|
|
5253
5449
|
if (comparison.hasRegressions) {
|
|
5254
5450
|
deps.stdoutWrite(chalk2.yellow("Regressions detected vs baseline\n"));
|
|
@@ -5303,7 +5499,7 @@ async function runEvalCases(cases, options, runnerOptions, deps, spinner) {
|
|
|
5303
5499
|
return bundles;
|
|
5304
5500
|
}
|
|
5305
5501
|
async function loadEvalGateThresholds(gatePath) {
|
|
5306
|
-
const raw = JSON.parse(await
|
|
5502
|
+
const raw = JSON.parse(await readFile7(gatePath, "utf8"));
|
|
5307
5503
|
return parseEvalGateThresholds(raw);
|
|
5308
5504
|
}
|
|
5309
5505
|
function failEval(deps, format, error) {
|
|
@@ -5336,7 +5532,7 @@ function selectModel(models, currentModel, answer, allowKeepCurrent) {
|
|
|
5336
5532
|
}
|
|
5337
5533
|
|
|
5338
5534
|
// src/git/hooks.ts
|
|
5339
|
-
import { appendFile, chmod, mkdir as
|
|
5535
|
+
import { appendFile, chmod, mkdir as mkdir5, readFile as readFile9, readdir as readdir2, unlink as unlink3, writeFile as writeFile8 } from "fs/promises";
|
|
5340
5536
|
import {
|
|
5341
5537
|
closeSync,
|
|
5342
5538
|
existsSync as existsSync5,
|
|
@@ -5346,19 +5542,19 @@ import {
|
|
|
5346
5542
|
writeFileSync,
|
|
5347
5543
|
writeSync
|
|
5348
5544
|
} from "fs";
|
|
5349
|
-
import { basename as basename6, dirname as dirname4, join as
|
|
5545
|
+
import { basename as basename6, dirname as dirname4, join as join15 } from "path";
|
|
5350
5546
|
import { fileURLToPath } from "url";
|
|
5351
5547
|
import { execa as execa7 } from "execa";
|
|
5352
|
-
import { z as
|
|
5548
|
+
import { z as z15 } from "zod";
|
|
5353
5549
|
|
|
5354
5550
|
// src/review/retention.ts
|
|
5355
|
-
import { readFile as
|
|
5551
|
+
import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
|
|
5356
5552
|
async function trimHookLog(logFile, maxBytes) {
|
|
5357
5553
|
if (maxBytes === 0) return;
|
|
5358
5554
|
try {
|
|
5359
|
-
const content = await
|
|
5555
|
+
const content = await readFile8(logFile);
|
|
5360
5556
|
if (content.length <= maxBytes) return;
|
|
5361
|
-
await
|
|
5557
|
+
await writeFile7(logFile, content.subarray(content.length - maxBytes));
|
|
5362
5558
|
} catch {
|
|
5363
5559
|
}
|
|
5364
5560
|
}
|
|
@@ -5386,24 +5582,24 @@ async function getHooksDir() {
|
|
|
5386
5582
|
async function getHookPath() {
|
|
5387
5583
|
const hooksDir = await getHooksDir();
|
|
5388
5584
|
if (basename6(hooksDir) === "_" && basename6(dirname4(hooksDir)) === ".husky") {
|
|
5389
|
-
return
|
|
5585
|
+
return join15(dirname4(hooksDir), "post-commit");
|
|
5390
5586
|
}
|
|
5391
|
-
return
|
|
5587
|
+
return join15(hooksDir, "post-commit");
|
|
5392
5588
|
}
|
|
5393
5589
|
async function installHook() {
|
|
5394
5590
|
const hookPath = await getHookPath();
|
|
5395
|
-
await
|
|
5591
|
+
await mkdir5(dirname4(hookPath), { recursive: true });
|
|
5396
5592
|
const command = await resolveHookCommand();
|
|
5397
5593
|
if (existsSync5(hookPath)) {
|
|
5398
|
-
const existing = await
|
|
5594
|
+
const existing = await readFile9(hookPath, "utf-8");
|
|
5399
5595
|
const base = existing.includes(HOOK_MARKER) ? removeManagedSection(existing) : existing.trimEnd();
|
|
5400
5596
|
const hookSection = generateManagedSection(command);
|
|
5401
5597
|
const updated = base && !isOnlyShebangs(base) ? `${base}
|
|
5402
5598
|
|
|
5403
5599
|
${hookSection}` : generateHookScript(command);
|
|
5404
|
-
await
|
|
5600
|
+
await writeFile8(hookPath, updated, "utf-8");
|
|
5405
5601
|
} else {
|
|
5406
|
-
await
|
|
5602
|
+
await writeFile8(hookPath, generateHookScript(command), "utf-8");
|
|
5407
5603
|
}
|
|
5408
5604
|
await chmod(hookPath, 493);
|
|
5409
5605
|
return hookPath;
|
|
@@ -5411,43 +5607,43 @@ ${hookSection}` : generateHookScript(command);
|
|
|
5411
5607
|
async function uninstallHook() {
|
|
5412
5608
|
const hookPath = await getHookPath();
|
|
5413
5609
|
if (!existsSync5(hookPath)) return false;
|
|
5414
|
-
const content = await
|
|
5610
|
+
const content = await readFile9(hookPath, "utf-8");
|
|
5415
5611
|
if (!content.includes(HOOK_MARKER)) return false;
|
|
5416
5612
|
const cleaned = removeManagedSection(content);
|
|
5417
5613
|
if (isOnlyShebangs(cleaned) || cleaned === "") {
|
|
5418
|
-
await
|
|
5614
|
+
await unlink3(hookPath);
|
|
5419
5615
|
} else {
|
|
5420
|
-
await
|
|
5616
|
+
await writeFile8(hookPath, cleaned + "\n", "utf-8");
|
|
5421
5617
|
}
|
|
5422
5618
|
return true;
|
|
5423
5619
|
}
|
|
5424
5620
|
async function isHookInstalled() {
|
|
5425
5621
|
const hookPath = await getHookPath();
|
|
5426
5622
|
if (!existsSync5(hookPath)) return false;
|
|
5427
|
-
const content = await
|
|
5623
|
+
const content = await readFile9(hookPath, "utf-8");
|
|
5428
5624
|
return content.includes(HOOK_MARKER);
|
|
5429
5625
|
}
|
|
5430
|
-
var HookFailureSchema =
|
|
5431
|
-
commit:
|
|
5432
|
-
exitCode:
|
|
5433
|
-
timestamp:
|
|
5434
|
-
message:
|
|
5626
|
+
var HookFailureSchema = z15.object({
|
|
5627
|
+
commit: z15.string().min(1).optional(),
|
|
5628
|
+
exitCode: z15.number().int(),
|
|
5629
|
+
timestamp: z15.string(),
|
|
5630
|
+
message: z15.string().optional()
|
|
5435
5631
|
});
|
|
5436
5632
|
async function checkRecentHookFailure() {
|
|
5437
5633
|
const dir = getDiffOwlDir();
|
|
5438
5634
|
const pending = await listPendingReviews(dir);
|
|
5439
5635
|
for (const item of pending) {
|
|
5440
|
-
const result = await readHookResult(
|
|
5636
|
+
const result = await readHookResult(join15(dir, "pending-reviews", `${item.sha}.result.json`));
|
|
5441
5637
|
if (result && result.exitCode !== 0 && result.message !== "Review started.") {
|
|
5442
5638
|
return result;
|
|
5443
5639
|
}
|
|
5444
5640
|
}
|
|
5445
|
-
const statusPath =
|
|
5641
|
+
const statusPath = join15(dir, "last-hook-status.json");
|
|
5446
5642
|
if (!existsSync5(statusPath)) {
|
|
5447
5643
|
return void 0;
|
|
5448
5644
|
}
|
|
5449
5645
|
try {
|
|
5450
|
-
const raw = await
|
|
5646
|
+
const raw = await readFile9(statusPath, "utf-8");
|
|
5451
5647
|
const parsed = HookFailureSchema.safeParse(JSON.parse(raw));
|
|
5452
5648
|
if (!parsed.success) return void 0;
|
|
5453
5649
|
const { commit, exitCode, timestamp, message } = parsed.data;
|
|
@@ -5483,18 +5679,18 @@ async function writeHookStatus(exitCode, commit, message, resultPath = process.e
|
|
|
5483
5679
|
2
|
|
5484
5680
|
);
|
|
5485
5681
|
if (resultPath) {
|
|
5486
|
-
await
|
|
5682
|
+
await writeFile8(resultPath, content, "utf-8");
|
|
5487
5683
|
return;
|
|
5488
5684
|
}
|
|
5489
|
-
await
|
|
5685
|
+
await writeFile8(join15(statusDir, "last-hook-status.json"), content, "utf-8");
|
|
5490
5686
|
} catch {
|
|
5491
5687
|
}
|
|
5492
5688
|
}
|
|
5493
5689
|
async function clearHookFailure(dir, commit) {
|
|
5494
|
-
const statusPath =
|
|
5690
|
+
const statusPath = join15(dir, "last-hook-status.json");
|
|
5495
5691
|
const status = await readHookResult(statusPath);
|
|
5496
5692
|
if (status?.commit !== commit || status.exitCode === 0) return;
|
|
5497
|
-
await
|
|
5693
|
+
await unlink3(statusPath).catch(() => {
|
|
5498
5694
|
});
|
|
5499
5695
|
}
|
|
5500
5696
|
function formatHookFailure(failure) {
|
|
@@ -5527,9 +5723,9 @@ function isHookQueueStopFailure(message) {
|
|
|
5527
5723
|
}
|
|
5528
5724
|
async function runHookReview() {
|
|
5529
5725
|
const dir = await ensureDiffOwlDir();
|
|
5530
|
-
const logFile =
|
|
5531
|
-
const latestReport =
|
|
5532
|
-
const lockFile =
|
|
5726
|
+
const logFile = join15(dir, "hook.log");
|
|
5727
|
+
const latestReport = join15(await getSharedDiffOwlDir(), "reviews", "latest.md");
|
|
5728
|
+
const lockFile = join15(dir, "hook-review.lock");
|
|
5533
5729
|
const commit = await getHeadCommit();
|
|
5534
5730
|
await enqueuePendingReview(dir, commit);
|
|
5535
5731
|
if (!acquireHookReviewLock(lockFile)) {
|
|
@@ -5578,7 +5774,7 @@ async function runHookReview() {
|
|
|
5578
5774
|
}
|
|
5579
5775
|
async function runPendingHookReviews() {
|
|
5580
5776
|
const dir = await ensureDiffOwlDir();
|
|
5581
|
-
const logFile =
|
|
5777
|
+
const logFile = join15(dir, "hook.log");
|
|
5582
5778
|
const cli = fileURLToPath(import.meta.url);
|
|
5583
5779
|
const attempted = /* @__PURE__ */ new Set();
|
|
5584
5780
|
while (true) {
|
|
@@ -5587,12 +5783,12 @@ async function runPendingHookReviews() {
|
|
|
5587
5783
|
if (!next) return;
|
|
5588
5784
|
attempted.add(next.sha);
|
|
5589
5785
|
const outFd = openSync(logFile, "a");
|
|
5590
|
-
const resultPath =
|
|
5786
|
+
const resultPath = join15(dir, "pending-reviews", `${next.sha}.result.json`);
|
|
5591
5787
|
try {
|
|
5592
5788
|
writeSync(outFd, `diffowl: reviewing queued commit ${next.sha}
|
|
5593
5789
|
`);
|
|
5594
5790
|
try {
|
|
5595
|
-
await
|
|
5791
|
+
await unlink3(resultPath);
|
|
5596
5792
|
} catch {
|
|
5597
5793
|
}
|
|
5598
5794
|
const env = { ...process.env };
|
|
@@ -5633,7 +5829,7 @@ async function runPendingHookReviews() {
|
|
|
5633
5829
|
continue;
|
|
5634
5830
|
}
|
|
5635
5831
|
try {
|
|
5636
|
-
await
|
|
5832
|
+
await unlink3(next.path);
|
|
5637
5833
|
} catch (error) {
|
|
5638
5834
|
await appendFile(
|
|
5639
5835
|
logFile,
|
|
@@ -5645,25 +5841,25 @@ async function runPendingHookReviews() {
|
|
|
5645
5841
|
continue;
|
|
5646
5842
|
}
|
|
5647
5843
|
try {
|
|
5648
|
-
await
|
|
5844
|
+
await unlink3(resultPath);
|
|
5649
5845
|
} catch {
|
|
5650
5846
|
}
|
|
5651
5847
|
await clearHookFailure(dir, next.sha);
|
|
5652
5848
|
}
|
|
5653
5849
|
}
|
|
5654
5850
|
async function enqueuePendingReview(dir, sha) {
|
|
5655
|
-
const pendingDir =
|
|
5656
|
-
await
|
|
5657
|
-
const marker =
|
|
5851
|
+
const pendingDir = join15(dir, "pending-reviews");
|
|
5852
|
+
await mkdir5(pendingDir, { recursive: true });
|
|
5853
|
+
const marker = join15(pendingDir, sha);
|
|
5658
5854
|
if (existsSync5(marker)) return;
|
|
5659
|
-
await
|
|
5855
|
+
await writeFile8(
|
|
5660
5856
|
marker,
|
|
5661
5857
|
JSON.stringify({ sha, queuedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
5662
5858
|
"utf-8"
|
|
5663
5859
|
);
|
|
5664
5860
|
}
|
|
5665
5861
|
async function listPendingReviews(dir) {
|
|
5666
|
-
const pendingDir =
|
|
5862
|
+
const pendingDir = join15(dir, "pending-reviews");
|
|
5667
5863
|
let files;
|
|
5668
5864
|
try {
|
|
5669
5865
|
files = await readdir2(pendingDir);
|
|
@@ -5672,14 +5868,14 @@ async function listPendingReviews(dir) {
|
|
|
5672
5868
|
}
|
|
5673
5869
|
const markerFiles = new Set(files.filter((file) => !file.endsWith(".result.json")));
|
|
5674
5870
|
await Promise.all(
|
|
5675
|
-
files.filter((file) => file.endsWith(".result.json")).filter((file) => !markerFiles.has(file.slice(0, -".result.json".length))).map((file) =>
|
|
5871
|
+
files.filter((file) => file.endsWith(".result.json")).filter((file) => !markerFiles.has(file.slice(0, -".result.json".length))).map((file) => unlink3(join15(pendingDir, file)).catch(() => {
|
|
5676
5872
|
}))
|
|
5677
5873
|
);
|
|
5678
5874
|
const pending = await Promise.all(
|
|
5679
5875
|
[...markerFiles].map(async (file) => {
|
|
5680
|
-
const path =
|
|
5876
|
+
const path = join15(pendingDir, file);
|
|
5681
5877
|
try {
|
|
5682
|
-
const parsed = JSON.parse(await
|
|
5878
|
+
const parsed = JSON.parse(await readFile9(path, "utf-8"));
|
|
5683
5879
|
if (typeof parsed.sha !== "string" || typeof parsed.queuedAt !== "string") {
|
|
5684
5880
|
return void 0;
|
|
5685
5881
|
}
|
|
@@ -5697,7 +5893,7 @@ async function getHeadCommit() {
|
|
|
5697
5893
|
}
|
|
5698
5894
|
async function readHookResult(path) {
|
|
5699
5895
|
try {
|
|
5700
|
-
const parsed = HookFailureSchema.safeParse(JSON.parse(await
|
|
5896
|
+
const parsed = HookFailureSchema.safeParse(JSON.parse(await readFile9(path, "utf-8")));
|
|
5701
5897
|
if (!parsed.success) return void 0;
|
|
5702
5898
|
return {
|
|
5703
5899
|
...parsed.data.commit ? { commit: parsed.data.commit } : {},
|
|
@@ -5766,7 +5962,7 @@ async function checkHookStale() {
|
|
|
5766
5962
|
}
|
|
5767
5963
|
let content;
|
|
5768
5964
|
try {
|
|
5769
|
-
content = await
|
|
5965
|
+
content = await readFile9(hookPath, "utf-8");
|
|
5770
5966
|
} catch (err) {
|
|
5771
5967
|
const message = err instanceof Error ? err.message : String(err);
|
|
5772
5968
|
return { installed: true, stale: false, reason: `Cannot read hook file: ${message}` };
|
|
@@ -5908,20 +6104,20 @@ function shellQuote(value) {
|
|
|
5908
6104
|
}
|
|
5909
6105
|
|
|
5910
6106
|
// src/review/report-path.ts
|
|
5911
|
-
import { readFile as
|
|
5912
|
-
import { basename as basename7, isAbsolute as
|
|
6107
|
+
import { readFile as readFile10, readdir as readdir3 } from "fs/promises";
|
|
6108
|
+
import { basename as basename7, isAbsolute as isAbsolute3, join as join16, resolve as resolve2 } from "path";
|
|
5913
6109
|
async function resolveReviewReportPath(report) {
|
|
5914
|
-
if (
|
|
6110
|
+
if (isAbsolute3(report)) return report;
|
|
5915
6111
|
if (report.includes("/") || report.includes("\\")) {
|
|
5916
6112
|
return resolve2(report);
|
|
5917
6113
|
}
|
|
5918
|
-
return
|
|
6114
|
+
return join16(await getSharedDiffOwlDir(), "reviews", report);
|
|
5919
6115
|
}
|
|
5920
6116
|
async function listReviewReportPaths() {
|
|
5921
|
-
const reviews =
|
|
6117
|
+
const reviews = join16(await getSharedDiffOwlDir(), "reviews");
|
|
5922
6118
|
const entries = await Promise.all([
|
|
5923
6119
|
listMarkdownFiles(reviews),
|
|
5924
|
-
listMarkdownFiles(
|
|
6120
|
+
listMarkdownFiles(join16(reviews, "resolved"))
|
|
5925
6121
|
]);
|
|
5926
6122
|
return entries.flat().filter((path) => basename7(path) !== "latest.md").sort((a, b) => basename7(b).localeCompare(basename7(a)));
|
|
5927
6123
|
}
|
|
@@ -5936,14 +6132,14 @@ function selectReviewReportPath(paths, answer) {
|
|
|
5936
6132
|
async function listMarkdownFiles(dir) {
|
|
5937
6133
|
let paths;
|
|
5938
6134
|
try {
|
|
5939
|
-
paths = (await readdir3(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) =>
|
|
6135
|
+
paths = (await readdir3(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => join16(dir, entry.name));
|
|
5940
6136
|
} catch {
|
|
5941
6137
|
return [];
|
|
5942
6138
|
}
|
|
5943
6139
|
const reports = await Promise.all(
|
|
5944
6140
|
paths.map(async (path) => {
|
|
5945
6141
|
try {
|
|
5946
|
-
return parseReviewMetadata(await
|
|
6142
|
+
return parseReviewMetadata(await readFile10(path, "utf-8")) ? path : void 0;
|
|
5947
6143
|
} catch {
|
|
5948
6144
|
return void 0;
|
|
5949
6145
|
}
|
|
@@ -5956,8 +6152,8 @@ async function listMarkdownFiles(dir) {
|
|
|
5956
6152
|
import { createHash as createHash3 } from "crypto";
|
|
5957
6153
|
|
|
5958
6154
|
// src/state/db.ts
|
|
5959
|
-
import { mkdir as
|
|
5960
|
-
import { join as
|
|
6155
|
+
import { mkdir as mkdir6 } from "fs/promises";
|
|
6156
|
+
import { join as join17 } from "path";
|
|
5961
6157
|
|
|
5962
6158
|
// src/state/migrations/001-initial-schema.ts
|
|
5963
6159
|
var MIGRATION_001_INITIAL_SCHEMA = `
|
|
@@ -6029,6 +6225,36 @@ CREATE TABLE finding_events (
|
|
|
6029
6225
|
CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
|
|
6030
6226
|
`;
|
|
6031
6227
|
|
|
6228
|
+
// src/state/migrations/002-base-review-target.ts
|
|
6229
|
+
var MIGRATION_002_BASE_REVIEW_TARGET = `
|
|
6230
|
+
PRAGMA legacy_alter_table = ON;
|
|
6231
|
+
|
|
6232
|
+
ALTER TABLE reviews RENAME TO reviews_old;
|
|
6233
|
+
|
|
6234
|
+
CREATE TABLE reviews (
|
|
6235
|
+
id TEXT PRIMARY KEY,
|
|
6236
|
+
created_at TEXT NOT NULL,
|
|
6237
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('staged', 'commit', 'last-commit', 'base')),
|
|
6238
|
+
target_ref TEXT,
|
|
6239
|
+
target_commit TEXT,
|
|
6240
|
+
diff_hash TEXT NOT NULL,
|
|
6241
|
+
model TEXT NOT NULL,
|
|
6242
|
+
reasoning TEXT NOT NULL,
|
|
6243
|
+
depth TEXT NOT NULL,
|
|
6244
|
+
session_id TEXT NOT NULL,
|
|
6245
|
+
summary TEXT NOT NULL,
|
|
6246
|
+
report_path TEXT,
|
|
6247
|
+
diagnostics_json TEXT NOT NULL DEFAULT '[]',
|
|
6248
|
+
timings_json TEXT NOT NULL DEFAULT '[]',
|
|
6249
|
+
skipped_reason TEXT
|
|
6250
|
+
);
|
|
6251
|
+
|
|
6252
|
+
INSERT INTO reviews SELECT * FROM reviews_old;
|
|
6253
|
+
DROP TABLE reviews_old;
|
|
6254
|
+
|
|
6255
|
+
PRAGMA legacy_alter_table = OFF;
|
|
6256
|
+
`;
|
|
6257
|
+
|
|
6032
6258
|
// src/state/sqlite.ts
|
|
6033
6259
|
var sqliteModule;
|
|
6034
6260
|
async function openSqliteDatabase(path) {
|
|
@@ -6141,19 +6367,20 @@ function normalizeRow(row) {
|
|
|
6141
6367
|
}
|
|
6142
6368
|
|
|
6143
6369
|
// src/state/types.ts
|
|
6144
|
-
import { randomUUID } from "crypto";
|
|
6145
|
-
var CURRENT_SCHEMA_VERSION =
|
|
6370
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6371
|
+
var CURRENT_SCHEMA_VERSION = 2;
|
|
6146
6372
|
function createReviewId() {
|
|
6147
|
-
return `rev_${
|
|
6373
|
+
return `rev_${randomUUID2()}`;
|
|
6148
6374
|
}
|
|
6149
6375
|
function createFindingId() {
|
|
6150
|
-
return `fnd_${
|
|
6376
|
+
return `fnd_${randomUUID2()}`;
|
|
6151
6377
|
}
|
|
6152
6378
|
|
|
6153
6379
|
// src/state/db.ts
|
|
6154
6380
|
var BUSY_TIMEOUT_MS = 5e3;
|
|
6155
6381
|
var MIGRATIONS = {
|
|
6156
|
-
1: MIGRATION_001_INITIAL_SCHEMA
|
|
6382
|
+
1: MIGRATION_001_INITIAL_SCHEMA,
|
|
6383
|
+
2: MIGRATION_002_BASE_REVIEW_TARGET
|
|
6157
6384
|
};
|
|
6158
6385
|
var StateDatabaseError = class extends Error {
|
|
6159
6386
|
name = "StateDatabaseError";
|
|
@@ -6162,10 +6389,10 @@ var InvalidFindingTransitionError = class extends StateDatabaseError {
|
|
|
6162
6389
|
name = "InvalidFindingTransitionError";
|
|
6163
6390
|
};
|
|
6164
6391
|
function getStateDbPath(diffOwlDir) {
|
|
6165
|
-
return
|
|
6392
|
+
return join17(diffOwlDir, "state.db");
|
|
6166
6393
|
}
|
|
6167
6394
|
async function openStateDatabase(diffOwlDir) {
|
|
6168
|
-
await
|
|
6395
|
+
await mkdir6(diffOwlDir, { recursive: true });
|
|
6169
6396
|
const path = getStateDbPath(diffOwlDir);
|
|
6170
6397
|
const db = await openSqliteDatabase(path);
|
|
6171
6398
|
try {
|
|
@@ -6225,12 +6452,22 @@ function applyMigrations(db, targetVersion, migrations = MIGRATIONS) {
|
|
|
6225
6452
|
}
|
|
6226
6453
|
const migrate = db.transaction(() => {
|
|
6227
6454
|
db.exec(sql);
|
|
6455
|
+
const violations = db.pragma("foreign_key_check");
|
|
6456
|
+
if (violations.length > 0) {
|
|
6457
|
+
throw new StateDatabaseError(`Migration ${version} introduced foreign key violations`);
|
|
6458
|
+
}
|
|
6228
6459
|
db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(
|
|
6229
6460
|
version,
|
|
6230
6461
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
6231
6462
|
);
|
|
6232
6463
|
});
|
|
6233
|
-
|
|
6464
|
+
const foreignKeysEnabled = db.pragma("foreign_keys", { simple: true }) === 1;
|
|
6465
|
+
if (foreignKeysEnabled) db.pragma("foreign_keys = OFF");
|
|
6466
|
+
try {
|
|
6467
|
+
migrate();
|
|
6468
|
+
} finally {
|
|
6469
|
+
if (foreignKeysEnabled) db.pragma("foreign_keys = ON");
|
|
6470
|
+
}
|
|
6234
6471
|
}
|
|
6235
6472
|
}
|
|
6236
6473
|
function listAppliedMigrationVersions(db) {
|
|
@@ -7029,6 +7266,8 @@ function mapReviewTarget(target) {
|
|
|
7029
7266
|
return { targetKind: "last-commit", targetRef: null };
|
|
7030
7267
|
case "commit":
|
|
7031
7268
|
return { targetKind: "commit", targetRef: target.ref ?? null };
|
|
7269
|
+
case "base":
|
|
7270
|
+
return { targetKind: "base", targetRef: target.ref ?? null };
|
|
7032
7271
|
}
|
|
7033
7272
|
}
|
|
7034
7273
|
|
|
@@ -7048,9 +7287,11 @@ function buildReviewJsonDocument(input) {
|
|
|
7048
7287
|
input.persisted.reconcile.observations,
|
|
7049
7288
|
input.verbose
|
|
7050
7289
|
);
|
|
7051
|
-
const
|
|
7052
|
-
|
|
7290
|
+
const unsuppressed = input.persisted.reconcile.observations.filter((item) => !item.suppressed);
|
|
7291
|
+
const actionableCount = unsuppressed.filter(
|
|
7292
|
+
(item) => item.observation.severity !== "info"
|
|
7053
7293
|
).length;
|
|
7294
|
+
const advisoryCount = unsuppressed.filter((item) => item.observation.severity === "info").length;
|
|
7054
7295
|
return {
|
|
7055
7296
|
schema_version: JSON_OUTPUT_SCHEMA_VERSION,
|
|
7056
7297
|
review: {
|
|
@@ -7066,7 +7307,7 @@ function buildReviewJsonDocument(input) {
|
|
|
7066
7307
|
depth: input.review.depth,
|
|
7067
7308
|
session_id: input.review.sessionId,
|
|
7068
7309
|
summary: input.review.summary,
|
|
7069
|
-
status: resolveReviewJsonStatus(input.review, actionableCount),
|
|
7310
|
+
status: resolveReviewJsonStatus(input.review, actionableCount, advisoryCount),
|
|
7070
7311
|
report_path: input.review.reportPath,
|
|
7071
7312
|
skipped_reason: input.review.skippedReason
|
|
7072
7313
|
},
|
|
@@ -7105,11 +7346,17 @@ function selectJsonObservations(observations, verbose = false) {
|
|
|
7105
7346
|
}
|
|
7106
7347
|
return observations.filter((item) => !item.suppressed);
|
|
7107
7348
|
}
|
|
7108
|
-
function resolveReviewJsonStatus(review, actionableCount) {
|
|
7349
|
+
function resolveReviewJsonStatus(review, actionableCount, advisoryCount) {
|
|
7109
7350
|
if (review.skippedReason) {
|
|
7110
7351
|
return "skipped";
|
|
7111
7352
|
}
|
|
7112
|
-
|
|
7353
|
+
if (actionableCount > 0) {
|
|
7354
|
+
return "open";
|
|
7355
|
+
}
|
|
7356
|
+
if (advisoryCount > 0) {
|
|
7357
|
+
return "advisory";
|
|
7358
|
+
}
|
|
7359
|
+
return "resolved";
|
|
7113
7360
|
}
|
|
7114
7361
|
function mapJsonFinding(item, occurrenceCounts) {
|
|
7115
7362
|
const { observation, finding, fingerprint, suppressed } = item;
|
|
@@ -7232,6 +7479,14 @@ function renderFindingDetailJson(detail) {
|
|
|
7232
7479
|
return `${JSON.stringify(detail, null, 2)}
|
|
7233
7480
|
`;
|
|
7234
7481
|
}
|
|
7482
|
+
function renderFindingListJson(items) {
|
|
7483
|
+
return `${JSON.stringify(
|
|
7484
|
+
{ schema_version: 1, count: items.length, findings: items },
|
|
7485
|
+
null,
|
|
7486
|
+
2
|
|
7487
|
+
)}
|
|
7488
|
+
`;
|
|
7489
|
+
}
|
|
7235
7490
|
function computeListLayout(columns) {
|
|
7236
7491
|
const fixedWidth = fixedPrefixWidth();
|
|
7237
7492
|
const flexibleWidth = Math.max(
|
|
@@ -7539,15 +7794,245 @@ function toFindingDetail(db, finding) {
|
|
|
7539
7794
|
};
|
|
7540
7795
|
}
|
|
7541
7796
|
|
|
7797
|
+
// src/review/run.ts
|
|
7798
|
+
var defaultReviewPipelineDeps = {
|
|
7799
|
+
buildReviewContextFromDiff,
|
|
7800
|
+
computeDiffHash,
|
|
7801
|
+
enrichReviewFindingsWithDurableMetadata,
|
|
7802
|
+
ensureServer,
|
|
7803
|
+
filterFindingsByChangedFiles,
|
|
7804
|
+
filterFindingsByConfidence,
|
|
7805
|
+
formatExcludedCandidateSummary,
|
|
7806
|
+
formatLifecycleSuppressedSummary,
|
|
7807
|
+
isServerRunning,
|
|
7808
|
+
loadFindingOccurrenceCounts,
|
|
7809
|
+
loadReviewSnapshot,
|
|
7810
|
+
mapReviewTarget,
|
|
7811
|
+
persistReviewRun,
|
|
7812
|
+
renderMarkdown,
|
|
7813
|
+
renderReviewContext,
|
|
7814
|
+
resolveTargetCommit,
|
|
7815
|
+
runReview,
|
|
7816
|
+
updatePersistedReview,
|
|
7817
|
+
writeMarkdownReport
|
|
7818
|
+
};
|
|
7819
|
+
async function runReviewPipeline(input, deps = defaultReviewPipelineDeps) {
|
|
7820
|
+
const outcome = await runReviewSkipChecks(input, deps);
|
|
7821
|
+
if (outcome.kind !== "continue") {
|
|
7822
|
+
return outcome;
|
|
7823
|
+
}
|
|
7824
|
+
const { snapshot, timings } = outcome;
|
|
7825
|
+
const { diff } = snapshot;
|
|
7826
|
+
const contextStart = performance.now();
|
|
7827
|
+
const reviewContext = await deps.buildReviewContextFromDiff(snapshot, input.config, input.depth);
|
|
7828
|
+
recordReviewTiming(timings, "context-build", "Local review context build", contextStart);
|
|
7829
|
+
const contextRenderStart = performance.now();
|
|
7830
|
+
const localContext = deps.renderReviewContext(reviewContext, { depth: input.depth });
|
|
7831
|
+
recordReviewTiming(timings, "context-render", "Local review context render", contextRenderStart);
|
|
7832
|
+
if (reviewContext.diagnostics.length > 0) {
|
|
7833
|
+
input.onDiagnostics?.(reviewContext.diagnostics);
|
|
7834
|
+
}
|
|
7835
|
+
const serverStart = performance.now();
|
|
7836
|
+
input.onStatus?.("Connecting to OpenCode...");
|
|
7837
|
+
await prepareReviewServer2(input.config, deps);
|
|
7838
|
+
recordReviewTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
|
|
7839
|
+
const reviewStart = performance.now();
|
|
7840
|
+
input.onStatus?.("Reviewing changes...");
|
|
7841
|
+
const reviewResult = await deps.runReview({
|
|
7842
|
+
target: snapshot.target,
|
|
7843
|
+
directory: input.projectRoot,
|
|
7844
|
+
config: input.config,
|
|
7845
|
+
localContext,
|
|
7846
|
+
depth: input.depth,
|
|
7847
|
+
...input.signal ? { signal: input.signal } : {},
|
|
7848
|
+
...input.onProgress ? { onProgress: input.onProgress } : {}
|
|
7849
|
+
});
|
|
7850
|
+
const report = reviewResult.report;
|
|
7851
|
+
recordReviewTiming(timings, "review-run", "OpenCode review run", reviewStart);
|
|
7852
|
+
const diagnostics = report.diagnostics ?? [];
|
|
7853
|
+
const confidenceFilter = deps.filterFindingsByConfidence(report.findings, input.config.min_confidence);
|
|
7854
|
+
report.findings = confidenceFilter.findings;
|
|
7855
|
+
const changedFilesSet = new Set(reviewContext.changedFiles.map((file) => file.file.path));
|
|
7856
|
+
const changedFileFilter = deps.filterFindingsByChangedFiles(report.findings, changedFilesSet);
|
|
7857
|
+
report.findings = changedFileFilter.findings;
|
|
7858
|
+
if (changedFileFilter.suppressed.length > 0 && input.verbose) {
|
|
7859
|
+
report.suppressedFindings = changedFileFilter.suppressed;
|
|
7860
|
+
}
|
|
7861
|
+
if (confidenceFilter.dropped > 0 || changedFileFilter.suppressed.length > 0) {
|
|
7862
|
+
diagnostics.push(deps.formatExcludedCandidateSummary(
|
|
7863
|
+
confidenceFilter.dropped,
|
|
7864
|
+
changedFileFilter.suppressed.length
|
|
7865
|
+
));
|
|
7866
|
+
}
|
|
7867
|
+
if (diagnostics.length > 0) {
|
|
7868
|
+
report.diagnostics = diagnostics;
|
|
7869
|
+
}
|
|
7870
|
+
const persistStart = performance.now();
|
|
7871
|
+
const persisted = await deps.persistReviewRun(input.diffOwlDir, {
|
|
7872
|
+
...deps.mapReviewTarget(snapshot.target),
|
|
7873
|
+
targetCommit: snapshot.targetCommit,
|
|
7874
|
+
diffHash: deps.computeDiffHash(diff.raw),
|
|
7875
|
+
model: input.config.model,
|
|
7876
|
+
reasoning: input.config.reasoning.effort,
|
|
7877
|
+
depth: input.depth,
|
|
7878
|
+
sessionId: reviewResult.sessionId,
|
|
7879
|
+
summary: report.summary,
|
|
7880
|
+
diagnostics,
|
|
7881
|
+
timings: [...timings, ...report.timings ?? []],
|
|
7882
|
+
findings: report.findings
|
|
7883
|
+
});
|
|
7884
|
+
recordReviewTiming(timings, "persist-state", "Persist review state", persistStart);
|
|
7885
|
+
report.findings = persisted.actionableFindings;
|
|
7886
|
+
const lifecycleSummary = deps.formatLifecycleSuppressedSummary(persisted.reconcile.suppressedCounts);
|
|
7887
|
+
if (lifecycleSummary) {
|
|
7888
|
+
diagnostics.push(lifecycleSummary);
|
|
7889
|
+
report.diagnostics = diagnostics;
|
|
7890
|
+
}
|
|
7891
|
+
if (input.verbose && persisted.lifecycleSuppressedFindings.length > 0) {
|
|
7892
|
+
report.suppressedFindings = [
|
|
7893
|
+
...report.suppressedFindings ?? [],
|
|
7894
|
+
...persisted.lifecycleSuppressedFindings
|
|
7895
|
+
];
|
|
7896
|
+
}
|
|
7897
|
+
report.findings = deps.enrichReviewFindingsWithDurableMetadata(report.findings, persisted.reconcile);
|
|
7898
|
+
if (report.suppressedFindings) {
|
|
7899
|
+
report.suppressedFindings = deps.enrichReviewFindingsWithDurableMetadata(report.suppressedFindings, persisted.reconcile);
|
|
7900
|
+
}
|
|
7901
|
+
const renderStart = performance.now();
|
|
7902
|
+
const markdown = deps.renderMarkdown(report);
|
|
7903
|
+
recordReviewTiming(timings, "render-report", "Markdown render", renderStart);
|
|
7904
|
+
const writeStart = performance.now();
|
|
7905
|
+
let reportPath;
|
|
7906
|
+
try {
|
|
7907
|
+
reportPath = await deps.writeMarkdownReport(markdown, {
|
|
7908
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
7909
|
+
review_id: persisted.reviewId,
|
|
7910
|
+
session_id: reviewResult.sessionId,
|
|
7911
|
+
project_root: input.projectRoot
|
|
7912
|
+
});
|
|
7913
|
+
await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath, diagnostics });
|
|
7914
|
+
} catch (err) {
|
|
7915
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7916
|
+
diagnostics.push(`Report write failed: ${message}`);
|
|
7917
|
+
report.diagnostics = diagnostics;
|
|
7918
|
+
await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath: null, diagnostics });
|
|
7919
|
+
throw err;
|
|
7920
|
+
}
|
|
7921
|
+
recordReviewTiming(timings, "write-report", "Report write", writeStart);
|
|
7922
|
+
return {
|
|
7923
|
+
kind: "completed",
|
|
7924
|
+
report,
|
|
7925
|
+
persisted,
|
|
7926
|
+
reportPath,
|
|
7927
|
+
sessionId: reviewResult.sessionId,
|
|
7928
|
+
suppressed: {
|
|
7929
|
+
outsideChangedFiles: changedFileFilter.suppressed.length,
|
|
7930
|
+
belowConfidence: confidenceFilter.dropped
|
|
7931
|
+
},
|
|
7932
|
+
timings: [...timings, ...report.timings ?? []],
|
|
7933
|
+
usage: reviewResult.usage ?? null
|
|
7934
|
+
};
|
|
7935
|
+
}
|
|
7936
|
+
async function runReviewSkipChecks(input, deps = defaultReviewPipelineDeps) {
|
|
7937
|
+
const timings = [...input.timings];
|
|
7938
|
+
const snapshot = await deps.loadReviewSnapshot(input.projectRoot, input.target);
|
|
7939
|
+
const { diff } = snapshot;
|
|
7940
|
+
const skippedReview = {
|
|
7941
|
+
...deps.mapReviewTarget(snapshot.target),
|
|
7942
|
+
diffHash: deps.computeDiffHash(diff.raw),
|
|
7943
|
+
model: input.config.model,
|
|
7944
|
+
reasoning: input.config.reasoning.effort,
|
|
7945
|
+
depth: input.depth,
|
|
7946
|
+
sessionId: "",
|
|
7947
|
+
diagnostics: [],
|
|
7948
|
+
timings,
|
|
7949
|
+
findings: []
|
|
7950
|
+
};
|
|
7951
|
+
if ((snapshot.target.kind === "staged" || snapshot.target.kind === "base") && diff.files.length === 0) {
|
|
7952
|
+
if (!input.persistEmptyDiff) {
|
|
7953
|
+
return { kind: "empty-diff", timings };
|
|
7954
|
+
}
|
|
7955
|
+
const summary = snapshot.target.kind === "staged" ? "No staged changes to review." : "No committed branch changes to review.";
|
|
7956
|
+
return {
|
|
7957
|
+
kind: "skipped",
|
|
7958
|
+
reason: "empty-diff",
|
|
7959
|
+
persisted: await deps.persistReviewRun(input.diffOwlDir, {
|
|
7960
|
+
...skippedReview,
|
|
7961
|
+
targetCommit: snapshot.targetCommit,
|
|
7962
|
+
summary,
|
|
7963
|
+
skippedReason: "empty-diff"
|
|
7964
|
+
}),
|
|
7965
|
+
reportPath: null,
|
|
7966
|
+
timings
|
|
7967
|
+
};
|
|
7968
|
+
}
|
|
7969
|
+
if (!input.config.skip_doc_only || !isDocOnlyDiff(diff)) {
|
|
7970
|
+
return { kind: "continue", snapshot, timings };
|
|
7971
|
+
}
|
|
7972
|
+
const persisted = await deps.persistReviewRun(input.diffOwlDir, {
|
|
7973
|
+
...skippedReview,
|
|
7974
|
+
targetCommit: snapshot.targetCommit,
|
|
7975
|
+
summary: "Documentation-only changes detected. No code review performed.",
|
|
7976
|
+
skippedReason: "documentation-only"
|
|
7977
|
+
});
|
|
7978
|
+
try {
|
|
7979
|
+
const reportPath = await deps.writeMarkdownReport(buildDocOnlySkipMarkdown(diff));
|
|
7980
|
+
await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath });
|
|
7981
|
+
return { kind: "skipped", reason: "documentation-only", persisted, reportPath, timings };
|
|
7982
|
+
} catch (err) {
|
|
7983
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7984
|
+
await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, {
|
|
7985
|
+
reportPath: null,
|
|
7986
|
+
diagnostics: [`Report write failed: ${message}`]
|
|
7987
|
+
});
|
|
7988
|
+
throw err;
|
|
7989
|
+
}
|
|
7990
|
+
}
|
|
7991
|
+
function buildDocOnlySkipMarkdown(diff) {
|
|
7992
|
+
return [
|
|
7993
|
+
"### Summary",
|
|
7994
|
+
"Documentation-only changes detected. No code review performed.",
|
|
7995
|
+
"",
|
|
7996
|
+
"### Changed Files",
|
|
7997
|
+
...diff.files.map((file) => `- ${file.path} (+${file.additions}/-${file.deletions})`)
|
|
7998
|
+
].join("\n");
|
|
7999
|
+
}
|
|
8000
|
+
async function resolveTargetCommit(target, resolveCommit = resolveCommitRef) {
|
|
8001
|
+
switch (target.kind) {
|
|
8002
|
+
case "staged":
|
|
8003
|
+
return null;
|
|
8004
|
+
case "last-commit":
|
|
8005
|
+
case "base":
|
|
8006
|
+
return resolveCommit("HEAD");
|
|
8007
|
+
case "commit":
|
|
8008
|
+
return resolveCommit(target.ref);
|
|
8009
|
+
}
|
|
8010
|
+
}
|
|
8011
|
+
async function prepareReviewServer2(config, deps = defaultReviewPipelineDeps) {
|
|
8012
|
+
if (config.server.auto_start) {
|
|
8013
|
+
await deps.ensureServer(config.server.port);
|
|
8014
|
+
return;
|
|
8015
|
+
}
|
|
8016
|
+
if (await deps.isServerRunning(config.server.port)) {
|
|
8017
|
+
return;
|
|
8018
|
+
}
|
|
8019
|
+
throw new Error(
|
|
8020
|
+
`OpenCode server is not running on port ${config.server.port}. Start it with \`diffowl server start\` or set server.auto_start: true.`
|
|
8021
|
+
);
|
|
8022
|
+
}
|
|
8023
|
+
function recordReviewTiming(timings, phase, label, start) {
|
|
8024
|
+
timings.push({ phase, label, ms: Math.max(0, Math.round(performance.now() - start)) });
|
|
8025
|
+
}
|
|
8026
|
+
|
|
7542
8027
|
// src/cli.ts
|
|
7543
|
-
import { readFile as
|
|
8028
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
7544
8029
|
import { basename as basename8, dirname as dirname5 } from "path";
|
|
7545
8030
|
import { execa as execa8 } from "execa";
|
|
7546
8031
|
|
|
7547
8032
|
// package.json
|
|
7548
8033
|
var package_default = {
|
|
7549
8034
|
name: "diffowl",
|
|
7550
|
-
version: "0.3.
|
|
8035
|
+
version: "0.3.3",
|
|
7551
8036
|
description: "Local AI code review agent powered by OpenCode",
|
|
7552
8037
|
keywords: [
|
|
7553
8038
|
"ai",
|
|
@@ -7585,7 +8070,6 @@ var package_default = {
|
|
|
7585
8070
|
lint: "oxlint . && pnpm run typecheck",
|
|
7586
8071
|
format: "oxfmt --write .",
|
|
7587
8072
|
"format:check": "oxfmt --check .",
|
|
7588
|
-
"dogfood:0.3": "pnpm run build && node scripts/dogfood-0.3.mjs",
|
|
7589
8073
|
eval: "pnpm run build && node dist/cli.js eval",
|
|
7590
8074
|
prepack: "npm run build"
|
|
7591
8075
|
},
|
|
@@ -7617,10 +8101,10 @@ var package_default = {
|
|
|
7617
8101
|
// src/cli.ts
|
|
7618
8102
|
var program = new Command();
|
|
7619
8103
|
program.name("diffowl").description("Local AI code review agent powered by OpenCode").version(package_default.version);
|
|
7620
|
-
program.command("review", { isDefault: true }).description("Review the last commit or
|
|
8104
|
+
program.command("review", { isDefault: true }).description("Review the last commit, staged changes, or committed branch changes").option("--staged", "Review staged changes instead of last commit").option("--commit <ref>", "Review a specific commit ref instead of HEAD").option("--base [ref]", "Review committed branch changes since the merge base").option("--hook", "Running from git hook (non-blocking mode)").option("--depth <depth>", "Review context depth: shallow or default").option(
|
|
7621
8105
|
"--reasoning <effort>",
|
|
7622
8106
|
"Reasoning variant: auto, none, minimal, low, medium, high, max, or xhigh"
|
|
7623
|
-
).option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
|
|
8107
|
+
).option("--model <id>", "Review model override").option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
|
|
7624
8108
|
const format = resolveReviewOutputFormat(options.format);
|
|
7625
8109
|
const jsonMode = format === "json";
|
|
7626
8110
|
const hookCommit = options.hook && options.commit ? String(options.commit) : void 0;
|
|
@@ -7635,7 +8119,7 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
7635
8119
|
const timings = [];
|
|
7636
8120
|
const gitRepoStart = performance.now();
|
|
7637
8121
|
const isRepo = await isGitRepo();
|
|
7638
|
-
|
|
8122
|
+
timings.push(createCliTiming("git-repo-check", "Git repository check", gitRepoStart));
|
|
7639
8123
|
if (!isRepo) {
|
|
7640
8124
|
await failReview(format, "Not a git repository", { hook: options.hook, hookCommit });
|
|
7641
8125
|
}
|
|
@@ -7643,23 +8127,39 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
7643
8127
|
console.log(chalk4.yellow("No .diffowl.yml found. Running first-time setup...\n"));
|
|
7644
8128
|
await runInit();
|
|
7645
8129
|
}
|
|
7646
|
-
const config = await
|
|
8130
|
+
const config = (await loadEffectiveConfigOrExit(options.model)).config;
|
|
7647
8131
|
const projectRoot = getProjectRoot();
|
|
7648
8132
|
const diffOwlDir = await getSharedDiffOwlDir();
|
|
8133
|
+
const baseRequested = options.base !== void 0;
|
|
7649
8134
|
if (options.staged && options.commit) {
|
|
7650
8135
|
await failReview(format, "Cannot use --staged and --commit together", {
|
|
7651
8136
|
hook: options.hook,
|
|
7652
8137
|
hookCommit
|
|
7653
8138
|
});
|
|
7654
8139
|
}
|
|
7655
|
-
|
|
8140
|
+
if (options.staged && baseRequested) {
|
|
8141
|
+
await failReview(format, "Cannot use --staged and --base together", {
|
|
8142
|
+
hook: options.hook,
|
|
8143
|
+
hookCommit
|
|
8144
|
+
});
|
|
8145
|
+
}
|
|
8146
|
+
if (options.commit && baseRequested) {
|
|
8147
|
+
await failReview(format, "Cannot use --commit and --base together", {
|
|
8148
|
+
hook: options.hook,
|
|
8149
|
+
hookCommit
|
|
8150
|
+
});
|
|
8151
|
+
}
|
|
8152
|
+
const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : baseRequested ? {
|
|
8153
|
+
kind: "base",
|
|
8154
|
+
...typeof options.base === "string" ? { ref: options.base } : {}
|
|
8155
|
+
} : { kind: "last-commit" };
|
|
7656
8156
|
const depth = resolveReviewDepth(options.depth, config);
|
|
7657
8157
|
config.reasoning.effort = resolveReasoningEffort(options.reasoning, config);
|
|
7658
8158
|
const verbose = Boolean(config.verbose || options.verbose);
|
|
7659
8159
|
if (target.kind !== "staged") {
|
|
7660
8160
|
const hasCommitsStart = performance.now();
|
|
7661
8161
|
const commitsExist = await hasCommits();
|
|
7662
|
-
|
|
8162
|
+
timings.push(createCliTiming("git-commit-check", "Git commit check", hasCommitsStart));
|
|
7663
8163
|
if (!commitsExist) {
|
|
7664
8164
|
await failReview(format, "No commits found in this repository", {
|
|
7665
8165
|
hook: options.hook,
|
|
@@ -7704,247 +8204,108 @@ program.command("review", { isDefault: true }).description("Review the last comm
|
|
|
7704
8204
|
});
|
|
7705
8205
|
});
|
|
7706
8206
|
try {
|
|
7707
|
-
const
|
|
7708
|
-
|
|
7709
|
-
|
|
8207
|
+
const outcome = await runReviewPipeline({
|
|
8208
|
+
target,
|
|
8209
|
+
config,
|
|
8210
|
+
depth,
|
|
8211
|
+
verbose,
|
|
8212
|
+
projectRoot,
|
|
8213
|
+
diffOwlDir,
|
|
8214
|
+
timings,
|
|
8215
|
+
persistEmptyDiff: jsonMode,
|
|
8216
|
+
signal: cancelController.signal,
|
|
8217
|
+
onProgress: (event) => {
|
|
8218
|
+
if (spinner) {
|
|
8219
|
+
spinner.text = formatReviewProgress(event);
|
|
8220
|
+
}
|
|
8221
|
+
},
|
|
8222
|
+
onDiagnostics: (diagnostics) => {
|
|
8223
|
+
if (!spinner) {
|
|
8224
|
+
return;
|
|
8225
|
+
}
|
|
8226
|
+
spinner.warn("Local review context built with warnings.");
|
|
8227
|
+
for (const diagnostic of diagnostics) {
|
|
8228
|
+
console.log(chalk4.yellow(` - ${diagnostic}`));
|
|
8229
|
+
}
|
|
8230
|
+
console.log();
|
|
8231
|
+
spinner.start("Connecting to OpenCode...");
|
|
8232
|
+
},
|
|
8233
|
+
onStatus: (message) => {
|
|
8234
|
+
if (spinner) {
|
|
8235
|
+
spinner.text = message;
|
|
8236
|
+
}
|
|
8237
|
+
}
|
|
8238
|
+
});
|
|
8239
|
+
if (outcome.kind === "empty-diff") {
|
|
7710
8240
|
spinner?.stop();
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
persisted: persisted2,
|
|
7730
|
-
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
7731
|
-
verbose,
|
|
7732
|
-
timings
|
|
7733
|
-
});
|
|
7734
|
-
process.exit(0);
|
|
8241
|
+
console.log(chalk4.yellow("No changes to review"));
|
|
8242
|
+
if (options.hook) {
|
|
8243
|
+
await writeHookStatus(0, hookCommit);
|
|
8244
|
+
}
|
|
8245
|
+
process.exit(0);
|
|
8246
|
+
}
|
|
8247
|
+
if (outcome.kind === "skipped" && outcome.reason === "empty-diff") {
|
|
8248
|
+
spinner?.stop();
|
|
8249
|
+
await emitReviewJsonSuccess({
|
|
8250
|
+
diffOwlDir,
|
|
8251
|
+
reviewId: outcome.persisted.reviewId,
|
|
8252
|
+
persisted: outcome.persisted,
|
|
8253
|
+
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
8254
|
+
verbose,
|
|
8255
|
+
timings: outcome.timings
|
|
8256
|
+
});
|
|
8257
|
+
if (options.hook) {
|
|
8258
|
+
await writeHookStatus(0, hookCommit);
|
|
7735
8259
|
}
|
|
7736
|
-
console.log(chalk4.yellow("No staged changes to review"));
|
|
7737
8260
|
process.exit(0);
|
|
7738
8261
|
}
|
|
7739
|
-
if (
|
|
8262
|
+
if (outcome.kind === "skipped" && outcome.reason === "documentation-only") {
|
|
7740
8263
|
spinner?.stop();
|
|
7741
8264
|
if (!jsonMode) {
|
|
7742
8265
|
console.warn(chalk4.yellow("Documentation-only changes detected. Skipping review."));
|
|
7743
8266
|
}
|
|
7744
|
-
const skipContent = buildDocOnlySkipMarkdown(diff);
|
|
7745
|
-
const diffHash2 = computeDiffHash(diff.raw);
|
|
7746
|
-
const targetFields2 = mapReviewTarget(target);
|
|
7747
|
-
const targetCommit2 = await resolveTargetCommit(target);
|
|
7748
|
-
const persisted2 = await persistReviewRun(diffOwlDir, {
|
|
7749
|
-
...targetFields2,
|
|
7750
|
-
targetCommit: targetCommit2,
|
|
7751
|
-
diffHash: diffHash2,
|
|
7752
|
-
model: config.model,
|
|
7753
|
-
reasoning: config.reasoning.effort,
|
|
7754
|
-
depth,
|
|
7755
|
-
sessionId: "",
|
|
7756
|
-
summary: "Documentation-only changes detected. No code review performed.",
|
|
7757
|
-
diagnostics: [],
|
|
7758
|
-
timings,
|
|
7759
|
-
findings: [],
|
|
7760
|
-
skippedReason: "documentation-only"
|
|
7761
|
-
});
|
|
7762
|
-
let reportPath2;
|
|
7763
|
-
try {
|
|
7764
|
-
reportPath2 = await writeMarkdownReport(skipContent);
|
|
7765
|
-
await updatePersistedReview(diffOwlDir, persisted2.reviewId, {
|
|
7766
|
-
reportPath: reportPath2
|
|
7767
|
-
});
|
|
7768
|
-
} catch (err) {
|
|
7769
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7770
|
-
await updatePersistedReview(diffOwlDir, persisted2.reviewId, {
|
|
7771
|
-
reportPath: null,
|
|
7772
|
-
diagnostics: [`Report write failed: ${message}`]
|
|
7773
|
-
});
|
|
7774
|
-
throw err;
|
|
7775
|
-
}
|
|
7776
8267
|
if (jsonMode) {
|
|
7777
8268
|
await emitReviewJsonSuccess({
|
|
7778
8269
|
diffOwlDir,
|
|
7779
|
-
reviewId:
|
|
7780
|
-
persisted:
|
|
8270
|
+
reviewId: outcome.persisted.reviewId,
|
|
8271
|
+
persisted: outcome.persisted,
|
|
7781
8272
|
suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
|
|
7782
8273
|
verbose,
|
|
7783
|
-
timings
|
|
8274
|
+
timings: outcome.timings
|
|
7784
8275
|
});
|
|
7785
8276
|
} else {
|
|
7786
|
-
console.log(chalk4.dim(`Report saved: ${
|
|
8277
|
+
console.log(chalk4.dim(`Report saved: ${outcome.reportPath}`));
|
|
7787
8278
|
}
|
|
7788
8279
|
if (options.hook) {
|
|
7789
8280
|
await writeHookStatus(0, hookCommit);
|
|
7790
8281
|
}
|
|
7791
8282
|
process.exit(0);
|
|
7792
8283
|
}
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
recordCliTiming(timings, "context-build", "Local review context build", contextStart);
|
|
7796
|
-
const contextRenderStart = performance.now();
|
|
7797
|
-
const localContext = renderReviewContext(reviewContext, { depth });
|
|
7798
|
-
recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
|
|
7799
|
-
if (reviewContext.diagnostics.length > 0 && spinner) {
|
|
7800
|
-
spinner.warn("Local review context built with warnings.");
|
|
7801
|
-
for (const diagnostic of reviewContext.diagnostics) {
|
|
7802
|
-
console.log(chalk4.yellow(` - ${diagnostic}`));
|
|
7803
|
-
}
|
|
7804
|
-
console.log();
|
|
7805
|
-
spinner.start("Connecting to OpenCode...");
|
|
7806
|
-
}
|
|
7807
|
-
if (spinner) {
|
|
7808
|
-
spinner.text = "Connecting to OpenCode...";
|
|
7809
|
-
}
|
|
7810
|
-
const serverStart = performance.now();
|
|
7811
|
-
await prepareReviewServer2(config);
|
|
7812
|
-
recordCliTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
|
|
7813
|
-
if (spinner) {
|
|
7814
|
-
spinner.text = "Reviewing changes...";
|
|
8284
|
+
if (outcome.kind !== "completed") {
|
|
8285
|
+
process.exit(0);
|
|
7815
8286
|
}
|
|
7816
|
-
const
|
|
7817
|
-
const reviewResult = await runReview({
|
|
7818
|
-
target,
|
|
7819
|
-
directory: projectRoot,
|
|
7820
|
-
config,
|
|
7821
|
-
localContext,
|
|
7822
|
-
depth,
|
|
7823
|
-
signal: cancelController.signal,
|
|
7824
|
-
onProgress: (event) => {
|
|
7825
|
-
if (spinner) {
|
|
7826
|
-
spinner.text = formatReviewProgress(event);
|
|
7827
|
-
}
|
|
7828
|
-
}
|
|
7829
|
-
});
|
|
7830
|
-
const report = reviewResult.report;
|
|
7831
|
-
recordCliTiming(timings, "review-run", "OpenCode review run", reviewStart);
|
|
8287
|
+
const report = outcome.report;
|
|
7832
8288
|
spinner?.succeed("Review complete.");
|
|
7833
8289
|
if (!jsonMode) {
|
|
7834
8290
|
console.log();
|
|
7835
8291
|
}
|
|
7836
|
-
const
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
for (const file of reviewContext.changedFiles) {
|
|
7841
|
-
changedFilesSet.add(file.file.path);
|
|
7842
|
-
}
|
|
7843
|
-
const changedFileFilter = filterFindingsByChangedFiles(report.findings, changedFilesSet);
|
|
7844
|
-
report.findings = changedFileFilter.findings;
|
|
7845
|
-
if (changedFileFilter.suppressed.length > 0) {
|
|
7846
|
-
if (verbose) {
|
|
7847
|
-
report.suppressedFindings = changedFileFilter.suppressed;
|
|
7848
|
-
}
|
|
7849
|
-
}
|
|
7850
|
-
if (confidenceFilter.dropped > 0 || changedFileFilter.suppressed.length > 0) {
|
|
7851
|
-
diagnostics.push(
|
|
7852
|
-
formatExcludedCandidateSummary(
|
|
7853
|
-
confidenceFilter.dropped,
|
|
7854
|
-
changedFileFilter.suppressed.length
|
|
7855
|
-
)
|
|
7856
|
-
);
|
|
7857
|
-
}
|
|
7858
|
-
if (diagnostics.length > 0) {
|
|
7859
|
-
report.diagnostics = diagnostics;
|
|
7860
|
-
}
|
|
7861
|
-
const diffHash = computeDiffHash(diff.raw);
|
|
7862
|
-
const targetFields = mapReviewTarget(target);
|
|
7863
|
-
const targetCommit = await resolveTargetCommit(target);
|
|
7864
|
-
const persistStart = performance.now();
|
|
7865
|
-
const persisted = await persistReviewRun(diffOwlDir, {
|
|
7866
|
-
...targetFields,
|
|
7867
|
-
targetCommit,
|
|
7868
|
-
diffHash,
|
|
7869
|
-
model: config.model,
|
|
7870
|
-
reasoning: config.reasoning.effort,
|
|
7871
|
-
depth,
|
|
7872
|
-
sessionId: reviewResult.sessionId,
|
|
7873
|
-
summary: report.summary,
|
|
7874
|
-
diagnostics,
|
|
7875
|
-
timings: [...timings, ...report.timings ?? []],
|
|
7876
|
-
findings: report.findings
|
|
7877
|
-
});
|
|
7878
|
-
recordCliTiming(timings, "persist-state", "Persist review state", persistStart);
|
|
7879
|
-
report.findings = persisted.actionableFindings;
|
|
7880
|
-
const lifecycleSummary = formatLifecycleSuppressedSummary(
|
|
7881
|
-
persisted.reconcile.suppressedCounts
|
|
7882
|
-
);
|
|
7883
|
-
if (lifecycleSummary) {
|
|
7884
|
-
diagnostics.push(lifecycleSummary);
|
|
7885
|
-
report.diagnostics = diagnostics;
|
|
7886
|
-
}
|
|
7887
|
-
if (verbose && persisted.lifecycleSuppressedFindings.length > 0) {
|
|
7888
|
-
report.suppressedFindings = [
|
|
7889
|
-
...report.suppressedFindings ?? [],
|
|
7890
|
-
...persisted.lifecycleSuppressedFindings
|
|
7891
|
-
];
|
|
7892
|
-
}
|
|
7893
|
-
report.findings = enrichReviewFindingsWithDurableMetadata(
|
|
7894
|
-
report.findings,
|
|
7895
|
-
persisted.reconcile
|
|
7896
|
-
);
|
|
7897
|
-
if (report.suppressedFindings) {
|
|
7898
|
-
report.suppressedFindings = enrichReviewFindingsWithDurableMetadata(
|
|
7899
|
-
report.suppressedFindings,
|
|
7900
|
-
persisted.reconcile
|
|
7901
|
-
);
|
|
7902
|
-
}
|
|
7903
|
-
const renderStart = performance.now();
|
|
7904
|
-
const markdown = renderMarkdown(report);
|
|
7905
|
-
recordCliTiming(timings, "render-report", "Markdown render", renderStart);
|
|
7906
|
-
const writeStart = performance.now();
|
|
7907
|
-
let reportPath;
|
|
7908
|
-
try {
|
|
7909
|
-
reportPath = await writeMarkdownReport(markdown, {
|
|
7910
|
-
schema_version: REPORT_SCHEMA_VERSION,
|
|
7911
|
-
review_id: persisted.reviewId,
|
|
7912
|
-
session_id: reviewResult.sessionId,
|
|
7913
|
-
project_root: projectRoot
|
|
7914
|
-
});
|
|
7915
|
-
await updatePersistedReview(diffOwlDir, persisted.reviewId, {
|
|
7916
|
-
reportPath,
|
|
7917
|
-
diagnostics
|
|
7918
|
-
});
|
|
7919
|
-
} catch (err) {
|
|
7920
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7921
|
-
diagnostics.push(`Report write failed: ${message}`);
|
|
7922
|
-
report.diagnostics = diagnostics;
|
|
7923
|
-
await updatePersistedReview(diffOwlDir, persisted.reviewId, {
|
|
7924
|
-
reportPath: null,
|
|
7925
|
-
diagnostics
|
|
7926
|
-
});
|
|
7927
|
-
throw err;
|
|
7928
|
-
}
|
|
7929
|
-
recordCliTiming(timings, "write-report", "Report write", writeStart);
|
|
7930
|
-
recordCliTiming(timings, "total", "Total review command", totalStart);
|
|
8292
|
+
const outputTimings = [
|
|
8293
|
+
...outcome.timings,
|
|
8294
|
+
createCliTiming("total", "Total review command", totalStart)
|
|
8295
|
+
];
|
|
7931
8296
|
if (jsonMode) {
|
|
7932
8297
|
await emitReviewJsonSuccess({
|
|
7933
8298
|
diffOwlDir,
|
|
7934
|
-
reviewId: persisted.reviewId,
|
|
7935
|
-
persisted,
|
|
7936
|
-
suppressed:
|
|
7937
|
-
outsideChangedFiles: changedFileFilter.suppressed.length,
|
|
7938
|
-
belowConfidence: confidenceFilter.dropped
|
|
7939
|
-
},
|
|
8299
|
+
reviewId: outcome.persisted.reviewId,
|
|
8300
|
+
persisted: outcome.persisted,
|
|
8301
|
+
suppressed: outcome.suppressed,
|
|
7940
8302
|
verbose,
|
|
7941
|
-
timings:
|
|
7942
|
-
usage:
|
|
8303
|
+
timings: outputTimings,
|
|
8304
|
+
usage: outcome.usage
|
|
7943
8305
|
});
|
|
7944
8306
|
} else {
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
printTimingSummary([...timings, ...report.timings ?? []]);
|
|
8307
|
+
printFooter(report, outcome.reportPath);
|
|
8308
|
+
printTimingSummary(outputTimings);
|
|
7948
8309
|
}
|
|
7949
8310
|
if (options.hook) {
|
|
7950
8311
|
await writeHookStatus(0, hookCommit);
|
|
@@ -7983,7 +8344,7 @@ program.command("chat").description("Open the OpenCode session for a review").ar
|
|
|
7983
8344
|
const reportPath = report ? await resolveReviewReportPath(report) : await selectReviewInteractively();
|
|
7984
8345
|
let content;
|
|
7985
8346
|
try {
|
|
7986
|
-
content = await
|
|
8347
|
+
content = await readFile11(reportPath, "utf-8");
|
|
7987
8348
|
} catch {
|
|
7988
8349
|
console.error(chalk4.red(`Review report not found: ${reportPath}`));
|
|
7989
8350
|
process.exit(1);
|
|
@@ -8104,8 +8465,8 @@ function resolveReasoningEffort(value, config) {
|
|
|
8104
8465
|
process.exit(1);
|
|
8105
8466
|
}
|
|
8106
8467
|
}
|
|
8107
|
-
function
|
|
8108
|
-
|
|
8468
|
+
function createCliTiming(phase, label, start) {
|
|
8469
|
+
return { phase, label, ms: Math.max(0, Math.round(performance.now() - start)) };
|
|
8109
8470
|
}
|
|
8110
8471
|
function printTimingSummary(timings) {
|
|
8111
8472
|
if (timings.length === 0) return;
|
|
@@ -8123,47 +8484,58 @@ function formatDuration2(ms) {
|
|
|
8123
8484
|
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
8124
8485
|
return `${(ms / 1e3).toFixed(1)}s`;
|
|
8125
8486
|
}
|
|
8126
|
-
async function prepareReviewServer2(config) {
|
|
8127
|
-
if (config.server.auto_start) {
|
|
8128
|
-
await ensureServer(config.server.port);
|
|
8129
|
-
return;
|
|
8130
|
-
}
|
|
8131
|
-
if (await isServerRunning(config.server.port)) {
|
|
8132
|
-
return;
|
|
8133
|
-
}
|
|
8134
|
-
throw new Error(
|
|
8135
|
-
`OpenCode server is not running on port ${config.server.port}. Start it with \`diffowl server start\` or set server.auto_start: true.`
|
|
8136
|
-
);
|
|
8137
|
-
}
|
|
8138
8487
|
program.command("init").description("Set up DiffOwl for this project").action(async () => {
|
|
8139
8488
|
await runInit();
|
|
8140
8489
|
});
|
|
8141
8490
|
async function runInit() {
|
|
8142
8491
|
console.log(chalk4.bold("DiffOwl Setup\n"));
|
|
8143
|
-
const config = await
|
|
8492
|
+
const config = await loadProjectConfigOrExit();
|
|
8144
8493
|
await selectModelInteractively(config, { allowKeepCurrent: false });
|
|
8494
|
+
console.log(chalk4.green(`\u2713 Config saved to ${await saveConfig(config)}`));
|
|
8145
8495
|
}
|
|
8146
|
-
program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode/big-pickle)").action(async (model) => {
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8496
|
+
program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode/big-pickle)").option("--reset", "Remove the shared local model preference").action(async (model, options) => {
|
|
8497
|
+
if (model && options.reset) {
|
|
8498
|
+
console.error(chalk4.red("Cannot pass a model and --reset together"));
|
|
8499
|
+
process.exit(1);
|
|
8500
|
+
}
|
|
8501
|
+
if (options.reset) {
|
|
8502
|
+
await resetModelPreference();
|
|
8503
|
+
console.log(chalk4.green("\u2713 Local model preference reset"));
|
|
8504
|
+
console.log(chalk4.dim("Run `diffowl model <provider/model>` to choose another."));
|
|
8151
8505
|
return;
|
|
8152
8506
|
}
|
|
8153
|
-
|
|
8507
|
+
if (model !== void 0) {
|
|
8508
|
+
let parsedModel;
|
|
8509
|
+
try {
|
|
8510
|
+
parsedModel = parseModel(model);
|
|
8511
|
+
} catch {
|
|
8512
|
+
console.error(chalk4.red(`Invalid model: ${model}`));
|
|
8513
|
+
console.error(chalk4.dim("Expected provider/model format, for example opencode/big-pickle"));
|
|
8514
|
+
process.exit(1);
|
|
8515
|
+
}
|
|
8516
|
+
const configPath = await saveModelPreference(parsedModel);
|
|
8517
|
+
console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(parsedModel)}`));
|
|
8518
|
+
console.log(chalk4.dim(`Local preference: ${configPath}`));
|
|
8519
|
+
return;
|
|
8520
|
+
}
|
|
8521
|
+
let effective;
|
|
8154
8522
|
try {
|
|
8155
|
-
|
|
8156
|
-
} catch {
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8523
|
+
effective = await loadEffectiveConfig();
|
|
8524
|
+
} catch (err) {
|
|
8525
|
+
if (!(err instanceof MissingModelError)) {
|
|
8526
|
+
console.error(
|
|
8527
|
+
chalk4.red(`Config error: ${err instanceof Error ? err.message : String(err)}`)
|
|
8528
|
+
);
|
|
8529
|
+
process.exit(1);
|
|
8530
|
+
}
|
|
8531
|
+
const config2 = await loadProjectConfigOrExit();
|
|
8532
|
+
console.log(chalk4.yellow("No model selected."));
|
|
8533
|
+
await selectModelInteractively(config2, { allowKeepCurrent: false });
|
|
8534
|
+
return;
|
|
8162
8535
|
}
|
|
8163
|
-
config
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
console.log(chalk4.dim(`Config: ${configPath}`));
|
|
8536
|
+
const config = effective.config;
|
|
8537
|
+
console.log(formatEffectiveModel(config.model, effective.modelSource));
|
|
8538
|
+
await selectModelInteractively(config, { allowKeepCurrent: true });
|
|
8167
8539
|
});
|
|
8168
8540
|
async function selectModelInteractively(config, options) {
|
|
8169
8541
|
const spinner = ora2("Querying available models from OpenCode...").start();
|
|
@@ -8224,22 +8596,14 @@ async function selectModelInteractively(config, options) {
|
|
|
8224
8596
|
rl.close();
|
|
8225
8597
|
}
|
|
8226
8598
|
} else {
|
|
8227
|
-
console.
|
|
8228
|
-
console.
|
|
8229
|
-
|
|
8230
|
-
);
|
|
8231
|
-
console.log(chalk4.dim("Using fallback default model: ") + chalk4.cyan(config.model));
|
|
8232
|
-
console.log();
|
|
8599
|
+
console.error(chalk4.red("\nNo active/connected providers found in OpenCode."));
|
|
8600
|
+
console.error(chalk4.dim("Run opencode to configure a provider, then retry."));
|
|
8601
|
+
process.exit(1);
|
|
8233
8602
|
}
|
|
8234
8603
|
if (selectedModel !== config.model || !options.allowKeepCurrent) {
|
|
8235
8604
|
config.model = selectedModel;
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(selectedModel)}`));
|
|
8239
|
-
} else {
|
|
8240
|
-
console.log(chalk4.green(`\u2713 Config saved to ${configPath}`));
|
|
8241
|
-
console.log(chalk4.dim(`Model set to: `) + chalk4.cyan(selectedModel));
|
|
8242
|
-
}
|
|
8605
|
+
await saveModelPreference(selectedModel);
|
|
8606
|
+
console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(selectedModel)}`));
|
|
8243
8607
|
console.log();
|
|
8244
8608
|
}
|
|
8245
8609
|
}
|
|
@@ -8342,9 +8706,14 @@ serverCmd.command("status").description("Check if the OpenCode server is running
|
|
|
8342
8706
|
}
|
|
8343
8707
|
});
|
|
8344
8708
|
var findingsCmd = program.command("findings").description("Inspect and manage durable findings");
|
|
8345
|
-
findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").action(async () => {
|
|
8709
|
+
findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
|
|
8346
8710
|
await loadConfigOrExit();
|
|
8711
|
+
const format = resolveReviewOutputFormat(options.format);
|
|
8347
8712
|
const items = await withFindingDatabase(await getSharedDiffOwlDir(), listUnresolvedFindings);
|
|
8713
|
+
if (format === "json") {
|
|
8714
|
+
process.stdout.write(renderFindingListJson(items));
|
|
8715
|
+
return;
|
|
8716
|
+
}
|
|
8348
8717
|
if (items.length === 0) {
|
|
8349
8718
|
console.log(chalk4.green("No unresolved findings."));
|
|
8350
8719
|
return;
|
|
@@ -8456,34 +8825,29 @@ function collectValues(value, previous) {
|
|
|
8456
8825
|
return [...previous, value];
|
|
8457
8826
|
}
|
|
8458
8827
|
async function loadConfigOrExit() {
|
|
8828
|
+
return await loadProjectConfigOrExit();
|
|
8829
|
+
}
|
|
8830
|
+
async function loadEffectiveConfigOrExit(commandModel) {
|
|
8459
8831
|
try {
|
|
8460
|
-
return await
|
|
8832
|
+
return await loadEffectiveConfig(commandModel);
|
|
8461
8833
|
} catch (err) {
|
|
8462
8834
|
const message = err instanceof Error ? err.message : String(err);
|
|
8463
8835
|
console.error(chalk4.red(`Config error: ${message}`));
|
|
8464
8836
|
process.exit(1);
|
|
8465
8837
|
}
|
|
8466
8838
|
}
|
|
8467
|
-
function
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
lines.push(`- ${file.path} (+${file.additions}/-${file.deletions})`);
|
|
8839
|
+
async function loadProjectConfigOrExit() {
|
|
8840
|
+
try {
|
|
8841
|
+
return await loadConfig();
|
|
8842
|
+
} catch (err) {
|
|
8843
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8844
|
+
console.error(chalk4.red(`Config error: ${message}`));
|
|
8845
|
+
process.exit(1);
|
|
8475
8846
|
}
|
|
8476
|
-
return lines.join("\n");
|
|
8477
8847
|
}
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
return null;
|
|
8482
|
-
case "last-commit":
|
|
8483
|
-
return resolveCommitRef("HEAD");
|
|
8484
|
-
case "commit":
|
|
8485
|
-
return resolveCommitRef(target.ref);
|
|
8486
|
-
}
|
|
8848
|
+
function formatEffectiveModel(model, source) {
|
|
8849
|
+
const label = source === "local" ? "local preference" : source;
|
|
8850
|
+
return `${chalk4.bold("Current model: ")} ${chalk4.cyan(model)} ${chalk4.dim(`(${label})`)}`;
|
|
8487
8851
|
}
|
|
8488
8852
|
function handleReviewInterrupt(input) {
|
|
8489
8853
|
input.cancelController.abort();
|