@tamagui/codemod-flat-values 0.0.0-bootstrap.0 → 3.0.0-beta.637.1

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.
@@ -0,0 +1,210 @@
1
+ // Only Tamagui style syntax converts. A `hoverStyle` object or a `$token` string
2
+ // means nothing to Emotion, restyle, or a local `styled` helper, so a site is only
3
+ // a conversion site when its binding is provably Tamagui.
4
+ //
5
+ // Provenance is followed through module specifiers, never guessed from a name:
6
+ // an import from `tamagui`/`@tamagui/*`, a re-export chain of relative modules
7
+ // ending in one, or a local value assigned from a Tamagui `styled()` call.
8
+
9
+ import {
10
+ Node,
11
+ SyntaxKind,
12
+ type CallExpression,
13
+ type Identifier,
14
+ type JsxOpeningElement,
15
+ type JsxSelfClosingElement,
16
+ type SourceFile,
17
+ } from 'ts-morph'
18
+
19
+ const tamaguiModule = /^(tamagui|@tamagui\/)/
20
+
21
+ /** the identifier a JSX tag or a call ultimately binds to (`Card.Header` → `Card`) */
22
+ function rootIdentifier(node: Node): Identifier | null {
23
+ let current = node
24
+ while (Node.isPropertyAccessExpression(current)) current = current.getExpression()
25
+ return Node.isIdentifier(current) ? current : null
26
+ }
27
+
28
+ export function createProvenance(): {
29
+ isTamaguiElement: (opening: JsxOpeningElement | JsxSelfClosingElement) => boolean
30
+ isTamaguiStyledCall: (call: CallExpression) => boolean
31
+ } {
32
+ const resolved = new Map<Node, boolean>()
33
+ const active = new Set<Node>()
34
+
35
+ /** whether a declaration binds a value that came from Tamagui */
36
+ const fromDeclaration = (declaration: Node): boolean => {
37
+ const cached = resolved.get(declaration)
38
+ if (cached !== undefined) return cached
39
+ // an export cycle must terminate, and a binding is not Tamagui until proven
40
+ if (active.has(declaration)) return false
41
+ active.add(declaration)
42
+ const answer = computeDeclaration(declaration)
43
+ active.delete(declaration)
44
+ resolved.set(declaration, answer)
45
+ return answer
46
+ }
47
+
48
+ /** the named export of a module reached by a relative specifier */
49
+ const fromModule = (
50
+ specifier: string,
51
+ source: SourceFile | undefined,
52
+ name: string
53
+ ): boolean => {
54
+ if (tamaguiModule.test(specifier)) return true
55
+ if (source === undefined) return false
56
+ return exportedBinding(source, name)
57
+ }
58
+
59
+ const bindings = new Map<string, boolean>()
60
+ const openBindings = new Set<string>()
61
+
62
+ const exportedBinding = (source: SourceFile, name: string): boolean => {
63
+ const key = `${source.getFilePath()}#${name}`
64
+ const cached = bindings.get(key)
65
+ if (cached !== undefined) return cached
66
+ // a re-export cycle must terminate, and a binding is not Tamagui until proven
67
+ if (openBindings.has(key)) return false
68
+ openBindings.add(key)
69
+ const answer = computeBinding(source, name)
70
+ openBindings.delete(key)
71
+ bindings.set(key, answer)
72
+ return answer
73
+ }
74
+
75
+ const computeBinding = (source: SourceFile, name: string): boolean => {
76
+ // the re-export walk runs first: it answers from the module specifier alone,
77
+ // without asking the checker to load a package's whole declaration file
78
+ for (const declaration of source.getExportDeclarations()) {
79
+ const specifier = declaration.getModuleSpecifierValue()
80
+ const named = declaration.getNamedExports()
81
+ if (specifier === undefined) {
82
+ // `export { View }`: the local binding is what carries the provenance
83
+ for (const element of named) {
84
+ if ((element.getAliasNode() ?? element.getNameNode()).getText() !== name) {
85
+ continue
86
+ }
87
+ if (fromDeclaration(element)) return true
88
+ }
89
+ continue
90
+ }
91
+ if (!named.length && !declaration.getNamespaceExport()) {
92
+ // `export * from '...'`: the name can come from any of them
93
+ if (fromModule(specifier, declaration.getModuleSpecifierSourceFile(), name)) {
94
+ return true
95
+ }
96
+ continue
97
+ }
98
+ for (const element of named) {
99
+ if ((element.getAliasNode() ?? element.getNameNode()).getText() !== name) continue
100
+ if (
101
+ fromModule(
102
+ specifier,
103
+ declaration.getModuleSpecifierSourceFile(),
104
+ element.getName()
105
+ )
106
+ ) {
107
+ return true
108
+ }
109
+ }
110
+ }
111
+
112
+ for (const [exportName, declarations] of source.getExportedDeclarations()) {
113
+ if (exportName !== name) continue
114
+ for (const declaration of declarations) {
115
+ if (fromDeclaration(declaration)) return true
116
+ }
117
+ }
118
+ return false
119
+ }
120
+
121
+ const computeDeclaration = (declaration: Node): boolean => {
122
+ if (Node.isImportSpecifier(declaration)) {
123
+ const importDeclaration = declaration.getImportDeclaration()
124
+ return fromModule(
125
+ importDeclaration.getModuleSpecifierValue(),
126
+ importDeclaration.getModuleSpecifierSourceFile(),
127
+ declaration.getName()
128
+ )
129
+ }
130
+ if (Node.isImportClause(declaration) || Node.isNamespaceImport(declaration)) {
131
+ const importDeclaration = declaration.getFirstAncestorByKindOrThrow(
132
+ SyntaxKind.ImportDeclaration
133
+ )
134
+ return tamaguiModule.test(importDeclaration.getModuleSpecifierValue())
135
+ }
136
+ if (Node.isExportSpecifier(declaration)) {
137
+ const exportDeclaration = declaration.getExportDeclaration()
138
+ const specifier = exportDeclaration.getModuleSpecifierValue()
139
+ if (specifier !== undefined) {
140
+ return fromModule(
141
+ specifier,
142
+ exportDeclaration.getModuleSpecifierSourceFile(),
143
+ declaration.getName()
144
+ )
145
+ }
146
+ // `export { View }`: whatever declares View in this file is the binding
147
+ for (const local of declaration.getLocalTargetDeclarations()) {
148
+ if (fromDeclaration(local)) return true
149
+ }
150
+ return false
151
+ }
152
+ if (Node.isVariableDeclaration(declaration)) {
153
+ const initializer = declaration.getInitializer()
154
+ return initializer !== undefined && isTamaguiValue(initializer)
155
+ }
156
+ return false
157
+ }
158
+
159
+ const fromIdentifier = (identifier: Identifier): boolean => {
160
+ const symbol = identifier.getSymbol()
161
+ if (symbol === undefined) return false
162
+ for (const declaration of symbol.getDeclarations()) {
163
+ if (fromDeclaration(declaration)) return true
164
+ }
165
+ // an alias the checker already followed across files
166
+ const aliased = symbol.getAliasedSymbol()
167
+ if (aliased === undefined) return false
168
+ for (const declaration of aliased.getDeclarations()) {
169
+ if (fromDeclaration(declaration)) return true
170
+ }
171
+ return false
172
+ }
173
+
174
+ const isTamaguiStyledCall = (call: CallExpression): boolean => {
175
+ const callee = rootIdentifier(call.getExpression())
176
+ if (callee === null || callee.getText() !== 'styled') return false
177
+ return fromIdentifier(callee)
178
+ }
179
+
180
+ /**
181
+ * A value re-bound from Tamagui: an alias (`const Sheet = SheetRaw as ...`), a
182
+ * member of one, or the result of a Tamagui factory (`styled`,
183
+ * `withStaticProperties`). A local factory over a Tamagui component is not one:
184
+ * whatever it returns is that function's contract, not Tamagui's.
185
+ */
186
+ function isTamaguiValue(expression: Node): boolean {
187
+ let current = expression
188
+ while (
189
+ Node.isParenthesizedExpression(current) ||
190
+ Node.isAsExpression(current) ||
191
+ Node.isNonNullExpression(current)
192
+ ) {
193
+ current = current.getExpression()
194
+ }
195
+ const identifier = rootIdentifier(
196
+ Node.isCallExpression(current) ? current.getExpression() : current
197
+ )
198
+ return identifier !== null && fromIdentifier(identifier)
199
+ }
200
+
201
+ return {
202
+ isTamaguiElement: (opening) => {
203
+ const tag = rootIdentifier(opening.getTagNameNode())
204
+ // a lowercase tag is an intrinsic element, never a Tamagui component
205
+ if (tag === null || !/^[A-Z_$]/.test(tag.getText())) return false
206
+ return fromIdentifier(tag)
207
+ },
208
+ isTamaguiStyledCall,
209
+ }
210
+ }
package/src/report.ts ADDED
@@ -0,0 +1,235 @@
1
+ import type { Flag, SiteReport } from './convert'
2
+
3
+ export interface FileReport {
4
+ file: string
5
+ sites: SiteReport[]
6
+ }
7
+
8
+ function countCodes(flags: Iterable<Flag>): Array<[string, number]> {
9
+ const counts = new Map<string, number>()
10
+ for (const flag of flags) counts.set(flag.code, (counts.get(flag.code) ?? 0) + 1)
11
+ return [...counts].sort(
12
+ ([leftCode, leftCount], [rightCode, rightCount]) =>
13
+ rightCount - leftCount || leftCode.localeCompare(rightCode)
14
+ )
15
+ }
16
+
17
+ export interface ReportSummary {
18
+ sites: number
19
+ clean: number
20
+ needsRelocation: number
21
+ unknownHost: number
22
+ ineligible: number
23
+ flagged: number
24
+ warnings: number
25
+ /** sites with nothing to convert until the runtime catches up */
26
+ waiting: number
27
+ ignoredFiles: number
28
+ }
29
+
30
+ export function renderReport(
31
+ files: readonly FileReport[],
32
+ corpus: readonly string[],
33
+ registryDiagnostics: readonly string[],
34
+ ignoredFiles = 0,
35
+ write = false
36
+ ): { text: string; summary: ReportSummary } {
37
+ const sites = files.flatMap((file) => file.sites)
38
+ const clean = sites.filter(
39
+ (site) =>
40
+ site.flags.length === 0 &&
41
+ site.warnings.length === 0 &&
42
+ site.assessmentVerdict === 'clean'
43
+ )
44
+ const flagged = sites.filter((site) => site.flags.length > 0)
45
+ const warnings = sites.filter((site) => site.warnings.length > 0)
46
+ const needsRelocation = sites.filter(
47
+ (site) => site.assessmentVerdict === 'needs-relocation'
48
+ )
49
+ const unknownHost = sites.filter((site) => site.assessmentVerdict === 'unknown-host')
50
+ const ineligible = sites.filter((site) => site.assessmentVerdict === 'ineligible')
51
+ const waiting = clean.filter((site) => site.programs.length === 0)
52
+ const jsx = sites.filter((site) => site.kind === 'jsx')
53
+ const styled = sites.filter((site) => site.kind === 'styled')
54
+ const cleanJsx = jsx.filter(
55
+ (site) =>
56
+ site.flags.length === 0 &&
57
+ site.warnings.length === 0 &&
58
+ site.assessmentVerdict === 'clean'
59
+ ).length
60
+ const cleanStyled = styled.filter(
61
+ (site) =>
62
+ site.flags.length === 0 &&
63
+ site.warnings.length === 0 &&
64
+ site.assessmentVerdict === 'clean'
65
+ ).length
66
+ const readyFiles = files.filter(
67
+ (file) => file.sites.length > 0 && file.sites.every((site) => site.legacyLeft === 0)
68
+ )
69
+ const filesWithSites = files.filter((file) => file.sites.length > 0).length
70
+ const blockedFiles = files
71
+ .filter((file) => file.sites.some((site) => site.legacyLeft > 0))
72
+ .map((file) => file.file)
73
+
74
+ const lines = [
75
+ '# Flat-values codemod dry-run',
76
+ '',
77
+ `Corpus: ${corpus.map((entry) => `\`${entry}\``).join(', ')}.`,
78
+ '',
79
+ write
80
+ ? 'Statically safe conversions were written in place. Flagged legacy syntax remains for manual migration.'
81
+ : 'Dry run only: no source files were written. Pass `--write` to apply statically safe conversions.',
82
+ '',
83
+ '## Summary',
84
+ '',
85
+ `- ${sites.length} conversion sites found`,
86
+ `- ${clean.length - waiting.length} converted with no open questions`,
87
+ `- ${needsRelocation.length} need relocation because the authored target or host cannot evaluate the conversion`,
88
+ `- ${unknownHost.length} have an unverified host type`,
89
+ `- ${ineligible.length} use properties that cannot carry flat clauses`,
90
+ `- ${waiting.length} have nothing to convert until the runtime catches up (see below)`,
91
+ `- ${flagged.length} have syntax or ordering flags for manual work`,
92
+ `- ${warnings.length} have configuration warnings for manual review`,
93
+ `- ${ignoredFiles} source files skipped by \`.tamagui-flat-values-ignore\` markers`,
94
+ `- ${jsx.length} JSX sites: ${cleanJsx} clean, ${jsx.length - cleanJsx} need review`,
95
+ `- ${styled.length} styled config sites: ${cleanStyled} clean, ${styled.length - cleanStyled} need review`,
96
+ '',
97
+ '### Remaining manual migration',
98
+ '',
99
+ `${readyFiles.length} of ${filesWithSites} files have no legacy condition object left after`,
100
+ 'conversion. V3 has no compatibility setting; finish the remaining files directly:',
101
+ blockedFiles.length
102
+ ? blockedFiles.map((file) => `\`${file}\``).join(', ')
103
+ : 'every file in this corpus is fully migrated.',
104
+ '',
105
+ '### Flag reasons',
106
+ '',
107
+ ]
108
+
109
+ const flagCounts = countCodes(flagged.flatMap((site) => site.flags))
110
+ lines.push(
111
+ ...(flagCounts.length
112
+ ? flagCounts.map(([code, count]) => `- ${code}: ${count}`)
113
+ : ['- none'])
114
+ )
115
+
116
+ const warningCounts = countCodes(warnings.flatMap((site) => site.warnings))
117
+ lines.push(
118
+ '',
119
+ '### Configuration warning reasons',
120
+ '',
121
+ ...(warningCounts.length
122
+ ? warningCounts.map(([code, count]) => `- ${code}: ${count}`)
123
+ : ['- none'])
124
+ )
125
+
126
+ const inventoryCounts = countCodes(sites.flatMap((site) => site.inventory))
127
+ if (inventoryCounts.length) {
128
+ lines.push(
129
+ '',
130
+ '### Values left authored for another migration',
131
+ '',
132
+ 'These are not flat-value migration work. The conversion keeps them exactly as',
133
+ 'authored and records them so their follow-up migrations have a corpus inventory.',
134
+ '',
135
+ ...inventoryCounts.map(([code, count]) => `- ${code}: ${count}`)
136
+ )
137
+ }
138
+
139
+ const pendingCounts = countCodes(sites.flatMap((site) => site.pending))
140
+ if (pendingCounts.length) {
141
+ lines.push(
142
+ '',
143
+ '### Waiting on runtime support',
144
+ '',
145
+ 'The conversion is known but not offered, because the runtime cannot read it yet.',
146
+ 'Each pending reason below names the exact host or merge contract that must land',
147
+ 'before the suggested source is safe to apply.',
148
+ '',
149
+ ...pendingCounts.map(([code, count]) => `- ${code}: ${count}`)
150
+ )
151
+ }
152
+
153
+ if (registryDiagnostics.length) {
154
+ lines.push('', '### Modifier registry diagnostics', '')
155
+ for (const diagnostic of registryDiagnostics) lines.push(`- ${diagnostic}`)
156
+ }
157
+
158
+ for (const file of files) {
159
+ if (!file.sites.length) continue
160
+ lines.push('', `## \`${file.file}\``, '')
161
+ for (const site of file.sites) {
162
+ const status = [
163
+ site.assessmentVerdict === 'clean' ? null : site.assessmentVerdict,
164
+ site.flags.length ? 'syntax-blocked' : null,
165
+ site.warnings.length ? 'configuration-warning' : null,
166
+ ]
167
+ .filter(Boolean)
168
+ .join(', ')
169
+ lines.push(
170
+ `### ${site.label} at line ${site.line} (${status || 'clean'})`,
171
+ '',
172
+ 'Before:',
173
+ '',
174
+ '```tsx',
175
+ site.before,
176
+ '```',
177
+ '',
178
+ 'After:',
179
+ '',
180
+ '```tsx',
181
+ site.after,
182
+ '```'
183
+ )
184
+ if (site.flags.length) {
185
+ lines.push('', 'Flags:', '')
186
+ for (const flag of site.flags) lines.push(`- **${flag.code}**: ${flag.detail}`)
187
+ }
188
+ if (site.warnings.length) {
189
+ lines.push('', 'Configuration warnings:', '')
190
+ for (const warning of site.warnings) {
191
+ lines.push(`- **${warning.code}**: ${warning.detail}`)
192
+ }
193
+ }
194
+ if (site.assessments.length) {
195
+ lines.push('', 'Conversion assessment:', '')
196
+ for (const assessment of site.assessments) {
197
+ for (const reason of assessment.reasons) {
198
+ lines.push(
199
+ `- **${assessment.verdict}: ${assessment.property}**: ${reason.message}. Remedy: ${reason.remedy}.`
200
+ )
201
+ }
202
+ }
203
+ }
204
+ if (site.inventory.length) {
205
+ lines.push('', 'Left authored:', '')
206
+ for (const flag of site.inventory)
207
+ lines.push(`- **${flag.code}**: ${flag.detail}`)
208
+ }
209
+ if (site.pending.length) {
210
+ lines.push('', 'Waiting on runtime support:', '')
211
+ for (const flag of site.pending) lines.push(`- **${flag.code}**: ${flag.detail}`)
212
+ }
213
+ if (site.notes.length) {
214
+ lines.push('', 'Notes:', '')
215
+ for (const note of site.notes) lines.push(`- ${note}`)
216
+ }
217
+ lines.push('')
218
+ }
219
+ }
220
+
221
+ return {
222
+ text: `${lines.join('\n')}\n`,
223
+ summary: {
224
+ sites: sites.length,
225
+ clean: clean.length,
226
+ needsRelocation: needsRelocation.length,
227
+ unknownHost: unknownHost.length,
228
+ ineligible: ineligible.length,
229
+ flagged: flagged.length,
230
+ warnings: warnings.length,
231
+ waiting: waiting.length,
232
+ ignoredFiles,
233
+ },
234
+ }
235
+ }