@planningcenter/tapestry-migration-cli 2.3.0-rc.8 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/tapestry-react-shim.cjs +2 -1
  2. package/package.json +2 -2
  3. package/src/components/button/transforms/convertStyleProps.test.ts +4 -5
  4. package/src/components/link/index.ts +32 -0
  5. package/src/components/link/transforms/inlineToKind.test.ts +308 -0
  6. package/src/components/link/transforms/inlineToKind.ts +51 -0
  7. package/src/components/link/transforms/targetBlankToExternal.test.ts +191 -0
  8. package/src/components/link/transforms/targetBlankToExternal.ts +30 -0
  9. package/src/components/link/transforms/toToHref.test.ts +245 -0
  10. package/src/components/link/transforms/toToHref.ts +14 -0
  11. package/src/components/shared/actions/addAttribute.test.ts +108 -0
  12. package/src/components/shared/actions/addAttribute.ts +14 -0
  13. package/src/components/shared/actions/removeAttribute.ts +9 -2
  14. package/src/components/shared/actions/transformElementName.ts +23 -9
  15. package/src/components/shared/transformFactories/attributeTransformFactory.test.ts +83 -0
  16. package/src/components/shared/transformFactories/attributeTransformFactory.ts +21 -14
  17. package/src/components/shared/transformFactories/componentTransformFactory.test.ts +85 -2
  18. package/src/components/shared/transformFactories/componentTransformFactory.ts +41 -22
  19. package/src/components/shared/transformFactories/helpers/findJSXElements.ts +37 -0
  20. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -27
  21. package/src/components/shared/types.ts +19 -1
  22. package/src/index.ts +7 -2
  23. package/src/jscodeshiftRunner.ts +7 -0
  24. package/src/reportGenerator.ts +450 -0
  25. package/src/shared/types.ts +1 -0
@@ -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
  }