@astryxdesign/cli 0.1.2 → 0.1.3

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 (238) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +49 -55
  3. package/docs/layout.doc.dense.mjs +90 -0
  4. package/docs/layout.doc.mjs +160 -0
  5. package/docs/principles.doc.dense.mjs +2 -2
  6. package/docs/principles.doc.mjs +4 -0
  7. package/docs/principles.doc.zh.mjs +2 -2
  8. package/docs/theme.doc.mjs +2 -2
  9. package/package.json +13 -1
  10. package/src/api/blog.mjs +192 -0
  11. package/src/api/blog.test.mjs +144 -0
  12. package/src/api/component.mjs +253 -12
  13. package/src/api/discover.mjs +14 -7
  14. package/src/api/doctor.mjs +10 -25
  15. package/src/api/index.mjs +1 -0
  16. package/src/api/layout.mjs +11 -10
  17. package/src/api/layout.test.mjs +4 -1
  18. package/src/api/template-integration.test.mjs +225 -0
  19. package/src/api/template.mjs +242 -84
  20. package/src/api/validate-integration.mjs +370 -0
  21. package/src/api/validate-integration.test.mjs +222 -0
  22. package/src/codemod.mjs +93 -0
  23. package/src/codemod.test.mjs +134 -0
  24. package/src/codemods/__tests__/registry.test.mjs +1 -0
  25. package/src/codemods/__tests__/runner.test.mjs +103 -0
  26. package/src/codemods/integration-discovery.mjs +168 -0
  27. package/src/codemods/integration-discovery.test.mjs +234 -0
  28. package/src/codemods/integration-runner.mjs +109 -0
  29. package/src/codemods/registry.mjs +1 -0
  30. package/src/codemods/run-codemod.mjs +207 -0
  31. package/src/codemods/runner.mjs +73 -96
  32. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  33. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
  34. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
  35. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -0
  36. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  37. package/src/codemods/transforms/v0.1.0/index.mjs +43 -6
  38. package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
  39. package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
  40. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  41. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  42. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  43. package/src/commands/agent-docs.mjs +4 -1
  44. package/src/commands/blog.mjs +60 -0
  45. package/src/commands/build-theme.import-path.test.mjs +2 -12
  46. package/src/commands/build-theme.mjs +117 -0
  47. package/src/commands/build-theme.prose.test.mjs +5 -15
  48. package/src/commands/build-theme.watch.test.mjs +149 -0
  49. package/src/commands/component/index.mjs +44 -10
  50. package/src/commands/component-ownership.test.mjs +227 -0
  51. package/src/commands/discover.mjs +5 -16
  52. package/src/commands/doctor.test.mjs +3 -3
  53. package/src/commands/ensure-core-built.mjs +120 -0
  54. package/src/commands/json-contract.test.mjs +0 -32
  55. package/src/commands/swizzle.mjs +224 -235
  56. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  57. package/src/commands/swizzle.routing.test.mjs +279 -0
  58. package/src/commands/template.mjs +33 -37
  59. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  60. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  61. package/src/commands/upgrade.mjs +349 -170
  62. package/src/commands/validate-integration.mjs +110 -0
  63. package/src/commands/validate-integration.test.mjs +124 -0
  64. package/src/config.mjs +7 -20
  65. package/src/config.test.mjs +89 -13
  66. package/src/index.mjs +8 -3
  67. package/src/integration.mjs +19 -0
  68. package/src/lib/component-discovery.mjs +177 -0
  69. package/src/lib/config-cache.mjs +91 -0
  70. package/src/lib/config-cache.test.mjs +83 -0
  71. package/src/lib/config-schema.mjs +41 -55
  72. package/src/lib/error-codes.mjs +15 -12
  73. package/src/lib/integration-warnings.mjs +62 -0
  74. package/src/lib/integration-warnings.test.mjs +102 -0
  75. package/src/lib/integrations.mjs +92 -115
  76. package/src/lib/integrations.test.mjs +88 -107
  77. package/src/lib/manifest.mjs +5 -2
  78. package/src/lib/module-loader.mjs +80 -0
  79. package/src/lib/module-loader.test.mjs +106 -0
  80. package/src/lib/project.mjs +502 -0
  81. package/src/lib/project.test.mjs +308 -0
  82. package/src/lib/site.mjs +20 -0
  83. package/src/template.mjs +73 -0
  84. package/src/template.test.mjs +127 -0
  85. package/src/types/api.d.ts +2 -6
  86. package/src/types/base.d.ts +3 -9
  87. package/src/types/codemod.d.ts +81 -0
  88. package/src/types/component.d.ts +27 -2
  89. package/src/types/config.d.ts +56 -85
  90. package/src/types/error-codes.d.ts +5 -3
  91. package/src/types/index.d.ts +0 -1
  92. package/src/types/integration.d.ts +29 -0
  93. package/src/types/swizzle.d.ts +9 -2
  94. package/src/types/template-api.d.ts +54 -0
  95. package/src/types/template.d.ts +10 -7
  96. package/src/types/upgrade.d.ts +29 -0
  97. package/src/types/validate-integration.d.ts +24 -0
  98. package/src/utils/github.mjs +0 -237
  99. package/src/utils/interactive.mjs +2 -2
  100. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
  101. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
  102. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
  103. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
  104. package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
  105. package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
  106. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
  107. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
  108. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
  109. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
  110. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
  111. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
  112. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
  113. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
  114. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
  115. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
  116. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
  117. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
  118. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
  119. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
  120. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
  121. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
  122. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
  123. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
  124. package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
  125. package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
  126. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  127. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  128. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  129. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  130. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  131. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  132. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
  133. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
  134. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
  135. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
  136. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
  137. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
  138. package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
  139. package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
  140. package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
  141. package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
  142. package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
  143. package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
  144. package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
  145. package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
  146. package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
  147. package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
  148. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
  149. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
  150. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  151. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  152. package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
  153. package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
  154. package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
  155. package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
  156. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
  157. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
  158. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
  159. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
  160. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
  161. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
  162. package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
  163. package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
  164. package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
  165. package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
  166. package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
  167. package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
  168. package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
  169. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
  170. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
  171. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
  172. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
  173. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
  174. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
  175. package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
  176. package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
  177. package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
  178. package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
  179. package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
  180. package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
  181. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
  182. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
  183. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
  184. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
  185. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
  186. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
  187. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
  188. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
  189. package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
  190. package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
  191. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
  192. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
  193. package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
  194. package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
  195. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  196. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  197. package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
  198. package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
  199. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
  200. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
  201. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
  202. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
  203. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
  204. package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
  205. package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
  206. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
  207. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
  208. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
  209. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
  210. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
  211. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
  212. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
  213. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
  214. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
  215. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
  216. package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
  217. package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
  218. package/templates/pages/kanban-board/page.tsx +729 -0
  219. package/templates/pages/kanban-board/template.doc.mjs +12 -0
  220. package/templates/pages/shell-nav/page.tsx +321 -0
  221. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  222. package/templates/pages/shell-side-nav/page.tsx +241 -0
  223. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  224. package/templates/pages/shell-top-nav/page.tsx +224 -0
  225. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  226. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  227. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  228. package/src/commands/gap-report.mjs +0 -464
  229. package/src/commands/gap-report.test.mjs +0 -168
  230. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  231. package/src/lib/config.mjs +0 -113
  232. package/src/lib/config.test.mjs +0 -91
  233. package/src/types/gap-report.d.ts +0 -29
  234. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
  235. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
  236. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
  237. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
  238. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -0,0 +1,134 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {
5
+ createCodemod,
6
+ createConfigCodemod,
7
+ CodemodEnvelopeSchema,
8
+ } from './codemod.mjs';
9
+
10
+ // createCodemod/createConfigCodemod are now stamp-only: they inject the `type`
11
+ // discriminator and return the definition otherwise unchanged, performing NO
12
+ // runtime validation. Validation happens at the LOAD boundary (discovery runs
13
+ // the default export through CodemodEnvelopeSchema), so the rejection cases
14
+ // that used to assert factory throws now assert the envelope schema rejects.
15
+
16
+ describe('createCodemod (stamp-only)', () => {
17
+ it('stamps type: code and returns the def otherwise unchanged', () => {
18
+ const transform = file => file.source;
19
+ const result = createCodemod({title: 'Drop foo', transform});
20
+ expect(result.type).toBe('code');
21
+ expect(result.title).toBe('Drop foo');
22
+ expect(result.transform).toBe(transform);
23
+ // Stamp-only: it does NOT apply schema defaults (e.g. isOptional).
24
+ expect(result.isOptional).toBeUndefined();
25
+ });
26
+
27
+ it('preserves description, fileExtensions, and explicit isOptional', () => {
28
+ const result = createCodemod({
29
+ title: 'Rename',
30
+ description: 'renames things',
31
+ isOptional: true,
32
+ fileExtensions: ['.tsx'],
33
+ transform: () => null,
34
+ });
35
+ expect(result.description).toBe('renames things');
36
+ expect(result.isOptional).toBe(true);
37
+ expect(result.fileExtensions).toEqual(['.tsx']);
38
+ expect(result.type).toBe('code');
39
+ });
40
+
41
+ it('does NOT validate — returns an invalid def stamped, unchanged', () => {
42
+ const result = createCodemod({transform: () => null});
43
+ expect(result.type).toBe('code');
44
+ expect(result.title).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe('createConfigCodemod (stamp-only)', () => {
49
+ it('stamps type: config', () => {
50
+ const transform = file => file.source;
51
+ const result = createConfigCodemod({title: 'Config bump', transform});
52
+ expect(result.type).toBe('config');
53
+ expect(result.transform).toBe(transform);
54
+ });
55
+ });
56
+
57
+ describe('CodemodEnvelopeSchema (load-boundary validation)', () => {
58
+ it('accepts a stamped code codemod (incl. defaults)', () => {
59
+ const parsed = CodemodEnvelopeSchema.parse(
60
+ createCodemod({title: 'Drop foo', transform: () => null}),
61
+ );
62
+ expect(parsed.type).toBe('code');
63
+ expect(parsed.isOptional).toBe(false); // schema default applied at load
64
+ });
65
+
66
+ it('accepts a PLAIN OBJECT envelope (no factory required)', () => {
67
+ const parsed = CodemodEnvelopeSchema.parse({
68
+ type: 'code',
69
+ title: 'Hand-written',
70
+ transform: () => null,
71
+ });
72
+ expect(parsed.title).toBe('Hand-written');
73
+ });
74
+
75
+ it('accepts a stamped config codemod', () => {
76
+ const parsed = CodemodEnvelopeSchema.parse(
77
+ createConfigCodemod({title: 'Bump', transform: () => null}),
78
+ );
79
+ expect(parsed.type).toBe('config');
80
+ });
81
+
82
+ it('rejects a missing title', () => {
83
+ expect(() =>
84
+ CodemodEnvelopeSchema.parse({type: 'code', transform: () => null}),
85
+ ).toThrow(/title/i);
86
+ });
87
+
88
+ it('rejects a missing transform', () => {
89
+ expect(() =>
90
+ CodemodEnvelopeSchema.parse({type: 'code', title: 'x'}),
91
+ ).toThrow(/transform/i);
92
+ });
93
+
94
+ it('rejects a non-function transform', () => {
95
+ expect(() =>
96
+ CodemodEnvelopeSchema.parse({type: 'code', title: 'x', transform: 'nope'}),
97
+ ).toThrow(/transform/i);
98
+ });
99
+
100
+ it('rejects a missing/invalid type discriminator', () => {
101
+ expect(() =>
102
+ CodemodEnvelopeSchema.parse({title: 'x', transform: () => null}),
103
+ ).toThrow();
104
+ expect(() =>
105
+ CodemodEnvelopeSchema.parse({
106
+ type: 'bogus',
107
+ title: 'x',
108
+ transform: () => null,
109
+ }),
110
+ ).toThrow();
111
+ });
112
+
113
+ it('rejects unknown keys (strict)', () => {
114
+ expect(() =>
115
+ CodemodEnvelopeSchema.parse({
116
+ type: 'code',
117
+ title: 'x',
118
+ transform: () => null,
119
+ bogus: true,
120
+ }),
121
+ ).toThrow();
122
+ });
123
+
124
+ it('rejects fileExtensions on a config codemod', () => {
125
+ expect(() =>
126
+ CodemodEnvelopeSchema.parse({
127
+ type: 'config',
128
+ title: 'x',
129
+ transform: () => null,
130
+ fileExtensions: ['.ts'],
131
+ }),
132
+ ).toThrow();
133
+ });
134
+ });
@@ -18,6 +18,7 @@ describe('registry', () => {
18
18
  '0.0.15',
19
19
  '0.1.0',
20
20
  '0.1.2',
21
+ '0.1.3',
21
22
  ]);
22
23
  });
23
24
  });
