@bfra.me/workspace-analyzer 0.1.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.
Files changed (73) hide show
  1. package/README.md +402 -0
  2. package/lib/chunk-4LSFAAZW.js +1 -0
  3. package/lib/chunk-JDF7DQ4V.js +27 -0
  4. package/lib/chunk-WOJ4C7N7.js +7122 -0
  5. package/lib/cli.d.ts +1 -0
  6. package/lib/cli.js +318 -0
  7. package/lib/index.d.ts +3701 -0
  8. package/lib/index.js +1262 -0
  9. package/lib/types/index.d.ts +146 -0
  10. package/lib/types/index.js +28 -0
  11. package/package.json +89 -0
  12. package/src/analyzers/analyzer.ts +201 -0
  13. package/src/analyzers/architectural-analyzer.ts +304 -0
  14. package/src/analyzers/build-config-analyzer.ts +334 -0
  15. package/src/analyzers/circular-import-analyzer.ts +463 -0
  16. package/src/analyzers/config-consistency-analyzer.ts +335 -0
  17. package/src/analyzers/dead-code-analyzer.ts +565 -0
  18. package/src/analyzers/duplicate-code-analyzer.ts +626 -0
  19. package/src/analyzers/duplicate-dependency-analyzer.ts +381 -0
  20. package/src/analyzers/eslint-config-analyzer.ts +281 -0
  21. package/src/analyzers/exports-field-analyzer.ts +324 -0
  22. package/src/analyzers/index.ts +388 -0
  23. package/src/analyzers/large-dependency-analyzer.ts +535 -0
  24. package/src/analyzers/package-json-analyzer.ts +349 -0
  25. package/src/analyzers/peer-dependency-analyzer.ts +275 -0
  26. package/src/analyzers/tree-shaking-analyzer.ts +623 -0
  27. package/src/analyzers/tsconfig-analyzer.ts +382 -0
  28. package/src/analyzers/unused-dependency-analyzer.ts +356 -0
  29. package/src/analyzers/version-alignment-analyzer.ts +308 -0
  30. package/src/api/analyze-workspace.ts +245 -0
  31. package/src/api/index.ts +11 -0
  32. package/src/cache/cache-manager.ts +495 -0
  33. package/src/cache/cache-schema.ts +247 -0
  34. package/src/cache/change-detector.ts +169 -0
  35. package/src/cache/file-hasher.ts +65 -0
  36. package/src/cache/index.ts +47 -0
  37. package/src/cli/commands/analyze.ts +240 -0
  38. package/src/cli/commands/index.ts +5 -0
  39. package/src/cli/index.ts +61 -0
  40. package/src/cli/types.ts +65 -0
  41. package/src/cli/ui.ts +213 -0
  42. package/src/cli.ts +9 -0
  43. package/src/config/defaults.ts +183 -0
  44. package/src/config/index.ts +81 -0
  45. package/src/config/loader.ts +270 -0
  46. package/src/config/merger.ts +229 -0
  47. package/src/config/schema.ts +263 -0
  48. package/src/core/incremental-analyzer.ts +462 -0
  49. package/src/core/index.ts +34 -0
  50. package/src/core/orchestrator.ts +416 -0
  51. package/src/graph/dependency-graph.ts +408 -0
  52. package/src/graph/index.ts +19 -0
  53. package/src/index.ts +417 -0
  54. package/src/parser/config-parser.ts +491 -0
  55. package/src/parser/import-extractor.ts +340 -0
  56. package/src/parser/index.ts +54 -0
  57. package/src/parser/typescript-parser.ts +95 -0
  58. package/src/performance/bundle-estimator.ts +444 -0
  59. package/src/performance/index.ts +27 -0
  60. package/src/reporters/console-reporter.ts +355 -0
  61. package/src/reporters/index.ts +49 -0
  62. package/src/reporters/json-reporter.ts +273 -0
  63. package/src/reporters/markdown-reporter.ts +349 -0
  64. package/src/reporters/reporter.ts +399 -0
  65. package/src/rules/builtin-rules.ts +709 -0
  66. package/src/rules/index.ts +52 -0
  67. package/src/rules/rule-engine.ts +409 -0
  68. package/src/scanner/index.ts +18 -0
  69. package/src/scanner/workspace-scanner.ts +403 -0
  70. package/src/types/index.ts +176 -0
  71. package/src/types/result.ts +19 -0
  72. package/src/utils/index.ts +7 -0
  73. package/src/utils/pattern-matcher.ts +48 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Console reporter for terminal output with colors and formatting.
