@planningcenter/tapestry-migration-cli 2.3.0-rc.12 → 2.3.0-rc.14

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.
@@ -3084,8 +3084,8 @@ const tokens = {
3084
3084
  "--t-fill-color-transparency-dark-070": "hsla(0, 0%, 0%, 0.7)",
3085
3085
  "--t-fill-color-transparency-dark-080": "hsla(0, 0%, 0%, 0.8)",
3086
3086
  "--t-fill-color-transparency-dark-090": "hsla(0, 0%, 0%, 0.9)",
3087
- "--t-surface-color-canvas": "hsl(0, 0%, 98%)",
3088
3087
  "--t-surface-color-card": "hsl(0, 0%, 100%)",
3088
+ "--t-surface-color-canvas": "hsl(0, 0%, 100%)",
3089
3089
  "--t-border-color-default-base": "hsl(0, 0%, 88%)",
3090
3090
  "--t-border-color-default-dark": "hsl(0, 0%, 81%)",
3091
3091
  "--t-border-color-default-darker": "hsl(0, 0%, 68%)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "2.3.0-rc.12",
3
+ "version": "2.3.0-rc.14",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,5 +51,5 @@
51
51
  "publishConfig": {
52
52
  "access": "public"
53
53
  },
54
- "gitHead": "6da3662a1766ecb928eaa3ba24c347ab1a37e6e7"
54
+ "gitHead": "4d57f3d8f77f8595eb8edb6a486a6f3f5063fcf0"
55
55
  }
@@ -110,9 +110,9 @@ describe("convertStyleProps transform", () => {
110
110
 
111
111
  const result = applyTransform(source)
112
112
 
113
- expect(result).not.toContain('height="40px"')
113
+ expect(result).not.toContain('<Button height="40px"')
114
114
  expect(result).toContain(
115
- 'TODO: tapestry-migration (height): height has been removed as this is covered by default styling: height: "40px"'
115
+ 'TODO: tapestry-migration (styleProp): height has been removed as this is covered by default styling: height="40px"'
116
116
  )
117
117
  expect(result).toContain('kind="primary"')
118
118
  })
