mstro-app 0.4.44 → 0.4.46
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 +15 -7
- package/dist/server/cli/headless/resilient-runner.d.ts +2 -1
- package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -1
- package/dist/server/cli/headless/resilient-runner.js +2 -0
- package/dist/server/cli/headless/resilient-runner.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +2 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +21 -2
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/websocket/handler.js +1 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js +8 -2
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +88 -59
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +4 -21
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-linting.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-linting.js +71 -50
- package/dist/server/services/websocket/quality-linting.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +0 -4
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +40 -34
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +13 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +32 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +18 -2
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +2 -2
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +21 -5
- package/server/cli/headless/resilient-runner.ts +5 -1
- package/server/services/plan/composer.ts +42 -1
- package/server/services/websocket/handler.ts +1 -1
- package/server/services/websocket/plan-board-handlers.ts +11 -2
- package/server/services/websocket/quality-complexity.ts +87 -51
- package/server/services/websocket/quality-handlers.ts +4 -18
- package/server/services/websocket/quality-linting.ts +74 -47
- package/server/services/websocket/quality-persistence.ts +1 -1
- package/server/services/websocket/quality-service.ts +44 -39
- package/server/services/websocket/quality-tools.ts +33 -0
- package/server/services/websocket/session-handlers.ts +19 -2
- package/server/services/websocket/types.ts +2 -2
- package/dist/server/services/websocket/quality-fix-agent.d.ts +0 -16
- package/dist/server/services/websocket/quality-fix-agent.d.ts.map +0 -1
- package/dist/server/services/websocket/quality-fix-agent.js +0 -181
- package/dist/server/services/websocket/quality-fix-agent.js.map +0 -1
- package/server/services/websocket/quality-fix-agent.ts +0 -216
|
@@ -13,6 +13,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import type { ToolUseEvent } from '../../cli/headless/index.js';
|
|
15
15
|
import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
|
|
16
|
+
import { cleanupAttachments, preparePromptAndAttachments } from '../../cli/improvisation-attachments.js';
|
|
17
|
+
import type { FileAttachment } from '../../cli/improvisation-types.js';
|
|
16
18
|
import type { HandlerContext } from '../websocket/handler-context.js';
|
|
17
19
|
import type { WSContext } from '../websocket/types.js';
|
|
18
20
|
import { defaultPmDir, getNextId, parseBoardDirectory, parsePlanDirectory, resolvePmDir } from './parser.js';
|
|
@@ -65,6 +67,35 @@ function readFileOrEmpty(path: string): string {
|
|
|
65
67
|
return '';
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
interface PreparedAttachmentPrompt {
|
|
71
|
+
prompt: string;
|
|
72
|
+
imageAttachments: FileAttachment[] | undefined;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function prepareAttachmentPrompt(
|
|
77
|
+
ctx: HandlerContext,
|
|
78
|
+
enrichedPrompt: string,
|
|
79
|
+
attachments: FileAttachment[] | undefined,
|
|
80
|
+
workingDir: string,
|
|
81
|
+
effectiveBoardId: string | null,
|
|
82
|
+
): PreparedAttachmentPrompt {
|
|
83
|
+
const sessionId = `plan-${effectiveBoardId ?? 'default'}`;
|
|
84
|
+
const { prompt, imageAttachments } = preparePromptAndAttachments(
|
|
85
|
+
enrichedPrompt,
|
|
86
|
+
attachments,
|
|
87
|
+
workingDir,
|
|
88
|
+
sessionId,
|
|
89
|
+
(warning) => {
|
|
90
|
+
ctx.broadcastToAll({
|
|
91
|
+
type: 'planPromptProgress',
|
|
92
|
+
data: { message: warning },
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
return { prompt, imageAttachments, sessionId };
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
interface ComposerContext {
|
|
69
100
|
boardContext: string;
|
|
70
101
|
stateContent: string;
|
|
@@ -130,6 +161,7 @@ export async function handlePlanPrompt(
|
|
|
130
161
|
workingDir: string,
|
|
131
162
|
boardId?: string,
|
|
132
163
|
executionDir?: string,
|
|
164
|
+
attachments?: FileAttachment[],
|
|
133
165
|
): Promise<void> {
|
|
134
166
|
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
135
167
|
const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
|
|
@@ -249,6 +281,12 @@ Implementation guidance.
|
|
|
249
281
|
|
|
250
282
|
User request: ${userPrompt}`;
|
|
251
283
|
|
|
284
|
+
// Reuse the improvisation attachment pipeline: text/dir files get inlined into
|
|
285
|
+
// the prompt as @path code blocks; images are extracted as multimodal blocks
|
|
286
|
+
// passed through to Claude Code via ResilientRunner.
|
|
287
|
+
const { prompt: finalPrompt, imageAttachments, sessionId: attachmentSessionId } =
|
|
288
|
+
prepareAttachmentPrompt(ctx, enrichedPrompt, attachments, workingDir, cc.effectiveBoardId);
|
|
289
|
+
|
|
252
290
|
try {
|
|
253
291
|
ctx.broadcastToAll({
|
|
254
292
|
type: 'planPromptProgress',
|
|
@@ -257,12 +295,13 @@ User request: ${userPrompt}`;
|
|
|
257
295
|
|
|
258
296
|
const runner = new ResilientRunner({
|
|
259
297
|
workingDir: executionDir || workingDir,
|
|
260
|
-
prompt:
|
|
298
|
+
prompt: finalPrompt,
|
|
261
299
|
policy: 'STANDARD',
|
|
262
300
|
stallWarningMs: 300_000,
|
|
263
301
|
stallKillMs: 900_000,
|
|
264
302
|
stallHardCapMs: 1_800_000,
|
|
265
303
|
verbose: true,
|
|
304
|
+
imageAttachments,
|
|
266
305
|
outputCallback: (text: string) => {
|
|
267
306
|
ctx.send(ws, {
|
|
268
307
|
type: 'planPromptStreaming',
|
|
@@ -316,5 +355,7 @@ User request: ${userPrompt}`;
|
|
|
316
355
|
type: 'planError',
|
|
317
356
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
318
357
|
});
|
|
358
|
+
} finally {
|
|
359
|
+
cleanupAttachments(workingDir, attachmentSessionId);
|
|
319
360
|
}
|
|
320
361
|
}
|
|
@@ -160,7 +160,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
160
160
|
// Git
|
|
161
161
|
gitStatus: 'git', gitStage: 'git', gitUnstage: 'git', gitCommit: 'git', gitCommitWithAI: 'git', gitPush: 'git', gitPull: 'git', gitLog: 'git', gitDiscoverRepos: 'git', gitSetDirectory: 'git', gitGetRemoteInfo: 'git', gitCreatePR: 'git', gitGeneratePRDescription: 'git', gitListBranches: 'git', gitCheckout: 'git', gitCreateBranch: 'git', gitDeleteBranch: 'git', gitDiff: 'git', gitShowCommit: 'git', gitCommitDiff: 'git', gitListTags: 'git', gitCreateTag: 'git', gitPushTag: 'git', gitWorktreeList: 'git', gitWorktreeCreate: 'git', gitWorktreeCreateAndAssign: 'git', gitWorktreeRemove: 'git', tabWorktreeSwitch: 'git', gitWorktreePush: 'git', gitWorktreeCreatePR: 'git', gitMergePreview: 'git', gitWorktreeMerge: 'git', gitMergeAbort: 'git', gitMergeComplete: 'git',
|
|
162
162
|
// Quality
|
|
163
|
-
qualityDetectTools: 'quality', qualityScan: 'quality', qualityInstallTools: 'quality', qualityCodeReview: 'quality',
|
|
163
|
+
qualityDetectTools: 'quality', qualityScan: 'quality', qualityInstallTools: 'quality', qualityCodeReview: 'quality', qualityLoadState: 'quality', qualitySaveDirectories: 'quality',
|
|
164
164
|
// Plan + boards + sprints
|
|
165
165
|
planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan',
|
|
166
166
|
// File upload
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import type { FileAttachment } from '../../cli/improvisation-types.js';
|
|
6
7
|
import { handlePlanPrompt } from '../plan/composer.js';
|
|
7
8
|
import { replaceFrontMatterField } from '../plan/front-matter.js';
|
|
8
9
|
import { getNextBoardId, getNextBoardNumber, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, resolvePmDir } from '../plan/parser.js';
|
|
9
10
|
import type { Workspace } from '../plan/types.js';
|
|
10
11
|
import type { HandlerContext } from './handler-context.js';
|
|
11
12
|
import { denyIfViewOnly, formatYamlValue } from './plan-helpers.js';
|
|
13
|
+
import { mergePreUploadedAttachments } from './session-handlers.js';
|
|
12
14
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
13
15
|
|
|
14
16
|
// ============================================================================
|
|
@@ -320,10 +322,11 @@ export function handleChatToBoard(
|
|
|
320
322
|
): void {
|
|
321
323
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
322
324
|
|
|
323
|
-
const { conversation, autoImplement, focusHint } = (msg.data || {}) as {
|
|
325
|
+
const { conversation, autoImplement, focusHint, attachments: inlineAttachments } = (msg.data || {}) as {
|
|
324
326
|
conversation?: string;
|
|
325
327
|
autoImplement?: boolean;
|
|
326
328
|
focusHint?: string;
|
|
329
|
+
attachments?: FileAttachment[];
|
|
327
330
|
};
|
|
328
331
|
|
|
329
332
|
if (!conversation) {
|
|
@@ -331,6 +334,12 @@ export function handleChatToBoard(
|
|
|
331
334
|
return;
|
|
332
335
|
}
|
|
333
336
|
|
|
337
|
+
// Merge any chunked pre-uploads (routed by tabId) with inline browser attachments
|
|
338
|
+
// so large plan docs and images all flow into the plan prompt.
|
|
339
|
+
const attachments = msg.tabId
|
|
340
|
+
? mergePreUploadedAttachments(ctx, msg.tabId, inlineAttachments)
|
|
341
|
+
: inlineAttachments;
|
|
342
|
+
|
|
334
343
|
const pmDir = resolvePmDir(workingDir);
|
|
335
344
|
if (!pmDir) {
|
|
336
345
|
ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found. Run planScaffold first.' } });
|
|
@@ -417,7 +426,7 @@ paused: false
|
|
|
417
426
|
if (focusHint) {
|
|
418
427
|
prompt = `Focus on: ${focusHint}\n\n${conversation}`;
|
|
419
428
|
}
|
|
420
|
-
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
|
|
429
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, undefined, attachments).catch(error => {
|
|
421
430
|
ctx.send(ws, {
|
|
422
431
|
type: 'planError',
|
|
423
432
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
import { extname, relative } from 'node:path';
|
|
5
|
-
import { runCommand, type SourceFile } from './quality-tools.js';
|
|
5
|
+
import { chunkFileList, filesByExt, runCommand, type SourceFile } from './quality-tools.js';
|
|
6
6
|
import { biomeDiagToFinding, type Ecosystem, FUNCTION_LENGTH_THRESHOLD, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
|
|
7
7
|
|
|
8
|
+
const NODE_COMPLEXITY_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
9
|
+
const PY_COMPLEXITY_EXTS = ['.py', '.pyi'];
|
|
10
|
+
|
|
8
11
|
// ============================================================================
|
|
9
12
|
// Function Length Analysis
|
|
10
13
|
// ============================================================================
|
|
@@ -160,43 +163,62 @@ function computeComplexityScore(findings: QualityFinding[]): number {
|
|
|
160
163
|
return Math.max(0, 100 - penalty);
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
async function complexityFromBiome(dirPath: string): Promise<QualityFinding[] | null> {
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
166
|
+
async function complexityFromBiome(dirPath: string, files: SourceFile[]): Promise<QualityFinding[] | null> {
|
|
167
|
+
const targets = filesByExt(files, NODE_COMPLEXITY_EXTS);
|
|
168
|
+
if (targets.length === 0) return [];
|
|
169
|
+
|
|
170
|
+
const findings: QualityFinding[] = [];
|
|
171
|
+
for (const chunk of chunkFileList(targets)) {
|
|
172
|
+
const result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', ...chunk], dirPath);
|
|
173
|
+
if (result.exitCode > 1) return null;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(result.stdout);
|
|
177
|
+
if (!parsed.diagnostics) continue;
|
|
178
|
+
for (const d of parsed.diagnostics) {
|
|
179
|
+
if (isBiomeComplexityDiagnostic(d)) findings.push(biomeDiagToFinding(d, 'complexity'));
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return findings;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function eslintFileToComplexityFindings(
|
|
189
|
+
file: { filePath: string; messages?: Array<Record<string, unknown>> },
|
|
190
|
+
dirPath: string,
|
|
191
|
+
): QualityFinding[] {
|
|
192
|
+
const out: QualityFinding[] = [];
|
|
193
|
+
for (const msg of file.messages || []) {
|
|
194
|
+
if (!isEslintComplexityRule(msg.ruleId as string)) continue;
|
|
195
|
+
out.push({
|
|
196
|
+
severity: msg.severity === 2 ? 'high' : 'medium',
|
|
197
|
+
category: 'complexity',
|
|
198
|
+
file: relative(dirPath, file.filePath),
|
|
199
|
+
line: (msg.line as number) ?? null,
|
|
200
|
+
title: (msg.ruleId as string) || 'complexity',
|
|
201
|
+
description: msg.message as string,
|
|
202
|
+
});
|
|
175
203
|
}
|
|
204
|
+
return out;
|
|
176
205
|
}
|
|
177
206
|
|
|
178
|
-
async function complexityFromEslint(dirPath: string): Promise<QualityFinding[] | null> {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
207
|
+
async function complexityFromEslint(dirPath: string, files: SourceFile[]): Promise<QualityFinding[] | null> {
|
|
208
|
+
const targets = filesByExt(files, NODE_COMPLEXITY_EXTS);
|
|
209
|
+
if (targets.length === 0) return [];
|
|
181
210
|
|
|
182
211
|
const findings: QualityFinding[] = [];
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
line: msg.line ?? null,
|
|
193
|
-
title: msg.ruleId || 'complexity',
|
|
194
|
-
description: msg.message,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
212
|
+
for (const chunk of chunkFileList(targets)) {
|
|
213
|
+
const result = await runCommand('npx', ['eslint', '--format=json', ...chunk], dirPath);
|
|
214
|
+
if (result.exitCode > 1 && !result.stdout.trim().startsWith('[')) return null;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(result.stdout);
|
|
218
|
+
for (const file of parsed) findings.push(...eslintFileToComplexityFindings(file, dirPath));
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
197
221
|
}
|
|
198
|
-
} catch {
|
|
199
|
-
return null;
|
|
200
222
|
}
|
|
201
223
|
|
|
202
224
|
return findings;
|
|
@@ -215,28 +237,40 @@ function radonFuncToFinding(filePath: string, func: Record<string, unknown>): Qu
|
|
|
215
237
|
};
|
|
216
238
|
}
|
|
217
239
|
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
function radonPayloadToFindings(payload: Record<string, Array<Record<string, unknown>>>): QualityFinding[] {
|
|
241
|
+
const out: QualityFinding[] = [];
|
|
242
|
+
for (const [filePath, functions] of Object.entries(payload)) {
|
|
243
|
+
for (const func of functions) {
|
|
244
|
+
const finding = radonFuncToFinding(filePath, func);
|
|
245
|
+
if (finding) out.push(finding);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function complexityFromRadon(dirPath: string, files: SourceFile[]): Promise<QualityFinding[] | null> {
|
|
252
|
+
const targets = filesByExt(files, PY_COMPLEXITY_EXTS);
|
|
253
|
+
if (targets.length === 0) return [];
|
|
254
|
+
|
|
255
|
+
const findings: QualityFinding[] = [];
|
|
256
|
+
for (const chunk of chunkFileList(targets)) {
|
|
257
|
+
const result = await runCommand('radon', ['cc', '--json', ...chunk], dirPath);
|
|
258
|
+
if (result.exitCode !== 0 && !result.stdout.trim().startsWith('{')) return null;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(result.stdout) as Record<string, Array<Record<string, unknown>>>;
|
|
262
|
+
findings.push(...radonPayloadToFindings(parsed));
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
230
265
|
}
|
|
231
|
-
return findings;
|
|
232
|
-
} catch {
|
|
233
|
-
return null;
|
|
234
266
|
}
|
|
267
|
+
return findings;
|
|
235
268
|
}
|
|
236
269
|
|
|
237
270
|
async function analyzeNodeComplexity(
|
|
238
271
|
dirPath: string,
|
|
239
272
|
installed: Set<string> | null,
|
|
273
|
+
files: SourceFile[],
|
|
240
274
|
): Promise<QualityFinding[] | null> {
|
|
241
275
|
const hasCapableTool = !installed || installed.has('biome') || installed.has('eslint');
|
|
242
276
|
if (!hasCapableTool) return null;
|
|
@@ -245,24 +279,26 @@ async function analyzeNodeComplexity(
|
|
|
245
279
|
// This fixes monorepo scenarios where biome.json is in a subdirectory.
|
|
246
280
|
const hasBiome = !installed || installed.has('biome');
|
|
247
281
|
if (hasBiome) {
|
|
248
|
-
const findings = await complexityFromBiome(dirPath);
|
|
282
|
+
const findings = await complexityFromBiome(dirPath, files);
|
|
249
283
|
if (findings) return findings;
|
|
250
284
|
}
|
|
251
|
-
return complexityFromEslint(dirPath);
|
|
285
|
+
return complexityFromEslint(dirPath, files);
|
|
252
286
|
}
|
|
253
287
|
|
|
254
288
|
async function analyzePythonComplexity(
|
|
255
289
|
dirPath: string,
|
|
256
290
|
installed: Set<string> | null,
|
|
291
|
+
files: SourceFile[],
|
|
257
292
|
): Promise<QualityFinding[] | null> {
|
|
258
293
|
const hasRadon = !installed || installed.has('radon');
|
|
259
294
|
if (!hasRadon) return null;
|
|
260
|
-
return complexityFromRadon(dirPath);
|
|
295
|
+
return complexityFromRadon(dirPath, files);
|
|
261
296
|
}
|
|
262
297
|
|
|
263
298
|
export async function analyzeComplexity(
|
|
264
299
|
dirPath: string,
|
|
265
300
|
ecosystems: Ecosystem[],
|
|
301
|
+
files: SourceFile[],
|
|
266
302
|
installedToolNames?: string[],
|
|
267
303
|
): Promise<{ score: number; findings: QualityFinding[]; issueCount: number; available: boolean }> {
|
|
268
304
|
const allFindings: QualityFinding[] = [];
|
|
@@ -272,7 +308,7 @@ export async function analyzeComplexity(
|
|
|
272
308
|
for (const ecosystem of ecosystems) {
|
|
273
309
|
const analyze = ecosystem === 'node' ? analyzeNodeComplexity : ecosystem === 'python' ? analyzePythonComplexity : null;
|
|
274
310
|
if (!analyze) continue;
|
|
275
|
-
const findings = await analyze(dirPath, installed);
|
|
311
|
+
const findings = await analyze(dirPath, installed, files);
|
|
276
312
|
if (findings) {
|
|
277
313
|
canAnalyze = true;
|
|
278
314
|
allFindings.push(...findings);
|
|
@@ -2,19 +2,18 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Quality Handlers — WebSocket message router for quality scanning
|
|
6
|
-
* code review
|
|
5
|
+
* Quality Handlers — WebSocket message router for quality scanning
|
|
6
|
+
* and code review operations. Fixes are handled via chat tabs from the
|
|
7
|
+
* web client (see web/src/components/views/QualityView/qualityUtils.ts)
|
|
8
|
+
* so there is no server-side fix message anymore.
|
|
7
9
|
*
|
|
8
10
|
* Agent logic lives in focused modules:
|
|
9
11
|
* - quality-review-agent.ts — AI code review prompt, parsing, handler
|
|
10
|
-
* - quality-fix-agent.ts — AI fix prompt, progress tracking, handler
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { join, resolve } from 'node:path';
|
|
14
15
|
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
15
16
|
import type { HandlerContext } from './handler-context.js';
|
|
16
|
-
import type { FindingForFix } from './quality-fix-agent.js';
|
|
17
|
-
import { handleFixIssues } from './quality-fix-agent.js';
|
|
18
17
|
import { QualityPersistence } from './quality-persistence.js';
|
|
19
18
|
import { handleCodeReview } from './quality-review-agent.js';
|
|
20
19
|
import { detectTools, installTools, runQualityScan } from './quality-service.js';
|
|
@@ -92,17 +91,6 @@ export function handleQualityMessage(
|
|
|
92
91
|
handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence)
|
|
93
92
|
.finally(() => persistence.clearActiveOperation(reportPath));
|
|
94
93
|
},
|
|
95
|
-
qualityFixIssues: () => {
|
|
96
|
-
const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
97
|
-
if (error) { sendPathError(msg.data?.path || '.', error); return; }
|
|
98
|
-
const reportPath = msg.data?.path || '.';
|
|
99
|
-
const section: string | undefined = msg.data?.section;
|
|
100
|
-
const findings: FindingForFix[] = msg.data?.findings || [];
|
|
101
|
-
const persistence = getPersistence(workingDir);
|
|
102
|
-
persistence.setActiveOperation(reportPath, 'fixing');
|
|
103
|
-
handleFixIssues(ctx, ws, reportPath, dirPath, workingDir, section, findings, getPersistence)
|
|
104
|
-
.finally(() => persistence.clearActiveOperation(reportPath));
|
|
105
|
-
},
|
|
106
94
|
qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
|
|
107
95
|
qualityClearPending: () => {
|
|
108
96
|
const persistence = getPersistence(workingDir);
|
|
@@ -145,8 +133,6 @@ async function handleLoadState(
|
|
|
145
133
|
ctx.send(ws, { type: 'qualityScanResults', data: pending.data });
|
|
146
134
|
} else if (pending.type === 'codeReview') {
|
|
147
135
|
ctx.send(ws, { type: 'qualityCodeReview', data: pending.data });
|
|
148
|
-
} else if (pending.type === 'fixComplete') {
|
|
149
|
-
ctx.send(ws, { type: 'qualityFixComplete', data: pending.data });
|
|
150
136
|
}
|
|
151
137
|
}
|
|
152
138
|
persistence.clearPendingResults();
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
import { relative } from 'node:path';
|
|
5
|
-
import { runCommand, type SourceFile } from './quality-tools.js';
|
|
5
|
+
import { chunkFileList, filesByExt, runCommand, type SourceFile } from './quality-tools.js';
|
|
6
6
|
import { biomeDiagToFinding, type Ecosystem, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
|
|
7
7
|
|
|
8
|
+
const NODE_LINT_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
9
|
+
const PY_LINT_EXTS = ['.py', '.pyi'];
|
|
10
|
+
|
|
8
11
|
interface LintAccumulator {
|
|
9
12
|
errors: number;
|
|
10
13
|
warnings: number;
|
|
@@ -31,17 +34,22 @@ function parseBiomeDiagnostics(stdout: string, acc: LintAccumulator): void {
|
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
async function lintWithBiome(dirPath: string, acc: LintAccumulator): Promise<void> {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
+
async function lintWithBiome(dirPath: string, acc: LintAccumulator, files: SourceFile[]): Promise<void> {
|
|
38
|
+
const targets = filesByExt(files, NODE_LINT_EXTS);
|
|
39
|
+
if (targets.length === 0) return;
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
acc.
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
for (const chunk of chunkFileList(targets)) {
|
|
42
|
+
const result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', ...chunk], dirPath);
|
|
43
|
+
if (result.exitCode > 1) return;
|
|
44
|
+
|
|
45
|
+
acc.ran = true;
|
|
46
|
+
try {
|
|
47
|
+
parseBiomeDiagnostics(result.stdout, acc);
|
|
48
|
+
} catch {
|
|
49
|
+
acc.errors += (result.stdout.match(/error/gi) || []).length;
|
|
50
|
+
acc.warnings += (result.stdout.match(/warning/gi) || []).length;
|
|
51
|
+
acc.ran = acc.errors > 0 || acc.warnings > 0 || result.exitCode === 0;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -64,58 +72,77 @@ function processEslintMessage(
|
|
|
64
72
|
});
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
async function lintWithEslint(dirPath: string, acc: LintAccumulator): Promise<void> {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
async function lintWithEslint(dirPath: string, acc: LintAccumulator, files: SourceFile[]): Promise<void> {
|
|
76
|
+
const targets = filesByExt(files, NODE_LINT_EXTS);
|
|
77
|
+
if (targets.length === 0) return;
|
|
78
|
+
|
|
79
|
+
for (const chunk of chunkFileList(targets)) {
|
|
80
|
+
const result = await runCommand('npx', ['eslint', '--format=json', ...chunk], dirPath);
|
|
81
|
+
acc.ran = true;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(result.stdout);
|
|
84
|
+
for (const file of parsed) {
|
|
85
|
+
for (const msg of file.messages || []) {
|
|
86
|
+
processEslintMessage(msg, file.filePath, dirPath, acc);
|
|
87
|
+
}
|
|
75
88
|
}
|
|
89
|
+
} catch {
|
|
90
|
+
acc.errors += (result.stderr.match(/error/gi) || []).length;
|
|
91
|
+
acc.warnings += (result.stderr.match(/warning/gi) || []).length;
|
|
76
92
|
}
|
|
77
|
-
} catch {
|
|
78
|
-
acc.errors += (result.stderr.match(/error/gi) || []).length;
|
|
79
|
-
acc.warnings += (result.stderr.match(/warning/gi) || []).length;
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
async function lintNode(
|
|
96
|
+
async function lintNode(
|
|
97
|
+
dirPath: string,
|
|
98
|
+
acc: LintAccumulator,
|
|
99
|
+
installed: Set<string> | null,
|
|
100
|
+
files: SourceFile[],
|
|
101
|
+
): Promise<void> {
|
|
84
102
|
// Use installed tools list to decide which linter to run, not config file presence.
|
|
85
103
|
// This fixes monorepo scenarios where the config is in a subdirectory.
|
|
86
104
|
const hasBiome = !installed || installed.has('biome');
|
|
87
105
|
const hasEslint = !installed || installed.has('eslint');
|
|
88
106
|
|
|
89
107
|
if (hasBiome) {
|
|
90
|
-
await lintWithBiome(dirPath, acc);
|
|
108
|
+
await lintWithBiome(dirPath, acc, files);
|
|
91
109
|
if (acc.ran) return;
|
|
92
110
|
}
|
|
93
111
|
if (hasEslint) {
|
|
94
|
-
await lintWithEslint(dirPath, acc);
|
|
112
|
+
await lintWithEslint(dirPath, acc, files);
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
116
|
+
function processRuffItem(item: Record<string, unknown>, dirPath: string, acc: LintAccumulator): void {
|
|
117
|
+
const code = item.code as string | undefined;
|
|
118
|
+
const sev = code?.startsWith('E') ? 'high' : 'medium';
|
|
119
|
+
if (sev === 'high') acc.errors++;
|
|
120
|
+
else acc.warnings++;
|
|
121
|
+
const location = item.location as Record<string, unknown> | undefined;
|
|
122
|
+
acc.findings.push({
|
|
123
|
+
severity: sev,
|
|
124
|
+
category: 'lint',
|
|
125
|
+
file: item.filename ? relative(dirPath, item.filename as string) : '',
|
|
126
|
+
line: (location?.row as number) ?? null,
|
|
127
|
+
title: code || 'Lint issue',
|
|
128
|
+
description: (item.message as string) || '',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
101
131
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
} catch { /* ignore */ }
|
|
132
|
+
async function lintPython(dirPath: string, acc: LintAccumulator, files: SourceFile[]): Promise<void> {
|
|
133
|
+
const targets = filesByExt(files, PY_LINT_EXTS);
|
|
134
|
+
if (targets.length === 0) return;
|
|
135
|
+
|
|
136
|
+
for (const chunk of chunkFileList(targets)) {
|
|
137
|
+
const result = await runCommand('ruff', ['check', '--output-format=json', ...chunk], dirPath);
|
|
138
|
+
if (result.exitCode !== 0 && !result.stdout.trim().startsWith('[')) continue;
|
|
139
|
+
|
|
140
|
+
acc.ran = true;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(result.stdout);
|
|
143
|
+
for (const item of parsed) processRuffItem(item, dirPath, acc);
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
}
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
function processClippyMessage(msg: Record<string, unknown>, acc: LintAccumulator): void {
|
|
@@ -174,8 +201,8 @@ export async function analyzeLinting(
|
|
|
174
201
|
const acc = newLintAccumulator();
|
|
175
202
|
const installed = installedToolNames ? new Set(installedToolNames) : null;
|
|
176
203
|
|
|
177
|
-
if (ecosystems.includes('node')) await lintNode(dirPath, acc, installed);
|
|
178
|
-
if (ecosystems.includes('python')) await lintPython(dirPath, acc);
|
|
204
|
+
if (ecosystems.includes('node')) await lintNode(dirPath, acc, installed, files);
|
|
205
|
+
if (ecosystems.includes('python')) await lintPython(dirPath, acc, files);
|
|
179
206
|
if (ecosystems.includes('rust')) await lintRust(dirPath, acc);
|
|
180
207
|
|
|
181
208
|
if (!acc.ran) {
|