mstro-app 0.3.7 → 0.3.9
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 +4 -8
- package/bin/mstro.js +54 -15
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +18 -9
- 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 +74 -20
- 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 +30 -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/index.js +0 -4
- package/dist/server/index.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/mcp/bouncer-integration.d.ts +2 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +55 -39
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/bouncer-sandbox.d.ts +60 -0
- package/dist/server/mcp/bouncer-sandbox.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-sandbox.js +182 -0
- package/dist/server/mcp/bouncer-sandbox.js.map +1 -0
- package/dist/server/mcp/security-patterns.d.ts +6 -12
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +197 -10
- package/dist/server/mcp/security-patterns.js.map +1 -1
- 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 +152 -0
- package/dist/server/services/plan/dependency-resolver.js.map +1 -0
- package/dist/server/services/plan/executor.d.ts +91 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -0
- package/dist/server/services/plan/executor.js +545 -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 +415 -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 +105 -0
- package/dist/server/services/plan/state-reconciler.js.map +1 -0
- package/dist/server/services/plan/types.d.ts +120 -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 +0 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +28 -2
- 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 +4 -0
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-handlers.js +470 -0
- package/dist/server/services/websocket/quality-handlers.js.map +1 -0
- 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 +54 -0
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-service.js +816 -0
- package/dist/server/services/websocket/quality-service.js.map +1 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +23 -0
- 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/package.json +3 -2
- package/server/cli/headless/claude-invoker.ts +21 -9
- 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 +101 -20
- package/server/cli/headless/tool-watchdog.ts +18 -9
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +118 -11
- package/server/index.ts +0 -4
- package/server/mcp/bouncer-cli.ts +73 -0
- package/server/mcp/bouncer-integration.ts +66 -44
- package/server/mcp/bouncer-sandbox.ts +214 -0
- package/server/mcp/security-patterns.ts +206 -10
- package/server/services/plan/composer.ts +199 -0
- package/server/services/plan/dependency-resolver.ts +179 -0
- package/server/services/plan/executor.ts +604 -0
- package/server/services/plan/parser.ts +459 -0
- package/server/services/plan/state-reconciler.ts +132 -0
- package/server/services/plan/types.ts +164 -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 +28 -2
- package/server/services/websocket/plan-handlers.ts +592 -0
- package/server/services/websocket/quality-handlers.ts +570 -0
- package/server/services/websocket/quality-persistence.ts +250 -0
- package/server/services/websocket/quality-service.ts +975 -0
- package/server/services/websocket/session-handlers.ts +26 -0
- package/server/services/websocket/types.ts +62 -2
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { extname, join, relative } from 'node:path';
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Constants
|
|
8
|
+
// ============================================================================
|
|
9
|
+
const ECOSYSTEM_TOOLS = {
|
|
10
|
+
node: [
|
|
11
|
+
{ name: 'eslint', check: ['npx', 'eslint', '--version'], category: 'linter', installCmd: 'npm install -D eslint' },
|
|
12
|
+
{ name: 'biome', check: ['npx', '@biomejs/biome', '--version'], category: 'linter', installCmd: 'npm install -D @biomejs/biome' },
|
|
13
|
+
{ name: 'prettier', check: ['npx', 'prettier', '--version'], category: 'formatter', installCmd: 'npm install -D prettier' },
|
|
14
|
+
{ name: 'typescript', check: ['npx', 'tsc', '--version'], category: 'general', installCmd: 'npm install -D typescript' },
|
|
15
|
+
],
|
|
16
|
+
python: [
|
|
17
|
+
{ name: 'ruff', check: ['ruff', '--version'], category: 'linter', installCmd: 'uv tool install ruff || pip install ruff' },
|
|
18
|
+
{ name: 'black', check: ['black', '--version'], category: 'formatter', installCmd: 'uv tool install black || pip install black' },
|
|
19
|
+
{ name: 'radon', check: ['radon', '--version'], category: 'complexity', installCmd: 'uv tool install radon || pip install radon' },
|
|
20
|
+
],
|
|
21
|
+
rust: [
|
|
22
|
+
{ name: 'clippy', check: ['cargo', 'clippy', '--version'], category: 'linter', installCmd: 'rustup component add clippy' },
|
|
23
|
+
{ name: 'rustfmt', check: ['rustfmt', '--version'], category: 'formatter', installCmd: 'rustup component add rustfmt' },
|
|
24
|
+
],
|
|
25
|
+
go: [
|
|
26
|
+
{ name: 'golangci-lint', check: ['golangci-lint', '--version'], category: 'linter', installCmd: 'go install github.com/golangci-lint/golangci-lint/cmd/golangci-lint@latest' },
|
|
27
|
+
{ name: 'gofmt', check: ['gofmt', '-h'], category: 'formatter', installCmd: '(built-in with Go)' },
|
|
28
|
+
],
|
|
29
|
+
swift: [
|
|
30
|
+
{ name: 'swiftlint', check: ['swiftlint', '--version'], category: 'linter', installCmd: 'brew install swiftlint' },
|
|
31
|
+
{ name: 'swiftformat', check: ['swiftformat', '--version'], category: 'formatter', installCmd: 'brew install swiftformat' },
|
|
32
|
+
],
|
|
33
|
+
kotlin: [
|
|
34
|
+
{ name: 'ktlint', check: ['ktlint', '--version'], category: 'linter', installCmd: 'brew install ktlint' },
|
|
35
|
+
{ name: 'ktfmt', check: ['ktfmt', '--version'], category: 'formatter', installCmd: 'brew install ktfmt' },
|
|
36
|
+
],
|
|
37
|
+
unknown: [],
|
|
38
|
+
};
|
|
39
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
40
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
41
|
+
'.py', '.pyi',
|
|
42
|
+
'.rs',
|
|
43
|
+
'.go',
|
|
44
|
+
'.java', '.kt',
|
|
45
|
+
'.cs',
|
|
46
|
+
'.rb',
|
|
47
|
+
'.php',
|
|
48
|
+
'.swift',
|
|
49
|
+
'.c', '.cpp', '.h', '.hpp',
|
|
50
|
+
]);
|
|
51
|
+
const IGNORE_DIRS = new Set([
|
|
52
|
+
'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
|
|
53
|
+
'target', 'vendor', '.venv', 'venv', '.tox', 'coverage',
|
|
54
|
+
'.mstro', '.cache', '.turbo', '.output',
|
|
55
|
+
]);
|
|
56
|
+
const FILE_LENGTH_THRESHOLD = 300;
|
|
57
|
+
const FUNCTION_LENGTH_THRESHOLD = 50;
|
|
58
|
+
const TOTAL_STEPS = 7;
|
|
59
|
+
function hasInstalledToolInCategory(installedSet, ecosystems, category) {
|
|
60
|
+
for (const eco of ecosystems) {
|
|
61
|
+
const specs = ECOSYSTEM_TOOLS[eco] || [];
|
|
62
|
+
for (const spec of specs) {
|
|
63
|
+
if (spec.category === category && installedSet.has(spec.name))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Ecosystem Detection
|
|
71
|
+
// ============================================================================
|
|
72
|
+
export function detectEcosystem(dirPath) {
|
|
73
|
+
const ecosystems = [];
|
|
74
|
+
try {
|
|
75
|
+
const files = readdirSync(dirPath);
|
|
76
|
+
if (files.includes('package.json'))
|
|
77
|
+
ecosystems.push('node');
|
|
78
|
+
if (files.includes('pyproject.toml') || files.includes('setup.py') || files.includes('requirements.txt'))
|
|
79
|
+
ecosystems.push('python');
|
|
80
|
+
if (files.includes('Cargo.toml'))
|
|
81
|
+
ecosystems.push('rust');
|
|
82
|
+
if (files.includes('go.mod'))
|
|
83
|
+
ecosystems.push('go');
|
|
84
|
+
if (files.includes('Package.swift') || files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
|
|
85
|
+
ecosystems.push('swift');
|
|
86
|
+
if (files.includes('build.gradle') || files.includes('build.gradle.kts'))
|
|
87
|
+
ecosystems.push('kotlin');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Directory not readable
|
|
91
|
+
}
|
|
92
|
+
if (ecosystems.length === 0)
|
|
93
|
+
ecosystems.push('unknown');
|
|
94
|
+
return ecosystems;
|
|
95
|
+
}
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Tool Detection
|
|
98
|
+
// ============================================================================
|
|
99
|
+
async function checkToolInstalled(check, cwd) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
const proc = spawn(check[0], check.slice(1), {
|
|
102
|
+
cwd,
|
|
103
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
timeout: 10000,
|
|
105
|
+
});
|
|
106
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
107
|
+
proc.on('error', () => resolve(false));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export async function detectTools(dirPath) {
|
|
111
|
+
const ecosystems = detectEcosystem(dirPath);
|
|
112
|
+
const tools = [];
|
|
113
|
+
for (const eco of ecosystems) {
|
|
114
|
+
const specs = ECOSYSTEM_TOOLS[eco] || [];
|
|
115
|
+
for (const spec of specs) {
|
|
116
|
+
const installed = await checkToolInstalled(spec.check, dirPath);
|
|
117
|
+
tools.push({
|
|
118
|
+
name: spec.name,
|
|
119
|
+
installed,
|
|
120
|
+
installCommand: spec.installCmd,
|
|
121
|
+
category: spec.category,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { tools, ecosystem: ecosystems };
|
|
126
|
+
}
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Tool Installation
|
|
129
|
+
// ============================================================================
|
|
130
|
+
export async function installTools(dirPath, toolNames) {
|
|
131
|
+
const { tools } = await detectTools(dirPath);
|
|
132
|
+
const toInstall = tools.filter((t) => !t.installed && (!toolNames || toolNames.includes(t.name)));
|
|
133
|
+
const failures = [];
|
|
134
|
+
for (const tool of toInstall) {
|
|
135
|
+
if (tool.installCommand.startsWith('('))
|
|
136
|
+
continue; // built-in, skip
|
|
137
|
+
// Support chained commands with || (try first, fallback to second)
|
|
138
|
+
const commands = tool.installCommand.split(' || ');
|
|
139
|
+
let installed = false;
|
|
140
|
+
for (const cmd of commands) {
|
|
141
|
+
const parts = cmd.trim().split(' ');
|
|
142
|
+
const result = await runCommand(parts[0], parts.slice(1), dirPath);
|
|
143
|
+
if (result.exitCode === 0) {
|
|
144
|
+
installed = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!installed) {
|
|
149
|
+
failures.push(`${tool.name}: all install methods failed`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Re-detect after install
|
|
153
|
+
const detected = await detectTools(dirPath);
|
|
154
|
+
if (failures.length > 0) {
|
|
155
|
+
const stillMissing = detected.tools.filter((t) => !t.installed).map((t) => t.name);
|
|
156
|
+
if (stillMissing.length > 0) {
|
|
157
|
+
throw new Error(`Failed to install: ${stillMissing.join(', ')}. ${failures.join('; ')}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return detected;
|
|
161
|
+
}
|
|
162
|
+
function tryStatSync(path) {
|
|
163
|
+
try {
|
|
164
|
+
return statSync(path);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function tryReadFile(path) {
|
|
171
|
+
try {
|
|
172
|
+
return readFileSync(path, 'utf-8');
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function tryReaddirSync(dir) {
|
|
179
|
+
try {
|
|
180
|
+
return readdirSync(dir);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function tryReadSourceFile(fullPath, rootPath) {
|
|
187
|
+
const content = tryReadFile(fullPath);
|
|
188
|
+
if (!content)
|
|
189
|
+
return null;
|
|
190
|
+
return {
|
|
191
|
+
path: fullPath,
|
|
192
|
+
relativePath: relative(rootPath, fullPath),
|
|
193
|
+
lines: content.split('\n').length,
|
|
194
|
+
content,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function processEntry(entry, dir, rootPath, stack, files) {
|
|
198
|
+
if (IGNORE_DIRS.has(entry))
|
|
199
|
+
return;
|
|
200
|
+
const fullPath = join(dir, entry);
|
|
201
|
+
const stat = tryStatSync(fullPath);
|
|
202
|
+
if (!stat)
|
|
203
|
+
return;
|
|
204
|
+
if (stat.isDirectory()) {
|
|
205
|
+
stack.push(fullPath);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!stat.isFile() || !SOURCE_EXTENSIONS.has(extname(entry).toLowerCase()))
|
|
209
|
+
return;
|
|
210
|
+
const sourceFile = tryReadSourceFile(fullPath, rootPath);
|
|
211
|
+
if (sourceFile)
|
|
212
|
+
files.push(sourceFile);
|
|
213
|
+
}
|
|
214
|
+
function collectSourceFiles(dirPath, rootPath) {
|
|
215
|
+
const files = [];
|
|
216
|
+
const stack = [dirPath];
|
|
217
|
+
while (stack.length > 0) {
|
|
218
|
+
const dir = stack.pop();
|
|
219
|
+
const entries = tryReaddirSync(dir);
|
|
220
|
+
if (!entries)
|
|
221
|
+
continue;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
processEntry(entry, dir, rootPath, stack, files);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return files;
|
|
227
|
+
}
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// Command Runner
|
|
230
|
+
// ============================================================================
|
|
231
|
+
function runCommand(cmd, args, cwd) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const proc = spawn(cmd, args, {
|
|
234
|
+
cwd,
|
|
235
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
236
|
+
timeout: 120000,
|
|
237
|
+
});
|
|
238
|
+
let stdout = '';
|
|
239
|
+
let stderr = '';
|
|
240
|
+
proc.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
241
|
+
proc.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
242
|
+
proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
|
|
243
|
+
proc.on('error', (err) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function newLintAccumulator() {
|
|
247
|
+
return { errors: 0, warnings: 0, findings: [], ran: false };
|
|
248
|
+
}
|
|
249
|
+
function biomeSeverity(severity) {
|
|
250
|
+
if (severity === 'error')
|
|
251
|
+
return 'high';
|
|
252
|
+
if (severity === 'warning')
|
|
253
|
+
return 'medium';
|
|
254
|
+
return 'low';
|
|
255
|
+
}
|
|
256
|
+
function processBiomeDiagnostic(d, acc) {
|
|
257
|
+
const sev = biomeSeverity(d.severity);
|
|
258
|
+
if (d.severity === 'error')
|
|
259
|
+
acc.errors++;
|
|
260
|
+
else
|
|
261
|
+
acc.warnings++;
|
|
262
|
+
const location = d.location;
|
|
263
|
+
const span = location?.span ?? {};
|
|
264
|
+
const start = span.start ?? {};
|
|
265
|
+
const message = d.message;
|
|
266
|
+
acc.findings.push({
|
|
267
|
+
severity: sev,
|
|
268
|
+
category: 'linting',
|
|
269
|
+
file: location?.path || '',
|
|
270
|
+
line: start.line ?? null,
|
|
271
|
+
title: d.category || 'Lint issue',
|
|
272
|
+
description: (typeof message === 'object' ? message?.text : message) || '',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function parseBiomeDiagnostics(stdout, acc) {
|
|
276
|
+
const parsed = JSON.parse(stdout);
|
|
277
|
+
if (!parsed.diagnostics)
|
|
278
|
+
return;
|
|
279
|
+
for (const d of parsed.diagnostics) {
|
|
280
|
+
processBiomeDiagnostic(d, acc);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function lintWithBiome(dirPath, acc) {
|
|
284
|
+
const result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', '.'], dirPath);
|
|
285
|
+
if (result.exitCode > 1)
|
|
286
|
+
return;
|
|
287
|
+
acc.ran = true;
|
|
288
|
+
try {
|
|
289
|
+
parseBiomeDiagnostics(result.stdout, acc);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// JSON parse failed, try line counting
|
|
293
|
+
acc.errors += (result.stdout.match(/error/gi) || []).length;
|
|
294
|
+
acc.warnings += (result.stdout.match(/warning/gi) || []).length;
|
|
295
|
+
acc.ran = acc.errors > 0 || acc.warnings > 0 || result.exitCode === 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function lintWithEslint(dirPath, acc) {
|
|
299
|
+
const result = await runCommand('npx', ['eslint', '--format=json', '.'], dirPath);
|
|
300
|
+
acc.ran = true;
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(result.stdout);
|
|
303
|
+
for (const file of parsed) {
|
|
304
|
+
for (const msg of file.messages || []) {
|
|
305
|
+
if (msg.severity === 2)
|
|
306
|
+
acc.errors++;
|
|
307
|
+
else
|
|
308
|
+
acc.warnings++;
|
|
309
|
+
acc.findings.push({
|
|
310
|
+
severity: msg.severity === 2 ? 'high' : 'medium',
|
|
311
|
+
category: 'linting',
|
|
312
|
+
file: relative(dirPath, file.filePath),
|
|
313
|
+
line: msg.line ?? null,
|
|
314
|
+
title: msg.ruleId || 'Lint issue',
|
|
315
|
+
description: msg.message,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
acc.errors += (result.stderr.match(/error/gi) || []).length;
|
|
322
|
+
acc.warnings += (result.stderr.match(/warning/gi) || []).length;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function lintNode(dirPath, acc) {
|
|
326
|
+
const biomeConfig = existsSync(join(dirPath, 'biome.json')) || existsSync(join(dirPath, 'biome.jsonc'));
|
|
327
|
+
if (biomeConfig) {
|
|
328
|
+
await lintWithBiome(dirPath, acc);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
await lintWithEslint(dirPath, acc);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function lintPython(dirPath, acc) {
|
|
335
|
+
const result = await runCommand('ruff', ['check', '--output-format=json', '.'], dirPath);
|
|
336
|
+
if (result.exitCode !== 0 && !result.stdout.trim().startsWith('['))
|
|
337
|
+
return;
|
|
338
|
+
acc.ran = true;
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(result.stdout);
|
|
341
|
+
for (const item of parsed) {
|
|
342
|
+
const sev = item.code?.startsWith('E') ? 'high' : 'medium';
|
|
343
|
+
if (sev === 'high')
|
|
344
|
+
acc.errors++;
|
|
345
|
+
else
|
|
346
|
+
acc.warnings++;
|
|
347
|
+
acc.findings.push({
|
|
348
|
+
severity: sev,
|
|
349
|
+
category: 'linting',
|
|
350
|
+
file: item.filename ? relative(dirPath, item.filename) : '',
|
|
351
|
+
line: item.location?.row ?? null,
|
|
352
|
+
title: item.code || 'Lint issue',
|
|
353
|
+
description: item.message || '',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch { /* ignore */ }
|
|
358
|
+
}
|
|
359
|
+
function processClippyMessage(msg, acc) {
|
|
360
|
+
if (msg.reason !== 'compiler-message' || !msg.message)
|
|
361
|
+
return;
|
|
362
|
+
const message = msg.message;
|
|
363
|
+
const level = message.level;
|
|
364
|
+
if (level === 'error')
|
|
365
|
+
acc.errors++;
|
|
366
|
+
else if (level === 'warning')
|
|
367
|
+
acc.warnings++;
|
|
368
|
+
const spans = message.spans;
|
|
369
|
+
const span = spans?.[0];
|
|
370
|
+
const code = message.code;
|
|
371
|
+
acc.findings.push({
|
|
372
|
+
severity: level === 'error' ? 'high' : 'medium',
|
|
373
|
+
category: 'linting',
|
|
374
|
+
file: span?.file_name || '',
|
|
375
|
+
line: span?.line_start ?? null,
|
|
376
|
+
title: code?.code || 'Clippy',
|
|
377
|
+
description: message.message || '',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function parseClippyOutput(stdout, acc) {
|
|
381
|
+
for (const line of stdout.split('\n')) {
|
|
382
|
+
try {
|
|
383
|
+
const msg = JSON.parse(line);
|
|
384
|
+
processClippyMessage(msg, acc);
|
|
385
|
+
}
|
|
386
|
+
catch { /* not JSON line */ }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function lintRust(dirPath, acc) {
|
|
390
|
+
const result = await runCommand('cargo', ['clippy', '--message-format=json', '--', '-W', 'clippy::all'], dirPath);
|
|
391
|
+
if (result.exitCode > 1)
|
|
392
|
+
return;
|
|
393
|
+
acc.ran = true;
|
|
394
|
+
parseClippyOutput(result.stdout, acc);
|
|
395
|
+
}
|
|
396
|
+
function computeLintScore(totalErrors, totalWarnings, totalLines) {
|
|
397
|
+
const kloc = Math.max(totalLines / 1000, 1);
|
|
398
|
+
const penaltyRaw = totalErrors * 10 + totalWarnings * 3;
|
|
399
|
+
const penaltyPerKloc = penaltyRaw / kloc;
|
|
400
|
+
let score;
|
|
401
|
+
if (penaltyPerKloc === 0)
|
|
402
|
+
score = 100;
|
|
403
|
+
else if (penaltyPerKloc <= 5)
|
|
404
|
+
score = 100 - penaltyPerKloc * 2;
|
|
405
|
+
else if (penaltyPerKloc <= 20)
|
|
406
|
+
score = 90 - (penaltyPerKloc - 5) * 2;
|
|
407
|
+
else if (penaltyPerKloc <= 50)
|
|
408
|
+
score = 60 - (penaltyPerKloc - 20) * 1.5;
|
|
409
|
+
else
|
|
410
|
+
score = Math.max(0, 15 - (penaltyPerKloc - 50) * 0.3);
|
|
411
|
+
return Math.round(Math.max(0, Math.min(100, score)));
|
|
412
|
+
}
|
|
413
|
+
async function analyzeLinting(dirPath, ecosystems, files) {
|
|
414
|
+
const acc = newLintAccumulator();
|
|
415
|
+
if (ecosystems.includes('node'))
|
|
416
|
+
await lintNode(dirPath, acc);
|
|
417
|
+
if (ecosystems.includes('python'))
|
|
418
|
+
await lintPython(dirPath, acc);
|
|
419
|
+
if (ecosystems.includes('rust'))
|
|
420
|
+
await lintRust(dirPath, acc);
|
|
421
|
+
if (!acc.ran) {
|
|
422
|
+
return { score: 0, findings: [], available: false, issueCount: 0 };
|
|
423
|
+
}
|
|
424
|
+
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
|
|
425
|
+
const score = computeLintScore(acc.errors, acc.warnings, totalLines);
|
|
426
|
+
return {
|
|
427
|
+
score,
|
|
428
|
+
findings: acc.findings.slice(0, 100),
|
|
429
|
+
available: true,
|
|
430
|
+
issueCount: acc.errors + acc.warnings,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// ============================================================================
|
|
434
|
+
// Formatting Analysis
|
|
435
|
+
// ============================================================================
|
|
436
|
+
async function analyzeFormatting(dirPath, ecosystems, files) {
|
|
437
|
+
let totalFiles = 0;
|
|
438
|
+
let passingFiles = 0;
|
|
439
|
+
let ran = false;
|
|
440
|
+
if (ecosystems.includes('node')) {
|
|
441
|
+
const result = await runCommand('npx', ['prettier', '--check', '.'], dirPath);
|
|
442
|
+
ran = true;
|
|
443
|
+
// prettier --check outputs filenames of unformatted files to stdout
|
|
444
|
+
const unformatted = result.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('Checking'));
|
|
445
|
+
const nodeFiles = files.filter((f) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(f.path)));
|
|
446
|
+
totalFiles += nodeFiles.length;
|
|
447
|
+
passingFiles += Math.max(0, nodeFiles.length - unformatted.length);
|
|
448
|
+
}
|
|
449
|
+
if (ecosystems.includes('python')) {
|
|
450
|
+
const result = await runCommand('black', ['--check', '--quiet', '.'], dirPath);
|
|
451
|
+
ran = true;
|
|
452
|
+
const pyFiles = files.filter((f) => ['.py', '.pyi'].includes(extname(f.path)));
|
|
453
|
+
totalFiles += pyFiles.length;
|
|
454
|
+
if (result.exitCode === 0) {
|
|
455
|
+
passingFiles += pyFiles.length;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const wouldReformat = (result.stderr.match(/would reformat/gi) || []).length;
|
|
459
|
+
passingFiles += Math.max(0, pyFiles.length - wouldReformat);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (ecosystems.includes('rust')) {
|
|
463
|
+
const result = await runCommand('cargo', ['fmt', '--check'], dirPath);
|
|
464
|
+
ran = true;
|
|
465
|
+
const rsFiles = files.filter((f) => extname(f.path) === '.rs');
|
|
466
|
+
totalFiles += rsFiles.length;
|
|
467
|
+
if (result.exitCode === 0)
|
|
468
|
+
passingFiles += rsFiles.length;
|
|
469
|
+
}
|
|
470
|
+
if (!ran || totalFiles === 0) {
|
|
471
|
+
return { score: 0, available: false, issueCount: 0 };
|
|
472
|
+
}
|
|
473
|
+
const score = Math.round((passingFiles / totalFiles) * 100);
|
|
474
|
+
return { score, available: true, issueCount: totalFiles - passingFiles };
|
|
475
|
+
}
|
|
476
|
+
// ============================================================================
|
|
477
|
+
// File Length Analysis
|
|
478
|
+
// ============================================================================
|
|
479
|
+
function analyzeFileLength(files) {
|
|
480
|
+
if (files.length === 0)
|
|
481
|
+
return { score: 100, findings: [], issueCount: 0 };
|
|
482
|
+
const findings = [];
|
|
483
|
+
let totalScore = 0;
|
|
484
|
+
for (const file of files) {
|
|
485
|
+
const ratio = Math.max(1, file.lines / FILE_LENGTH_THRESHOLD);
|
|
486
|
+
const fileScore = 100 / ratio ** 1.5;
|
|
487
|
+
totalScore += fileScore;
|
|
488
|
+
if (file.lines > FILE_LENGTH_THRESHOLD) {
|
|
489
|
+
findings.push({
|
|
490
|
+
severity: file.lines > FILE_LENGTH_THRESHOLD * 3 ? 'high' : file.lines > FILE_LENGTH_THRESHOLD * 2 ? 'medium' : 'low',
|
|
491
|
+
category: 'file-length',
|
|
492
|
+
file: file.relativePath,
|
|
493
|
+
line: null,
|
|
494
|
+
title: `File has ${file.lines} lines (threshold: ${FILE_LENGTH_THRESHOLD})`,
|
|
495
|
+
description: `This file exceeds the recommended length of ${FILE_LENGTH_THRESHOLD} lines by ${file.lines - FILE_LENGTH_THRESHOLD} lines.`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const score = Math.round(totalScore / files.length);
|
|
500
|
+
return { score: Math.min(100, score), findings: findings.slice(0, 50), issueCount: findings.length };
|
|
501
|
+
}
|
|
502
|
+
// Match function declarations, arrow functions assigned to const/let, and methods
|
|
503
|
+
const JS_FUNC_PATTERN = /^(\s*)(export\s+)?(async\s+)?function\s+(\w+)|^(\s*)(export\s+)?(const|let|var)\s+(\w+)\s*=\s*(async\s+)?\(|^(\s*)(public|private|protected)?\s*(async\s+)?(\w+)\s*\(/;
|
|
504
|
+
function countBraceDeltas(line) {
|
|
505
|
+
let delta = 0;
|
|
506
|
+
for (const ch of line) {
|
|
507
|
+
if (ch === '{')
|
|
508
|
+
delta++;
|
|
509
|
+
else if (ch === '}')
|
|
510
|
+
delta--;
|
|
511
|
+
}
|
|
512
|
+
return delta;
|
|
513
|
+
}
|
|
514
|
+
function matchJsFuncStart(line) {
|
|
515
|
+
const match = JS_FUNC_PATTERN.exec(line);
|
|
516
|
+
if (!match)
|
|
517
|
+
return null;
|
|
518
|
+
const name = match[4] || match[8] || match[13] || 'anonymous';
|
|
519
|
+
const indent = (match[1] || match[5] || match[10] || '').length;
|
|
520
|
+
return { name, indent };
|
|
521
|
+
}
|
|
522
|
+
function extractJsFunctions(file) {
|
|
523
|
+
const functions = [];
|
|
524
|
+
const lines = file.content.split('\n');
|
|
525
|
+
let braceDepth = 0;
|
|
526
|
+
let currentFunc = null;
|
|
527
|
+
let funcStartBraceDepth = 0;
|
|
528
|
+
for (let i = 0; i < lines.length; i++) {
|
|
529
|
+
if (!currentFunc) {
|
|
530
|
+
const funcStart = matchJsFuncStart(lines[i]);
|
|
531
|
+
if (funcStart) {
|
|
532
|
+
currentFunc = { name: funcStart.name, startLine: i + 1, indent: funcStart.indent };
|
|
533
|
+
funcStartBraceDepth = braceDepth;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
braceDepth += countBraceDeltas(lines[i]);
|
|
537
|
+
if (currentFunc && braceDepth <= funcStartBraceDepth && i > currentFunc.startLine - 1) {
|
|
538
|
+
functions.push({
|
|
539
|
+
name: currentFunc.name,
|
|
540
|
+
file: file.relativePath,
|
|
541
|
+
startLine: currentFunc.startLine,
|
|
542
|
+
lines: i + 1 - currentFunc.startLine + 1,
|
|
543
|
+
});
|
|
544
|
+
currentFunc = null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return functions;
|
|
548
|
+
}
|
|
549
|
+
function extractPyFunctions(file) {
|
|
550
|
+
const functions = [];
|
|
551
|
+
const lines = file.content.split('\n');
|
|
552
|
+
const defPattern = /^(\s*)(async\s+)?def\s+(\w+)/;
|
|
553
|
+
let currentFunc = null;
|
|
554
|
+
for (let i = 0; i < lines.length; i++) {
|
|
555
|
+
const match = defPattern.exec(lines[i]);
|
|
556
|
+
if (match) {
|
|
557
|
+
if (currentFunc) {
|
|
558
|
+
functions.push({
|
|
559
|
+
name: currentFunc.name,
|
|
560
|
+
file: file.relativePath,
|
|
561
|
+
startLine: currentFunc.startLine,
|
|
562
|
+
lines: i - currentFunc.startLine + 1,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
currentFunc = { name: match[3], startLine: i + 1, indent: match[1].length };
|
|
566
|
+
}
|
|
567
|
+
else if (currentFunc && lines[i].trim() && !lines[i].startsWith(' '.repeat(currentFunc.indent + 1)) && !lines[i].startsWith('\t')) {
|
|
568
|
+
functions.push({
|
|
569
|
+
name: currentFunc.name,
|
|
570
|
+
file: file.relativePath,
|
|
571
|
+
startLine: currentFunc.startLine,
|
|
572
|
+
lines: i - currentFunc.startLine + 1,
|
|
573
|
+
});
|
|
574
|
+
currentFunc = null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (currentFunc) {
|
|
578
|
+
functions.push({
|
|
579
|
+
name: currentFunc.name,
|
|
580
|
+
file: file.relativePath,
|
|
581
|
+
startLine: currentFunc.startLine,
|
|
582
|
+
lines: lines.length - currentFunc.startLine + 1,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return functions;
|
|
586
|
+
}
|
|
587
|
+
function extractFunctions(file) {
|
|
588
|
+
const ext = extname(file.path).toLowerCase();
|
|
589
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext))
|
|
590
|
+
return extractJsFunctions(file);
|
|
591
|
+
if (['.py', '.pyi'].includes(ext))
|
|
592
|
+
return extractPyFunctions(file);
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
function analyzeFunctionLength(files) {
|
|
596
|
+
const allFunctions = [];
|
|
597
|
+
for (const file of files) {
|
|
598
|
+
allFunctions.push(...extractFunctions(file));
|
|
599
|
+
}
|
|
600
|
+
if (allFunctions.length === 0)
|
|
601
|
+
return { score: 100, findings: [], issueCount: 0 };
|
|
602
|
+
const findings = [];
|
|
603
|
+
let totalScore = 0;
|
|
604
|
+
for (const func of allFunctions) {
|
|
605
|
+
const ratio = Math.max(1, func.lines / FUNCTION_LENGTH_THRESHOLD);
|
|
606
|
+
const funcScore = 100 / ratio ** 1.5;
|
|
607
|
+
totalScore += funcScore;
|
|
608
|
+
if (func.lines > FUNCTION_LENGTH_THRESHOLD) {
|
|
609
|
+
findings.push({
|
|
610
|
+
severity: func.lines > FUNCTION_LENGTH_THRESHOLD * 3 ? 'high' : func.lines > FUNCTION_LENGTH_THRESHOLD * 2 ? 'medium' : 'low',
|
|
611
|
+
category: 'function-length',
|
|
612
|
+
file: func.file,
|
|
613
|
+
line: func.startLine,
|
|
614
|
+
title: `${func.name}() has ${func.lines} lines (threshold: ${FUNCTION_LENGTH_THRESHOLD})`,
|
|
615
|
+
description: `Function "${func.name}" exceeds the recommended length by ${func.lines - FUNCTION_LENGTH_THRESHOLD} lines.`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const score = Math.round(totalScore / allFunctions.length);
|
|
620
|
+
return { score: Math.min(100, score), findings: findings.slice(0, 50), issueCount: findings.length };
|
|
621
|
+
}
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Cyclomatic Complexity (Heuristic)
|
|
624
|
+
// ============================================================================
|
|
625
|
+
function countCyclomaticComplexity(funcContent) {
|
|
626
|
+
let cc = 1; // base
|
|
627
|
+
cc += (funcContent.match(/\bif\b/g) || []).length;
|
|
628
|
+
cc += (funcContent.match(/\belse\s+if\b/g) || []).length;
|
|
629
|
+
cc += (funcContent.match(/\bfor\b/g) || []).length;
|
|
630
|
+
cc += (funcContent.match(/\bwhile\b/g) || []).length;
|
|
631
|
+
cc += (funcContent.match(/\bcase\b/g) || []).length;
|
|
632
|
+
cc += (funcContent.match(/\bcatch\b/g) || []).length;
|
|
633
|
+
cc += (funcContent.match(/&&|\|\|/g) || []).length;
|
|
634
|
+
cc += (funcContent.match(/\?\s*[^:]/g) || []).length; // ternary
|
|
635
|
+
return cc;
|
|
636
|
+
}
|
|
637
|
+
function complexityToScore(cc) {
|
|
638
|
+
if (cc <= 5)
|
|
639
|
+
return 100;
|
|
640
|
+
if (cc <= 10)
|
|
641
|
+
return 100 - (cc - 5) * 5;
|
|
642
|
+
if (cc <= 15)
|
|
643
|
+
return 75 - (cc - 10) * 5;
|
|
644
|
+
if (cc <= 20)
|
|
645
|
+
return 50 - (cc - 15) * 5;
|
|
646
|
+
return Math.max(0, 25 - (cc - 20) * 2.5);
|
|
647
|
+
}
|
|
648
|
+
function getFuncContent(file, func) {
|
|
649
|
+
return file.content.split('\n').slice(func.startLine - 1, func.startLine - 1 + func.lines).join('\n');
|
|
650
|
+
}
|
|
651
|
+
function complexitySeverity(cc) {
|
|
652
|
+
if (cc > 20)
|
|
653
|
+
return 'high';
|
|
654
|
+
if (cc > 15)
|
|
655
|
+
return 'medium';
|
|
656
|
+
return 'low';
|
|
657
|
+
}
|
|
658
|
+
function analyzeFunc(file, func, acc) {
|
|
659
|
+
const funcContent = getFuncContent(file, func);
|
|
660
|
+
const cc = countCyclomaticComplexity(funcContent);
|
|
661
|
+
const funcScore = complexityToScore(cc);
|
|
662
|
+
acc.weightedScore += funcScore * func.lines;
|
|
663
|
+
acc.weight += func.lines;
|
|
664
|
+
if (cc > 10) {
|
|
665
|
+
acc.findings.push({
|
|
666
|
+
severity: complexitySeverity(cc),
|
|
667
|
+
category: 'complexity',
|
|
668
|
+
file: func.file,
|
|
669
|
+
line: func.startLine,
|
|
670
|
+
title: `${func.name}() has cyclomatic complexity ${cc}`,
|
|
671
|
+
description: `Complexity of ${cc} exceeds the recommended threshold of 10. Consider refactoring into smaller functions.`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function analyzeComplexity(files) {
|
|
676
|
+
const acc = { weightedScore: 0, weight: 0, findings: [] };
|
|
677
|
+
for (const file of files) {
|
|
678
|
+
const functions = extractFunctions(file);
|
|
679
|
+
for (const func of functions) {
|
|
680
|
+
analyzeFunc(file, func, acc);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (acc.weight === 0)
|
|
684
|
+
return { score: 100, findings: [], issueCount: 0 };
|
|
685
|
+
const score = Math.round(acc.weightedScore / acc.weight);
|
|
686
|
+
return { score: Math.min(100, score), findings: acc.findings.slice(0, 50), issueCount: acc.findings.length };
|
|
687
|
+
}
|
|
688
|
+
// ============================================================================
|
|
689
|
+
// Scoring
|
|
690
|
+
// ============================================================================
|
|
691
|
+
function computeGrade(score) {
|
|
692
|
+
if (score >= 90)
|
|
693
|
+
return 'A';
|
|
694
|
+
if (score >= 80)
|
|
695
|
+
return 'B';
|
|
696
|
+
if (score >= 70)
|
|
697
|
+
return 'C';
|
|
698
|
+
if (score >= 60)
|
|
699
|
+
return 'D';
|
|
700
|
+
return 'F';
|
|
701
|
+
}
|
|
702
|
+
const DEFAULT_WEIGHTS = {
|
|
703
|
+
linting: 0.30,
|
|
704
|
+
formatting: 0.15,
|
|
705
|
+
complexity: 0.25,
|
|
706
|
+
fileLength: 0.15,
|
|
707
|
+
functionLength: 0.15,
|
|
708
|
+
};
|
|
709
|
+
function computeOverallScore(categories) {
|
|
710
|
+
const available = categories.filter((c) => c.available);
|
|
711
|
+
if (available.length === 0)
|
|
712
|
+
return 0;
|
|
713
|
+
const totalWeight = available.reduce((sum, c) => sum + c.weight, 0);
|
|
714
|
+
let weighted = 0;
|
|
715
|
+
for (const cat of available) {
|
|
716
|
+
const effectiveWeight = cat.weight / totalWeight;
|
|
717
|
+
cat.effectiveWeight = effectiveWeight;
|
|
718
|
+
weighted += cat.score * effectiveWeight;
|
|
719
|
+
}
|
|
720
|
+
return Math.round(Math.max(0, Math.min(100, weighted)));
|
|
721
|
+
}
|
|
722
|
+
export async function runQualityScan(dirPath, onProgress, installedToolNames) {
|
|
723
|
+
const ecosystems = detectEcosystem(dirPath);
|
|
724
|
+
// Build set of installed tools for gating analyses
|
|
725
|
+
const installedSet = installedToolNames ? new Set(installedToolNames) : null;
|
|
726
|
+
const progress = (step, current) => {
|
|
727
|
+
onProgress?.({ step, current, total: TOTAL_STEPS });
|
|
728
|
+
};
|
|
729
|
+
// Step 1: Collect source files
|
|
730
|
+
progress('Collecting source files', 1);
|
|
731
|
+
const files = collectSourceFiles(dirPath, dirPath);
|
|
732
|
+
// Step 2: Run linting (only if a linter is installed)
|
|
733
|
+
progress('Running linters', 2);
|
|
734
|
+
const hasLinter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'linter');
|
|
735
|
+
const lintResult = hasLinter
|
|
736
|
+
? await analyzeLinting(dirPath, ecosystems, files)
|
|
737
|
+
: { score: 0, findings: [], available: false, issueCount: 0 };
|
|
738
|
+
// Step 3: Check formatting (only if a formatter is installed)
|
|
739
|
+
progress('Checking formatting', 3);
|
|
740
|
+
const hasFormatter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'formatter');
|
|
741
|
+
const fmtResult = hasFormatter
|
|
742
|
+
? await analyzeFormatting(dirPath, ecosystems, files)
|
|
743
|
+
: { score: 0, available: false, issueCount: 0 };
|
|
744
|
+
// Step 4: Analyze complexity
|
|
745
|
+
progress('Analyzing complexity', 4);
|
|
746
|
+
const complexityResult = analyzeComplexity(files);
|
|
747
|
+
// Step 5: Check file lengths
|
|
748
|
+
progress('Checking file lengths', 5);
|
|
749
|
+
const fileLengthResult = analyzeFileLength(files);
|
|
750
|
+
// Step 6: Check function lengths
|
|
751
|
+
progress('Checking function lengths', 6);
|
|
752
|
+
const funcLengthResult = analyzeFunctionLength(files);
|
|
753
|
+
// Step 7: Compute scores
|
|
754
|
+
progress('Computing scores', 7);
|
|
755
|
+
const categories = [
|
|
756
|
+
{
|
|
757
|
+
name: 'Linting',
|
|
758
|
+
score: lintResult.score,
|
|
759
|
+
weight: DEFAULT_WEIGHTS.linting,
|
|
760
|
+
effectiveWeight: DEFAULT_WEIGHTS.linting,
|
|
761
|
+
available: lintResult.available,
|
|
762
|
+
issueCount: lintResult.issueCount,
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: 'Formatting',
|
|
766
|
+
score: fmtResult.score,
|
|
767
|
+
weight: DEFAULT_WEIGHTS.formatting,
|
|
768
|
+
effectiveWeight: DEFAULT_WEIGHTS.formatting,
|
|
769
|
+
available: fmtResult.available,
|
|
770
|
+
issueCount: fmtResult.issueCount,
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
name: 'Complexity',
|
|
774
|
+
score: complexityResult.score,
|
|
775
|
+
weight: DEFAULT_WEIGHTS.complexity,
|
|
776
|
+
effectiveWeight: DEFAULT_WEIGHTS.complexity,
|
|
777
|
+
available: true,
|
|
778
|
+
issueCount: complexityResult.issueCount,
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
name: 'File Length',
|
|
782
|
+
score: fileLengthResult.score,
|
|
783
|
+
weight: DEFAULT_WEIGHTS.fileLength,
|
|
784
|
+
effectiveWeight: DEFAULT_WEIGHTS.fileLength,
|
|
785
|
+
available: true,
|
|
786
|
+
issueCount: fileLengthResult.issueCount,
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: 'Function Length',
|
|
790
|
+
score: funcLengthResult.score,
|
|
791
|
+
weight: DEFAULT_WEIGHTS.functionLength,
|
|
792
|
+
effectiveWeight: DEFAULT_WEIGHTS.functionLength,
|
|
793
|
+
available: true,
|
|
794
|
+
issueCount: funcLengthResult.issueCount,
|
|
795
|
+
},
|
|
796
|
+
];
|
|
797
|
+
const overall = computeOverallScore(categories);
|
|
798
|
+
const allFindings = [
|
|
799
|
+
...lintResult.findings,
|
|
800
|
+
...complexityResult.findings,
|
|
801
|
+
...fileLengthResult.findings,
|
|
802
|
+
...funcLengthResult.findings,
|
|
803
|
+
];
|
|
804
|
+
return {
|
|
805
|
+
overall,
|
|
806
|
+
grade: computeGrade(overall),
|
|
807
|
+
categories,
|
|
808
|
+
findings: allFindings.slice(0, 200),
|
|
809
|
+
codeReview: [],
|
|
810
|
+
analyzedFiles: files.length,
|
|
811
|
+
totalLines: files.reduce((sum, f) => sum + f.lines, 0),
|
|
812
|
+
timestamp: new Date().toISOString(),
|
|
813
|
+
ecosystem: ecosystems,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
//# sourceMappingURL=quality-service.js.map
|