jstar-reviewer 2.1.3 → 2.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 +2 -2
- package/bin/jstar.js +15 -4
- package/dist/scripts/chat.js +150 -0
- package/dist/scripts/config.js +6 -2
- package/dist/scripts/core/critique.js +137 -0
- package/dist/scripts/core/debate.js +95 -0
- package/dist/scripts/detective.js +5 -4
- package/dist/scripts/gemini-embedding.js +2 -2
- package/dist/scripts/indexer.js +4 -3
- package/dist/scripts/reviewer.js +139 -43
- package/dist/scripts/session.js +273 -0
- package/dist/scripts/ui/interaction.js +43 -0
- package/dist/scripts/utils/logger.js +110 -0
- package/package.json +14 -10
- package/scripts/chat.ts +130 -0
- package/scripts/config.ts +6 -2
- package/scripts/core/critique.ts +162 -0
- package/scripts/core/debate.ts +111 -0
- package/scripts/detective.ts +5 -4
- package/scripts/gemini-embedding.ts +2 -2
- package/scripts/indexer.ts +4 -3
- package/scripts/reviewer.ts +154 -43
- package/scripts/session.ts +312 -0
- package/scripts/types.ts +9 -0
- package/scripts/ui/interaction.ts +38 -0
- package/scripts/utils/logger.ts +118 -0
- package/setup.js +5 -5
- package/scripts/local-embedding.ts +0 -55
package/scripts/reviewer.ts
CHANGED
|
@@ -5,12 +5,15 @@ import * as path from "path";
|
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import simpleGit from "simple-git";
|
|
8
|
+
import { Logger } from "./utils/logger";
|
|
8
9
|
import { Config } from "./config";
|
|
9
10
|
import { Detective } from "./detective";
|
|
10
11
|
import { GeminiEmbedding } from "./gemini-embedding";
|
|
11
12
|
import { MockLLM } from "./mock-llm";
|
|
12
13
|
import { FileFinding, DashboardReport, LLMReviewResponse, EMPTY_REVIEW } from "./types";
|
|
13
14
|
import { renderDashboard, determineStatus, generateRecommendation } from "./dashboard";
|
|
15
|
+
import { startInteractiveSession } from "./session";
|
|
16
|
+
import { critiqueFindings } from "./core/critique";
|
|
14
17
|
import {
|
|
15
18
|
VectorStoreIndex,
|
|
16
19
|
storageContextFromDefaults,
|
|
@@ -18,7 +21,8 @@ import {
|
|
|
18
21
|
serviceContextFromDefaults
|
|
19
22
|
} from "llamaindex";
|
|
20
23
|
|
|
21
|
-
const
|
|
24
|
+
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
25
|
+
const google = createGoogleGenerativeAI({ apiKey: geminiKey });
|
|
22
26
|
const groq = createGroq({ apiKey: process.env.GROQ_API_KEY });
|
|
23
27
|
|
|
24
28
|
const embedModel = new GeminiEmbedding();
|
|
@@ -65,6 +69,38 @@ function sleep(ms: number): Promise<void> {
|
|
|
65
69
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Filter issues by confidence threshold and log what was removed
|
|
74
|
+
*/
|
|
75
|
+
function filterByConfidence(findings: FileFinding[]): FileFinding[] {
|
|
76
|
+
const threshold = Config.CONFIDENCE_THRESHOLD;
|
|
77
|
+
let removedCount = 0;
|
|
78
|
+
|
|
79
|
+
const filtered = findings.map(finding => {
|
|
80
|
+
const validIssues = finding.issues.filter(issue => {
|
|
81
|
+
const confidence = issue.confidenceScore ?? 5; // Default to high if not specified
|
|
82
|
+
if (confidence < threshold) {
|
|
83
|
+
removedCount++;
|
|
84
|
+
Logger.info(chalk.dim(` ⚡ Low confidence (${confidence}): "${issue.title}" - filtered out`));
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
...finding,
|
|
92
|
+
issues: validIssues,
|
|
93
|
+
severity: validIssues.length === 0 ? 'LGTM' as const : finding.severity
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (removedCount > 0) {
|
|
98
|
+
Logger.info(chalk.blue(`\n 📊 Confidence Filter: ${removedCount} low-confidence issue(s) removed\n`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return filtered;
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
function parseReviewResponse(text: string): LLMReviewResponse {
|
|
69
105
|
try {
|
|
70
106
|
// Try to extract JSON from the response
|
|
@@ -107,20 +143,23 @@ function parseReviewResponse(text: string): LLMReviewResponse {
|
|
|
107
143
|
|
|
108
144
|
// --- Main ---
|
|
109
145
|
async function main() {
|
|
110
|
-
|
|
146
|
+
// Initialize logger mode based on CLI flags
|
|
147
|
+
Logger.init();
|
|
148
|
+
|
|
149
|
+
Logger.info(chalk.blue("🕵️ J-Star Reviewer: Analyzing your changes...\n"));
|
|
111
150
|
|
|
112
151
|
// 0. Environment Validation
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
if (!geminiKey || !process.env.GROQ_API_KEY) {
|
|
153
|
+
Logger.error(chalk.red("❌ Missing API Keys!"));
|
|
154
|
+
Logger.info(chalk.yellow("\nPlease ensure you have a .env.local file with:"));
|
|
155
|
+
Logger.info(chalk.white("- GEMINI_API_KEY (or GOOGLE_API_KEY)"));
|
|
156
|
+
Logger.info(chalk.white("- GROQ_API_KEY"));
|
|
157
|
+
Logger.info(chalk.white("\nCheck .env.example for a template.\n"));
|
|
119
158
|
return;
|
|
120
159
|
}
|
|
121
160
|
|
|
122
161
|
// 1. Detective
|
|
123
|
-
|
|
162
|
+
Logger.info(chalk.blue("🔎 Running Detective Engine..."));
|
|
124
163
|
|
|
125
164
|
const detective = new Detective(SOURCE_DIR);
|
|
126
165
|
await detective.scan();
|
|
@@ -129,13 +168,13 @@ async function main() {
|
|
|
129
168
|
// 1. Get the Diff
|
|
130
169
|
const diff = await git.diff(["--staged"]);
|
|
131
170
|
if (!diff) {
|
|
132
|
-
|
|
171
|
+
Logger.info(chalk.green("\n✅ No staged changes to review. (Did you 'git add'?)"));
|
|
133
172
|
return;
|
|
134
173
|
}
|
|
135
174
|
|
|
136
175
|
// 2. Load the Brain
|
|
137
176
|
if (!fs.existsSync(STORAGE_DIR)) {
|
|
138
|
-
|
|
177
|
+
Logger.error(chalk.red("❌ Local Brain not found. Run 'pnpm run index:init' first."));
|
|
139
178
|
return;
|
|
140
179
|
}
|
|
141
180
|
const storageContext = await storageContextFromDefaults({ persistDir: STORAGE_DIR });
|
|
@@ -149,15 +188,15 @@ async function main() {
|
|
|
149
188
|
const contextNodes = await retriever.retrieve(keywords);
|
|
150
189
|
const relatedContext = contextNodes.map(n => n.node.getContent(MetadataMode.NONE).slice(0, 1500)).join("\n");
|
|
151
190
|
|
|
152
|
-
|
|
191
|
+
Logger.info(chalk.yellow(`\n🧠 Found ${contextNodes.length} context chunk.`));
|
|
153
192
|
|
|
154
193
|
// 4. Chunk the Diff
|
|
155
194
|
const fileChunks = chunkDiffByFile(diff);
|
|
156
195
|
const totalTokens = estimateTokens(diff);
|
|
157
|
-
|
|
196
|
+
Logger.info(chalk.dim(` Total diff: ~${totalTokens} tokens across ${fileChunks.length} files.`));
|
|
158
197
|
|
|
159
|
-
// 5. Structured JSON Prompt
|
|
160
|
-
const systemPrompt = `You are J-Star, a Senior Code Reviewer. Be
|
|
198
|
+
// 5. Structured JSON Prompt (Conservative)
|
|
199
|
+
const systemPrompt = `You are J-Star, a Senior Code Reviewer. Be CONSERVATIVE and PRECISE.
|
|
161
200
|
|
|
162
201
|
Analyze the Git Diff and return a JSON response with this EXACT structure:
|
|
163
202
|
{
|
|
@@ -167,7 +206,8 @@ Analyze the Git Diff and return a JSON response with this EXACT structure:
|
|
|
167
206
|
"title": "Short issue title",
|
|
168
207
|
"description": "Detailed description of the problem",
|
|
169
208
|
"line": 42,
|
|
170
|
-
"fixPrompt": "A specific prompt an AI can use to fix this issue"
|
|
209
|
+
"fixPrompt": "A specific prompt an AI can use to fix this issue",
|
|
210
|
+
"confidenceScore": 5
|
|
171
211
|
}
|
|
172
212
|
]
|
|
173
213
|
}
|
|
@@ -178,10 +218,20 @@ SEVERITY GUIDE:
|
|
|
178
218
|
- P2_MEDIUM: Code quality, missing types, cleanup needed
|
|
179
219
|
- LGTM: No issues found (return empty issues array)
|
|
180
220
|
|
|
181
|
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
221
|
+
CONFIDENCE SCORE (1-5) - BE HONEST:
|
|
222
|
+
- 5: Absolutely certain. The bug is obvious in the diff.
|
|
223
|
+
- 4: Very likely. Clear code smell or anti-pattern.
|
|
224
|
+
- 3: Probable issue, might be missing context.
|
|
225
|
+
- 2: Unsure, could be intentional.
|
|
226
|
+
- 1: Speculation, likely false positive.
|
|
227
|
+
|
|
228
|
+
CRITICAL RULES:
|
|
229
|
+
1. Only flag issues you are HIGHLY confident about (4-5).
|
|
230
|
+
2. Test mocks, stubs, and intentional patterns are NOT bugs.
|
|
231
|
+
3. If the code looks intentional or well-handled, it's probably fine.
|
|
232
|
+
4. When in doubt, lean towards LGTM.
|
|
233
|
+
5. Return ONLY valid JSON, no markdown.
|
|
234
|
+
6. If the file is clean: {"severity": "LGTM", "issues": []}
|
|
185
235
|
|
|
186
236
|
Context: ${relatedContext.slice(0, 800)}`;
|
|
187
237
|
|
|
@@ -189,7 +239,7 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
189
239
|
let chunkIndex = 0;
|
|
190
240
|
let skippedCount = 0;
|
|
191
241
|
|
|
192
|
-
|
|
242
|
+
Logger.info(chalk.blue("\n⚖️ Sending to Judge...\n"));
|
|
193
243
|
|
|
194
244
|
for (const chunk of fileChunks) {
|
|
195
245
|
chunkIndex++;
|
|
@@ -197,7 +247,7 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
197
247
|
|
|
198
248
|
// Skip excluded files
|
|
199
249
|
if (shouldSkipFile(fileName)) {
|
|
200
|
-
|
|
250
|
+
Logger.info(chalk.dim(` ⏭️ Skipping ${fileName} (excluded)`));
|
|
201
251
|
skippedCount++;
|
|
202
252
|
continue;
|
|
203
253
|
}
|
|
@@ -206,7 +256,7 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
206
256
|
|
|
207
257
|
// Skip huge files
|
|
208
258
|
if (chunkTokens > MAX_TOKENS_PER_REQUEST) {
|
|
209
|
-
|
|
259
|
+
Logger.info(chalk.yellow(` ⚠️ Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
|
|
210
260
|
findings.push({
|
|
211
261
|
file: fileName,
|
|
212
262
|
severity: Config.DEFAULT_SEVERITY,
|
|
@@ -219,7 +269,7 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
219
269
|
continue;
|
|
220
270
|
}
|
|
221
271
|
|
|
222
|
-
|
|
272
|
+
Logger.progress(chalk.dim(` 📄 ${fileName}...`));
|
|
223
273
|
|
|
224
274
|
try {
|
|
225
275
|
const { text } = await generateText({
|
|
@@ -239,10 +289,10 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
239
289
|
const emoji = response.severity === 'LGTM' ? '✅' :
|
|
240
290
|
response.severity === 'P0_CRITICAL' ? '🛑' :
|
|
241
291
|
response.severity === 'P1_HIGH' ? '⚠️' : '📝';
|
|
242
|
-
|
|
292
|
+
Logger.info(` ${emoji}`);
|
|
243
293
|
|
|
244
294
|
} catch (error: any) {
|
|
245
|
-
|
|
295
|
+
Logger.info(chalk.red(` ❌ (${error.message.slice(0, 50)})`));
|
|
246
296
|
findings.push({
|
|
247
297
|
file: fileName,
|
|
248
298
|
severity: Config.DEFAULT_SEVERITY,
|
|
@@ -260,15 +310,24 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
260
310
|
}
|
|
261
311
|
}
|
|
262
312
|
|
|
263
|
-
// 6.
|
|
313
|
+
// 6. Confidence Filtering
|
|
314
|
+
Logger.info(chalk.blue("\n🎯 Filtering by Confidence...\n"));
|
|
315
|
+
let filteredFindings = filterByConfidence(findings);
|
|
316
|
+
|
|
317
|
+
// 7. Self-Critique Pass (if enabled)
|
|
318
|
+
if (Config.ENABLE_SELF_CRITIQUE) {
|
|
319
|
+
filteredFindings = await critiqueFindings(filteredFindings, diff);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 8. Build Dashboard Report
|
|
264
323
|
const metrics = {
|
|
265
324
|
filesScanned: fileChunks.length - skippedCount,
|
|
266
325
|
totalTokens,
|
|
267
|
-
violations:
|
|
268
|
-
critical:
|
|
269
|
-
high:
|
|
270
|
-
medium:
|
|
271
|
-
lgtm:
|
|
326
|
+
violations: filteredFindings.reduce((sum, f) => sum + f.issues.length, 0),
|
|
327
|
+
critical: filteredFindings.filter(f => f.severity === 'P0_CRITICAL').length,
|
|
328
|
+
high: filteredFindings.filter(f => f.severity === 'P1_HIGH').length,
|
|
329
|
+
medium: filteredFindings.filter(f => f.severity === 'P2_MEDIUM').length,
|
|
330
|
+
lgtm: filteredFindings.filter(f => f.severity === 'LGTM').length,
|
|
272
331
|
};
|
|
273
332
|
|
|
274
333
|
const report: DashboardReport = {
|
|
@@ -276,7 +335,7 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
276
335
|
reviewer: 'Detective Engine & Judge',
|
|
277
336
|
status: determineStatus(metrics),
|
|
278
337
|
metrics,
|
|
279
|
-
findings,
|
|
338
|
+
findings: filteredFindings,
|
|
280
339
|
recommendedAction: generateRecommendation(metrics)
|
|
281
340
|
};
|
|
282
341
|
|
|
@@ -287,20 +346,72 @@ Context: ${relatedContext.slice(0, 800)}`;
|
|
|
287
346
|
fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
|
|
288
347
|
fs.writeFileSync(OUTPUT_FILE, dashboard);
|
|
289
348
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
349
|
+
// Save Session State for "jstar chat"
|
|
350
|
+
const SESSION_FILE = path.join(process.cwd(), ".jstar", "session.json");
|
|
351
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
|
352
|
+
date: report.date,
|
|
353
|
+
findings: report.findings,
|
|
354
|
+
metrics: report.metrics
|
|
355
|
+
}, null, 2));
|
|
356
|
+
|
|
357
|
+
Logger.info("\n" + chalk.bold.green("📊 DASHBOARD GENERATED"));
|
|
358
|
+
Logger.info(chalk.dim(` Saved to: ${OUTPUT_FILE}`));
|
|
359
|
+
Logger.info("\n" + chalk.bold.white("─".repeat(50)));
|
|
293
360
|
|
|
294
361
|
// Print summary to console
|
|
295
362
|
const statusEmoji = report.status === 'APPROVED' ? '🟢' :
|
|
296
363
|
report.status === 'NEEDS_REVIEW' ? '🟡' : '🔴';
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
364
|
+
Logger.info(`\n${statusEmoji} Status: ${report.status.replace('_', ' ')}`);
|
|
365
|
+
Logger.info(` 🛑 Critical: ${metrics.critical}`);
|
|
366
|
+
Logger.info(` ⚠️ High: ${metrics.high}`);
|
|
367
|
+
Logger.info(` 📝 Medium: ${metrics.medium}`);
|
|
368
|
+
Logger.info(` ✅ LGTM: ${metrics.lgtm}`);
|
|
369
|
+
Logger.info(`\n💡 ${report.recommendedAction}`);
|
|
370
|
+
Logger.info(chalk.dim(`\n📄 Full report: ${OUTPUT_FILE}`));
|
|
371
|
+
|
|
372
|
+
// 8. Interactive Session OR JSON Output
|
|
373
|
+
if (Logger.isHeadless()) {
|
|
374
|
+
// In JSON mode: output report to stdout and skip interactive session
|
|
375
|
+
Logger.json(report);
|
|
376
|
+
} else {
|
|
377
|
+
// Normal TUI mode: start interactive session
|
|
378
|
+
const { updatedFindings, hasUpdates } = await startInteractiveSession(findings, index);
|
|
379
|
+
|
|
380
|
+
if (hasUpdates) {
|
|
381
|
+
Logger.info(chalk.blue("\n🔄 Updating Dashboard with session changes..."));
|
|
382
|
+
|
|
383
|
+
// Recalculate metrics
|
|
384
|
+
const newMetrics = {
|
|
385
|
+
filesScanned: fileChunks.length - skippedCount,
|
|
386
|
+
totalTokens,
|
|
387
|
+
violations: updatedFindings.reduce((sum, f) => sum + f.issues.length, 0),
|
|
388
|
+
critical: updatedFindings.filter(f => f.severity === 'P0_CRITICAL').length,
|
|
389
|
+
high: updatedFindings.filter(f => f.severity === 'P1_HIGH').length,
|
|
390
|
+
medium: updatedFindings.filter(f => f.severity === 'P2_MEDIUM').length,
|
|
391
|
+
lgtm: updatedFindings.filter(f => f.severity === 'LGTM').length,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const newReport: DashboardReport = {
|
|
395
|
+
...report, // Keep date/reviewer
|
|
396
|
+
metrics: newMetrics,
|
|
397
|
+
findings: updatedFindings,
|
|
398
|
+
status: determineStatus(newMetrics),
|
|
399
|
+
recommendedAction: generateRecommendation(newMetrics)
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const newDashboard = renderDashboard(newReport);
|
|
403
|
+
fs.writeFileSync(OUTPUT_FILE, newDashboard);
|
|
404
|
+
|
|
405
|
+
// Also update session file with new findings
|
|
406
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
|
407
|
+
date: newReport.date,
|
|
408
|
+
findings: newReport.findings,
|
|
409
|
+
metrics: newReport.metrics
|
|
410
|
+
}, null, 2));
|
|
411
|
+
|
|
412
|
+
Logger.info(chalk.bold.green("📊 DASHBOARD UPDATED"));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
304
415
|
}
|
|
305
416
|
|
|
306
417
|
main().catch(console.error);
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { FileFinding, ReviewIssue } from "./types";
|
|
2
|
+
import { showActionMenu, askForArgument } from "./ui/interaction";
|
|
3
|
+
import { debateIssue } from "./core/debate";
|
|
4
|
+
import { VectorStoreIndex } from "llamaindex";
|
|
5
|
+
import { Logger } from "./utils/logger";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import prompts from "prompts";
|
|
8
|
+
import * as readline from "readline";
|
|
9
|
+
|
|
10
|
+
export async function startInteractiveSession(
|
|
11
|
+
findings: FileFinding[],
|
|
12
|
+
index: VectorStoreIndex
|
|
13
|
+
): Promise<{ updatedFindings: FileFinding[], hasUpdates: boolean }> {
|
|
14
|
+
|
|
15
|
+
// Deep clone to track local state without mutating original immediately (though we return updated findings)
|
|
16
|
+
const interactiveFindings: FileFinding[] = JSON.parse(JSON.stringify(findings));
|
|
17
|
+
let hasUpdates = false;
|
|
18
|
+
let active = true;
|
|
19
|
+
|
|
20
|
+
if (interactiveFindings.length === 0 || interactiveFindings.every(f => f.issues.length === 0)) {
|
|
21
|
+
return { updatedFindings: interactiveFindings, hasUpdates: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(chalk.bold.magenta("\n🗣️ Interactive Review Session"));
|
|
25
|
+
console.log(chalk.dim(" Use arrow keys to navigate. Select an issue to debate."));
|
|
26
|
+
|
|
27
|
+
while (active) {
|
|
28
|
+
// Re-calculate choices every loop to reflect status changes
|
|
29
|
+
// Flatten
|
|
30
|
+
const flatIssues: {
|
|
31
|
+
issue: ReviewIssue,
|
|
32
|
+
fileIndex: number,
|
|
33
|
+
issueIndex: number,
|
|
34
|
+
file: string
|
|
35
|
+
}[] = [];
|
|
36
|
+
|
|
37
|
+
interactiveFindings.forEach((f, fIdx) => {
|
|
38
|
+
f.issues.forEach((i, iIdx) => {
|
|
39
|
+
flatIssues.push({
|
|
40
|
+
issue: i,
|
|
41
|
+
fileIndex: fIdx,
|
|
42
|
+
issueIndex: iIdx,
|
|
43
|
+
file: f.file
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const choices = flatIssues.map((item, idx) => {
|
|
49
|
+
const i = item.issue;
|
|
50
|
+
const statusIcon = i.status === 'resolved' ? '✅ ' : i.status === 'ignored' ? '🗑️ ' : '';
|
|
51
|
+
return {
|
|
52
|
+
title: `${statusIcon}${i.confidenceScore ? `[${i.confidenceScore}/5] ` : ''}${i.title} ${chalk.dim(`(${item.file})`)}`,
|
|
53
|
+
value: idx,
|
|
54
|
+
description: i.status ? `Marked as ${i.status}` : i.description.slice(0, 80)
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
choices.push({ title: '🚪 Finish Review', value: -1, description: 'Exit and save report' });
|
|
59
|
+
|
|
60
|
+
const { selectedIdx } = await prompts({
|
|
61
|
+
type: 'select',
|
|
62
|
+
name: 'selectedIdx',
|
|
63
|
+
message: 'Select an issue:',
|
|
64
|
+
choices: choices,
|
|
65
|
+
initial: 0
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (selectedIdx === undefined || selectedIdx === -1) {
|
|
69
|
+
active = false;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const selected = flatIssues[selectedIdx];
|
|
74
|
+
const { issue, file } = selected;
|
|
75
|
+
|
|
76
|
+
// Show Details
|
|
77
|
+
console.log(chalk.cyan(`\nTitle: ${issue.title}`));
|
|
78
|
+
console.log(chalk.white(issue.description));
|
|
79
|
+
console.log(chalk.dim(`File: ${file}`));
|
|
80
|
+
if (issue.confidenceScore) console.log(chalk.yellow(`Confidence: ${issue.confidenceScore}/5`));
|
|
81
|
+
if (issue.status) console.log(chalk.green(`Status: ${issue.status}`));
|
|
82
|
+
|
|
83
|
+
// Action Menu
|
|
84
|
+
const action = await showActionMenu(issue.title);
|
|
85
|
+
|
|
86
|
+
if (action === 'discuss') {
|
|
87
|
+
const argument = await askForArgument();
|
|
88
|
+
const result = await debateIssue(
|
|
89
|
+
issue.title,
|
|
90
|
+
issue.description,
|
|
91
|
+
file,
|
|
92
|
+
argument,
|
|
93
|
+
index
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
console.log(chalk.yellow(`\n🤖 Bot: ${result.text}`));
|
|
97
|
+
|
|
98
|
+
if (result.severity === 'LGTM') {
|
|
99
|
+
console.log(chalk.green("✅ Issue withdrawn by AI!"));
|
|
100
|
+
// Direct update to our state
|
|
101
|
+
interactiveFindings[selected.fileIndex].issues[selected.issueIndex].status = 'resolved';
|
|
102
|
+
hasUpdates = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
} else if (action === 'ignore') {
|
|
106
|
+
console.log(chalk.dim('Issue ignored locally.'));
|
|
107
|
+
interactiveFindings[selected.fileIndex].issues[selected.issueIndex].status = 'ignored';
|
|
108
|
+
hasUpdates = true;
|
|
109
|
+
} else if (action === 'accept') {
|
|
110
|
+
console.log(chalk.green('Issue accepted.'));
|
|
111
|
+
} else if (action === 'exit') {
|
|
112
|
+
active = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Filter out resolved/ignored issues for the final report
|
|
117
|
+
const finalFindings: FileFinding[] = interactiveFindings.map(f => ({
|
|
118
|
+
...f,
|
|
119
|
+
issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
|
|
120
|
+
})).filter(f => f.issues.length > 0);
|
|
121
|
+
|
|
122
|
+
return { updatedFindings: finalFindings, hasUpdates };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Headless interface for flatIssues structure
|
|
127
|
+
*/
|
|
128
|
+
interface FlatIssue {
|
|
129
|
+
id: number;
|
|
130
|
+
title: string;
|
|
131
|
+
description: string;
|
|
132
|
+
file: string;
|
|
133
|
+
confidenceScore?: number;
|
|
134
|
+
status?: 'resolved' | 'ignored';
|
|
135
|
+
fileIndex: number;
|
|
136
|
+
issueIndex: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Headless command protocol
|
|
141
|
+
*/
|
|
142
|
+
interface HeadlessCommand {
|
|
143
|
+
action: 'list' | 'debate' | 'ignore' | 'accept' | 'exit';
|
|
144
|
+
issueId?: number;
|
|
145
|
+
argument?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Flatten findings into a simple array for headless mode
|
|
150
|
+
*/
|
|
151
|
+
function flattenIssues(findings: FileFinding[]): FlatIssue[] {
|
|
152
|
+
const flat: FlatIssue[] = [];
|
|
153
|
+
findings.forEach((f, fIdx) => {
|
|
154
|
+
f.issues.forEach((i, iIdx) => {
|
|
155
|
+
flat.push({
|
|
156
|
+
id: flat.length,
|
|
157
|
+
title: i.title,
|
|
158
|
+
description: i.description,
|
|
159
|
+
file: f.file,
|
|
160
|
+
confidenceScore: i.confidenceScore,
|
|
161
|
+
status: i.status,
|
|
162
|
+
fileIndex: fIdx,
|
|
163
|
+
issueIndex: iIdx
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
return flat;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Headless session for AI agents and CI/CD.
|
|
172
|
+
*
|
|
173
|
+
* Protocol:
|
|
174
|
+
* - Input (stdin): JSON commands, one per line
|
|
175
|
+
* { "action": "list" }
|
|
176
|
+
* { "action": "debate", "issueId": 0, "argument": "This is intentional" }
|
|
177
|
+
* { "action": "ignore", "issueId": 0 }
|
|
178
|
+
* { "action": "accept", "issueId": 0 }
|
|
179
|
+
* { "action": "exit" }
|
|
180
|
+
*
|
|
181
|
+
* - Output (stdout): JSON events, one per line
|
|
182
|
+
* { "type": "ready", "issues": [...] }
|
|
183
|
+
* { "type": "list", "issues": [...] }
|
|
184
|
+
* { "type": "response", "issueId": 0, "text": "...", "verdict": "LGTM" | "STANDS" }
|
|
185
|
+
* { "type": "update", "issueId": 0, "status": "ignored" | "resolved" | "accepted" }
|
|
186
|
+
* { "type": "error", "message": "..." }
|
|
187
|
+
* { "type": "done", "hasUpdates": true, "updatedFindings": [...] }
|
|
188
|
+
*/
|
|
189
|
+
export async function startHeadlessSession(
|
|
190
|
+
findings: FileFinding[],
|
|
191
|
+
index: VectorStoreIndex
|
|
192
|
+
): Promise<{ updatedFindings: FileFinding[], hasUpdates: boolean }> {
|
|
193
|
+
|
|
194
|
+
// Deep clone to track state
|
|
195
|
+
const sessionFindings: FileFinding[] = JSON.parse(JSON.stringify(findings));
|
|
196
|
+
let hasUpdates = false;
|
|
197
|
+
|
|
198
|
+
// Emit ready event with all issues
|
|
199
|
+
const issues = flattenIssues(sessionFindings);
|
|
200
|
+
Logger.json({ type: 'ready', issues });
|
|
201
|
+
|
|
202
|
+
// Create readline interface for stdin
|
|
203
|
+
const rl = readline.createInterface({
|
|
204
|
+
input: process.stdin,
|
|
205
|
+
output: process.stdout,
|
|
206
|
+
terminal: false
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Process commands
|
|
210
|
+
for await (const line of rl) {
|
|
211
|
+
if (!line.trim()) continue;
|
|
212
|
+
|
|
213
|
+
let cmd: HeadlessCommand;
|
|
214
|
+
try {
|
|
215
|
+
cmd = JSON.parse(line);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
Logger.json({ type: 'error', message: 'Invalid JSON command' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const currentIssues = flattenIssues(sessionFindings);
|
|
222
|
+
|
|
223
|
+
switch (cmd.action) {
|
|
224
|
+
case 'list':
|
|
225
|
+
Logger.json({ type: 'list', issues: currentIssues });
|
|
226
|
+
break;
|
|
227
|
+
|
|
228
|
+
case 'debate':
|
|
229
|
+
if (cmd.issueId === undefined || !cmd.argument) {
|
|
230
|
+
Logger.json({ type: 'error', message: 'debate requires issueId and argument' });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const debateTarget = currentIssues.find(i => i.id === cmd.issueId);
|
|
234
|
+
if (!debateTarget) {
|
|
235
|
+
Logger.json({ type: 'error', message: `Issue ${cmd.issueId} not found` });
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const result = await debateIssue(
|
|
241
|
+
debateTarget.title,
|
|
242
|
+
debateTarget.description,
|
|
243
|
+
debateTarget.file,
|
|
244
|
+
cmd.argument,
|
|
245
|
+
index
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const verdict = result.severity === 'LGTM' ? 'LGTM' : 'STANDS';
|
|
249
|
+
Logger.json({
|
|
250
|
+
type: 'response',
|
|
251
|
+
issueId: cmd.issueId,
|
|
252
|
+
text: result.text,
|
|
253
|
+
verdict
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (result.severity === 'LGTM') {
|
|
257
|
+
sessionFindings[debateTarget.fileIndex].issues[debateTarget.issueIndex].status = 'resolved';
|
|
258
|
+
hasUpdates = true;
|
|
259
|
+
Logger.json({ type: 'update', issueId: cmd.issueId, status: 'resolved' });
|
|
260
|
+
}
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
Logger.json({ type: 'error', message: e.message });
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'ignore':
|
|
267
|
+
if (cmd.issueId === undefined) {
|
|
268
|
+
Logger.json({ type: 'error', message: 'ignore requires issueId' });
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const ignoreTarget = currentIssues.find(i => i.id === cmd.issueId);
|
|
272
|
+
if (!ignoreTarget) {
|
|
273
|
+
Logger.json({ type: 'error', message: `Issue ${cmd.issueId} not found` });
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
sessionFindings[ignoreTarget.fileIndex].issues[ignoreTarget.issueIndex].status = 'ignored';
|
|
277
|
+
hasUpdates = true;
|
|
278
|
+
Logger.json({ type: 'update', issueId: cmd.issueId, status: 'ignored' });
|
|
279
|
+
break;
|
|
280
|
+
|
|
281
|
+
case 'accept':
|
|
282
|
+
if (cmd.issueId === undefined) {
|
|
283
|
+
Logger.json({ type: 'error', message: 'accept requires issueId' });
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
Logger.json({ type: 'update', issueId: cmd.issueId, status: 'accepted' });
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
case 'exit':
|
|
290
|
+
// Filter out resolved/ignored for final report
|
|
291
|
+
const finalFindings: FileFinding[] = sessionFindings.map(f => ({
|
|
292
|
+
...f,
|
|
293
|
+
issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
|
|
294
|
+
})).filter(f => f.issues.length > 0);
|
|
295
|
+
|
|
296
|
+
Logger.json({ type: 'done', hasUpdates, updatedFindings: finalFindings });
|
|
297
|
+
rl.close();
|
|
298
|
+
return { updatedFindings: finalFindings, hasUpdates };
|
|
299
|
+
|
|
300
|
+
default:
|
|
301
|
+
Logger.json({ type: 'error', message: `Unknown action: ${(cmd as any).action}` });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// If stdin closes without exit command, still return
|
|
306
|
+
const finalFindings: FileFinding[] = sessionFindings.map(f => ({
|
|
307
|
+
...f,
|
|
308
|
+
issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
|
|
309
|
+
})).filter(f => f.issues.length > 0);
|
|
310
|
+
|
|
311
|
+
return { updatedFindings: finalFindings, hasUpdates };
|
|
312
|
+
}
|
package/scripts/types.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface ReviewIssue {
|
|
|
10
10
|
description: string;
|
|
11
11
|
line?: number;
|
|
12
12
|
fixPrompt: string;
|
|
13
|
+
confidenceScore?: number;
|
|
14
|
+
status?: 'resolved' | 'ignored';
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface FileFinding {
|
|
@@ -35,6 +37,12 @@ export interface DashboardReport {
|
|
|
35
37
|
recommendedAction: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
export interface SessionState {
|
|
41
|
+
date: string;
|
|
42
|
+
findings: FileFinding[];
|
|
43
|
+
metrics: DashboardReport['metrics'];
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* Schema for LLM response (per-file review)
|
|
40
48
|
*/
|
|
@@ -45,6 +53,7 @@ export interface LLMReviewResponse {
|
|
|
45
53
|
description: string;
|
|
46
54
|
line?: number;
|
|
47
55
|
fixPrompt: string;
|
|
56
|
+
confidenceScore?: number; // 1-5 confidence rating
|
|
48
57
|
}[];
|
|
49
58
|
}
|
|
50
59
|
|