devkit-quality 0.1.0 → 0.1.1
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/CHANGELOG.md +93 -0
- package/README.md +38 -15
- package/dist/cli.js +71 -49
- package/dist/cliLogic.js +39 -4
- package/dist/discovery.js +175 -17
- package/dist/glob.js +5 -1
- package/dist/ignore.js +14 -5
- package/dist/moduleGraph.js +4 -1
- package/dist/reporters.js +180 -92
- package/dist/rules/deadCode.js +4 -4
- package/dist/rules/dependencies.js +126 -53
- package/dist/rules/duplication.js +24 -9
- package/dist/rules/errorHandling.js +3 -1
- package/dist/rules/hygiene.js +11 -2
- package/dist/rules/security.js +20 -6
- package/dist/scanner.js +90 -27
- package/dist/scoring.js +53 -15
- package/dist/suppressions.js +28 -4
- package/dist/terminal.js +151 -37
- package/package.json +11 -3
package/dist/reporters.js
CHANGED
|
@@ -28,56 +28,111 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
|
28
28
|
function severityRank(severity) {
|
|
29
29
|
return SEVERITY_RANK[severity] ?? 0;
|
|
30
30
|
}
|
|
31
|
+
const CONFIDENCE_RANK = { CERTAIN: 3, HIGH: 2, MEDIUM: 1, LOW: 0 };
|
|
32
|
+
function confidenceRank(confidence) {
|
|
33
|
+
return CONFIDENCE_RANK[confidence] ?? 0;
|
|
34
|
+
}
|
|
31
35
|
const MAX_DETAILED_FINDINGS = 6;
|
|
32
|
-
|
|
36
|
+
const SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
|
|
37
|
+
function plural(count, word) {
|
|
38
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
39
|
+
}
|
|
40
|
+
function displayPath(repository) {
|
|
41
|
+
const home = process.env.HOME;
|
|
42
|
+
return home && repository.startsWith(home) ? `~${repository.slice(home.length)}` : repository;
|
|
43
|
+
}
|
|
44
|
+
function renderHeader(summary, width) {
|
|
45
|
+
const title = `${terminal_1.color.bold(terminal_1.color.cyan('DevKit'))} ${terminal_1.color.dim(terminal_1.glyph.bullet)} ${terminal_1.color.bold('Repository Quality Scan')}`;
|
|
46
|
+
return (0, terminal_1.renderBox)([title, terminal_1.color.dim((0, terminal_1.truncateMiddle)(displayPath(summary.repository), width - 6))], width);
|
|
47
|
+
}
|
|
48
|
+
function renderScore(summary, width) {
|
|
33
49
|
const lines = [];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
lines.push('');
|
|
37
|
-
lines.push(` ${
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const categoryEntries = Object.entries(summary.categoryScores);
|
|
41
|
-
const nameWidth = Math.max(...categoryEntries.map(([category]) => humanizeCategory(category).length)) + 2;
|
|
42
|
-
for (const [category, score] of categoryEntries) {
|
|
43
|
-
const label = (0, terminal_1.pad)(humanizeCategory(category), nameWidth);
|
|
44
|
-
const scoreText = (0, terminal_1.padStart)(score.toFixed(1), 4);
|
|
45
|
-
lines.push(` ${terminal_1.color.dim(label)}${(0, terminal_1.scoreColor)(score, scoreText)}`);
|
|
50
|
+
const grade = (0, terminal_1.scoreColor)(summary.score, terminal_1.color.bold(` ${(0, terminal_1.scoreGrade)(summary.score)} `));
|
|
51
|
+
const scoreText = `${terminal_1.color.bold((0, terminal_1.scoreColor)(summary.score, summary.score.toFixed(1)))}${terminal_1.color.dim(' / 10')}`;
|
|
52
|
+
lines.push(` ${scoreText} ${terminal_1.color.dim('grade')} ${grade} ${(0, terminal_1.scoreColor)(summary.score, (0, terminal_1.scoreLabel)(summary.score))}`);
|
|
53
|
+
lines.push(` ${(0, terminal_1.renderScoreBar)(summary.score, Math.min(48, width - 4))}`);
|
|
54
|
+
if (summary.securityCapped) {
|
|
55
|
+
lines.push(` ${terminal_1.color.red(`${terminal_1.glyph.warn} Capped at ${scoring_1.SECURITY_CAP}`)} ${terminal_1.color.dim('— a high-confidence security finding outweighs code quality. Fix it first.')}`);
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
lines.push('');
|
|
51
|
-
lines.push(terminal_1.color.dim('─'.repeat(50)));
|
|
52
|
-
if (summary.findings.length === 0) {
|
|
53
|
-
lines.push('');
|
|
54
|
-
lines.push(terminal_1.color.green('No findings. The repository looks clean.'));
|
|
55
|
-
lines.push('');
|
|
56
|
-
return lines.join('\n');
|
|
57
|
+
if (summary.coverage && summary.coverage.analyzedFiles === 0) {
|
|
58
|
+
lines.push(` ${terminal_1.color.yellow(`${terminal_1.glyph.warn} No JavaScript/TypeScript files were analyzed, so this score means nothing.`)}`);
|
|
59
|
+
lines.push(` ${terminal_1.color.dim(' Check that you ran devkit from the project root, and your scan.include / .devkitignore settings.')}`);
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
function renderCoverage(summary, width) {
|
|
64
|
+
const coverage = summary.coverage;
|
|
65
|
+
if (!coverage)
|
|
66
|
+
return [];
|
|
67
|
+
const lines = ['', (0, terminal_1.sectionHeading)('Scan coverage', width, `${(0, terminal_1.formatDuration)(coverage.durationMs)} · via ${coverage.discoveryMethod === 'git' ? 'git (respects .gitignore)' : 'filesystem walk'}`)];
|
|
68
|
+
const stat = (value, label) => `${terminal_1.color.bold(String(value))} ${terminal_1.color.dim(label)}`;
|
|
69
|
+
lines.push(` ${stat(coverage.discoveredFiles, 'files found')} ${stat(coverage.analyzedFiles, 'analyzed as JS/TS')} ${stat(coverage.textFilesScanned, 'other files secret-scanned')}`);
|
|
70
|
+
const skipped = [];
|
|
71
|
+
if (coverage.generatedFilesSkipped > 0)
|
|
72
|
+
skipped.push(plural(coverage.generatedFilesSkipped, 'generated file'));
|
|
73
|
+
if (coverage.tooLargeFilesSkipped.length > 0)
|
|
74
|
+
skipped.push(`${plural(coverage.tooLargeFilesSkipped.length, 'oversized file')}`);
|
|
75
|
+
if (coverage.binaryFilesSkipped > 0)
|
|
76
|
+
skipped.push(plural(coverage.binaryFilesSkipped, 'binary file'));
|
|
77
|
+
lines.push(` ${terminal_1.color.dim('Lines of code:')} ${summary.metrics.sourceLOC.toLocaleString()} ${terminal_1.color.dim('source ·')} ${summary.metrics.testLOC.toLocaleString()} ${terminal_1.color.dim('test')}${skipped.length ? ` ${terminal_1.color.dim(`Skipped: ${skipped.join(', ')}`)}` : ''}`);
|
|
78
|
+
const languages = Object.entries(coverage.languages)
|
|
79
|
+
.sort((a, b) => b[1] - a[1])
|
|
80
|
+
.slice(0, 8)
|
|
81
|
+
.map(([extension, count]) => `${extension} ${terminal_1.color.dim(String(count))}`)
|
|
82
|
+
.join(terminal_1.color.dim(' · '));
|
|
83
|
+
if (languages)
|
|
84
|
+
lines.push(` ${terminal_1.color.dim('Files:')} ${languages}`);
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
function renderCategories(summary, width) {
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
const worst = new Map();
|
|
59
90
|
for (const finding of summary.findings) {
|
|
60
|
-
|
|
91
|
+
counts.set(finding.category, (counts.get(finding.category) ?? 0) + 1);
|
|
92
|
+
const previous = worst.get(finding.category);
|
|
93
|
+
if (!previous || severityRank(finding.severity) > severityRank(previous))
|
|
94
|
+
worst.set(finding.category, finding.severity);
|
|
61
95
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
lines
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
const categories = Object.keys(CATEGORY_LABELS).filter((category) => category in summary.categoryScores);
|
|
97
|
+
if (!categories.includes('security'))
|
|
98
|
+
categories.push('security');
|
|
99
|
+
const nameWidth = Math.max(...categories.map((category) => humanizeCategory(category).length)) + 2;
|
|
100
|
+
const barWidth = Math.max(10, Math.min(30, width - nameWidth - 30));
|
|
101
|
+
const lines = ['', (0, terminal_1.sectionHeading)('Categories', width)];
|
|
102
|
+
for (const category of categories) {
|
|
103
|
+
const score = category === 'security' ? summary.securityScore : summary.categoryScores[category];
|
|
104
|
+
const count = counts.get(category) ?? 0;
|
|
105
|
+
const countText = count === 0 ? terminal_1.color.green(`${terminal_1.glyph.check} clean`) : (0, terminal_1.severityColor)(worst.get(category) ?? 'LOW', plural(count, 'finding'));
|
|
106
|
+
lines.push(` ${(0, terminal_1.pad)(humanizeCategory(category), nameWidth)}${(0, terminal_1.scoreColor)(score, (0, terminal_1.padStart)(score.toFixed(1), 4))} ${(0, terminal_1.renderScoreBar)(score, barWidth)} ${countText}`);
|
|
73
107
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const [category, categoryFindings] of [...findingsByCategory.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
77
|
-
const worst = categoryFindings.reduce((max, finding) => (severityRank(finding.severity) > severityRank(max.severity) ? finding : max));
|
|
78
|
-
lines.push(` ${(0, terminal_1.pad)(humanizeCategory(category), categoryCountWidth)} ${terminal_1.color.dim('›')} ${(0, terminal_1.severityColor)(worst.severity, `${categoryFindings.length} finding${categoryFindings.length === 1 ? '' : 's'}`)}`);
|
|
108
|
+
if (!('architecture' in summary.categoryScores)) {
|
|
109
|
+
lines.push(` ${(0, terminal_1.pad)(humanizeCategory('architecture'), nameWidth)}${terminal_1.color.dim(' n/a')} ${terminal_1.color.dim('not scored — add "architecture.layers" to .devkitrc.json')}`);
|
|
79
110
|
}
|
|
80
|
-
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
function renderSeverity(summary, width) {
|
|
114
|
+
const bySeverity = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
|
|
115
|
+
for (const finding of summary.findings)
|
|
116
|
+
bySeverity[finding.severity] += 1;
|
|
117
|
+
const legend = SEVERITIES.filter((severity) => bySeverity[severity] > 0)
|
|
118
|
+
.map((severity) => `${(0, terminal_1.severityColor)(severity, terminal_1.glyph.dot)} ${bySeverity[severity]} ${severity.toLowerCase()}`)
|
|
119
|
+
.join(' ');
|
|
120
|
+
const bar = (0, terminal_1.renderStackedBar)(SEVERITIES.map((severity) => ({ value: bySeverity[severity], paint: (text) => (0, terminal_1.severityColor)(severity, text) })), Math.min(60, width - 4));
|
|
121
|
+
return ['', (0, terminal_1.sectionHeading)('Findings', width, `${summary.findings.length} total`), ` ${bar}`, ` ${legend}`];
|
|
122
|
+
}
|
|
123
|
+
function renderHotspots(summary, width) {
|
|
124
|
+
const hotspots = (0, scoring_1.summarizeHotspots)(summary.findings);
|
|
125
|
+
if (hotspots.length === 0)
|
|
126
|
+
return [];
|
|
127
|
+
const fileWidth = Math.min(Math.max(...hotspots.map((hotspot) => hotspot.file.length)) + 2, width - 30);
|
|
128
|
+
const lines = ['', (0, terminal_1.sectionHeading)('Hotspots', width, 'files that cost the most points')];
|
|
129
|
+
for (const hotspot of hotspots) {
|
|
130
|
+
lines.push(` ${(0, terminal_1.severityColor)(hotspot.worst, terminal_1.glyph.dot)} ${terminal_1.color.cyan((0, terminal_1.pad)((0, terminal_1.truncateMiddle)(hotspot.file, fileWidth - 2), fileWidth))}${(0, terminal_1.padStart)(plural(hotspot.count, 'finding'), 12)} ${terminal_1.color.dim(`worst: ${hotspot.worst.toLowerCase()}`)}`);
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
function renderTopIssues(summary, width) {
|
|
135
|
+
const severityRankedFindings = [...summary.findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || confidenceRank(b.confidence) - confidenceRank(a.confidence));
|
|
81
136
|
// One representative per category (its worst finding) so the detail section reads as a
|
|
82
137
|
// scannable cross-section of the repository rather than six variations of the same rule.
|
|
83
138
|
const representativeByCategory = new Map();
|
|
@@ -87,34 +142,49 @@ function formatTerminal(summary) {
|
|
|
87
142
|
}
|
|
88
143
|
}
|
|
89
144
|
const topFindings = [...representativeByCategory.values()].slice(0, MAX_DETAILED_FINDINGS);
|
|
90
|
-
if (topFindings.length
|
|
91
|
-
|
|
92
|
-
|
|
145
|
+
if (topFindings.length === 0)
|
|
146
|
+
return [];
|
|
147
|
+
const lines = ['', (0, terminal_1.sectionHeading)('Top issues', width, 'worst finding per category')];
|
|
148
|
+
for (const finding of topFindings) {
|
|
149
|
+
const rail = (0, terminal_1.severityColor)(finding.severity, terminal_1.glyph.rail);
|
|
93
150
|
lines.push('');
|
|
94
|
-
lines.push(terminal_1.color.bold(
|
|
151
|
+
lines.push(` ${(0, terminal_1.severityBadge)(finding.severity)} ${terminal_1.color.bold(finding.message)} ${terminal_1.color.dim(`${humanizeCategory(finding.category)} · ${finding.ruleId}`)}`);
|
|
152
|
+
lines.push(` ${rail} ${finding.description}`);
|
|
153
|
+
lines.push(` ${rail} ${terminal_1.color.green(terminal_1.glyph.arrow)} ${finding.suggestion}`);
|
|
154
|
+
lines.push(` ${rail} ${terminal_1.color.cyan(`${finding.file}:${finding.line}`)}${finding.confidence === 'LOW' ? terminal_1.color.dim(' (low confidence)') : ''}`);
|
|
155
|
+
lines.push(...(0, terminal_1.renderCodeFrame)(summary.repository, finding.file, finding.line, finding.column, finding.severity));
|
|
95
156
|
}
|
|
96
|
-
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
function formatTerminal(summary) {
|
|
160
|
+
const width = (0, terminal_1.terminalWidth)();
|
|
161
|
+
const lines = [];
|
|
162
|
+
lines.push(...renderHeader(summary, width));
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push(...renderScore(summary, width));
|
|
165
|
+
lines.push(...renderCoverage(summary, width));
|
|
166
|
+
lines.push(...renderCategories(summary, width));
|
|
167
|
+
if (summary.findings.length === 0) {
|
|
97
168
|
lines.push('');
|
|
98
|
-
lines.push(` ${terminal_1.color.
|
|
99
|
-
lines.push(` ${finding.description}`);
|
|
100
|
-
lines.push(` ${terminal_1.color.dim('→')} ${finding.suggestion}`);
|
|
101
|
-
lines.push(` ${terminal_1.color.cyan(`${finding.file}:${finding.line}`)}`);
|
|
169
|
+
lines.push(` ${terminal_1.color.green(`${terminal_1.glyph.check} No findings.`)} ${terminal_1.color.dim('The repository looks clean.')}`);
|
|
102
170
|
lines.push('');
|
|
103
|
-
|
|
104
|
-
lines.push(frameLine);
|
|
105
|
-
}
|
|
171
|
+
return lines.join('\n');
|
|
106
172
|
}
|
|
107
|
-
|
|
108
|
-
lines.push(
|
|
109
|
-
lines.push(
|
|
173
|
+
lines.push(...renderSeverity(summary, width));
|
|
174
|
+
lines.push(...renderHotspots(summary, width));
|
|
175
|
+
lines.push(...renderTopIssues(summary, width));
|
|
176
|
+
const shown = Math.min(MAX_DETAILED_FINDINGS, new Set(summary.findings.map((finding) => finding.category)).size);
|
|
177
|
+
const remaining = summary.findings.length - shown;
|
|
110
178
|
lines.push('');
|
|
179
|
+
lines.push((0, terminal_1.sectionHeading)('Next steps', width));
|
|
111
180
|
if (remaining > 0) {
|
|
112
|
-
lines.push(terminal_1.color.dim(`
|
|
181
|
+
lines.push(terminal_1.color.dim(` ${plural(remaining, 'more finding')} not shown above.`));
|
|
113
182
|
}
|
|
114
|
-
|
|
115
|
-
lines.push(
|
|
116
|
-
lines.push(
|
|
117
|
-
lines.push(
|
|
183
|
+
const hint = (command, text) => ` ${terminal_1.color.cyan((0, terminal_1.pad)(command, 32))}${terminal_1.color.dim(text)}`;
|
|
184
|
+
lines.push(hint('devkit scan --category <name>', 'focus on one category (e.g. security, dead-code)'));
|
|
185
|
+
lines.push(hint('devkit scan --format markdown', 'full report with every finding'));
|
|
186
|
+
lines.push(hint('devkit explain <RULE_ID>', 'why a rule exists and how to fix it'));
|
|
187
|
+
lines.push(hint('devkit fix', 'preview findings marked safe to fix'));
|
|
118
188
|
lines.push('');
|
|
119
189
|
return lines.join('\n');
|
|
120
190
|
}
|
|
@@ -126,8 +196,12 @@ function formatMarkdown(summary) {
|
|
|
126
196
|
lines.push('# DevKit Scan Report');
|
|
127
197
|
lines.push('');
|
|
128
198
|
lines.push(`- Repository: ${summary.repository}`);
|
|
129
|
-
lines.push(`- Overall score: ${summary.score.toFixed(1)} / 10`);
|
|
130
|
-
lines.push(`- Security score
|
|
199
|
+
lines.push(`- Overall score: ${summary.score.toFixed(1)} / 10 (grade ${(0, terminal_1.scoreGrade)(summary.score)}, ${(0, terminal_1.scoreLabel)(summary.score)})`);
|
|
200
|
+
lines.push(`- Security score: ${summary.securityScore.toFixed(1)} / 10${summary.securityCapped ? ` — overall score capped at ${scoring_1.SECURITY_CAP} by a high-confidence security finding` : ''}`);
|
|
201
|
+
if (summary.coverage) {
|
|
202
|
+
const coverage = summary.coverage;
|
|
203
|
+
lines.push(`- Coverage: ${coverage.discoveredFiles} files found, ${coverage.analyzedFiles} analyzed as JS/TS, ${coverage.textFilesScanned} other files secret-scanned, ${coverage.generatedFilesSkipped} generated skipped (${(0, terminal_1.formatDuration)(coverage.durationMs)})`);
|
|
204
|
+
}
|
|
131
205
|
lines.push('');
|
|
132
206
|
lines.push('## Category scores');
|
|
133
207
|
lines.push('');
|
|
@@ -136,6 +210,9 @@ function formatMarkdown(summary) {
|
|
|
136
210
|
for (const [category, score] of Object.entries(summary.categoryScores)) {
|
|
137
211
|
lines.push(`| ${humanizeCategory(category)} | ${score.toFixed(1)} |`);
|
|
138
212
|
}
|
|
213
|
+
if (!('architecture' in summary.categoryScores)) {
|
|
214
|
+
lines.push('| Architecture | n/a (no layers configured) |');
|
|
215
|
+
}
|
|
139
216
|
lines.push('');
|
|
140
217
|
lines.push('## Main deductions');
|
|
141
218
|
lines.push('');
|
|
@@ -212,47 +289,58 @@ function formatSarif(summary) {
|
|
|
212
289
|
}
|
|
213
290
|
function formatMetrics(summary) {
|
|
214
291
|
const metrics = summary.metrics;
|
|
215
|
-
const
|
|
216
|
-
lines.
|
|
217
|
-
|
|
218
|
-
lines.push('');
|
|
219
|
-
lines.push(
|
|
220
|
-
lines.push(
|
|
221
|
-
lines.push(
|
|
222
|
-
lines.push(
|
|
223
|
-
lines.push(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
lines.push(
|
|
229
|
-
lines.push(`
|
|
230
|
-
lines.push(
|
|
231
|
-
|
|
232
|
-
lines.push(`
|
|
292
|
+
const width = (0, terminal_1.terminalWidth)();
|
|
293
|
+
const lines = [...(0, terminal_1.renderBox)([terminal_1.color.bold('Repository metrics'), terminal_1.color.dim((0, terminal_1.truncateMiddle)(displayPath(summary.repository), width - 6))], width)];
|
|
294
|
+
const row = (key, value, note = '') => ` ${terminal_1.color.dim((0, terminal_1.pad)(key, 24))}${(0, terminal_1.pad)(value, 12)}${terminal_1.color.dim(note)}`;
|
|
295
|
+
lines.push('', (0, terminal_1.sectionHeading)('Size', width));
|
|
296
|
+
lines.push(row('totalFiles', terminal_1.color.bold(String(metrics.totalFiles)), `${metrics.sourceFiles} source · ${metrics.testFiles} test`));
|
|
297
|
+
lines.push(row('sourceLOC', metrics.sourceLOC.toLocaleString()));
|
|
298
|
+
lines.push(row('testLOC', metrics.testLOC.toLocaleString()));
|
|
299
|
+
lines.push(row('commentLOC', metrics.commentLOC.toLocaleString()));
|
|
300
|
+
lines.push(row('dependencyCount', String(metrics.dependencyCount)));
|
|
301
|
+
if (summary.coverage) {
|
|
302
|
+
lines.push(row('otherFilesSecretScanned', String(summary.coverage.textFilesScanned)));
|
|
303
|
+
lines.push(row('generatedFilesSkipped', String(summary.coverage.generatedFilesSkipped)));
|
|
304
|
+
}
|
|
305
|
+
lines.push('', (0, terminal_1.sectionHeading)('Structure', width));
|
|
306
|
+
lines.push(row('functionCount', String(metrics.functionCount), `avg ${metrics.averageFunctionSize.toFixed(1)} lines`));
|
|
307
|
+
lines.push(row('classCount', String(metrics.classCount)));
|
|
308
|
+
const distribution = metrics.complexityDistribution;
|
|
309
|
+
lines.push(` ${terminal_1.color.dim((0, terminal_1.pad)('complexity', 24))}${(0, terminal_1.renderStackedBar)([
|
|
310
|
+
{ value: distribution.low, paint: terminal_1.color.green },
|
|
311
|
+
{ value: distribution.medium, paint: terminal_1.color.yellow },
|
|
312
|
+
{ value: distribution.high, paint: terminal_1.color.red }
|
|
313
|
+
], 30)} ${terminal_1.color.green(`${distribution.low} low`)} ${terminal_1.color.dim('·')} ${terminal_1.color.yellow(`${distribution.medium} medium`)} ${terminal_1.color.dim('·')} ${terminal_1.color.red(`${distribution.high} high`)}`);
|
|
314
|
+
lines.push('', (0, terminal_1.sectionHeading)('Health', width));
|
|
315
|
+
const percent = (value, invert = false) => {
|
|
316
|
+
const score = invert ? 10 - value / 10 : value / 10;
|
|
317
|
+
return (0, terminal_1.scoreColor)(score, `${value.toFixed(1)}%`);
|
|
318
|
+
};
|
|
319
|
+
lines.push(row('duplicationPercentage', percent(metrics.duplicationPercentage, true)));
|
|
320
|
+
lines.push(row('deadCodePercentage', percent(metrics.deadCodePercentage, true)));
|
|
321
|
+
lines.push(row('testToSourceRatio', metrics.testToSourceRatio.toFixed(2), metrics.testToSourceRatio < 0.3 ? 'low test coverage by volume' : ''));
|
|
233
322
|
if (metrics.largestFunctions.length > 0) {
|
|
234
|
-
lines.push('');
|
|
235
|
-
lines.push(terminal_1.color.bold('Largest functions'));
|
|
323
|
+
lines.push('', (0, terminal_1.sectionHeading)('Largest functions', width));
|
|
236
324
|
for (const fn of metrics.largestFunctions) {
|
|
237
|
-
lines.push(` ${terminal_1.
|
|
325
|
+
lines.push(` ${(0, terminal_1.padStart)(String(fn.size), 5)} ${terminal_1.color.dim('lines')} ${terminal_1.color.cyan(fn.name)} ${terminal_1.color.dim(fn.file)}`);
|
|
238
326
|
}
|
|
239
327
|
}
|
|
240
328
|
if (metrics.largestFiles.length > 0) {
|
|
241
|
-
lines.push('');
|
|
242
|
-
lines.push(terminal_1.color.bold('Largest files'));
|
|
329
|
+
lines.push('', (0, terminal_1.sectionHeading)('Largest files', width));
|
|
243
330
|
for (const file of metrics.largestFiles) {
|
|
244
|
-
lines.push(` ${terminal_1.
|
|
331
|
+
lines.push(` ${(0, terminal_1.padStart)(String(file.loc), 5)} ${terminal_1.color.dim('lines')} ${terminal_1.color.cyan(file.file)}`);
|
|
245
332
|
}
|
|
246
333
|
}
|
|
247
334
|
return lines.join('\n');
|
|
248
335
|
}
|
|
249
336
|
function formatBaselineCompare(previousScore, currentScore) {
|
|
250
337
|
const delta = Number((currentScore - previousScore).toFixed(1));
|
|
338
|
+
const deltaText = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}`;
|
|
339
|
+
const verdict = delta < 0 ? terminal_1.color.red(`${terminal_1.glyph.warn} regression ${deltaText}`) : delta > 0 ? terminal_1.color.green(`${terminal_1.glyph.check} improved ${deltaText}`) : terminal_1.color.dim(`no change ${deltaText}`);
|
|
251
340
|
const lines = [];
|
|
252
341
|
lines.push(terminal_1.color.bold('Baseline compare'));
|
|
253
|
-
lines.push(` Previous
|
|
254
|
-
lines.push(` Current
|
|
255
|
-
|
|
256
|
-
lines.push(` Regression: ${delta < 0 ? terminal_1.color.red(deltaText) : terminal_1.color.green(deltaText)}`);
|
|
342
|
+
lines.push(` ${terminal_1.color.dim('Previous')} ${(0, terminal_1.renderScoreBar)(previousScore, 24)} ${previousScore.toFixed(1)}`);
|
|
343
|
+
lines.push(` ${terminal_1.color.dim('Current ')} ${(0, terminal_1.renderScoreBar)(currentScore, 24)} ${currentScore.toFixed(1)}`);
|
|
344
|
+
lines.push(` ${verdict}`);
|
|
257
345
|
return lines.join('\n');
|
|
258
346
|
}
|
package/dist/rules/deadCode.js
CHANGED
|
@@ -128,12 +128,12 @@ function detectUnusedExports(context) {
|
|
|
128
128
|
continue;
|
|
129
129
|
if (moduleGraph.entryPoints.has(file))
|
|
130
130
|
continue;
|
|
131
|
-
|
|
131
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
132
|
+
if ((0, discovery_1.isTestFile)(relativePath))
|
|
132
133
|
continue;
|
|
133
134
|
if (fullyUsedFiles.has(file))
|
|
134
135
|
continue;
|
|
135
136
|
const used = usedExportsByFile.get(file) ?? new Set();
|
|
136
|
-
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
137
137
|
const dynamicHint = hasDynamicRequireHint(context, relativePath);
|
|
138
138
|
for (const exportInfo of exports) {
|
|
139
139
|
if (used.has(exportInfo.name)) {
|
|
@@ -164,14 +164,14 @@ function detectUnusedFiles(context) {
|
|
|
164
164
|
const findings = [];
|
|
165
165
|
const { moduleGraph } = context;
|
|
166
166
|
for (const file of context.files) {
|
|
167
|
-
|
|
167
|
+
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
168
|
+
if ((0, discovery_1.isTestFile)(relativePath))
|
|
168
169
|
continue;
|
|
169
170
|
if (moduleGraph.entryPoints.has(file))
|
|
170
171
|
continue;
|
|
171
172
|
const incoming = moduleGraph.reverseEdges.get(file);
|
|
172
173
|
if (incoming && incoming.size > 0)
|
|
173
174
|
continue;
|
|
174
|
-
const relativePath = node_path_1.default.relative(context.projectRoot, file).replace(/\\/g, '/');
|
|
175
175
|
const dynamicHint = hasDynamicRequireHint(context, relativePath);
|
|
176
176
|
findings.push((0, finding_1.buildFinding)({
|
|
177
177
|
ruleId: 'DEAD010',
|
|
@@ -44,68 +44,141 @@ function getDeclaredDependencyNames(packageJson) {
|
|
|
44
44
|
}
|
|
45
45
|
return names;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Every package.json in the project (root plus any workspace members), so dependency checks run
|
|
49
|
+
* against the package that actually declares them instead of only the root. Parse failures are
|
|
50
|
+
* skipped rather than treated as "declares nothing", which would flood a workspace with false
|
|
51
|
+
* unlisted-dependency findings.
|
|
52
|
+
*/
|
|
53
|
+
function discoverWorkspacePackages(context) {
|
|
54
|
+
const packages = [];
|
|
55
|
+
const seenRoots = new Set();
|
|
56
|
+
const addPackage = (absolutePath, json, text) => {
|
|
57
|
+
const root = node_path_1.default.dirname(absolutePath);
|
|
58
|
+
if (seenRoots.has(root))
|
|
59
|
+
return;
|
|
60
|
+
seenRoots.add(root);
|
|
61
|
+
const scripts = json?.scripts ?? {};
|
|
62
|
+
packages.push({
|
|
63
|
+
root,
|
|
64
|
+
relativePath: node_path_1.default.relative(context.projectRoot, absolutePath).replace(/\\/g, '/') || 'package.json',
|
|
65
|
+
text,
|
|
66
|
+
declared: getDeclaredDependencyNames(json),
|
|
67
|
+
scriptText: Object.values(scripts).join(' ')
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const rootPackageJsonPath = node_path_1.default.join(context.projectRoot, 'package.json');
|
|
71
|
+
addPackage(rootPackageJsonPath, context.packageJson, (0, discovery_1.readFileSafe)(rootPackageJsonPath));
|
|
72
|
+
for (const file of context.allFiles) {
|
|
73
|
+
if (node_path_1.default.basename(file) !== 'package.json' || node_path_1.default.resolve(file) === node_path_1.default.resolve(rootPackageJsonPath))
|
|
74
|
+
continue;
|
|
75
|
+
const text = (0, discovery_1.readFileSafe)(file);
|
|
76
|
+
try {
|
|
77
|
+
addPackage(file, JSON.parse(text), text);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Malformed package.json: skip it rather than treating it as declaring nothing.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return packages.sort((a, b) => b.root.length - a.root.length);
|
|
84
|
+
}
|
|
85
|
+
/** The package whose directory most closely contains `absoluteFile` (longest matching root). */
|
|
86
|
+
function nearestPackage(absoluteFile, packagesByRootLengthDesc) {
|
|
87
|
+
const fileDir = node_path_1.default.dirname(absoluteFile);
|
|
88
|
+
for (const pkg of packagesByRootLengthDesc) {
|
|
89
|
+
const relative = node_path_1.default.relative(pkg.root, fileDir);
|
|
90
|
+
if (relative === '' || (!relative.startsWith('..') && !node_path_1.default.isAbsolute(relative))) {
|
|
91
|
+
return pkg;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return packagesByRootLengthDesc[packagesByRootLengthDesc.length - 1];
|
|
95
|
+
}
|
|
47
96
|
function runDependencyRules(context) {
|
|
48
97
|
const findings = [];
|
|
49
|
-
const { moduleGraph,
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
98
|
+
const { moduleGraph, projectRoot } = context;
|
|
99
|
+
const packages = discoverWorkspacePackages(context);
|
|
100
|
+
const isWorkspace = packages.length > 1;
|
|
101
|
+
const root = packages.reduce((longestRootPkg, pkg) => (pkg.root.length < longestRootPkg.root.length ? pkg : longestRootPkg));
|
|
102
|
+
// Usage grouped by the nearest package.json, plus a project-wide union: a dependency hoisted to
|
|
103
|
+
// the workspace root can legitimately be used by any member package without being redeclared there.
|
|
104
|
+
const usedByPackageRoot = new Map();
|
|
105
|
+
const usedAnywhere = new Set();
|
|
106
|
+
for (const [file, imports] of moduleGraph.importsByFile) {
|
|
107
|
+
const pkg = nearestPackage(file, packages);
|
|
53
108
|
for (const importInfo of imports) {
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
109
|
+
if (importInfo.isRelative || importInfo.resolved)
|
|
110
|
+
continue;
|
|
111
|
+
const name = packageNameFromSpecifier(importInfo.specifier);
|
|
112
|
+
usedAnywhere.add(name);
|
|
113
|
+
if (!usedByPackageRoot.has(pkg.root))
|
|
114
|
+
usedByPackageRoot.set(pkg.root, new Set());
|
|
115
|
+
usedByPackageRoot.get(pkg.root).add(name);
|
|
57
116
|
}
|
|
58
117
|
}
|
|
59
|
-
const scripts = packageJson?.scripts ?? {};
|
|
60
|
-
const scriptText = Object.values(scripts).join(' ');
|
|
61
|
-
const declaredDependencies = getDeclaredDependencyNames(packageJson);
|
|
62
118
|
if ((0, config_1.isRuleEnabled)(context.config, 'DEP001')) {
|
|
63
|
-
for (const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
for (const pkg of packages) {
|
|
120
|
+
// The root's dependencies may be consumed by any workspace member; a nested package's
|
|
121
|
+
// own dependencies are expected to be used within that package.
|
|
122
|
+
const used = pkg === root ? usedAnywhere : (usedByPackageRoot.get(pkg.root) ?? new Set());
|
|
123
|
+
const scriptText = isWorkspace && pkg !== root ? `${pkg.scriptText} ${root.scriptText}` : pkg.scriptText;
|
|
124
|
+
for (const dependencyName of pkg.declared) {
|
|
125
|
+
if (used.has(dependencyName))
|
|
126
|
+
continue;
|
|
127
|
+
// @types/* packages provide ambient global declarations (process, Buffer, JSX, ...)
|
|
128
|
+
// that are never referenced through an import specifier, so they are exempt by design.
|
|
129
|
+
if (dependencyName.startsWith('@types/'))
|
|
130
|
+
continue;
|
|
131
|
+
const wordBoundary = new RegExp(`(^|[^A-Za-z0-9_@/.-])${dependencyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_@/.-]|$)`);
|
|
132
|
+
if (wordBoundary.test(scriptText))
|
|
133
|
+
continue;
|
|
134
|
+
findings.push((0, finding_1.buildFinding)({
|
|
135
|
+
ruleId: 'DEP001',
|
|
136
|
+
category: 'dependencies',
|
|
137
|
+
severity: 'MEDIUM',
|
|
138
|
+
confidence: 'CERTAIN',
|
|
139
|
+
file: pkg.relativePath,
|
|
140
|
+
line: findDependencyLine(pkg.text, dependencyName),
|
|
141
|
+
column: 1,
|
|
142
|
+
message: `Unused dependency: ${dependencyName}`,
|
|
143
|
+
description: `"${dependencyName}" is declared in ${pkg.relativePath} but not referenced by source files, require/import calls, or package.json scripts.`,
|
|
144
|
+
evidence: dependencyName,
|
|
145
|
+
suggestion: 'Remove the unused dependency or confirm it is required by a toolchain entry point.',
|
|
146
|
+
fixAvailable: true
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
87
149
|
}
|
|
88
150
|
}
|
|
89
151
|
if ((0, config_1.isRuleEnabled)(context.config, 'DEP002')) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
152
|
+
const reported = new Set();
|
|
153
|
+
for (const [file, imports] of moduleGraph.importsByFile) {
|
|
154
|
+
const pkg = nearestPackage(file, packages);
|
|
155
|
+
// Tolerate hoisting: a nested package may use a dependency declared only at the workspace root.
|
|
156
|
+
const declared = pkg === root ? pkg.declared : new Set([...pkg.declared, ...root.declared]);
|
|
157
|
+
for (const importInfo of imports) {
|
|
158
|
+
if (importInfo.isRelative || importInfo.resolved)
|
|
159
|
+
continue;
|
|
160
|
+
const usedName = packageNameFromSpecifier(importInfo.specifier);
|
|
161
|
+
if (BUILTIN_MODULES.has(usedName) || declared.has(usedName))
|
|
162
|
+
continue;
|
|
163
|
+
const key = `${pkg.root}::${usedName}`;
|
|
164
|
+
if (reported.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
reported.add(key);
|
|
167
|
+
findings.push((0, finding_1.buildFinding)({
|
|
168
|
+
ruleId: 'DEP002',
|
|
169
|
+
category: 'dependencies',
|
|
170
|
+
severity: 'HIGH',
|
|
171
|
+
confidence: 'HIGH',
|
|
172
|
+
file: pkg.relativePath,
|
|
173
|
+
line: 1,
|
|
174
|
+
column: 1,
|
|
175
|
+
message: `Unlisted dependency: ${usedName}`,
|
|
176
|
+
description: `"${usedName}" is imported from source under ${node_path_1.default.relative(projectRoot, pkg.root).replace(/\\/g, '/') || '.'} but is not declared in ${pkg.relativePath}${isWorkspace ? ' or the workspace root' : ''}.`,
|
|
177
|
+
evidence: usedName,
|
|
178
|
+
suggestion: 'Add the package to the appropriate package.json dependencies, or remove the import if it was unintentional.',
|
|
179
|
+
fixAvailable: false
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
109
182
|
}
|
|
110
183
|
}
|
|
111
184
|
if ((0, config_1.isRuleEnabled)(context.config, 'DEP003')) {
|