projscan 0.1.13 → 0.3.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/README.md +164 -19
- package/dist/analyzers/architectureCheck.js +1 -0
- package/dist/analyzers/architectureCheck.js.map +1 -1
- package/dist/analyzers/securityCheck.js +16 -1
- package/dist/analyzers/securityCheck.js.map +1 -1
- package/dist/cli/index.js +209 -164
- package/dist/cli/index.js.map +1 -1
- package/dist/core/fileInspector.d.ts +13 -0
- package/dist/core/fileInspector.js +205 -0
- package/dist/core/fileInspector.js.map +1 -0
- package/dist/core/hotspotAnalyzer.d.ts +16 -0
- package/dist/core/hotspotAnalyzer.js +342 -0
- package/dist/core/hotspotAnalyzer.js.map +1 -0
- package/dist/core/repositoryScanner.d.ts +4 -1
- package/dist/core/repositoryScanner.js +6 -3
- package/dist/core/repositoryScanner.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/prompts.d.ts +14 -0
- package/dist/mcp/prompts.js +126 -0
- package/dist/mcp/prompts.js.map +1 -0
- package/dist/mcp/resources.d.ts +8 -0
- package/dist/mcp/resources.js +57 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.js +205 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +176 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/reporters/consoleReporter.d.ts +3 -1
- package/dist/reporters/consoleReporter.js +127 -0
- package/dist/reporters/consoleReporter.js.map +1 -1
- package/dist/reporters/jsonReporter.d.ts +3 -1
- package/dist/reporters/jsonReporter.js +6 -0
- package/dist/reporters/jsonReporter.js.map +1 -1
- package/dist/reporters/markdownReporter.d.ts +3 -1
- package/dist/reporters/markdownReporter.js +99 -0
- package/dist/reporters/markdownReporter.js.map +1 -1
- package/dist/reporters/sarifReporter.d.ts +61 -0
- package/dist/reporters/sarifReporter.js +102 -0
- package/dist/reporters/sarifReporter.js.map +1 -0
- package/dist/types.d.ts +112 -1
- package/dist/utils/baseline.d.ts +4 -4
- package/dist/utils/baseline.js +71 -5
- package/dist/utils/baseline.js.map +1 -1
- package/dist/utils/changedFiles.d.ts +14 -0
- package/dist/utils/changedFiles.js +113 -0
- package/dist/utils/changedFiles.js.map +1 -0
- package/dist/utils/config.d.ts +8 -0
- package/dist/utils/config.js +117 -0
- package/dist/utils/config.js.map +1 -0
- package/package.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -14,33 +14,74 @@ import { detectLanguages } from '../core/languageDetector.js';
|
|
|
14
14
|
import { detectFrameworks } from '../core/frameworkDetector.js';
|
|
15
15
|
import { analyzeDependencies } from '../core/dependencyAnalyzer.js';
|
|
16
16
|
import { collectIssues } from '../core/issueEngine.js';
|
|
17
|
+
import { analyzeHotspots } from '../core/hotspotAnalyzer.js';
|
|
18
|
+
import { inspectFile, extractImports, extractExports, inferPurpose, detectFileIssues, } from '../core/fileInspector.js';
|
|
17
19
|
import { getAllAvailableFixes } from '../fixes/fixRegistry.js';
|
|
18
20
|
import { setLogLevel } from '../utils/logger.js';
|
|
19
21
|
import { calculateScore, badgeUrl, badgeMarkdown } from '../utils/scoreCalculator.js';
|
|
20
22
|
import { showBanner, showCompactBanner, showHelp } from '../utils/banner.js';
|
|
21
23
|
import { saveBaseline, loadBaseline, computeDiff } from '../utils/baseline.js';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
24
|
+
import { loadConfig, applyConfigToIssues } from '../utils/config.js';
|
|
25
|
+
import { getChangedFiles } from '../utils/changedFiles.js';
|
|
26
|
+
import { runMcpServer } from '../mcp/server.js';
|
|
27
|
+
import { reportAnalysis, reportHealth, reportCi, reportDiff, reportDetectedIssues, reportExplanation, reportDiagram, reportStructure, reportDependencies, reportHotspots, reportFileInspection, } from '../reporters/consoleReporter.js';
|
|
28
|
+
import { reportAnalysisJson, reportHealthJson, reportCiJson, reportDiffJson, reportExplanationJson, reportDiagramJson, reportStructureJson, reportDependenciesJson, reportHotspotsJson, reportFileJson, } from '../reporters/jsonReporter.js';
|
|
29
|
+
import { reportAnalysisMarkdown, reportHealthMarkdown, reportCiMarkdown, reportDiffMarkdown, reportExplanationMarkdown, reportDiagramMarkdown, reportStructureMarkdown, reportDependenciesMarkdown, reportHotspotsMarkdown, reportFileMarkdown, } from '../reporters/markdownReporter.js';
|
|
30
|
+
import { reportAnalysisSarif, reportHealthSarif, reportCiSarif, } from '../reporters/sarifReporter.js';
|
|
25
31
|
// ── CLI Setup ─────────────────────────────────────────────
|
|
26
32
|
const program = new Command();
|
|
27
33
|
program
|
|
28
34
|
.name('projscan')
|
|
29
35
|
.description('Instant codebase insights — doctor, x-ray, and architecture map for any repository')
|
|
30
36
|
.version(pkg.version)
|
|
31
|
-
.option('--format <type>', 'output format: console, json, markdown', 'console')
|
|
37
|
+
.option('--format <type>', 'output format: console, json, markdown, sarif', 'console')
|
|
38
|
+
.option('--config <path>', 'path to .projscanrc config file')
|
|
32
39
|
.option('--verbose', 'enable verbose output')
|
|
33
40
|
.option('--quiet', 'suppress non-essential output');
|
|
34
41
|
function getFormat() {
|
|
35
42
|
const opts = program.opts();
|
|
36
43
|
const f = opts.format;
|
|
37
|
-
if (f === 'json' || f === 'markdown')
|
|
44
|
+
if (f === 'json' || f === 'markdown' || f === 'sarif')
|
|
38
45
|
return f;
|
|
39
46
|
return 'console';
|
|
40
47
|
}
|
|
41
48
|
function getRootPath() {
|
|
42
49
|
return process.cwd();
|
|
43
50
|
}
|
|
51
|
+
async function loadProjectConfig() {
|
|
52
|
+
const opts = program.opts();
|
|
53
|
+
const explicit = typeof opts.config === 'string' ? opts.config : undefined;
|
|
54
|
+
try {
|
|
55
|
+
const { config, source } = await loadConfig(getRootPath(), explicit);
|
|
56
|
+
if (source && !opts.quiet && getFormat() === 'console') {
|
|
57
|
+
console.error(chalk.dim(` [config: ${path.relative(getRootPath(), source) || source}]`));
|
|
58
|
+
}
|
|
59
|
+
return config;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
63
|
+
console.error(chalk.red(` Config error: ${msg}`));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function filterIssuesByChangedFiles(issues, rootPath, baseRef) {
|
|
68
|
+
const result = await getChangedFiles(rootPath, baseRef);
|
|
69
|
+
if (!result.available) {
|
|
70
|
+
if (getFormat() === 'console' && !program.opts().quiet) {
|
|
71
|
+
console.error(chalk.yellow(` [--changed-only: ${result.reason ?? 'unavailable'} — reporting all issues]`));
|
|
72
|
+
}
|
|
73
|
+
return issues;
|
|
74
|
+
}
|
|
75
|
+
if (getFormat() === 'console' && !program.opts().quiet) {
|
|
76
|
+
console.error(chalk.dim(` [--changed-only: base=${result.baseRef}, ${result.files.length} file(s)]`));
|
|
77
|
+
}
|
|
78
|
+
const set = new Set(result.files);
|
|
79
|
+
return issues.filter((issue) => {
|
|
80
|
+
if (!issue.locations || issue.locations.length === 0)
|
|
81
|
+
return false;
|
|
82
|
+
return issue.locations.some((loc) => set.has(loc.file));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
44
85
|
function setupLogLevel() {
|
|
45
86
|
const opts = program.opts();
|
|
46
87
|
if (opts.verbose)
|
|
@@ -74,14 +115,17 @@ function maybeCompactBanner() {
|
|
|
74
115
|
program
|
|
75
116
|
.command('analyze', { isDefault: true })
|
|
76
117
|
.description('Analyze repository and show project report')
|
|
77
|
-
.
|
|
118
|
+
.option('--changed-only', 'only report issues on files changed vs base ref')
|
|
119
|
+
.option('--base-ref <ref>', 'git base ref for --changed-only (default: origin/main)')
|
|
120
|
+
.action(async (cmdOpts) => {
|
|
78
121
|
setupLogLevel();
|
|
79
122
|
maybeBanner();
|
|
80
123
|
const rootPath = getRootPath();
|
|
81
124
|
const format = getFormat();
|
|
125
|
+
const config = await loadProjectConfig();
|
|
82
126
|
const spinner = format === 'console' ? ora('Scanning repository...').start() : null;
|
|
83
127
|
try {
|
|
84
|
-
const scan = await scanRepository(rootPath);
|
|
128
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
85
129
|
if (spinner)
|
|
86
130
|
spinner.text = 'Detecting languages...';
|
|
87
131
|
const languages = detectLanguages(scan.files);
|
|
@@ -93,7 +137,11 @@ program
|
|
|
93
137
|
const dependencies = await analyzeDependencies(rootPath);
|
|
94
138
|
if (spinner)
|
|
95
139
|
spinner.text = 'Checking for issues...';
|
|
96
|
-
|
|
140
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
141
|
+
issues = applyConfigToIssues(issues, config);
|
|
142
|
+
if (cmdOpts.changedOnly) {
|
|
143
|
+
issues = await filterIssuesByChangedFiles(issues, rootPath, cmdOpts.baseRef ?? config.baseRef);
|
|
144
|
+
}
|
|
97
145
|
if (spinner)
|
|
98
146
|
spinner.stop();
|
|
99
147
|
const report = {
|
|
@@ -113,6 +161,9 @@ program
|
|
|
113
161
|
case 'markdown':
|
|
114
162
|
reportAnalysisMarkdown(report);
|
|
115
163
|
break;
|
|
164
|
+
case 'sarif':
|
|
165
|
+
reportAnalysisSarif(issues, pkg.version);
|
|
166
|
+
break;
|
|
116
167
|
default:
|
|
117
168
|
reportAnalysis(report);
|
|
118
169
|
}
|
|
@@ -128,15 +179,22 @@ program
|
|
|
128
179
|
program
|
|
129
180
|
.command('doctor')
|
|
130
181
|
.description('Evaluate project health and detect issues')
|
|
131
|
-
.
|
|
182
|
+
.option('--changed-only', 'only report issues on files changed vs base ref')
|
|
183
|
+
.option('--base-ref <ref>', 'git base ref for --changed-only (default: origin/main)')
|
|
184
|
+
.action(async (cmdOpts) => {
|
|
132
185
|
setupLogLevel();
|
|
133
186
|
maybeCompactBanner();
|
|
134
187
|
const rootPath = getRootPath();
|
|
135
188
|
const format = getFormat();
|
|
189
|
+
const config = await loadProjectConfig();
|
|
136
190
|
const spinner = format === 'console' ? ora('Running health checks...').start() : null;
|
|
137
191
|
try {
|
|
138
|
-
const scan = await scanRepository(rootPath);
|
|
139
|
-
|
|
192
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
193
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
194
|
+
issues = applyConfigToIssues(issues, config);
|
|
195
|
+
if (cmdOpts.changedOnly) {
|
|
196
|
+
issues = await filterIssuesByChangedFiles(issues, rootPath, cmdOpts.baseRef ?? config.baseRef);
|
|
197
|
+
}
|
|
140
198
|
if (spinner)
|
|
141
199
|
spinner.stop();
|
|
142
200
|
switch (format) {
|
|
@@ -146,6 +204,9 @@ program
|
|
|
146
204
|
case 'markdown':
|
|
147
205
|
reportHealthMarkdown(issues);
|
|
148
206
|
break;
|
|
207
|
+
case 'sarif':
|
|
208
|
+
reportHealthSarif(issues, pkg.version);
|
|
209
|
+
break;
|
|
149
210
|
default:
|
|
150
211
|
reportHealth(issues, scan.scanDurationMs);
|
|
151
212
|
}
|
|
@@ -161,16 +222,24 @@ program
|
|
|
161
222
|
program
|
|
162
223
|
.command('ci')
|
|
163
224
|
.description('Run health check for CI pipelines (exits 1 if score below threshold)')
|
|
164
|
-
.option('--min-score <score>', 'minimum passing score (0-100)'
|
|
225
|
+
.option('--min-score <score>', 'minimum passing score (0-100)')
|
|
226
|
+
.option('--changed-only', 'gate only on issues in files changed vs base ref')
|
|
227
|
+
.option('--base-ref <ref>', 'git base ref for --changed-only (default: origin/main)')
|
|
165
228
|
.action(async (cmdOpts) => {
|
|
166
229
|
setupLogLevel();
|
|
167
230
|
maybeCompactBanner();
|
|
168
231
|
const rootPath = getRootPath();
|
|
169
232
|
const format = getFormat();
|
|
233
|
+
const config = await loadProjectConfig();
|
|
170
234
|
try {
|
|
171
|
-
const scan = await scanRepository(rootPath);
|
|
172
|
-
|
|
173
|
-
|
|
235
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
236
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
237
|
+
issues = applyConfigToIssues(issues, config);
|
|
238
|
+
if (cmdOpts.changedOnly) {
|
|
239
|
+
issues = await filterIssuesByChangedFiles(issues, rootPath, cmdOpts.baseRef ?? config.baseRef);
|
|
240
|
+
}
|
|
241
|
+
const rawThreshold = cmdOpts.minScore ?? config.minScore ?? 70;
|
|
242
|
+
const threshold = Math.max(0, Math.min(100, typeof rawThreshold === 'string' ? parseInt(rawThreshold, 10) || 70 : rawThreshold));
|
|
174
243
|
const { score } = calculateScore(issues);
|
|
175
244
|
switch (format) {
|
|
176
245
|
case 'json':
|
|
@@ -179,6 +248,9 @@ program
|
|
|
179
248
|
case 'markdown':
|
|
180
249
|
reportCiMarkdown(issues, threshold);
|
|
181
250
|
break;
|
|
251
|
+
case 'sarif':
|
|
252
|
+
reportCiSarif(issues, pkg.version);
|
|
253
|
+
break;
|
|
182
254
|
default:
|
|
183
255
|
reportCi(issues, threshold);
|
|
184
256
|
}
|
|
@@ -202,15 +274,24 @@ program
|
|
|
202
274
|
maybeCompactBanner();
|
|
203
275
|
const rootPath = getRootPath();
|
|
204
276
|
const format = getFormat();
|
|
277
|
+
const config = await loadProjectConfig();
|
|
205
278
|
try {
|
|
206
|
-
const scan = await scanRepository(rootPath);
|
|
207
|
-
|
|
279
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
280
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
281
|
+
issues = applyConfigToIssues(issues, config);
|
|
282
|
+
const hotspotReport = await analyzeHotspots(rootPath, scan.files, issues, { limit: 20 });
|
|
208
283
|
if (cmdOpts.saveBaseline) {
|
|
209
|
-
const filePath = await saveBaseline(rootPath, issues);
|
|
284
|
+
const filePath = await saveBaseline(rootPath, issues, hotspotReport);
|
|
210
285
|
const { score, grade } = calculateScore(issues);
|
|
211
286
|
console.log(chalk.green(`\n Baseline saved to ${filePath}`));
|
|
212
287
|
console.log(` Score: ${chalk.bold(`${grade} (${score}/100)`)}`);
|
|
213
|
-
console.log(` Issues: ${issues.length}
|
|
288
|
+
console.log(` Issues: ${issues.length}`);
|
|
289
|
+
if (hotspotReport.available) {
|
|
290
|
+
console.log(` Hotspots snapshotted: ${hotspotReport.hotspots.length}\n`);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
console.log('');
|
|
294
|
+
}
|
|
214
295
|
return;
|
|
215
296
|
}
|
|
216
297
|
let baseline;
|
|
@@ -222,7 +303,7 @@ program
|
|
|
222
303
|
console.error(` Run ${chalk.bold.cyan('projscan diff --save-baseline')} first to create one.\n`);
|
|
223
304
|
process.exit(1);
|
|
224
305
|
}
|
|
225
|
-
const diff = computeDiff(baseline, issues);
|
|
306
|
+
const diff = computeDiff(baseline, issues, hotspotReport);
|
|
226
307
|
switch (format) {
|
|
227
308
|
case 'json':
|
|
228
309
|
reportDiffJson(diff);
|
|
@@ -249,9 +330,11 @@ program
|
|
|
249
330
|
maybeCompactBanner();
|
|
250
331
|
const rootPath = getRootPath();
|
|
251
332
|
const spinner = ora('Detecting issues...').start();
|
|
333
|
+
const config = await loadProjectConfig();
|
|
252
334
|
try {
|
|
253
|
-
const scan = await scanRepository(rootPath);
|
|
254
|
-
|
|
335
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
336
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
337
|
+
issues = applyConfigToIssues(issues, config);
|
|
255
338
|
const fixes = getAllAvailableFixes(issues);
|
|
256
339
|
spinner.stop();
|
|
257
340
|
if (fixes.length === 0) {
|
|
@@ -299,6 +382,42 @@ program
|
|
|
299
382
|
process.exit(1);
|
|
300
383
|
}
|
|
301
384
|
});
|
|
385
|
+
// ── Command: file ─────────────────────────────────────────
|
|
386
|
+
program
|
|
387
|
+
.command('file <file>')
|
|
388
|
+
.description('Drill into a file — purpose, risk, ownership, related issues')
|
|
389
|
+
.action(async (filePath) => {
|
|
390
|
+
setupLogLevel();
|
|
391
|
+
maybeCompactBanner();
|
|
392
|
+
const rootPath = getRootPath();
|
|
393
|
+
const format = getFormat();
|
|
394
|
+
const spinner = format === 'console' ? ora('Inspecting file...').start() : null;
|
|
395
|
+
try {
|
|
396
|
+
const inspection = await inspectFile(rootPath, filePath);
|
|
397
|
+
if (spinner)
|
|
398
|
+
spinner.stop();
|
|
399
|
+
if (!inspection.exists) {
|
|
400
|
+
console.error(chalk.red(`\n ${inspection.reason ?? 'File unavailable'}: ${filePath}\n`));
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
switch (format) {
|
|
404
|
+
case 'json':
|
|
405
|
+
reportFileJson(inspection);
|
|
406
|
+
break;
|
|
407
|
+
case 'markdown':
|
|
408
|
+
reportFileMarkdown(inspection);
|
|
409
|
+
break;
|
|
410
|
+
default:
|
|
411
|
+
reportFileInspection(inspection);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (spinner)
|
|
416
|
+
spinner.fail('File inspection failed');
|
|
417
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
302
421
|
// ── Command: explain ──────────────────────────────────────
|
|
303
422
|
program
|
|
304
423
|
.command('explain <file>')
|
|
@@ -341,9 +460,10 @@ program
|
|
|
341
460
|
maybeCompactBanner();
|
|
342
461
|
const rootPath = getRootPath();
|
|
343
462
|
const format = getFormat();
|
|
463
|
+
const config = await loadProjectConfig();
|
|
344
464
|
const spinner = format === 'console' ? ora('Analyzing architecture...').start() : null;
|
|
345
465
|
try {
|
|
346
|
-
const scan = await scanRepository(rootPath);
|
|
466
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
347
467
|
const frameworks = await detectFrameworks(rootPath, scan.files);
|
|
348
468
|
const layers = buildArchitectureLayers(scan.files, frameworks.frameworks.map((f) => f.name));
|
|
349
469
|
if (spinner)
|
|
@@ -375,9 +495,10 @@ program
|
|
|
375
495
|
maybeCompactBanner();
|
|
376
496
|
const rootPath = getRootPath();
|
|
377
497
|
const format = getFormat();
|
|
498
|
+
const config = await loadProjectConfig();
|
|
378
499
|
const spinner = format === 'console' ? ora('Scanning...').start() : null;
|
|
379
500
|
try {
|
|
380
|
-
const scan = await scanRepository(rootPath);
|
|
501
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
381
502
|
if (spinner)
|
|
382
503
|
spinner.stop();
|
|
383
504
|
switch (format) {
|
|
@@ -434,6 +555,65 @@ program
|
|
|
434
555
|
process.exit(1);
|
|
435
556
|
}
|
|
436
557
|
});
|
|
558
|
+
// ── Command: hotspots ─────────────────────────────────────
|
|
559
|
+
program
|
|
560
|
+
.command('hotspots')
|
|
561
|
+
.description('Rank files by risk (git churn × complexity × open issues)')
|
|
562
|
+
.option('--limit <n>', 'number of hotspots to show')
|
|
563
|
+
.option('--since <when>', 'git history window (e.g. "6 months ago", "2024-01-01")')
|
|
564
|
+
.action(async (cmdOpts) => {
|
|
565
|
+
setupLogLevel();
|
|
566
|
+
maybeCompactBanner();
|
|
567
|
+
const rootPath = getRootPath();
|
|
568
|
+
const format = getFormat();
|
|
569
|
+
const config = await loadProjectConfig();
|
|
570
|
+
const spinner = format === 'console' ? ora('Analyzing hotspots...').start() : null;
|
|
571
|
+
try {
|
|
572
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
573
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
574
|
+
issues = applyConfigToIssues(issues, config);
|
|
575
|
+
const limitRaw = cmdOpts.limit ?? config.hotspots?.limit ?? 10;
|
|
576
|
+
const limit = Math.max(1, Math.min(100, typeof limitRaw === 'string' ? parseInt(limitRaw, 10) || 10 : limitRaw));
|
|
577
|
+
const since = cmdOpts.since ?? config.hotspots?.since ?? '12 months ago';
|
|
578
|
+
const report = await analyzeHotspots(rootPath, scan.files, issues, {
|
|
579
|
+
since,
|
|
580
|
+
limit,
|
|
581
|
+
});
|
|
582
|
+
if (spinner)
|
|
583
|
+
spinner.stop();
|
|
584
|
+
switch (format) {
|
|
585
|
+
case 'json':
|
|
586
|
+
reportHotspotsJson(report);
|
|
587
|
+
break;
|
|
588
|
+
case 'markdown':
|
|
589
|
+
reportHotspotsMarkdown(report);
|
|
590
|
+
break;
|
|
591
|
+
default:
|
|
592
|
+
reportHotspots(report);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (spinner)
|
|
597
|
+
spinner.fail('Hotspot analysis failed');
|
|
598
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
// ── Command: mcp ──────────────────────────────────────────
|
|
603
|
+
program
|
|
604
|
+
.command('mcp')
|
|
605
|
+
.description('Run projscan as an MCP server (stdio) for AI coding agents')
|
|
606
|
+
.action(async () => {
|
|
607
|
+
setLogLevel('quiet');
|
|
608
|
+
const rootPath = getRootPath();
|
|
609
|
+
try {
|
|
610
|
+
await runMcpServer(rootPath);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
437
617
|
// ── Command: badge ────────────────────────────────────────
|
|
438
618
|
program
|
|
439
619
|
.command('badge')
|
|
@@ -444,9 +624,11 @@ program
|
|
|
444
624
|
maybeCompactBanner();
|
|
445
625
|
const rootPath = getRootPath();
|
|
446
626
|
const spinner = ora('Calculating health score...').start();
|
|
627
|
+
const config = await loadProjectConfig();
|
|
447
628
|
try {
|
|
448
|
-
const scan = await scanRepository(rootPath);
|
|
449
|
-
|
|
629
|
+
const scan = await scanRepository(rootPath, { ignore: config.ignore });
|
|
630
|
+
let issues = await collectIssues(rootPath, scan.files);
|
|
631
|
+
issues = applyConfigToIssues(issues, config);
|
|
450
632
|
const { score, grade } = calculateScore(issues);
|
|
451
633
|
spinner.stop();
|
|
452
634
|
const gradeColor = grade === 'A' || grade === 'B' ? chalk.green : grade === 'C' ? chalk.yellow : chalk.red;
|
|
@@ -473,7 +655,7 @@ function analyzeFile(filePath, content) {
|
|
|
473
655
|
const lines = content.split('\n');
|
|
474
656
|
const imports = extractImports(content);
|
|
475
657
|
const exports = extractExports(content);
|
|
476
|
-
const purpose = inferPurpose(filePath,
|
|
658
|
+
const purpose = inferPurpose(filePath, exports);
|
|
477
659
|
const potentialIssues = detectFileIssues(content, lines.length);
|
|
478
660
|
return {
|
|
479
661
|
filePath: path.relative(process.cwd(), filePath),
|
|
@@ -484,143 +666,6 @@ function analyzeFile(filePath, content) {
|
|
|
484
666
|
lineCount: lines.length,
|
|
485
667
|
};
|
|
486
668
|
}
|
|
487
|
-
function extractImports(content) {
|
|
488
|
-
const imports = [];
|
|
489
|
-
const seen = new Set();
|
|
490
|
-
// ES import
|
|
491
|
-
const esImportRegex = /import\s+(?:(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?|\*\s+as\s+\w+)\s+from\s+)?['"]([^'"]+)['"]/gm;
|
|
492
|
-
let match;
|
|
493
|
-
while ((match = esImportRegex.exec(content)) !== null) {
|
|
494
|
-
const source = match[1];
|
|
495
|
-
if (!seen.has(source)) {
|
|
496
|
-
seen.add(source);
|
|
497
|
-
imports.push({
|
|
498
|
-
source,
|
|
499
|
-
specifiers: [],
|
|
500
|
-
isRelative: source.startsWith('.') || source.startsWith('/'),
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
// CommonJS require
|
|
505
|
-
const requireRegex = /(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/gm;
|
|
506
|
-
while ((match = requireRegex.exec(content)) !== null) {
|
|
507
|
-
const source = match[1];
|
|
508
|
-
if (!seen.has(source)) {
|
|
509
|
-
seen.add(source);
|
|
510
|
-
imports.push({
|
|
511
|
-
source,
|
|
512
|
-
specifiers: [],
|
|
513
|
-
isRelative: source.startsWith('.') || source.startsWith('/'),
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
return imports;
|
|
518
|
-
}
|
|
519
|
-
function extractExports(content) {
|
|
520
|
-
const exports = [];
|
|
521
|
-
// export function
|
|
522
|
-
const funcRegex = /^export\s+(?:async\s+)?function\s+(\w+)/gm;
|
|
523
|
-
let match;
|
|
524
|
-
while ((match = funcRegex.exec(content)) !== null) {
|
|
525
|
-
exports.push({ name: match[1], type: 'function' });
|
|
526
|
-
}
|
|
527
|
-
// export class
|
|
528
|
-
const classRegex = /^export\s+class\s+(\w+)/gm;
|
|
529
|
-
while ((match = classRegex.exec(content)) !== null) {
|
|
530
|
-
exports.push({ name: match[1], type: 'class' });
|
|
531
|
-
}
|
|
532
|
-
// export const/let/var
|
|
533
|
-
const varRegex = /^export\s+(?:const|let|var)\s+(\w+)/gm;
|
|
534
|
-
while ((match = varRegex.exec(content)) !== null) {
|
|
535
|
-
exports.push({ name: match[1], type: 'variable' });
|
|
536
|
-
}
|
|
537
|
-
// export interface
|
|
538
|
-
const interfaceRegex = /^export\s+interface\s+(\w+)/gm;
|
|
539
|
-
while ((match = interfaceRegex.exec(content)) !== null) {
|
|
540
|
-
exports.push({ name: match[1], type: 'interface' });
|
|
541
|
-
}
|
|
542
|
-
// export type
|
|
543
|
-
const typeRegex = /^export\s+type\s+(\w+)/gm;
|
|
544
|
-
while ((match = typeRegex.exec(content)) !== null) {
|
|
545
|
-
exports.push({ name: match[1], type: 'type' });
|
|
546
|
-
}
|
|
547
|
-
// export default
|
|
548
|
-
if (/^export\s+default/m.test(content)) {
|
|
549
|
-
exports.push({ name: 'default', type: 'default' });
|
|
550
|
-
}
|
|
551
|
-
return exports;
|
|
552
|
-
}
|
|
553
|
-
function inferPurpose(filePath, imports, exports) {
|
|
554
|
-
const name = path.basename(filePath, path.extname(filePath)).toLowerCase();
|
|
555
|
-
const dir = path.dirname(filePath).toLowerCase();
|
|
556
|
-
if (name.includes('test') || name.includes('spec'))
|
|
557
|
-
return 'Test file';
|
|
558
|
-
if (name.includes('config') || name.includes('rc'))
|
|
559
|
-
return 'Configuration file';
|
|
560
|
-
if (name === 'index')
|
|
561
|
-
return 'Module entry point / barrel file';
|
|
562
|
-
if (name === 'main' || name === 'app')
|
|
563
|
-
return 'Application entry point';
|
|
564
|
-
if (name.includes('route') || name.includes('router'))
|
|
565
|
-
return 'Route definitions';
|
|
566
|
-
if (name.includes('middleware'))
|
|
567
|
-
return 'Middleware handler';
|
|
568
|
-
if (name.includes('controller'))
|
|
569
|
-
return 'Request controller';
|
|
570
|
-
if (name.includes('service'))
|
|
571
|
-
return 'Service layer logic';
|
|
572
|
-
if (name.includes('model') || name.includes('schema'))
|
|
573
|
-
return 'Data model / schema definition';
|
|
574
|
-
if (name.includes('util') || name.includes('helper'))
|
|
575
|
-
return 'Utility functions';
|
|
576
|
-
if (name.includes('hook'))
|
|
577
|
-
return 'Custom hook';
|
|
578
|
-
if (name.includes('context') || name.includes('provider'))
|
|
579
|
-
return 'Context / state provider';
|
|
580
|
-
if (name.includes('type') || name.includes('interface'))
|
|
581
|
-
return 'Type definitions';
|
|
582
|
-
if (name.includes('constant') || name.includes('config'))
|
|
583
|
-
return 'Constants / configuration';
|
|
584
|
-
if (name.includes('migration'))
|
|
585
|
-
return 'Database migration';
|
|
586
|
-
if (name.includes('seed'))
|
|
587
|
-
return 'Database seed data';
|
|
588
|
-
if (name.includes('auth'))
|
|
589
|
-
return 'Authentication logic';
|
|
590
|
-
if (name.includes('api'))
|
|
591
|
-
return 'API endpoint handler';
|
|
592
|
-
if (dir.includes('component') || dir.includes('pages'))
|
|
593
|
-
return 'UI component';
|
|
594
|
-
if (dir.includes('service'))
|
|
595
|
-
return 'Service module';
|
|
596
|
-
if (dir.includes('model'))
|
|
597
|
-
return 'Data model';
|
|
598
|
-
if (dir.includes('util') || dir.includes('lib'))
|
|
599
|
-
return 'Library / utility module';
|
|
600
|
-
const exportTypes = exports.map((e) => e.type);
|
|
601
|
-
if (exportTypes.includes('class'))
|
|
602
|
-
return 'Class-based module';
|
|
603
|
-
if (exportTypes.filter((t) => t === 'function').length > 2)
|
|
604
|
-
return 'Function library';
|
|
605
|
-
return 'Source module';
|
|
606
|
-
}
|
|
607
|
-
function detectFileIssues(content, lineCount) {
|
|
608
|
-
const issues = [];
|
|
609
|
-
if (lineCount > 500)
|
|
610
|
-
issues.push(`Large file (${lineCount} lines) — consider splitting`);
|
|
611
|
-
if (lineCount > 1000)
|
|
612
|
-
issues.push('Very large file — strongly consider refactoring');
|
|
613
|
-
if (/console\.(log|warn|error|debug)\s*\(/.test(content)) {
|
|
614
|
-
issues.push('Contains console.log statements — consider using a proper logger');
|
|
615
|
-
}
|
|
616
|
-
if (/TODO|FIXME|HACK|XXX/i.test(content)) {
|
|
617
|
-
issues.push('Contains TODO/FIXME comments');
|
|
618
|
-
}
|
|
619
|
-
if (/any\b/.test(content) && /\.tsx?$/.test(content)) {
|
|
620
|
-
issues.push('Uses "any" type — consider using proper types');
|
|
621
|
-
}
|
|
622
|
-
return issues;
|
|
623
|
-
}
|
|
624
669
|
// ── Architecture Layer Detection ──────────────────────────
|
|
625
670
|
function buildArchitectureLayers(files, frameworkNames) {
|
|
626
671
|
const layers = [];
|