glassbox 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -3
- package/dist/cli.js +2545 -274
- package/dist/client/app.global.js +8 -8
- package/dist/client/styles.css +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
2
11
|
|
|
3
12
|
// src/db/connection.ts
|
|
13
|
+
var connection_exports = {};
|
|
14
|
+
__export(connection_exports, {
|
|
15
|
+
getDb: () => getDb
|
|
16
|
+
});
|
|
4
17
|
import { PGlite } from "@electric-sql/pglite";
|
|
5
|
-
import { mkdirSync } from "fs";
|
|
18
|
+
import { mkdirSync, rmSync } from "fs";
|
|
6
19
|
import { homedir } from "os";
|
|
7
20
|
import { join } from "path";
|
|
8
|
-
var dataDir = join(homedir(), ".glassbox", "data");
|
|
9
|
-
mkdirSync(dataDir, { recursive: true });
|
|
10
|
-
var db = null;
|
|
11
21
|
async function getDb() {
|
|
12
22
|
if (db) return db;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
try {
|
|
24
|
+
db = new PGlite(dbPath);
|
|
25
|
+
await db.waitReady;
|
|
26
|
+
await initSchema(db);
|
|
27
|
+
return db;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
db = null;
|
|
30
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
31
|
+
if (message.includes("Aborted") || message.includes("RuntimeError")) {
|
|
32
|
+
console.error("Database appears to be corrupt. Recreating...");
|
|
33
|
+
console.error("(Previous review data will be lost.)");
|
|
34
|
+
try {
|
|
35
|
+
rmSync(dbPath, { recursive: true, force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
db = new PGlite(dbPath);
|
|
39
|
+
await db.waitReady;
|
|
40
|
+
await initSchema(db);
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
17
45
|
}
|
|
18
46
|
async function initSchema(db2) {
|
|
19
47
|
await db2.exec(`
|
|
@@ -55,21 +83,99 @@ async function initSchema(db2) {
|
|
|
55
83
|
|
|
56
84
|
CREATE INDEX IF NOT EXISTS idx_annotations_file ON annotations(review_file_id);
|
|
57
85
|
`);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
await db2.exec(`
|
|
87
|
+
CREATE TABLE IF NOT EXISTS ai_analyses (
|
|
88
|
+
id TEXT PRIMARY KEY,
|
|
89
|
+
review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
90
|
+
analysis_type TEXT NOT NULL,
|
|
91
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
92
|
+
error_message TEXT,
|
|
93
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
94
|
+
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_ai_analyses_review ON ai_analyses(review_id);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS ai_file_scores (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
analysis_id TEXT NOT NULL REFERENCES ai_analyses(id) ON DELETE CASCADE,
|
|
102
|
+
review_file_id TEXT NOT NULL,
|
|
103
|
+
file_path TEXT NOT NULL,
|
|
104
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
aggregate_score REAL,
|
|
106
|
+
rationale TEXT,
|
|
107
|
+
dimension_scores TEXT,
|
|
108
|
+
notes TEXT,
|
|
109
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_ai_file_scores_analysis ON ai_file_scores(analysis_id);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS user_preferences (
|
|
115
|
+
id TEXT PRIMARY KEY DEFAULT 'singleton',
|
|
116
|
+
sort_mode TEXT NOT NULL DEFAULT 'folder',
|
|
117
|
+
risk_sort_dimension TEXT NOT NULL DEFAULT 'aggregate',
|
|
118
|
+
show_risk_scores BOOLEAN NOT NULL DEFAULT FALSE
|
|
119
|
+
);
|
|
120
|
+
`);
|
|
121
|
+
await addColumnIfMissing(db2, "reviews", "head_commit", "TEXT");
|
|
122
|
+
await addColumnIfMissing(db2, "annotations", "is_stale", "BOOLEAN NOT NULL DEFAULT FALSE");
|
|
123
|
+
await addColumnIfMissing(db2, "annotations", "original_content", "TEXT");
|
|
124
|
+
await addColumnIfMissing(db2, "ai_file_scores", "notes", "TEXT");
|
|
125
|
+
await addColumnIfMissing(db2, "ai_analyses", "progress_completed", "INTEGER NOT NULL DEFAULT 0");
|
|
126
|
+
await addColumnIfMissing(db2, "ai_analyses", "progress_total", "INTEGER NOT NULL DEFAULT 0");
|
|
127
|
+
await db2.exec(
|
|
128
|
+
`UPDATE ai_analyses SET status = 'failed', error_message = 'Interrupted (server restarted)' WHERE status = 'running'`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
async function addColumnIfMissing(db2, table, column, definition) {
|
|
132
|
+
const result = await db2.query(
|
|
133
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`,
|
|
134
|
+
[table, column]
|
|
135
|
+
);
|
|
136
|
+
if (result.rows.length === 0) {
|
|
137
|
+
await db2.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
69
138
|
}
|
|
70
139
|
}
|
|
140
|
+
var dataDir, dbPath, db;
|
|
141
|
+
var init_connection = __esm({
|
|
142
|
+
"src/db/connection.ts"() {
|
|
143
|
+
"use strict";
|
|
144
|
+
dataDir = join(homedir(), ".glassbox", "data");
|
|
145
|
+
mkdirSync(dataDir, { recursive: true });
|
|
146
|
+
dbPath = join(dataDir, "reviews");
|
|
147
|
+
db = null;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
71
150
|
|
|
72
151
|
// src/db/queries.ts
|
|
152
|
+
var queries_exports = {};
|
|
153
|
+
__export(queries_exports, {
|
|
154
|
+
addAnnotation: () => addAnnotation,
|
|
155
|
+
addReviewFile: () => addReviewFile,
|
|
156
|
+
createReview: () => createReview,
|
|
157
|
+
deleteAnnotation: () => deleteAnnotation,
|
|
158
|
+
deleteReview: () => deleteReview,
|
|
159
|
+
deleteReviewFile: () => deleteReviewFile,
|
|
160
|
+
deleteStaleAnnotations: () => deleteStaleAnnotations,
|
|
161
|
+
getAnnotationsForFile: () => getAnnotationsForFile,
|
|
162
|
+
getAnnotationsForReview: () => getAnnotationsForReview,
|
|
163
|
+
getLatestInProgressReview: () => getLatestInProgressReview,
|
|
164
|
+
getReview: () => getReview,
|
|
165
|
+
getReviewFile: () => getReviewFile,
|
|
166
|
+
getReviewFiles: () => getReviewFiles,
|
|
167
|
+
getStaleCountsForReview: () => getStaleCountsForReview,
|
|
168
|
+
keepAllStaleAnnotations: () => keepAllStaleAnnotations,
|
|
169
|
+
listReviews: () => listReviews,
|
|
170
|
+
markAnnotationCurrent: () => markAnnotationCurrent,
|
|
171
|
+
markAnnotationStale: () => markAnnotationStale,
|
|
172
|
+
moveAnnotation: () => moveAnnotation,
|
|
173
|
+
updateAnnotation: () => updateAnnotation,
|
|
174
|
+
updateFileDiff: () => updateFileDiff,
|
|
175
|
+
updateFileStatus: () => updateFileStatus,
|
|
176
|
+
updateReviewHead: () => updateReviewHead,
|
|
177
|
+
updateReviewStatus: () => updateReviewStatus
|
|
178
|
+
});
|
|
73
179
|
function generateId() {
|
|
74
180
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
75
181
|
}
|
|
@@ -254,14 +360,913 @@ async function getStaleCountsForReview(reviewId) {
|
|
|
254
360
|
}
|
|
255
361
|
return counts;
|
|
256
362
|
}
|
|
363
|
+
var init_queries = __esm({
|
|
364
|
+
"src/db/queries.ts"() {
|
|
365
|
+
"use strict";
|
|
366
|
+
init_connection();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
257
369
|
|
|
258
|
-
// src/
|
|
370
|
+
// src/cli.ts
|
|
371
|
+
init_queries();
|
|
372
|
+
|
|
373
|
+
// src/debug.ts
|
|
374
|
+
var debugEnabled = false;
|
|
375
|
+
var aiServiceTestEnabled = false;
|
|
376
|
+
var demoMode = null;
|
|
377
|
+
function setDebug(enabled) {
|
|
378
|
+
debugEnabled = enabled;
|
|
379
|
+
}
|
|
380
|
+
function isDebug() {
|
|
381
|
+
return debugEnabled;
|
|
382
|
+
}
|
|
383
|
+
function setAIServiceTest(enabled) {
|
|
384
|
+
aiServiceTestEnabled = enabled;
|
|
385
|
+
}
|
|
386
|
+
function isAIServiceTest() {
|
|
387
|
+
return aiServiceTestEnabled;
|
|
388
|
+
}
|
|
389
|
+
function setDemoMode(scenario) {
|
|
390
|
+
demoMode = scenario;
|
|
391
|
+
}
|
|
392
|
+
function getDemoMode() {
|
|
393
|
+
return demoMode;
|
|
394
|
+
}
|
|
395
|
+
function debugLog(...args) {
|
|
396
|
+
if (debugEnabled) {
|
|
397
|
+
console.log("[debug]", ...args);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/ai/config.ts
|
|
259
402
|
import { execSync } from "child_process";
|
|
260
|
-
import { readFileSync } from "fs";
|
|
403
|
+
import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
404
|
+
import { homedir as homedir2 } from "os";
|
|
405
|
+
import { join as join2 } from "path";
|
|
406
|
+
|
|
407
|
+
// src/ai/models.ts
|
|
408
|
+
var PLATFORMS = {
|
|
409
|
+
anthropic: "Anthropic",
|
|
410
|
+
openai: "OpenAI",
|
|
411
|
+
google: "Google"
|
|
412
|
+
};
|
|
413
|
+
var MODELS = {
|
|
414
|
+
anthropic: [
|
|
415
|
+
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", contextWindow: 2e5, isDefault: true },
|
|
416
|
+
{ id: "claude-haiku-4-20250514", name: "Claude Haiku 4", contextWindow: 2e5, isDefault: false }
|
|
417
|
+
],
|
|
418
|
+
openai: [
|
|
419
|
+
{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128e3, isDefault: true },
|
|
420
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128e3, isDefault: false }
|
|
421
|
+
],
|
|
422
|
+
google: [
|
|
423
|
+
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 1e6, isDefault: true },
|
|
424
|
+
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 1e6, isDefault: false }
|
|
425
|
+
]
|
|
426
|
+
};
|
|
427
|
+
var ENV_KEY_NAMES = {
|
|
428
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
429
|
+
openai: "OPENAI_API_KEY",
|
|
430
|
+
google: "GEMINI_API_KEY"
|
|
431
|
+
};
|
|
432
|
+
function getDefaultModel(platform) {
|
|
433
|
+
const models = MODELS[platform];
|
|
434
|
+
const def = models.find((m) => m.isDefault);
|
|
435
|
+
return def ? def.id : models[0].id;
|
|
436
|
+
}
|
|
437
|
+
function getModelContextWindow(platform, modelId) {
|
|
438
|
+
const model = MODELS[platform].find((m) => m.id === modelId);
|
|
439
|
+
return model ? model.contextWindow : 128e3;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/ai/config.ts
|
|
443
|
+
var CONFIG_DIR = join2(homedir2(), ".glassbox");
|
|
444
|
+
var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
|
|
445
|
+
function readConfigFile() {
|
|
446
|
+
try {
|
|
447
|
+
if (existsSync(CONFIG_PATH)) {
|
|
448
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
return {};
|
|
453
|
+
}
|
|
454
|
+
function writeConfigFile(config) {
|
|
455
|
+
mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
456
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
457
|
+
try {
|
|
458
|
+
chmodSync(CONFIG_PATH, 384);
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function getKeyFromEnv(platform) {
|
|
463
|
+
const envName = ENV_KEY_NAMES[platform];
|
|
464
|
+
return process.env[envName] ?? null;
|
|
465
|
+
}
|
|
466
|
+
var WIN_CRED_READ_PS = `
|
|
467
|
+
Add-Type -TypeDefinition @'
|
|
468
|
+
using System;
|
|
469
|
+
using System.Runtime.InteropServices;
|
|
470
|
+
public class CredHelper {
|
|
471
|
+
[DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
472
|
+
static extern bool CredRead(string t, int type, int f, out IntPtr p);
|
|
473
|
+
[DllImport("advapi32")]
|
|
474
|
+
static extern void CredFree(IntPtr p);
|
|
475
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
476
|
+
struct CRED {
|
|
477
|
+
public int Flags; public int Type; public string TargetName; public string Comment;
|
|
478
|
+
public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob;
|
|
479
|
+
public int Persist; public int AttributeCount; public IntPtr Attributes;
|
|
480
|
+
public string TargetAlias; public string UserName;
|
|
481
|
+
}
|
|
482
|
+
public static string Read(string target) {
|
|
483
|
+
IntPtr ptr;
|
|
484
|
+
if (!CredRead(target, 1, 0, out ptr)) return "";
|
|
485
|
+
CRED c = (CRED)Marshal.PtrToStructure(ptr, typeof(CRED));
|
|
486
|
+
string r = Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);
|
|
487
|
+
CredFree(ptr);
|
|
488
|
+
return r;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
'@
|
|
492
|
+
`;
|
|
493
|
+
function winCredTarget(platform) {
|
|
494
|
+
return `glassbox-${platform}-api-key`;
|
|
495
|
+
}
|
|
496
|
+
function getKeyFromKeychain(platform) {
|
|
497
|
+
const os = process.platform;
|
|
498
|
+
const account = `${platform}-api-key`;
|
|
499
|
+
try {
|
|
500
|
+
if (os === "darwin") {
|
|
501
|
+
const result = execSync(
|
|
502
|
+
`security find-generic-password -s glassbox -a "${account}" -w 2>/dev/null`,
|
|
503
|
+
{ encoding: "utf-8" }
|
|
504
|
+
).trim();
|
|
505
|
+
return result !== "" ? result : null;
|
|
506
|
+
}
|
|
507
|
+
if (os === "linux") {
|
|
508
|
+
const result = execSync(
|
|
509
|
+
`secret-tool lookup service glassbox account "${account}" 2>/dev/null`,
|
|
510
|
+
{ encoding: "utf-8" }
|
|
511
|
+
).trim();
|
|
512
|
+
return result !== "" ? result : null;
|
|
513
|
+
}
|
|
514
|
+
if (os === "win32") {
|
|
515
|
+
const target = winCredTarget(platform);
|
|
516
|
+
const script = WIN_CRED_READ_PS + `Write-Output ([CredHelper]::Read('${target}'))`;
|
|
517
|
+
const result = execSync("powershell -NoProfile -Command -", {
|
|
518
|
+
input: script,
|
|
519
|
+
encoding: "utf-8"
|
|
520
|
+
}).trim();
|
|
521
|
+
return result !== "" ? result : null;
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
function getKeyFromConfig(platform) {
|
|
529
|
+
const config = readConfigFile();
|
|
530
|
+
const encoded = config.ai?.keys?.[platform];
|
|
531
|
+
if (encoded === void 0 || encoded === "") return null;
|
|
532
|
+
try {
|
|
533
|
+
return Buffer.from(encoded, "base64").toString("utf-8");
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function resolveAPIKey(platform) {
|
|
539
|
+
const envKey = getKeyFromEnv(platform);
|
|
540
|
+
if (envKey !== null) return { key: envKey, source: "env" };
|
|
541
|
+
const keychainKey = getKeyFromKeychain(platform);
|
|
542
|
+
if (keychainKey !== null) return { key: keychainKey, source: "keychain" };
|
|
543
|
+
const configKey = getKeyFromConfig(platform);
|
|
544
|
+
if (configKey !== null) return { key: configKey, source: "config" };
|
|
545
|
+
return { key: null, source: null };
|
|
546
|
+
}
|
|
547
|
+
function loadAIConfig() {
|
|
548
|
+
const config = readConfigFile();
|
|
549
|
+
const platform = config.ai?.platform ?? "anthropic";
|
|
550
|
+
const model = config.ai?.model ?? getDefaultModel(platform);
|
|
551
|
+
const { key, source } = resolveAPIKey(platform);
|
|
552
|
+
return { platform, model, apiKey: key, keySource: source };
|
|
553
|
+
}
|
|
554
|
+
function saveAIConfigPreferences(platform, model) {
|
|
555
|
+
const config = readConfigFile();
|
|
556
|
+
if (config.ai === void 0) config.ai = {};
|
|
557
|
+
config.ai.platform = platform;
|
|
558
|
+
config.ai.model = model;
|
|
559
|
+
writeConfigFile(config);
|
|
560
|
+
}
|
|
561
|
+
function saveAPIKey(platform, key, storage) {
|
|
562
|
+
if (storage === "keychain") {
|
|
563
|
+
saveKeyToKeychain(platform, key);
|
|
564
|
+
} else {
|
|
565
|
+
const config = readConfigFile();
|
|
566
|
+
if (config.ai === void 0) config.ai = {};
|
|
567
|
+
if (config.ai.keys === void 0) config.ai.keys = {};
|
|
568
|
+
config.ai.keys[platform] = Buffer.from(key).toString("base64");
|
|
569
|
+
writeConfigFile(config);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function saveKeyToKeychain(platform, key) {
|
|
573
|
+
const os = process.platform;
|
|
574
|
+
const account = `${platform}-api-key`;
|
|
575
|
+
if (os === "darwin") {
|
|
576
|
+
try {
|
|
577
|
+
execSync(`security delete-generic-password -s glassbox -a "${account}" 2>/dev/null`);
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
execSync(
|
|
581
|
+
`security add-generic-password -s glassbox -a "${account}" -w "${key.replace(/"/g, '\\"')}"`
|
|
582
|
+
);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (os === "linux") {
|
|
586
|
+
execSync(
|
|
587
|
+
`secret-tool store --label='Glassbox API Key' service glassbox account "${account}"`,
|
|
588
|
+
{ input: key, encoding: "utf-8" }
|
|
589
|
+
);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (os === "win32") {
|
|
593
|
+
const target = winCredTarget(platform);
|
|
594
|
+
const escapedKey = key.replace(/'/g, "''");
|
|
595
|
+
const script = `cmdkey /generic:'${target}' /user:'glassbox' /pass:'${escapedKey}'`;
|
|
596
|
+
execSync("powershell -NoProfile -Command -", {
|
|
597
|
+
input: script,
|
|
598
|
+
encoding: "utf-8"
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function deleteAPIKey(platform) {
|
|
603
|
+
const os = process.platform;
|
|
604
|
+
const account = `${platform}-api-key`;
|
|
605
|
+
try {
|
|
606
|
+
if (os === "darwin") {
|
|
607
|
+
execSync(`security delete-generic-password -s glassbox -a "${account}" 2>/dev/null`);
|
|
608
|
+
} else if (os === "linux") {
|
|
609
|
+
execSync(`secret-tool clear service glassbox account "${account}" 2>/dev/null`);
|
|
610
|
+
} else if (os === "win32") {
|
|
611
|
+
const target = winCredTarget(platform);
|
|
612
|
+
execSync("powershell -NoProfile -Command -", {
|
|
613
|
+
input: `cmdkey /delete:'${target}'`,
|
|
614
|
+
encoding: "utf-8"
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
const config = readConfigFile();
|
|
620
|
+
if (config.ai?.keys !== void 0) {
|
|
621
|
+
config.ai.keys[platform] = "";
|
|
622
|
+
writeConfigFile(config);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function detectAvailablePlatforms() {
|
|
626
|
+
const results = [];
|
|
627
|
+
for (const platform of ["anthropic", "openai", "google"]) {
|
|
628
|
+
const { source } = resolveAPIKey(platform);
|
|
629
|
+
if (source !== null) {
|
|
630
|
+
results.push({ platform, source });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return results;
|
|
634
|
+
}
|
|
635
|
+
function isKeychainAvailable() {
|
|
636
|
+
const os = process.platform;
|
|
637
|
+
if (os === "darwin" || os === "win32") return true;
|
|
638
|
+
if (os === "linux") {
|
|
639
|
+
try {
|
|
640
|
+
execSync("which secret-tool 2>/dev/null", { encoding: "utf-8" });
|
|
641
|
+
return true;
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
function getKeychainLabel() {
|
|
649
|
+
const os = process.platform;
|
|
650
|
+
if (os === "darwin") return "Keychain";
|
|
651
|
+
if (os === "linux") return "System Keyring";
|
|
652
|
+
if (os === "win32") return "Credential Manager";
|
|
653
|
+
return "System Keychain";
|
|
654
|
+
}
|
|
655
|
+
function loadGuidedReviewConfig() {
|
|
656
|
+
const config = readConfigFile();
|
|
657
|
+
return {
|
|
658
|
+
enabled: config.guidedReview?.enabled ?? false,
|
|
659
|
+
topics: config.guidedReview?.topics ?? []
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function saveGuidedReviewConfig(settings) {
|
|
663
|
+
const config = readConfigFile();
|
|
664
|
+
config.guidedReview = { enabled: settings.enabled, topics: settings.topics };
|
|
665
|
+
writeConfigFile(config);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/db/ai-queries.ts
|
|
669
|
+
init_connection();
|
|
670
|
+
function generateId2() {
|
|
671
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
672
|
+
}
|
|
673
|
+
async function createAnalysis(reviewId, analysisType) {
|
|
674
|
+
const db2 = await getDb();
|
|
675
|
+
const id = generateId2();
|
|
676
|
+
const result = await db2.query(
|
|
677
|
+
`INSERT INTO ai_analyses (id, review_id, analysis_type, status)
|
|
678
|
+
VALUES ($1, $2, $3, 'running') RETURNING *`,
|
|
679
|
+
[id, reviewId, analysisType]
|
|
680
|
+
);
|
|
681
|
+
return result.rows[0];
|
|
682
|
+
}
|
|
683
|
+
async function updateAnalysisStatus(id, status, errorMessage) {
|
|
684
|
+
const db2 = await getDb();
|
|
685
|
+
await db2.query(
|
|
686
|
+
"UPDATE ai_analyses SET status = $1, error_message = $2, updated_at = NOW() WHERE id = $3",
|
|
687
|
+
[status, errorMessage ?? null, id]
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
async function updateAnalysisProgress(id, completed, total) {
|
|
691
|
+
const db2 = await getDb();
|
|
692
|
+
await db2.query(
|
|
693
|
+
"UPDATE ai_analyses SET progress_completed = $1, progress_total = $2, updated_at = NOW() WHERE id = $3",
|
|
694
|
+
[completed, total, id]
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
async function getLatestAnalysis(reviewId, analysisType) {
|
|
698
|
+
const db2 = await getDb();
|
|
699
|
+
const result = await db2.query(
|
|
700
|
+
`SELECT * FROM ai_analyses
|
|
701
|
+
WHERE review_id = $1 AND analysis_type = $2
|
|
702
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
703
|
+
[reviewId, analysisType]
|
|
704
|
+
);
|
|
705
|
+
return result.rows[0];
|
|
706
|
+
}
|
|
707
|
+
async function appendFileScores(analysisId, scores) {
|
|
708
|
+
const db2 = await getDb();
|
|
709
|
+
const existing = await db2.query(
|
|
710
|
+
"SELECT file_path FROM ai_file_scores WHERE analysis_id = $1",
|
|
711
|
+
[analysisId]
|
|
712
|
+
);
|
|
713
|
+
const existingPaths = new Set(existing.rows.map((r) => r.file_path));
|
|
714
|
+
for (const score of scores) {
|
|
715
|
+
if (existingPaths.has(score.filePath)) continue;
|
|
716
|
+
const id = generateId2();
|
|
717
|
+
await db2.query(
|
|
718
|
+
`INSERT INTO ai_file_scores (id, analysis_id, review_file_id, file_path, sort_order, aggregate_score, rationale, dimension_scores, notes)
|
|
719
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
720
|
+
[
|
|
721
|
+
id,
|
|
722
|
+
analysisId,
|
|
723
|
+
score.reviewFileId,
|
|
724
|
+
score.filePath,
|
|
725
|
+
score.sortOrder,
|
|
726
|
+
score.aggregateScore,
|
|
727
|
+
score.rationale,
|
|
728
|
+
score.dimensionScores !== null ? JSON.stringify(score.dimensionScores) : null,
|
|
729
|
+
score.notes !== null ? JSON.stringify(score.notes) : null
|
|
730
|
+
]
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function getFileScoresForReview(reviewId, analysisType) {
|
|
735
|
+
const db2 = await getDb();
|
|
736
|
+
const result = await db2.query(
|
|
737
|
+
`SELECT s.* FROM ai_file_scores s
|
|
738
|
+
JOIN ai_analyses a ON s.analysis_id = a.id
|
|
739
|
+
WHERE a.review_id = $1 AND a.analysis_type = $2 AND a.status IN ('completed', 'running')
|
|
740
|
+
ORDER BY a.created_at DESC, s.sort_order
|
|
741
|
+
LIMIT 1000`,
|
|
742
|
+
[reviewId, analysisType]
|
|
743
|
+
);
|
|
744
|
+
if (result.rows.length === 0) return [];
|
|
745
|
+
const latestAnalysisId = result.rows[0].analysis_id;
|
|
746
|
+
const rows = result.rows.filter((r) => r.analysis_id === latestAnalysisId);
|
|
747
|
+
const seen = /* @__PURE__ */ new Set();
|
|
748
|
+
return rows.filter((r) => {
|
|
749
|
+
if (seen.has(r.file_path)) return false;
|
|
750
|
+
seen.add(r.file_path);
|
|
751
|
+
return true;
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async function getPreviousScores(reviewId, analysisType, excludeAnalysisId) {
|
|
755
|
+
const db2 = await getDb();
|
|
756
|
+
for (const status of ["completed", "failed"]) {
|
|
757
|
+
const result = await db2.query(
|
|
758
|
+
`SELECT s.* FROM ai_file_scores s
|
|
759
|
+
JOIN ai_analyses a ON s.analysis_id = a.id
|
|
760
|
+
WHERE a.review_id = $1 AND a.analysis_type = $2 AND a.status = $3
|
|
761
|
+
AND a.id != $4
|
|
762
|
+
ORDER BY a.created_at DESC, s.sort_order
|
|
763
|
+
LIMIT 1000`,
|
|
764
|
+
[reviewId, analysisType, status, excludeAnalysisId]
|
|
765
|
+
);
|
|
766
|
+
if (result.rows.length > 0) {
|
|
767
|
+
const latestAnalysisId = result.rows[0].analysis_id;
|
|
768
|
+
return result.rows.filter((r) => r.analysis_id === latestAnalysisId);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
async function getUserPreferences() {
|
|
774
|
+
const db2 = await getDb();
|
|
775
|
+
const result = await db2.query(
|
|
776
|
+
"SELECT * FROM user_preferences WHERE id = $1",
|
|
777
|
+
["singleton"]
|
|
778
|
+
);
|
|
779
|
+
if (result.rows.length === 0) {
|
|
780
|
+
return { sort_mode: "folder", risk_sort_dimension: "aggregate", show_risk_scores: false };
|
|
781
|
+
}
|
|
782
|
+
return result.rows[0];
|
|
783
|
+
}
|
|
784
|
+
async function saveUserPreferences(prefs) {
|
|
785
|
+
const db2 = await getDb();
|
|
786
|
+
const current = await getUserPreferences();
|
|
787
|
+
const merged = { ...current, ...prefs };
|
|
788
|
+
await db2.query(
|
|
789
|
+
`INSERT INTO user_preferences (id, sort_mode, risk_sort_dimension, show_risk_scores)
|
|
790
|
+
VALUES ($1, $2, $3, $4)
|
|
791
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
792
|
+
sort_mode = EXCLUDED.sort_mode,
|
|
793
|
+
risk_sort_dimension = EXCLUDED.risk_sort_dimension,
|
|
794
|
+
show_risk_scores = EXCLUDED.show_risk_scores`,
|
|
795
|
+
["singleton", merged.sort_mode, merged.risk_sort_dimension, merged.show_risk_scores]
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/demo.ts
|
|
800
|
+
init_queries();
|
|
801
|
+
var DEMO_SCENARIOS = [
|
|
802
|
+
{ id: 1, label: "Main UI with guided review notes" },
|
|
803
|
+
{ id: 2, label: "Risk mode with inline risk notes" },
|
|
804
|
+
{ id: 3, label: "Narrative mode with walkthrough notes" },
|
|
805
|
+
{ id: 4, label: "Annotations with different categories" },
|
|
806
|
+
{ id: 5, label: "Settings dialog with guided review" }
|
|
807
|
+
];
|
|
808
|
+
var DEMO_FILES = [
|
|
809
|
+
{
|
|
810
|
+
path: "src/auth/session.ts",
|
|
811
|
+
status: "modified",
|
|
812
|
+
hunks: [{
|
|
813
|
+
oldStart: 1,
|
|
814
|
+
oldCount: 18,
|
|
815
|
+
newStart: 1,
|
|
816
|
+
newCount: 28,
|
|
817
|
+
lines: [
|
|
818
|
+
{ type: "context", oldNum: 1, newNum: 1, content: "import { randomBytes } from 'crypto';" },
|
|
819
|
+
{ type: "context", oldNum: 2, newNum: 2, content: "import type { Request, Response } from 'express';" },
|
|
820
|
+
{ type: "add", oldNum: null, newNum: 3, content: "import { redis } from '../db/redis.js';" },
|
|
821
|
+
{ type: "context", oldNum: 3, newNum: 4, content: "" },
|
|
822
|
+
{ type: "context", oldNum: 4, newNum: 5, content: "export interface Session {" },
|
|
823
|
+
{ type: "context", oldNum: 5, newNum: 6, content: " id: string;" },
|
|
824
|
+
{ type: "context", oldNum: 6, newNum: 7, content: " userId: string;" },
|
|
825
|
+
{ type: "remove", oldNum: 7, newNum: null, content: " expiresAt: number;" },
|
|
826
|
+
{ type: "add", oldNum: null, newNum: 8, content: " expiresAt: Date;" },
|
|
827
|
+
{ type: "add", oldNum: null, newNum: 9, content: " refreshToken: string;" },
|
|
828
|
+
{ type: "context", oldNum: 8, newNum: 10, content: "}" },
|
|
829
|
+
{ type: "context", oldNum: 9, newNum: 11, content: "" },
|
|
830
|
+
{ type: "remove", oldNum: 10, newNum: null, content: "const sessions = new Map<string, Session>();" },
|
|
831
|
+
{ type: "add", oldNum: null, newNum: 12, content: "const SESSION_TTL = 60 * 60 * 24; // 24 hours" },
|
|
832
|
+
{ type: "context", oldNum: 11, newNum: 13, content: "" },
|
|
833
|
+
{ type: "remove", oldNum: 12, newNum: null, content: "export function createSession(userId: string): Session {" },
|
|
834
|
+
{ type: "remove", oldNum: 13, newNum: null, content: " const session: Session = {" },
|
|
835
|
+
{ type: "remove", oldNum: 14, newNum: null, content: " id: randomBytes(32).toString('hex')," },
|
|
836
|
+
{ type: "remove", oldNum: 15, newNum: null, content: " userId," },
|
|
837
|
+
{ type: "remove", oldNum: 16, newNum: null, content: " expiresAt: Date.now() + 86400000," },
|
|
838
|
+
{ type: "remove", oldNum: 17, newNum: null, content: " };" },
|
|
839
|
+
{ type: "remove", oldNum: 18, newNum: null, content: " sessions.set(session.id, session);" },
|
|
840
|
+
{ type: "add", oldNum: null, newNum: 14, content: "export async function createSession(userId: string): Promise<Session> {" },
|
|
841
|
+
{ type: "add", oldNum: null, newNum: 15, content: " const id = randomBytes(32).toString('hex');" },
|
|
842
|
+
{ type: "add", oldNum: null, newNum: 16, content: " const refreshToken = randomBytes(48).toString('hex');" },
|
|
843
|
+
{ type: "add", oldNum: null, newNum: 17, content: " const session: Session = {" },
|
|
844
|
+
{ type: "add", oldNum: null, newNum: 18, content: " id," },
|
|
845
|
+
{ type: "add", oldNum: null, newNum: 19, content: " userId," },
|
|
846
|
+
{ type: "add", oldNum: null, newNum: 20, content: " expiresAt: new Date(Date.now() + SESSION_TTL * 1000)," },
|
|
847
|
+
{ type: "add", oldNum: null, newNum: 21, content: " refreshToken," },
|
|
848
|
+
{ type: "add", oldNum: null, newNum: 22, content: " };" },
|
|
849
|
+
{ type: "add", oldNum: null, newNum: 23, content: " await redis.set(`session:${id}`, JSON.stringify(session), 'EX', SESSION_TTL);" },
|
|
850
|
+
{ type: "context", oldNum: 19, newNum: 24, content: " return session;" },
|
|
851
|
+
{ type: "context", oldNum: 20, newNum: 25, content: "}" },
|
|
852
|
+
{ type: "context", oldNum: 21, newNum: 26, content: "" },
|
|
853
|
+
{ type: "add", oldNum: null, newNum: 27, content: "export async function validateSession(id: string): Promise<Session | null> {" },
|
|
854
|
+
{ type: "add", oldNum: null, newNum: 28, content: " const raw = await redis.get(`session:${id}`);" },
|
|
855
|
+
{ type: "add", oldNum: null, newNum: 29, content: " if (raw === null) return null;" },
|
|
856
|
+
{ type: "add", oldNum: null, newNum: 30, content: " const session = JSON.parse(raw) as Session;" },
|
|
857
|
+
{ type: "add", oldNum: null, newNum: 31, content: " if (new Date(session.expiresAt) < new Date()) return null;" },
|
|
858
|
+
{ type: "add", oldNum: null, newNum: 32, content: " return session;" },
|
|
859
|
+
{ type: "add", oldNum: null, newNum: 33, content: "}" }
|
|
860
|
+
]
|
|
861
|
+
}]
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
path: "src/api/routes/users.ts",
|
|
865
|
+
status: "modified",
|
|
866
|
+
hunks: [{
|
|
867
|
+
oldStart: 12,
|
|
868
|
+
oldCount: 10,
|
|
869
|
+
newStart: 12,
|
|
870
|
+
newCount: 16,
|
|
871
|
+
lines: [
|
|
872
|
+
{ type: "context", oldNum: 12, newNum: 12, content: "router.post('/login', async (req, res) => {" },
|
|
873
|
+
{ type: "context", oldNum: 13, newNum: 13, content: " const { email, password } = req.body;" },
|
|
874
|
+
{ type: "add", oldNum: null, newNum: 14, content: "" },
|
|
875
|
+
{ type: "add", oldNum: null, newNum: 15, content: " if (!email || !password) {" },
|
|
876
|
+
{ type: "add", oldNum: null, newNum: 16, content: " return res.status(400).json({ error: 'Missing credentials' });" },
|
|
877
|
+
{ type: "add", oldNum: null, newNum: 17, content: " }" },
|
|
878
|
+
{ type: "context", oldNum: 14, newNum: 18, content: "" },
|
|
879
|
+
{ type: "context", oldNum: 15, newNum: 19, content: " const user = await findUserByEmail(email);" },
|
|
880
|
+
{ type: "remove", oldNum: 16, newNum: null, content: " if (!user || user.password !== password) {" },
|
|
881
|
+
{ type: "add", oldNum: null, newNum: 20, content: " if (!user || !(await verifyPassword(password, user.passwordHash))) {" },
|
|
882
|
+
{ type: "context", oldNum: 17, newNum: 21, content: " return res.status(401).json({ error: 'Invalid credentials' });" },
|
|
883
|
+
{ type: "context", oldNum: 18, newNum: 22, content: " }" },
|
|
884
|
+
{ type: "context", oldNum: 19, newNum: 23, content: "" },
|
|
885
|
+
{ type: "remove", oldNum: 20, newNum: null, content: " const session = createSession(user.id);" },
|
|
886
|
+
{ type: "add", oldNum: null, newNum: 24, content: " const session = await createSession(user.id);" },
|
|
887
|
+
{ type: "add", oldNum: null, newNum: 25, content: " res.cookie('sid', session.id, { httpOnly: true, secure: true, sameSite: 'strict' });" },
|
|
888
|
+
{ type: "context", oldNum: 21, newNum: 26, content: " res.json({ ok: true });" },
|
|
889
|
+
{ type: "context", oldNum: 22, newNum: 27, content: "});" }
|
|
890
|
+
]
|
|
891
|
+
}]
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
path: "src/db/redis.ts",
|
|
895
|
+
status: "added",
|
|
896
|
+
hunks: [{
|
|
897
|
+
oldStart: 0,
|
|
898
|
+
oldCount: 0,
|
|
899
|
+
newStart: 1,
|
|
900
|
+
newCount: 12,
|
|
901
|
+
lines: [
|
|
902
|
+
{ type: "add", oldNum: null, newNum: 1, content: "import Redis from 'ioredis';" },
|
|
903
|
+
{ type: "add", oldNum: null, newNum: 2, content: "" },
|
|
904
|
+
{ type: "add", oldNum: null, newNum: 3, content: "const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';" },
|
|
905
|
+
{ type: "add", oldNum: null, newNum: 4, content: "" },
|
|
906
|
+
{ type: "add", oldNum: null, newNum: 5, content: "export const redis = new Redis(REDIS_URL, {" },
|
|
907
|
+
{ type: "add", oldNum: null, newNum: 6, content: " maxRetriesPerRequest: 3," },
|
|
908
|
+
{ type: "add", oldNum: null, newNum: 7, content: " retryStrategy(times) {" },
|
|
909
|
+
{ type: "add", oldNum: null, newNum: 8, content: " return Math.min(times * 200, 5000);" },
|
|
910
|
+
{ type: "add", oldNum: null, newNum: 9, content: " }," },
|
|
911
|
+
{ type: "add", oldNum: null, newNum: 10, content: "});" },
|
|
912
|
+
{ type: "add", oldNum: null, newNum: 11, content: "" },
|
|
913
|
+
{ type: "add", oldNum: null, newNum: 12, content: "redis.on('error', (err) => console.error('Redis error:', err));" }
|
|
914
|
+
]
|
|
915
|
+
}]
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
path: "src/middleware/auth.ts",
|
|
919
|
+
status: "modified",
|
|
920
|
+
hunks: [{
|
|
921
|
+
oldStart: 1,
|
|
922
|
+
oldCount: 8,
|
|
923
|
+
newStart: 1,
|
|
924
|
+
newCount: 14,
|
|
925
|
+
lines: [
|
|
926
|
+
{ type: "context", oldNum: 1, newNum: 1, content: "import type { NextFunction, Request, Response } from 'express';" },
|
|
927
|
+
{ type: "remove", oldNum: 2, newNum: null, content: "import { getSession } from '../auth/session.js';" },
|
|
928
|
+
{ type: "add", oldNum: null, newNum: 2, content: "import { validateSession } from '../auth/session.js';" },
|
|
929
|
+
{ type: "context", oldNum: 3, newNum: 3, content: "" },
|
|
930
|
+
{ type: "remove", oldNum: 4, newNum: null, content: "export function requireAuth(req: Request, res: Response, next: NextFunction) {" },
|
|
931
|
+
{ type: "remove", oldNum: 5, newNum: null, content: " const session = getSession(req.cookies.sid);" },
|
|
932
|
+
{ type: "remove", oldNum: 6, newNum: null, content: " if (!session) return res.status(401).end();" },
|
|
933
|
+
{ type: "add", oldNum: null, newNum: 4, content: "export async function requireAuth(req: Request, res: Response, next: NextFunction) {" },
|
|
934
|
+
{ type: "add", oldNum: null, newNum: 5, content: " const sid = req.cookies.sid ?? req.headers.authorization?.replace(/^Bearer /, '');" },
|
|
935
|
+
{ type: "add", oldNum: null, newNum: 6, content: " if (!sid) return res.status(401).json({ error: 'Not authenticated' });" },
|
|
936
|
+
{ type: "add", oldNum: null, newNum: 7, content: "" },
|
|
937
|
+
{ type: "add", oldNum: null, newNum: 8, content: " const session = await validateSession(sid);" },
|
|
938
|
+
{ type: "add", oldNum: null, newNum: 9, content: " if (!session) return res.status(401).json({ error: 'Session expired' });" },
|
|
939
|
+
{ type: "context", oldNum: 7, newNum: 10, content: "" },
|
|
940
|
+
{ type: "context", oldNum: 8, newNum: 11, content: " req.userId = session.userId;" },
|
|
941
|
+
{ type: "add", oldNum: null, newNum: 12, content: " req.sessionId = session.id;" },
|
|
942
|
+
{ type: "context", oldNum: 9, newNum: 13, content: " next();" },
|
|
943
|
+
{ type: "context", oldNum: 10, newNum: 14, content: "}" }
|
|
944
|
+
]
|
|
945
|
+
}]
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
path: "src/utils/password.ts",
|
|
949
|
+
status: "added",
|
|
950
|
+
hunks: [{
|
|
951
|
+
oldStart: 0,
|
|
952
|
+
oldCount: 0,
|
|
953
|
+
newStart: 1,
|
|
954
|
+
newCount: 11,
|
|
955
|
+
lines: [
|
|
956
|
+
{ type: "add", oldNum: null, newNum: 1, content: "import bcrypt from 'bcrypt';" },
|
|
957
|
+
{ type: "add", oldNum: null, newNum: 2, content: "" },
|
|
958
|
+
{ type: "add", oldNum: null, newNum: 3, content: "const SALT_ROUNDS = 12;" },
|
|
959
|
+
{ type: "add", oldNum: null, newNum: 4, content: "" },
|
|
960
|
+
{ type: "add", oldNum: null, newNum: 5, content: "export async function hashPassword(password: string): Promise<string> {" },
|
|
961
|
+
{ type: "add", oldNum: null, newNum: 6, content: " return bcrypt.hash(password, SALT_ROUNDS);" },
|
|
962
|
+
{ type: "add", oldNum: null, newNum: 7, content: "}" },
|
|
963
|
+
{ type: "add", oldNum: null, newNum: 8, content: "" },
|
|
964
|
+
{ type: "add", oldNum: null, newNum: 9, content: "export async function verifyPassword(password: string, hash: string): Promise<boolean> {" },
|
|
965
|
+
{ type: "add", oldNum: null, newNum: 10, content: " return bcrypt.compare(password, hash);" },
|
|
966
|
+
{ type: "add", oldNum: null, newNum: 11, content: "}" }
|
|
967
|
+
]
|
|
968
|
+
}]
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
path: "tests/auth.test.ts",
|
|
972
|
+
status: "added",
|
|
973
|
+
hunks: [{
|
|
974
|
+
oldStart: 0,
|
|
975
|
+
oldCount: 0,
|
|
976
|
+
newStart: 1,
|
|
977
|
+
newCount: 15,
|
|
978
|
+
lines: [
|
|
979
|
+
{ type: "add", oldNum: null, newNum: 1, content: "import { describe, expect, it } from 'vitest';" },
|
|
980
|
+
{ type: "add", oldNum: null, newNum: 2, content: "import { createSession, validateSession } from '../src/auth/session.js';" },
|
|
981
|
+
{ type: "add", oldNum: null, newNum: 3, content: "" },
|
|
982
|
+
{ type: "add", oldNum: null, newNum: 4, content: "describe('Session management', () => {" },
|
|
983
|
+
{ type: "add", oldNum: null, newNum: 5, content: " it('creates and validates a session', async () => {" },
|
|
984
|
+
{ type: "add", oldNum: null, newNum: 6, content: " const session = await createSession('user-1');" },
|
|
985
|
+
{ type: "add", oldNum: null, newNum: 7, content: " expect(session.id).toBeDefined();" },
|
|
986
|
+
{ type: "add", oldNum: null, newNum: 8, content: " expect(session.userId).toBe('user-1');" },
|
|
987
|
+
{ type: "add", oldNum: null, newNum: 9, content: "" },
|
|
988
|
+
{ type: "add", oldNum: null, newNum: 10, content: " const valid = await validateSession(session.id);" },
|
|
989
|
+
{ type: "add", oldNum: null, newNum: 11, content: " expect(valid).not.toBeNull();" },
|
|
990
|
+
{ type: "add", oldNum: null, newNum: 12, content: " });" },
|
|
991
|
+
{ type: "add", oldNum: null, newNum: 13, content: "" },
|
|
992
|
+
{ type: "add", oldNum: null, newNum: 14, content: " it('rejects expired sessions', async () => {" },
|
|
993
|
+
{ type: "add", oldNum: null, newNum: 15, content: " const result = await validateSession('nonexistent');" },
|
|
994
|
+
{ type: "add", oldNum: null, newNum: 16, content: " expect(result).toBeNull();" },
|
|
995
|
+
{ type: "add", oldNum: null, newNum: 17, content: " });" },
|
|
996
|
+
{ type: "add", oldNum: null, newNum: 18, content: "});" }
|
|
997
|
+
]
|
|
998
|
+
}]
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
path: "package.json",
|
|
1002
|
+
status: "modified",
|
|
1003
|
+
hunks: [{
|
|
1004
|
+
oldStart: 10,
|
|
1005
|
+
oldCount: 3,
|
|
1006
|
+
newStart: 10,
|
|
1007
|
+
newCount: 5,
|
|
1008
|
+
lines: [
|
|
1009
|
+
{ type: "context", oldNum: 10, newNum: 10, content: ' "dependencies": {' },
|
|
1010
|
+
{ type: "context", oldNum: 11, newNum: 11, content: ' "express": "^4.18.2",' },
|
|
1011
|
+
{ type: "add", oldNum: null, newNum: 12, content: ' "bcrypt": "^5.1.1",' },
|
|
1012
|
+
{ type: "add", oldNum: null, newNum: 13, content: ' "ioredis": "^5.3.2",' },
|
|
1013
|
+
{ type: "context", oldNum: 12, newNum: 14, content: ' "typescript": "^5.3.0"' }
|
|
1014
|
+
]
|
|
1015
|
+
}]
|
|
1016
|
+
}
|
|
1017
|
+
];
|
|
1018
|
+
var GUIDED_NOTES = {
|
|
1019
|
+
"src/auth/session.ts": {
|
|
1020
|
+
overview: "This file manages user sessions \u2014 the mechanism that keeps you logged in across requests. The change migrates from in-memory storage (a Map) to Redis, which persists sessions across server restarts and works in multi-server deployments.",
|
|
1021
|
+
lines: [
|
|
1022
|
+
{ line: 3, content: "Redis is an in-memory data store that runs as a separate process. Unlike a JavaScript Map, data in Redis survives server restarts and can be shared across multiple server instances." },
|
|
1023
|
+
{ line: 8, content: "Changed from a number (Unix timestamp) to a Date object. Date objects are more readable and less error-prone than raw milliseconds \u2014 compare 'new Date() > expiresAt' vs 'Date.now() > expiresAt'." },
|
|
1024
|
+
{ line: 9, content: "A refresh token allows the client to get a new session without re-entering credentials. This is a security best practice \u2014 short-lived sessions with refresh tokens limit the damage if a session ID is stolen." },
|
|
1025
|
+
{ line: 12, content: "Defining the TTL as a named constant makes the code self-documenting. The '60 * 60 * 24' expression is clearer than a magic number like 86400." },
|
|
1026
|
+
{ line: 23, content: "Template literals (backtick strings) let you embed expressions with ${...}. The 'EX' flag tells Redis to automatically delete this key after SESSION_TTL seconds \u2014 no need for manual cleanup." },
|
|
1027
|
+
{ line: 30, content: "JSON.parse returns 'unknown' in strict TypeScript. The 'as Session' is a type assertion telling the compiler to trust that the stored JSON matches the Session shape." }
|
|
1028
|
+
]
|
|
1029
|
+
},
|
|
1030
|
+
"src/api/routes/users.ts": {
|
|
1031
|
+
overview: "The login route now validates input, uses proper password hashing instead of plaintext comparison, and sets a secure HTTP cookie. These are fundamental security improvements.",
|
|
1032
|
+
lines: [
|
|
1033
|
+
{ line: 15, content: "Input validation is a key security practice. Always verify that required fields exist before using them \u2014 this prevents crashes from undefined values and makes error messages more helpful." },
|
|
1034
|
+
{ line: 20, content: "verifyPassword uses bcrypt to compare the plaintext password against a stored hash. The old code compared passwords directly, which means passwords were stored in plaintext \u2014 a critical security vulnerability." },
|
|
1035
|
+
{ line: 25, content: "httpOnly prevents JavaScript from reading the cookie (mitigates XSS attacks). 'secure' ensures it's only sent over HTTPS. 'sameSite: strict' prevents the cookie from being sent in cross-site requests (mitigates CSRF attacks)." }
|
|
1036
|
+
]
|
|
1037
|
+
},
|
|
1038
|
+
"src/db/redis.ts": {
|
|
1039
|
+
overview: "A new module that initializes the Redis connection. It reads the connection URL from an environment variable with a sensible local default, and includes retry logic for resilience.",
|
|
1040
|
+
lines: [
|
|
1041
|
+
{ line: 3, content: "The nullish coalescing operator (??) returns the right side only if the left is null or undefined. This gives you a fallback: use the env variable if set, otherwise default to localhost." },
|
|
1042
|
+
{ line: 7, content: "A retry strategy controls what happens when Redis is temporarily unreachable. This implements exponential backoff \u2014 waiting longer between each retry, capped at 5 seconds \u2014 to avoid overwhelming a recovering server." },
|
|
1043
|
+
{ line: 12, content: "Listening for 'error' events prevents unhandled exceptions from crashing the process. In Node.js, an EventEmitter that emits 'error' with no listener will throw." }
|
|
1044
|
+
]
|
|
1045
|
+
},
|
|
1046
|
+
"src/middleware/auth.ts": {
|
|
1047
|
+
overview: "The auth middleware now supports both cookie-based and Bearer token authentication, and returns proper JSON error responses instead of empty 401s.",
|
|
1048
|
+
lines: [
|
|
1049
|
+
{ line: 5, content: "Optional chaining (?.) safely accesses properties that might not exist. This line checks for a session ID in cookies first, then falls back to an Authorization header \u2014 supporting both browser and API clients." },
|
|
1050
|
+
{ line: 12, content: "Attaching the session ID to the request object makes it available to downstream route handlers. This is a common Express pattern called 'request augmentation'." }
|
|
1051
|
+
]
|
|
1052
|
+
},
|
|
1053
|
+
"src/utils/password.ts": {
|
|
1054
|
+
overview: "A utility module for securely hashing and verifying passwords using bcrypt. Bcrypt is an industry-standard algorithm specifically designed for password hashing.",
|
|
1055
|
+
lines: [
|
|
1056
|
+
{ line: 3, content: "Salt rounds control how computationally expensive the hash is. 12 rounds means 2^12 (4,096) iterations. Higher is more secure but slower \u2014 12 is a good balance for most applications." },
|
|
1057
|
+
{ line: 6, content: "bcrypt.hash automatically generates a random salt and embeds it in the output. You never need to store the salt separately \u2014 it's included in the hash string itself." },
|
|
1058
|
+
{ line: 10, content: "bcrypt.compare is timing-safe, meaning it takes the same amount of time regardless of where the mismatch occurs. This prevents timing attacks where an attacker measures response times to guess the hash character by character." }
|
|
1059
|
+
]
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
var RISK_SCORES = [
|
|
1063
|
+
{
|
|
1064
|
+
path: "src/auth/session.ts",
|
|
1065
|
+
aggregate: 0.65,
|
|
1066
|
+
scores: { security: 0.65, correctness: 0.4, "error-handling": 0.5, maintainability: 0.2, architecture: 0.3, performance: 0.15 },
|
|
1067
|
+
rationale: "Session data is JSON-serialized without schema validation. Redis key construction uses string interpolation which could be exploited if session IDs contain special characters.",
|
|
1068
|
+
notes: {
|
|
1069
|
+
overview: "Moderate security risk from unvalidated deserialization and potential Redis key injection.",
|
|
1070
|
+
lines: [
|
|
1071
|
+
{ line: 23, content: "Template literal in Redis key \u2014 if 'id' ever contains colons or special Redis characters, this could cause key collisions or unexpected behavior." },
|
|
1072
|
+
{ line: 30, content: "JSON.parse without validation \u2014 a corrupted Redis entry would cause a runtime crash. Consider wrapping in try/catch or using a schema validator like zod." }
|
|
1073
|
+
]
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
path: "src/api/routes/users.ts",
|
|
1078
|
+
aggregate: 0.45,
|
|
1079
|
+
scores: { security: 0.45, correctness: 0.3, "error-handling": 0.35, maintainability: 0.2, architecture: 0.15, performance: 0.1 },
|
|
1080
|
+
rationale: "Login route has good input validation and secure cookie settings, but lacks rate limiting which makes it vulnerable to brute-force attacks.",
|
|
1081
|
+
notes: {
|
|
1082
|
+
overview: "Solid authentication improvements, but missing rate limiting on the login endpoint.",
|
|
1083
|
+
lines: [
|
|
1084
|
+
{ line: 12, content: "No rate limiting on login attempts. Consider adding express-rate-limit to prevent brute-force attacks." }
|
|
1085
|
+
]
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
path: "src/db/redis.ts",
|
|
1090
|
+
aggregate: 0.35,
|
|
1091
|
+
scores: { security: 0.35, correctness: 0.2, "error-handling": 0.3, maintainability: 0.1, architecture: 0.15, performance: 0.1 },
|
|
1092
|
+
rationale: "Redis connection is established at module load time. If Redis is down, the import itself will start retrying, which could delay application startup.",
|
|
1093
|
+
notes: {
|
|
1094
|
+
overview: "Minor risk from eager connection initialization. Connection errors are handled but could delay startup.",
|
|
1095
|
+
lines: [
|
|
1096
|
+
{ line: 5, content: "Connection is created at import time. Consider lazy initialization to avoid blocking app startup if Redis is temporarily unavailable." }
|
|
1097
|
+
]
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
path: "src/middleware/auth.ts",
|
|
1102
|
+
aggregate: 0.3,
|
|
1103
|
+
scores: { security: 0.3, correctness: 0.2, "error-handling": 0.15, maintainability: 0.1, architecture: 0.1, performance: 0.1 },
|
|
1104
|
+
rationale: "Auth middleware correctly validates sessions but the Bearer token extraction could be tighter.",
|
|
1105
|
+
notes: {
|
|
1106
|
+
overview: "Low risk. Bearer token parsing is functional but could be more strict with format validation.",
|
|
1107
|
+
lines: [
|
|
1108
|
+
{ line: 5, content: "The regex replace is loose \u2014 it only strips 'Bearer ' prefix but doesn't validate the token format. A malformed Authorization header would pass through." }
|
|
1109
|
+
]
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
path: "src/utils/password.ts",
|
|
1114
|
+
aggregate: 0.1,
|
|
1115
|
+
scores: { security: 0.1, correctness: 0.05, "error-handling": 0.1, maintainability: 0.05, architecture: 0.05, performance: 0.1 },
|
|
1116
|
+
rationale: "Well-implemented password hashing with appropriate salt rounds. No significant concerns.",
|
|
1117
|
+
notes: {
|
|
1118
|
+
overview: "Clean, secure implementation. Salt rounds are appropriate for production use.",
|
|
1119
|
+
lines: []
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
path: "tests/auth.test.ts",
|
|
1124
|
+
aggregate: 0.05,
|
|
1125
|
+
scores: { security: 0, correctness: 0.05, "error-handling": 0, maintainability: 0.05, architecture: 0, performance: 0 },
|
|
1126
|
+
rationale: "Test file with basic coverage. Consider adding tests for edge cases like expired sessions and concurrent access.",
|
|
1127
|
+
notes: {
|
|
1128
|
+
overview: "Basic test coverage. No security or correctness risks in test code itself.",
|
|
1129
|
+
lines: []
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
path: "package.json",
|
|
1134
|
+
aggregate: 0.15,
|
|
1135
|
+
scores: { security: 0.15, correctness: 0, "error-handling": 0, maintainability: 0.05, architecture: 0, performance: 0 },
|
|
1136
|
+
rationale: "New dependencies are well-known and maintained. Bcrypt 5.x uses N-API bindings which are stable.",
|
|
1137
|
+
notes: {
|
|
1138
|
+
overview: "Low risk. Dependencies are well-maintained and widely used.",
|
|
1139
|
+
lines: []
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
];
|
|
1143
|
+
var NARRATIVE_ORDER = [
|
|
1144
|
+
{ path: "src/utils/password.ts", position: 1, rationale: "Start with this utility \u2014 it introduces the bcrypt dependency used by the login route.", notes: { overview: "Read first: this new utility module is a building block used by the authentication changes that follow.", lines: [{ line: 3, content: "This salt rounds constant is referenced conceptually in the login route changes." }] } },
|
|
1145
|
+
{ path: "src/db/redis.ts", position: 2, rationale: "Redis client setup \u2014 needed to understand the session storage migration.", notes: { overview: "Read second: the Redis client created here replaces the in-memory Map used for sessions.", lines: [{ line: 5, content: "This exported redis instance is imported by the session module next." }] } },
|
|
1146
|
+
{ path: "src/auth/session.ts", position: 3, rationale: "Core session changes \u2014 depends on Redis client, used by auth middleware.", notes: { overview: "The main change: sessions move from an in-memory Map to Redis. The interface gains a refresh token and the functions become async.", lines: [{ line: 14, content: "Note this is now async \u2014 all callers need to be updated to await the result." }, { line: 27, content: "New function that replaces the old synchronous getSession." }] } },
|
|
1147
|
+
{ path: "src/middleware/auth.ts", position: 4, rationale: "Auth middleware \u2014 consumes the new async session API.", notes: { overview: "Updated to use the new async validateSession. Also adds Bearer token support for API clients.", lines: [{ line: 4, content: "Changed to async to accommodate the Redis-backed session lookup." }] } },
|
|
1148
|
+
{ path: "src/api/routes/users.ts", position: 5, rationale: "Login route \u2014 integrates password hashing and new session creation.", notes: { overview: "The login endpoint ties together the password utility and session changes.", lines: [{ line: 20, content: "This is where verifyPassword (from step 1) gets used in practice." }, { line: 24, content: "The await here is new \u2014 createSession is now async because of the Redis migration." }] } },
|
|
1149
|
+
{ path: "package.json", position: 6, rationale: "Dependencies \u2014 confirms bcrypt and ioredis were added.", notes: { overview: "Quick check: the two new dependencies (bcrypt, ioredis) that the code changes require.", lines: [] } },
|
|
1150
|
+
{ path: "tests/auth.test.ts", position: 7, rationale: "Tests \u2014 read last to verify the changes work correctly.", notes: { overview: "Tests for the session management changes. Read these last to confirm the new async API works as expected.", lines: [] } }
|
|
1151
|
+
];
|
|
1152
|
+
var ANNOTATIONS = [
|
|
1153
|
+
{ filePath: "src/auth/session.ts", line: 23, side: "new", category: "bug", content: "Redis key should be sanitized \u2014 if a session ID contains a colon, it will conflict with the key namespace." },
|
|
1154
|
+
{ filePath: "src/auth/session.ts", line: 30, side: "new", category: "fix", content: "Wrap JSON.parse in try/catch to handle corrupted Redis data gracefully instead of crashing." },
|
|
1155
|
+
{ filePath: "src/auth/session.ts", line: 12, side: "new", category: "pattern-follow", content: "Good use of a named constant instead of a magic number. This makes the TTL self-documenting." },
|
|
1156
|
+
{ filePath: "src/api/routes/users.ts", line: 15, side: "new", category: "pattern-follow", content: "Good input validation pattern \u2014 checking required fields early and returning a descriptive error." },
|
|
1157
|
+
{ filePath: "src/api/routes/users.ts", line: 25, side: "new", category: "style", content: "Consider extracting cookie options into a shared constant so they're consistent across all cookie-setting code." },
|
|
1158
|
+
{ filePath: "src/api/routes/users.ts", line: 12, side: "new", category: "fix", content: "Add rate limiting to prevent brute-force login attempts. Use express-rate-limit with a window of 15 minutes and max 5 attempts." },
|
|
1159
|
+
{ filePath: "src/db/redis.ts", line: 5, side: "new", category: "note", content: "Consider using lazy initialization so the app can start even if Redis is temporarily down." },
|
|
1160
|
+
{ filePath: "src/middleware/auth.ts", line: 5, side: "new", category: "fix", content: "Validate the token format after extracting it. A regex like /^[a-f0-9]{64}$/ would reject malformed tokens early." },
|
|
1161
|
+
{ filePath: "src/utils/password.ts", line: 3, side: "new", category: "remember", content: "Always use bcrypt or argon2 for password hashing. Never use SHA/MD5 for passwords." }
|
|
1162
|
+
];
|
|
1163
|
+
async function setupDemoReview(scenario) {
|
|
1164
|
+
const repoRoot = process.cwd();
|
|
1165
|
+
const review = await createReview(repoRoot, "demo-project", "demo", `scenario-${String(scenario)}`);
|
|
1166
|
+
const fileIdMap = /* @__PURE__ */ new Map();
|
|
1167
|
+
for (const file of DEMO_FILES) {
|
|
1168
|
+
const diff = {
|
|
1169
|
+
filePath: file.path,
|
|
1170
|
+
oldPath: null,
|
|
1171
|
+
status: file.status,
|
|
1172
|
+
hunks: file.hunks,
|
|
1173
|
+
isBinary: false
|
|
1174
|
+
};
|
|
1175
|
+
const rf = await addReviewFile(review.id, file.path, JSON.stringify(diff));
|
|
1176
|
+
fileIdMap.set(file.path, rf.id);
|
|
1177
|
+
}
|
|
1178
|
+
const { updateFileStatus: updateFileStatus2 } = await Promise.resolve().then(() => (init_queries(), queries_exports));
|
|
1179
|
+
const reviewedPaths = ["src/utils/password.ts", "src/db/redis.ts", "package.json"];
|
|
1180
|
+
for (const p of reviewedPaths) {
|
|
1181
|
+
const fid = fileIdMap.get(p);
|
|
1182
|
+
if (fid !== void 0) await updateFileStatus2(fid, "reviewed");
|
|
1183
|
+
}
|
|
1184
|
+
switch (scenario) {
|
|
1185
|
+
case 1:
|
|
1186
|
+
await setupGuidedNotes(review.id, fileIdMap);
|
|
1187
|
+
saveGuidedReviewConfig({ enabled: true, topics: ["codebase", "typescript"] });
|
|
1188
|
+
await saveUserPreferences({ sort_mode: "folder" });
|
|
1189
|
+
break;
|
|
1190
|
+
case 2:
|
|
1191
|
+
await setupRiskScores(review.id, fileIdMap);
|
|
1192
|
+
await saveUserPreferences({ sort_mode: "risk", show_risk_scores: true });
|
|
1193
|
+
break;
|
|
1194
|
+
case 3:
|
|
1195
|
+
await setupNarrativeOrder(review.id, fileIdMap);
|
|
1196
|
+
await saveUserPreferences({ sort_mode: "narrative" });
|
|
1197
|
+
break;
|
|
1198
|
+
case 4:
|
|
1199
|
+
await setupAnnotations(fileIdMap);
|
|
1200
|
+
await saveUserPreferences({ sort_mode: "folder" });
|
|
1201
|
+
break;
|
|
1202
|
+
case 5:
|
|
1203
|
+
saveGuidedReviewConfig({ enabled: true, topics: ["programming", "codebase", "typescript", "javascript"] });
|
|
1204
|
+
await saveUserPreferences({ sort_mode: "folder" });
|
|
1205
|
+
break;
|
|
1206
|
+
default:
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
return { reviewId: review.id };
|
|
1210
|
+
}
|
|
1211
|
+
async function setupGuidedNotes(reviewId, fileIdMap) {
|
|
1212
|
+
const analysis = await createAnalysis(reviewId, "guided");
|
|
1213
|
+
const scores = Object.entries(GUIDED_NOTES).map(([path, notes], idx) => ({
|
|
1214
|
+
reviewFileId: fileIdMap.get(path) ?? "",
|
|
1215
|
+
filePath: path,
|
|
1216
|
+
sortOrder: idx,
|
|
1217
|
+
aggregateScore: null,
|
|
1218
|
+
rationale: null,
|
|
1219
|
+
dimensionScores: null,
|
|
1220
|
+
notes
|
|
1221
|
+
}));
|
|
1222
|
+
await appendFileScores(analysis.id, scores);
|
|
1223
|
+
await updateAnalysisStatus(analysis.id, "completed");
|
|
1224
|
+
}
|
|
1225
|
+
async function setupRiskScores(reviewId, fileIdMap) {
|
|
1226
|
+
const analysis = await createAnalysis(reviewId, "risk");
|
|
1227
|
+
const sorted = RISK_SCORES.slice().sort((a, b) => b.aggregate - a.aggregate);
|
|
1228
|
+
const scores = sorted.map((r, idx) => ({
|
|
1229
|
+
reviewFileId: fileIdMap.get(r.path) ?? "",
|
|
1230
|
+
filePath: r.path,
|
|
1231
|
+
sortOrder: idx,
|
|
1232
|
+
aggregateScore: r.aggregate,
|
|
1233
|
+
rationale: r.rationale,
|
|
1234
|
+
dimensionScores: r.scores,
|
|
1235
|
+
notes: r.notes
|
|
1236
|
+
}));
|
|
1237
|
+
await appendFileScores(analysis.id, scores);
|
|
1238
|
+
await updateAnalysisStatus(analysis.id, "completed");
|
|
1239
|
+
}
|
|
1240
|
+
async function setupNarrativeOrder(reviewId, fileIdMap) {
|
|
1241
|
+
const analysis = await createAnalysis(reviewId, "narrative");
|
|
1242
|
+
const scores = NARRATIVE_ORDER.map((r) => ({
|
|
1243
|
+
reviewFileId: fileIdMap.get(r.path) ?? "",
|
|
1244
|
+
filePath: r.path,
|
|
1245
|
+
sortOrder: r.position,
|
|
1246
|
+
aggregateScore: null,
|
|
1247
|
+
rationale: r.rationale,
|
|
1248
|
+
dimensionScores: null,
|
|
1249
|
+
notes: r.notes
|
|
1250
|
+
}));
|
|
1251
|
+
await appendFileScores(analysis.id, scores);
|
|
1252
|
+
await updateAnalysisStatus(analysis.id, "completed");
|
|
1253
|
+
}
|
|
1254
|
+
async function setupAnnotations(fileIdMap) {
|
|
1255
|
+
for (const ann of ANNOTATIONS) {
|
|
1256
|
+
const fileId = fileIdMap.get(ann.filePath);
|
|
1257
|
+
if (fileId !== void 0) {
|
|
1258
|
+
await addAnnotation(fileId, ann.line, ann.side, ann.category, ann.content);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/git/diff.ts
|
|
1264
|
+
import { execSync as execSync2 } from "child_process";
|
|
1265
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
261
1266
|
import { resolve } from "path";
|
|
262
1267
|
function git(args, cwd) {
|
|
263
1268
|
try {
|
|
264
|
-
return
|
|
1269
|
+
return execSync2(`git ${args}`, { cwd, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
|
|
265
1270
|
} catch (e) {
|
|
266
1271
|
const err = e;
|
|
267
1272
|
if (err.stdout !== void 0 && err.stdout !== "") return err.stdout;
|
|
@@ -336,7 +1341,7 @@ function getAllFiles(repoRoot) {
|
|
|
336
1341
|
function createNewFileDiff(filePath, repoRoot) {
|
|
337
1342
|
let content;
|
|
338
1343
|
try {
|
|
339
|
-
const buf =
|
|
1344
|
+
const buf = readFileSync2(resolve(repoRoot, filePath));
|
|
340
1345
|
const checkLen = Math.min(buf.length, 8192);
|
|
341
1346
|
for (let i = 0; i < checkLen; i++) {
|
|
342
1347
|
if (buf[i] === 0) {
|
|
@@ -377,13 +1382,13 @@ function parseDiff(raw2) {
|
|
|
377
1382
|
if (headerEnd === -1 && !header.includes("Binary")) {
|
|
378
1383
|
const pathMatch2 = chunk.match(/^a\/(.+?) b\/(.+)/m);
|
|
379
1384
|
if (pathMatch2) {
|
|
380
|
-
const
|
|
1385
|
+
const isBinary3 = header.includes("Binary");
|
|
381
1386
|
files.push({
|
|
382
1387
|
filePath: pathMatch2[2],
|
|
383
1388
|
oldPath: pathMatch2[1] !== pathMatch2[2] ? pathMatch2[1] : null,
|
|
384
1389
|
status: header.includes("new file") ? "added" : header.includes("deleted file") ? "deleted" : "modified",
|
|
385
1390
|
hunks: [],
|
|
386
|
-
isBinary:
|
|
1391
|
+
isBinary: isBinary3
|
|
387
1392
|
});
|
|
388
1393
|
}
|
|
389
1394
|
continue;
|
|
@@ -396,8 +1401,8 @@ function parseDiff(raw2) {
|
|
|
396
1401
|
if (header.includes("new file mode")) status = "added";
|
|
397
1402
|
else if (header.includes("deleted file mode")) status = "deleted";
|
|
398
1403
|
else if (oldPath !== null) status = "renamed";
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
1404
|
+
const isBinary2 = header.includes("Binary file");
|
|
1405
|
+
if (isBinary2) {
|
|
401
1406
|
files.push({ filePath, oldPath, status, hunks: [], isBinary: true });
|
|
402
1407
|
continue;
|
|
403
1408
|
}
|
|
@@ -457,7 +1462,7 @@ function getFileContent(filePath, ref, cwd) {
|
|
|
457
1462
|
const repoRoot = getRepoRoot(cwd);
|
|
458
1463
|
try {
|
|
459
1464
|
if (ref === "working") {
|
|
460
|
-
return
|
|
1465
|
+
return execSync2(`cat "${resolve(repoRoot, filePath)}"`, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
|
461
1466
|
}
|
|
462
1467
|
return git(`show ${ref}:${filePath}`, repoRoot);
|
|
463
1468
|
} catch {
|
|
@@ -465,7 +1470,7 @@ function getFileContent(filePath, ref, cwd) {
|
|
|
465
1470
|
}
|
|
466
1471
|
}
|
|
467
1472
|
function getHeadCommit(cwd) {
|
|
468
|
-
return
|
|
1473
|
+
return execSync2("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
|
|
469
1474
|
}
|
|
470
1475
|
function getModeString(mode) {
|
|
471
1476
|
switch (mode.type) {
|
|
@@ -504,143 +1509,1359 @@ function getModeArgs(mode) {
|
|
|
504
1509
|
return void 0;
|
|
505
1510
|
}
|
|
506
1511
|
}
|
|
507
|
-
|
|
508
|
-
// src/review-update.ts
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (side === "
|
|
514
|
-
|
|
1512
|
+
|
|
1513
|
+
// src/review-update.ts
|
|
1514
|
+
init_queries();
|
|
1515
|
+
function findLineContent(diff, lineNumber, side) {
|
|
1516
|
+
for (const hunk of diff.hunks) {
|
|
1517
|
+
for (const line of hunk.lines) {
|
|
1518
|
+
if (side === "old" && line.oldNum === lineNumber) return line.content;
|
|
1519
|
+
if (side === "new" && line.newNum === lineNumber) return line.content;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
function findMatchingLine(diff, content, origLineNum, side, radius = 10) {
|
|
1525
|
+
let bestMatch = null;
|
|
1526
|
+
let bestDistance = Infinity;
|
|
1527
|
+
for (const hunk of diff.hunks) {
|
|
1528
|
+
for (const line of hunk.lines) {
|
|
1529
|
+
if (line.content !== content) continue;
|
|
1530
|
+
const lineNum = side === "old" ? line.oldNum : line.newNum;
|
|
1531
|
+
if (lineNum == null) continue;
|
|
1532
|
+
const distance = Math.abs(lineNum - origLineNum);
|
|
1533
|
+
if (distance <= radius && distance < bestDistance) {
|
|
1534
|
+
bestDistance = distance;
|
|
1535
|
+
bestMatch = { lineNumber: lineNum, side };
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return bestMatch;
|
|
1540
|
+
}
|
|
1541
|
+
async function migrateAnnotations(annotations, oldDiff, newDiff) {
|
|
1542
|
+
let staleCount = 0;
|
|
1543
|
+
for (const annotation of annotations) {
|
|
1544
|
+
const oldContent = findLineContent(oldDiff, annotation.line_number, annotation.side);
|
|
1545
|
+
if (oldContent === null) {
|
|
1546
|
+
if (!annotation.is_stale) {
|
|
1547
|
+
await markAnnotationStale(annotation.id, null);
|
|
1548
|
+
staleCount++;
|
|
1549
|
+
}
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
const match = findMatchingLine(newDiff, oldContent, annotation.line_number, annotation.side);
|
|
1553
|
+
if (match) {
|
|
1554
|
+
if (match.lineNumber !== annotation.line_number || match.side !== annotation.side) {
|
|
1555
|
+
await moveAnnotation(annotation.id, match.lineNumber, match.side);
|
|
1556
|
+
} else if (annotation.is_stale) {
|
|
1557
|
+
await markAnnotationCurrent(annotation.id);
|
|
1558
|
+
}
|
|
1559
|
+
} else {
|
|
1560
|
+
if (!annotation.is_stale) {
|
|
1561
|
+
await markAnnotationStale(annotation.id, oldContent);
|
|
1562
|
+
staleCount++;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
return staleCount;
|
|
1567
|
+
}
|
|
1568
|
+
async function updateReviewDiffs(reviewId, newDiffs, headCommit) {
|
|
1569
|
+
const existingFiles = await getReviewFiles(reviewId);
|
|
1570
|
+
const existingByPath = /* @__PURE__ */ new Map();
|
|
1571
|
+
for (const f of existingFiles) {
|
|
1572
|
+
existingByPath.set(f.file_path, f);
|
|
1573
|
+
}
|
|
1574
|
+
const newDiffsByPath = /* @__PURE__ */ new Map();
|
|
1575
|
+
for (const d of newDiffs) {
|
|
1576
|
+
newDiffsByPath.set(d.filePath, d);
|
|
1577
|
+
}
|
|
1578
|
+
let updated = 0;
|
|
1579
|
+
let added = 0;
|
|
1580
|
+
let stale = 0;
|
|
1581
|
+
for (const [path, existingFile] of existingByPath) {
|
|
1582
|
+
const newDiff = newDiffsByPath.get(path);
|
|
1583
|
+
if (newDiff) {
|
|
1584
|
+
const oldDiff = JSON.parse(existingFile.diff_data ?? "{}");
|
|
1585
|
+
const annotations = await getAnnotationsForFile(existingFile.id);
|
|
1586
|
+
if (annotations.length > 0) {
|
|
1587
|
+
stale += await migrateAnnotations(annotations, oldDiff, newDiff);
|
|
1588
|
+
}
|
|
1589
|
+
await updateFileDiff(existingFile.id, JSON.stringify(newDiff));
|
|
1590
|
+
updated++;
|
|
1591
|
+
} else {
|
|
1592
|
+
const annotations = await getAnnotationsForFile(existingFile.id);
|
|
1593
|
+
if (annotations.length === 0) {
|
|
1594
|
+
await deleteReviewFile(existingFile.id);
|
|
1595
|
+
} else {
|
|
1596
|
+
const oldDiff = JSON.parse(existingFile.diff_data ?? "{}");
|
|
1597
|
+
for (const a of annotations) {
|
|
1598
|
+
if (!a.is_stale) {
|
|
1599
|
+
const content = findLineContent(oldDiff, a.line_number, a.side);
|
|
1600
|
+
await markAnnotationStale(a.id, content);
|
|
1601
|
+
stale++;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
for (const [path, diff] of newDiffsByPath) {
|
|
1608
|
+
if (!existingByPath.has(path)) {
|
|
1609
|
+
await addReviewFile(reviewId, path, JSON.stringify(diff));
|
|
1610
|
+
added++;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
await updateReviewHead(reviewId, headCommit);
|
|
1614
|
+
return { updated, added, stale };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/server.ts
|
|
1618
|
+
import { serve } from "@hono/node-server";
|
|
1619
|
+
import { exec } from "child_process";
|
|
1620
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
1621
|
+
import { Hono as Hono4 } from "hono";
|
|
1622
|
+
import { dirname, join as join4 } from "path";
|
|
1623
|
+
import { fileURLToPath } from "url";
|
|
1624
|
+
|
|
1625
|
+
// src/routes/ai-api.ts
|
|
1626
|
+
import { Hono } from "hono";
|
|
1627
|
+
|
|
1628
|
+
// src/ai/client.ts
|
|
1629
|
+
async function sendAIRequest(config, systemPrompt, messages) {
|
|
1630
|
+
if (config.apiKey === null) {
|
|
1631
|
+
throw new Error(`No API key configured for ${config.platform}`);
|
|
1632
|
+
}
|
|
1633
|
+
const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0) + systemPrompt.length;
|
|
1634
|
+
debugLog(`AI request \u2192 ${config.platform}/${config.model} | ${String(messages.length)} message(s) | ~${String(Math.ceil(totalChars / 3))} estimated tokens`);
|
|
1635
|
+
const start = Date.now();
|
|
1636
|
+
let response;
|
|
1637
|
+
switch (config.platform) {
|
|
1638
|
+
case "anthropic":
|
|
1639
|
+
response = await sendAnthropicRequest(config.apiKey, config.model, systemPrompt, messages);
|
|
1640
|
+
break;
|
|
1641
|
+
case "openai":
|
|
1642
|
+
response = await sendOpenAIRequest(config.apiKey, config.model, systemPrompt, messages);
|
|
1643
|
+
break;
|
|
1644
|
+
case "google":
|
|
1645
|
+
response = await sendGoogleRequest(config.apiKey, config.model, systemPrompt, messages);
|
|
1646
|
+
break;
|
|
1647
|
+
}
|
|
1648
|
+
const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
|
|
1649
|
+
debugLog(`AI response \u2190 ${elapsed}s | ${String(response.inputTokens)} in / ${String(response.outputTokens)} out tokens`);
|
|
1650
|
+
return response;
|
|
1651
|
+
}
|
|
1652
|
+
async function sendAnthropicRequest(apiKey, model, systemPrompt, messages) {
|
|
1653
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1654
|
+
method: "POST",
|
|
1655
|
+
headers: {
|
|
1656
|
+
"Content-Type": "application/json",
|
|
1657
|
+
"x-api-key": apiKey,
|
|
1658
|
+
"anthropic-version": "2023-06-01"
|
|
1659
|
+
},
|
|
1660
|
+
body: JSON.stringify({
|
|
1661
|
+
model,
|
|
1662
|
+
max_tokens: 8192,
|
|
1663
|
+
system: systemPrompt,
|
|
1664
|
+
messages: messages.map((m) => ({
|
|
1665
|
+
role: m.role,
|
|
1666
|
+
content: m.content
|
|
1667
|
+
}))
|
|
1668
|
+
})
|
|
1669
|
+
});
|
|
1670
|
+
if (!response.ok) {
|
|
1671
|
+
const errorText = await response.text();
|
|
1672
|
+
throw new Error(`Anthropic API error (${String(response.status)}): ${errorText}`);
|
|
1673
|
+
}
|
|
1674
|
+
const data = await response.json();
|
|
1675
|
+
const text = data.content.filter((c) => c.type === "text").map((c) => c.text).join("");
|
|
1676
|
+
return {
|
|
1677
|
+
content: text,
|
|
1678
|
+
inputTokens: data.usage.input_tokens,
|
|
1679
|
+
outputTokens: data.usage.output_tokens
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
async function sendOpenAIRequest(apiKey, model, systemPrompt, messages) {
|
|
1683
|
+
const oaiMessages = [
|
|
1684
|
+
{ role: "system", content: systemPrompt },
|
|
1685
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
1686
|
+
];
|
|
1687
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1688
|
+
method: "POST",
|
|
1689
|
+
headers: {
|
|
1690
|
+
"Content-Type": "application/json",
|
|
1691
|
+
"Authorization": `Bearer ${apiKey}`
|
|
1692
|
+
},
|
|
1693
|
+
body: JSON.stringify({
|
|
1694
|
+
model,
|
|
1695
|
+
messages: oaiMessages,
|
|
1696
|
+
max_tokens: 8192
|
|
1697
|
+
})
|
|
1698
|
+
});
|
|
1699
|
+
if (!response.ok) {
|
|
1700
|
+
const errorText = await response.text();
|
|
1701
|
+
throw new Error(`OpenAI API error (${String(response.status)}): ${errorText}`);
|
|
1702
|
+
}
|
|
1703
|
+
const data = await response.json();
|
|
1704
|
+
return {
|
|
1705
|
+
content: data.choices[0].message.content,
|
|
1706
|
+
inputTokens: data.usage.prompt_tokens,
|
|
1707
|
+
outputTokens: data.usage.completion_tokens
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function sendGoogleRequest(apiKey, model, systemPrompt, messages) {
|
|
1711
|
+
const contents = messages.map((m) => ({
|
|
1712
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
1713
|
+
parts: [{ text: m.content }]
|
|
1714
|
+
}));
|
|
1715
|
+
const response = await fetch(
|
|
1716
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
1717
|
+
{
|
|
1718
|
+
method: "POST",
|
|
1719
|
+
headers: { "Content-Type": "application/json" },
|
|
1720
|
+
body: JSON.stringify({
|
|
1721
|
+
system_instruction: { parts: [{ text: systemPrompt }] },
|
|
1722
|
+
contents,
|
|
1723
|
+
generationConfig: {
|
|
1724
|
+
maxOutputTokens: 8192,
|
|
1725
|
+
responseMimeType: "application/json"
|
|
1726
|
+
}
|
|
1727
|
+
})
|
|
1728
|
+
}
|
|
1729
|
+
);
|
|
1730
|
+
if (!response.ok) {
|
|
1731
|
+
const errorText = await response.text();
|
|
1732
|
+
throw new Error(`Google AI API error (${String(response.status)}): ${errorText}`);
|
|
1733
|
+
}
|
|
1734
|
+
const data = await response.json();
|
|
1735
|
+
const text = data.candidates[0].content.parts.map((p) => p.text).join("");
|
|
1736
|
+
return {
|
|
1737
|
+
content: text,
|
|
1738
|
+
inputTokens: data.usageMetadata?.promptTokenCount ?? 0,
|
|
1739
|
+
outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/ai/context-builder.ts
|
|
1744
|
+
function summarizeHunk(hunk, maxLines) {
|
|
1745
|
+
const lines = [];
|
|
1746
|
+
lines.push(`@@ -${String(hunk.oldStart)},${String(hunk.oldCount)} +${String(hunk.newStart)},${String(hunk.newCount)} @@`);
|
|
1747
|
+
const hunkLines = hunk.lines;
|
|
1748
|
+
if (hunkLines.length <= maxLines) {
|
|
1749
|
+
for (const line of hunkLines) {
|
|
1750
|
+
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
1751
|
+
lines.push(prefix + line.content);
|
|
1752
|
+
}
|
|
1753
|
+
} else {
|
|
1754
|
+
const half = Math.floor(maxLines / 2);
|
|
1755
|
+
for (let i = 0; i < half; i++) {
|
|
1756
|
+
const line = hunkLines[i];
|
|
1757
|
+
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
1758
|
+
lines.push(prefix + line.content);
|
|
1759
|
+
}
|
|
1760
|
+
lines.push(`... (${String(hunkLines.length - maxLines)} lines omitted) ...`);
|
|
1761
|
+
for (let i = hunkLines.length - half; i < hunkLines.length; i++) {
|
|
1762
|
+
const line = hunkLines[i];
|
|
1763
|
+
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
1764
|
+
lines.push(prefix + line.content);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return lines.join("\n");
|
|
1768
|
+
}
|
|
1769
|
+
function buildDiffText(diff, charBudget) {
|
|
1770
|
+
if (diff.isBinary) return "[Binary file]";
|
|
1771
|
+
if (diff.hunks.length === 0) return "[No changes]";
|
|
1772
|
+
const fullLines = [];
|
|
1773
|
+
for (const hunk of diff.hunks) {
|
|
1774
|
+
fullLines.push(`@@ -${String(hunk.oldStart)},${String(hunk.oldCount)} +${String(hunk.newStart)},${String(hunk.newCount)} @@`);
|
|
1775
|
+
for (const line of hunk.lines) {
|
|
1776
|
+
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
1777
|
+
fullLines.push(prefix + line.content);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
const fullText = fullLines.join("\n");
|
|
1781
|
+
if (fullText.length <= charBudget) return fullText;
|
|
1782
|
+
const maxLinesPerHunk = Math.max(10, Math.floor(charBudget / (diff.hunks.length * 80)));
|
|
1783
|
+
return diff.hunks.map((h) => summarizeHunk(h, maxLinesPerHunk)).join("\n\n");
|
|
1784
|
+
}
|
|
1785
|
+
function buildFileContexts(files, charBudget) {
|
|
1786
|
+
const contexts = [];
|
|
1787
|
+
const perFileBudget = Math.floor(charBudget / Math.max(files.length, 1));
|
|
1788
|
+
for (const file of files) {
|
|
1789
|
+
const diff = JSON.parse(file.diff_data !== null && file.diff_data !== "" ? file.diff_data : "{}");
|
|
1790
|
+
let added = 0;
|
|
1791
|
+
let removed = 0;
|
|
1792
|
+
const hunks = diff.hunks;
|
|
1793
|
+
for (const hunk of hunks ?? []) {
|
|
1794
|
+
for (const line of hunk.lines) {
|
|
1795
|
+
if (line.type === "add") added++;
|
|
1796
|
+
if (line.type === "remove") removed++;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
contexts.push({
|
|
1800
|
+
fileId: file.id,
|
|
1801
|
+
filePath: file.file_path,
|
|
1802
|
+
status: diff.status ?? file.status,
|
|
1803
|
+
linesAdded: added,
|
|
1804
|
+
linesRemoved: removed,
|
|
1805
|
+
diffText: buildDiffText(diff, perFileBudget)
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
return contexts;
|
|
1809
|
+
}
|
|
1810
|
+
function formatContextsForPrompt(contexts) {
|
|
1811
|
+
const sections = contexts.map((ctx) => {
|
|
1812
|
+
return [
|
|
1813
|
+
`=== ${ctx.filePath} (${ctx.status}, +${String(ctx.linesAdded)} -${String(ctx.linesRemoved)}) ===`,
|
|
1814
|
+
ctx.diffText
|
|
1815
|
+
].join("\n");
|
|
1816
|
+
});
|
|
1817
|
+
return sections.join("\n\n");
|
|
1818
|
+
}
|
|
1819
|
+
function formatAdditionalContext(files) {
|
|
1820
|
+
return files.map((f) => {
|
|
1821
|
+
return `=== Full content: ${f.path} ===
|
|
1822
|
+
${f.content}`;
|
|
1823
|
+
}).join("\n\n");
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// src/ai/shared.ts
|
|
1827
|
+
function isNeedContext(parsed) {
|
|
1828
|
+
return typeof parsed === "object" && parsed !== null && "needContext" in parsed && Array.isArray(parsed.needContext);
|
|
1829
|
+
}
|
|
1830
|
+
function extractJSON(text) {
|
|
1831
|
+
const stripped = text.trim().replace(/^```json?\n?/, "").replace(/\n?```$/, "");
|
|
1832
|
+
try {
|
|
1833
|
+
return JSON.parse(stripped);
|
|
1834
|
+
} catch {
|
|
1835
|
+
}
|
|
1836
|
+
const arrayMatch = text.match(/\[[\s\S]*\]/);
|
|
1837
|
+
if (arrayMatch !== null) {
|
|
1838
|
+
try {
|
|
1839
|
+
return JSON.parse(arrayMatch[0]);
|
|
1840
|
+
} catch {
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
const objMatch = text.match(/\{[\s\S]*\}/);
|
|
1844
|
+
if (objMatch !== null) {
|
|
1845
|
+
try {
|
|
1846
|
+
return JSON.parse(objMatch[0]);
|
|
1847
|
+
} catch {
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
throw new Error(`Could not extract JSON from AI response: ${text.slice(0, 300)}`);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/ai/analyze-guided.ts
|
|
1854
|
+
var TOPIC_DISPLAY = {
|
|
1855
|
+
programming: "programming in general",
|
|
1856
|
+
codebase: "this codebase",
|
|
1857
|
+
javascript: "JavaScript",
|
|
1858
|
+
python: "Python",
|
|
1859
|
+
typescript: "TypeScript",
|
|
1860
|
+
java: "Java",
|
|
1861
|
+
csharp: "C#",
|
|
1862
|
+
cpp: "C++",
|
|
1863
|
+
go: "Go",
|
|
1864
|
+
rust: "Rust",
|
|
1865
|
+
php: "PHP",
|
|
1866
|
+
swift: "Swift",
|
|
1867
|
+
ruby: "Ruby",
|
|
1868
|
+
kotlin: "Kotlin",
|
|
1869
|
+
scala: "Scala",
|
|
1870
|
+
c: "C",
|
|
1871
|
+
objectivec: "Objective-C",
|
|
1872
|
+
r: "R",
|
|
1873
|
+
lua: "Lua",
|
|
1874
|
+
perl: "Perl",
|
|
1875
|
+
bash: "Shell scripting",
|
|
1876
|
+
dart: "Dart",
|
|
1877
|
+
elixir: "Elixir",
|
|
1878
|
+
erlang: "Erlang",
|
|
1879
|
+
haskell: "Haskell",
|
|
1880
|
+
clojure: "Clojure",
|
|
1881
|
+
ocaml: "OCaml",
|
|
1882
|
+
zig: "Zig",
|
|
1883
|
+
nim: "Nim",
|
|
1884
|
+
groovy: "Groovy"
|
|
1885
|
+
};
|
|
1886
|
+
function buildSystemPrompt(config) {
|
|
1887
|
+
const topicNames = config.topics.map((t) => TOPIC_DISPLAY[t] ?? t);
|
|
1888
|
+
const topicList = topicNames.join(", ");
|
|
1889
|
+
const hasLang = config.topics.some((t) => t !== "programming" && t !== "codebase");
|
|
1890
|
+
const newToProgramming = config.topics.includes("programming");
|
|
1891
|
+
const newToCodebase = config.topics.includes("codebase");
|
|
1892
|
+
let focusInstructions = "";
|
|
1893
|
+
if (newToProgramming) {
|
|
1894
|
+
focusInstructions += `
|
|
1895
|
+
- Explain basic programming concepts (variables, functions, control flow, types) when they appear
|
|
1896
|
+
- Define technical terms the first time you use them
|
|
1897
|
+
- Explain WHY code is structured a certain way, not just what it does`;
|
|
1898
|
+
}
|
|
1899
|
+
if (newToCodebase) {
|
|
1900
|
+
focusInstructions += `
|
|
1901
|
+
- Explain how each file fits into the broader system architecture
|
|
1902
|
+
- Describe the purpose of the module/component and its relationships with other parts
|
|
1903
|
+
- Highlight conventions and patterns specific to this codebase`;
|
|
1904
|
+
}
|
|
1905
|
+
if (hasLang) {
|
|
1906
|
+
focusInstructions += `
|
|
1907
|
+
- Explain language-specific idioms, syntax, and features that may be unfamiliar
|
|
1908
|
+
- Point out language best practices and common pitfalls
|
|
1909
|
+
- When a pattern is language-specific, explain the underlying concept and alternatives`;
|
|
1910
|
+
}
|
|
1911
|
+
return `You are a JSON-only API. You output raw JSON with no other text, no markdown fences, no explanation.
|
|
1912
|
+
|
|
1913
|
+
TASK: Provide educational walkthrough notes for a code reviewer who is new to: ${topicList}.
|
|
1914
|
+
|
|
1915
|
+
Your goal is to help this reviewer understand the code changes by explaining concepts, patterns, and decisions they may not be familiar with. Focus on teaching \u2014 not evaluating risk or ordering files.
|
|
1916
|
+
|
|
1917
|
+
For each file, provide:
|
|
1918
|
+
- "overview": A clear, educational explanation of what this file does, what changed, and why it matters. Tailor the depth to the reviewer's experience level.
|
|
1919
|
+
- "lines": An array of specific line-level educational notes referencing NEW-side line numbers from the diff. Each entry has "line" (number) and "content" (educational explanation). Focus on the most instructive lines \u2014 not every line.
|
|
1920
|
+
|
|
1921
|
+
Focus areas:${focusInstructions}
|
|
1922
|
+
|
|
1923
|
+
General guidelines:
|
|
1924
|
+
- Explain WHY something matters, not just WHAT it is
|
|
1925
|
+
- Use concrete, accessible language \u2014 avoid jargon unless you define it
|
|
1926
|
+
- When a change introduces a pattern, explain the pattern and its benefits
|
|
1927
|
+
- Keep notes practical and specific to the actual code shown
|
|
1928
|
+
|
|
1929
|
+
OUTPUT FORMAT \u2014 you MUST output ONLY this JSON array, nothing else:
|
|
1930
|
+
[{"filePath":"src/example.ts","notes":{"overview":"Educational summary of this file and its changes","lines":[{"line":42,"content":"This uses a closure to capture the variable \u2014 closures are functions that remember variables from their outer scope"}]}}]
|
|
1931
|
+
|
|
1932
|
+
If you need full file content to explain accurately, output ONLY: {"needContext":["path/to/file.ts"]}
|
|
1933
|
+
|
|
1934
|
+
CRITICAL: Your entire response must be parseable by JSON.parse(). No prose, no markdown, no explanation.`;
|
|
1935
|
+
}
|
|
1936
|
+
async function runGuidedAnalysisBatch(files, config, repoRoot, guidedReview) {
|
|
1937
|
+
const contextWindow = getModelContextWindow(config.platform, config.model);
|
|
1938
|
+
const charBudget = Math.floor(contextWindow * 0.7 * 3);
|
|
1939
|
+
const systemPrompt = buildSystemPrompt(guidedReview);
|
|
1940
|
+
const contexts = buildFileContexts(files, charBudget);
|
|
1941
|
+
const validPaths = new Set(files.map((f) => f.file_path));
|
|
1942
|
+
const initialPrompt = [
|
|
1943
|
+
`Provide educational walkthrough notes for these ${String(files.length)} changed files:`,
|
|
1944
|
+
"",
|
|
1945
|
+
formatContextsForPrompt(contexts)
|
|
1946
|
+
].join("\n");
|
|
1947
|
+
const messages = [{ role: "user", content: initialPrompt }];
|
|
1948
|
+
for (let round = 0; round < 3; round++) {
|
|
1949
|
+
const response = await sendAIRequest(config, systemPrompt, messages);
|
|
1950
|
+
const parsed = extractJSON(response.content);
|
|
1951
|
+
if (isNeedContext(parsed)) {
|
|
1952
|
+
const safePaths = parsed.needContext.filter((p) => validPaths.has(p));
|
|
1953
|
+
if (safePaths.length === 0) {
|
|
1954
|
+
throw new Error("AI requested context for files not in the review");
|
|
1955
|
+
}
|
|
1956
|
+
const fileContents = safePaths.map((path) => ({
|
|
1957
|
+
path,
|
|
1958
|
+
content: getFileContent(path, "working", repoRoot)
|
|
1959
|
+
}));
|
|
1960
|
+
messages.push({ role: "assistant", content: response.content });
|
|
1961
|
+
messages.push({
|
|
1962
|
+
role: "user",
|
|
1963
|
+
content: `Here is the full content of the requested files:
|
|
1964
|
+
|
|
1965
|
+
${formatAdditionalContext(fileContents)}`
|
|
1966
|
+
});
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
if (!Array.isArray(parsed)) {
|
|
1970
|
+
throw new Error("Expected an array of guided review notes from AI");
|
|
1971
|
+
}
|
|
1972
|
+
return parsed;
|
|
1973
|
+
}
|
|
1974
|
+
throw new Error("Guided analysis did not converge after 3 context rounds");
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// src/ai/guided-review.ts
|
|
1978
|
+
var TOPIC_DISPLAY2 = {
|
|
1979
|
+
programming: "programming in general",
|
|
1980
|
+
codebase: "this codebase",
|
|
1981
|
+
javascript: "JavaScript",
|
|
1982
|
+
python: "Python",
|
|
1983
|
+
typescript: "TypeScript",
|
|
1984
|
+
java: "Java",
|
|
1985
|
+
csharp: "C#",
|
|
1986
|
+
cpp: "C++",
|
|
1987
|
+
go: "Go",
|
|
1988
|
+
rust: "Rust",
|
|
1989
|
+
php: "PHP",
|
|
1990
|
+
swift: "Swift",
|
|
1991
|
+
ruby: "Ruby",
|
|
1992
|
+
kotlin: "Kotlin",
|
|
1993
|
+
scala: "Scala",
|
|
1994
|
+
c: "C",
|
|
1995
|
+
objectivec: "Objective-C",
|
|
1996
|
+
r: "R",
|
|
1997
|
+
lua: "Lua",
|
|
1998
|
+
perl: "Perl",
|
|
1999
|
+
bash: "Shell scripting",
|
|
2000
|
+
dart: "Dart",
|
|
2001
|
+
elixir: "Elixir",
|
|
2002
|
+
erlang: "Erlang",
|
|
2003
|
+
haskell: "Haskell",
|
|
2004
|
+
clojure: "Clojure",
|
|
2005
|
+
ocaml: "OCaml",
|
|
2006
|
+
zig: "Zig",
|
|
2007
|
+
nim: "Nim",
|
|
2008
|
+
groovy: "Groovy"
|
|
2009
|
+
};
|
|
2010
|
+
function buildGuidedReviewSuffix(config, analysisType) {
|
|
2011
|
+
if (!config.enabled || config.topics.length === 0) return "";
|
|
2012
|
+
const topicNames = config.topics.map((t) => TOPIC_DISPLAY2[t] ?? t);
|
|
2013
|
+
const topicList = topicNames.join(", ");
|
|
2014
|
+
let suffix = `
|
|
2015
|
+
|
|
2016
|
+
GUIDED REVIEW MODE \u2014 The reviewer is new to: ${topicList}.
|
|
2017
|
+
|
|
2018
|
+
Adjust your output for this experience level:
|
|
2019
|
+
- Write more detailed, educational explanations that help the reviewer learn
|
|
2020
|
+
- Explain WHY something matters, not just WHAT it is
|
|
2021
|
+
- When a concept relates to something the reviewer is learning, explain the underlying principle
|
|
2022
|
+
- Include actionable suggestions for improvement, not just observations`;
|
|
2023
|
+
if (analysisType === "risk") {
|
|
2024
|
+
suffix += `
|
|
2025
|
+
- In rationale, provide context about why each risk dimension matters for this file
|
|
2026
|
+
- In line-level notes, explain language idioms, design patterns, and security concepts the reviewer may not know
|
|
2027
|
+
- Suggest specific fixes with brief code examples where helpful
|
|
2028
|
+
- Be more verbose and explanatory than you would for an expert reviewer`;
|
|
2029
|
+
} else {
|
|
2030
|
+
suffix += `
|
|
2031
|
+
- In overview notes, explain what the file does and its role in the broader system
|
|
2032
|
+
- In line-level notes, explain patterns, conventions, and design decisions
|
|
2033
|
+
- Help build understanding progressively through the reading order
|
|
2034
|
+
- When relevant, explain how files relate to each other and why the ordering matters`;
|
|
2035
|
+
}
|
|
2036
|
+
return suffix;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/ai/analyze-narrative.ts
|
|
2040
|
+
var SYSTEM_PROMPT = `You are a JSON-only API. You output raw JSON with no other text, no markdown fences, no explanation.
|
|
2041
|
+
|
|
2042
|
+
TASK: Determine the best reading order for a human reviewing these code changes, and provide walkthrough notes for each file.
|
|
2043
|
+
|
|
2044
|
+
PRINCIPLES for ordering:
|
|
2045
|
+
1. Types/interfaces/data models first (foundations)
|
|
2046
|
+
2. Utility functions and shared helpers next
|
|
2047
|
+
3. Core business logic, ordered by dependency
|
|
2048
|
+
4. Integration code (routes, controllers, event handlers)
|
|
2049
|
+
5. Configuration and build files
|
|
2050
|
+
6. Tests last
|
|
2051
|
+
|
|
2052
|
+
For each file, provide walkthrough notes to guide the reviewer:
|
|
2053
|
+
- "overview": A 1-2 sentence summary explaining what changed in this file and why it matters in the reading order
|
|
2054
|
+
- "lines": An array of specific line-level walkthrough notes referencing NEW-side line numbers from the diff. Highlight key changes, important patterns, and connections to other files. Each entry has "line" (number) and "content" (brief note).
|
|
2055
|
+
|
|
2056
|
+
OUTPUT FORMAT \u2014 you MUST output ONLY this JSON array, nothing else:
|
|
2057
|
+
[{"filePath":"src/types.ts","position":1,"rationale":"Defines core interfaces","notes":{"overview":"Start here: these new types are used throughout the rest of the changes","lines":[{"line":10,"content":"This interface is the foundation for the new feature in routes.ts"}]}}]
|
|
2058
|
+
|
|
2059
|
+
Every file in the diff must appear exactly once.
|
|
2060
|
+
|
|
2061
|
+
If you need full file content to determine dependencies, output ONLY: {"needContext":["path/to/file.ts"]}
|
|
2062
|
+
|
|
2063
|
+
CRITICAL: Your entire response must be parseable by JSON.parse(). No prose, no markdown, no explanation.`;
|
|
2064
|
+
async function runNarrativeAnalysisBatch(files, config, repoRoot, guidedReview) {
|
|
2065
|
+
const contextWindow = getModelContextWindow(config.platform, config.model);
|
|
2066
|
+
const charBudget = Math.floor(contextWindow * 0.7 * 3);
|
|
2067
|
+
const systemPrompt = SYSTEM_PROMPT + (guidedReview !== void 0 ? buildGuidedReviewSuffix(guidedReview, "narrative") : "");
|
|
2068
|
+
const contexts = buildFileContexts(files, charBudget);
|
|
2069
|
+
const validPaths = new Set(files.map((f) => f.file_path));
|
|
2070
|
+
const initialPrompt = [
|
|
2071
|
+
`Determine the best reading order for reviewing these ${String(files.length)} changed files:`,
|
|
2072
|
+
"",
|
|
2073
|
+
formatContextsForPrompt(contexts)
|
|
2074
|
+
].join("\n");
|
|
2075
|
+
const messages = [{ role: "user", content: initialPrompt }];
|
|
2076
|
+
for (let round = 0; round < 3; round++) {
|
|
2077
|
+
const response = await sendAIRequest(config, systemPrompt, messages);
|
|
2078
|
+
const parsed = extractJSON(response.content);
|
|
2079
|
+
if (isNeedContext(parsed)) {
|
|
2080
|
+
const safePaths = parsed.needContext.filter((p) => validPaths.has(p));
|
|
2081
|
+
if (safePaths.length === 0) {
|
|
2082
|
+
throw new Error("AI requested context for files not in the review");
|
|
2083
|
+
}
|
|
2084
|
+
const fileContents = safePaths.map((path) => ({
|
|
2085
|
+
path,
|
|
2086
|
+
content: getFileContent(path, "working", repoRoot)
|
|
2087
|
+
}));
|
|
2088
|
+
messages.push({ role: "assistant", content: response.content });
|
|
2089
|
+
messages.push({
|
|
2090
|
+
role: "user",
|
|
2091
|
+
content: `Here is the full content of the requested files:
|
|
2092
|
+
|
|
2093
|
+
${formatAdditionalContext(fileContents)}`
|
|
2094
|
+
});
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
if (!Array.isArray(parsed)) {
|
|
2098
|
+
throw new Error("Expected an array of narrative ordering from AI");
|
|
2099
|
+
}
|
|
2100
|
+
return parsed;
|
|
2101
|
+
}
|
|
2102
|
+
throw new Error("Narrative analysis did not converge after 3 context rounds");
|
|
2103
|
+
}
|
|
2104
|
+
function mergeNarrativeOrders(batchResults, batchCount) {
|
|
2105
|
+
if (batchCount <= 1) {
|
|
2106
|
+
return new Map(batchResults.map((r) => [r.filePath, r.position]));
|
|
2107
|
+
}
|
|
2108
|
+
const batches = [];
|
|
2109
|
+
let currentBatch = [];
|
|
2110
|
+
let lastPos = 0;
|
|
2111
|
+
const sorted = batchResults.slice();
|
|
2112
|
+
for (const r of sorted) {
|
|
2113
|
+
if (r.position <= lastPos && currentBatch.length > 0) {
|
|
2114
|
+
batches.push(currentBatch.slice().sort((a, b) => a.position - b.position));
|
|
2115
|
+
currentBatch = [];
|
|
2116
|
+
}
|
|
2117
|
+
currentBatch.push(r);
|
|
2118
|
+
lastPos = r.position;
|
|
2119
|
+
}
|
|
2120
|
+
if (currentBatch.length > 0) {
|
|
2121
|
+
batches.push(currentBatch.slice().sort((a, b) => a.position - b.position));
|
|
2122
|
+
}
|
|
2123
|
+
const merged = [];
|
|
2124
|
+
const maxLen = Math.max(...batches.map((b) => b.length));
|
|
2125
|
+
for (let i = 0; i < maxLen; i++) {
|
|
2126
|
+
for (const batch of batches) {
|
|
2127
|
+
if (i < batch.length) {
|
|
2128
|
+
merged.push(batch[i].filePath);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
const positions = /* @__PURE__ */ new Map();
|
|
2133
|
+
for (let i = 0; i < merged.length; i++) {
|
|
2134
|
+
positions.set(merged[i], i + 1);
|
|
2135
|
+
}
|
|
2136
|
+
return positions;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// src/ai/analyze-risk.ts
|
|
2140
|
+
var SYSTEM_PROMPT2 = `You are a JSON-only API. You output raw JSON with no other text, no markdown fences, no explanation.
|
|
2141
|
+
|
|
2142
|
+
TASK: Evaluate each changed file across these risk dimensions on a 0.0 to 1.0 scale (0.0 = no concern, 1.0 = critical):
|
|
2143
|
+
|
|
2144
|
+
1. security - Injection vulnerabilities, auth gaps, data exposure, insecure crypto, path traversal
|
|
2145
|
+
2. correctness - Logic errors, off-by-one, null handling, type safety, race conditions
|
|
2146
|
+
3. error-handling - Missing catches, unvalidated input, silent failures
|
|
2147
|
+
4. maintainability - Complexity, coupling, unclear naming, magic numbers
|
|
2148
|
+
5. architecture - Separation of concerns, dependency direction, scalability, API design
|
|
2149
|
+
6. performance - Algorithmic complexity, memory management, unnecessary allocation
|
|
2150
|
+
|
|
2151
|
+
For each file, also provide detailed notes:
|
|
2152
|
+
- "overview": A 1-2 sentence summary of the key risk concerns for this file
|
|
2153
|
+
- "lines": An array of specific line-level observations referencing NEW-side line numbers from the diff. Focus on the most important risks, not every line. Each entry has "line" (number) and "content" (brief note).
|
|
2154
|
+
|
|
2155
|
+
OUTPUT FORMAT \u2014 you MUST output ONLY this JSON array, nothing else:
|
|
2156
|
+
[{"filePath":"src/example.ts","scores":{"security":0.2,"correctness":0.5,"error-handling":0.3,"maintainability":0.4,"architecture":0.1,"performance":0.2},"aggregate":0.35,"rationale":"Brief concern","notes":{"overview":"Key risk summary for this file","lines":[{"line":42,"content":"SQL injection: user input not parameterized"}]}}]
|
|
2157
|
+
|
|
2158
|
+
The aggregate should be the MAX of all individual dimension scores (if a file has one critical issue, the aggregate should reflect that).
|
|
2159
|
+
|
|
2160
|
+
If you need full file content to assess accurately, output ONLY: {"needContext":["path/to/file.ts"]}
|
|
2161
|
+
|
|
2162
|
+
CRITICAL: Your entire response must be parseable by JSON.parse(). No prose, no markdown, no explanation.`;
|
|
2163
|
+
async function runRiskAnalysisBatch(files, config, repoRoot, guidedReview) {
|
|
2164
|
+
const contextWindow = getModelContextWindow(config.platform, config.model);
|
|
2165
|
+
const charBudget = Math.floor(contextWindow * 0.7 * 3);
|
|
2166
|
+
const systemPrompt = SYSTEM_PROMPT2 + (guidedReview !== void 0 ? buildGuidedReviewSuffix(guidedReview, "risk") : "");
|
|
2167
|
+
const contexts = buildFileContexts(files, charBudget);
|
|
2168
|
+
const validPaths = new Set(files.map((f) => f.file_path));
|
|
2169
|
+
const initialPrompt = [
|
|
2170
|
+
`Analyze the following ${String(files.length)} file diffs for risk:`,
|
|
2171
|
+
"",
|
|
2172
|
+
formatContextsForPrompt(contexts)
|
|
2173
|
+
].join("\n");
|
|
2174
|
+
const messages = [{ role: "user", content: initialPrompt }];
|
|
2175
|
+
for (let round = 0; round < 3; round++) {
|
|
2176
|
+
const response = await sendAIRequest(config, systemPrompt, messages);
|
|
2177
|
+
const parsed = extractJSON(response.content);
|
|
2178
|
+
if (isNeedContext(parsed)) {
|
|
2179
|
+
const safePaths = parsed.needContext.filter((p) => validPaths.has(p));
|
|
2180
|
+
if (safePaths.length === 0) {
|
|
2181
|
+
throw new Error("AI requested context for files not in the review");
|
|
2182
|
+
}
|
|
2183
|
+
const fileContents = safePaths.map((path) => ({
|
|
2184
|
+
path,
|
|
2185
|
+
content: getFileContent(path, "working", repoRoot)
|
|
2186
|
+
}));
|
|
2187
|
+
messages.push({ role: "assistant", content: response.content });
|
|
2188
|
+
messages.push({
|
|
2189
|
+
role: "user",
|
|
2190
|
+
content: `Here is the full content of the requested files:
|
|
2191
|
+
|
|
2192
|
+
${formatAdditionalContext(fileContents)}`
|
|
2193
|
+
});
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
if (!Array.isArray(parsed)) {
|
|
2197
|
+
throw new Error("Expected an array of risk assessments from AI");
|
|
2198
|
+
}
|
|
2199
|
+
return parsed;
|
|
2200
|
+
}
|
|
2201
|
+
throw new Error("Risk analysis did not converge after 3 context rounds");
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// src/ai/batch-planner.ts
|
|
2205
|
+
function parseDiff2(file) {
|
|
2206
|
+
return JSON.parse(
|
|
2207
|
+
file.diff_data !== null && file.diff_data !== "" ? file.diff_data : "{}"
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
function isBinary(file) {
|
|
2211
|
+
const diff = parseDiff2(file);
|
|
2212
|
+
return diff.isBinary;
|
|
2213
|
+
}
|
|
2214
|
+
function estimateFileTokens(file) {
|
|
2215
|
+
const diff = parseDiff2(file);
|
|
2216
|
+
if (diff.isBinary) return 0;
|
|
2217
|
+
const hunks = diff.hunks;
|
|
2218
|
+
if (!hunks) return 0;
|
|
2219
|
+
let charCount = 0;
|
|
2220
|
+
for (const hunk of hunks) {
|
|
2221
|
+
charCount += 40;
|
|
2222
|
+
for (const line of hunk.lines) {
|
|
2223
|
+
charCount += line.content.length + 2;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
charCount += file.file_path.length + 80;
|
|
2227
|
+
return Math.ceil(charCount / 3);
|
|
2228
|
+
}
|
|
2229
|
+
var DEFAULT_BATCH_TOKEN_LIMIT = 2e4;
|
|
2230
|
+
function planBatches(files, contextWindowTokens) {
|
|
2231
|
+
const binaryFiles = [];
|
|
2232
|
+
const analyzableFiles = [];
|
|
2233
|
+
for (const file of files) {
|
|
2234
|
+
if (isBinary(file)) {
|
|
2235
|
+
binaryFiles.push(file);
|
|
2236
|
+
} else {
|
|
2237
|
+
analyzableFiles.push({ file, tokens: estimateFileTokens(file) });
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
if (analyzableFiles.length === 0) {
|
|
2241
|
+
return { batches: [], binaryFiles };
|
|
2242
|
+
}
|
|
2243
|
+
const contextCap = Math.floor(contextWindowTokens * 0.7 * 0.85);
|
|
2244
|
+
const batchTokenLimit = Math.min(DEFAULT_BATCH_TOKEN_LIMIT, contextCap);
|
|
2245
|
+
analyzableFiles.sort((a, b) => b.tokens - a.tokens);
|
|
2246
|
+
const batches = [];
|
|
2247
|
+
const placed = /* @__PURE__ */ new Set();
|
|
2248
|
+
for (let i = 0; i < analyzableFiles.length; i++) {
|
|
2249
|
+
if (placed.has(i)) continue;
|
|
2250
|
+
const entry = analyzableFiles[i];
|
|
2251
|
+
const batchFiles = [entry.file];
|
|
2252
|
+
let batchTokens = entry.tokens;
|
|
2253
|
+
placed.add(i);
|
|
2254
|
+
for (let j = i + 1; j < analyzableFiles.length; j++) {
|
|
2255
|
+
if (placed.has(j)) continue;
|
|
2256
|
+
const candidate = analyzableFiles[j];
|
|
2257
|
+
if (batchTokens + candidate.tokens <= batchTokenLimit) {
|
|
2258
|
+
batchFiles.push(candidate.file);
|
|
2259
|
+
batchTokens += candidate.tokens;
|
|
2260
|
+
placed.add(j);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
batches.push({ files: batchFiles, estimatedTokens: batchTokens });
|
|
2264
|
+
}
|
|
2265
|
+
debugLog(`Batch plan: ${String(analyzableFiles.length)} analyzable files, ${String(binaryFiles.length)} binary files \u2192 ${String(batches.length)} batch(es), limit ${String(batchTokenLimit)} tokens/batch`);
|
|
2266
|
+
for (let i = 0; i < batches.length; i++) {
|
|
2267
|
+
debugLog(` Batch ${String(i)}: ${String(batches[i].files.length)} files, ~${String(batches[i].estimatedTokens)} tokens`);
|
|
2268
|
+
}
|
|
2269
|
+
return { batches, binaryFiles };
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/ai/batch-runner.ts
|
|
2273
|
+
async function runBatches(batches, totalFiles, processBatch, onBatchComplete, onProgress, concurrency = 1, shouldCancel, label = "") {
|
|
2274
|
+
const tag = label !== "" ? `[${label}] ` : "";
|
|
2275
|
+
const allResults = [];
|
|
2276
|
+
let completedBatches = 0;
|
|
2277
|
+
let completedFiles = 0;
|
|
2278
|
+
const progress = () => ({
|
|
2279
|
+
totalBatches: batches.length,
|
|
2280
|
+
completedBatches,
|
|
2281
|
+
totalFiles,
|
|
2282
|
+
completedFiles
|
|
2283
|
+
});
|
|
2284
|
+
async function runOne(batch, index) {
|
|
2285
|
+
debugLog(`${tag}Batch ${String(index)} starting: ${String(batch.files.length)} files, ~${String(batch.estimatedTokens)} tokens`);
|
|
2286
|
+
let results;
|
|
2287
|
+
try {
|
|
2288
|
+
results = await processBatch(batch, index);
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
if (isRetriable(err)) {
|
|
2291
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2292
|
+
debugLog(`${tag}Batch ${String(index)} hit retriable error, waiting 30-60s: ${msg.slice(0, 120)}`);
|
|
2293
|
+
const delay = 3e4 + Math.random() * 3e4;
|
|
2294
|
+
await sleep(delay);
|
|
2295
|
+
debugLog(`${tag}Batch ${String(index)} retrying...`);
|
|
2296
|
+
results = await processBatch(batch, index);
|
|
2297
|
+
} else {
|
|
2298
|
+
throw err;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
debugLog(`${tag}Batch ${String(index)} completed: ${String(results.length)} results`);
|
|
2302
|
+
allResults.push(...results);
|
|
2303
|
+
completedBatches++;
|
|
2304
|
+
completedFiles += batch.files.length;
|
|
2305
|
+
await onBatchComplete(index, results);
|
|
2306
|
+
await onProgress(progress());
|
|
2307
|
+
}
|
|
2308
|
+
let nextIndex = 0;
|
|
2309
|
+
const running = /* @__PURE__ */ new Set();
|
|
2310
|
+
while (nextIndex < batches.length || running.size > 0) {
|
|
2311
|
+
while (nextIndex < batches.length && running.size < concurrency) {
|
|
2312
|
+
if (shouldCancel !== void 0 && shouldCancel()) {
|
|
2313
|
+
debugLog(`${tag}Batch runner cancelled \u2014 skipping batch ${String(nextIndex)} and ${String(batches.length - nextIndex - 1)} remaining`);
|
|
2314
|
+
nextIndex = batches.length;
|
|
2315
|
+
break;
|
|
2316
|
+
}
|
|
2317
|
+
const idx = nextIndex++;
|
|
2318
|
+
const batch = batches[idx];
|
|
2319
|
+
const p = runOne(batch, idx).catch((err) => {
|
|
2320
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2321
|
+
console.error(`${tag}Batch ${String(idx)} failed: ${msg}`);
|
|
2322
|
+
completedBatches++;
|
|
2323
|
+
void onProgress(progress());
|
|
2324
|
+
});
|
|
2325
|
+
running.add(p);
|
|
2326
|
+
void p.then(() => {
|
|
2327
|
+
running.delete(p);
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
if (running.size > 0) {
|
|
2331
|
+
await Promise.race(running);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
return allResults;
|
|
2335
|
+
}
|
|
2336
|
+
function isRetriable(err) {
|
|
2337
|
+
if (!(err instanceof Error)) return false;
|
|
2338
|
+
const msg = err.message;
|
|
2339
|
+
return msg.includes("429") || msg.includes("500") || msg.includes("502") || msg.includes("503") || msg.includes("504") || msg.includes("rate_limit");
|
|
2340
|
+
}
|
|
2341
|
+
function sleep(ms) {
|
|
2342
|
+
return new Promise((resolve2) => {
|
|
2343
|
+
setTimeout(resolve2, ms);
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/ai/mock.ts
|
|
2348
|
+
var LOREM = [
|
|
2349
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
2350
|
+
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
|
2351
|
+
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
|
|
2352
|
+
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
|
|
2353
|
+
"Excepteur sint occaecat cupidatat non proident, sunt in culpa.",
|
|
2354
|
+
"Nulla facilisi morbi tempus iaculis urna id volutpat lacus.",
|
|
2355
|
+
"Viverra accumsan in nisl nisi scelerisque eu ultrices vitae.",
|
|
2356
|
+
"Amet consectetur adipiscing elit pellentesque habitant morbi tristique."
|
|
2357
|
+
];
|
|
2358
|
+
var LINE_NOTES = [
|
|
2359
|
+
"Consider extracting this into a helper function.",
|
|
2360
|
+
"This variable could use a more descriptive name.",
|
|
2361
|
+
"Potential null reference if upstream data is missing.",
|
|
2362
|
+
"Good use of early return pattern here.",
|
|
2363
|
+
"Magic number \u2014 consider defining as a named constant.",
|
|
2364
|
+
"This logic duplicates what exists in the utility module.",
|
|
2365
|
+
"Edge case: what happens when the input array is empty?",
|
|
2366
|
+
"Type assertion here bypasses compile-time safety checks."
|
|
2367
|
+
];
|
|
2368
|
+
function pick(arr) {
|
|
2369
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
2370
|
+
}
|
|
2371
|
+
function randomScore() {
|
|
2372
|
+
return Math.round(Math.random() * 10) / 10;
|
|
2373
|
+
}
|
|
2374
|
+
function randomLines(count) {
|
|
2375
|
+
const lines = [];
|
|
2376
|
+
const numLines = 1 + Math.floor(Math.random() * Math.min(count, 3));
|
|
2377
|
+
const usedLines = /* @__PURE__ */ new Set();
|
|
2378
|
+
for (let i = 0; i < numLines; i++) {
|
|
2379
|
+
let line = 1 + Math.floor(Math.random() * Math.max(count, 20));
|
|
2380
|
+
while (usedLines.has(line)) line++;
|
|
2381
|
+
usedLines.add(line);
|
|
2382
|
+
lines.push({ line, content: pick(LINE_NOTES) });
|
|
515
2383
|
}
|
|
516
|
-
return
|
|
2384
|
+
return lines.sort((a, b) => a.line - b.line);
|
|
517
2385
|
}
|
|
518
|
-
function
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
2386
|
+
function sleep2(ms) {
|
|
2387
|
+
return new Promise((resolve2) => {
|
|
2388
|
+
setTimeout(resolve2, ms);
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
async function mockRiskAnalysisBatch(files) {
|
|
2392
|
+
const delay = 2e3 + Math.random() * 2e3;
|
|
2393
|
+
debugLog(`[mock] Risk batch: ${String(files.length)} files, waiting ${String(Math.round(delay / 1e3))}s`);
|
|
2394
|
+
await sleep2(delay);
|
|
2395
|
+
return files.map((f) => {
|
|
2396
|
+
const scores = {
|
|
2397
|
+
security: randomScore(),
|
|
2398
|
+
correctness: randomScore(),
|
|
2399
|
+
"error-handling": randomScore(),
|
|
2400
|
+
maintainability: randomScore(),
|
|
2401
|
+
architecture: randomScore(),
|
|
2402
|
+
performance: randomScore()
|
|
2403
|
+
};
|
|
2404
|
+
const aggregate = Math.max(...Object.values(scores));
|
|
2405
|
+
return {
|
|
2406
|
+
filePath: f.file_path,
|
|
2407
|
+
scores,
|
|
2408
|
+
aggregate,
|
|
2409
|
+
rationale: pick(LOREM),
|
|
2410
|
+
notes: {
|
|
2411
|
+
overview: pick(LOREM),
|
|
2412
|
+
lines: randomLines(50)
|
|
530
2413
|
}
|
|
2414
|
+
};
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
async function mockNarrativeAnalysisBatch(files) {
|
|
2418
|
+
const delay = 2e3 + Math.random() * 2e3;
|
|
2419
|
+
debugLog(`[mock] Narrative batch: ${String(files.length)} files, waiting ${String(Math.round(delay / 1e3))}s`);
|
|
2420
|
+
await sleep2(delay);
|
|
2421
|
+
const shuffled = files.slice().sort(() => Math.random() - 0.5);
|
|
2422
|
+
return shuffled.map((f, idx) => ({
|
|
2423
|
+
filePath: f.file_path,
|
|
2424
|
+
position: idx + 1,
|
|
2425
|
+
rationale: pick(LOREM),
|
|
2426
|
+
notes: {
|
|
2427
|
+
overview: pick(LOREM),
|
|
2428
|
+
lines: randomLines(50)
|
|
531
2429
|
}
|
|
532
|
-
}
|
|
533
|
-
return bestMatch;
|
|
2430
|
+
}));
|
|
534
2431
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
2432
|
+
var GUIDED_NOTES2 = [
|
|
2433
|
+
"This is a closure \u2014 it captures variables from the surrounding scope so they can be used later.",
|
|
2434
|
+
"The async/await pattern makes asynchronous code read like synchronous code.",
|
|
2435
|
+
"This uses destructuring to extract specific properties from an object.",
|
|
2436
|
+
"A Set is used here because it automatically prevents duplicate entries.",
|
|
2437
|
+
"Template literals (backtick strings) allow embedding expressions with ${...} syntax.",
|
|
2438
|
+
"The optional chaining operator (?.) safely accesses nested properties that might be null.",
|
|
2439
|
+
"This arrow function uses an implicit return \u2014 no braces means the expression is returned directly.",
|
|
2440
|
+
"The spread operator (...) creates a shallow copy of the array to avoid mutating the original."
|
|
2441
|
+
];
|
|
2442
|
+
async function mockGuidedAnalysisBatch(files) {
|
|
2443
|
+
const delay = 2e3 + Math.random() * 2e3;
|
|
2444
|
+
debugLog(`[mock] Guided batch: ${String(files.length)} files, waiting ${String(Math.round(delay / 1e3))}s`);
|
|
2445
|
+
await sleep2(delay);
|
|
2446
|
+
return files.map((f) => ({
|
|
2447
|
+
filePath: f.file_path,
|
|
2448
|
+
notes: {
|
|
2449
|
+
overview: pick(LOREM),
|
|
2450
|
+
lines: randomLines(50).map((l) => ({ ...l, content: pick(GUIDED_NOTES2) }))
|
|
545
2451
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
2452
|
+
}));
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// src/routes/ai-api.ts
|
|
2456
|
+
init_queries();
|
|
2457
|
+
var aiApiRoutes = new Hono();
|
|
2458
|
+
var cancelledAnalyses = /* @__PURE__ */ new Set();
|
|
2459
|
+
aiApiRoutes.get("/config", (c) => {
|
|
2460
|
+
const config = loadAIConfig();
|
|
2461
|
+
return c.json({
|
|
2462
|
+
platform: config.platform,
|
|
2463
|
+
model: config.model,
|
|
2464
|
+
keyConfigured: config.apiKey !== null || isAIServiceTest() || getDemoMode() !== null,
|
|
2465
|
+
keySource: config.keySource,
|
|
2466
|
+
guidedReview: loadGuidedReviewConfig()
|
|
2467
|
+
});
|
|
2468
|
+
});
|
|
2469
|
+
aiApiRoutes.post("/config", async (c) => {
|
|
2470
|
+
const body = await c.req.json();
|
|
2471
|
+
saveAIConfigPreferences(body.platform, body.model);
|
|
2472
|
+
if (body.guidedReview !== void 0) {
|
|
2473
|
+
saveGuidedReviewConfig(body.guidedReview);
|
|
2474
|
+
}
|
|
2475
|
+
return c.json({ ok: true });
|
|
2476
|
+
});
|
|
2477
|
+
aiApiRoutes.get("/models", (c) => {
|
|
2478
|
+
return c.json({
|
|
2479
|
+
platforms: PLATFORMS,
|
|
2480
|
+
models: MODELS
|
|
2481
|
+
});
|
|
2482
|
+
});
|
|
2483
|
+
aiApiRoutes.get("/key-status", (c) => {
|
|
2484
|
+
const platforms = ["anthropic", "openai", "google"];
|
|
2485
|
+
const status = {};
|
|
2486
|
+
for (const platform of platforms) {
|
|
2487
|
+
const { source } = resolveAPIKey(platform);
|
|
2488
|
+
status[platform] = { configured: source !== null, source };
|
|
2489
|
+
}
|
|
2490
|
+
return c.json({
|
|
2491
|
+
status,
|
|
2492
|
+
keychainAvailable: isKeychainAvailable(),
|
|
2493
|
+
keychainLabel: getKeychainLabel(),
|
|
2494
|
+
availablePlatforms: detectAvailablePlatforms()
|
|
2495
|
+
});
|
|
2496
|
+
});
|
|
2497
|
+
aiApiRoutes.post("/key", async (c) => {
|
|
2498
|
+
const body = await c.req.json();
|
|
2499
|
+
saveAPIKey(
|
|
2500
|
+
body.platform,
|
|
2501
|
+
body.key,
|
|
2502
|
+
body.storage
|
|
2503
|
+
);
|
|
2504
|
+
return c.json({ ok: true });
|
|
2505
|
+
});
|
|
2506
|
+
aiApiRoutes.delete("/key", (c) => {
|
|
2507
|
+
const platform = c.req.query("platform") ?? "anthropic";
|
|
2508
|
+
deleteAPIKey(platform);
|
|
2509
|
+
return c.json({ ok: true });
|
|
2510
|
+
});
|
|
2511
|
+
aiApiRoutes.post("/analyze", async (c) => {
|
|
2512
|
+
const reviewId = c.req.query("reviewId") ?? "";
|
|
2513
|
+
const repoRoot = c.get("repoRoot");
|
|
2514
|
+
const body = await c.req.json();
|
|
2515
|
+
const analysisType = body.type;
|
|
2516
|
+
const invalidateCache = body.invalidateCache === true;
|
|
2517
|
+
debugLog(`POST /analyze: type=${analysisType}, reviewId=${reviewId}`);
|
|
2518
|
+
if (analysisType !== "risk" && analysisType !== "narrative" && analysisType !== "guided") {
|
|
2519
|
+
return c.json({ error: "Invalid analysis type" }, 400);
|
|
2520
|
+
}
|
|
2521
|
+
const testMode = isAIServiceTest();
|
|
2522
|
+
const config = loadAIConfig();
|
|
2523
|
+
if (config.apiKey === null && !testMode) {
|
|
2524
|
+
debugLog("POST /analyze: no API key configured");
|
|
2525
|
+
return c.json({ error: "No API key configured" }, 400);
|
|
2526
|
+
}
|
|
2527
|
+
debugLog(`POST /analyze: platform=${config.platform}, model=${config.model}${testMode ? " (TEST MODE)" : ""}`);
|
|
2528
|
+
const files = await getReviewFiles(reviewId);
|
|
2529
|
+
debugLog(`POST /analyze: ${String(files.length)} files in review`);
|
|
2530
|
+
if (files.length === 0) {
|
|
2531
|
+
return c.json({ error: "No files in review" }, 400);
|
|
2532
|
+
}
|
|
2533
|
+
if (invalidateCache) {
|
|
2534
|
+
debugLog("POST /analyze: invalidateCache=true, cancelling all running analyses");
|
|
2535
|
+
for (const type of ["risk", "narrative", "guided"]) {
|
|
2536
|
+
const running = await getLatestAnalysis(reviewId, type);
|
|
2537
|
+
if (running !== void 0 && running.status === "running") {
|
|
2538
|
+
debugLog(`POST /analyze: cancelling ${type} analysis id=${running.id}`);
|
|
2539
|
+
cancelledAnalyses.add(running.id);
|
|
2540
|
+
await updateAnalysisStatus(running.id, "failed", "Cancelled");
|
|
557
2541
|
}
|
|
558
2542
|
}
|
|
2543
|
+
} else if (analysisType === "risk" || analysisType === "narrative") {
|
|
2544
|
+
const otherType = analysisType === "risk" ? "narrative" : "risk";
|
|
2545
|
+
const otherRunning = await getLatestAnalysis(reviewId, otherType);
|
|
2546
|
+
if (otherRunning !== void 0 && otherRunning.status === "running") {
|
|
2547
|
+
debugLog(`POST /analyze: cancelling ${otherType} analysis id=${otherRunning.id} (switching to ${analysisType})`);
|
|
2548
|
+
cancelledAnalyses.add(otherRunning.id);
|
|
2549
|
+
}
|
|
559
2550
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
2551
|
+
if (!invalidateCache) {
|
|
2552
|
+
const existing = await getLatestAnalysis(reviewId, analysisType);
|
|
2553
|
+
if (existing !== void 0) {
|
|
2554
|
+
debugLog(`POST /analyze: found existing ${analysisType} analysis id=${existing.id}, status=${existing.status}, created=${existing.created_at}, updated=${existing.updated_at}`);
|
|
2555
|
+
}
|
|
2556
|
+
if (existing !== void 0 && existing.status === "running") {
|
|
2557
|
+
const ageMs = Date.now() - (/* @__PURE__ */ new Date(existing.updated_at + "Z")).getTime();
|
|
2558
|
+
debugLog(`POST /analyze: existing analysis age=${String(Math.round(ageMs / 1e3))}s`);
|
|
2559
|
+
if (ageMs < 15 * 60 * 1e3) {
|
|
2560
|
+
debugLog("POST /analyze: reusing existing running analysis");
|
|
2561
|
+
return c.json({ analysisId: existing.id, status: "running" });
|
|
2562
|
+
}
|
|
2563
|
+
debugLog("POST /analyze: marking stale analysis as timed out");
|
|
2564
|
+
await updateAnalysisStatus(existing.id, "failed", "Analysis timed out");
|
|
2565
|
+
}
|
|
571
2566
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
2567
|
+
const analysis = await createAnalysis(reviewId, analysisType);
|
|
2568
|
+
debugLog(`POST /analyze: created new analysis id=${analysis.id}`);
|
|
2569
|
+
const guidedReview = loadGuidedReviewConfig();
|
|
2570
|
+
void (async () => {
|
|
2571
|
+
try {
|
|
2572
|
+
debugLog("Background analysis starting...");
|
|
2573
|
+
const contextWindow = getModelContextWindow(config.platform, config.model);
|
|
2574
|
+
debugLog(`Context window: ${String(contextWindow)} tokens`);
|
|
2575
|
+
const { batches, binaryFiles } = planBatches(files, contextWindow);
|
|
2576
|
+
const fileIdMap = new Map(files.map((f) => [f.file_path, f.id]));
|
|
2577
|
+
const totalAnalyzable = batches.reduce((sum, b) => sum + b.files.length, 0);
|
|
2578
|
+
debugLog(`Analysis plan: ${String(totalAnalyzable)} analyzable + ${String(binaryFiles.length)} binary = ${String(totalAnalyzable + binaryFiles.length)} total files in ${String(batches.length)} batch(es)`);
|
|
2579
|
+
const prevScores = invalidateCache ? [] : await getPreviousScores(reviewId, analysisType, analysis.id);
|
|
2580
|
+
const binaryPathSet = new Set(binaryFiles.map((f) => f.file_path));
|
|
2581
|
+
const unchangedPaths = /* @__PURE__ */ new Set();
|
|
2582
|
+
const cachedScores = prevScores.filter((s) => {
|
|
2583
|
+
if (fileIdMap.has(s.file_path) && !binaryPathSet.has(s.file_path)) {
|
|
2584
|
+
unchangedPaths.add(s.file_path);
|
|
2585
|
+
return true;
|
|
2586
|
+
}
|
|
2587
|
+
return false;
|
|
2588
|
+
});
|
|
2589
|
+
debugLog(`Cache: ${String(cachedScores.length)} scores from previous analysis, ${String(totalAnalyzable - cachedScores.length)} files need processing`);
|
|
2590
|
+
if (cachedScores.length > 0) {
|
|
2591
|
+
const cachedForInsert = cachedScores.map((s) => ({
|
|
2592
|
+
reviewFileId: fileIdMap.get(s.file_path) ?? s.review_file_id,
|
|
2593
|
+
filePath: s.file_path,
|
|
2594
|
+
sortOrder: s.sort_order,
|
|
2595
|
+
aggregateScore: s.aggregate_score,
|
|
2596
|
+
rationale: s.rationale,
|
|
2597
|
+
dimensionScores: s.dimension_scores !== null ? JSON.parse(s.dimension_scores) : null,
|
|
2598
|
+
notes: s.notes !== null ? JSON.parse(s.notes) : null
|
|
2599
|
+
}));
|
|
2600
|
+
await appendFileScores(analysis.id, cachedForInsert);
|
|
582
2601
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
2602
|
+
const filteredBatches = batches.map((batch) => {
|
|
2603
|
+
const remaining = batch.files.filter((f) => !unchangedPaths.has(f.file_path));
|
|
2604
|
+
return { files: remaining, estimatedTokens: batch.estimatedTokens };
|
|
2605
|
+
}).filter((batch) => batch.files.length > 0);
|
|
2606
|
+
const filteredAnalyzable = filteredBatches.reduce((sum, b) => sum + b.files.length, 0);
|
|
2607
|
+
const totalForProgress = filteredAnalyzable + binaryFiles.length + cachedScores.length;
|
|
2608
|
+
debugLog(`After cache: ${String(filteredAnalyzable)} files to analyze in ${String(filteredBatches.length)} batch(es)`);
|
|
2609
|
+
await updateAnalysisProgress(analysis.id, cachedScores.length, totalForProgress);
|
|
2610
|
+
if (binaryFiles.length > 0) {
|
|
2611
|
+
debugLog(`Saving ${String(binaryFiles.length)} binary files with score 0`);
|
|
2612
|
+
const binaryScoreEntries = binaryFiles.map((f, idx) => ({
|
|
2613
|
+
reviewFileId: fileIdMap.get(f.file_path) ?? "",
|
|
2614
|
+
filePath: f.file_path,
|
|
2615
|
+
sortOrder: 99999 + idx,
|
|
2616
|
+
// Will be re-sorted later
|
|
2617
|
+
aggregateScore: analysisType === "risk" ? 0 : null,
|
|
2618
|
+
rationale: "Binary file \u2014 not analyzed",
|
|
2619
|
+
dimensionScores: analysisType === "risk" ? { security: 0, correctness: 0, "error-handling": 0, maintainability: 0, architecture: 0, performance: 0 } : null,
|
|
2620
|
+
notes: null
|
|
2621
|
+
}));
|
|
2622
|
+
await appendFileScores(analysis.id, binaryScoreEntries);
|
|
2623
|
+
await updateAnalysisProgress(analysis.id, cachedScores.length + binaryFiles.length, totalForProgress);
|
|
2624
|
+
}
|
|
2625
|
+
if (filteredBatches.length === 0) {
|
|
2626
|
+
debugLog("No batches to process (all files cached or binary), marking completed");
|
|
2627
|
+
await updateAnalysisStatus(analysis.id, "completed");
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
const shouldCancel = () => cancelledAnalyses.has(analysis.id);
|
|
2631
|
+
const progressOffset = cachedScores.length + binaryFiles.length;
|
|
2632
|
+
if (analysisType === "risk") {
|
|
2633
|
+
await runBatchedRiskAnalysis(analysis.id, filteredBatches, files, config, repoRoot, fileIdMap, totalForProgress, progressOffset, shouldCancel, guidedReview);
|
|
2634
|
+
} else if (analysisType === "narrative") {
|
|
2635
|
+
await runBatchedNarrativeAnalysis(analysis.id, filteredBatches, files, config, repoRoot, fileIdMap, totalForProgress, progressOffset, shouldCancel, guidedReview);
|
|
589
2636
|
} else {
|
|
590
|
-
|
|
591
|
-
for (const a of annotations) {
|
|
592
|
-
if (!a.is_stale) {
|
|
593
|
-
const content = findLineContent(oldDiff, a.line_number, a.side);
|
|
594
|
-
await markAnnotationStale(a.id, content);
|
|
595
|
-
stale++;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
2637
|
+
await runBatchedGuidedAnalysis(analysis.id, filteredBatches, files, config, repoRoot, fileIdMap, totalForProgress, progressOffset, shouldCancel, guidedReview);
|
|
598
2638
|
}
|
|
2639
|
+
if (cancelledAnalyses.has(analysis.id)) {
|
|
2640
|
+
cancelledAnalyses.delete(analysis.id);
|
|
2641
|
+
debugLog(`Analysis ${analysis.id} was cancelled (user switched modes)`);
|
|
2642
|
+
await updateAnalysisStatus(analysis.id, "failed", "Cancelled");
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
cancelledAnalyses.delete(analysis.id);
|
|
2646
|
+
debugLog(`Analysis ${analysis.id} completed successfully`);
|
|
2647
|
+
await updateAnalysisStatus(analysis.id, "completed");
|
|
2648
|
+
} catch (err) {
|
|
2649
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2650
|
+
console.error(`Analysis failed: ${message}`);
|
|
2651
|
+
debugLog(`Analysis ${analysis.id} failed: ${message}`);
|
|
2652
|
+
await updateAnalysisStatus(analysis.id, "failed", message);
|
|
599
2653
|
}
|
|
2654
|
+
})();
|
|
2655
|
+
return c.json({ analysisId: analysis.id, status: "running" });
|
|
2656
|
+
});
|
|
2657
|
+
async function runBatchedRiskAnalysis(analysisId, batches, allFiles, config, repoRoot, fileIdMap, progressTotal, progressOffset, shouldCancel, guidedReview) {
|
|
2658
|
+
const allResults = await runBatches(
|
|
2659
|
+
batches,
|
|
2660
|
+
allFiles.length,
|
|
2661
|
+
async (batch) => isAIServiceTest() ? mockRiskAnalysisBatch(batch.files) : runRiskAnalysisBatch(batch.files, config, repoRoot, guidedReview),
|
|
2662
|
+
async (_batchIndex, results) => {
|
|
2663
|
+
for (const r of results) {
|
|
2664
|
+
const maxDimension = Math.max(...Object.values(r.scores));
|
|
2665
|
+
r.aggregate = Math.max(r.aggregate, maxDimension);
|
|
2666
|
+
}
|
|
2667
|
+
const scores = results.map((r) => ({
|
|
2668
|
+
reviewFileId: fileIdMap.get(r.filePath) ?? "",
|
|
2669
|
+
filePath: r.filePath,
|
|
2670
|
+
sortOrder: 0,
|
|
2671
|
+
// Placeholder — final sort happens after all batches
|
|
2672
|
+
aggregateScore: r.aggregate,
|
|
2673
|
+
rationale: r.rationale,
|
|
2674
|
+
dimensionScores: r.scores,
|
|
2675
|
+
notes: r.notes ?? null
|
|
2676
|
+
}));
|
|
2677
|
+
await appendFileScores(analysisId, scores);
|
|
2678
|
+
},
|
|
2679
|
+
async (progress) => {
|
|
2680
|
+
await updateAnalysisProgress(analysisId, progressOffset + progress.completedFiles, progressTotal);
|
|
2681
|
+
},
|
|
2682
|
+
1,
|
|
2683
|
+
shouldCancel,
|
|
2684
|
+
"risk"
|
|
2685
|
+
);
|
|
2686
|
+
const sorted = allResults.slice().sort((a, b) => b.aggregate - a.aggregate);
|
|
2687
|
+
const sortMap = new Map(sorted.map((r, idx) => [r.filePath, idx]));
|
|
2688
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_connection(), connection_exports));
|
|
2689
|
+
const db2 = await getDb2();
|
|
2690
|
+
for (const [filePath, sortOrder] of sortMap) {
|
|
2691
|
+
await db2.query(
|
|
2692
|
+
"UPDATE ai_file_scores SET sort_order = $1 WHERE analysis_id = $2 AND file_path = $3",
|
|
2693
|
+
[sortOrder, analysisId, filePath]
|
|
2694
|
+
);
|
|
600
2695
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
2696
|
+
}
|
|
2697
|
+
async function runBatchedNarrativeAnalysis(analysisId, batches, allFiles, config, repoRoot, fileIdMap, progressTotal, progressOffset, shouldCancel, guidedReview) {
|
|
2698
|
+
const allResults = await runBatches(
|
|
2699
|
+
batches,
|
|
2700
|
+
allFiles.length,
|
|
2701
|
+
async (batch) => isAIServiceTest() ? mockNarrativeAnalysisBatch(batch.files) : runNarrativeAnalysisBatch(batch.files, config, repoRoot, guidedReview),
|
|
2702
|
+
async (_batchIndex, results) => {
|
|
2703
|
+
const scores = results.map((r) => ({
|
|
2704
|
+
reviewFileId: fileIdMap.get(r.filePath) ?? "",
|
|
2705
|
+
filePath: r.filePath,
|
|
2706
|
+
sortOrder: r.position,
|
|
2707
|
+
// Batch-local position — will be re-sorted after merge
|
|
2708
|
+
aggregateScore: null,
|
|
2709
|
+
rationale: r.rationale,
|
|
2710
|
+
dimensionScores: null,
|
|
2711
|
+
notes: r.notes ?? null
|
|
2712
|
+
}));
|
|
2713
|
+
await appendFileScores(analysisId, scores);
|
|
2714
|
+
},
|
|
2715
|
+
async (progress) => {
|
|
2716
|
+
await updateAnalysisProgress(analysisId, progressOffset + progress.completedFiles, progressTotal);
|
|
2717
|
+
},
|
|
2718
|
+
1,
|
|
2719
|
+
shouldCancel,
|
|
2720
|
+
"narrative"
|
|
2721
|
+
);
|
|
2722
|
+
if (allResults.length > 0) {
|
|
2723
|
+
const mergedPositions = mergeNarrativeOrders(allResults, batches.length);
|
|
2724
|
+
const { getDb: getDb2 } = await Promise.resolve().then(() => (init_connection(), connection_exports));
|
|
2725
|
+
const db2 = await getDb2();
|
|
2726
|
+
for (const [filePath, position] of mergedPositions) {
|
|
2727
|
+
await db2.query(
|
|
2728
|
+
"UPDATE ai_file_scores SET sort_order = $1 WHERE analysis_id = $2 AND file_path = $3",
|
|
2729
|
+
[position, analysisId, filePath]
|
|
2730
|
+
);
|
|
605
2731
|
}
|
|
606
2732
|
}
|
|
607
|
-
await updateReviewHead(reviewId, headCommit);
|
|
608
|
-
return { updated, added, stale };
|
|
609
2733
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
2734
|
+
async function runBatchedGuidedAnalysis(analysisId, batches, allFiles, config, repoRoot, fileIdMap, progressTotal, progressOffset, shouldCancel, guidedReview) {
|
|
2735
|
+
await runBatches(
|
|
2736
|
+
batches,
|
|
2737
|
+
allFiles.length,
|
|
2738
|
+
async (batch) => {
|
|
2739
|
+
if (isAIServiceTest()) return mockGuidedAnalysisBatch(batch.files);
|
|
2740
|
+
if (guidedReview === void 0) throw new Error("Guided review config required");
|
|
2741
|
+
return runGuidedAnalysisBatch(batch.files, config, repoRoot, guidedReview);
|
|
2742
|
+
},
|
|
2743
|
+
async (_batchIndex, results) => {
|
|
2744
|
+
const scores = results.map((r, idx) => ({
|
|
2745
|
+
reviewFileId: fileIdMap.get(r.filePath) ?? "",
|
|
2746
|
+
filePath: r.filePath,
|
|
2747
|
+
sortOrder: idx,
|
|
2748
|
+
aggregateScore: null,
|
|
2749
|
+
rationale: null,
|
|
2750
|
+
dimensionScores: null,
|
|
2751
|
+
notes: r.notes
|
|
2752
|
+
}));
|
|
2753
|
+
await appendFileScores(analysisId, scores);
|
|
2754
|
+
},
|
|
2755
|
+
async (progress) => {
|
|
2756
|
+
await updateAnalysisProgress(analysisId, progressOffset + progress.completedFiles, progressTotal);
|
|
2757
|
+
},
|
|
2758
|
+
1,
|
|
2759
|
+
shouldCancel,
|
|
2760
|
+
"guided"
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
aiApiRoutes.get("/analysis/:type", async (c) => {
|
|
2764
|
+
const reviewId = c.req.query("reviewId") ?? "";
|
|
2765
|
+
const analysisType = c.req.param("type");
|
|
2766
|
+
const analysis = await getLatestAnalysis(reviewId, analysisType);
|
|
2767
|
+
if (analysis === void 0) {
|
|
2768
|
+
debugLog(`GET /analysis/${analysisType}: no analysis found`);
|
|
2769
|
+
return c.json({ status: "none", scores: [] });
|
|
2770
|
+
}
|
|
2771
|
+
debugLog(`GET /analysis/${analysisType}: id=${analysis.id}, status=${analysis.status}, error=${analysis.error_message ?? "none"}`);
|
|
2772
|
+
if (analysis.status === "failed") {
|
|
2773
|
+
return c.json({
|
|
2774
|
+
status: analysis.status,
|
|
2775
|
+
error: analysis.error_message,
|
|
2776
|
+
scores: []
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
const scores = await getFileScoresForReview(reviewId, analysisType);
|
|
2780
|
+
return c.json({
|
|
2781
|
+
status: analysis.status,
|
|
2782
|
+
progressCompleted: analysis.progress_completed,
|
|
2783
|
+
progressTotal: analysis.progress_total,
|
|
2784
|
+
scores: scores.map((s) => ({
|
|
2785
|
+
reviewFileId: s.review_file_id,
|
|
2786
|
+
filePath: s.file_path,
|
|
2787
|
+
sortOrder: s.sort_order,
|
|
2788
|
+
aggregateScore: s.aggregate_score,
|
|
2789
|
+
rationale: s.rationale,
|
|
2790
|
+
dimensionScores: s.dimension_scores !== null ? JSON.parse(s.dimension_scores) : null,
|
|
2791
|
+
notes: s.notes !== null ? JSON.parse(s.notes) : null
|
|
2792
|
+
}))
|
|
2793
|
+
});
|
|
2794
|
+
});
|
|
2795
|
+
aiApiRoutes.get("/analysis/:type/status", async (c) => {
|
|
2796
|
+
const reviewId = c.req.query("reviewId") ?? "";
|
|
2797
|
+
const analysisType = c.req.param("type");
|
|
2798
|
+
const analysis = await getLatestAnalysis(reviewId, analysisType);
|
|
2799
|
+
if (analysis === void 0) {
|
|
2800
|
+
debugLog(`GET /analysis/${analysisType}/status: no analysis found`);
|
|
2801
|
+
return c.json({ status: "none" });
|
|
2802
|
+
}
|
|
2803
|
+
debugLog(`GET /analysis/${analysisType}/status: id=${analysis.id}, status=${analysis.status}, progress=${String(analysis.progress_completed)}/${String(analysis.progress_total)}, updated=${analysis.updated_at}`);
|
|
2804
|
+
if (analysis.status === "running") {
|
|
2805
|
+
const ageMs = Date.now() - (/* @__PURE__ */ new Date(analysis.updated_at + "Z")).getTime();
|
|
2806
|
+
if (ageMs > 15 * 60 * 1e3) {
|
|
2807
|
+
debugLog(`GET /analysis/${analysisType}/status: timing out stale analysis (age=${String(Math.round(ageMs / 1e3))}s)`);
|
|
2808
|
+
await updateAnalysisStatus(analysis.id, "failed", "Analysis timed out");
|
|
2809
|
+
return c.json({ status: "failed", error: "Analysis timed out" });
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
return c.json({
|
|
2813
|
+
status: analysis.status,
|
|
2814
|
+
error: analysis.error_message,
|
|
2815
|
+
progressCompleted: analysis.progress_completed,
|
|
2816
|
+
progressTotal: analysis.progress_total
|
|
2817
|
+
});
|
|
2818
|
+
});
|
|
2819
|
+
aiApiRoutes.get("/debug-status", (c) => {
|
|
2820
|
+
return c.json({ enabled: isDebug() });
|
|
2821
|
+
});
|
|
2822
|
+
aiApiRoutes.post("/debug-log", async (c) => {
|
|
2823
|
+
if (!isDebug()) return c.json({ ok: true });
|
|
2824
|
+
const body = await c.req.json();
|
|
2825
|
+
debugLog(`[client] ${body.message}`);
|
|
2826
|
+
return c.json({ ok: true });
|
|
2827
|
+
});
|
|
2828
|
+
aiApiRoutes.get("/preferences", async (c) => {
|
|
2829
|
+
const prefs = await getUserPreferences();
|
|
2830
|
+
return c.json(prefs);
|
|
2831
|
+
});
|
|
2832
|
+
aiApiRoutes.post("/preferences", async (c) => {
|
|
2833
|
+
const body = await c.req.json();
|
|
2834
|
+
await saveUserPreferences(body);
|
|
2835
|
+
return c.json({ ok: true });
|
|
2836
|
+
});
|
|
618
2837
|
|
|
619
2838
|
// src/routes/api.ts
|
|
620
|
-
|
|
2839
|
+
init_queries();
|
|
2840
|
+
import { Hono as Hono2 } from "hono";
|
|
621
2841
|
|
|
622
2842
|
// src/export/generate.ts
|
|
623
|
-
|
|
624
|
-
import {
|
|
625
|
-
import {
|
|
626
|
-
import {
|
|
627
|
-
|
|
2843
|
+
init_queries();
|
|
2844
|
+
import { execSync as execSync3 } from "child_process";
|
|
2845
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2846
|
+
import { homedir as homedir3 } from "os";
|
|
2847
|
+
import { join as join3 } from "path";
|
|
2848
|
+
var DISMISS_FILE = join3(homedir3(), ".glassbox", "gitignore-dismissed.json");
|
|
628
2849
|
var DISMISS_DAYS = 30;
|
|
629
2850
|
function loadDismissals() {
|
|
630
2851
|
try {
|
|
631
|
-
return JSON.parse(
|
|
2852
|
+
return JSON.parse(readFileSync3(DISMISS_FILE, "utf-8"));
|
|
632
2853
|
} catch {
|
|
633
2854
|
return {};
|
|
634
2855
|
}
|
|
635
2856
|
}
|
|
636
2857
|
function saveDismissals(data) {
|
|
637
|
-
const dir =
|
|
638
|
-
|
|
639
|
-
|
|
2858
|
+
const dir = join3(homedir3(), ".glassbox");
|
|
2859
|
+
mkdirSync3(dir, { recursive: true });
|
|
2860
|
+
writeFileSync2(DISMISS_FILE, JSON.stringify(data), "utf-8");
|
|
640
2861
|
}
|
|
641
2862
|
function isGlassboxGitignored(repoRoot) {
|
|
642
2863
|
try {
|
|
643
|
-
|
|
2864
|
+
execSync3("git check-ignore -q .glassbox", { cwd: repoRoot, stdio: "pipe" });
|
|
644
2865
|
return true;
|
|
645
2866
|
} catch {
|
|
646
2867
|
return false;
|
|
@@ -657,16 +2878,16 @@ function shouldPromptGitignore(repoRoot) {
|
|
|
657
2878
|
return true;
|
|
658
2879
|
}
|
|
659
2880
|
function addGlassboxToGitignore(repoRoot) {
|
|
660
|
-
const gitignorePath =
|
|
661
|
-
if (
|
|
662
|
-
const content =
|
|
2881
|
+
const gitignorePath = join3(repoRoot, ".gitignore");
|
|
2882
|
+
if (existsSync2(gitignorePath)) {
|
|
2883
|
+
const content = readFileSync3(gitignorePath, "utf-8");
|
|
663
2884
|
if (!content.endsWith("\n")) {
|
|
664
2885
|
appendFileSync(gitignorePath, "\n.glassbox/\n", "utf-8");
|
|
665
2886
|
} else {
|
|
666
2887
|
appendFileSync(gitignorePath, ".glassbox/\n", "utf-8");
|
|
667
2888
|
}
|
|
668
2889
|
} else {
|
|
669
|
-
|
|
2890
|
+
writeFileSync2(gitignorePath, ".glassbox/\n", "utf-8");
|
|
670
2891
|
}
|
|
671
2892
|
}
|
|
672
2893
|
function dismissGitignorePrompt(repoRoot) {
|
|
@@ -675,17 +2896,17 @@ function dismissGitignorePrompt(repoRoot) {
|
|
|
675
2896
|
saveDismissals(dismissals);
|
|
676
2897
|
}
|
|
677
2898
|
function deleteReviewExport(reviewId, repoRoot) {
|
|
678
|
-
const exportDir =
|
|
679
|
-
const archivePath =
|
|
680
|
-
if (
|
|
2899
|
+
const exportDir = join3(repoRoot, ".glassbox");
|
|
2900
|
+
const archivePath = join3(exportDir, `review-${reviewId}.md`);
|
|
2901
|
+
if (existsSync2(archivePath)) unlinkSync(archivePath);
|
|
681
2902
|
}
|
|
682
2903
|
async function generateReviewExport(reviewId, repoRoot, isCurrent) {
|
|
683
2904
|
const review = await getReview(reviewId);
|
|
684
2905
|
if (!review) throw new Error("Review not found");
|
|
685
2906
|
const files = await getReviewFiles(reviewId);
|
|
686
2907
|
const annotations = await getAnnotationsForReview(reviewId);
|
|
687
|
-
const exportDir =
|
|
688
|
-
|
|
2908
|
+
const exportDir = join3(repoRoot, ".glassbox");
|
|
2909
|
+
mkdirSync3(exportDir, { recursive: true });
|
|
689
2910
|
const byFile = {};
|
|
690
2911
|
for (const a of annotations) {
|
|
691
2912
|
if (!(a.file_path in byFile)) byFile[a.file_path] = [];
|
|
@@ -750,11 +2971,11 @@ async function generateReviewExport(reviewId, repoRoot, isCurrent) {
|
|
|
750
2971
|
lines.push("6. **note** annotations are informational context. Consider them but they may not require code changes.");
|
|
751
2972
|
lines.push("");
|
|
752
2973
|
const content = lines.join("\n");
|
|
753
|
-
const archivePath =
|
|
754
|
-
|
|
2974
|
+
const archivePath = join3(exportDir, `review-${review.id}.md`);
|
|
2975
|
+
writeFileSync2(archivePath, content, "utf-8");
|
|
755
2976
|
if (isCurrent) {
|
|
756
|
-
const latestPath =
|
|
757
|
-
|
|
2977
|
+
const latestPath = join3(exportDir, "latest-review.md");
|
|
2978
|
+
writeFileSync2(latestPath, content, "utf-8");
|
|
758
2979
|
return latestPath;
|
|
759
2980
|
}
|
|
760
2981
|
return archivePath;
|
|
@@ -1079,7 +3300,7 @@ function pushIndentSymbol(root, stack, sym, indent, lines, lineIdx) {
|
|
|
1079
3300
|
}
|
|
1080
3301
|
|
|
1081
3302
|
// src/routes/api.ts
|
|
1082
|
-
var apiRoutes = new
|
|
3303
|
+
var apiRoutes = new Hono2();
|
|
1083
3304
|
function resolveReviewId(c) {
|
|
1084
3305
|
return c.req.query("reviewId") ?? c.get("reviewId");
|
|
1085
3306
|
}
|
|
@@ -1254,7 +3475,7 @@ apiRoutes.get("/context/:fileId", async (c) => {
|
|
|
1254
3475
|
});
|
|
1255
3476
|
|
|
1256
3477
|
// src/routes/pages.tsx
|
|
1257
|
-
import { Hono as
|
|
3478
|
+
import { Hono as Hono3 } from "hono";
|
|
1258
3479
|
|
|
1259
3480
|
// src/utils/escapeHtml.ts
|
|
1260
3481
|
function escapeHtml(str) {
|
|
@@ -1342,67 +3563,73 @@ function DiffView({ file, diff, annotations, mode }) {
|
|
|
1342
3563
|
] });
|
|
1343
3564
|
}
|
|
1344
3565
|
function SplitDiff({ hunks, annotationsByLine }) {
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
"
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
"
|
|
1359
|
-
hunk.
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
"
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
"
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
3566
|
+
const lastHunk = hunks[hunks.length - 1];
|
|
3567
|
+
const tailStart = lastHunk ? lastHunk.newStart + lastHunk.newCount : 1;
|
|
3568
|
+
return /* @__PURE__ */ jsx("div", { className: "diff-table-split", children: [
|
|
3569
|
+
hunks.map((hunk, hunkIdx) => {
|
|
3570
|
+
const pairs = pairLines(hunk.lines);
|
|
3571
|
+
return /* @__PURE__ */ jsx("div", { className: "hunk-block", children: [
|
|
3572
|
+
/* @__PURE__ */ jsx(
|
|
3573
|
+
"div",
|
|
3574
|
+
{
|
|
3575
|
+
className: "hunk-separator",
|
|
3576
|
+
"data-hunk-idx": hunkIdx,
|
|
3577
|
+
"data-old-start": hunk.oldStart,
|
|
3578
|
+
"data-old-count": hunk.oldCount,
|
|
3579
|
+
"data-new-start": hunk.newStart,
|
|
3580
|
+
"data-new-count": hunk.newCount,
|
|
3581
|
+
children: [
|
|
3582
|
+
"@@ -",
|
|
3583
|
+
hunk.oldStart,
|
|
3584
|
+
",",
|
|
3585
|
+
hunk.oldCount,
|
|
3586
|
+
" +",
|
|
3587
|
+
hunk.newStart,
|
|
3588
|
+
",",
|
|
3589
|
+
hunk.newCount,
|
|
3590
|
+
" @@"
|
|
3591
|
+
]
|
|
3592
|
+
}
|
|
3593
|
+
),
|
|
3594
|
+
pairs.map((pair) => {
|
|
3595
|
+
const leftAnns = pair.left ? annotationsByLine[`${pair.left.oldNum}:old`] ?? [] : [];
|
|
3596
|
+
const rightAnns = pair.right ? annotationsByLine[`${pair.right.newNum}:new`] ?? [] : [];
|
|
3597
|
+
const allAnns = [...leftAnns, ...rightAnns];
|
|
3598
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
3599
|
+
/* @__PURE__ */ jsx("div", { className: "split-row", children: [
|
|
3600
|
+
/* @__PURE__ */ jsx(
|
|
3601
|
+
"div",
|
|
3602
|
+
{
|
|
3603
|
+
className: `diff-line split-left ${pair.left?.type || "empty"}`,
|
|
3604
|
+
"data-line": pair.left?.oldNum ?? "",
|
|
3605
|
+
"data-side": "old",
|
|
3606
|
+
"data-new-line": pair.left?.newNum ?? pair.right?.newNum ?? "",
|
|
3607
|
+
children: [
|
|
3608
|
+
/* @__PURE__ */ jsx("span", { className: "gutter", children: pair.left?.oldNum ?? "" }),
|
|
3609
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: pair.left ? raw(escapeHtml(pair.left.content)) : "" })
|
|
3610
|
+
]
|
|
3611
|
+
}
|
|
3612
|
+
),
|
|
3613
|
+
/* @__PURE__ */ jsx(
|
|
3614
|
+
"div",
|
|
3615
|
+
{
|
|
3616
|
+
className: `diff-line split-right ${pair.right?.type || "empty"}`,
|
|
3617
|
+
"data-line": pair.right?.newNum ?? "",
|
|
3618
|
+
"data-side": "new",
|
|
3619
|
+
children: [
|
|
3620
|
+
/* @__PURE__ */ jsx("span", { className: "gutter", children: pair.right?.newNum ?? "" }),
|
|
3621
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: pair.right ? raw(escapeHtml(pair.right.content)) : "" })
|
|
3622
|
+
]
|
|
3623
|
+
}
|
|
3624
|
+
)
|
|
3625
|
+
] }),
|
|
3626
|
+
allAnns.length > 0 ? /* @__PURE__ */ jsx(AnnotationRows, { annotations: allAnns }) : null
|
|
3627
|
+
] });
|
|
3628
|
+
})
|
|
3629
|
+
] });
|
|
3630
|
+
}),
|
|
3631
|
+
/* @__PURE__ */ jsx("div", { className: "hunk-separator hunk-expander-tail", "data-start": tailStart, children: "\u2195 Show remaining lines" })
|
|
3632
|
+
] });
|
|
1406
3633
|
}
|
|
1407
3634
|
function pairLines(lines) {
|
|
1408
3635
|
const pairs = [];
|
|
@@ -1438,51 +3665,56 @@ function pairLines(lines) {
|
|
|
1438
3665
|
return pairs;
|
|
1439
3666
|
}
|
|
1440
3667
|
function UnifiedDiff({ hunks, annotationsByLine }) {
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
"
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
"
|
|
1453
|
-
hunk.
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
"
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
3668
|
+
const lastHunk = hunks[hunks.length - 1];
|
|
3669
|
+
const tailStart = lastHunk ? lastHunk.newStart + lastHunk.newCount : 1;
|
|
3670
|
+
return /* @__PURE__ */ jsx("div", { className: "diff-table-unified", children: [
|
|
3671
|
+
hunks.map((hunk, hunkIdx) => /* @__PURE__ */ jsx("div", { className: "hunk-block", children: [
|
|
3672
|
+
/* @__PURE__ */ jsx(
|
|
3673
|
+
"div",
|
|
3674
|
+
{
|
|
3675
|
+
className: "hunk-separator",
|
|
3676
|
+
"data-hunk-idx": hunkIdx,
|
|
3677
|
+
"data-old-start": hunk.oldStart,
|
|
3678
|
+
"data-old-count": hunk.oldCount,
|
|
3679
|
+
"data-new-start": hunk.newStart,
|
|
3680
|
+
"data-new-count": hunk.newCount,
|
|
3681
|
+
children: [
|
|
3682
|
+
"@@ -",
|
|
3683
|
+
hunk.oldStart,
|
|
3684
|
+
",",
|
|
3685
|
+
hunk.oldCount,
|
|
3686
|
+
" +",
|
|
3687
|
+
hunk.newStart,
|
|
3688
|
+
",",
|
|
3689
|
+
hunk.newCount,
|
|
3690
|
+
" @@"
|
|
3691
|
+
]
|
|
3692
|
+
}
|
|
3693
|
+
),
|
|
3694
|
+
hunk.lines.map((line) => {
|
|
3695
|
+
const lineNum = line.type === "remove" ? line.oldNum : line.newNum;
|
|
3696
|
+
const side = line.type === "remove" ? "old" : "new";
|
|
3697
|
+
const anns = annotationsByLine[`${lineNum}:${side}`] ?? [];
|
|
3698
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
3699
|
+
/* @__PURE__ */ jsx(
|
|
3700
|
+
"div",
|
|
3701
|
+
{
|
|
3702
|
+
className: `diff-line ${line.type}${anns.length ? " has-annotation" : ""}`,
|
|
3703
|
+
"data-line": lineNum,
|
|
3704
|
+
"data-side": side,
|
|
3705
|
+
children: [
|
|
3706
|
+
/* @__PURE__ */ jsx("span", { className: "gutter-old", children: line.oldNum ?? "" }),
|
|
3707
|
+
/* @__PURE__ */ jsx("span", { className: "gutter-new", children: line.newNum ?? "" }),
|
|
3708
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: raw(escapeHtml(line.content)) })
|
|
3709
|
+
]
|
|
3710
|
+
}
|
|
3711
|
+
),
|
|
3712
|
+
anns.length > 0 ? /* @__PURE__ */ jsx(AnnotationRows, { annotations: anns }) : null
|
|
3713
|
+
] });
|
|
3714
|
+
})
|
|
3715
|
+
] })),
|
|
3716
|
+
/* @__PURE__ */ jsx("div", { className: "hunk-separator hunk-expander-tail", "data-start": tailStart, children: "\u2195 Show remaining lines" })
|
|
3717
|
+
] });
|
|
1486
3718
|
}
|
|
1487
3719
|
function AnnotationRows({ annotations }) {
|
|
1488
3720
|
return /* @__PURE__ */ jsx("div", { className: "annotation-row", children: annotations.map((a) => /* @__PURE__ */ jsx(
|
|
@@ -1748,7 +3980,8 @@ function getHistoryScript() {
|
|
|
1748
3980
|
}
|
|
1749
3981
|
|
|
1750
3982
|
// src/routes/pages.tsx
|
|
1751
|
-
|
|
3983
|
+
init_queries();
|
|
3984
|
+
var pageRoutes = new Hono3();
|
|
1752
3985
|
pageRoutes.get("/", async (c) => {
|
|
1753
3986
|
const reviewId = c.get("reviewId");
|
|
1754
3987
|
const review = await getReview(reviewId);
|
|
@@ -1875,9 +4108,9 @@ pageRoutes.get("/history", async (c) => {
|
|
|
1875
4108
|
});
|
|
1876
4109
|
|
|
1877
4110
|
// src/server.ts
|
|
1878
|
-
function tryServe(
|
|
4111
|
+
function tryServe(fetch2, port) {
|
|
1879
4112
|
return new Promise((resolve2, reject) => {
|
|
1880
|
-
const server = serve({ fetch, port });
|
|
4113
|
+
const server = serve({ fetch: fetch2, port });
|
|
1881
4114
|
server.on("listening", () => {
|
|
1882
4115
|
resolve2(port);
|
|
1883
4116
|
});
|
|
@@ -1891,7 +4124,7 @@ function tryServe(fetch, port) {
|
|
|
1891
4124
|
});
|
|
1892
4125
|
}
|
|
1893
4126
|
async function startServer(port, reviewId, repoRoot) {
|
|
1894
|
-
const app = new
|
|
4127
|
+
const app = new Hono4();
|
|
1895
4128
|
app.use("*", async (c, next) => {
|
|
1896
4129
|
c.set("reviewId", reviewId);
|
|
1897
4130
|
c.set("currentReviewId", reviewId);
|
|
@@ -1899,16 +4132,17 @@ async function startServer(port, reviewId, repoRoot) {
|
|
|
1899
4132
|
await next();
|
|
1900
4133
|
});
|
|
1901
4134
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
1902
|
-
const distDir =
|
|
4135
|
+
const distDir = existsSync3(join4(selfDir, "client", "styles.css")) ? join4(selfDir, "client") : join4(selfDir, "..", "dist", "client");
|
|
1903
4136
|
app.get("/static/styles.css", (c) => {
|
|
1904
|
-
const css =
|
|
4137
|
+
const css = readFileSync4(join4(distDir, "styles.css"), "utf-8");
|
|
1905
4138
|
return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
|
|
1906
4139
|
});
|
|
1907
4140
|
app.get("/static/app.js", (c) => {
|
|
1908
|
-
const js =
|
|
4141
|
+
const js = readFileSync4(join4(distDir, "app.global.js"), "utf-8");
|
|
1909
4142
|
return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
|
|
1910
4143
|
});
|
|
1911
4144
|
app.route("/api", apiRoutes);
|
|
4145
|
+
app.route("/api/ai", aiApiRoutes);
|
|
1912
4146
|
app.route("/", pageRoutes);
|
|
1913
4147
|
let actualPort = port;
|
|
1914
4148
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
@@ -1934,18 +4168,18 @@ async function startServer(port, reviewId, repoRoot) {
|
|
|
1934
4168
|
}
|
|
1935
4169
|
|
|
1936
4170
|
// src/update-check.ts
|
|
1937
|
-
import { existsSync as
|
|
4171
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1938
4172
|
import { get } from "https";
|
|
1939
|
-
import { homedir as
|
|
1940
|
-
import { dirname as dirname2, join as
|
|
4173
|
+
import { homedir as homedir4 } from "os";
|
|
4174
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
1941
4175
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1942
|
-
var DATA_DIR =
|
|
1943
|
-
var CHECK_FILE =
|
|
4176
|
+
var DATA_DIR = join5(homedir4(), ".glassbox");
|
|
4177
|
+
var CHECK_FILE = join5(DATA_DIR, "last-update-check");
|
|
1944
4178
|
var PACKAGE_NAME = "glassbox";
|
|
1945
4179
|
function getCurrentVersion() {
|
|
1946
4180
|
try {
|
|
1947
4181
|
const dir = dirname2(fileURLToPath2(import.meta.url));
|
|
1948
|
-
const pkg = JSON.parse(
|
|
4182
|
+
const pkg = JSON.parse(readFileSync5(join5(dir, "..", "package.json"), "utf-8"));
|
|
1949
4183
|
return pkg.version;
|
|
1950
4184
|
} catch {
|
|
1951
4185
|
return "0.0.0";
|
|
@@ -1953,16 +4187,16 @@ function getCurrentVersion() {
|
|
|
1953
4187
|
}
|
|
1954
4188
|
function getLastCheckDate() {
|
|
1955
4189
|
try {
|
|
1956
|
-
if (
|
|
1957
|
-
return
|
|
4190
|
+
if (existsSync4(CHECK_FILE)) {
|
|
4191
|
+
return readFileSync5(CHECK_FILE, "utf-8").trim();
|
|
1958
4192
|
}
|
|
1959
4193
|
} catch {
|
|
1960
4194
|
}
|
|
1961
4195
|
return null;
|
|
1962
4196
|
}
|
|
1963
4197
|
function saveCheckDate() {
|
|
1964
|
-
|
|
1965
|
-
|
|
4198
|
+
mkdirSync4(DATA_DIR, { recursive: true });
|
|
4199
|
+
writeFileSync3(CHECK_FILE, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "utf-8");
|
|
1966
4200
|
}
|
|
1967
4201
|
function isFirstUseToday() {
|
|
1968
4202
|
const last = getLastCheckDate();
|
|
@@ -2066,6 +4300,7 @@ Options:
|
|
|
2066
4300
|
--port <number> Port to run on (default: 4173)
|
|
2067
4301
|
--resume Resume the latest in-progress review for this mode
|
|
2068
4302
|
--check-for-updates Check for a newer version on npm
|
|
4303
|
+
--ai-service-test Use mock AI responses (no API calls, no tokens used)
|
|
2069
4304
|
--help Show this help message
|
|
2070
4305
|
|
|
2071
4306
|
Examples:
|
|
@@ -2083,6 +4318,8 @@ function parseArgs(argv) {
|
|
|
2083
4318
|
let resume = false;
|
|
2084
4319
|
let forceUpdateCheck = false;
|
|
2085
4320
|
let debug = false;
|
|
4321
|
+
let aiServiceTest = false;
|
|
4322
|
+
let demo = null;
|
|
2086
4323
|
for (let i = 0; i < args.length; i++) {
|
|
2087
4324
|
const arg = args[i];
|
|
2088
4325
|
switch (arg) {
|
|
@@ -2129,7 +4366,18 @@ function parseArgs(argv) {
|
|
|
2129
4366
|
case "--debug":
|
|
2130
4367
|
debug = true;
|
|
2131
4368
|
break;
|
|
4369
|
+
case "--ai-service-test":
|
|
4370
|
+
aiServiceTest = true;
|
|
4371
|
+
break;
|
|
2132
4372
|
default:
|
|
4373
|
+
if (arg.startsWith("--demo:")) {
|
|
4374
|
+
demo = parseInt(arg.slice(7), 10);
|
|
4375
|
+
if (isNaN(demo) || demo < 1) {
|
|
4376
|
+
console.error(`Invalid demo scenario: ${arg}`);
|
|
4377
|
+
process.exit(1);
|
|
4378
|
+
}
|
|
4379
|
+
break;
|
|
4380
|
+
}
|
|
2133
4381
|
console.error(`Unknown option: ${arg}`);
|
|
2134
4382
|
printUsage();
|
|
2135
4383
|
process.exit(1);
|
|
@@ -2138,7 +4386,7 @@ function parseArgs(argv) {
|
|
|
2138
4386
|
if (!mode) {
|
|
2139
4387
|
mode = { type: "uncommitted" };
|
|
2140
4388
|
}
|
|
2141
|
-
return { mode, port, resume, forceUpdateCheck, debug };
|
|
4389
|
+
return { mode, port, resume, forceUpdateCheck, debug, aiServiceTest, demo };
|
|
2142
4390
|
}
|
|
2143
4391
|
async function main() {
|
|
2144
4392
|
const parsed = parseArgs(process.argv);
|
|
@@ -2146,9 +4394,32 @@ async function main() {
|
|
|
2146
4394
|
printUsage();
|
|
2147
4395
|
process.exit(1);
|
|
2148
4396
|
}
|
|
2149
|
-
const { mode, port, resume, forceUpdateCheck, debug } = parsed;
|
|
4397
|
+
const { mode, port, resume, forceUpdateCheck, debug, aiServiceTest, demo } = parsed;
|
|
4398
|
+
setDebug(debug);
|
|
4399
|
+
setAIServiceTest(aiServiceTest);
|
|
4400
|
+
if (aiServiceTest) {
|
|
4401
|
+
console.log("AI service test mode enabled \u2014 using mock AI responses");
|
|
4402
|
+
}
|
|
2150
4403
|
if (debug) {
|
|
2151
|
-
console.log(`Build timestamp: ${"2026-03-
|
|
4404
|
+
console.log(`[debug] Build timestamp: ${"2026-03-10T06:39:37.519Z"}`);
|
|
4405
|
+
}
|
|
4406
|
+
if (demo !== null) {
|
|
4407
|
+
const scenario = DEMO_SCENARIOS.find((s) => s.id === demo);
|
|
4408
|
+
if (scenario === void 0) {
|
|
4409
|
+
console.error(`Unknown demo scenario: ${String(demo)}`);
|
|
4410
|
+
console.error("Available scenarios:");
|
|
4411
|
+
for (const s of DEMO_SCENARIOS) {
|
|
4412
|
+
console.error(` --demo:${String(s.id)} ${s.label}`);
|
|
4413
|
+
}
|
|
4414
|
+
process.exit(1);
|
|
4415
|
+
}
|
|
4416
|
+
setDemoMode(demo);
|
|
4417
|
+
console.log(`
|
|
4418
|
+
DEMO MODE: ${scenario.label}
|
|
4419
|
+
`);
|
|
4420
|
+
const { reviewId } = await setupDemoReview(demo);
|
|
4421
|
+
await startServer(port, reviewId, process.cwd());
|
|
4422
|
+
return;
|
|
2152
4423
|
}
|
|
2153
4424
|
await checkForUpdates(forceUpdateCheck);
|
|
2154
4425
|
const cwd = process.cwd();
|