@maggioli-design-system/magma-codemods 2.0.0-beta.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 (44) hide show
  1. package/README.md +193 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +77 -0
  4. package/dist/generate/diff-docs.d.ts +36 -0
  5. package/dist/generate/diff-docs.js +180 -0
  6. package/dist/index.d.ts +39 -0
  7. package/dist/index.js +166 -0
  8. package/dist/manifest/manifest.d.ts +3 -0
  9. package/dist/manifest/manifest.generated.d.ts +2 -0
  10. package/dist/manifest/manifest.generated.js +1901 -0
  11. package/dist/manifest/manifest.js +546 -0
  12. package/dist/manifest/registry.d.ts +32 -0
  13. package/dist/manifest/registry.js +102 -0
  14. package/dist/manifest/schema.d.ts +245 -0
  15. package/dist/manifest/schema.js +9 -0
  16. package/dist/report/diff.d.ts +2 -0
  17. package/dist/report/diff.js +95 -0
  18. package/dist/report/reporter.d.ts +27 -0
  19. package/dist/report/reporter.js +118 -0
  20. package/dist/report/types.d.ts +51 -0
  21. package/dist/report/types.js +2 -0
  22. package/dist/surfaces/angular.d.ts +3 -0
  23. package/dist/surfaces/angular.js +489 -0
  24. package/dist/surfaces/css.d.ts +5 -0
  25. package/dist/surfaces/css.js +287 -0
  26. package/dist/surfaces/html.d.ts +3 -0
  27. package/dist/surfaces/html.js +402 -0
  28. package/dist/surfaces/inline-templates.d.ts +3 -0
  29. package/dist/surfaces/inline-templates.js +98 -0
  30. package/dist/surfaces/react.d.ts +3 -0
  31. package/dist/surfaces/react.js +496 -0
  32. package/dist/surfaces/shared/attribute-ops.d.ts +56 -0
  33. package/dist/surfaces/shared/attribute-ops.js +62 -0
  34. package/dist/surfaces/shared/class-ops.d.ts +55 -0
  35. package/dist/surfaces/shared/class-ops.js +91 -0
  36. package/dist/surfaces/shared/edits.d.ts +19 -0
  37. package/dist/surfaces/shared/edits.js +30 -0
  38. package/dist/surfaces/shared/negate.d.ts +13 -0
  39. package/dist/surfaces/shared/negate.js +51 -0
  40. package/dist/surfaces/shared/transform.d.ts +17 -0
  41. package/dist/surfaces/shared/transform.js +8 -0
  42. package/dist/surfaces/shared/value-model.d.ts +49 -0
  43. package/dist/surfaces/shared/value-model.js +18 -0
  44. package/package.json +61 -0
