@astryxdesign/cli 0.1.1 → 0.1.2-canary.043f3fd

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 (185) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +31 -8
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +87 -28
  7. package/src/api/doctor.mjs +7 -22
  8. package/src/api/index.mjs +1 -0
  9. package/src/api/layout.mjs +302 -0
  10. package/src/api/layout.test.mjs +241 -0
  11. package/src/api/template-integration.test.mjs +225 -0
  12. package/src/api/template.mjs +421 -122
  13. package/src/api/template.test.mjs +2 -0
  14. package/src/api/theme-add.mjs +182 -0
  15. package/src/api/validate-integration.mjs +370 -0
  16. package/src/api/validate-integration.test.mjs +222 -0
  17. package/src/codemod.mjs +93 -0
  18. package/src/codemod.test.mjs +134 -0
  19. package/src/codemods/__tests__/registry.test.mjs +2 -0
  20. package/src/codemods/__tests__/runner.test.mjs +103 -0
  21. package/src/codemods/integration-discovery.mjs +168 -0
  22. package/src/codemods/integration-discovery.test.mjs +234 -0
  23. package/src/codemods/integration-runner.mjs +109 -0
  24. package/src/codemods/registry.mjs +2 -0
  25. package/src/codemods/run-codemod.mjs +207 -0
  26. package/src/codemods/runner.mjs +73 -96
  27. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  28. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
  29. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  30. package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
  31. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  32. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  33. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  34. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  35. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  36. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  37. package/src/commands/build-theme.import-path.test.mjs +22 -2
  38. package/src/commands/build-theme.mjs +185 -16
  39. package/src/commands/component/index.mjs +44 -10
  40. package/src/commands/component-ownership.test.mjs +227 -0
  41. package/src/commands/discover.mjs +5 -16
  42. package/src/commands/doctor.test.mjs +3 -3
  43. package/src/commands/init.mjs +34 -8
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/json-contract.test.mjs +0 -32
  46. package/src/commands/layout.mjs +139 -0
  47. package/src/commands/swizzle.mjs +241 -224
  48. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  49. package/src/commands/swizzle.routing.test.mjs +279 -0
  50. package/src/commands/template.mjs +33 -37
  51. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  52. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  53. package/src/commands/upgrade.mjs +349 -239
  54. package/src/commands/validate-integration.mjs +110 -0
  55. package/src/commands/validate-integration.test.mjs +124 -0
  56. package/src/config.mjs +18 -0
  57. package/src/config.test.mjs +100 -0
  58. package/src/index.mjs +13 -3
  59. package/src/integration.mjs +19 -0
  60. package/src/lib/component-discovery.mjs +177 -0
  61. package/src/lib/config-cache.mjs +91 -0
  62. package/src/lib/config-cache.test.mjs +83 -0
  63. package/src/lib/config-schema.mjs +105 -0
  64. package/src/lib/error-codes.mjs +18 -12
  65. package/src/lib/integration-warnings.mjs +62 -0
  66. package/src/lib/integration-warnings.test.mjs +102 -0
  67. package/src/lib/integrations.mjs +132 -0
  68. package/src/lib/integrations.test.mjs +135 -0
  69. package/src/lib/levenshtein.mjs +29 -0
  70. package/src/lib/manifest.mjs +15 -2
  71. package/src/lib/module-loader.mjs +80 -0
  72. package/src/lib/module-loader.test.mjs +106 -0
  73. package/src/lib/package-scanner.mjs +31 -7
  74. package/src/lib/project.mjs +502 -0
  75. package/src/lib/project.test.mjs +308 -0
  76. package/src/lib/string-utils.mjs +5 -14
  77. package/src/lib/xle/browser.d.ts +100 -0
  78. package/src/lib/xle/browser.mjs +120 -0
  79. package/src/lib/xle/expand.mjs +622 -0
  80. package/src/lib/xle/parse.mjs +581 -0
  81. package/src/lib/xle/print.mjs +174 -0
  82. package/src/lib/xle/registry-core.mjs +170 -0
  83. package/src/lib/xle/registry.mjs +237 -0
  84. package/src/lib/xle/splice.mjs +137 -0
  85. package/src/lib/xle/validate.mjs +356 -0
  86. package/src/lib/xle/xle.test.mjs +333 -0
  87. package/src/template.mjs +73 -0
  88. package/src/template.test.mjs +127 -0
  89. package/src/types/api.d.ts +2 -6
  90. package/src/types/base.d.ts +3 -9
  91. package/src/types/codemod.d.ts +81 -0
  92. package/src/types/component.d.ts +27 -2
  93. package/src/types/config.d.ts +70 -0
  94. package/src/types/error-codes.d.ts +5 -3
  95. package/src/types/index.d.ts +0 -1
  96. package/src/types/integration.d.ts +29 -0
  97. package/src/types/swizzle.d.ts +9 -2
  98. package/src/types/template-api.d.ts +54 -0
  99. package/src/types/template.d.ts +10 -7
  100. package/src/types/upgrade.d.ts +29 -0
  101. package/src/types/validate-integration.d.ts +24 -0
  102. package/src/utils/github.mjs +0 -252
  103. package/src/utils/interactive.mjs +2 -2
  104. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  105. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  106. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  107. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  108. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  109. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  110. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  111. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  112. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  113. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  114. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  115. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  116. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  117. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  118. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  119. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  120. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  121. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  122. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  123. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  124. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  125. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  126. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  127. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  128. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  129. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  130. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  131. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  132. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  133. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  134. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  135. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  136. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  137. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  138. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  139. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  140. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  141. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  142. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  143. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  144. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  145. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  146. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  147. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  148. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  149. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  150. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  151. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  152. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  153. package/templates/pages/shell-nav/page.tsx +321 -0
  154. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  155. package/templates/pages/shell-side-nav/page.tsx +242 -0
  156. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  157. package/templates/pages/shell-top-nav/page.tsx +224 -0
  158. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  159. package/templates/pages/theme-showcase/page.tsx +8 -19
  160. package/templates/themes/butter/butterTheme.ts +916 -0
  161. package/templates/themes/butter/icons.tsx +77 -0
  162. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  163. package/templates/themes/chocolate/icons.tsx +77 -0
  164. package/templates/themes/gothic/gothicTheme.ts +657 -0
  165. package/templates/themes/gothic/icons.tsx +77 -0
  166. package/templates/themes/manifest.json +90 -0
  167. package/templates/themes/matcha/icons.tsx +67 -0
  168. package/templates/themes/matcha/matchaTheme.ts +247 -0
  169. package/templates/themes/neutral/icons.tsx +77 -0
  170. package/templates/themes/neutral/neutralTheme.ts +603 -0
  171. package/templates/themes/stone/icons.tsx +77 -0
  172. package/templates/themes/stone/stoneTheme.ts +652 -0
  173. package/templates/themes/y2k/icons.tsx +67 -0
  174. package/templates/themes/y2k/y2kTheme.ts +617 -0
  175. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  176. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  177. package/src/commands/gap-report.mjs +0 -456
  178. package/src/commands/gap-report.test.mjs +0 -163
  179. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  180. package/src/lib/config.mjs +0 -86
  181. package/src/lib/config.test.mjs +0 -42
  182. package/src/types/gap-report.d.ts +0 -29
  183. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  184. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
  185. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -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
