@tamagui/codemod-flat-values 0.0.0-bootstrap.0 → 3.0.0-beta.1093.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.
Files changed (44) hide show
  1. package/README.md +234 -1
  2. package/dist/builtInNames.mjs +14 -0
  3. package/dist/builtInNames.mjs.map +1 -0
  4. package/dist/containers.mjs +141 -0
  5. package/dist/containers.mjs.map +1 -0
  6. package/dist/convert.mjs +1233 -0
  7. package/dist/convert.mjs.map +1 -0
  8. package/dist/expressions.mjs +188 -0
  9. package/dist/expressions.mjs.map +1 -0
  10. package/dist/functionalVariants.mjs +469 -0
  11. package/dist/functionalVariants.mjs.map +1 -0
  12. package/dist/grammar.mjs +73 -0
  13. package/dist/grammar.mjs.map +1 -0
  14. package/dist/index.mjs +374 -0
  15. package/dist/index.mjs.map +1 -0
  16. package/dist/legacyConditions.mjs +293 -0
  17. package/dist/legacyConditions.mjs.map +1 -0
  18. package/dist/legacyNames.mjs +130 -0
  19. package/dist/legacyNames.mjs.map +1 -0
  20. package/dist/provenance.mjs +137 -0
  21. package/dist/provenance.mjs.map +1 -0
  22. package/dist/report.mjs +188 -0
  23. package/dist/report.mjs.map +1 -0
  24. package/dist/sheetAnatomy.mjs +200 -0
  25. package/dist/sheetAnatomy.mjs.map +1 -0
  26. package/dist/structuredNative.mjs +275 -0
  27. package/dist/structuredNative.mjs.map +1 -0
  28. package/dist/transition.mjs +257 -0
  29. package/dist/transition.mjs.map +1 -0
  30. package/package.json +35 -7
  31. package/src/builtInNames.ts +23 -0
  32. package/src/containers.ts +227 -0
  33. package/src/convert.ts +1977 -0
  34. package/src/expressions.ts +220 -0
  35. package/src/functionalVariants.ts +642 -0
  36. package/src/grammar.ts +190 -0
  37. package/src/index.ts +589 -0
  38. package/src/legacyConditions.ts +357 -0
  39. package/src/legacyNames.ts +160 -0
  40. package/src/provenance.ts +210 -0
  41. package/src/report.ts +362 -0
  42. package/src/sheetAnatomy.ts +277 -0
  43. package/src/structuredNative.ts +459 -0
  44. package/src/transition.ts +415 -0