@@ -128,7 +128,7 @@ describe("convertStyleProps transform", () => {
128
128
 
129
129
  const result = applyTransform(source)
130
130
 
131
- expect(result).not.toContain("height={buttonHeight}")
131
+ expect(result).not.toContain("<Button height={buttonHeight}")
132
132
  expect(result).toContain("TODO: tapestry-migration")
133
133
  expect(result).toContain("buttonHeight")
134
134
  expect(result).toContain("disabled")
@@ -219,10 +219,9 @@ describe("convertStyleProps transform", () => {
219
219
  expect(result).toContain('marginLeft: "128px"')
220
220
  expect(result).toContain("}}>")
221
221
  expect(result).toContain(
222
- 'TODO: tapestry-migration (height): height has been removed as this is covered by default styling: height: "40px"'
222
+ 'TODO: tapestry-migration (styleProp): height has been removed as this is covered by default styling: height="40px"'
223
223
  )
224
224
  expect(result).not.toContain("marginLeft={16}")
225
- expect(result).not.toContain('height="40px"')
226
225
  expect(result).not.toContain('distribution="center"')
227
226
  expect(result).not.toContain('paddingTop="8px"')
228
227
  expect(result).toContain('kind="primary"')
@@ -7,6 +7,7 @@ import { getPrintableAttributeValue } from "./getPrintableAttributeValue"
7
7
  type RemoveAttributeConfig = {
8
8
  /** Function to build comment text given the prop name and formatted value */
9
9
  buildComment?: (propName: string, formattedValue: string) => string | void
10
+ commentScope?: string
10
11
  element: JSXElement
11
12
  j: JSCodeshift
12
13
  source: Collection
@@ -14,7 +15,7 @@ type RemoveAttributeConfig = {
14
15
 
15
16
  export function removeAttribute(
16
17
  name: string,
17
- { element, buildComment, j, source }: RemoveAttributeConfig
18
+ { element, buildComment, j, source, commentScope }: RemoveAttributeConfig
18
19
  ): boolean {
19
20
  const attributes = element.openingElement.attributes || []
20
21
  const attribute = findAttribute(attributes, name)
@@ -28,7 +29,13 @@ export function removeAttribute(
28
29
  const commentText = buildComment(name, printableValue || "")
29
30
 
30
31
  if (commentText) {
31
- addComment({ element, j, scope: name, source, text: commentText })
32
+ addComment({
33
+ element,
34
+ j,
35
+ scope: commentScope || name,
36
+ source,
37
+ text: commentText,
38
+ })
32
39
  }
33
40
  }
34
41
 
@@ -108,7 +108,8 @@ function processRemoveStyleProps({
108
108
  const propName = attr.name.name as string
109
109
  removeAttribute(propName, {
110
110
  buildComment: (name, value) =>
111
- `${name} has been removed as this is covered by default styling: ${name}: ${value}`,
111
+ `${name} has been removed as this is covered by default styling: ${name}=${value}`,
112
+ commentScope: "styleProp",
112
113
  element,
113
114
  j,
114
115
  source,
@@ -299,7 +300,6 @@ export function stylePropTransformFactory(config: {
299
300
  source,
300
301
  })
301
302
 
302
- // Convert collected style props to CSS if any exist
303
303
  if (
304
304
  Object.keys(allStyleProps).length > 0 ||
305
305
  Object.keys(directStyleProps).length > 0
@@ -307,7 +307,6 @@ export function stylePropTransformFactory(config: {
307
307
  try {
308
308
  let styles: Record<string, unknown> = {}
309
309
 
310
- // Process theme props through the theme system
311
310
  if (Object.keys(allStyleProps).length > 0) {
312
311
  const result = splitStyles({
313
312
  ...allStyleProps,
@@ -317,11 +316,9 @@ export function stylePropTransformFactory(config: {
317
316
  styles = { ...styles, ...(cssObject[0] || {}) }
318
317
  }
319
318
 
320
- // Add direct props without theme processing
321
319
  styles = { ...styles, ...directStyleProps }
322
320
  if (options.verbose) console.log("Final generated styles:", styles)
323
321
 
324
- // Apply the styles to the component
325
322
  applyStylesToComponent({ element, j, styles })
326
323
  } catch (error) {
327
324
  console.log("Error processing style props:", error)
@@ -329,28 +326,6 @@ export function stylePropTransformFactory(config: {
329
326
  }
330
327
  }
331
328
 
332
- // Add new attributes from replacements and mappings
333
- // const allNewAttributes = [...mappingResult.newAttributes]
334
- // if (allNewAttributes.length > 0) {
335
- // path.value.attributes = path.value.attributes || []
336
- // path.value.attributes.push(...allNewAttributes)
337
- // hasChanges = true
338
- // }
339
-
340
- // Check for unhandled style props before removing processed attributes
341
- // const mappingPropNames = Object.keys(STYLE_PROP_MAPPINGS)
342
- // processUnhandledProps(
343
- // j,
344
- // path,
345
- // attributes,
346
- // KEEP_STYLE_PROPS,
347
- // REMOVE_STYLE_PROPS,
348
- // mappingPropNames,
349
- // stylePropNames,
350
- // options,
351
- // fileInfo
352
- // )
353
-
354
329
  return attributes.length + removeAttributes.length > 0
355
330
  }
356
331
 
package/src/index.ts CHANGED
@@ -17,12 +17,17 @@ program
17
17
  .description("Run a migration of a component from Tapestry React to Tapestry")
18
18
  .argument("<component-name>", "The name of the component to migrate")
19
19
  .option("-f, --fix", "Write the changes")
20
- .option("-p, --path <path>", "The path to the folder/file to migrate")
20
+ .requiredOption("-p, --path <path>", "The path to the folder/file to migrate")
21
21
  .option("-v, --verbose", "Verbose output")
22
22
  .option(
23
23
  "-j, --js-theme <path>",
24
24
  "Path to JavaScript theme file containing design tokens"
25
25
  )
26
+ .option(
27
+ "-r, --report-path <path>",
28
+ "Path for the migration report. Only runs with --fix and does not create a report if no additional changes are required.",
29
+ "MIGRATION_REPORT.md"
30
+ )
26
31
  .action((componentName, options) => {
27
32
  console.log("Hello from Tapestry Migration CLI! 🎨")
28
33
  console.log(`Component: ${componentName}`)
@@ -3,6 +3,7 @@ import { existsSync } from "fs"
3
3
  import { dirname, resolve } from "path"
4
4
  import { fileURLToPath } from "url"
5
5
 
6
+ import { generateMigrationReport, WEIGHT_CONFIGS } from "./reportGenerator"
6
7
  import { Options } from "./shared/types"
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url)
@@ -43,6 +44,12 @@ export function runTransforms(key: string, options: Options): void {
43
44
  env: process.env,
44
45
  stdio: "inherit",
45
46
  })
47
+
48
+ generateMigrationReport({
49
+ outputPath: options.reportPath,
50
+ targetPath,
51
+ weightConfig: WEIGHT_CONFIGS[key] || { default: 1 },
52
+ })
46
53
  } catch (error) {
47
54
  console.error("❌ Transform failed:", error)
48
55
  }
@@ -0,0 +1,450 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "fs"
2
+ import { join, relative } from "path"
3
+
4
+ type Weight = 1 | 2 | 3 | 4 | 5
5
+ export interface WeightConfig {
6
+ [scope: string]: Weight
7
+ default: Weight
8
+ }
9
+
10
+ interface CommentData {
11
+ file: string
12
+ line: number
13
+ scope: string
14
+ text: string
15
+ }
16
+
17
+ interface CommentStats {
18
+ count: number
19
+ examples: string[]
20
+ files: string[]
21
+ normalizedText: string
22
+ scope: string
23
+ weight: number
24
+ }
25
+
26
+ export const WEIGHT_CONFIGS: { [key: string]: WeightConfig } = {
27
+ button: {
28
+ default: 1,
29
+ hover: 4,
30
+ href: 2,
31
+ mediaQueries: 4,
32
+ spreadAttribute: 5,
33
+ style: 3,
34
+ styleProps: 3,
35
+ "theme/variant": 2,
36
+ tooltip: 2,
37
+ unsupported: 3,
38
+ },
39
+ }
40
+
41
+ /**
42
+ * Extracts tapestry-migration comments from a file
43
+ */
44
+ function extractCommentsFromFile(filePath: string): CommentData[] {
45
+ const content = readFileSync(filePath, "utf-8")
46
+ const comments: CommentData[] = []
47
+
48
+ // Match both JSX and regular comment formats:
49
+ // {/* TODO: tapestry-migration (scope): text */}
50
+ // /* TODO: tapestry-migration (scope): text */
51
+ // Use lazy matching with [\s\S]*? to capture everything including * until we hit the closing */
52
+ const commentRegex =
53
+ /(?:\{\/\*|\/\*)\s*TODO:\s*tapestry-migration\s*\(([^)]+)\):\s*([\s\S]*?)\s*\*\/\}?/g
54
+
55
+ let match
56
+ while ((match = commentRegex.exec(content)) !== null) {
57
+ const scope = match[1].trim()
58
+ const text = match[2].trim()
59
+
60
+ // Calculate line number
61
+ const beforeMatch = content.substring(0, match.index)
62
+ const line = beforeMatch.split("\n").length
63
+
64
+ comments.push({
65
+ file: filePath,
66
+ line,
67
+ scope,
68
+ text,
69
+ })
70
+ }
71
+
72
+ return comments
73
+ }
74
+
75
+ /**
76
+ * Recursively scans a directory for files with specific extensions
77
+ */
78
+ function scanDirectory(
79
+ dirPath: string,
80
+ extensions: string[] = [".js", ".jsx", ".ts", ".tsx"]
81
+ ): string[] {
82
+ const files: string[] = []
83
+
84
+ function scan(currentPath: string) {
85
+ const entries = readdirSync(currentPath)
86
+
87
+ for (const entry of entries) {
88
+ const fullPath = join(currentPath, entry)
89
+ const stat = statSync(fullPath)
90
+
91
+ if (stat.isDirectory()) {
92
+ // Skip node_modules and other common ignore patterns
93
+ if (
94
+ !entry.startsWith(".") &&
95
+ entry !== "node_modules" &&
96
+ entry !== "dist" &&
97
+ entry !== "build"
98
+ ) {
99
+ scan(fullPath)
100
+ }
101
+ } else if (
102
+ stat.isFile() &&
103
+ extensions.some((ext) => entry.endsWith(ext))
104
+ ) {
105
+ files.push(fullPath)
106
+ }
107
+ }
108
+ }
109
+
110
+ const stat = statSync(dirPath)
111
+ if (stat.isFile()) {
112
+ files.push(dirPath)
113
+ } else {
114
+ scan(dirPath)
115
+ }
116
+
117
+ return files
118
+ }
119
+
120
+ /**
121
+ * Finds and replaces attribute values with balanced braces
122
+ */
123
+ function replaceBalancedBraces(text: string): string {
124
+ const attrPattern = /(\w+)=\{/g
125
+ let result = text
126
+ let match
127
+
128
+ // Find all attribute={...} patterns
129
+ const replacements: Array<{
130
+ end: number
131
+ replacement: string
132
+ start: number
133
+ }> = []
134
+
135
+ while ((match = attrPattern.exec(text)) !== null) {
136
+ const attrName = match[1]
137
+ const startPos = match.index
138
+ const braceStartPos = match.index + match[0].length - 1 // Position of opening {
139
+
140
+ // Count balanced braces
141
+ let braceCount = 1
142
+ let i = braceStartPos + 1
143
+
144
+ while (i < text.length && braceCount > 0) {
145
+ if (text[i] === "{") braceCount++
146
+ else if (text[i] === "}") braceCount--
147
+ i++
148
+ }
149
+
150
+ if (braceCount === 0) {
151
+ // Found balanced braces
152
+ replacements.push({
153
+ end: i,
154
+ replacement: `${attrName}="..."`,
155
+ start: startPos,
156
+ })
157
+ }
158
+ }
159
+
160
+ // Apply replacements in reverse order to maintain positions
161
+ for (let i = replacements.length - 1; i >= 0; i--) {
162
+ const { end, replacement, start } = replacements[i]
163
+ result = result.substring(0, start) + replacement + result.substring(end)
164
+ }
165
+
166
+ return result
167
+ }
168
+
169
+ /**
170
+ * Normalizes comment text by replacing specific values with placeholders
171
+ * to group similar comments together
172
+ */
173
+ function normalizeCommentText(text: string): string {
174
+ let normalized = text
175
+
176
+ // Replace attribute values in curly braces (handling nested braces)
177
+ normalized = replaceBalancedBraces(normalized)
178
+
179
+ // Replace attribute values in quotes
180
+ normalized = normalized.replace(/(\w+)="[^"]*"/g, '$1="..."')
181
+
182
+ // Replace standalone numbers with "..."
183
+ normalized = normalized.replace(/\b\d+\b/g, '"..."')
184
+
185
+ return normalized
186
+ }
187
+
188
+ /**
189
+ * Gets the weight for a given scope
190
+ */
191
+ function getScopeWeight(scope: string, config: WeightConfig): number {
192
+ // Try exact match first
193
+ if (scope in config) {
194
+ return config[scope]
195
+ }
196
+
197
+ // Try partial match (e.g., "theme/variant" would match if scope contains "theme")
198
+ for (const [key, weight] of Object.entries(config)) {
199
+ if (scope.includes(key)) {
200
+ return weight
201
+ }
202
+ }
203
+
204
+ // Default weight
205
+ return config.default || 1
206
+ }
207
+
208
+ /**
209
+ * Aggregates comment data by scope and normalized text
210
+ */
211
+ function aggregateComments(
212
+ comments: CommentData[],
213
+ weightConfig: WeightConfig
214
+ ): CommentStats[] {
215
+ const statsMap = new Map<string, CommentStats>()
216
+
217
+ for (const comment of comments) {
218
+ const normalizedText = normalizeCommentText(comment.text)
219
+ const key = `${comment.scope}|||${normalizedText}`
220
+
221
+ if (statsMap.has(key)) {
222
+ const stats = statsMap.get(key)!
223
+ stats.count++
224
+ if (!stats.files.includes(comment.file)) {
225
+ stats.files.push(comment.file)
226
+ }
227
+ // Keep a few examples of the original text (max 5)
228
+ if (stats.examples.length < 5 && !stats.examples.includes(comment.text)) {
229
+ stats.examples.push(comment.text)
230
+ }
231
+ } else {
232
+ const weight = getScopeWeight(comment.scope, weightConfig)
233
+ statsMap.set(key, {
234
+ count: 1,
235
+ examples: [comment.text],
236
+ files: [comment.file],
237
+ normalizedText,
238
+ scope: comment.scope,
239
+ weight,
240
+ })
241
+ }
242
+ }
243
+
244
+ return Array.from(statsMap.values()).sort((a, b) => {
245
+ // Sort by total effort (weight * count) descending, then by scope
246
+ const effortA = a.weight * a.count
247
+ const effortB = b.weight * b.count
248
+ if (effortA !== effortB) {
249
+ return effortB - effortA
250
+ }
251
+ return a.scope.localeCompare(b.scope)
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Generates a markdown report from comment statistics
257
+ */
258
+ function generateMarkdownReport(
259
+ stats: CommentStats[],
260
+ allComments: CommentData[],
261
+ basePath: string
262
+ ): string {
263
+ const totalComments = allComments.length
264
+ const uniqueCommentTypes = stats.length
265
+ const affectedFiles = new Set(allComments.map((c) => c.file)).size
266
+
267
+ // Calculate effort breakdown by type (scope)
268
+ const effortByScope = new Map<string, { count: number; effort: number }>()
269
+ for (const stat of stats) {
270
+ if (!effortByScope.has(stat.scope)) {
271
+ effortByScope.set(stat.scope, { count: 0, effort: 0 })
272
+ }
273
+ const data = effortByScope.get(stat.scope)!
274
+ data.count += stat.count
275
+ data.effort += stat.weight * stat.count
276
+ }
277
+
278
+ const lines: string[] = [
279
+ "# Tapestry Migration Report",
280
+ "",
281
+ `**Generated:** ${new Date().toISOString()}`,
282
+ "",
283
+ "## Overview",
284
+ "",
285
+ ]
286
+
287
+ // Add overview table sorted by effort
288
+ lines.push("| Type | Count | Effort Points |")
289
+ lines.push("|------|-------|---------------|")
290
+
291
+ for (const [scope, data] of Array.from(effortByScope.entries()).sort(
292
+ (a, b) => b[1].effort - a[1].effort
293
+ )) {
294
+ lines.push(`| ${scope} | ${data.count} | ${data.effort} |`)
295
+ }
296
+
297
+ lines.push("")
298
+ lines.push("## Summary")
299
+ lines.push("")
300
+ lines.push(`- **Total Comments:** ${totalComments}`)
301
+ lines.push(`- **Unique Comment Types:** ${uniqueCommentTypes}`)
302
+ lines.push(`- **Affected Files:** ${affectedFiles}`)
303
+ lines.push("")
304
+ lines.push("### Effort by Difficulty")
305
+ lines.push("")
306
+
307
+ // Calculate effort breakdown by difficulty level
308
+ const effortByDifficulty = new Map<
309
+ string,
310
+ { count: number; effort: number }
311
+ >()
312
+ for (const stat of stats) {
313
+ let difficulty: string
314
+ if (stat.weight >= 5) difficulty = "High (5+ pts)"
315
+ else if (stat.weight >= 3) difficulty = "Medium (3-4 pts)"
316
+ else difficulty = "Low (1-2 pts)"
317
+
318
+ if (!effortByDifficulty.has(difficulty)) {
319
+ effortByDifficulty.set(difficulty, { count: 0, effort: 0 })
320
+ }
321
+ const data = effortByDifficulty.get(difficulty)!
322
+ data.count += stat.count
323
+ data.effort += stat.weight * stat.count
324
+ }
325
+
326
+ for (const [difficulty, data] of Array.from(
327
+ effortByDifficulty.entries()
328
+ ).sort((a, b) => b[1].effort - a[1].effort)) {
329
+ lines.push(`- **${difficulty}:** ${data.count} occurrences`)
330
+ }
331
+
332
+ lines.push("")
333
+ lines.push("## Comments by Effort (sorted by total effort)")
334
+ lines.push("")
335
+ lines.push(
336
+ "_Items are sorted by total effort (weight × occurrences) to help prioritize migration work._"
337
+ )
338
+ lines.push("")
339
+
340
+ // Group by scope
341
+ const byScope = new Map<string, CommentStats[]>()
342
+ for (const stat of stats) {
343
+ if (!byScope.has(stat.scope)) {
344
+ byScope.set(stat.scope, [])
345
+ }
346
+ byScope.get(stat.scope)!.push(stat)
347
+ }
348
+
349
+ for (const [scope, scopeStats] of Array.from(byScope.entries()).sort(
350
+ (a, b) => {
351
+ // Sort by total effort within scope
352
+ const effortA = a[1].reduce((sum, s) => sum + s.weight * s.count, 0)
353
+ const effortB = b[1].reduce((sum, s) => sum + s.weight * s.count, 0)
354
+ return effortB - effortA
355
+ }
356
+ )) {
357
+ const scopeTotal = scopeStats.reduce((sum, s) => sum + s.count, 0)
358
+ lines.push(`### ${scope} (${scopeTotal} occurrences)`)
359
+ lines.push("")
360
+
361
+ for (const stat of scopeStats) {
362
+ lines.push(`#### ${stat.normalizedText}`)
363
+ lines.push("")
364
+ lines.push(`- **Occurrences:** ${stat.count}`)
365
+ lines.push(`- **Files affected:** ${stat.files.length}`)
366
+ lines.push("")
367
+
368
+ // Show examples if we have variations
369
+ if (
370
+ stat.examples.length > 1 ||
371
+ stat.examples[0] !== stat.normalizedText
372
+ ) {
373
+ lines.push("**Example variations:**")
374
+ for (const example of stat.examples) {
375
+ lines.push(`- ${example}`)
376
+ }
377
+ lines.push("")
378
+ }
379
+
380
+ if (stat.files.length <= 10) {
381
+ lines.push("**Files:**")
382
+ for (const file of stat.files) {
383
+ lines.push(`- \`${relative(basePath, file)}\``)
384
+ }
385
+ } else {
386
+ lines.push("**Sample files (first 10):**")
387
+ for (const file of stat.files.slice(0, 10)) {
388
+ lines.push(`- \`${relative(basePath, file)}\``)
389
+ }
390
+ lines.push(`- _(and ${stat.files.length - 10} more)_`)
391
+ }
392
+ lines.push("")
393
+ }
394
+ }
395
+
396
+ return lines.join("\n")
397
+ }
398
+
399
+ /**
400
+ * Main function to generate and save the migration report
401
+ */
402
+ export function generateMigrationReport({
403
+ targetPath,
404
+ outputPath,
405
+ weightConfig,
406
+ }: {
407
+ outputPath?: string
408
+ targetPath: string
409
+ weightConfig: WeightConfig
410
+ }): void {
411
+ console.log("📊 Generating migration report...")
412
+
413
+ // Scan for files
414
+ const files = scanDirectory(targetPath)
415
+ console.log(`📁 Scanning ${files.length} files...`)
416
+
417
+ // Extract all comments
418
+ const allComments: CommentData[] = []
419
+ for (const file of files) {
420
+ const comments = extractCommentsFromFile(file)
421
+ allComments.push(...comments)
422
+ }
423
+
424
+ if (allComments.length === 0) {
425
+ console.log("✅ No migration comments found!")
426
+ return
427
+ }
428
+
429
+ // Aggregate statistics
430
+ const stats = aggregateComments(allComments, weightConfig)
431
+
432
+ // Generate markdown report
433
+ const report = generateMarkdownReport(stats, allComments, targetPath)
434
+
435
+ // Determine output path
436
+ const finalOutputPath =
437
+ outputPath || join(process.cwd(), "MIGRATION_REPORT.md")
438
+
439
+ // Write report
440
+ writeFileSync(finalOutputPath, report, "utf-8")
441
+
442
+ const totalEffort = stats.reduce((sum, s) => sum + s.weight * s.count, 0)
443
+ console.log(`✅ Report generated: ${finalOutputPath}`)
444
+ console.log(` Total comments: ${allComments.length}`)
445
+ console.log(` Unique types: ${stats.length}`)
446
+ console.log(
447
+ ` Affected files: ${new Set(allComments.map((c) => c.file)).size}`
448
+ )
449
+ console.log(` Estimated effort: ${totalEffort} points`)
450
+ }
@@ -2,5 +2,6 @@ export interface Options {
2
2
  fix?: boolean
3
3
  jsTheme?: string
4
4
  path: string
5
+ reportPath?: string
5
6
  verbose?: boolean
6
7
  }