@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.
- package/dist/tapestry-react-shim.cjs +1 -1
- package/package.json +2 -2
- package/src/components/button/transforms/convertStyleProps.test.ts +4 -5
- package/src/components/shared/actions/removeAttribute.ts +9 -2
- package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -27
- package/src/index.ts +6 -1
- package/src/jscodeshiftRunner.ts +7 -0
- package/src/reportGenerator.ts +450 -0
- package/src/shared/types.ts +1 -0
|
@@ -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.
|
|
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": "
|
|
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 (
|
|
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 (
|
|
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({
|
|
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}
|
|
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
|
-
.
|
|
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}`)
|
package/src/jscodeshiftRunner.ts
CHANGED
|
@@ -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
|
+
}
|