@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.
- package/CHANGELOG.md +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +31 -8
- package/src/api/component.mjs +253 -12
- package/src/api/discover.mjs +87 -28
- package/src/api/doctor.mjs +7 -22
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +302 -0
- package/src/api/layout.test.mjs +241 -0
- package/src/api/template-integration.test.mjs +225 -0
- package/src/api/template.mjs +421 -122
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/api/validate-integration.mjs +370 -0
- package/src/api/validate-integration.test.mjs +222 -0
- package/src/codemod.mjs +93 -0
- package/src/codemod.test.mjs +134 -0
- package/src/codemods/__tests__/registry.test.mjs +2 -0
- package/src/codemods/__tests__/runner.test.mjs +103 -0
- package/src/codemods/integration-discovery.mjs +168 -0
- package/src/codemods/integration-discovery.test.mjs +234 -0
- package/src/codemods/integration-runner.mjs +109 -0
- package/src/codemods/registry.mjs +2 -0
- package/src/codemods/run-codemod.mjs +207 -0
- package/src/codemods/runner.mjs +73 -96
- package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
- package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
- package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
- package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
- package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/component/index.mjs +44 -10
- package/src/commands/component-ownership.test.mjs +227 -0
- package/src/commands/discover.mjs +5 -16
- package/src/commands/doctor.test.mjs +3 -3
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/json-contract.test.mjs +0 -32
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +241 -224
- package/src/commands/swizzle.path-safety.test.mjs +3 -3
- package/src/commands/swizzle.routing.test.mjs +279 -0
- package/src/commands/template.mjs +33 -37
- package/src/commands/upgrade.config-ordering.test.mjs +273 -0
- package/src/commands/upgrade.integration-policy.test.mjs +204 -0
- package/src/commands/upgrade.mjs +349 -239
- package/src/commands/validate-integration.mjs +110 -0
- package/src/commands/validate-integration.test.mjs +124 -0
- package/src/config.mjs +18 -0
- package/src/config.test.mjs +100 -0
- package/src/index.mjs +13 -3
- package/src/integration.mjs +19 -0
- package/src/lib/component-discovery.mjs +177 -0
- package/src/lib/config-cache.mjs +91 -0
- package/src/lib/config-cache.test.mjs +83 -0
- package/src/lib/config-schema.mjs +105 -0
- package/src/lib/error-codes.mjs +18 -12
- package/src/lib/integration-warnings.mjs +62 -0
- package/src/lib/integration-warnings.test.mjs +102 -0
- package/src/lib/integrations.mjs +132 -0
- package/src/lib/integrations.test.mjs +135 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +15 -2
- package/src/lib/module-loader.mjs +80 -0
- package/src/lib/module-loader.test.mjs +106 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/project.mjs +502 -0
- package/src/lib/project.test.mjs +308 -0
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/template.mjs +73 -0
- package/src/template.test.mjs +127 -0
- package/src/types/api.d.ts +2 -6
- package/src/types/base.d.ts +3 -9
- package/src/types/codemod.d.ts +81 -0
- package/src/types/component.d.ts +27 -2
- package/src/types/config.d.ts +70 -0
- package/src/types/error-codes.d.ts +5 -3
- package/src/types/index.d.ts +0 -1
- package/src/types/integration.d.ts +29 -0
- package/src/types/swizzle.d.ts +9 -2
- package/src/types/template-api.d.ts +54 -0
- package/src/types/template.d.ts +10 -7
- package/src/types/upgrade.d.ts +29 -0
- package/src/types/validate-integration.d.ts +24 -0
- package/src/utils/github.mjs +0 -252
- package/src/utils/interactive.mjs +2 -2
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
- package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
- package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
- package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
- package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
- package/src/commands/gap-report.mjs +0 -456
- package/src/commands/gap-report.test.mjs +0 -163
- package/src/commands/swizzle-gap-safety.test.mjs +0 -273
- package/src/lib/config.mjs +0 -86
- package/src/lib/config.test.mjs +0 -42
- package/src/types/gap-report.d.ts +0 -29
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
- /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Hermetic tests for the validate-integration API.
|
|
5
|
+
*
|
|
6
|
+
* Each test stands up a temp integration package and exercises the public
|
|
7
|
+
* validate API directly. Temp dirs live UNDER the repo root (process.cwd())
|
|
8
|
+
* so node_modules-style paths are within Vite's allowed fs roots; we never
|
|
9
|
+
* load configs from /tmp.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import {pathToFileURL} from 'node:url';
|
|
16
|
+
import {
|
|
17
|
+
validateLocalIntegration,
|
|
18
|
+
validateInstalledIntegration,
|
|
19
|
+
summarizeIssues,
|
|
20
|
+
} from './validate-integration.mjs';
|
|
21
|
+
|
|
22
|
+
let tmpDir;
|
|
23
|
+
|
|
24
|
+
// Absolute file:// URL to the codemod helper so codemod modules in the temp
|
|
25
|
+
// package can import createCodemod without node_modules wiring.
|
|
26
|
+
const codemodModuleUrl = pathToFileURL(
|
|
27
|
+
path.resolve(process.cwd(), 'packages/cli/src/codemod.mjs'),
|
|
28
|
+
).href;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-validate-it-'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create an integration package directory with a package.json + manifest.
|
|
40
|
+
* @param {string} dir
|
|
41
|
+
* @param {{name?: string, version?: string, manifest: string, manifestExt?: string}} opts
|
|
42
|
+
*/
|
|
43
|
+
function writePackage(dir, {name = '@acme/widgets', version = '1.0.0', manifest, manifestExt = 'mjs'}) {
|
|
44
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(dir, 'package.json'),
|
|
47
|
+
JSON.stringify({name, version}),
|
|
48
|
+
);
|
|
49
|
+
fs.writeFileSync(path.join(dir, `astryx.integration.${manifestExt}`), manifest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find issues by code. */
|
|
53
|
+
function byCode(issues, code) {
|
|
54
|
+
return issues.filter(i => i.code === code);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('validate-integration API', () => {
|
|
58
|
+
it('reports no errors for a valid integration with a codemod', async () => {
|
|
59
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
60
|
+
writePackage(pkgDir, {
|
|
61
|
+
manifest: `export default { codemods: './codemods' };\n`,
|
|
62
|
+
});
|
|
63
|
+
const cmDir = path.join(pkgDir, 'codemods', '0.2.0');
|
|
64
|
+
fs.mkdirSync(cmDir, {recursive: true});
|
|
65
|
+
fs.writeFileSync(
|
|
66
|
+
path.join(cmDir, 'drop-foo.mjs'),
|
|
67
|
+
`import {createCodemod} from ${JSON.stringify(codemodModuleUrl)};\n` +
|
|
68
|
+
`export default createCodemod({ title: 'Drop foo', transform: (file) => file.source });\n`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
72
|
+
expect(result.found).toBe(true);
|
|
73
|
+
expect(result.name).toBe('@acme/widgets');
|
|
74
|
+
expect(result.version).toBe('1.0.0');
|
|
75
|
+
const {errors} = summarizeIssues(result.issues);
|
|
76
|
+
expect(errors).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('flags an unknown manifest key as invalid_manifest error', async () => {
|
|
80
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
81
|
+
writePackage(pkgDir, {
|
|
82
|
+
manifest: `export default { components: './c', bogus: true };\n`,
|
|
83
|
+
});
|
|
84
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
85
|
+
const manifestIssues = byCode(result.issues, 'invalid_manifest');
|
|
86
|
+
expect(manifestIssues).toHaveLength(1);
|
|
87
|
+
expect(manifestIssues[0].severity).toBe('error');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('flags a declared-but-missing root as missing_root error', async () => {
|
|
91
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
92
|
+
writePackage(pkgDir, {
|
|
93
|
+
manifest: `export default { templates: './nope' };\n`,
|
|
94
|
+
});
|
|
95
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
96
|
+
const rootIssues = byCode(result.issues, 'missing_root');
|
|
97
|
+
expect(rootIssues).toHaveLength(1);
|
|
98
|
+
expect(rootIssues[0].severity).toBe('error');
|
|
99
|
+
expect(summarizeIssues(result.issues).errors).toBeGreaterThan(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('flags multiple manifests as multiple_manifests error', async () => {
|
|
103
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
104
|
+
writePackage(pkgDir, {manifest: `export default {};\n`});
|
|
105
|
+
fs.writeFileSync(path.join(pkgDir, 'astryx.integration.js'), 'export default {};\n');
|
|
106
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
107
|
+
expect(byCode(result.issues, 'multiple_manifests')).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns found:false (guidance) when no manifest is present', async () => {
|
|
111
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
112
|
+
fs.mkdirSync(pkgDir, {recursive: true});
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(pkgDir, 'package.json'),
|
|
115
|
+
JSON.stringify({name: 'plain'}),
|
|
116
|
+
);
|
|
117
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
118
|
+
expect(result.found).toBe(false);
|
|
119
|
+
expect(result.issues).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('flags a broken codemod as invalid_codemod error', async () => {
|
|
123
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
124
|
+
writePackage(pkgDir, {
|
|
125
|
+
manifest: `export default { codemods: './codemods' };\n`,
|
|
126
|
+
});
|
|
127
|
+
const cmDir = path.join(pkgDir, 'codemods', '0.2.0');
|
|
128
|
+
fs.mkdirSync(cmDir, {recursive: true});
|
|
129
|
+
// Missing default export → discovery throws → invalid_codemod.
|
|
130
|
+
fs.writeFileSync(path.join(cmDir, 'broken.mjs'), `export const nope = 1;\n`);
|
|
131
|
+
|
|
132
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
133
|
+
expect(byCode(result.issues, 'invalid_codemod')).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('flags a broken template as invalid_template error', async () => {
|
|
137
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
138
|
+
writePackage(pkgDir, {
|
|
139
|
+
manifest: `export default { templates: './templates' };\n`,
|
|
140
|
+
});
|
|
141
|
+
const tplDir = path.join(pkgDir, 'templates');
|
|
142
|
+
fs.mkdirSync(tplDir, {recursive: true});
|
|
143
|
+
// Doc with no same-stem source file.
|
|
144
|
+
fs.writeFileSync(
|
|
145
|
+
path.join(tplDir, 'dash.doc.mjs'),
|
|
146
|
+
`export default { type: 'page', name: 'Dash' };\n`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
150
|
+
expect(byCode(result.issues, 'invalid_template')).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('validates an installed package resolved from node_modules', async () => {
|
|
154
|
+
const consumer = path.join(tmpDir, 'consumer');
|
|
155
|
+
fs.mkdirSync(consumer, {recursive: true});
|
|
156
|
+
fs.writeFileSync(
|
|
157
|
+
path.join(consumer, 'package.json'),
|
|
158
|
+
JSON.stringify({name: 'consumer'}),
|
|
159
|
+
);
|
|
160
|
+
const pkgDir = path.join(consumer, 'node_modules', '@acme', 'widgets');
|
|
161
|
+
writePackage(pkgDir, {
|
|
162
|
+
name: '@acme/widgets',
|
|
163
|
+
version: '2.0.0',
|
|
164
|
+
manifest: `export default { templates: './gone' };\n`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await validateInstalledIntegration('@acme/widgets', consumer);
|
|
168
|
+
expect(result.found).toBe(true);
|
|
169
|
+
expect(result.name).toBe('@acme/widgets');
|
|
170
|
+
expect(result.version).toBe('2.0.0');
|
|
171
|
+
expect(byCode(result.issues, 'missing_root')).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('flags a non-installed package as package_not_found error', async () => {
|
|
175
|
+
const consumer = path.join(tmpDir, 'consumer');
|
|
176
|
+
fs.mkdirSync(consumer, {recursive: true});
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
path.join(consumer, 'package.json'),
|
|
179
|
+
JSON.stringify({name: 'consumer'}),
|
|
180
|
+
);
|
|
181
|
+
const result = await validateInstalledIntegration('@acme/nope', consumer);
|
|
182
|
+
expect(byCode(result.issues, 'package_not_found')).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports no errors for a valid component (doc + same-stem source)', async () => {
|
|
186
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
187
|
+
writePackage(pkgDir, {
|
|
188
|
+
manifest: `export default { components: './components' };\n`,
|
|
189
|
+
});
|
|
190
|
+
const cDir = path.join(pkgDir, 'components');
|
|
191
|
+
fs.mkdirSync(cDir, {recursive: true});
|
|
192
|
+
fs.writeFileSync(
|
|
193
|
+
path.join(cDir, 'Widget.doc.mjs'),
|
|
194
|
+
`export default { name: 'Widget' };\n`,
|
|
195
|
+
);
|
|
196
|
+
fs.writeFileSync(
|
|
197
|
+
path.join(cDir, 'Widget.tsx'),
|
|
198
|
+
`export function Widget() { return null; }\n`,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
202
|
+
expect(byCode(result.issues, 'invalid_component')).toHaveLength(0);
|
|
203
|
+
expect(summarizeIssues(result.issues).errors).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('flags a component doc missing its same-stem source as invalid_component', async () => {
|
|
207
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
208
|
+
writePackage(pkgDir, {
|
|
209
|
+
manifest: `export default { components: './components' };\n`,
|
|
210
|
+
});
|
|
211
|
+
const cDir = path.join(pkgDir, 'components');
|
|
212
|
+
fs.mkdirSync(cDir, {recursive: true});
|
|
213
|
+
// Doc with no sibling Widget.tsx.
|
|
214
|
+
fs.writeFileSync(
|
|
215
|
+
path.join(cDir, 'Widget.doc.mjs'),
|
|
216
|
+
`export default { name: 'Widget' };\n`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const result = await validateLocalIntegration(pkgDir);
|
|
220
|
+
expect(byCode(result.issues, 'invalid_component')).toHaveLength(1);
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/codemod.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Public file-based codemod-authoring API.
|
|
5
|
+
*
|
|
6
|
+
* Integrations (and core) author codemods as standalone modules that
|
|
7
|
+
* default-export a `createCodemod(...)` or `createConfigCodemod(...)` result.
|
|
8
|
+
* These helpers are intentionally tiny: they stamp a `type` discriminator
|
|
9
|
+
* (`'code'` | `'config'`) consumed by integration codemod discovery during
|
|
10
|
+
* `astryx upgrade`, and otherwise return the definition unchanged. They do NOT
|
|
11
|
+
* validate — validation happens at the load boundary, where discovery runs the
|
|
12
|
+
* module's default export through {@link CodemodEnvelopeSchema} (see
|
|
13
|
+
* `loadModuleWithSchema`). The exported schemas below ARE that contract.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {z} from 'zod';
|
|
17
|
+
|
|
18
|
+
const Fn = z.custom(value => typeof value === 'function', {
|
|
19
|
+
message: 'Expected a function',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Authoring shape for a file-transforming codemod (the `def` an author passes
|
|
24
|
+
* to {@link createCodemod}), before the `type` discriminator is stamped on.
|
|
25
|
+
* Exported so discovery can validate the stamped result against the envelope.
|
|
26
|
+
*/
|
|
27
|
+
export const CodemodSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
title: z.string(),
|
|
30
|
+
description: z.string().optional(),
|
|
31
|
+
isOptional: z.boolean().optional().default(false),
|
|
32
|
+
fileExtensions: z.array(z.string()).optional(),
|
|
33
|
+
transform: Fn,
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Authoring shape for a config-targeting codemod (the `def` an author passes to
|
|
39
|
+
* {@link createConfigCodemod}). Like {@link CodemodSchema} but without
|
|
40
|
+
* `fileExtensions`, which is only meaningful for code codemods.
|
|
41
|
+
*/
|
|
42
|
+
export const ConfigCodemodSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
title: z.string(),
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
isOptional: z.boolean().optional().default(false),
|
|
47
|
+
transform: Fn,
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The metadata envelope discovery validates: a stamped codemod result. This is
|
|
53
|
+
* the LOAD-boundary contract — a hand-written plain object that matches this
|
|
54
|
+
* shape is accepted (discovery does not check "was it made by the factory",
|
|
55
|
+
* only the shape). A single discriminated union over the stamped `type`:
|
|
56
|
+
* - `type: 'code'` → may carry `fileExtensions`
|
|
57
|
+
* - `type: 'config'` → no `fileExtensions`
|
|
58
|
+
*/
|
|
59
|
+
export const CodemodEnvelopeSchema = z.discriminatedUnion('type', [
|
|
60
|
+
CodemodSchema.extend({type: z.literal('code')}),
|
|
61
|
+
ConfigCodemodSchema.extend({type: z.literal('config')}),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Define a file-transforming codemod. Stamp-only: returns the definition with
|
|
66
|
+
* `type: 'code'` injected. Validation happens at the load boundary.
|
|
67
|
+
*
|
|
68
|
+
* @template {import('./types/codemod').AstryxCodemodDef} T
|
|
69
|
+
* @param {T} def
|
|
70
|
+
* @returns {import('./types/codemod').AstryxCodemod}
|
|
71
|
+
*/
|
|
72
|
+
export function createCodemod(def) {
|
|
73
|
+
return /** @type {import('./types/codemod').AstryxCodemod} */ ({
|
|
74
|
+
...def,
|
|
75
|
+
type: 'code',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Define a codemod that targets the consumer's astryx.config.* file. Stamp-only:
|
|
81
|
+
* returns the definition with `type: 'config'` injected. Validation happens at
|
|
82
|
+
* the load boundary.
|
|
83
|
+
*
|
|
84
|
+
* @template {import('./types/codemod').AstryxConfigCodemodDef} T
|
|
85
|
+
* @param {T} def
|
|
86
|
+
* @returns {import('./types/codemod').AstryxConfigCodemod}
|
|
87
|
+
*/
|
|
88
|
+
export function createConfigCodemod(def) {
|
|
89
|
+
return /** @type {import('./types/codemod').AstryxConfigCodemod} */ ({
|
|
90
|
+
...def,
|
|
91
|
+
type: 'config',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|