mstro-app 0.4.4 → 0.4.11
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/dist/server/server-setup.js +1 -1
- package/dist/server/server-setup.js.map +1 -1
- package/dist/server/services/auth.d.ts.map +1 -1
- package/dist/server/services/auth.js +4 -2
- package/dist/server/services/auth.js.map +1 -1
- package/dist/server/services/instances.js +1 -1
- package/dist/server/services/instances.js.map +1 -1
- package/dist/server/services/plan/config-installer.d.ts +5 -14
- package/dist/server/services/plan/config-installer.d.ts.map +1 -1
- package/dist/server/services/plan/config-installer.js +14 -72
- package/dist/server/services/plan/config-installer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +15 -8
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +95 -59
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts +17 -0
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -0
- package/dist/server/services/plan/issue-prompt-builder.js +73 -0
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -0
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +9 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +2 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform.d.ts +1 -0
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +3 -7
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +5 -4
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +2 -2
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.d.ts +1 -1
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +2 -2
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +12 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +5 -7
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-linting.d.ts +1 -1
- package/dist/server/services/websocket/quality-linting.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-linting.js +16 -11
- package/dist/server/services/websocket/quality-linting.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +14 -3
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +87 -23
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +62 -39
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +2 -0
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +3 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/package.json +1 -1
- package/server/server-setup.ts +1 -1
- package/server/services/auth.ts +3 -2
- package/server/services/instances.ts +1 -1
- package/server/services/plan/config-installer.ts +13 -72
- package/server/services/plan/executor.ts +105 -61
- package/server/services/plan/issue-prompt-builder.ts +92 -0
- package/server/services/plan/parser-core.ts +8 -0
- package/server/services/plan/types.ts +2 -0
- package/server/services/platform.ts +3 -8
- package/server/services/websocket/file-explorer-handlers.ts +7 -3
- package/server/services/websocket/git-handlers.ts +3 -3
- package/server/services/websocket/git-pr-handlers.ts +3 -3
- package/server/services/websocket/handler.ts +12 -0
- package/server/services/websocket/quality-complexity.ts +5 -7
- package/server/services/websocket/quality-linting.ts +17 -10
- package/server/services/websocket/quality-review-agent.ts +95 -23
- package/server/services/websocket/quality-service.ts +70 -39
- package/server/services/websocket/quality-types.ts +2 -0
- package/server/services/websocket/session-handlers.ts +3 -0
|
@@ -208,6 +208,13 @@ function optionalNumber(val: unknown): number | null {
|
|
|
208
208
|
return val != null ? Number(val) : null;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
function clampParallelAgents(val: unknown): number {
|
|
212
|
+
if (val == null) return 3;
|
|
213
|
+
const n = Number(val);
|
|
214
|
+
if (!Number.isFinite(n) || n < 1) return 3;
|
|
215
|
+
return Math.min(Math.round(n), 10);
|
|
216
|
+
}
|
|
217
|
+
|
|
211
218
|
export function parseProjectConfig(content: string): ProjectConfig {
|
|
212
219
|
const { frontMatter, body } = parseFrontMatter(content);
|
|
213
220
|
const sections = extractSections(body);
|
|
@@ -389,6 +396,7 @@ export function parseBoard(content: string, filePath: string): Board {
|
|
|
389
396
|
completedAt: optionalString(fm.completed_at),
|
|
390
397
|
goal: String(fm.goal || sections.get('Goal') || ''),
|
|
391
398
|
executionSummary,
|
|
399
|
+
maxParallelAgents: clampParallelAgents(fm.max_parallel_agents),
|
|
392
400
|
path: filePath,
|
|
393
401
|
};
|
|
394
402
|
}
|
|
@@ -119,6 +119,8 @@ export interface Board {
|
|
|
119
119
|
completedAt: string | null;
|
|
120
120
|
goal: string;
|
|
121
121
|
executionSummary: BoardExecutionSummary | null;
|
|
122
|
+
/** Max parallel headless Claude Code instances per execution wave (default: 3) */
|
|
123
|
+
maxParallelAgents: number;
|
|
122
124
|
path: string;
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -72,6 +72,7 @@ export class PlatformConnection {
|
|
|
72
72
|
private tokenRefreshInterval: ReturnType<typeof setInterval> | null = null
|
|
73
73
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
|
74
74
|
private missedPongs = 0
|
|
75
|
+
private everConnected = false
|
|
75
76
|
private readonly startedAt: string
|
|
76
77
|
|
|
77
78
|
constructor(
|
|
@@ -228,13 +229,6 @@ export class PlatformConnection {
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
let everConnected = false
|
|
232
|
-
const originalOnConnected = this.callbacks.onConnected
|
|
233
|
-
this.callbacks.onConnected = (connectionId) => {
|
|
234
|
-
everConnected = true
|
|
235
|
-
originalOnConnected?.(connectionId)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
232
|
this.ws.onclose = (event) => {
|
|
239
233
|
this.stopHeartbeat()
|
|
240
234
|
this.isConnected = false
|
|
@@ -242,7 +236,7 @@ export class PlatformConnection {
|
|
|
242
236
|
if (!this.isIntentionallyClosed) {
|
|
243
237
|
const isAuthFailure = event.code === 4001 ||
|
|
244
238
|
event.reason?.includes('Unauthorized') ||
|
|
245
|
-
(event.code === 1006 && !everConnected)
|
|
239
|
+
(event.code === 1006 && !this.everConnected)
|
|
246
240
|
|
|
247
241
|
if (isAuthFailure) {
|
|
248
242
|
console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
|
|
@@ -267,6 +261,7 @@ export class PlatformConnection {
|
|
|
267
261
|
switch (message.type) {
|
|
268
262
|
case 'paired':
|
|
269
263
|
this.isConnected = true
|
|
264
|
+
this.everConnected = true
|
|
270
265
|
this.connectionId = message.connectionId as string
|
|
271
266
|
this.startHeartbeat()
|
|
272
267
|
this.callbacks.onConnected?.(message.connectionId as string)
|
|
@@ -93,9 +93,13 @@ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, ms
|
|
|
93
93
|
handleDeleteFile(ctx, ws, msg, tabId, workingDir);
|
|
94
94
|
},
|
|
95
95
|
renameFile: () => {
|
|
96
|
-
if (isSandboxed
|
|
97
|
-
const
|
|
98
|
-
|
|
96
|
+
if (isSandboxed) {
|
|
97
|
+
const oldValidation = msg.data?.oldPath ? validatePathWithinWorkingDir(msg.data.oldPath, workingDir) : { valid: false };
|
|
98
|
+
const newValidation = msg.data?.newPath ? validatePathWithinWorkingDir(msg.data.newPath, workingDir) : { valid: false };
|
|
99
|
+
if (!oldValidation.valid || !newValidation.valid) {
|
|
100
|
+
ctx.send(ws, { type: 'fileError', tabId, data: { operation: 'renameFile', path: msg.data?.oldPath || '', error: 'Sandboxed: path outside project directory' } });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
99
103
|
}
|
|
100
104
|
handleRenameFile(ctx, ws, msg, tabId, workingDir);
|
|
101
105
|
},
|
|
@@ -31,7 +31,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
31
31
|
const gitDir = ctx.gitDirectories.get(tabId) || workingDir;
|
|
32
32
|
|
|
33
33
|
if (GIT_PR_TYPES.has(msg.type)) {
|
|
34
|
-
handleGitPRMessage(ctx, ws, msg, tabId, gitDir, workingDir);
|
|
34
|
+
await handleGitPRMessage(ctx, ws, msg, tabId, gitDir, workingDir);
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
if (GIT_WORKTREE_TYPES.has(msg.type)) {
|
|
@@ -39,7 +39,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const handlers: Record<string, () => void
|
|
42
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
43
43
|
gitStatus: () => handleGitStatus(ctx, ws, tabId, gitDir),
|
|
44
44
|
gitStage: () => handleGitStage(ctx, ws, msg, tabId, gitDir),
|
|
45
45
|
gitUnstage: () => handleGitUnstage(ctx, ws, msg, tabId, gitDir),
|
|
@@ -61,7 +61,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
61
61
|
gitCreateTag: () => handleGitCreateTag(ctx, ws, msg, tabId, gitDir),
|
|
62
62
|
gitPushTag: () => handleGitPushTag(ctx, ws, msg, tabId, gitDir),
|
|
63
63
|
};
|
|
64
|
-
handlers[msg.type]?.();
|
|
64
|
+
await handlers[msg.type]?.();
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export async function handleGitStatus(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
|
|
@@ -6,13 +6,13 @@ import { detectGitProvider, executeGitCommand, spawnCheck, spawnHaikuWithPrompt,
|
|
|
6
6
|
import type { HandlerContext } from './handler-context.js';
|
|
7
7
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
8
8
|
|
|
9
|
-
export function handleGitPRMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, _workingDir: string): void {
|
|
10
|
-
const handlers: Record<string, () => void
|
|
9
|
+
export async function handleGitPRMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, _workingDir: string): Promise<void> {
|
|
10
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
11
11
|
gitGetRemoteInfo: () => handleGitGetRemoteInfo(ctx, ws, tabId, gitDir),
|
|
12
12
|
gitCreatePR: () => handleGitCreatePR(ctx, ws, msg, tabId, gitDir),
|
|
13
13
|
gitGeneratePRDescription: () => handleGitGeneratePRDescription(ctx, ws, msg, tabId, gitDir),
|
|
14
14
|
};
|
|
15
|
-
handlers[msg.type]?.();
|
|
15
|
+
await handlers[msg.type]?.();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function handleGitGetRemoteInfo(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
|
|
@@ -215,6 +215,18 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
handleClose(ws: WSContext): void {
|
|
218
|
+
// Destroy sessions owned by this connection to free interval timers
|
|
219
|
+
const tabMap = this.connections.get(ws);
|
|
220
|
+
if (tabMap) {
|
|
221
|
+
const sessionIds = new Set(tabMap.values());
|
|
222
|
+
for (const sessionId of sessionIds) {
|
|
223
|
+
const session = this.sessions.get(sessionId);
|
|
224
|
+
if (session) {
|
|
225
|
+
session.destroy();
|
|
226
|
+
this.sessions.delete(sessionId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
218
230
|
this.connections.delete(ws);
|
|
219
231
|
this.allConnections.delete(ws);
|
|
220
232
|
cleanupTerminalSubscribers(this, ws);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import { extname, join, relative } from 'node:path';
|
|
4
|
+
import { extname, relative } from 'node:path';
|
|
6
5
|
import { runCommand, type SourceFile } from './quality-tools.js';
|
|
7
6
|
import { biomeDiagToFinding, type Ecosystem, FUNCTION_LENGTH_THRESHOLD, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
|
|
8
7
|
|
|
@@ -162,9 +161,6 @@ function computeComplexityScore(findings: QualityFinding[]): number {
|
|
|
162
161
|
}
|
|
163
162
|
|
|
164
163
|
async function complexityFromBiome(dirPath: string): Promise<QualityFinding[] | null> {
|
|
165
|
-
const hasBiomeConfig = existsSync(join(dirPath, 'biome.json')) || existsSync(join(dirPath, 'biome.jsonc'));
|
|
166
|
-
if (!hasBiomeConfig) return null;
|
|
167
|
-
|
|
168
164
|
const result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', '.'], dirPath);
|
|
169
165
|
if (result.exitCode > 1) return null;
|
|
170
166
|
|
|
@@ -245,8 +241,10 @@ async function analyzeNodeComplexity(
|
|
|
245
241
|
const hasCapableTool = !installed || installed.has('biome') || installed.has('eslint');
|
|
246
242
|
if (!hasCapableTool) return null;
|
|
247
243
|
|
|
248
|
-
|
|
249
|
-
|
|
244
|
+
// Use installed tools list instead of config file presence.
|
|
245
|
+
// This fixes monorepo scenarios where biome.json is in a subdirectory.
|
|
246
|
+
const hasBiome = !installed || installed.has('biome');
|
|
247
|
+
if (hasBiome) {
|
|
250
248
|
const findings = await complexityFromBiome(dirPath);
|
|
251
249
|
if (findings) return findings;
|
|
252
250
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import { join, relative } from 'node:path';
|
|
4
|
+
import { relative } from 'node:path';
|
|
6
5
|
import { runCommand, type SourceFile } from './quality-tools.js';
|
|
7
6
|
import { biomeDiagToFinding, type Ecosystem, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
|
|
8
7
|
|
|
@@ -57,7 +56,7 @@ function processEslintMessage(
|
|
|
57
56
|
else acc.warnings++;
|
|
58
57
|
acc.findings.push({
|
|
59
58
|
severity: msg.severity === 2 ? 'high' : 'medium',
|
|
60
|
-
category: '
|
|
59
|
+
category: 'lint',
|
|
61
60
|
file: relative(dirPath, filePath),
|
|
62
61
|
line: (msg.line as number) ?? null,
|
|
63
62
|
title: (msg.ruleId as string) || 'Lint issue',
|
|
@@ -81,11 +80,17 @@ async function lintWithEslint(dirPath: string, acc: LintAccumulator): Promise<vo
|
|
|
81
80
|
}
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
async function lintNode(dirPath: string, acc: LintAccumulator): Promise<void> {
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
async function lintNode(dirPath: string, acc: LintAccumulator, installed: Set<string> | null): Promise<void> {
|
|
84
|
+
// Use installed tools list to decide which linter to run, not config file presence.
|
|
85
|
+
// This fixes monorepo scenarios where the config is in a subdirectory.
|
|
86
|
+
const hasBiome = !installed || installed.has('biome');
|
|
87
|
+
const hasEslint = !installed || installed.has('eslint');
|
|
88
|
+
|
|
89
|
+
if (hasBiome) {
|
|
87
90
|
await lintWithBiome(dirPath, acc);
|
|
88
|
-
|
|
91
|
+
if (acc.ran) return;
|
|
92
|
+
}
|
|
93
|
+
if (hasEslint) {
|
|
89
94
|
await lintWithEslint(dirPath, acc);
|
|
90
95
|
}
|
|
91
96
|
}
|
|
@@ -103,7 +108,7 @@ async function lintPython(dirPath: string, acc: LintAccumulator): Promise<void>
|
|
|
103
108
|
else acc.warnings++;
|
|
104
109
|
acc.findings.push({
|
|
105
110
|
severity: sev,
|
|
106
|
-
category: '
|
|
111
|
+
category: 'lint',
|
|
107
112
|
file: item.filename ? relative(dirPath, item.filename) : '',
|
|
108
113
|
line: item.location?.row ?? null,
|
|
109
114
|
title: item.code || 'Lint issue',
|
|
@@ -124,7 +129,7 @@ function processClippyMessage(msg: Record<string, unknown>, acc: LintAccumulator
|
|
|
124
129
|
const code = message.code as Record<string, unknown> | undefined;
|
|
125
130
|
acc.findings.push({
|
|
126
131
|
severity: level === 'error' ? 'high' : 'medium',
|
|
127
|
-
category: '
|
|
132
|
+
category: 'lint',
|
|
128
133
|
file: (span?.file_name as string) || '',
|
|
129
134
|
line: (span?.line_start as number) ?? null,
|
|
130
135
|
title: (code?.code as string) || 'Clippy',
|
|
@@ -164,10 +169,12 @@ export async function analyzeLinting(
|
|
|
164
169
|
dirPath: string,
|
|
165
170
|
ecosystems: Ecosystem[],
|
|
166
171
|
files: SourceFile[],
|
|
172
|
+
installedToolNames?: string[],
|
|
167
173
|
): Promise<{ score: number; findings: QualityFinding[]; available: boolean; issueCount: number }> {
|
|
168
174
|
const acc = newLintAccumulator();
|
|
175
|
+
const installed = installedToolNames ? new Set(installedToolNames) : null;
|
|
169
176
|
|
|
170
|
-
if (ecosystems.includes('node')) await lintNode(dirPath, acc);
|
|
177
|
+
if (ecosystems.includes('node')) await lintNode(dirPath, acc, installed);
|
|
171
178
|
if (ecosystems.includes('python')) await lintPython(dirPath, acc);
|
|
172
179
|
if (ecosystems.includes('rust')) await lintRust(dirPath, acc);
|
|
173
180
|
|
|
@@ -29,28 +29,62 @@ export interface CodeReviewFinding {
|
|
|
29
29
|
|
|
30
30
|
// ── Prompt ────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
-
export function buildCodeReviewPrompt(dirPath: string): string {
|
|
33
|
-
|
|
32
|
+
export function buildCodeReviewPrompt(dirPath: string, cliFindings?: Array<{ severity: string; category: string; file: string; line: number | null; title: string; description: string }>): string {
|
|
33
|
+
const cliFindingsSection = cliFindings && cliFindings.length > 0
|
|
34
|
+
? `\n## CLI Tool Findings (already detected)\n\nThe following issues were found by automated CLI tools (linters, formatters, complexity analyzers). Review these for context — they are already included in the final report. Focus your analysis on DEEPER issues these tools cannot detect.\n\n${cliFindings.slice(0, 50).map((f, i) => `${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.file}${f.line ? `:${f.line}` : ''} — ${f.title}: ${f.description}`).join('\n')}\n${cliFindings.length > 50 ? `\n...and ${cliFindings.length - 50} more issues from CLI tools.\n` : ''}`
|
|
35
|
+
: '';
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
return `You are a senior staff engineer performing a rigorous, honest code review. Your job is to surface the most impactful quality bottlenecks — the issues a principal engineer would flag in a code review. Be critical and objective. Do NOT inflate scores.
|
|
36
38
|
|
|
39
|
+
IMPORTANT: Your current working directory is "${dirPath}". Only review files within this directory.
|
|
40
|
+
${cliFindingsSection}
|
|
37
41
|
## Review Process
|
|
38
42
|
|
|
39
|
-
1. **Discover**: Use Glob to find source files (e.g. "**/*.{ts,tsx,js,py,rs,go,java,rb,php}"). Understand the project structure.
|
|
43
|
+
1. **Discover**: Use Glob to find source files (e.g. "**/*.{ts,tsx,js,py,rs,go,java,rb,php}"). Understand the project structure.
|
|
40
44
|
2. **Read**: Read the most important files — entry points, core modules, handlers, services. Prioritize files with recent git changes (\`git diff --name-only HEAD~5\` via Bash if available).
|
|
41
|
-
3. **Analyze**: Look for real, actionable issues across these categories:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
45
|
+
3. **Analyze**: Look for real, actionable issues across ALL of these categories:
|
|
46
|
+
|
|
47
|
+
### Architecture
|
|
48
|
+
- What is the current architecture (monolith, microservices, layered, etc.)?
|
|
49
|
+
- Are there architectural violations? (e.g., presentation layer directly accessing data layer, circular dependencies between modules)
|
|
50
|
+
- Is there proper separation of concerns?
|
|
51
|
+
- Are there god objects or god modules that do too much?
|
|
52
|
+
|
|
53
|
+
### SOLID / OOP Principles
|
|
54
|
+
- **SRP**: Classes/modules with multiple unrelated responsibilities
|
|
55
|
+
- **OCP**: Code that requires modification instead of extension for new features
|
|
56
|
+
- **LSP**: Subtypes that don't properly substitute for their base types
|
|
57
|
+
- **ISP**: Interfaces/contracts that force implementations to depend on methods they don't use
|
|
58
|
+
- **DIP**: High-level modules directly depending on low-level modules instead of abstractions
|
|
59
|
+
|
|
60
|
+
### Security
|
|
61
|
+
- Injection vulnerabilities (SQL, XSS, command), hardcoded secrets/credentials, auth bypasses, insecure crypto, path traversal, SSRF, unsafe deserialization
|
|
62
|
+
|
|
63
|
+
### Bugs & Logic
|
|
64
|
+
- Null/undefined errors, race conditions, logic errors, unhandled edge cases, off-by-one errors, resource leaks, incorrect error handling, incorrect algorithms
|
|
65
|
+
|
|
66
|
+
### Performance
|
|
67
|
+
- N+1 queries, unnecessary re-renders, missing memoization, blocking I/O in hot paths, unbounded data structures, missing pagination
|
|
46
68
|
|
|
47
69
|
## Rules
|
|
48
70
|
|
|
49
71
|
- Only report findings you are >80% confident about. No speculative or low-confidence issues.
|
|
50
|
-
- Focus on bugs and security over style
|
|
72
|
+
- Focus on architecture, SOLID violations, bugs, and security over style nits.
|
|
51
73
|
- Each finding MUST reference a specific file and line number. Do not report vague or file-level issues.
|
|
52
|
-
- Limit to the
|
|
74
|
+
- Limit to the 25 most important findings, ranked by severity.
|
|
53
75
|
- Do NOT modify any files. This is a read-only review.
|
|
76
|
+
- Be HONEST about the overall quality. A codebase with serious issues should score low.
|
|
77
|
+
|
|
78
|
+
## Scoring Guidelines
|
|
79
|
+
|
|
80
|
+
After your analysis, provide an honest overall quality score (0-100) and letter grade:
|
|
81
|
+
- **A (90-100)**: Excellent — clean architecture, minimal issues, well-tested, follows best practices
|
|
82
|
+
- **B (80-89)**: Good — solid code with minor issues, mostly well-structured
|
|
83
|
+
- **C (70-79)**: Adequate — functional but has notable quality issues that should be addressed
|
|
84
|
+
- **D (60-69)**: Below average — significant issues in architecture, testing, or code quality
|
|
85
|
+
- **F (0-59)**: Poor — serious problems: security vulnerabilities, broken architecture, major bugs, or unmaintainable code
|
|
86
|
+
|
|
87
|
+
Consider ALL findings (both CLI tool findings and your own) when determining the score. The score should reflect the overall state of the codebase honestly. A project with 50+ linting errors, formatting issues, complex functions, AND architectural problems should NOT score above 70.
|
|
54
88
|
|
|
55
89
|
## Output
|
|
56
90
|
|
|
@@ -58,10 +92,13 @@ After your analysis, output EXACTLY one JSON code block with your findings. No o
|
|
|
58
92
|
|
|
59
93
|
\`\`\`json
|
|
60
94
|
{
|
|
95
|
+
"score": 72,
|
|
96
|
+
"grade": "C",
|
|
97
|
+
"scoreRationale": "Brief explanation of why this score was given, referencing key issues",
|
|
61
98
|
"findings": [
|
|
62
99
|
{
|
|
63
100
|
"severity": "critical|high|medium|low",
|
|
64
|
-
"category": "security|bugs|performance|
|
|
101
|
+
"category": "architecture|oop|security|bugs|performance|logic",
|
|
65
102
|
"file": "relative/path/to/file.ts",
|
|
66
103
|
"line": 42,
|
|
67
104
|
"title": "Short title describing the issue",
|
|
@@ -77,7 +114,7 @@ After your analysis, output EXACTLY one JSON code block with your findings. No o
|
|
|
77
114
|
// ── Response parsing ──────────────────────────────────────────
|
|
78
115
|
|
|
79
116
|
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
80
|
-
const VALID_CATEGORIES = new Set(['security', 'bugs', 'performance', 'maintainability']);
|
|
117
|
+
const VALID_CATEGORIES = new Set(['architecture', 'oop', 'security', 'bugs', 'performance', 'logic', 'maintainability']);
|
|
81
118
|
|
|
82
119
|
function normalizeFinding(f: Record<string, unknown>): CodeReviewFinding | null {
|
|
83
120
|
if (typeof f.file !== 'string' || typeof f.title !== 'string') return null;
|
|
@@ -105,7 +142,15 @@ function extractJson(response: string): string {
|
|
|
105
142
|
return response.trim();
|
|
106
143
|
}
|
|
107
144
|
|
|
108
|
-
export
|
|
145
|
+
export interface CodeReviewResult {
|
|
146
|
+
findings: CodeReviewFinding[];
|
|
147
|
+
summary: string;
|
|
148
|
+
score: number | null;
|
|
149
|
+
grade: string | null;
|
|
150
|
+
scoreRationale: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseCodeReviewResponse(response: string): CodeReviewResult {
|
|
109
154
|
const jsonStr = extractJson(response);
|
|
110
155
|
|
|
111
156
|
try {
|
|
@@ -113,9 +158,12 @@ export function parseCodeReviewResponse(response: string): { findings: CodeRevie
|
|
|
113
158
|
const rawFindings: Record<string, unknown>[] = Array.isArray(parsed.findings) ? parsed.findings : [];
|
|
114
159
|
const findings = rawFindings.map(normalizeFinding).filter((f): f is CodeReviewFinding => f !== null);
|
|
115
160
|
const summary = typeof parsed.summary === 'string' ? parsed.summary : `Found ${findings.length} issue(s).`;
|
|
116
|
-
|
|
161
|
+
const score = typeof parsed.score === 'number' ? Math.max(0, Math.min(100, Math.round(parsed.score))) : null;
|
|
162
|
+
const grade = typeof parsed.grade === 'string' ? parsed.grade : null;
|
|
163
|
+
const scoreRationale = typeof parsed.scoreRationale === 'string' ? parsed.scoreRationale : null;
|
|
164
|
+
return { findings, summary, score, grade, scoreRationale };
|
|
117
165
|
} catch {
|
|
118
|
-
return { findings: [], summary: 'Failed to parse code review results.' };
|
|
166
|
+
return { findings: [], summary: 'Failed to parse code review results.', score: null, grade: null, scoreRationale: null };
|
|
119
167
|
}
|
|
120
168
|
}
|
|
121
169
|
|
|
@@ -180,9 +228,21 @@ export async function handleCodeReview(
|
|
|
180
228
|
data: { path: reportPath, message: 'Starting AI code review...' },
|
|
181
229
|
});
|
|
182
230
|
|
|
231
|
+
// Load CLI findings from the existing report to pass to the AI reviewer
|
|
232
|
+
let cliFindings: Array<{ severity: string; category: string; file: string; line: number | null; title: string; description: string }> | undefined;
|
|
233
|
+
try {
|
|
234
|
+
const persistence = getPersistence(workingDir);
|
|
235
|
+
const existingReport = persistence.loadReport(reportPath);
|
|
236
|
+
if (existingReport?.findings) {
|
|
237
|
+
cliFindings = existingReport.findings;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Continue without CLI findings if persistence fails
|
|
241
|
+
}
|
|
242
|
+
|
|
183
243
|
const runner = new HeadlessRunner({
|
|
184
244
|
workingDir: dirPath,
|
|
185
|
-
directPrompt: buildCodeReviewPrompt(dirPath),
|
|
245
|
+
directPrompt: buildCodeReviewPrompt(dirPath, cliFindings),
|
|
186
246
|
stallWarningMs: 120_000,
|
|
187
247
|
stallKillMs: 600_000,
|
|
188
248
|
stallHardCapMs: 900_000,
|
|
@@ -213,27 +273,39 @@ export async function handleCodeReview(
|
|
|
213
273
|
});
|
|
214
274
|
|
|
215
275
|
const responseText = result.assistantResponse || '';
|
|
216
|
-
const
|
|
276
|
+
const reviewResult = parseCodeReviewResponse(responseText);
|
|
217
277
|
|
|
218
|
-
//
|
|
278
|
+
// Use AI-determined score if available, otherwise fall back to recomputation
|
|
219
279
|
let updatedResults: import('./quality-service.js').QualityResults | null = null;
|
|
220
280
|
try {
|
|
221
281
|
const persistence = getPersistence(workingDir);
|
|
222
282
|
const existingReport = persistence.loadReport(reportPath);
|
|
223
283
|
if (existingReport) {
|
|
224
|
-
|
|
225
|
-
|
|
284
|
+
if (reviewResult.score !== null && reviewResult.grade !== null) {
|
|
285
|
+
// Use the AI-determined score and grade directly
|
|
286
|
+
updatedResults = {
|
|
287
|
+
...existingReport,
|
|
288
|
+
overall: reviewResult.score,
|
|
289
|
+
grade: reviewResult.grade,
|
|
290
|
+
codeReview: reviewResult.findings as unknown as typeof existingReport.codeReview,
|
|
291
|
+
scoreRationale: reviewResult.scoreRationale ?? undefined,
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
// Fallback: recompute with weighted formula
|
|
295
|
+
updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
|
|
296
|
+
updatedResults = { ...updatedResults, codeReview: reviewResult.findings as unknown as typeof updatedResults.codeReview };
|
|
297
|
+
}
|
|
226
298
|
persistence.saveReport(reportPath, updatedResults);
|
|
227
299
|
persistence.appendHistory(updatedResults, reportPath);
|
|
228
300
|
}
|
|
229
|
-
persistence.saveCodeReview(reportPath, findings as unknown as Record<string, unknown>[], summary);
|
|
301
|
+
persistence.saveCodeReview(reportPath, reviewResult.findings as unknown as Record<string, unknown>[], reviewResult.summary);
|
|
230
302
|
} catch {
|
|
231
303
|
// Persistence failure should not break the review flow
|
|
232
304
|
}
|
|
233
305
|
|
|
234
306
|
ctx.send(ws, {
|
|
235
307
|
type: 'qualityCodeReview',
|
|
236
|
-
data: { path: reportPath, findings, summary, results: updatedResults },
|
|
308
|
+
data: { path: reportPath, findings: reviewResult.findings, summary: reviewResult.summary, results: updatedResults },
|
|
237
309
|
});
|
|
238
310
|
} catch (error) {
|
|
239
311
|
ctx.send(ws, {
|
|
@@ -15,51 +15,81 @@ export type { CategoryScore, QualityFinding, QualityResults, QualityTool, ScanPr
|
|
|
15
15
|
// Formatting Analysis
|
|
16
16
|
// ============================================================================
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
interface FmtAccumulator {
|
|
19
|
+
totalFiles: number;
|
|
20
|
+
passingFiles: number;
|
|
21
|
+
ran: boolean;
|
|
22
|
+
findings: QualityFinding[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function newFmtAccumulator(): FmtAccumulator {
|
|
26
|
+
return { totalFiles: 0, passingFiles: 0, ran: false, findings: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fmtNode(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
|
|
30
|
+
const result = await runCommand('npx', ['prettier', '--check', '.'], dirPath);
|
|
31
|
+
acc.ran = true;
|
|
32
|
+
const unformatted = result.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('Checking'));
|
|
33
|
+
const nodeFiles = files.filter((f) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(f.path)));
|
|
34
|
+
acc.totalFiles += nodeFiles.length;
|
|
35
|
+
acc.passingFiles += Math.max(0, nodeFiles.length - unformatted.length);
|
|
36
|
+
for (const filePath of unformatted) {
|
|
37
|
+
if (!filePath.trim()) continue;
|
|
38
|
+
const rel = filePath.startsWith('/') ? filePath.replace(`${dirPath}/`, '') : filePath;
|
|
39
|
+
acc.findings.push({ severity: 'low', category: 'format', file: rel, line: null, title: 'File not formatted', description: 'Does not match Prettier formatting rules.' });
|
|
34
40
|
}
|
|
41
|
+
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const wouldReformat = (result.stderr.match(/would reformat/gi) || []).length;
|
|
45
|
-
passingFiles += Math.max(0, pyFiles.length - wouldReformat);
|
|
46
|
-
}
|
|
43
|
+
async function fmtPython(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
|
|
44
|
+
const result = await runCommand('black', ['--check', '--quiet', '.'], dirPath);
|
|
45
|
+
acc.ran = true;
|
|
46
|
+
const pyFiles = files.filter((f) => ['.py', '.pyi'].includes(extname(f.path)));
|
|
47
|
+
acc.totalFiles += pyFiles.length;
|
|
48
|
+
if (result.exitCode === 0) {
|
|
49
|
+
acc.passingFiles += pyFiles.length;
|
|
50
|
+
return;
|
|
47
51
|
}
|
|
52
|
+
const reformatLines = result.stderr.split('\n').filter((l) => l.includes('would reformat'));
|
|
53
|
+
acc.passingFiles += Math.max(0, pyFiles.length - reformatLines.length);
|
|
54
|
+
for (const line of reformatLines) {
|
|
55
|
+
const match = line.match(/would reformat (.+)/);
|
|
56
|
+
if (match) acc.findings.push({ severity: 'low', category: 'format', file: match[1].trim(), line: null, title: 'File not formatted', description: 'Does not match Black formatting rules.' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
async function fmtRust(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
|
|
61
|
+
const result = await runCommand('cargo', ['fmt', '--check'], dirPath);
|
|
62
|
+
acc.ran = true;
|
|
63
|
+
const rsFiles = files.filter((f) => extname(f.path) === '.rs');
|
|
64
|
+
acc.totalFiles += rsFiles.length;
|
|
65
|
+
if (result.exitCode === 0) {
|
|
66
|
+
acc.passingFiles += rsFiles.length;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const diffLines = result.stdout.split('\n').filter((l) => l.startsWith('Diff in'));
|
|
70
|
+
for (const line of diffLines) {
|
|
71
|
+
const match = line.match(/Diff in (.+?) at/);
|
|
72
|
+
if (match) acc.findings.push({ severity: 'low', category: 'format', file: match[1].trim(), line: null, title: 'File not formatted', description: 'Does not match rustfmt formatting rules.' });
|
|
55
73
|
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function analyzeFormatting(
|
|
77
|
+
dirPath: string,
|
|
78
|
+
ecosystems: Ecosystem[],
|
|
79
|
+
files: SourceFile[],
|
|
80
|
+
): Promise<{ score: number; available: boolean; issueCount: number; findings: QualityFinding[] }> {
|
|
81
|
+
const acc = newFmtAccumulator();
|
|
82
|
+
|
|
83
|
+
if (ecosystems.includes('node')) await fmtNode(dirPath, files, acc);
|
|
84
|
+
if (ecosystems.includes('python')) await fmtPython(dirPath, files, acc);
|
|
85
|
+
if (ecosystems.includes('rust')) await fmtRust(dirPath, files, acc);
|
|
56
86
|
|
|
57
|
-
if (!ran || totalFiles === 0) {
|
|
58
|
-
return { score: 0, available: false, issueCount: 0 };
|
|
87
|
+
if (!acc.ran || acc.totalFiles === 0) {
|
|
88
|
+
return { score: 0, available: false, issueCount: 0, findings: [] };
|
|
59
89
|
}
|
|
60
90
|
|
|
61
|
-
const score = Math.round((passingFiles / totalFiles) * 100);
|
|
62
|
-
return { score, available: true, issueCount: totalFiles - passingFiles };
|
|
91
|
+
const score = Math.round((acc.passingFiles / acc.totalFiles) * 100);
|
|
92
|
+
return { score, available: true, issueCount: acc.totalFiles - acc.passingFiles, findings: acc.findings.slice(0, 50) };
|
|
63
93
|
}
|
|
64
94
|
|
|
65
95
|
// ============================================================================
|
|
@@ -195,7 +225,7 @@ export async function runQualityScan(
|
|
|
195
225
|
progress('Running linters', 2);
|
|
196
226
|
const hasLinter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'linter');
|
|
197
227
|
const lintResult = hasLinter
|
|
198
|
-
? await analyzeLinting(dirPath, ecosystems, files)
|
|
228
|
+
? await analyzeLinting(dirPath, ecosystems, files, installedToolNames)
|
|
199
229
|
: { score: 0, findings: [], available: false, issueCount: 0 };
|
|
200
230
|
|
|
201
231
|
// Step 3: Check formatting (only if a formatter is installed)
|
|
@@ -203,7 +233,7 @@ export async function runQualityScan(
|
|
|
203
233
|
const hasFormatter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'formatter');
|
|
204
234
|
const fmtResult = hasFormatter
|
|
205
235
|
? await analyzeFormatting(dirPath, ecosystems, files)
|
|
206
|
-
: { score: 0, available: false, issueCount: 0 };
|
|
236
|
+
: { score: 0, available: false, issueCount: 0, findings: [] as QualityFinding[] };
|
|
207
237
|
|
|
208
238
|
// Step 4: Analyze complexity (using real tools: Biome, ESLint, radon)
|
|
209
239
|
progress('Analyzing complexity', 4);
|
|
@@ -274,6 +304,7 @@ export async function runQualityScan(
|
|
|
274
304
|
const overall = computeOverallScore(categories);
|
|
275
305
|
const allFindings = [
|
|
276
306
|
...lintResult.findings,
|
|
307
|
+
...fmtResult.findings,
|
|
277
308
|
...complexityResult.findings,
|
|
278
309
|
...fileLengthResult.findings,
|
|
279
310
|
...funcLengthResult.findings,
|
|
@@ -201,7 +201,10 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
201
201
|
}
|
|
202
202
|
case 'new': {
|
|
203
203
|
const oldSession = requireSession(ctx, ws, tabId);
|
|
204
|
+
const oldSessionId = oldSession.getSessionInfo().sessionId;
|
|
204
205
|
const newSession = oldSession.startNewSession({ model: getModel() });
|
|
206
|
+
oldSession.destroy();
|
|
207
|
+
ctx.sessions.delete(oldSessionId);
|
|
205
208
|
setupSessionListeners(ctx, newSession, ws, tabId);
|
|
206
209
|
const newSessionId = newSession.getSessionInfo().sessionId;
|
|
207
210
|
ctx.sessions.set(newSessionId, newSession);
|