@@ -0,0 +1,3 @@
1
+ import { type Manifest } from '../manifest/schema.js';
2
+ import { type TransformContext, type TransformResult } from './shared/transform.js';
3
+ export declare const transformInlineTemplates: (source: string, manifest: Manifest, ctx: TransformContext) => TransformResult;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Inline-template surface for `.ts` files. Extracts and rewrites:
3
+ *
4
+ * - Angular `@Component({ template: `…` })` inline templates → {@link transformAngular}.
5
+ * - Generic inline HTML in no-substitution template literals that contain
6
+ * `<mds-…>` tags or migrated utility classes (e.g. `el.innerHTML = `…``)
7
+ * → {@link transformHtml}.
8
+ *
9
+ * Template literals with `${…}` substitutions are reported under `dynamic`
10
+ * (the holes make the markup unsafe to parse) rather than rewritten. Host
11
+ * bindings (`@Component({ host: {…} })`) are intentionally not rewritten:
12
+ * applying `mds-*` rules to a consumer component's own host is almost never
13
+ * correct, so it is left for manual review.
14
+ */
15
+ import { Node, Project, SyntaxKind, ts } from 'ts-morph';
16
+ import { applyEdits } from './shared/edits.js';
17
+ import { classRulesOf } from './shared/class-ops.js';
18
+ import { transformAngular } from './angular.js';
19
+ import { transformHtml } from './html.js';
20
+ const MDS_TAG_RE = /<mds-[a-z-]/;
21
+ export const transformInlineTemplates = (source, manifest, ctx) => {
22
+ const project = new Project({
23
+ useInMemoryFileSystem: true,
24
+ skipFileDependencyResolution: true,
25
+ compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true },
26
+ });
27
+ const sf = project.createSourceFile('__codemod__.ts', source, { overwrite: true });
28
+ const edits = [];
29
+ const findings = [];
30
+ const handled = new Set();
31
+ // A literal is worth parsing when it contains mds-* markup OR mentions a
32
+ // migrated utility class (J) — those live on any element, not just mds-*.
33
+ const classRules = classRulesOf(manifest);
34
+ const relevant = (chunk) => MDS_TAG_RE.test(chunk) || (classRules.candidateRe?.test(chunk) ?? false);
35
+ /** Transform the raw inner text of a no-substitution template literal node. */
36
+ const transformLiteral = (node, run) => {
37
+ handled.add(node.getStart());
38
+ const innerStart = node.getStart() + 1;
39
+ const innerEnd = node.getEnd() - 1;
40
+ const inner = source.slice(innerStart, innerEnd);
41
+ if (!relevant(inner))
42
+ return;
43
+ const sub = run(inner, manifest, ctx);
44
+ const baseLine = node.getStartLineNumber();
45
+ for (const f of sub.findings) {
46
+ findings.push({ ...f, file: ctx.file, line: f.line ? baseLine + f.line - 1 : baseLine });
47
+ }
48
+ if (sub.changed)
49
+ edits.push({ start: innerStart, end: innerEnd, text: sub.output });
50
+ };
51
+ const flagDynamic = (node, message) => {
52
+ handled.add(node.getStart());
53
+ if (!relevant(node.getText()))
54
+ return;
55
+ findings.push({
56
+ kind: 'dynamic',
57
+ surface: 'html',
58
+ file: ctx.file,
59
+ line: node.getStartLineNumber(),
60
+ message,
61
+ });
62
+ };
63
+ // 1. Angular @Component({ template: `…` }) inline templates.
64
+ for (const dec of sf.getDescendantsOfKind(SyntaxKind.Decorator)) {
65
+ if (dec.getName() !== 'Component')
66
+ continue;
67
+ const call = dec.getCallExpression();
68
+ const arg = call?.getArguments()[0];
69
+ if (!arg || !Node.isObjectLiteralExpression(arg))
70
+ continue;
71
+ const templateProp = arg.getProperty('template');
72
+ if (!templateProp || !Node.isPropertyAssignment(templateProp))
73
+ continue;
74
+ const init = templateProp.getInitializer();
75
+ if (!init)
76
+ continue;
77
+ if (init.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
78
+ transformLiteral(init, transformAngular);
79
+ }
80
+ else if (init.getKind() === SyntaxKind.TemplateExpression) {
81
+ flagDynamic(init, 'inline Angular template contains ${} interpolation; migrate it manually');
82
+ }
83
+ }
84
+ // 2. Generic inline HTML in no-substitution template literals with <mds-…>.
85
+ for (const lit of sf.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
86
+ if (handled.has(lit.getStart()))
87
+ continue;
88
+ transformLiteral(lit, transformHtml);
89
+ }
90
+ for (const te of sf.getDescendantsOfKind(SyntaxKind.TemplateExpression)) {
91
+ if (handled.has(te.getStart()))
92
+ continue;
93
+ flagDynamic(te, 'inline HTML template contains ${} interpolation; migrate it manually');
94
+ }
95
+ if (edits.length === 0)
96
+ return { output: source, changed: false, findings };
97
+ return { output: applyEdits(source, edits), changed: true, findings };
98
+ };
@@ -0,0 +1,3 @@
1
+ import { type Manifest } from '../manifest/schema.js';
2
+ import { type TransformContext, type TransformResult } from './shared/transform.js';
3
+ export declare const transformReact: (source: string, manifest: Manifest, ctx: TransformContext) => TransformResult;
@@ -0,0 +1,496 @@
1
+ /**
2
+ * React (JSX/TSX) surface. ts-morph is used purely as a parser to locate JSX
3
+ * elements and read their attribute/children shapes; transformations are
4
+ * emitted as offset edits on the original source (via {@link applyEdits}) so
5
+ * formatting is preserved and node invalidation is a non-issue.
6
+ *
7
+ * Both the PascalCase React components (`<MdsButton>`) and the custom elements
8
+ * used directly as intrinsic JSX tags (`<mds-button>`) are matched; intrinsic
9
+ * elements are matched and emitted with the kebab-case attribute spelling.
10
+ *
11
+ * Unanalyzable usage — spread props (`{...props}`), aliased components, computed
12
+ * names, dynamic children — is reported under the `dynamic` category instead of
13
+ * being rewritten.
14
+ */
15
+ import { Project, SyntaxKind, ts, } from 'ts-morph';
16
+ import { getByReactName, getByTag, ruleId, rulesForComponent, tagRenamesOf, } from '../manifest/registry.js';
17
+ import { classRulesOf, hasClassRules, rewriteClassList } from './shared/class-ops.js';
18
+ import { invertBoolean, remapEnum } from './shared/attribute-ops.js';
19
+ import { bareTrue, boolLiteral, dynamic, stringLiteral, } from './shared/value-model.js';
20
+ import { applyEdits } from './shared/edits.js';
21
+ import { ruleEnabled } from './shared/transform.js';
22
+ const jsxStringFor = (text) => /["&<>\n]/.test(text) ? `{${JSON.stringify(text)}}` : `"${text}"`;
23
+ const collapseWhitespace = (s) => s.replace(/\s+/g, ' ').trim();
24
+ export const transformReact = (source, manifest, ctx) => {
25
+ const project = new Project({
26
+ useInMemoryFileSystem: true,
27
+ skipFileDependencyResolution: true,
28
+ compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true },
29
+ });
30
+ const sf = project.createSourceFile('__codemod__.tsx', source, { overwrite: true });
31
+ const edits = [];
32
+ const findings = [];
33
+ const openings = [
34
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
35
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
36
+ ];
37
+ for (const opening of openings) {
38
+ const tagName = opening.getTagNameNode().getText();
39
+ // A dash can only appear in a plain intrinsic tag (member expressions
40
+ // contain `.`, namespaced names `:`), so it safely discriminates
41
+ // `<mds-button>` from `<MdsButton>`.
42
+ const isIntrinsic = tagName.includes('-');
43
+ const component = isIntrinsic ? getByTag(manifest, tagName) : getByReactName(manifest, tagName);
44
+ if (!component)
45
+ continue;
46
+ const tag = component.tag;
47
+ const line = opening.getStartLineNumber();
48
+ // Intrinsic custom elements take the kebab-case attribute spelling (React
49
+ // sets attributes on unknown elements); React components take the prop.
50
+ const nameOf = (p) => (isIntrinsic ? p.attr : p.prop);
51
+ const attributes = opening.getAttributes();
52
+ const hasSpread = attributes.some((a) => a.getKind() === SyntaxKind.JsxSpreadAttribute);
53
+ const jsxAttrs = attributes.filter((a) => a.getKind() === SyntaxKind.JsxAttribute);
54
+ const findAttr = (name) => jsxAttrs.find((a) => a.getNameNode().getText() === name);
55
+ const hasAttr = (name) => findAttr(name) !== undefined;
56
+ if (hasSpread) {
57
+ findings.push({
58
+ kind: 'dynamic',
59
+ surface: 'react',
60
+ file: ctx.file,
61
+ line,
62
+ component: tag,
63
+ message: `<${tagName}> uses spread props ({...}); verify v2 props by hand`,
64
+ });
65
+ }
66
+ for (const rule of rulesForComponent(manifest, component)) {
67
+ const id = ruleId(tag, rule);
68
+ if (!ruleEnabled(ctx, id))
69
+ continue;
70
+ switch (rule.kind) {
71
+ case 'booleanInvert':
72
+ applyBooleanInvert(rule, id);
73
+ break;
74
+ case 'propRename':
75
+ applyPropRename(rule, id);
76
+ break;
77
+ case 'propRemove':
78
+ applyPropRemove(rule, id);
79
+ break;
80
+ case 'enumRemap':
81
+ applyEnumRemap(rule, id);
82
+ break;
83
+ case 'slotToAttr':
84
+ applySlotToAttr(rule, id);
85
+ break;
86
+ case 'ensureAttr':
87
+ applyEnsureAttr(rule, id);
88
+ break;
89
+ case 'slotRemove':
90
+ applySlotRemove(rule, id);
91
+ break;
92
+ default:
93
+ break;
94
+ }
95
+ }
96
+ function applyEnsureAttr(rule, id) {
97
+ if (hasSpread)
98
+ return; // a spread may already set the prop; don't risk a duplicate
99
+ if (rule.unless.some((p) => hasAttr(nameOf(p))))
100
+ return;
101
+ const nameEnd = opening.getTagNameNode().getEnd();
102
+ const after = rule.value === undefined ? nameOf(rule.attr) : `${nameOf(rule.attr)}="${rule.value}"`;
103
+ edits.push({ start: nameEnd, end: nameEnd, text: ` ${after}` });
104
+ findings.push({
105
+ kind: 'change',
106
+ surface: 'react',
107
+ file: ctx.file,
108
+ line,
109
+ ruleId: id,
110
+ component: tag,
111
+ message: rule.reason,
112
+ after,
113
+ });
114
+ }
115
+ function applySlotRemove(rule, id) {
116
+ const element = opening.getParentIfKind(SyntaxKind.JsxElement);
117
+ if (!element)
118
+ return;
119
+ for (const child of element.getJsxChildren()) {
120
+ let childOpening;
121
+ if (child.getKind() === SyntaxKind.JsxElement)
122
+ childOpening = child.asKindOrThrow(SyntaxKind.JsxElement).getOpeningElement();
123
+ else if (child.getKind() === SyntaxKind.JsxSelfClosingElement)
124
+ childOpening = child.asKindOrThrow(SyntaxKind.JsxSelfClosingElement);
125
+ else
126
+ continue;
127
+ const slotAttr = childOpening
128
+ .getAttributes()
129
+ .filter((a) => a.getKind() === SyntaxKind.JsxAttribute)
130
+ .find((a) => a.getNameNode().getText() === 'slot');
131
+ const init = slotAttr?.getInitializer();
132
+ if (!init || init.getKind() !== SyntaxKind.StringLiteral)
133
+ continue;
134
+ if (init.getText().slice(1, -1) !== rule.from)
135
+ continue;
136
+ findings.push({
137
+ kind: 'warn',
138
+ surface: 'react',
139
+ file: ctx.file,
140
+ line: child.getStartLineNumber(),
141
+ ruleId: id,
142
+ component: tag,
143
+ message: `the \`${rule.from}\` slot of ${tag} was removed in v2; migrate the slotted content manually`,
144
+ });
145
+ }
146
+ }
147
+ function removeAttrEdit(attr) {
148
+ const start = attr.getStart();
149
+ const realStart = start > 0 && source[start - 1] === ' ' ? start - 1 : start;
150
+ return { start: realStart, end: attr.getEnd(), text: '' };
151
+ }
152
+ function valueOf(attr) {
153
+ const init = attr.getInitializer();
154
+ if (!init)
155
+ return bareTrue();
156
+ if (init.getKind() === SyntaxKind.StringLiteral)
157
+ return stringLiteral(init.getText().slice(1, -1));
158
+ if (init.getKind() === SyntaxKind.JsxExpression) {
159
+ const expr = init.getExpression();
160
+ if (!expr)
161
+ return dynamic('');
162
+ if (expr.getKind() === SyntaxKind.TrueKeyword)
163
+ return boolLiteral(true);
164
+ if (expr.getKind() === SyntaxKind.FalseKeyword)
165
+ return boolLiteral(false);
166
+ return dynamic(expr.getText());
167
+ }
168
+ return dynamic(init.getText());
169
+ }
170
+ function applyBooleanInvert(rule, id) {
171
+ const attr = findAttr(nameOf(rule.from));
172
+ if (!attr)
173
+ return;
174
+ const outcome = invertBoolean(valueOf(attr), rule.newDefault);
175
+ let after = '';
176
+ if (outcome.action === 'omit') {
177
+ edits.push(removeAttrEdit(attr));
178
+ }
179
+ else if (outcome.action === 'shorthandTrue') {
180
+ after = nameOf(rule.to);
181
+ edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
182
+ }
183
+ else if (outcome.action === 'explicitFalse') {
184
+ // Stencil parses the attribute string "false" as false (see resolveBoolean).
185
+ after = isIntrinsic ? `${rule.to.attr}="false"` : `${rule.to.prop}={false}`;
186
+ edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
187
+ }
188
+ else {
189
+ after = `${nameOf(rule.to)}={${outcome.expr}}`;
190
+ edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
191
+ }
192
+ findings.push({
193
+ kind: 'change',
194
+ surface: 'react',
195
+ file: ctx.file,
196
+ line,
197
+ ruleId: id,
198
+ component: tag,
199
+ message: `invert ${rule.from.prop} → ${rule.to.prop}`,
200
+ before: attr.getText(),
201
+ after,
202
+ });
203
+ }
204
+ function applyPropRename(rule, id) {
205
+ const attr = findAttr(nameOf(rule.from));
206
+ if (!attr)
207
+ return;
208
+ const nameNode = attr.getNameNode();
209
+ edits.push({ start: nameNode.getStart(), end: nameNode.getEnd(), text: nameOf(rule.to) });
210
+ findings.push({
211
+ kind: 'change',
212
+ surface: 'react',
213
+ file: ctx.file,
214
+ line,
215
+ ruleId: id,
216
+ component: tag,
217
+ message: `rename ${rule.from.prop} → ${rule.to.prop}`,
218
+ before: nameOf(rule.from),
219
+ after: nameOf(rule.to),
220
+ });
221
+ }
222
+ function applyPropRemove(rule, id) {
223
+ const attr = findAttr(nameOf(rule.prop));
224
+ if (!attr)
225
+ return;
226
+ edits.push(removeAttrEdit(attr));
227
+ findings.push({
228
+ kind: 'warn',
229
+ surface: 'react',
230
+ file: ctx.file,
231
+ line,
232
+ ruleId: id,
233
+ component: tag,
234
+ message: rule.message,
235
+ });
236
+ }
237
+ function applyEnumRemap(rule, id) {
238
+ const attr = findAttr(nameOf(rule.prop));
239
+ if (!attr)
240
+ return;
241
+ const v2set = rule.v2set ? component.v2EnumSets?.[rule.v2set] : undefined;
242
+ const outcome = remapEnum(valueOf(attr), rule.map, v2set);
243
+ if (outcome.action === 'rename') {
244
+ edits.push({
245
+ start: attr.getStart(),
246
+ end: attr.getEnd(),
247
+ text: `${nameOf(rule.prop)}="${outcome.value}"`,
248
+ });
249
+ findings.push({
250
+ kind: 'change',
251
+ surface: 'react',
252
+ file: ctx.file,
253
+ line,
254
+ ruleId: id,
255
+ component: tag,
256
+ message: `remap ${rule.prop.prop}`,
257
+ after: outcome.value,
258
+ });
259
+ }
260
+ else if (outcome.action === 'flag') {
261
+ const kind = valueOf(attr).kind === 'dynamic' ? 'dynamic' : 'flag';
262
+ findings.push({
263
+ kind,
264
+ surface: 'react',
265
+ file: ctx.file,
266
+ line,
267
+ ruleId: id,
268
+ component: tag,
269
+ message: `${rule.prop.prop}: ${outcome.reason}`,
270
+ });
271
+ }
272
+ }
273
+ function applySlotToAttr(rule, id) {
274
+ if (rule.slot !== 'default')
275
+ return;
276
+ if (hasAttr(nameOf(rule.to)))
277
+ return;
278
+ const element = opening.getParentIfKind(SyntaxKind.JsxElement);
279
+ if (!element)
280
+ return; // self-closing → no children to lift
281
+ const children = element.getJsxChildren();
282
+ const elems = children.filter((c) => c.getKind() === SyntaxKind.JsxElement ||
283
+ c.getKind() === SyntaxKind.JsxSelfClosingElement ||
284
+ c.getKind() === SyntaxKind.JsxFragment);
285
+ const exprs = children.filter((c) => c.getKind() === SyntaxKind.JsxExpression);
286
+ const text = collapseWhitespace(children
287
+ .filter((c) => c.getKind() === SyntaxKind.JsxText)
288
+ .map((c) => c.getText())
289
+ .join(''));
290
+ const flag = (message) => {
291
+ findings.push({
292
+ kind: 'dynamic',
293
+ surface: 'react',
294
+ file: ctx.file,
295
+ line,
296
+ ruleId: id,
297
+ component: tag,
298
+ message,
299
+ });
300
+ };
301
+ let labelAttr;
302
+ let before = '';
303
+ if (elems.length > 0) {
304
+ flag(`<${tagName}> content contains elements; move the text into \`${nameOf(rule.to)}\` manually`);
305
+ return;
306
+ }
307
+ else if (exprs.length === 0 && text !== '') {
308
+ labelAttr = `${nameOf(rule.to)}=${jsxStringFor(text)}`;
309
+ before = text;
310
+ }
311
+ else if (exprs.length === 1 && text === '') {
312
+ const expr = exprs[0].getExpression();
313
+ if (!expr)
314
+ return;
315
+ labelAttr = `${nameOf(rule.to)}={${expr.getText()}}`;
316
+ before = `{${expr.getText()}}`;
317
+ }
318
+ else if (exprs.length === 0 && text === '') {
319
+ return; // empty element
320
+ }
321
+ else {
322
+ flag(`<${tagName}> has mixed text/expression content; move it into \`${nameOf(rule.to)}\` manually`);
323
+ return;
324
+ }
325
+ // Replace `>children</Tag>` with ` label=... />` (one edit; attribute edits sit before this range).
326
+ const start = opening.getEnd() - 1;
327
+ edits.push({ start, end: element.getEnd(), text: ` ${labelAttr} />` });
328
+ findings.push({
329
+ kind: 'change',
330
+ surface: 'react',
331
+ file: ctx.file,
332
+ line,
333
+ ruleId: id,
334
+ component: tag,
335
+ message: `lift content into \`${nameOf(rule.to)}\``,
336
+ before,
337
+ after: labelAttr,
338
+ });
339
+ }
340
+ }
341
+ // Global: utility-class migrations (J) on `className` / `class` of ANY JSX
342
+ // element (the classes are not tied to an mds-* component). String literals
343
+ // and no-substitution templates anywhere in the attribute expression are
344
+ // rewritten (covers clsx()/classnames(), ternaries, `||` fallbacks);
345
+ // substitution templates can split a class across chunks, so they are
346
+ // reported instead.
347
+ const classRules = classRulesOf(manifest);
348
+ if (hasClassRules(classRules)) {
349
+ const rewriteLiteralContent = (node) => {
350
+ const inner = source.slice(node.getStart() + 1, node.getEnd() - 1);
351
+ const line = node.getStartLineNumber();
352
+ const result = rewriteClassList(inner, classRules, (id) => ruleEnabled(ctx, id), (entry, before, after) => {
353
+ findings.push({
354
+ kind: 'change',
355
+ surface: 'react',
356
+ file: ctx.file,
357
+ line,
358
+ ruleId: entry.id,
359
+ message: 'rename utility class',
360
+ before,
361
+ after,
362
+ });
363
+ if (entry.rule.note) {
364
+ findings.push({
365
+ kind: 'flag',
366
+ surface: 'react',
367
+ file: ctx.file,
368
+ line,
369
+ ruleId: entry.id,
370
+ message: entry.rule.note,
371
+ });
372
+ }
373
+ }, (entry, token) => {
374
+ findings.push({
375
+ kind: 'warn',
376
+ surface: 'react',
377
+ file: ctx.file,
378
+ line,
379
+ ruleId: entry.id,
380
+ message: `\`${token}\`: ${entry.rule.message}`,
381
+ });
382
+ });
383
+ if (result.changed)
384
+ edits.push({ start: node.getStart() + 1, end: node.getEnd() - 1, text: result.value });
385
+ };
386
+ const isPlainLiteral = (node) => node.getKind() === SyntaxKind.StringLiteral ||
387
+ node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral;
388
+ for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
389
+ const name = attr.getNameNode().getText();
390
+ if (name !== 'className' && name !== 'class')
391
+ continue;
392
+ const init = attr.getInitializer();
393
+ if (!init)
394
+ continue;
395
+ if (isPlainLiteral(init)) {
396
+ rewriteLiteralContent(init);
397
+ continue;
398
+ }
399
+ if (init.getKind() !== SyntaxKind.JsxExpression)
400
+ continue;
401
+ const expr = init.getExpression();
402
+ if (!expr)
403
+ continue;
404
+ const literals = [
405
+ ...(isPlainLiteral(expr) ? [expr] : []),
406
+ ...expr.getDescendantsOfKind(SyntaxKind.StringLiteral),
407
+ ...expr.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral),
408
+ ];
409
+ for (const literal of literals)
410
+ rewriteLiteralContent(literal);
411
+ const templates = [
412
+ ...(expr.getKind() === SyntaxKind.TemplateExpression ? [expr] : []),
413
+ ...expr.getDescendantsOfKind(SyntaxKind.TemplateExpression),
414
+ ];
415
+ for (const template of templates) {
416
+ if (!classRules.candidateRe?.test(template.getText()))
417
+ continue;
418
+ findings.push({
419
+ kind: 'dynamic',
420
+ surface: 'react',
421
+ file: ctx.file,
422
+ line: template.getStartLineNumber(),
423
+ message: `template literal in \`${name}\` mentions a migrated utility class; rewrite it manually`,
424
+ });
425
+ }
426
+ }
427
+ }
428
+ // K: tag renames. A separate pass over the tag names, so a component the
429
+ // manifest does not otherwise touch is renamed too. Every rename is looked up
430
+ // by the name AS WRITTEN, so a v1 name that another component takes in v2 is
431
+ // never renamed twice.
432
+ const tagRenames = tagRenamesOf(manifest);
433
+ if (tagRenames.size > 0) {
434
+ const byReact = new Map();
435
+ for (const [tag, rule] of tagRenames) {
436
+ const component = getByTag(manifest, tag);
437
+ if (component)
438
+ byReact.set(component.react, { tag, rule });
439
+ }
440
+ const renamedAt = new Set();
441
+ const renameNode = (node, tag, from, to) => {
442
+ const start = node.getStart();
443
+ if (renamedAt.has(start))
444
+ return;
445
+ renamedAt.add(start);
446
+ edits.push({ start, end: node.getEnd(), text: to });
447
+ findings.push({
448
+ kind: 'change',
449
+ surface: 'react',
450
+ file: ctx.file,
451
+ line: node.getStartLineNumber(),
452
+ component: tag,
453
+ ruleId: `${tag}/tagRename`,
454
+ message: `rename ${from} to ${to}`,
455
+ before: from,
456
+ after: to,
457
+ });
458
+ };
459
+ const tagNodes = [...openings, ...sf.getDescendantsOfKind(SyntaxKind.JsxClosingElement)].map((el) => el.getTagNameNode());
460
+ for (const nameNode of tagNodes) {
461
+ const name = nameNode.getText();
462
+ const hit = name.includes('-')
463
+ ? tagRenames.has(name)
464
+ ? { tag: name, to: tagRenames.get(name).to }
465
+ : undefined
466
+ : byReact.has(name)
467
+ ? { tag: byReact.get(name).tag, to: byReact.get(name).rule.toReact }
468
+ : undefined;
469
+ if (hit && ruleEnabled(ctx, `${hit.tag}/tagRename`))
470
+ renameNode(nameNode, hit.tag, name, hit.to);
471
+ }
472
+ // The named import from the React wrapper, and every other reference to it
473
+ // (`typeof MdsPrefTheme`, a re-export). An aliased import renames only the
474
+ // imported name: the local alias stays valid.
475
+ for (const decl of sf.getImportDeclarations()) {
476
+ if (!decl.getModuleSpecifierValue().startsWith('@maggioli-design-system/magma-react'))
477
+ continue;
478
+ for (const spec of decl.getNamedImports()) {
479
+ const name = spec.getName();
480
+ const hit = byReact.get(name);
481
+ if (!hit || !ruleEnabled(ctx, `${hit.tag}/tagRename`))
482
+ continue;
483
+ renameNode(spec.getNameNode(), hit.tag, name, hit.rule.toReact);
484
+ if (spec.getAliasNode())
485
+ continue;
486
+ for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
487
+ if (id.getText() === name)
488
+ renameNode(id, hit.tag, name, hit.rule.toReact);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ if (edits.length === 0)
494
+ return { output: source, changed: false, findings };
495
+ return { output: applyEdits(source, edits), changed: true, findings };
496
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Surface-agnostic transformation core. These functions take a parsed value
3
+ * (see {@link AttrValue}) plus the primitive parameters of a manifest rule and
4
+ * return an *outcome* describing what the surface should render — they never
5
+ * touch a concrete AST, so the logic is written and tested once.
6
+ */
7
+ import { type AttrValue } from './value-model.js';
8
+ /** What to do with the renamed boolean prop after inverting its value. */
9
+ export type InvertOutcome =
10
+ /** New value equals the v2 default → drop the prop entirely. */
11
+ {
12
+ action: 'omit';
13
+ }
14
+ /** New value is `true` → emit the prop in shorthand form. */
15
+ | {
16
+ action: 'shorthandTrue';
17
+ }
18
+ /** New value is `false` but the v2 default is `true` → emit an explicit `false`. */
19
+ | {
20
+ action: 'explicitFalse';
21
+ }
22
+ /** New value is dynamic → emit the prop with the negated expression text. */
23
+ | {
24
+ action: 'expr';
25
+ expr: string;
26
+ };
27
+ /**
28
+ * Invert a boolean prop's value (category B). The new prop's value is the
29
+ * logical negation of the old one; `newDefault` decides whether that value can
30
+ * be omitted. `oldDefault` is intentionally unused — for a boolean prop the
31
+ * shorthand always means `true` regardless of the declared default.
32
+ */
33
+ export declare const invertBoolean: (current: AttrValue, newDefault: boolean) => InvertOutcome;
34
+ /** Outcome of remapping an enum value (categories A and E). */
35
+ export type EnumRemapOutcome =
36
+ /** Map the literal to a new v2 value. */
37
+ {
38
+ action: 'rename';
39
+ value: string;
40
+ }
41
+ /** The value is already a valid v2 literal not covered by the map → leave it. */
42
+ | {
43
+ action: 'keep';
44
+ }
45
+ /** Cannot transform safely; surface should report it for manual review. */
46
+ | {
47
+ action: 'flag';
48
+ reason: string;
49
+ };
50
+ /**
51
+ * Remap an enum literal, validating the target against the component's v2 enum
52
+ * set when available. Dynamic values, map targets that are `null` (no 1:1
53
+ * replacement) and literals absent from the v2 set are flagged for the report
54
+ * instead of being rewritten blindly.
55
+ */
56
+ export declare const remapEnum: (current: AttrValue, map: Record<string, string | null>, v2set?: readonly string[]) => EnumRemapOutcome;