@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.
- package/dist/tapestry-react-shim.cjs +2 -1
- package/package.json +2 -2
- package/src/components/button/transforms/convertStyleProps.test.ts +4 -5
- package/src/components/link/index.ts +32 -0
- package/src/components/link/transforms/inlineToKind.test.ts +308 -0
- package/src/components/link/transforms/inlineToKind.ts +51 -0
- package/src/components/link/transforms/targetBlankToExternal.test.ts +191 -0
- package/src/components/link/transforms/targetBlankToExternal.ts +30 -0
- package/src/components/link/transforms/toToHref.test.ts +245 -0
- package/src/components/link/transforms/toToHref.ts +14 -0
- package/src/components/shared/actions/addAttribute.test.ts +108 -0
- package/src/components/shared/actions/addAttribute.ts +14 -0
- package/src/components/shared/actions/removeAttribute.ts +9 -2
- package/src/components/shared/actions/transformElementName.ts +23 -9
- package/src/components/shared/transformFactories/attributeTransformFactory.test.ts +83 -0
- package/src/components/shared/transformFactories/attributeTransformFactory.ts +21 -14
- package/src/components/shared/transformFactories/componentTransformFactory.test.ts +85 -2
- package/src/components/shared/transformFactories/componentTransformFactory.ts +41 -22
- package/src/components/shared/transformFactories/helpers/findJSXElements.ts +37 -0
- package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -27
- package/src/components/shared/types.ts +19 -1
- package/src/index.ts +7 -2
- package/src/jscodeshiftRunner.ts +7 -0
- package/src/reportGenerator.ts +450 -0
- 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
|
+
}
|