jstar-reviewer 2.0.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 +147 -0
- package/bin/jstar.js +170 -0
- package/package.json +64 -0
- package/scripts/config.ts +21 -0
- package/scripts/dashboard.ts +227 -0
- package/scripts/detective.ts +137 -0
- package/scripts/gemini-embedding.ts +97 -0
- package/scripts/indexer.ts +103 -0
- package/scripts/local-embedding.ts +55 -0
- package/scripts/mock-llm.ts +18 -0
- package/scripts/reviewer.ts +295 -0
- package/scripts/types.ts +61 -0
- package/setup.js +364 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import { createGroq } from "@ai-sdk/groq";
|
|
3
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import simpleGit from "simple-git";
|
|
8
|
+
import { Config } from "./config";
|
|
9
|
+
import { Detective } from "./detective";
|
|
10
|
+
import { GeminiEmbedding } from "./gemini-embedding";
|
|
11
|
+
import { MockLLM } from "./mock-llm";
|
|
12
|
+
import { FileFinding, DashboardReport, LLMReviewResponse, EMPTY_REVIEW } from "./types";
|
|
13
|
+
import { renderDashboard, determineStatus, generateRecommendation } from "./dashboard";
|
|
14
|
+
import {
|
|
15
|
+
VectorStoreIndex,
|
|
16
|
+
storageContextFromDefaults,
|
|
17
|
+
MetadataMode,
|
|
18
|
+
serviceContextFromDefaults
|
|
19
|
+
} from "llamaindex";
|
|
20
|
+
|
|
21
|
+
const google = createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_API_KEY });
|
|
22
|
+
const groq = createGroq({ apiKey: process.env.GROQ_API_KEY });
|
|
23
|
+
|
|
24
|
+
const embedModel = new GeminiEmbedding();
|
|
25
|
+
const llm = new MockLLM();
|
|
26
|
+
const serviceContext = serviceContextFromDefaults({ embedModel, llm: llm as any });
|
|
27
|
+
|
|
28
|
+
const STORAGE_DIR = path.join(process.cwd(), ".jstar", "storage");
|
|
29
|
+
const SOURCE_DIR = path.join(process.cwd(), "scripts");
|
|
30
|
+
const OUTPUT_FILE = path.join(process.cwd(), ".jstar", "last-review.md");
|
|
31
|
+
const git = simpleGit();
|
|
32
|
+
|
|
33
|
+
// --- Config ---
|
|
34
|
+
const MODEL_NAME = Config.MODEL_NAME;
|
|
35
|
+
const MAX_TOKENS_PER_REQUEST = 8000;
|
|
36
|
+
const CHARS_PER_TOKEN = 4;
|
|
37
|
+
const DELAY_BETWEEN_CHUNKS_MS = 2000;
|
|
38
|
+
|
|
39
|
+
// --- Helpers ---
|
|
40
|
+
function estimateTokens(text: string): number {
|
|
41
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EXCLUDED_PATTERNS = [
|
|
45
|
+
/pnpm-lock\.yaml/,
|
|
46
|
+
/package-lock\.json/,
|
|
47
|
+
/yarn\.lock/,
|
|
48
|
+
/\.env/,
|
|
49
|
+
/\.json$/,
|
|
50
|
+
/\.txt$/,
|
|
51
|
+
/\.md$/,
|
|
52
|
+
/node_modules/,
|
|
53
|
+
/\.jstar\//,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function shouldSkipFile(fileName: string): boolean {
|
|
57
|
+
return EXCLUDED_PATTERNS.some(pattern => pattern.test(fileName));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function chunkDiffByFile(diff: string): string[] {
|
|
61
|
+
return diff.split(/(?=^diff --git)/gm).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms: number): Promise<void> {
|
|
65
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseReviewResponse(text: string): LLMReviewResponse {
|
|
69
|
+
try {
|
|
70
|
+
// Try to extract JSON from the response
|
|
71
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
72
|
+
if (jsonMatch) {
|
|
73
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
74
|
+
|
|
75
|
+
// Validate structure
|
|
76
|
+
if (
|
|
77
|
+
parsed &&
|
|
78
|
+
typeof parsed === 'object' &&
|
|
79
|
+
Array.isArray(parsed.issues) &&
|
|
80
|
+
['P0_CRITICAL', 'P1_HIGH', 'P2_MEDIUM', 'LGTM'].includes(parsed.severity)
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
severity: parsed.severity,
|
|
84
|
+
issues: parsed.issues
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Parse failed, try to extract from markdown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fallback: If "LGTM" in text, it's clean
|
|
93
|
+
if (text.includes('LGTM') || text.includes('ā
')) {
|
|
94
|
+
return { severity: 'LGTM', issues: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Otherwise, assume there are issues (treat as medium)
|
|
98
|
+
return {
|
|
99
|
+
severity: Config.DEFAULT_SEVERITY,
|
|
100
|
+
issues: [{
|
|
101
|
+
title: 'Review Notes',
|
|
102
|
+
description: text.slice(0, 500),
|
|
103
|
+
fixPrompt: 'Review the file and address the issues mentioned above.'
|
|
104
|
+
}]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Main ---
|
|
109
|
+
async function main() {
|
|
110
|
+
console.log(chalk.blue("šµļø J-Star Reviewer: Analyzing your changes...\n"));
|
|
111
|
+
|
|
112
|
+
// 0. Detective
|
|
113
|
+
console.log(chalk.blue("š Running Detective Engine..."));
|
|
114
|
+
const detective = new Detective(SOURCE_DIR);
|
|
115
|
+
await detective.scan();
|
|
116
|
+
detective.report();
|
|
117
|
+
|
|
118
|
+
// 1. Get the Diff
|
|
119
|
+
const diff = await git.diff(["--staged"]);
|
|
120
|
+
if (!diff) {
|
|
121
|
+
console.log(chalk.green("\nā
No staged changes to review. (Did you 'git add'?)"));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Load the Brain
|
|
126
|
+
if (!fs.existsSync(STORAGE_DIR)) {
|
|
127
|
+
console.error(chalk.red("ā Local Brain not found. Run 'pnpm run index:init' first."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const storageContext = await storageContextFromDefaults({ persistDir: STORAGE_DIR });
|
|
131
|
+
const index = await VectorStoreIndex.init({ storageContext, serviceContext });
|
|
132
|
+
|
|
133
|
+
// 3. Retrieval
|
|
134
|
+
const retriever = index.asRetriever({ similarityTopK: 1 });
|
|
135
|
+
const keywords = (diff.match(/import .* from ['"](.*)['"]/g) || [])
|
|
136
|
+
.map(s => s.replace(/import .* from ['"](.*)['"]/, '$1'))
|
|
137
|
+
.join(" ").slice(0, 300) || "general context";
|
|
138
|
+
const contextNodes = await retriever.retrieve(keywords);
|
|
139
|
+
const relatedContext = contextNodes.map(n => n.node.getContent(MetadataMode.NONE).slice(0, 1500)).join("\n");
|
|
140
|
+
|
|
141
|
+
console.log(chalk.yellow(`\nš§ Found ${contextNodes.length} context chunk.`));
|
|
142
|
+
|
|
143
|
+
// 4. Chunk the Diff
|
|
144
|
+
const fileChunks = chunkDiffByFile(diff);
|
|
145
|
+
const totalTokens = estimateTokens(diff);
|
|
146
|
+
console.log(chalk.dim(` Total diff: ~${totalTokens} tokens across ${fileChunks.length} files.`));
|
|
147
|
+
|
|
148
|
+
// 5. Structured JSON Prompt
|
|
149
|
+
const systemPrompt = `You are J-Star, a Senior Code Reviewer. Be direct and professional.
|
|
150
|
+
|
|
151
|
+
Analyze the Git Diff and return a JSON response with this EXACT structure:
|
|
152
|
+
{
|
|
153
|
+
"severity": "P0_CRITICAL" | "P1_HIGH" | "P2_MEDIUM" | "LGTM",
|
|
154
|
+
"issues": [
|
|
155
|
+
{
|
|
156
|
+
"title": "Short issue title",
|
|
157
|
+
"description": "Detailed description of the problem",
|
|
158
|
+
"line": 42,
|
|
159
|
+
"fixPrompt": "A specific prompt an AI can use to fix this issue"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
SEVERITY GUIDE:
|
|
165
|
+
- P0_CRITICAL: Security vulnerabilities, data leaks, auth bypass, SQL injection
|
|
166
|
+
- P1_HIGH: Missing validation, race conditions, architectural violations
|
|
167
|
+
- P2_MEDIUM: Code quality, missing types, cleanup needed
|
|
168
|
+
- LGTM: No issues found (return empty issues array)
|
|
169
|
+
|
|
170
|
+
IMPORTANT:
|
|
171
|
+
- Return ONLY valid JSON, no markdown or explanation
|
|
172
|
+
- Each issue MUST have a fixPrompt that explains exactly how to fix it
|
|
173
|
+
- If the file is clean, return {"severity": "LGTM", "issues": []}
|
|
174
|
+
|
|
175
|
+
Context: ${relatedContext.slice(0, 800)}`;
|
|
176
|
+
|
|
177
|
+
const findings: FileFinding[] = [];
|
|
178
|
+
let chunkIndex = 0;
|
|
179
|
+
let skippedCount = 0;
|
|
180
|
+
|
|
181
|
+
console.log(chalk.blue("\nāļø Sending to Judge...\n"));
|
|
182
|
+
|
|
183
|
+
for (const chunk of fileChunks) {
|
|
184
|
+
chunkIndex++;
|
|
185
|
+
const fileName = chunk.match(/diff --git a\/(.+?) /)?.[1] || `Chunk ${chunkIndex}`;
|
|
186
|
+
|
|
187
|
+
// Skip excluded files
|
|
188
|
+
if (shouldSkipFile(fileName)) {
|
|
189
|
+
console.log(chalk.dim(` āļø Skipping ${fileName} (excluded)`));
|
|
190
|
+
skippedCount++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const chunkTokens = estimateTokens(chunk) + estimateTokens(systemPrompt);
|
|
195
|
+
|
|
196
|
+
// Skip huge files
|
|
197
|
+
if (chunkTokens > MAX_TOKENS_PER_REQUEST) {
|
|
198
|
+
console.log(chalk.yellow(` ā ļø Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
|
|
199
|
+
findings.push({
|
|
200
|
+
file: fileName,
|
|
201
|
+
severity: Config.DEFAULT_SEVERITY,
|
|
202
|
+
issues: [{
|
|
203
|
+
title: 'File too large for review',
|
|
204
|
+
description: `This file has ~${chunkTokens} tokens which exceeds the limit.`,
|
|
205
|
+
fixPrompt: 'Consider splitting this file into smaller modules.'
|
|
206
|
+
}]
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
process.stdout.write(chalk.dim(` š ${fileName}...`));
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const { text } = await generateText({
|
|
215
|
+
model: groq(MODEL_NAME),
|
|
216
|
+
system: systemPrompt,
|
|
217
|
+
prompt: `REVIEW THIS DIFF:\n\n${chunk}`,
|
|
218
|
+
temperature: 0.1,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const response = parseReviewResponse(text);
|
|
222
|
+
findings.push({
|
|
223
|
+
file: fileName,
|
|
224
|
+
severity: response.severity,
|
|
225
|
+
issues: response.issues
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const emoji = response.severity === 'LGTM' ? 'ā
' :
|
|
229
|
+
response.severity === 'P0_CRITICAL' ? 'š' :
|
|
230
|
+
response.severity === 'P1_HIGH' ? 'ā ļø' : 'š';
|
|
231
|
+
console.log(` ${emoji}`);
|
|
232
|
+
|
|
233
|
+
} catch (error: any) {
|
|
234
|
+
console.log(chalk.red(` ā (${error.message.slice(0, 50)})`));
|
|
235
|
+
findings.push({
|
|
236
|
+
file: fileName,
|
|
237
|
+
severity: Config.DEFAULT_SEVERITY,
|
|
238
|
+
issues: [{
|
|
239
|
+
title: 'Review failed',
|
|
240
|
+
description: error.message,
|
|
241
|
+
fixPrompt: 'Retry the review or check manually.'
|
|
242
|
+
}]
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Rate limit delay
|
|
247
|
+
if (chunkIndex < fileChunks.length) {
|
|
248
|
+
await sleep(DELAY_BETWEEN_CHUNKS_MS);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 6. Build Dashboard Report
|
|
253
|
+
const metrics = {
|
|
254
|
+
filesScanned: fileChunks.length - skippedCount,
|
|
255
|
+
totalTokens,
|
|
256
|
+
violations: findings.reduce((sum, f) => sum + f.issues.length, 0),
|
|
257
|
+
critical: findings.filter(f => f.severity === 'P0_CRITICAL').length,
|
|
258
|
+
high: findings.filter(f => f.severity === 'P1_HIGH').length,
|
|
259
|
+
medium: findings.filter(f => f.severity === 'P2_MEDIUM').length,
|
|
260
|
+
lgtm: findings.filter(f => f.severity === 'LGTM').length,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const report: DashboardReport = {
|
|
264
|
+
date: new Date().toISOString().split('T')[0],
|
|
265
|
+
reviewer: 'Detective Engine & Judge',
|
|
266
|
+
status: determineStatus(metrics),
|
|
267
|
+
metrics,
|
|
268
|
+
findings,
|
|
269
|
+
recommendedAction: generateRecommendation(metrics)
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// 7. Render and Save Dashboard
|
|
273
|
+
const dashboard = renderDashboard(report);
|
|
274
|
+
|
|
275
|
+
// Ensure .jstar directory exists
|
|
276
|
+
fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
|
|
277
|
+
fs.writeFileSync(OUTPUT_FILE, dashboard);
|
|
278
|
+
|
|
279
|
+
console.log("\n" + chalk.bold.green("š DASHBOARD GENERATED"));
|
|
280
|
+
console.log(chalk.dim(` Saved to: ${OUTPUT_FILE}`));
|
|
281
|
+
console.log("\n" + chalk.bold.white("ā".repeat(50)));
|
|
282
|
+
|
|
283
|
+
// Print summary to console
|
|
284
|
+
const statusEmoji = report.status === 'APPROVED' ? 'š¢' :
|
|
285
|
+
report.status === 'NEEDS_REVIEW' ? 'š”' : 'š“';
|
|
286
|
+
console.log(`\n${statusEmoji} Status: ${report.status.replace('_', ' ')}`);
|
|
287
|
+
console.log(` š Critical: ${metrics.critical}`);
|
|
288
|
+
console.log(` ā ļø High: ${metrics.high}`);
|
|
289
|
+
console.log(` š Medium: ${metrics.medium}`);
|
|
290
|
+
console.log(` ā
LGTM: ${metrics.lgtm}`);
|
|
291
|
+
console.log(`\nš” ${report.recommendedAction}`);
|
|
292
|
+
console.log(chalk.dim(`\nš Full report: ${OUTPUT_FILE}`));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
main().catch(console.error);
|
package/scripts/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* J-Star Reviewer Types
|
|
3
|
+
* Structured types for dashboard output
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Severity = 'P0_CRITICAL' | 'P1_HIGH' | 'P2_MEDIUM' | 'LGTM';
|
|
7
|
+
|
|
8
|
+
export interface ReviewIssue {
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
fixPrompt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileFinding {
|
|
16
|
+
file: string;
|
|
17
|
+
severity: Severity;
|
|
18
|
+
issues: ReviewIssue[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DashboardReport {
|
|
22
|
+
date: string;
|
|
23
|
+
reviewer: string;
|
|
24
|
+
status: 'CRITICAL_FAILURE' | 'NEEDS_REVIEW' | 'APPROVED';
|
|
25
|
+
metrics: {
|
|
26
|
+
filesScanned: number;
|
|
27
|
+
totalTokens: number;
|
|
28
|
+
violations: number;
|
|
29
|
+
critical: number;
|
|
30
|
+
high: number;
|
|
31
|
+
medium: number;
|
|
32
|
+
lgtm: number;
|
|
33
|
+
};
|
|
34
|
+
findings: FileFinding[];
|
|
35
|
+
recommendedAction: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Schema for LLM response (per-file review)
|
|
40
|
+
*/
|
|
41
|
+
export interface LLMReviewResponse {
|
|
42
|
+
severity: Severity;
|
|
43
|
+
issues: {
|
|
44
|
+
title: string;
|
|
45
|
+
description: string;
|
|
46
|
+
line?: number;
|
|
47
|
+
fixPrompt: string;
|
|
48
|
+
}[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default empty response for parse failures
|
|
53
|
+
*/
|
|
54
|
+
export const EMPTY_REVIEW: LLMReviewResponse = {
|
|
55
|
+
severity: 'LGTM',
|
|
56
|
+
issues: []
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|