+ });
@@ -0,0 +1,109 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Runner for file-based integration codemods.
5
+ *
6
+ * Integration codemods authored with `createCodemod` / `createConfigCodemod`
7
+ * use the file-based contract `(file, api) => string | null | undefined`.
8
+ * Config codemods target the consumer's astryx.config.* file; code codemods
9
+ * are applied to source files discovered under `--path`, filtered by each
10
+ * codemod's `fileExtensions`.
11
+ *
12
+ * Execution is delegated to the shared primitives in `run-codemod.mjs`, which
13
+ * the core registry runner (`runner.mjs`) reuses too — there is a single
14
+ * implementation of "run a config codemod against astryx.config.*" and "run a
15
+ * code codemod against source files".
16
+ */
17
+
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import {
21
+ findSourceFiles,
22
+ makeLog,
23
+ runCodeCodemod,
24
+ runConfigCodemod,
25
+ } from './run-codemod.mjs';
26
+
27
+ /**
28
+ * Run file-based integration codemods, version-ordered. Config codemods run
29
+ * first (targeting astryx.config.*), then code codemods (source globbing).
30
+ *
31
+ * Optional codemods (isOptional) are skipped unless explicitly requested via
32
+ * `codemod` (matched against the codemod id).
33
+ *
34
+ * @param {Array<{version: string, codemods: Array<object>}>} versionGroups
35
+ * @param {object} options
36
+ * @param {boolean} options.apply
37
+ * @param {string} options.path source directory to scan
38
+ * @param {string} [options.codemod] run only this codemod id
39
+ * @param {Set<string>} [options.skipCodemods] codemod ids to exclude
40
+ * @param {Function} options.jscodeshift
41
+ * @param {boolean} [options.silent]
42
+ * @returns {{totalFilesChanged: number, totalTransformsApplied: number, writtenFiles: string[], errors: Array, skippedOptional: Array}}
43
+ */
44
+ export function runIntegrationCodemods(
45
+ versionGroups,
46
+ {apply, path: srcPath, codemod, skipCodemods, jscodeshift, silent = false},
47
+ ) {
48
+ const log = makeLog(silent);
49
+
50
+ let totalFilesChanged = 0;
51
+ let totalTransformsApplied = 0;
52
+ const writtenFiles = [];
53
+ const errors = [];
54
+ const skippedOptional = [];
55
+
56
+ // Flatten and split by type, preserving version ordering.
57
+ const configEntries = [];
58
+ const codeEntries = [];
59
+ for (const {version, codemods} of versionGroups) {
60
+ for (const entry of codemods) {
61
+ const withVersion = {...entry, version};
62
+ // Exclude explicitly skipped codemods (by codemod id).
63
+ if (skipCodemods?.has(entry.id)) continue;
64
+ if (entry.codemod.isOptional && !codemod) {
65
+ skippedOptional.push(withVersion);
66
+ continue;
67
+ }
68
+ if (codemod && entry.id !== codemod) continue;
69
+ if (entry.type === 'config') configEntries.push(withVersion);
70
+ else codeEntries.push(withVersion);
71
+ }
72
+ }
73
+
74
+ // Config codemods first.
75
+ for (const entry of configEntries) {
76
+ log.info(` ${entry.codemod.title} (v${entry.version}, ${entry.package})`);
77
+ const r = runConfigCodemod(entry, {apply, log, jscodeshift});
78
+ totalFilesChanged += r.filesChanged;
79
+ totalTransformsApplied += r.filesChanged;
80
+ writtenFiles.push(...r.writtenFiles);
81
+ errors.push(...r.errors);
82
+ }
83
+
84
+ // Then code codemods (only scan the tree if there are any).
85
+ if (codeEntries.length > 0) {
86
+ const resolvedPath = path.resolve(srcPath);
87
+ const files = fs.existsSync(resolvedPath)
88
+ ? findSourceFiles(resolvedPath)
89
+ : [];
90
+ for (const entry of codeEntries) {
91
+ log.info(
92
+ ` ${entry.codemod.title} (v${entry.version}, ${entry.package})`,
93
+ );
94
+ const r = runCodeCodemod(entry, files, {apply, log, jscodeshift});
95
+ totalFilesChanged += r.filesChanged;
96
+ totalTransformsApplied += r.filesChanged;
97
+ writtenFiles.push(...r.writtenFiles);
98
+ errors.push(...r.errors);
99
+ }
100
+ }
101
+
102
+ return {
103
+ totalFilesChanged,
104
+ totalTransformsApplied,
105
+ writtenFiles,
106
+ errors,
107
+ skippedOptional,
108
+ };
109
+ }
@@ -18,6 +18,8 @@ const registry = new Map([
18
18
  ['0.0.14', () => import('./transforms/v0.0.14/index.mjs')],
19
19
  ['0.0.15', () => import('./transforms/v0.0.15/index.mjs')],
20
20
  ['0.1.0', () => import('./transforms/v0.1.0/index.mjs')],
21
+ ['0.1.2', () => import('./transforms/v0.1.2/index.mjs')],
22
+ ['0.1.3', () => import('./transforms/v0.1.3/index.mjs')],
21
23
  ]);
22
24
 
23
25
  // Re-export from the shared utility so registry callers and other consumers