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/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
- function formatTerminal(summary) {
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
- lines.push(terminal_1.color.bold('DevKit Repository Scan'));
35
- lines.push(terminal_1.color.dim(summary.repository));
36
- lines.push('');
37
- lines.push(` ${terminal_1.color.bold(summary.score.toFixed(1))}${terminal_1.color.dim('/10')} ${(0, terminal_1.scoreColor)(summary.score, (0, terminal_1.scoreLabel)(summary.score))}`);
38
- lines.push(` ${(0, terminal_1.renderScoreBar)(summary.score)}`);
39
- lines.push('');
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
- const securityLabel = (0, terminal_1.pad)('Security', nameWidth);
48
- const securityScoreText = (0, terminal_1.padStart)(summary.securityScore.toFixed(1), 4);
49
- lines.push(` ${terminal_1.color.dim(securityLabel)}${(0, terminal_1.scoreColor)(summary.securityScore, securityScoreText)} ${terminal_1.color.dim('(reported separately)')}`);
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
- const bySeverityCount = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
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
- bySeverityCount[finding.severity] += 1;
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 severitySummary = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
63
- .filter((severity) => bySeverityCount[severity] > 0)
64
- .map((severity) => (0, terminal_1.severityColor)(severity, `${bySeverityCount[severity]} ${severity.toLowerCase()}`))
65
- .join(terminal_1.color.dim(' · '));
66
- lines.push('');
67
- lines.push(` ${terminal_1.color.bold(String(summary.findings.length))} findings ${terminal_1.color.dim('(')}${severitySummary}${terminal_1.color.dim(')')}`);
68
- const findingsByCategory = new Map();
69
- for (const finding of summary.findings) {
70
- if (!findingsByCategory.has(finding.category))
71
- findingsByCategory.set(finding.category, []);
72
- findingsByCategory.get(finding.category).push(finding);
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
- lines.push('');
75
- const categoryCountWidth = Math.max(...[...findingsByCategory.keys()].map((category) => humanizeCategory(category).length)) + 2;
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
- const severityRankedFindings = [...summary.findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
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 > 0) {
91
- lines.push('');
92
- lines.push(terminal_1.color.dim('─'.repeat(50)));
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('Top issues'));
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
- for (const finding of topFindings) {
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.dim(`${humanizeCategory(finding.category)}:`)} ${(0, terminal_1.severityColor)(finding.severity, finding.message)} ${terminal_1.color.dim(`[${finding.ruleId}]`)}`);
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
- for (const frameLine of (0, terminal_1.renderCodeFrame)(summary.repository, finding.file, finding.line, finding.column, finding.severity)) {
104
- lines.push(frameLine);
105
- }
171
+ return lines.join('\n');
106
172
  }
107
- const remaining = summary.findings.length - topFindings.length;
108
- lines.push('');
109
- lines.push(terminal_1.color.dim('─'.repeat(50)));
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(` + ${remaining} more finding${remaining === 1 ? '' : 's'} not shown above.`));
181
+ lines.push(terminal_1.color.dim(` ${plural(remaining, 'more finding')} not shown above.`));
113
182
  }
114
- lines.push(` ${terminal_1.color.dim('devkit scan --format json')} full machine-readable output`);
115
- lines.push(` ${terminal_1.color.dim('devkit scan --category <name>')} focus on one category`);
116
- lines.push(` ${terminal_1.color.dim('devkit explain <RULE_ID>')} why a rule exists and how to fix it`);
117
- lines.push(` ${terminal_1.color.dim('devkit fix')} preview findings marked safe to fix`);
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 (reported separately): ${summary.securityScore.toFixed(1)} / 10`);
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 lines = [];
216
- lines.push(terminal_1.color.bold('Repository metrics'));
217
- lines.push(terminal_1.color.dim(summary.repository));
218
- lines.push('');
219
- lines.push(` totalFiles ${terminal_1.color.bold(String(metrics.totalFiles))}`);
220
- lines.push(` sourceFiles ${metrics.sourceFiles}`);
221
- lines.push(` testFiles ${metrics.testFiles}`);
222
- lines.push(` totalLOC ${metrics.totalLOC}`);
223
- lines.push(` sourceLOC ${metrics.sourceLOC}`);
224
- lines.push(` testLOC ${metrics.testLOC}`);
225
- lines.push(` commentLOC ${metrics.commentLOC}`);
226
- lines.push(` functionCount ${metrics.functionCount}`);
227
- lines.push(` classCount ${metrics.classCount}`);
228
- lines.push(` dependencyCount ${metrics.dependencyCount}`);
229
- lines.push(` averageFunctionSize ${metrics.averageFunctionSize.toFixed(1)}`);
230
- lines.push(` duplicationPercentage ${metrics.duplicationPercentage.toFixed(1)}%`);
231
- lines.push(` deadCodePercentage ${metrics.deadCodePercentage.toFixed(1)}%`);
232
- lines.push(` testToSourceRatio ${metrics.testToSourceRatio.toFixed(2)}`);
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.color.cyan(fn.name)} ${terminal_1.color.dim(`(${fn.file})`)} - ${fn.size} lines`);
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.color.cyan(file.file)} - ${file.loc} lines`);
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 score: ${previousScore.toFixed(1)}`);
254
- lines.push(` Current score: ${currentScore.toFixed(1)}`);
255
- const deltaText = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}`;
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
  }