@@ -0,0 +1,103 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {runCodemods} from '../runner.mjs';
7
+
8
+ let tmpDir;
9
+ let originalCwd;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd();
13
+ // Repo-local temp dir (not /tmp) to mirror the integration-runner tests and
14
+ // avoid any Vite dynamic-import quirks with absolute /tmp paths.
15
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-runner-test-'));
16
+ process.chdir(tmpDir);
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ fs.rmSync(tmpDir, {recursive: true, force: true});
22
+ });
23
+
24
+ describe('runCodemods — unified config codemod path', () => {
25
+ it('routes a core config codemod through the (file, api) runner to edit astryx.config.*', async () => {
26
+ fs.writeFileSync(
27
+ path.join(tmpDir, 'package.json'),
28
+ JSON.stringify({name: 'consumer'}),
29
+ );
30
+ fs.writeFileSync(
31
+ path.join(tmpDir, 'astryx.config.mjs'),
32
+ `export default { theme: 'old-theme' };\n`,
33
+ );
34
+
35
+ // A synthetic CORE registry transform marked as a config codemod via
36
+ // meta.codemodType === 'config'. It authors against the unified
37
+ // (file, api) => string contract, just like createConfigCodemod results.
38
+ const versionManifests = [
39
+ {
40
+ version: '0.1.3',
41
+ transforms: [
42
+ {
43
+ name: 'synthetic-config-codemod',
44
+ meta: {
45
+ title: 'Synthetic config codemod',
46
+ codemodType: 'config',
47
+ },
48
+ transform: (file, api) => {
49
+ // Exercise the unified (file, api) contract: api carries a
50
+ // configured jscodeshift instance, and the transform returns the
51
+ // rewritten source string (or null/undefined for no-op).
52
+ expect(typeof api.jscodeshift).toBe('function');
53
+ return file.source.replace('old-theme', 'new-theme');
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ ];
59
+
60
+ const result = await runCodemods(versionManifests, {
61
+ apply: true,
62
+ path: './src',
63
+ silent: true,
64
+ });
65
+
66
+ expect(result.errors).toHaveLength(0);
67
+ expect(result.totalFilesChanged).toBe(1);
68
+ expect(
69
+ fs.readFileSync(path.join(tmpDir, 'astryx.config.mjs'), 'utf-8'),
70
+ ).toContain('new-theme');
71
+ });
72
+
73
+ it('still runs core code codemods against source files', async () => {
74
+ const srcDir = path.join(tmpDir, 'src');
75
+ fs.mkdirSync(srcDir);
76
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), 'const foo = 1;\n');
77
+
78
+ const versionManifests = [
79
+ {
80
+ version: '0.1.3',
81
+ transforms: [
82
+ {
83
+ name: 'synthetic-code-codemod',
84
+ meta: {title: 'Synthetic code codemod'},
85
+ transform: file => file.source.replace(/foo/g, 'bar'),
86
+ },
87
+ ],
88
+ },
89
+ ];
90
+
91
+ const result = await runCodemods(versionManifests, {
92
+ apply: true,
93
+ path: './src',
94
+ silent: true,
95
+ });
96
+
97
+ expect(result.errors).toHaveLength(0);
98
+ expect(result.totalFilesChanged).toBe(1);
99
+ expect(fs.readFileSync(path.join(srcDir, 'a.ts'), 'utf-8')).toContain(
100
+ 'const bar = 1',
101
+ );
102
+ });
103
+ });
@@ -0,0 +1,168 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration codemod discovery.
5
+ *
6
+ * Integrations may declare a `codemods` directory in their manifest. The
7
+ * directory layout is version-folder-first:
8
+ *
9
+ * <codemodsRoot>/<version>/<id>.<ext>
10
+ *
11
+ * where `<version>` is the immediate folder name (e.g. "0.2.0") and `<id>` is
12
+ * the codemod module's relative path under that version folder WITHOUT its
13
+ * extension (kebab-case; may include nested segments like "config/rename").
14
+ * Each module DEFAULT-EXPORTS a codemod envelope — typically a `createCodemod`
15
+ * / `createConfigCodemod` result, though any plain object matching
16
+ * {@link CodemodEnvelopeSchema} is accepted. Validation happens here at the
17
+ * load boundary via `loadModuleWithSchema`, not in the factories.
18
+ *
19
+ * Version selection mirrors the core registry's getTransformsBetween(from,to):
20
+ * a codemod under folder X runs when upgrading to include X.
21
+ *
22
+ * Strictness: any broken discovery (bad/missing default export, schema
23
+ * invalid, duplicate id) is a hard error so the upgrade fails loudly rather
24
+ * than silently skipping migrations.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import {loadModuleWithSchema} from '../lib/module-loader.mjs';
30
+ import {CodemodEnvelopeSchema} from '../codemod.mjs';
31
+ import {semverCompare} from '../utils/semver.mjs';
32
+
33
+ /** File extensions recognized as codemod modules. */
34
+ const CODEMOD_EXTENSIONS = ['.ts', '.mjs', '.js'];
35
+
36
+ /**
37
+ * Recursively collect codemod module files under a version folder. Returns
38
+ * entries of {id, file} where id is the extension-less relative path
39
+ * (POSIX-style) under `versionDir`.
40
+ * @param {string} versionDir
41
+ * @returns {Array<{id: string, file: string}>}
42
+ */
43
+ function collectCodemodFiles(versionDir) {
44
+ const out = [];
45
+
46
+ /** @param {string} dir */
47
+ function walk(dir) {
48
+ const entries = fs.readdirSync(dir, {withFileTypes: true});
49
+ for (const entry of entries) {
50
+ const full = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
53
+ walk(full);
54
+ continue;
55
+ }
56
+ const ext = path.extname(entry.name);
57
+ if (!CODEMOD_EXTENSIONS.includes(ext)) continue;
58
+ const rel = path.relative(versionDir, full);
59
+ const id = rel.slice(0, rel.length - ext.length).split(path.sep).join('/');
60
+ out.push({id, file: full});
61
+ }
62
+ }
63
+
64
+ walk(versionDir);
65
+ return out.sort((a, b) => a.id.localeCompare(b.id));
66
+ }
67
+
68
+ /**
69
+ * Discover file-based codemods contributed by configured integrations.
70
+ *
71
+ * @param {Array<{name?: string, codemods?: string, __spec?: string}>} loadedIntegrations
72
+ * @returns {Promise<Map<string, Array<{id: string, type: 'code'|'config', codemod: object, package: string}>>>}
73
+ * Map keyed by version string; each value is the list of discovered codemods
74
+ * for that version (across all integrations).
75
+ */
76
+ export async function discoverIntegrationCodemods(loadedIntegrations = []) {
77
+ /** @type {Map<string, Array<{id: string, type: string, codemod: object, package: string}>>} */
78
+ const byVersion = new Map();
79
+
80
+ for (const integration of loadedIntegrations ?? []) {
81
+ const root = integration?.codemods;
82
+ if (!root) continue;
83
+
84
+ const pkgLabel = integration.name ?? integration.__spec ?? 'integration';
85
+
86
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
87
+ throw new Error(
88
+ `Integration "${pkgLabel}" declares a codemods directory that does not exist: ${root}`,
89
+ );
90
+ }
91
+
92
+ const versionFolders = fs
93
+ .readdirSync(root, {withFileTypes: true})
94
+ .filter(entry => entry.isDirectory())
95
+ .map(entry => entry.name);
96
+
97
+ // Track ids seen ACROSS versions within this package — duplicate id across
98
+ // versions within a package is a hard error (locked decision).
99
+ /** @type {Map<string, string>} id -> version */
100
+ const idToVersion = new Map();
101
+
102
+ for (const version of versionFolders) {
103
+ const versionDir = path.join(root, version);
104
+ const files = collectCodemodFiles(versionDir);
105
+
106
+ // Track ids within this package+version to catch same-version dupes.
107
+ const seenInVersion = new Set();
108
+
109
+ for (const {id, file} of files) {
110
+ const label = `Integration "${pkgLabel}" codemod ${version}/${id} (${file})`;
111
+
112
+ if (seenInVersion.has(id)) {
113
+ throw new Error(
114
+ `Integration "${pkgLabel}" has a duplicate codemod id "${id}" within version ${version}.`,
115
+ );
116
+ }
117
+ seenInVersion.add(id);
118
+
119
+ const priorVersion = idToVersion.get(id);
120
+ if (priorVersion != null && priorVersion !== version) {
121
+ throw new Error(
122
+ `Integration "${pkgLabel}" reuses codemod id "${id}" across versions ${priorVersion} and ${version}. Codemod ids must be unique within a package.`,
123
+ );
124
+ }
125
+ idToVersion.set(id, version);
126
+
127
+ const codemod = await loadModuleWithSchema(file, CodemodEnvelopeSchema, {
128
+ label,
129
+ });
130
+
131
+ const entry = {
132
+ id,
133
+ type: codemod.type,
134
+ codemod,
135
+ package: pkgLabel,
136
+ };
137
+ const list = byVersion.get(version);
138
+ if (list) {
139
+ list.push(entry);
140
+ } else {
141
+ byVersion.set(version, [entry]);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ return byVersion;
148
+ }
149
+
150
+ /**
151
+ * Select discovered integration codemods that apply when upgrading from
152
+ * `from` (exclusive) to `to` (inclusive), ordered ascending by version.
153
+ *
154
+ * @param {Map<string, Array<object>>} byVersion
155
+ * @param {string} from
156
+ * @param {string} to
157
+ * @returns {Array<{version: string, codemods: Array<object>}>}
158
+ */
159
+ export function selectIntegrationCodemods(byVersion, from, to) {
160
+ const versions = [...byVersion.keys()].sort(semverCompare);
161
+ const results = [];
162
+ for (const version of versions) {
163
+ if (semverCompare(version, from) > 0 && semverCompare(version, to) <= 0) {
164
+ results.push({version, codemods: byVersion.get(version)});
165
+ }
166
+ }
167
+ return results;
168
+ }
@@ -0,0 +1,234 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {pathToFileURL} from 'node:url';
7
+ import {Project} from '../lib/project.mjs';
8
+ import {
9
+ discoverIntegrationCodemods,
10
+ selectIntegrationCodemods,
11
+ } from './integration-discovery.mjs';
12
+ import {runIntegrationCodemods} from './integration-runner.mjs';
13
+
14
+ let tmpDir;
15
+ let originalCwd;
16
+
17
+ /**
18
+ * Build a consumer project with an astryx.config.mjs that loads a single
19
+ * integration package, and the integration package itself (manifest +
20
+ * codemods dir). `codemodFiles` maps "<version>/<id>.mjs" -> file body.
21
+ */
22
+ function scaffold(codemodFiles) {
23
+ fs.writeFileSync(
24
+ path.join(tmpDir, 'package.json'),
25
+ JSON.stringify({name: 'consumer'}),
26
+ );
27
+ fs.writeFileSync(
28
+ path.join(tmpDir, 'astryx.config.mjs'),
29
+ `export default { integrations: ['@acme/widgets'] };\n`,
30
+ );
31
+
32
+ const pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
33
+ fs.mkdirSync(pkgDir, {recursive: true});
34
+ fs.writeFileSync(
35
+ path.join(pkgDir, 'package.json'),
36
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
37
+ );
38
+ fs.writeFileSync(
39
+ path.join(pkgDir, 'astryx.integration.mjs'),
40
+ `export default { codemods: './codemods' };\n`,
41
+ );
42
+
43
+ for (const [rel, body] of Object.entries(codemodFiles)) {
44
+ const full = path.join(pkgDir, 'codemods', rel);
45
+ fs.mkdirSync(path.dirname(full), {recursive: true});
46
+ fs.writeFileSync(full, body);
47
+ }
48
+ return pkgDir;
49
+ }
50
+
51
+ // Resolve the codemod helper module to an absolute file:// URL so codemod
52
+ // modules in the temp package can import it without node_modules wiring.
53
+ // vitest reports import.meta.url as an http:// URL, so derive the on-disk path
54
+ // from the vitest cwd (the repo root) instead.
55
+ const codemodModulePath = path.resolve(
56
+ process.cwd(),
57
+ 'packages/cli/src/codemod.mjs',
58
+ );
59
+ const codemodModuleUrl = pathToFileURL(codemodModulePath).href;
60
+
61
+ beforeEach(() => {
62
+ originalCwd = process.cwd();
63
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-codemod-test-'));
64
+ process.chdir(tmpDir);
65
+ });
66
+
67
+ afterEach(() => {
68
+ process.chdir(originalCwd);
69
+ fs.rmSync(tmpDir, {recursive: true, force: true});
70
+ });
71
+
72
+ describe('integration codemod discovery', () => {
73
+ it('discovers a code codemod and runs it for an applicable --from', async () => {
74
+ scaffold({
75
+ '0.2.0/drop-foo.mjs': `
76
+ import {createCodemod} from ${JSON.stringify(codemodModuleUrl)};
77
+ export default createCodemod({
78
+ title: 'Drop foo',
79
+ transform: (file) => file.source.replace(/foo/g, 'bar'),
80
+ });
81
+ `,
82
+ });
83
+
84
+ const project = await Project.load(tmpDir);
85
+ expect(project.loadedIntegrations).toHaveLength(1);
86
+
87
+ const byVersion = await discoverIntegrationCodemods(
88
+ project.loadedIntegrations,
89
+ );
90
+ expect([...byVersion.keys()]).toEqual(['0.2.0']);
91
+ const entry = byVersion.get('0.2.0')[0];
92
+ expect(entry.id).toBe('drop-foo');
93
+ expect(entry.type).toBe('code');
94
+ expect(entry.package).toBe('@acme/widgets');
95
+
96
+ // Selection: applies upgrading from 0.1.0 -> 0.2.0, not from 0.2.0 -> 0.2.0
97
+ expect(selectIntegrationCodemods(byVersion, '0.1.0', '0.2.0')).toHaveLength(
98
+ 1,
99
+ );
100
+ expect(selectIntegrationCodemods(byVersion, '0.2.0', '0.2.0')).toHaveLength(
101
+ 0,
102
+ );
103
+
104
+ // Run it against a source file.
105
+ const srcDir = path.join(tmpDir, 'src');
106
+ fs.mkdirSync(srcDir);
107
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), 'const foo = 1;\n');
108
+
109
+ const jscodeshift = (await import('jscodeshift')).default;
110
+ const groups = selectIntegrationCodemods(byVersion, '0.1.0', '0.2.0');
111
+ const result = runIntegrationCodemods(groups, {
112
+ apply: true,
113
+ path: './src',
114
+ jscodeshift,
115
+ silent: true,
116
+ });
117
+ expect(result.errors).toHaveLength(0);
118
+ expect(result.totalFilesChanged).toBe(1);
119
+ expect(fs.readFileSync(path.join(srcDir, 'a.ts'), 'utf-8')).toContain(
120
+ 'const bar = 1',
121
+ );
122
+ });
123
+
124
+ it('discovers and runs a config codemod against astryx.config.*', async () => {
125
+ scaffold({
126
+ '0.2.0/bump-config.mjs': `
127
+ import {createConfigCodemod} from ${JSON.stringify(codemodModuleUrl)};
128
+ export default createConfigCodemod({
129
+ title: 'Bump config',
130
+ transform: (file) => file.source.replace('consumer-old', 'consumer-new'),
131
+ });
132
+ `,
133
+ });
134
+ // Make the config file a transform target. (Project.load only needs a valid
135
+ // default export; the marker string lives in a comment.)
136
+ fs.writeFileSync(
137
+ path.join(tmpDir, 'astryx.config.mjs'),
138
+ `// consumer-old\nexport default { integrations: ['@acme/widgets'] };\n`,
139
+ );
140
+
141
+ const project = await Project.load(tmpDir);
142
+ const byVersion = await discoverIntegrationCodemods(
143
+ project.loadedIntegrations,
144
+ );
145
+ expect(byVersion.get('0.2.0')[0].type).toBe('config');
146
+
147
+ const jscodeshift = (await import('jscodeshift')).default;
148
+ const groups = selectIntegrationCodemods(byVersion, '0.1.0', '0.2.0');
149
+ const result = runIntegrationCodemods(groups, {
150
+ apply: true,
151
+ path: './src',
152
+ jscodeshift,
153
+ silent: true,
154
+ });
155
+ expect(result.errors).toHaveLength(0);
156
+ expect(result.totalFilesChanged).toBe(1);
157
+ expect(
158
+ fs.readFileSync(path.join(tmpDir, 'astryx.config.mjs'), 'utf-8'),
159
+ ).toContain('consumer-new');
160
+ });
161
+
162
+ it('fails discovery when a codemod has no default export', async () => {
163
+ scaffold({
164
+ '0.2.0/broken.mjs': `export const notDefault = 1;\n`,
165
+ });
166
+ const project = await Project.load(tmpDir);
167
+ // Validation now happens at the LOAD boundary (loadModuleWithSchema against
168
+ // CodemodEnvelopeSchema); a missing default export is a schema-invalid
169
+ // failure rather than the old bespoke "must default-export" message.
170
+ await expect(
171
+ discoverIntegrationCodemods(project.loadedIntegrations),
172
+ ).rejects.toThrow(/is invalid/i);
173
+ });
174
+
175
+ it('fails discovery when default export is not a codemod envelope', async () => {
176
+ scaffold({
177
+ '0.2.0/bad.mjs': `export default { title: 'x', transform: () => null };\n`,
178
+ });
179
+ const project = await Project.load(tmpDir);
180
+ // No `type` discriminator -> fails the envelope schema at load.
181
+ await expect(
182
+ discoverIntegrationCodemods(project.loadedIntegrations),
183
+ ).rejects.toThrow(/is invalid/i);
184
+ });
185
+
186
+ it('accepts a PLAIN OBJECT codemod envelope (no factory required)', async () => {
187
+ // Proves we dropped factory-identity coupling: a hand-written object that
188
+ // matches the envelope schema is accepted by discovery.
189
+ scaffold({
190
+ '0.2.0/hand-written.mjs': `
191
+ export default {
192
+ type: 'code',
193
+ title: 'Hand written',
194
+ transform: (file) => file.source,
195
+ };
196
+ `,
197
+ });
198
+ const project = await Project.load(tmpDir);
199
+ const byVersion = await discoverIntegrationCodemods(
200
+ project.loadedIntegrations,
201
+ );
202
+ const entry = byVersion.get('0.2.0')[0];
203
+ expect(entry.id).toBe('hand-written');
204
+ expect(entry.type).toBe('code');
205
+ expect(entry.codemod.isOptional).toBe(false); // schema default applied
206
+ });
207
+
208
+ it('rejects a malformed codemod envelope at load (missing transform)', async () => {
209
+ scaffold({
210
+ '0.2.0/no-transform.mjs': `export default { type: 'code', title: 'x' };\n`,
211
+ });
212
+ const project = await Project.load(tmpDir);
213
+ await expect(
214
+ discoverIntegrationCodemods(project.loadedIntegrations),
215
+ ).rejects.toThrow(/transform/i);
216
+ });
217
+
218
+ it('fails discovery on a duplicate id across versions within a package', async () => {
219
+ scaffold({
220
+ '0.2.0/dup.mjs': `
221
+ import {createCodemod} from ${JSON.stringify(codemodModuleUrl)};
222
+ export default createCodemod({title: 'a', transform: () => null});
223
+ `,
224
+ '0.3.0/dup.mjs': `
225
+ import {createCodemod} from ${JSON.stringify(codemodModuleUrl)};
226
+ export default createCodemod({title: 'b', transform: () => null});
227
+ `,
228
+ });
229
+ const project = await Project.load(tmpDir);
230
+ await expect(
231
+ discoverIntegrationCodemods(project.loadedIntegrations),
232
+ ).rejects.toThrow(/across versions/i);
233
+ });
234
+ });