@@ -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,362 @@
1
+ import type { Flag, SiteReport } from './convert'
2
+ import type { FunctionalVariantReport } from './functionalVariants'
3
+ import type { SheetFrameReport } from './sheetAnatomy'
4
+ import type { TransitionReport } from './transition'
5
+
6
+ export interface FileReport {
7
+ file: string
8
+ sites: SiteReport[]
9
+ functionalVariants: FunctionalVariantReport[]
10
+ sheetFrames: SheetFrameReport[]
11
+ transitions: TransitionReport[]
12
+ }
13
+
14
+ function countCodes(flags: Iterable<Flag>): Array<[string, number]> {
15
+ const counts = new Map<string, number>()
16
+ for (const flag of flags) counts.set(flag.code, (counts.get(flag.code) ?? 0) + 1)
17
+ return [...counts].sort(
18
+ ([leftCode, leftCount], [rightCode, rightCount]) =>
19
+ rightCount - leftCount || leftCode.localeCompare(rightCode)
20
+ )
21
+ }
22
+
23
+ export interface ReportSummary {
24
+ sites: number
25
+ clean: number
26
+ needsRelocation: number
27
+ unknownHost: number
28
+ ineligible: number
29
+ flagged: number
30
+ warnings: number
31
+ /** sites with nothing to convert until the runtime catches up */
32
+ waiting: number
33
+ ignoredFiles: number
34
+ functionalVariantSites: number
35
+ functionalVariantConverted: number
36
+ functionalVariantFlagged: number
37
+ functionalVariantFlags: Record<string, number>
38
+ sheetFrames: number
39
+ /** rewritten, but with a spread or a styled target a human has to place */
40
+ sheetFramesFlagged: number
41
+ transitions: number
42
+ /** v2 transitions the pass could not read statically */
43
+ transitionsFlagged: number
44
+ }
45
+
46
+ export function renderReport(
47
+ files: readonly FileReport[],
48
+ corpus: readonly string[],
49
+ registryDiagnostics: readonly string[],
50
+ ignoredFiles = 0,
51
+ write = false
52
+ ): { text: string; summary: ReportSummary } {
53
+ const sites = files.flatMap((file) => file.sites)
54
+ const functionalVariants = files.flatMap((file) => file.functionalVariants)
55
+ const sheetFrames = files.flatMap((file) => file.sheetFrames)
56
+ const flaggedSheetFrames = sheetFrames.filter((site) => site.flags.length > 0)
57
+ const transitions = files.flatMap((file) => file.transitions)
58
+ const flaggedTransitions = transitions.filter((site) => site.flags.length > 0)
59
+ const convertedFunctionalVariants = functionalVariants.filter((site) => site.converted)
60
+ const flaggedFunctionalVariants = functionalVariants.filter((site) => !site.converted)
61
+ const clean = sites.filter(
62
+ (site) =>
63
+ site.flags.length === 0 &&
64
+ site.warnings.length === 0 &&
65
+ site.assessmentVerdict === 'clean'
66
+ )
67
+ const flagged = sites.filter((site) => site.flags.length > 0)
68
+ const warnings = sites.filter((site) => site.warnings.length > 0)
69
+ const needsRelocation = sites.filter(
70
+ (site) => site.assessmentVerdict === 'needs-relocation'
71
+ )
72
+ const unknownHost = sites.filter((site) => site.assessmentVerdict === 'unknown-host')
73
+ const ineligible = sites.filter((site) => site.assessmentVerdict === 'ineligible')
74
+ const waiting = clean.filter((site) => site.programs.length === 0)
75
+ const jsx = sites.filter((site) => site.kind === 'jsx')
76
+ const styled = sites.filter((site) => site.kind === 'styled')
77
+ const cleanJsx = jsx.filter(
78
+ (site) =>
79
+ site.flags.length === 0 &&
80
+ site.warnings.length === 0 &&
81
+ site.assessmentVerdict === 'clean'
82
+ ).length
83
+ const cleanStyled = styled.filter(
84
+ (site) =>
85
+ site.flags.length === 0 &&
86
+ site.warnings.length === 0 &&
87
+ site.assessmentVerdict === 'clean'
88
+ ).length
89
+ const touched = (file: FileReport) =>
90
+ file.sites.length > 0 ||
91
+ file.functionalVariants.length > 0 ||
92
+ file.sheetFrames.length > 0 ||
93
+ file.transitions.length > 0
94
+ const blocked = (file: FileReport) =>
95
+ file.sites.some((site) => site.legacyLeft > 0) ||
96
+ file.functionalVariants.some((site) => !site.converted) ||
97
+ file.sheetFrames.some((site) => site.flags.length > 0) ||
98
+ file.transitions.some((site) => site.flags.length > 0)
99
+ const readyFiles = files.filter((file) => touched(file) && !blocked(file))
100
+ const filesWithSites = files.filter(touched).length
101
+ const blockedFiles = files.filter(blocked).map((file) => file.file)
102
+ const functionalFlagCounts = countCodes(
103
+ flaggedFunctionalVariants.flatMap((site) => [
104
+ ...new Map(site.flags.map((flag) => [flag.code, flag])).values(),
105
+ ])
106
+ )
107
+
108
+ const lines = [
109
+ '# Flat-values codemod dry-run',
110
+ '',
111
+ `Corpus: ${corpus.map((entry) => `\`${entry}\``).join(', ')}.`,
112
+ '',
113
+ write
114
+ ? 'Statically safe conversions were written in place. Flagged legacy syntax remains for manual migration.'
115
+ : 'Dry run only: no source files were written. Pass `--write` to apply statically safe conversions.',
116
+ '',
117
+ '## Summary',
118
+ '',
119
+ `- ${sites.length} conversion sites found`,
120
+ `- ${clean.length - waiting.length} converted with no open questions`,
121
+ `- ${needsRelocation.length} need relocation because the authored target or host cannot evaluate the conversion`,
122
+ `- ${unknownHost.length} have an unverified host type`,
123
+ `- ${ineligible.length} use properties that cannot carry flat clauses`,
124
+ `- ${waiting.length} have nothing to convert until the runtime catches up (see below)`,
125
+ `- ${flagged.length} have syntax or ordering flags for manual work`,
126
+ `- ${warnings.length} have configuration warnings for manual review`,
127
+ `- ${ignoredFiles} source files skipped by \`.tamagui-flat-values-ignore\` markers`,
128
+ `- ${jsx.length} JSX sites: ${cleanJsx} clean, ${jsx.length - cleanJsx} need review`,
129
+ `- ${styled.length} styled config sites: ${cleanStyled} clean, ${styled.length - cleanStyled} need review`,
130
+ `- ${functionalVariants.length} functional variant sites found`,
131
+ `- ${convertedFunctionalVariants.length} functional variants have automatic styled.dynamic rewrites`,
132
+ `- ${flaggedFunctionalVariants.length} functional variants need manual migration`,
133
+ `- ${sheetFrames.length} Sheet.Frame sites rewritten to Sheet.Container plus Sheet.Background`,
134
+ `- ${flaggedSheetFrames.length} Sheet.Frame sites need a human to place a spread or a styled target`,
135
+ `- ${transitions.length} v2 transition values rewritten to the v3 spelling`,
136
+ `- ${flaggedTransitions.length} v2 transition values need a hand migration`,
137
+ '',
138
+ '### Functional variant flag reasons',
139
+ '',
140
+ ...(functionalFlagCounts.length
141
+ ? functionalFlagCounts.map(([code, count]) => `- ${code}: ${count}`)
142
+ : ['- none']),
143
+ '',
144
+ '### Remaining manual migration',
145
+ '',
146
+ `${readyFiles.length} of ${filesWithSites} files have no legacy condition object, flagged functional variant, or flagged Sheet.Frame left after`,
147
+ 'conversion. V3 has no compatibility setting; finish the remaining files directly:',
148
+ blockedFiles.length
149
+ ? blockedFiles.map((file) => `\`${file}\``).join(', ')
150
+ : 'every file in this corpus is fully migrated.',
151
+ '',
152
+ '### Flag reasons',
153
+ '',
154
+ ]
155
+
156
+ const flagCounts = countCodes(flagged.flatMap((site) => site.flags))
157
+ lines.push(
158
+ ...(flagCounts.length
159
+ ? flagCounts.map(([code, count]) => `- ${code}: ${count}`)
160
+ : ['- none'])
161
+ )
162
+
163
+ const warningCounts = countCodes(warnings.flatMap((site) => site.warnings))
164
+ lines.push(
165
+ '',
166
+ '### Configuration warning reasons',
167
+ '',
168
+ ...(warningCounts.length
169
+ ? warningCounts.map(([code, count]) => `- ${code}: ${count}`)
170
+ : ['- none'])
171
+ )
172
+
173
+ const inventoryCounts = countCodes(sites.flatMap((site) => site.inventory))
174
+ if (inventoryCounts.length) {
175
+ lines.push(
176
+ '',
177
+ '### Values left authored for another migration',
178
+ '',
179
+ 'These are not flat-value migration work. The conversion keeps them exactly as',
180
+ 'authored and records them so their follow-up migrations have a corpus inventory.',
181
+ '',
182
+ ...inventoryCounts.map(([code, count]) => `- ${code}: ${count}`)
183
+ )
184
+ }
185
+
186
+ const pendingCounts = countCodes(sites.flatMap((site) => site.pending))
187
+ if (pendingCounts.length) {
188
+ lines.push(
189
+ '',
190
+ '### Waiting on runtime support',
191
+ '',
192
+ 'The conversion is known but not offered, because the runtime cannot read it yet.',
193
+ 'Each pending reason below names the exact host or merge contract that must land',
194
+ 'before the suggested source is safe to apply.',
195
+ '',
196
+ ...pendingCounts.map(([code, count]) => `- ${code}: ${count}`)
197
+ )
198
+ }
199
+
200
+ if (registryDiagnostics.length) {
201
+ lines.push('', '### Modifier registry diagnostics', '')
202
+ for (const diagnostic of registryDiagnostics) lines.push(`- ${diagnostic}`)
203
+ }
204
+
205
+ for (const file of files) {
206
+ if (!touched(file)) continue
207
+ lines.push('', `## \`${file.file}\``, '')
208
+ for (const site of file.transitions) {
209
+ lines.push(
210
+ `### ${site.label} at line ${site.line} (${site.flags.length ? 'review' : 'automatic'})`,
211
+ '',
212
+ 'Before:',
213
+ '',
214
+ '```tsx',
215
+ site.before,
216
+ '```',
217
+ '',
218
+ 'Automatic rewrite:',
219
+ '',
220
+ '```tsx',
221
+ site.after,
222
+ '```'
223
+ )
224
+ if (site.flags.length) {
225
+ lines.push('', 'Flags:', '')
226
+ for (const flag of site.flags) lines.push(`- **${flag.code}**: ${flag.detail}`)
227
+ }
228
+ }
229
+ for (const site of file.sheetFrames) {
230
+ lines.push(
231
+ `### ${site.label} at line ${site.line} (${site.flags.length ? 'review' : 'automatic'})`,
232
+ '',
233
+ 'Before:',
234
+ '',
235
+ '```tsx',
236
+ site.before,
237
+ '```',
238
+ '',
239
+ 'Automatic rewrite:',
240
+ '',
241
+ '```tsx',
242
+ site.after,
243
+ '```'
244
+ )
245
+ if (site.flags.length) {
246
+ lines.push('', 'Flags:', '')
247
+ for (const flag of site.flags) lines.push(`- **${flag.code}**: ${flag.detail}`)
248
+ }
249
+ lines.push('')
250
+ }
251
+ for (const site of file.functionalVariants) {
252
+ lines.push(
253
+ `### ${site.label} functional variant at line ${site.line} (${site.converted ? 'automatic' : 'manual'})`,
254
+ '',
255
+ 'Before:',
256
+ '',
257
+ '```tsx',
258
+ site.before,
259
+ '```',
260
+ '',
261
+ site.converted ? 'Automatic rewrite:' : 'Left authored:',
262
+ '',
263
+ '```tsx',
264
+ site.after,
265
+ '```'
266
+ )
267
+ if (site.flags.length) {
268
+ lines.push('', 'Flags:', '')
269
+ for (const flag of site.flags) lines.push(`- **${flag.code}**: ${flag.detail}`)
270
+ }
271
+ if (site.draft) {
272
+ lines.push('', 'Generated `.resolve` draft:', '', '```tsx', site.draft, '```')
273
+ }
274
+ if (site.notes.length) {
275
+ lines.push('', 'Notes:', '')
276
+ for (const note of site.notes) lines.push(`- ${note}`)
277
+ }
278
+ lines.push('')
279
+ }
280
+ for (const site of file.sites) {
281
+ const status = [
282
+ site.assessmentVerdict === 'clean' ? null : site.assessmentVerdict,
283
+ site.flags.length ? 'syntax-blocked' : null,
284
+ site.warnings.length ? 'configuration-warning' : null,
285
+ ]
286
+ .filter(Boolean)
287
+ .join(', ')
288
+ lines.push(
289
+ `### ${site.label} at line ${site.line} (${status || 'clean'})`,
290
+ '',
291
+ 'Before:',
292
+ '',
293
+ '```tsx',
294
+ site.before,
295
+ '```',
296
+ '',
297
+ 'After:',
298
+ '',
299
+ '```tsx',
300
+ site.after,
301
+ '```'
302
+ )
303
+ if (site.flags.length) {
304
+ lines.push('', 'Flags:', '')
305
+ for (const flag of site.flags) lines.push(`- **${flag.code}**: ${flag.detail}`)
306
+ }
307
+ if (site.warnings.length) {
308
+ lines.push('', 'Configuration warnings:', '')
309
+ for (const warning of site.warnings) {
310
+ lines.push(`- **${warning.code}**: ${warning.detail}`)
311
+ }
312
+ }
313
+ if (site.assessments.length) {
314
+ lines.push('', 'Conversion assessment:', '')
315
+ for (const assessment of site.assessments) {
316
+ for (const reason of assessment.reasons) {
317
+ lines.push(
318
+ `- **${assessment.verdict}: ${assessment.property}**: ${reason.message}. Remedy: ${reason.remedy}.`
319
+ )
320
+ }
321
+ }
322
+ }
323
+ if (site.inventory.length) {
324
+ lines.push('', 'Left authored:', '')
325
+ for (const flag of site.inventory)
326
+ lines.push(`- **${flag.code}**: ${flag.detail}`)
327
+ }
328
+ if (site.pending.length) {
329
+ lines.push('', 'Waiting on runtime support:', '')
330
+ for (const flag of site.pending) lines.push(`- **${flag.code}**: ${flag.detail}`)
331
+ }
332
+ if (site.notes.length) {
333
+ lines.push('', 'Notes:', '')
334
+ for (const note of site.notes) lines.push(`- ${note}`)
335
+ }
336
+ lines.push('')
337
+ }
338
+ }
339
+
340
+ return {
341
+ text: `${lines.join('\n')}\n`,
342
+ summary: {
343
+ sites: sites.length,
344
+ clean: clean.length,
345
+ needsRelocation: needsRelocation.length,
346
+ unknownHost: unknownHost.length,
347
+ ineligible: ineligible.length,
348
+ flagged: flagged.length,
349
+ warnings: warnings.length,
350
+ waiting: waiting.length,
351
+ ignoredFiles,
352
+ functionalVariantSites: functionalVariants.length,
353
+ functionalVariantConverted: convertedFunctionalVariants.length,
354
+ functionalVariantFlagged: flaggedFunctionalVariants.length,
355
+ functionalVariantFlags: Object.fromEntries(functionalFlagCounts),
356
+ sheetFrames: sheetFrames.length,
357
+ sheetFramesFlagged: flaggedSheetFrames.length,
358
+ transitions: transitions.length,
359
+ transitionsFlagged: flaggedTransitions.length,
360
+ },
361
+ }
362
+ }