3
+ *
4
+ * Generates colorful terminal output using consola and @clack/prompts
5
+ * for interactive analysis feedback and CI/CD integration.
6
+ */
7
+
8
+ import type {AnalysisResult, Issue, Severity} from '../types/index'
9
+ import type {GroupedIssues, Reporter, ReportOptions, ReportSummary} from './reporter'
10
+
11
+ import * as p from '@clack/prompts'
12
+ import {consola} from 'consola'
13
+
14
+ import {
15
+ calculateSummary,
16
+ CATEGORY_CONFIG,
17
+ DEFAULT_REPORT_OPTIONS,
18
+ filterIssuesForReport,
19
+ formatDuration,
20
+ getRelativePath,
21
+ groupIssues,
22
+ SEVERITY_CONFIG,
23
+ truncateText,
24
+ } from './reporter'
25
+
26
+ /**
27
+ * Options specific to console reporter.
28
+ */
29
+ export interface ConsoleReporterOptions extends ReportOptions {
30
+ /** Whether to use colors (auto-detected by default) */
31
+ readonly colors?: boolean
32
+ /** Whether to show verbose output with full descriptions */
33
+ readonly verbose?: boolean
34
+ /** Whether to show compact output (one line per issue) */
35
+ readonly compact?: boolean
36
+ /** Maximum width for text wrapping */
37
+ readonly maxWidth?: number
38
+ /** Whether to use @clack/prompts styling */
39
+ readonly useClack?: boolean
40
+ }
41
+
42
+ /**
43
+ * Create a console reporter instance.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const reporter = createConsoleReporter({verbose: true})
48
+ * reporter.generate(analysisResult) // Outputs to console
49
+ * ```
50
+ */
51
+ export function createConsoleReporter(defaultOptions?: ConsoleReporterOptions): Reporter {
52
+ return {
53
+ id: 'console',
54
+ name: 'Console Reporter',
55
+ generate(result: AnalysisResult, options?: ReportOptions): string {
56
+ const mergedOptions: ConsoleReporterOptions = {
57
+ ...DEFAULT_REPORT_OPTIONS,
58
+ ...defaultOptions,
59
+ ...options,
60
+ }
61
+
62
+ const output = generateConsoleOutput(result, mergedOptions)
63
+ printToConsole(output, mergedOptions)
64
+ return output.join('\n')
65
+ },
66
+ stream(result: AnalysisResult, write: (chunk: string) => void, options?: ReportOptions): void {
67
+ const output = this.generate(result, options)
68
+ write(output)
69
+ },
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Generate console output lines.
75
+ */
76
+ function generateConsoleOutput(result: AnalysisResult, options: ConsoleReporterOptions): string[] {
77
+ const lines: string[] = []
78
+ const filteredIssues = filterIssuesForReport(result.issues, options)
79
+ const summary = calculateSummary({...result, issues: filteredIssues})
80
+ const grouped = groupIssues(filteredIssues, options.groupBy)
81
+
82
+ lines.push(...generateHeader(summary))
83
+ lines.push('')
84
+
85
+ if (options.includeSummary) {
86
+ lines.push(...generateSummary(summary))
87
+ lines.push('')
88
+ }
89
+
90
+ lines.push(...generateIssuesList(grouped, result.workspacePath, options))
91
+
92
+ lines.push('')
93
+ lines.push(...generateFooter(summary, result))
94
+
95
+ return lines
96
+ }
97
+
98
+ /**
99
+ * Generate header section.
100
+ */
101
+ function generateHeader(summary: ReportSummary): string[] {
102
+ const lines: string[] = []
103
+ const status = getStatusIcon(summary.highestSeverity)
104
+ const statusText =
105
+ summary.totalIssues === 0 ? 'No issues found' : `${summary.totalIssues} issue(s) found`
106
+
107
+ lines.push('─'.repeat(60))
108
+ lines.push(`${status} Workspace Analysis Report`)
109
+ lines.push(` ${statusText}`)
110
+ lines.push('─'.repeat(60))
111
+
112
+ return lines
113
+ }
114
+
115
+ /**
116
+ * Generate summary section.
117
+ */
118
+ function generateSummary(summary: ReportSummary): string[] {
119
+ const lines: string[] = []
120
+
121
+ lines.push('📊 Summary')
122
+ lines.push('')
123
+
124
+ const severityCounts: string[] = []
125
+ for (const severity of ['critical', 'error', 'warning', 'info'] as const) {
126
+ const count = summary.bySeverity[severity]
127
+ if (count > 0) {
128
+ const icon = SEVERITY_CONFIG[severity].emoji
129
+ severityCounts.push(`${icon} ${count} ${severity}`)
130
+ }
131
+ }
132
+
133
+ if (severityCounts.length > 0) {
134
+ lines.push(` ${severityCounts.join(' | ')}`)
135
+ } else {
136
+ lines.push(' ✅ No issues!')
137
+ }
138
+
139
+ lines.push('')
140
+ lines.push(
141
+ ` 📦 ${summary.packagesAnalyzed} packages | 📄 ${summary.filesAnalyzed} files | ⏱️ ${formatDuration(summary.durationMs)}`,
142
+ )
143
+
144
+ return lines
145
+ }
146
+
147
+ /**
148
+ * Generate issues list.
149
+ */
150
+ function generateIssuesList(
151
+ groups: readonly GroupedIssues[],
152
+ workspacePath: string,
153
+ options: ConsoleReporterOptions,
154
+ ): string[] {
155
+ const lines: string[] = []
156
+
157
+ if (groups.length === 0 || groups.every(g => g.count === 0)) {
158
+ lines.push('✅ No issues to report!')
159
+ return lines
160
+ }
161
+
162
+ for (const group of groups) {
163
+ if (group.count === 0) {
164
+ continue
165
+ }
166
+
167
+ lines.push('')
168
+ lines.push(...generateGroupSection(group, workspacePath, options))
169
+ }
170
+
171
+ return lines
172
+ }
173
+
174
+ /**
175
+ * Generate a group section.
176
+ */
177
+ function generateGroupSection(
178
+ group: GroupedIssues,
179
+ workspacePath: string,
180
+ options: ConsoleReporterOptions,
181
+ ): string[] {
182
+ const lines: string[] = []
183
+
184
+ const groupIcon = getGroupIcon(group.key, options.groupBy)
185
+ lines.push(`${groupIcon} ${group.label} (${group.count})`)
186
+ lines.push('')
187
+
188
+ const maxIssues = options.maxIssuesPerGroup ?? 50
189
+ const issuesToShow = group.issues.slice(0, maxIssues)
190
+ const remainingCount = group.count - issuesToShow.length
191
+
192
+ for (const issue of issuesToShow) {
193
+ if (options.compact) {
194
+ lines.push(formatCompactIssue(issue, workspacePath))
195
+ } else {
196
+ lines.push(...formatDetailedIssue(issue, workspacePath, options))
197
+ }
198
+ }
199
+
200
+ if (remainingCount > 0) {
201
+ lines.push(` ... and ${remainingCount} more issue(s)`)
202
+ }
203
+
204
+ return lines
205
+ }
206
+
207
+ /**
208
+ * Format an issue in compact mode (single line).
209
+ */
210
+ function formatCompactIssue(issue: Issue, workspacePath: string): string {
211
+ const icon = SEVERITY_CONFIG[issue.severity].emoji
212
+ const relativePath = getRelativePath(issue.location.filePath, workspacePath)
213
+ const location =
214
+ issue.location.line === undefined ? relativePath : `${relativePath}:${issue.location.line}`
215
+ const title = truncateText(issue.title, 50)
216
+
217
+ return ` ${icon} ${location}: ${title}`
218
+ }
219
+
220
+ /**
221
+ * Format an issue in detailed mode.
222
+ */
223
+ function formatDetailedIssue(
224
+ issue: Issue,
225
+ workspacePath: string,
226
+ options: ConsoleReporterOptions,
227
+ ): string[] {
228
+ const lines: string[] = []
229
+ const icon = SEVERITY_CONFIG[issue.severity].emoji
230
+ const severityLabel = SEVERITY_CONFIG[issue.severity].label.toUpperCase()
231
+
232
+ lines.push(` ${icon} [${severityLabel}] ${issue.title}`)
233
+
234
+ const relativePath = getRelativePath(issue.location.filePath, workspacePath)
235
+ let location = ` 📍 ${relativePath}`
236
+ if (issue.location.line !== undefined) {
237
+ location += `:${issue.location.line}`
238
+ if (issue.location.column !== undefined) {
239
+ location += `:${issue.location.column}`
240
+ }
241
+ }
242
+ lines.push(location)
243
+
244
+ if (options.verbose) {
245
+ const maxWidth = options.maxWidth ?? 80
246
+ const wrappedDesc = wrapText(issue.description, maxWidth - 9)
247
+ for (const line of wrappedDesc) {
248
+ lines.push(` ${line}`)
249
+ }
250
+ }
251
+
252
+ if (options.includeSuggestions && issue.suggestion !== undefined) {
253
+ lines.push(` 💡 ${issue.suggestion}`)
254
+ }
255
+
256
+ lines.push('')
257
+
258
+ return lines
259
+ }
260
+
261
+ /**
262
+ * Generate footer section.
263
+ */
264
+ function generateFooter(summary: ReportSummary, result: AnalysisResult): string[] {
265
+ const lines: string[] = []
266
+
267
+ lines.push('─'.repeat(60))
268
+
269
+ if (summary.totalIssues === 0) {
270
+ lines.push('✅ Your workspace looks great!')
271
+ } else {
272
+ const recommendations: string[] = []
273
+ if (summary.bySeverity.critical > 0 || summary.bySeverity.error > 0) {
274
+ recommendations.push('Fix critical and error issues first')
275
+ }
276
+ if (summary.bySeverity.warning > 0) {
277
+ recommendations.push('Review warnings for potential improvements')
278
+ }
279
+
280
+ if (recommendations.length > 0) {
281
+ lines.push('💡 Recommendations:')
282
+ for (const rec of recommendations) {
283
+ lines.push(` • ${rec}`)
284
+ }
285
+ }
286
+ }
287
+
288
+ const duration = formatDuration(result.completedAt.getTime() - result.startedAt.getTime())
289
+ lines.push('')
290
+ lines.push(`Analysis completed in ${duration}`)
291
+
292
+ return lines
293
+ }
294
+
295
+ /**
296
+ * Print output to console using appropriate method.
297
+ */
298
+ function printToConsole(lines: string[], options: ConsoleReporterOptions): void {
299
+ if (options.useClack) {
300
+ p.log.message(lines.join('\n'))
301
+ } else {
302
+ for (const line of lines) {
303
+ consola.log(line)
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Get status icon based on severity.
310
+ */
311
+ function getStatusIcon(severity: Severity | null): string {
312
+ if (severity === null) {
313
+ return '✅'
314
+ }
315
+ return SEVERITY_CONFIG[severity].emoji
316
+ }
317
+
318
+ /**
319
+ * Get group icon based on groupBy option.
320
+ */
321
+ function getGroupIcon(key: string, groupBy: ReportOptions['groupBy']): string {
322
+ if (groupBy === 'severity') {
323
+ return SEVERITY_CONFIG[key as Severity]?.emoji ?? '📋'
324
+ }
325
+ if (groupBy === 'category') {
326
+ return CATEGORY_CONFIG[key as keyof typeof CATEGORY_CONFIG]?.emoji ?? '📋'
327
+ }
328
+ return '📄'
329
+ }
330
+
331
+ /**
332
+ * Wrap text to specified width.
333
+ */
334
+ function wrapText(text: string, maxWidth: number): string[] {
335
+ const words = text.split(/\s+/)
336
+ const lines: string[] = []
337
+ let currentLine = ''
338
+
339
+ for (const word of words) {
340
+ if (currentLine.length + word.length + 1 <= maxWidth) {
341
+ currentLine += (currentLine.length > 0 ? ' ' : '') + word
342
+ } else {
343
+ if (currentLine.length > 0) {
344
+ lines.push(currentLine)
345
+ }
346
+ currentLine = word
347
+ }
348
+ }
349
+
350
+ if (currentLine.length > 0) {
351
+ lines.push(currentLine)
352
+ }
353
+
354
+ return lines
355
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Reporter exports for workspace analysis output.
3
+ *
4
+ * Provides a unified API for generating analysis reports in various formats:
5
+ * - JSON: Machine-readable output for CI/CD integration
6
+ * - Markdown: Human-readable reports for documentation and review
7
+ * - Console: Terminal output with colors for interactive feedback
8
+ */
9
+
10
+ // Console reporter exports
11
+ export type {ConsoleReporterOptions} from './console-reporter'
12
+ export {createConsoleReporter} from './console-reporter'
13
+
14
+ // JSON reporter exports
15
+ export type {
16
+ JsonReport,
17
+ JsonReporterOptions,
18
+ JsonReportGroup,
19
+ JsonReportIssue,
20
+ JsonReportLocation,
21
+ JsonReportMetadata,
22
+ } from './json-reporter'
23
+ export {createJsonReporter} from './json-reporter'
24
+
25
+ // Markdown reporter exports
26
+ export type {MarkdownReporterOptions} from './markdown-reporter'
27
+ export {createMarkdownReporter} from './markdown-reporter'
28
+
29
+ // Core reporter types and utilities
30
+ export type {
31
+ GroupedIssues,
32
+ Reporter,
33
+ ReporterFactory,
34
+ ReportFormat,
35
+ ReportOptions,
36
+ ReportSummary,
37
+ } from './reporter'
38
+ export {
39
+ calculateSummary,
40
+ CATEGORY_CONFIG,
41
+ DEFAULT_REPORT_OPTIONS,
42
+ filterIssuesForReport,
43
+ formatDuration,
44
+ formatLocation,
45
+ getRelativePath,
46
+ groupIssues,
47
+ SEVERITY_CONFIG,
48
+ truncateText,
49
+ } from './reporter'
@@ -0,0 +1,273 @@
1
+ /**
2
+ * JSON reporter for machine-readable analysis output.
3
+ *
4
+ * Generates structured JSON reports suitable for CI/CD integration,
5
+ * programmatic processing, and data analysis tools.
6
+ */
7
+
8
+ import type {AnalysisResult, Issue} from '../types/index'
9
+ import type {Reporter, ReportOptions, ReportSummary} from './reporter'
10
+
11
+ import {
12
+ calculateSummary,
13
+ DEFAULT_REPORT_OPTIONS,
14
+ filterIssuesForReport,
15
+ getRelativePath,
16
+ groupIssues,
17
+ } from './reporter'
18
+
19
+ /**
20
+ * JSON report structure for serialization.
21
+ */
22
+ export interface JsonReport {
23
+ /** Schema version for compatibility checking */
24
+ readonly $schema?: string
25
+ /** Report metadata */
26
+ readonly metadata: JsonReportMetadata
27
+ /** Summary statistics */
28
+ readonly summary: ReportSummary
29
+ /** All issues (filtered based on options) */
30
+ readonly issues: readonly JsonReportIssue[]
31
+ /** Issues grouped by the specified key */
32
+ readonly groupedIssues?: readonly JsonReportGroup[]
33
+ }
34
+
35
+ /**
36
+ * Report metadata for provenance tracking.
37
+ */
38
+ export interface JsonReportMetadata {
39
+ /** Report generation timestamp (ISO 8601) */
40
+ readonly generatedAt: string
41
+ /** Analysis start timestamp (ISO 8601) */
42
+ readonly analysisStartedAt: string
43
+ /** Analysis completion timestamp (ISO 8601) */
44
+ readonly analysisCompletedAt: string
45
+ /** Workspace path that was analyzed */
46
+ readonly workspacePath: string
47
+ /** Report options used for generation */
48
+ readonly reportOptions: Partial<ReportOptions>
49
+ /** Analyzer version (if available) */
50
+ readonly version?: string
51
+ }
52
+
53
+ /**
54
+ * Issue representation in JSON report.
55
+ */
56
+ export interface JsonReportIssue {
57
+ /** Issue identifier */
58
+ readonly id: string
59
+ /** Issue title */
60
+ readonly title: string
61
+ /** Issue description */
62
+ readonly description: string
63
+ /** Severity level */
64
+ readonly severity: Issue['severity']
65
+ /** Issue category */
66
+ readonly category: Issue['category']
67
+ /** File location */
68
+ readonly location: JsonReportLocation
69
+ /** Related locations (for multi-file issues like circular imports) */
70
+ readonly relatedLocations?: readonly JsonReportLocation[]
71
+ /** Suggested fix */
72
+ readonly suggestion?: string
73
+ /** Additional metadata */
74
+ readonly metadata?: Readonly<Record<string, unknown>>
75
+ }
76
+
77
+ /**
78
+ * Location representation in JSON report.
79
+ */
80
+ export interface JsonReportLocation {
81
+ /** File path (relative to workspace root) */
82
+ readonly file: string
83
+ /** Absolute file path */
84
+ readonly absolutePath: string
85
+ /** Line number (1-indexed) */
86
+ readonly line?: number
87
+ /** Column number (1-indexed) */
88
+ readonly column?: number
89
+ /** End line number (1-indexed) */
90
+ readonly endLine?: number
91
+ /** End column number (1-indexed) */
92
+ readonly endColumn?: number
93
+ }
94
+
95
+ /**
96
+ * Grouped issues in JSON report.
97
+ */
98
+ export interface JsonReportGroup {
99
+ /** Group key */
100
+ readonly key: string
101
+ /** Group label */
102
+ readonly label: string
103
+ /** Number of issues in group */
104
+ readonly count: number
105
+ /** Issues in this group */
106
+ readonly issues: readonly JsonReportIssue[]
107
+ }
108
+
109
+ /**
110
+ * Options specific to JSON reporter.
111
+ */
112
+ export interface JsonReporterOptions extends ReportOptions {
113
+ /** Whether to pretty-print JSON output */
114
+ readonly prettyPrint?: boolean
115
+ /** Indentation size for pretty-printing (default: 2) */
116
+ readonly indentation?: number
117
+ /** Whether to include grouped issues section */
118
+ readonly includeGroupedIssues?: boolean
119
+ /** JSON schema URL for validation */
120
+ readonly schemaUrl?: string
121
+ }
122
+
123
+ /**
124
+ * Create a JSON reporter instance.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const reporter = createJsonReporter({prettyPrint: true})
129
+ * const json = reporter.generate(analysisResult)
130
+ * await fs.writeFile('report.json', json)
131
+ * ```
132
+ */
133
+ export function createJsonReporter(defaultOptions?: JsonReporterOptions): Reporter {
134
+ return {
135
+ id: 'json',
136
+ name: 'JSON Reporter',
137
+ generate(result: AnalysisResult, options?: ReportOptions): string {
138
+ const mergedOptions: JsonReporterOptions = {
139
+ ...DEFAULT_REPORT_OPTIONS,
140
+ ...defaultOptions,
141
+ ...options,
142
+ }
143
+
144
+ const report = generateJsonReport(result, mergedOptions)
145
+ const prettyPrint = mergedOptions.prettyPrint ?? true
146
+ const indentation = mergedOptions.indentation ?? 2
147
+
148
+ return prettyPrint ? JSON.stringify(report, null, indentation) : JSON.stringify(report)
149
+ },
150
+ stream(result: AnalysisResult, write: (chunk: string) => void, options?: ReportOptions): void {
151
+ const output = this.generate(result, options)
152
+ write(output)
153
+ },
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Generate the complete JSON report structure.
159
+ */
160
+ function generateJsonReport(result: AnalysisResult, options: JsonReporterOptions): JsonReport {
161
+ const filteredIssues = filterIssuesForReport(result.issues, options)
162
+ const summary = calculateSummary({...result, issues: filteredIssues})
163
+
164
+ const baseReport: JsonReport = {
165
+ metadata: generateMetadata(result, options),
166
+ summary,
167
+ issues: filteredIssues.map(issue => formatIssueForJson(issue, result.workspacePath, options)),
168
+ }
169
+
170
+ const report: JsonReport =
171
+ options.schemaUrl === undefined ? baseReport : {$schema: options.schemaUrl, ...baseReport}
172
+
173
+ if (options.includeGroupedIssues ?? options.groupBy !== 'none') {
174
+ const grouped = groupIssues(filteredIssues, options.groupBy)
175
+ return {
176
+ ...report,
177
+ groupedIssues: grouped.map(group => ({
178
+ key: group.key,
179
+ label: group.label,
180
+ count: group.count,
181
+ issues: group.issues.map(issue => formatIssueForJson(issue, result.workspacePath, options)),
182
+ })),
183
+ }
184
+ }
185
+
186
+ return report
187
+ }
188
+
189
+ /**
190
+ * Generate report metadata.
191
+ */
192
+ function generateMetadata(
193
+ result: AnalysisResult,
194
+ options: JsonReporterOptions,
195
+ ): JsonReportMetadata {
196
+ const {schemaUrl, prettyPrint, indentation, includeGroupedIssues, ...reportOptions} = options
197
+
198
+ return {
199
+ generatedAt: new Date().toISOString(),
200
+ analysisStartedAt: result.startedAt.toISOString(),
201
+ analysisCompletedAt: result.completedAt.toISOString(),
202
+ workspacePath: result.workspacePath,
203
+ reportOptions,
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Format a single issue for JSON output.
209
+ *
210
+ * Accumulates optional properties (relatedLocations, suggestion, metadata)
211
+ * based on issue data and report options.
212
+ */
213
+ function formatIssueForJson(
214
+ issue: Issue,
215
+ workspacePath: string,
216
+ options: JsonReporterOptions,
217
+ ): JsonReportIssue {
218
+ let jsonIssue: JsonReportIssue = {
219
+ id: issue.id,
220
+ title: issue.title,
221
+ description: issue.description,
222
+ severity: issue.severity,
223
+ category: issue.category,
224
+ location: formatLocationForJson(issue.location, workspacePath, options),
225
+ }
226
+
227
+ if (issue.relatedLocations !== undefined && issue.relatedLocations.length > 0) {
228
+ jsonIssue = {
229
+ ...jsonIssue,
230
+ relatedLocations: issue.relatedLocations.map(loc =>
231
+ formatLocationForJson(loc, workspacePath, options),
232
+ ),
233
+ }
234
+ }
235
+
236
+ if (options.includeSuggestions === true && issue.suggestion !== undefined) {
237
+ jsonIssue = {...jsonIssue, suggestion: issue.suggestion}
238
+ }
239
+
240
+ if (options.includeMetadata === true && issue.metadata !== undefined) {
241
+ jsonIssue = {...jsonIssue, metadata: issue.metadata}
242
+ }
243
+
244
+ return jsonIssue
245
+ }
246
+
247
+ /**
248
+ * Format a location for JSON output.
249
+ */
250
+ function formatLocationForJson(
251
+ location: Issue['location'],
252
+ workspacePath: string,
253
+ options: JsonReporterOptions,
254
+ ): JsonReportLocation {
255
+ const baseLocation: JsonReportLocation = {
256
+ file: getRelativePath(location.filePath, workspacePath),
257
+ absolutePath: location.filePath,
258
+ }
259
+
260
+ if (!options.includeLocation) {
261
+ return baseLocation
262
+ }
263
+
264
+ const result: JsonReportLocation = {
265
+ ...baseLocation,
266
+ ...(location.line === undefined ? {} : {line: location.line}),
267
+ ...(location.column === undefined ? {} : {column: location.column}),
268
+ ...(location.endLine === undefined ? {} : {endLine: location.endLine}),
269
+ ...(location.endColumn === undefined ? {} : {endColumn: location.endColumn}),
270
+ }
271
+
272
+ return result
273
+ }