mstro-app 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -21
- package/PRIVACY.md +286 -62
- package/README.md +81 -58
- package/bin/commands/status.js +1 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +22 -12
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/headless-logger.d.ts +10 -0
- package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
- package/dist/server/cli/headless/headless-logger.js +66 -0
- package/dist/server/cli/headless/headless-logger.js.map +1 -0
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +6 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +4 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +100 -24
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +22 -9
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +8 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +94 -11
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.d.ts +3 -0
- package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-cli.js +54 -0
- package/dist/server/mcp/bouncer-cli.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts +4 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -0
- package/dist/server/services/plan/composer.js +181 -0
- package/dist/server/services/plan/composer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/dependency-resolver.js +154 -0
- package/dist/server/services/plan/dependency-resolver.js.map +1 -0
- package/dist/server/services/plan/executor.d.ts +110 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -0
- package/dist/server/services/plan/executor.js +641 -0
- package/dist/server/services/plan/executor.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +11 -0
- package/dist/server/services/plan/parser.d.ts.map +1 -0
- package/dist/server/services/plan/parser.js +445 -0
- package/dist/server/services/plan/parser.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +2 -0
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
- package/dist/server/services/plan/state-reconciler.js +145 -0
- package/dist/server/services/plan/state-reconciler.js.map +1 -0
- package/dist/server/services/plan/types.d.ts +121 -0
- package/dist/server/services/plan/types.d.ts.map +1 -0
- package/dist/server/services/plan/types.js +4 -0
- package/dist/server/services/plan/types.js.map +1 -0
- package/dist/server/services/plan/watcher.d.ts +14 -0
- package/dist/server/services/plan/watcher.d.ts.map +1 -0
- package/dist/server/services/plan/watcher.js +69 -0
- package/dist/server/services/plan/watcher.js.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +21 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/plan-handlers.js +494 -0
- package/dist/server/services/websocket/plan-handlers.js.map +1 -0
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +384 -12
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-persistence.js +187 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -0
- package/dist/server/services/websocket/quality-service.d.ts +12 -2
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +162 -18
- package/dist/server/services/websocket/quality-service.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/package.json +3 -3
- package/server/cli/headless/claude-invoker.ts +25 -12
- package/server/cli/headless/headless-logger.ts +78 -0
- package/server/cli/headless/mcp-config.ts +6 -5
- package/server/cli/headless/runner.ts +4 -0
- package/server/cli/headless/stall-assessor.ts +131 -24
- package/server/cli/headless/tool-watchdog.ts +10 -9
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +118 -11
- package/server/mcp/bouncer-cli.ts +73 -0
- package/server/services/plan/composer.ts +199 -0
- package/server/services/plan/dependency-resolver.ts +182 -0
- package/server/services/plan/executor.ts +700 -0
- package/server/services/plan/parser.ts +491 -0
- package/server/services/plan/state-reconciler.ts +174 -0
- package/server/services/plan/types.ts +166 -0
- package/server/services/plan/watcher.ts +73 -0
- package/server/services/websocket/file-explorer-handlers.ts +20 -0
- package/server/services/websocket/handler.ts +21 -0
- package/server/services/websocket/plan-handlers.ts +592 -0
- package/server/services/websocket/quality-handlers.ts +450 -12
- package/server/services/websocket/quality-persistence.ts +250 -0
- package/server/services/websocket/quality-service.ts +183 -18
- package/server/services/websocket/types.ts +48 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Quality Persistence — Persists quality config, reports, and history
|
|
6
|
+
* to .mstro/quality/ in the working directory.
|
|
7
|
+
*
|
|
8
|
+
* Files:
|
|
9
|
+
* .mstro/quality/config.json — Directory list (paths + labels)
|
|
10
|
+
* .mstro/quality/reports/<slug>.json — Latest full report per directory
|
|
11
|
+
* .mstro/quality/history.json — Score history entries for trend tracking
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { QualityResults } from './quality-service.js';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export interface QualityDirectoryConfig {
|
|
23
|
+
path: string;
|
|
24
|
+
label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface QualityConfig {
|
|
28
|
+
directories: QualityDirectoryConfig[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HistoryDirectoryEntry {
|
|
32
|
+
path: string;
|
|
33
|
+
score: number;
|
|
34
|
+
grade: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QualityHistoryEntry {
|
|
38
|
+
timestamp: string;
|
|
39
|
+
overall: number;
|
|
40
|
+
grade: string;
|
|
41
|
+
directories: HistoryDirectoryEntry[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface QualityHistory {
|
|
45
|
+
entries: QualityHistoryEntry[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface QualityPersistedState {
|
|
49
|
+
directories: QualityDirectoryConfig[];
|
|
50
|
+
reports: Record<string, QualityResults>;
|
|
51
|
+
history: QualityHistoryEntry[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Helpers
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
const MAX_HISTORY_ENTRIES = 100;
|
|
59
|
+
|
|
60
|
+
function slugify(dirPath: string): string {
|
|
61
|
+
if (dirPath === '.' || dirPath === './') return '_root';
|
|
62
|
+
return dirPath.replace(/[/\\]/g, '_').replace(/^_+|_+$/g, '') || '_root';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ensureDir(dir: string): void {
|
|
66
|
+
if (!existsSync(dir)) {
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readJson<T>(filePath: string, fallback: T): T {
|
|
72
|
+
try {
|
|
73
|
+
if (existsSync(filePath)) {
|
|
74
|
+
return JSON.parse(readFileSync(filePath, 'utf-8')) as T;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Corrupted or unreadable — return fallback
|
|
78
|
+
}
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeJson(filePath: string, data: unknown): void {
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('[QualityPersistence] Error writing:', filePath, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Quality Persistence
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
export class QualityPersistence {
|
|
95
|
+
private qualityDir: string;
|
|
96
|
+
private reportsDir: string;
|
|
97
|
+
private configPath: string;
|
|
98
|
+
private historyPath: string;
|
|
99
|
+
|
|
100
|
+
constructor(workingDir: string) {
|
|
101
|
+
this.qualityDir = join(workingDir, '.mstro', 'quality');
|
|
102
|
+
this.reportsDir = join(this.qualityDir, 'reports');
|
|
103
|
+
this.configPath = join(this.qualityDir, 'config.json');
|
|
104
|
+
this.historyPath = join(this.qualityDir, 'history.json');
|
|
105
|
+
ensureDir(this.reportsDir);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- Config (directory list) ----
|
|
109
|
+
|
|
110
|
+
loadConfig(): QualityDirectoryConfig[] {
|
|
111
|
+
const config = readJson<QualityConfig>(this.configPath, { directories: [] });
|
|
112
|
+
return config.directories;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
saveConfig(directories: QualityDirectoryConfig[]): void {
|
|
116
|
+
writeJson(this.configPath, { directories });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addDirectory(path: string, label: string): void {
|
|
120
|
+
const dirs = this.loadConfig();
|
|
121
|
+
if (!dirs.some((d) => d.path === path)) {
|
|
122
|
+
dirs.push({ path, label });
|
|
123
|
+
this.saveConfig(dirs);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
removeDirectory(path: string): void {
|
|
128
|
+
const dirs = this.loadConfig().filter((d) => d.path !== path);
|
|
129
|
+
this.saveConfig(dirs);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Reports (latest per directory) ----
|
|
133
|
+
|
|
134
|
+
loadReport(dirPath: string): QualityResults | null {
|
|
135
|
+
const slug = slugify(dirPath);
|
|
136
|
+
const reportPath = join(this.reportsDir, `${slug}.json`);
|
|
137
|
+
return readJson<QualityResults | null>(reportPath, null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
saveReport(dirPath: string, results: QualityResults): void {
|
|
141
|
+
const slug = slugify(dirPath);
|
|
142
|
+
const reportPath = join(this.reportsDir, `${slug}.json`);
|
|
143
|
+
writeJson(reportPath, results);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
loadAllReports(directories: QualityDirectoryConfig[]): Record<string, QualityResults> {
|
|
147
|
+
const reports: Record<string, QualityResults> = {};
|
|
148
|
+
for (const dir of directories) {
|
|
149
|
+
const report = this.loadReport(dir.path);
|
|
150
|
+
if (report) {
|
|
151
|
+
reports[dir.path] = report;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return reports;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- History (trend tracking) ----
|
|
158
|
+
|
|
159
|
+
loadHistory(): QualityHistoryEntry[] {
|
|
160
|
+
const history = readJson<QualityHistory>(this.historyPath, { entries: [] });
|
|
161
|
+
return history.entries;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
appendHistory(results: QualityResults, dirPath: string): void {
|
|
165
|
+
const history = this.loadHistory();
|
|
166
|
+
|
|
167
|
+
// Find or create entry for this timestamp batch
|
|
168
|
+
// If the last entry was within 60 seconds, merge into it (for multi-dir scans)
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const lastEntry = history[history.length - 1];
|
|
171
|
+
const lastTime = lastEntry ? new Date(lastEntry.timestamp).getTime() : 0;
|
|
172
|
+
const mergeWindow = 60_000; // 60 seconds
|
|
173
|
+
|
|
174
|
+
const dirEntry: HistoryDirectoryEntry = {
|
|
175
|
+
path: dirPath,
|
|
176
|
+
score: results.overall,
|
|
177
|
+
grade: results.grade,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (lastEntry && now.getTime() - lastTime < mergeWindow) {
|
|
181
|
+
// Merge: update or add this directory in the last entry
|
|
182
|
+
const existing = lastEntry.directories.findIndex((d) => d.path === dirPath);
|
|
183
|
+
if (existing >= 0) {
|
|
184
|
+
lastEntry.directories[existing] = dirEntry;
|
|
185
|
+
} else {
|
|
186
|
+
lastEntry.directories.push(dirEntry);
|
|
187
|
+
}
|
|
188
|
+
// Recompute overall as average of all directories in this entry
|
|
189
|
+
const totalScore = lastEntry.directories.reduce((sum, d) => sum + d.score, 0);
|
|
190
|
+
lastEntry.overall = Math.round(totalScore / lastEntry.directories.length);
|
|
191
|
+
lastEntry.grade = gradeFromScore(lastEntry.overall);
|
|
192
|
+
lastEntry.timestamp = now.toISOString();
|
|
193
|
+
} else {
|
|
194
|
+
// New entry
|
|
195
|
+
history.push({
|
|
196
|
+
timestamp: now.toISOString(),
|
|
197
|
+
overall: results.overall,
|
|
198
|
+
grade: results.grade,
|
|
199
|
+
directories: [dirEntry],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Trim to max entries
|
|
204
|
+
while (history.length > MAX_HISTORY_ENTRIES) {
|
|
205
|
+
history.shift();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
writeJson(this.historyPath, { entries: history });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- Code Review (persisted per directory) ----
|
|
212
|
+
|
|
213
|
+
loadCodeReview(dirPath: string): { findings: Record<string, unknown>[]; summary: string; timestamp: string } | null {
|
|
214
|
+
const slug = slugify(dirPath);
|
|
215
|
+
const reviewPath = join(this.reportsDir, `${slug}-review.json`);
|
|
216
|
+
return readJson<{ findings: Record<string, unknown>[]; summary: string; timestamp: string } | null>(reviewPath, null);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
saveCodeReview(dirPath: string, findings: Record<string, unknown>[], summary: string): void {
|
|
220
|
+
const slug = slugify(dirPath);
|
|
221
|
+
const reviewPath = join(this.reportsDir, `${slug}-review.json`);
|
|
222
|
+
writeJson(reviewPath, { findings, summary, timestamp: new Date().toISOString() });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Full state load ----
|
|
226
|
+
|
|
227
|
+
loadState(): QualityPersistedState {
|
|
228
|
+
const directories = this.loadConfig();
|
|
229
|
+
const reports = this.loadAllReports(directories);
|
|
230
|
+
const history = this.loadHistory();
|
|
231
|
+
|
|
232
|
+
// Merge persisted code reviews into reports
|
|
233
|
+
for (const dir of directories) {
|
|
234
|
+
const review = this.loadCodeReview(dir.path);
|
|
235
|
+
if (review && reports[dir.path]) {
|
|
236
|
+
reports[dir.path] = { ...reports[dir.path], codeReview: review.findings as unknown as QualityResults['codeReview'] };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { directories, reports, history };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function gradeFromScore(score: number): string {
|
|
245
|
+
if (score >= 90) return 'A';
|
|
246
|
+
if (score >= 80) return 'B';
|
|
247
|
+
if (score >= 70) return 'C';
|
|
248
|
+
if (score >= 60) return 'D';
|
|
249
|
+
return 'F';
|
|
250
|
+
}
|
|
@@ -54,7 +54,7 @@ export interface ScanProgress {
|
|
|
54
54
|
total: number;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'unknown';
|
|
57
|
+
type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'swift' | 'kotlin' | 'unknown';
|
|
58
58
|
|
|
59
59
|
interface ToolSpec {
|
|
60
60
|
name: string;
|
|
@@ -75,9 +75,9 @@ const ECOSYSTEM_TOOLS: Record<Ecosystem, ToolSpec[]> = {
|
|
|
75
75
|
{ name: 'typescript', check: ['npx', 'tsc', '--version'], category: 'general', installCmd: 'npm install -D typescript' },
|
|
76
76
|
],
|
|
77
77
|
python: [
|
|
78
|
-
{ name: 'ruff', check: ['ruff', '--version'], category: 'linter', installCmd: 'pip install ruff' },
|
|
79
|
-
{ name: 'black', check: ['black', '--version'], category: 'formatter', installCmd: 'pip install black' },
|
|
80
|
-
{ name: 'radon', check: ['radon', '--version'], category: 'complexity', installCmd: 'pip install radon' },
|
|
78
|
+
{ name: 'ruff', check: ['ruff', '--version'], category: 'linter', installCmd: 'uv tool install ruff || pip install ruff' },
|
|
79
|
+
{ name: 'black', check: ['black', '--version'], category: 'formatter', installCmd: 'uv tool install black || pip install black' },
|
|
80
|
+
{ name: 'radon', check: ['radon', '--version'], category: 'complexity', installCmd: 'uv tool install radon || pip install radon' },
|
|
81
81
|
],
|
|
82
82
|
rust: [
|
|
83
83
|
{ name: 'clippy', check: ['cargo', 'clippy', '--version'], category: 'linter', installCmd: 'rustup component add clippy' },
|
|
@@ -87,6 +87,14 @@ const ECOSYSTEM_TOOLS: Record<Ecosystem, ToolSpec[]> = {
|
|
|
87
87
|
{ name: 'golangci-lint', check: ['golangci-lint', '--version'], category: 'linter', installCmd: 'go install github.com/golangci-lint/golangci-lint/cmd/golangci-lint@latest' },
|
|
88
88
|
{ name: 'gofmt', check: ['gofmt', '-h'], category: 'formatter', installCmd: '(built-in with Go)' },
|
|
89
89
|
],
|
|
90
|
+
swift: [
|
|
91
|
+
{ name: 'swiftlint', check: ['swiftlint', '--version'], category: 'linter', installCmd: 'brew install swiftlint' },
|
|
92
|
+
{ name: 'swiftformat', check: ['swiftformat', '--version'], category: 'formatter', installCmd: 'brew install swiftformat' },
|
|
93
|
+
],
|
|
94
|
+
kotlin: [
|
|
95
|
+
{ name: 'ktlint', check: ['ktlint', '--version'], category: 'linter', installCmd: 'brew install ktlint' },
|
|
96
|
+
{ name: 'ktfmt', check: ['ktfmt', '--version'], category: 'formatter', installCmd: 'brew install ktfmt' },
|
|
97
|
+
],
|
|
90
98
|
unknown: [],
|
|
91
99
|
};
|
|
92
100
|
|
|
@@ -113,6 +121,20 @@ const FILE_LENGTH_THRESHOLD = 300;
|
|
|
113
121
|
const FUNCTION_LENGTH_THRESHOLD = 50;
|
|
114
122
|
const TOTAL_STEPS = 7;
|
|
115
123
|
|
|
124
|
+
function hasInstalledToolInCategory(
|
|
125
|
+
installedSet: Set<string>,
|
|
126
|
+
ecosystems: Ecosystem[],
|
|
127
|
+
category: QualityTool['category'],
|
|
128
|
+
): boolean {
|
|
129
|
+
for (const eco of ecosystems) {
|
|
130
|
+
const specs = ECOSYSTEM_TOOLS[eco] || [];
|
|
131
|
+
for (const spec of specs) {
|
|
132
|
+
if (spec.category === category && installedSet.has(spec.name)) return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
116
138
|
// ============================================================================
|
|
117
139
|
// Ecosystem Detection
|
|
118
140
|
// ============================================================================
|
|
@@ -125,6 +147,8 @@ export function detectEcosystem(dirPath: string): Ecosystem[] {
|
|
|
125
147
|
if (files.includes('pyproject.toml') || files.includes('setup.py') || files.includes('requirements.txt')) ecosystems.push('python');
|
|
126
148
|
if (files.includes('Cargo.toml')) ecosystems.push('rust');
|
|
127
149
|
if (files.includes('go.mod')) ecosystems.push('go');
|
|
150
|
+
if (files.includes('Package.swift') || files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) ecosystems.push('swift');
|
|
151
|
+
if (files.includes('build.gradle') || files.includes('build.gradle.kts')) ecosystems.push('kotlin');
|
|
128
152
|
} catch {
|
|
129
153
|
// Directory not readable
|
|
130
154
|
}
|
|
@@ -132,6 +156,29 @@ export function detectEcosystem(dirPath: string): Ecosystem[] {
|
|
|
132
156
|
return ecosystems;
|
|
133
157
|
}
|
|
134
158
|
|
|
159
|
+
/** Detect the Node.js package manager from lockfiles */
|
|
160
|
+
function detectNodePackageManager(dirPath: string): 'npm' | 'yarn' | 'pnpm' | 'bun' {
|
|
161
|
+
try {
|
|
162
|
+
const files = readdirSync(dirPath);
|
|
163
|
+
if (files.includes('bun.lockb') || files.includes('bun.lock')) return 'bun';
|
|
164
|
+
if (files.includes('pnpm-lock.yaml')) return 'pnpm';
|
|
165
|
+
if (files.includes('yarn.lock')) return 'yarn';
|
|
166
|
+
} catch {
|
|
167
|
+
// Directory not readable
|
|
168
|
+
}
|
|
169
|
+
return 'npm';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Build the install command for a Node.js dev dependency */
|
|
173
|
+
function nodeInstallCmd(pm: 'npm' | 'yarn' | 'pnpm' | 'bun', pkg: string): string {
|
|
174
|
+
switch (pm) {
|
|
175
|
+
case 'yarn': return `yarn add -D ${pkg}`;
|
|
176
|
+
case 'pnpm': return `pnpm add -D ${pkg}`;
|
|
177
|
+
case 'bun': return `bun add -d ${pkg}`;
|
|
178
|
+
default: return `npm install -D ${pkg}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
135
182
|
// ============================================================================
|
|
136
183
|
// Tool Detection
|
|
137
184
|
// ============================================================================
|
|
@@ -151,15 +198,20 @@ async function checkToolInstalled(check: string[], cwd: string): Promise<boolean
|
|
|
151
198
|
export async function detectTools(dirPath: string): Promise<{ tools: QualityTool[]; ecosystem: string[] }> {
|
|
152
199
|
const ecosystems = detectEcosystem(dirPath);
|
|
153
200
|
const tools: QualityTool[] = [];
|
|
201
|
+
const nodePm = ecosystems.includes('node') ? detectNodePackageManager(dirPath) : 'npm';
|
|
154
202
|
|
|
155
203
|
for (const eco of ecosystems) {
|
|
156
204
|
const specs = ECOSYSTEM_TOOLS[eco] || [];
|
|
157
205
|
for (const spec of specs) {
|
|
158
206
|
const installed = await checkToolInstalled(spec.check, dirPath);
|
|
207
|
+
// For node tools, resolve install command using the project's package manager
|
|
208
|
+
const installCommand = eco === 'node'
|
|
209
|
+
? nodeInstallCmd(nodePm, spec.installCmd.replace(/^npm install -D /, ''))
|
|
210
|
+
: spec.installCmd;
|
|
159
211
|
tools.push({
|
|
160
212
|
name: spec.name,
|
|
161
213
|
installed,
|
|
162
|
-
installCommand
|
|
214
|
+
installCommand,
|
|
163
215
|
category: spec.category,
|
|
164
216
|
});
|
|
165
217
|
}
|
|
@@ -179,14 +231,35 @@ export async function installTools(
|
|
|
179
231
|
const { tools } = await detectTools(dirPath);
|
|
180
232
|
const toInstall = tools.filter((t) => !t.installed && (!toolNames || toolNames.includes(t.name)));
|
|
181
233
|
|
|
234
|
+
const failures: string[] = [];
|
|
182
235
|
for (const tool of toInstall) {
|
|
183
236
|
if (tool.installCommand.startsWith('(')) continue; // built-in, skip
|
|
184
|
-
|
|
185
|
-
|
|
237
|
+
// Support chained commands with || (try first, fallback to second)
|
|
238
|
+
const commands = tool.installCommand.split(' || ');
|
|
239
|
+
let installed = false;
|
|
240
|
+
for (const cmd of commands) {
|
|
241
|
+
const parts = cmd.trim().split(' ');
|
|
242
|
+
const result = await runCommand(parts[0], parts.slice(1), dirPath);
|
|
243
|
+
if (result.exitCode === 0) { installed = true; break; }
|
|
244
|
+
}
|
|
245
|
+
if (!installed) {
|
|
246
|
+
failures.push(`${tool.name}: all install methods failed`);
|
|
247
|
+
}
|
|
186
248
|
}
|
|
187
249
|
|
|
188
250
|
// Re-detect after install
|
|
189
|
-
|
|
251
|
+
const detected = await detectTools(dirPath);
|
|
252
|
+
|
|
253
|
+
// Check if any requested tools are still missing after install
|
|
254
|
+
const requestedNames = new Set(toolNames ?? toInstall.map((t) => t.name));
|
|
255
|
+
const stillMissing = detected.tools.filter((t) => !t.installed && requestedNames.has(t.name)).map((t) => t.name);
|
|
256
|
+
|
|
257
|
+
if (stillMissing.length > 0) {
|
|
258
|
+
const detail = failures.length > 0 ? ` ${failures.join('; ')}` : '';
|
|
259
|
+
throw new Error(`Failed to install: ${stillMissing.join(', ')}.${detail}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return detected;
|
|
190
263
|
}
|
|
191
264
|
|
|
192
265
|
// ============================================================================
|
|
@@ -371,7 +444,7 @@ async function lintNode(dirPath: string, acc: LintAccumulator): Promise<void> {
|
|
|
371
444
|
|
|
372
445
|
async function lintPython(dirPath: string, acc: LintAccumulator): Promise<void> {
|
|
373
446
|
const result = await runCommand('ruff', ['check', '--output-format=json', '.'], dirPath);
|
|
374
|
-
if (result.exitCode
|
|
447
|
+
if (result.exitCode !== 0 && !result.stdout.trim().startsWith('[')) return;
|
|
375
448
|
|
|
376
449
|
acc.ran = true;
|
|
377
450
|
try {
|
|
@@ -789,16 +862,47 @@ interface CategoryWeights {
|
|
|
789
862
|
complexity: number;
|
|
790
863
|
fileLength: number;
|
|
791
864
|
functionLength: number;
|
|
865
|
+
aiReview: number;
|
|
792
866
|
}
|
|
793
867
|
|
|
794
868
|
const DEFAULT_WEIGHTS: CategoryWeights = {
|
|
795
|
-
linting: 0.
|
|
796
|
-
formatting: 0.
|
|
797
|
-
complexity: 0.
|
|
798
|
-
fileLength: 0.
|
|
799
|
-
functionLength: 0.
|
|
869
|
+
linting: 0.25,
|
|
870
|
+
formatting: 0.10,
|
|
871
|
+
complexity: 0.20,
|
|
872
|
+
fileLength: 0.12,
|
|
873
|
+
functionLength: 0.13,
|
|
874
|
+
aiReview: 0.20,
|
|
800
875
|
};
|
|
801
876
|
|
|
877
|
+
// ============================================================================
|
|
878
|
+
// AI Code Review Score
|
|
879
|
+
// ============================================================================
|
|
880
|
+
|
|
881
|
+
const SEVERITY_PENALTY: Record<string, number> = {
|
|
882
|
+
critical: 10.0,
|
|
883
|
+
high: 5.0,
|
|
884
|
+
medium: 2.0,
|
|
885
|
+
low: 0.5,
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
/** Exponential decay constant — higher = harsher scoring */
|
|
889
|
+
const AI_REVIEW_DECAY = 0.10;
|
|
890
|
+
|
|
891
|
+
export function computeAiReviewScore(
|
|
892
|
+
findings: Array<{ severity: string }>,
|
|
893
|
+
totalLines: number,
|
|
894
|
+
): number {
|
|
895
|
+
if (findings.length === 0) return 100;
|
|
896
|
+
|
|
897
|
+
const effectiveKloc = Math.max(totalLines / 1000, 1.0);
|
|
898
|
+
const totalPenalty = findings.reduce(
|
|
899
|
+
(sum, f) => sum + (SEVERITY_PENALTY[f.severity] ?? 2.0),
|
|
900
|
+
0,
|
|
901
|
+
);
|
|
902
|
+
const penaltyDensity = totalPenalty / effectiveKloc;
|
|
903
|
+
return Math.round(100 * Math.exp(-AI_REVIEW_DECAY * penaltyDensity));
|
|
904
|
+
}
|
|
905
|
+
|
|
802
906
|
function computeOverallScore(categories: CategoryScore[]): number {
|
|
803
907
|
const available = categories.filter((c) => c.available);
|
|
804
908
|
if (available.length === 0) return 0;
|
|
@@ -823,9 +927,13 @@ export type ProgressCallback = (progress: ScanProgress) => void;
|
|
|
823
927
|
export async function runQualityScan(
|
|
824
928
|
dirPath: string,
|
|
825
929
|
onProgress?: ProgressCallback,
|
|
930
|
+
installedToolNames?: string[],
|
|
826
931
|
): Promise<QualityResults> {
|
|
827
932
|
const ecosystems = detectEcosystem(dirPath);
|
|
828
933
|
|
|
934
|
+
// Build set of installed tools for gating analyses
|
|
935
|
+
const installedSet = installedToolNames ? new Set(installedToolNames) : null;
|
|
936
|
+
|
|
829
937
|
const progress = (step: string, current: number) => {
|
|
830
938
|
onProgress?.({ step, current, total: TOTAL_STEPS });
|
|
831
939
|
};
|
|
@@ -834,13 +942,19 @@ export async function runQualityScan(
|
|
|
834
942
|
progress('Collecting source files', 1);
|
|
835
943
|
const files = collectSourceFiles(dirPath, dirPath);
|
|
836
944
|
|
|
837
|
-
// Step 2: Run linting
|
|
945
|
+
// Step 2: Run linting (only if a linter is installed)
|
|
838
946
|
progress('Running linters', 2);
|
|
839
|
-
const
|
|
947
|
+
const hasLinter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'linter');
|
|
948
|
+
const lintResult = hasLinter
|
|
949
|
+
? await analyzeLinting(dirPath, ecosystems, files)
|
|
950
|
+
: { score: 0, findings: [], available: false, issueCount: 0 };
|
|
840
951
|
|
|
841
|
-
// Step 3: Check formatting
|
|
952
|
+
// Step 3: Check formatting (only if a formatter is installed)
|
|
842
953
|
progress('Checking formatting', 3);
|
|
843
|
-
const
|
|
954
|
+
const hasFormatter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'formatter');
|
|
955
|
+
const fmtResult = hasFormatter
|
|
956
|
+
? await analyzeFormatting(dirPath, ecosystems, files)
|
|
957
|
+
: { score: 0, available: false, issueCount: 0 };
|
|
844
958
|
|
|
845
959
|
// Step 4: Analyze complexity
|
|
846
960
|
progress('Analyzing complexity', 4);
|
|
@@ -898,6 +1012,14 @@ export async function runQualityScan(
|
|
|
898
1012
|
available: true,
|
|
899
1013
|
issueCount: funcLengthResult.issueCount,
|
|
900
1014
|
},
|
|
1015
|
+
{
|
|
1016
|
+
name: 'AI Review',
|
|
1017
|
+
score: 0,
|
|
1018
|
+
weight: DEFAULT_WEIGHTS.aiReview,
|
|
1019
|
+
effectiveWeight: DEFAULT_WEIGHTS.aiReview,
|
|
1020
|
+
available: false,
|
|
1021
|
+
issueCount: 0,
|
|
1022
|
+
},
|
|
901
1023
|
];
|
|
902
1024
|
|
|
903
1025
|
const overall = computeOverallScore(categories);
|
|
@@ -920,3 +1042,46 @@ export async function runQualityScan(
|
|
|
920
1042
|
ecosystem: ecosystems,
|
|
921
1043
|
};
|
|
922
1044
|
}
|
|
1045
|
+
|
|
1046
|
+
// ============================================================================
|
|
1047
|
+
// Recompute with AI Review
|
|
1048
|
+
// ============================================================================
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Recompute the overall score after AI code review findings become available.
|
|
1052
|
+
* Returns a new QualityResults with the AI Review category enabled and score updated.
|
|
1053
|
+
*/
|
|
1054
|
+
export function recomputeWithAiReview(
|
|
1055
|
+
results: QualityResults,
|
|
1056
|
+
aiFindings: Array<{ severity: string }>,
|
|
1057
|
+
): QualityResults {
|
|
1058
|
+
const aiScore = computeAiReviewScore(aiFindings, results.totalLines);
|
|
1059
|
+
|
|
1060
|
+
// Update or add the AI Review category
|
|
1061
|
+
const categories = results.categories.map((cat) => ({ ...cat }));
|
|
1062
|
+
const aiCatIndex = categories.findIndex((c) => c.name === 'AI Review');
|
|
1063
|
+
const aiCategory: CategoryScore = {
|
|
1064
|
+
name: 'AI Review',
|
|
1065
|
+
score: aiScore,
|
|
1066
|
+
weight: DEFAULT_WEIGHTS.aiReview,
|
|
1067
|
+
effectiveWeight: DEFAULT_WEIGHTS.aiReview,
|
|
1068
|
+
available: true,
|
|
1069
|
+
issueCount: aiFindings.length,
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
if (aiCatIndex >= 0) {
|
|
1073
|
+
categories[aiCatIndex] = aiCategory;
|
|
1074
|
+
} else {
|
|
1075
|
+
categories.push(aiCategory);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const overall = computeOverallScore(categories);
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
...results,
|
|
1082
|
+
overall,
|
|
1083
|
+
grade: computeGrade(overall),
|
|
1084
|
+
categories,
|
|
1085
|
+
codeReview: results.codeReview,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
@@ -112,11 +112,31 @@ export interface WebSocketMessage {
|
|
|
112
112
|
| 'qualityScan'
|
|
113
113
|
| 'qualityInstallTools'
|
|
114
114
|
| 'qualityCodeReview'
|
|
115
|
+
| 'qualityFixIssues'
|
|
116
|
+
| 'qualityLoadState'
|
|
117
|
+
| 'qualitySaveDirectories'
|
|
115
118
|
// File upload message types (chunked remote uploads)
|
|
116
119
|
| 'fileUploadStart'
|
|
117
120
|
| 'fileUploadChunk'
|
|
118
121
|
| 'fileUploadComplete'
|
|
119
|
-
| 'fileUploadCancel'
|
|
122
|
+
| 'fileUploadCancel'
|
|
123
|
+
// Plan message types
|
|
124
|
+
| 'planInit'
|
|
125
|
+
| 'planGetState'
|
|
126
|
+
| 'planListIssues'
|
|
127
|
+
| 'planGetIssue'
|
|
128
|
+
| 'planGetSprint'
|
|
129
|
+
| 'planGetMilestone'
|
|
130
|
+
| 'planCreateIssue'
|
|
131
|
+
| 'planUpdateIssue'
|
|
132
|
+
| 'planDeleteIssue'
|
|
133
|
+
| 'planScaffold'
|
|
134
|
+
| 'planPrompt'
|
|
135
|
+
| 'planExecute'
|
|
136
|
+
| 'planExecuteEpic'
|
|
137
|
+
| 'planPause'
|
|
138
|
+
| 'planStop'
|
|
139
|
+
| 'planResume';
|
|
120
140
|
tabId?: string;
|
|
121
141
|
terminalId?: string;
|
|
122
142
|
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|
|
@@ -231,12 +251,38 @@ export interface WebSocketResponse {
|
|
|
231
251
|
| 'qualityInstallProgress'
|
|
232
252
|
| 'qualityInstallComplete'
|
|
233
253
|
| 'qualityCodeReview'
|
|
254
|
+
| 'qualityCodeReviewProgress'
|
|
234
255
|
| 'qualityPostSession'
|
|
256
|
+
| 'qualityFixProgress'
|
|
257
|
+
| 'qualityFixComplete'
|
|
235
258
|
| 'qualityError'
|
|
259
|
+
| 'qualityStateLoaded'
|
|
236
260
|
// File upload response types
|
|
237
261
|
| 'fileUploadAck'
|
|
238
262
|
| 'fileUploadReady'
|
|
239
|
-
| 'fileUploadError'
|
|
263
|
+
| 'fileUploadError'
|
|
264
|
+
// Plan response types
|
|
265
|
+
| 'planState'
|
|
266
|
+
| 'planIssueList'
|
|
267
|
+
| 'planIssue'
|
|
268
|
+
| 'planSprint'
|
|
269
|
+
| 'planMilestone'
|
|
270
|
+
| 'planNotFound'
|
|
271
|
+
| 'planStateUpdated'
|
|
272
|
+
| 'planIssueUpdated'
|
|
273
|
+
| 'planIssueCreated'
|
|
274
|
+
| 'planIssueDeleted'
|
|
275
|
+
| 'planScaffolded'
|
|
276
|
+
| 'planPromptStreaming'
|
|
277
|
+
| 'planPromptProgress'
|
|
278
|
+
| 'planPromptResponse'
|
|
279
|
+
| 'planExecutionStarted'
|
|
280
|
+
| 'planExecutionProgress'
|
|
281
|
+
| 'planExecutionOutput'
|
|
282
|
+
| 'planExecutionMetrics'
|
|
283
|
+
| 'planExecutionComplete'
|
|
284
|
+
| 'planExecutionError'
|
|
285
|
+
| 'planError';
|
|
240
286
|
tabId?: string;
|
|
241
287
|
terminalId?: string;
|
|
242
288
|
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|