@@ -128,12 +128,12 @@ function detectUnusedExports(context) {
128
128
  continue;
129
129
  if (moduleGraph.entryPoints.has(file))
130
130
  continue;
131
- if ((0, discovery_1.isTestFile)(file))
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
- if ((0, discovery_1.isTestFile)(file))
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, packageJson, projectRoot } = context;
50
- const packageJsonText = (0, discovery_1.readFileSafe)(node_path_1.default.join(projectRoot, 'package.json'));
51
- const usedPackageNames = new Set();
52
- for (const [, imports] of moduleGraph.importsByFile) {
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 (!importInfo.isRelative && !importInfo.resolved) {
55
- usedPackageNames.add(packageNameFromSpecifier(importInfo.specifier));
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 dependencyName of declaredDependencies) {
64
- if (usedPackageNames.has(dependencyName))
65
- continue;
66
- // @types/* packages provide ambient global declarations (process, Buffer, JSX, ...)
67
- // that are never referenced through an import specifier, so they are exempt by design.
68
- if (dependencyName.startsWith('@types/'))
69
- continue;
70
- const wordBoundary = new RegExp(`(^|[^A-Za-z0-9_@/.-])${dependencyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_@/.-]|$)`);
71
- if (wordBoundary.test(scriptText))
72
- continue;
73
- findings.push((0, finding_1.buildFinding)({
74
- ruleId: 'DEP001',
75
- category: 'dependencies',
76
- severity: 'MEDIUM',
77
- confidence: 'CERTAIN',
78
- file: 'package.json',
79
- line: findDependencyLine(packageJsonText, dependencyName),
80
- column: 1,
81
- message: `Unused dependency: ${dependencyName}`,
82
- description: `"${dependencyName}" is declared but not referenced by source files, require/import calls, or package.json scripts.`,
83
- evidence: dependencyName,
84
- suggestion: 'Remove the unused dependency or confirm it is required by a toolchain entry point.',
85
- fixAvailable: true
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
- for (const usedName of usedPackageNames) {
91
- if (BUILTIN_MODULES.has(usedName))
92
- continue;
93
- if (declaredDependencies.has(usedName))
94
- continue;
95
- findings.push((0, finding_1.buildFinding)({
96
- ruleId: 'DEP002',
97
- category: 'dependencies',
98
- severity: 'HIGH',
99
- confidence: 'HIGH',
100
- file: 'package.json',
101
- line: 1,
102
- column: 1,
103
- message: `Unlisted dependency: ${usedName}`,
104
- description: `"${usedName}" is imported from source but is not declared in package.json.`,
105
- evidence: usedName,
106
- suggestion: 'Add the package to package.json dependencies, or remove the import if it was unintentional.',
107
- fixAvailable: false
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')) {