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.
Files changed (60) hide show
  1. package/README.md +15 -7
  2. package/dist/server/cli/headless/resilient-runner.d.ts +2 -1
  3. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -1
  4. package/dist/server/cli/headless/resilient-runner.js +2 -0
  5. package/dist/server/cli/headless/resilient-runner.js.map +1 -1
  6. package/dist/server/services/plan/composer.d.ts +2 -1
  7. package/dist/server/services/plan/composer.d.ts.map +1 -1
  8. package/dist/server/services/plan/composer.js +21 -2
  9. package/dist/server/services/plan/composer.js.map +1 -1
  10. package/dist/server/services/websocket/handler.js +1 -1
  11. package/dist/server/services/websocket/handler.js.map +1 -1
  12. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  13. package/dist/server/services/websocket/plan-board-handlers.js +8 -2
  14. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  15. package/dist/server/services/websocket/quality-complexity.d.ts +1 -1
  16. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  17. package/dist/server/services/websocket/quality-complexity.js +88 -59
  18. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  19. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  20. package/dist/server/services/websocket/quality-handlers.js +4 -21
  21. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  22. package/dist/server/services/websocket/quality-linting.d.ts.map +1 -1
  23. package/dist/server/services/websocket/quality-linting.js +71 -50
  24. package/dist/server/services/websocket/quality-linting.js.map +1 -1
  25. package/dist/server/services/websocket/quality-persistence.d.ts +1 -1
  26. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  27. package/dist/server/services/websocket/quality-service.d.ts +0 -4
  28. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  29. package/dist/server/services/websocket/quality-service.js +40 -34
  30. package/dist/server/services/websocket/quality-service.js.map +1 -1
  31. package/dist/server/services/websocket/quality-tools.d.ts +13 -0
  32. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  33. package/dist/server/services/websocket/quality-tools.js +32 -0
  34. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  35. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  36. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  37. package/dist/server/services/websocket/session-handlers.js +18 -2
  38. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  39. package/dist/server/services/websocket/types.d.ts +2 -2
  40. package/dist/server/services/websocket/types.d.ts.map +1 -1
  41. package/dist/server/services/websocket/types.js +2 -2
  42. package/dist/server/services/websocket/types.js.map +1 -1
  43. package/package.json +21 -5
  44. package/server/cli/headless/resilient-runner.ts +5 -1
  45. package/server/services/plan/composer.ts +42 -1
  46. package/server/services/websocket/handler.ts +1 -1
  47. package/server/services/websocket/plan-board-handlers.ts +11 -2
  48. package/server/services/websocket/quality-complexity.ts +87 -51
  49. package/server/services/websocket/quality-handlers.ts +4 -18
  50. package/server/services/websocket/quality-linting.ts +74 -47
  51. package/server/services/websocket/quality-persistence.ts +1 -1
  52. package/server/services/websocket/quality-service.ts +44 -39
  53. package/server/services/websocket/quality-tools.ts +33 -0
  54. package/server/services/websocket/session-handlers.ts +19 -2
  55. package/server/services/websocket/types.ts +2 -2
  56. package/dist/server/services/websocket/quality-fix-agent.d.ts +0 -16
  57. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +0 -1
  58. package/dist/server/services/websocket/quality-fix-agent.js +0 -181
  59. package/dist/server/services/websocket/quality-fix-agent.js.map +0 -1
  60. 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: enrichedPrompt,
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', qualityFixIssues: 'quality', qualityLoadState: 'quality', qualitySaveDirectories: '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 result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', '.'], dirPath);
165
- if (result.exitCode > 1) return null;
166
-
167
- try {
168
- const parsed = JSON.parse(result.stdout);
169
- if (!parsed.diagnostics) return [];
170
- return parsed.diagnostics
171
- .filter(isBiomeComplexityDiagnostic)
172
- .map((d: Record<string, unknown>) => biomeDiagToFinding(d, 'complexity'));
173
- } catch {
174
- return null;
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 result = await runCommand('npx', ['eslint', '--format=json', '.'], dirPath);
180
- if (result.exitCode > 1 && !result.stdout.trim().startsWith('[')) return null;
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
- try {
184
- const parsed = JSON.parse(result.stdout);
185
- for (const file of parsed) {
186
- for (const msg of file.messages || []) {
187
- if (!isEslintComplexityRule(msg.ruleId)) continue;
188
- findings.push({
189
- severity: msg.severity === 2 ? 'high' : 'medium',
190
- category: 'complexity',
191
- file: relative(dirPath, file.filePath),
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
- async function complexityFromRadon(dirPath: string): Promise<QualityFinding[] | null> {
219
- const result = await runCommand('radon', ['cc', '--json', '.'], dirPath);
220
- if (result.exitCode !== 0 && !result.stdout.trim().startsWith('{')) return null;
221
-
222
- try {
223
- const parsed = JSON.parse(result.stdout) as Record<string, Array<Record<string, unknown>>>;
224
- const findings: QualityFinding[] = [];
225
- for (const [filePath, functions] of Object.entries(parsed)) {
226
- for (const func of functions) {
227
- const finding = radonFuncToFinding(filePath, func);
228
- if (finding) findings.push(finding);
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, and fix operations.
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 result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', '.'], dirPath);
36
- if (result.exitCode > 1) return;
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
- acc.ran = true;
39
- try {
40
- parseBiomeDiagnostics(result.stdout, acc);
41
- } catch {
42
- acc.errors += (result.stdout.match(/error/gi) || []).length;
43
- acc.warnings += (result.stdout.match(/warning/gi) || []).length;
44
- acc.ran = acc.errors > 0 || acc.warnings > 0 || result.exitCode === 0;
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 result = await runCommand('npx', ['eslint', '--format=json', '.'], dirPath);
69
- acc.ran = true;
70
- try {
71
- const parsed = JSON.parse(result.stdout);
72
- for (const file of parsed) {
73
- for (const msg of file.messages || []) {
74
- processEslintMessage(msg, file.filePath, dirPath, acc);
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(dirPath: string, acc: LintAccumulator, installed: Set<string> | null): Promise<void> {
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
- async function lintPython(dirPath: string, acc: LintAccumulator): Promise<void> {
99
- const result = await runCommand('ruff', ['check', '--output-format=json', '.'], dirPath);
100
- if (result.exitCode !== 0 && !result.stdout.trim().startsWith('[')) return;
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
- acc.ran = true;
103
- try {
104
- const parsed = JSON.parse(result.stdout);
105
- for (const item of parsed) {
106
- const sev = item.code?.startsWith('E') ? 'high' : 'medium';
107
- if (sev === 'high') acc.errors++;
108
- else acc.warnings++;
109
- acc.findings.push({
110
- severity: sev,
111
- category: 'lint',
112
- file: item.filename ? relative(dirPath, item.filename) : '',
113
- line: item.location?.row ?? null,
114
- title: item.code || 'Lint issue',
115
- description: item.message || '',
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) {
@@ -62,7 +62,7 @@ export interface ActiveOperation {
62
62
  }
63
63
 
64
64
  export interface PendingResult {
65
- type: 'scanResults' | 'codeReview' | 'fixComplete';
65
+ type: 'scanResults' | 'codeReview';
66
66
  path: string;
67
67
  data: Record<string, unknown>;
68
68
  completedAt: string;