@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.
- package/CHANGELOG.md +64 -1
- package/README.md +49 -55
- package/docs/layout.doc.dense.mjs +90 -0
- package/docs/layout.doc.mjs +160 -0
- package/docs/principles.doc.dense.mjs +2 -2
- package/docs/principles.doc.mjs +4 -0
- package/docs/principles.doc.zh.mjs +2 -2
- package/docs/theme.doc.mjs +2 -2
- package/package.json +13 -1
- package/src/api/blog.mjs +192 -0
- package/src/api/blog.test.mjs +144 -0
- package/src/api/component.mjs +253 -12
- package/src/api/discover.mjs +14 -7
- package/src/api/doctor.mjs +10 -25
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +11 -10
- package/src/api/layout.test.mjs +4 -1
- package/src/api/template-integration.test.mjs +225 -0
- package/src/api/template.mjs +242 -84
- 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 +1 -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 +1 -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__/migrate-xds-css-surfaces.test.mjs +67 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
- package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -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 +43 -6
- package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -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/agent-docs.mjs +4 -1
- package/src/commands/blog.mjs +60 -0
- package/src/commands/build-theme.import-path.test.mjs +2 -12
- package/src/commands/build-theme.mjs +117 -0
- package/src/commands/build-theme.prose.test.mjs +5 -15
- package/src/commands/build-theme.watch.test.mjs +149 -0
- 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/ensure-core-built.mjs +120 -0
- package/src/commands/json-contract.test.mjs +0 -32
- package/src/commands/swizzle.mjs +224 -235
- 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 -170
- package/src/commands/validate-integration.mjs +110 -0
- package/src/commands/validate-integration.test.mjs +124 -0
- package/src/config.mjs +7 -20
- package/src/config.test.mjs +89 -13
- package/src/index.mjs +8 -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 +41 -55
- package/src/lib/error-codes.mjs +15 -12
- package/src/lib/integration-warnings.mjs +62 -0
- package/src/lib/integration-warnings.test.mjs +102 -0
- package/src/lib/integrations.mjs +92 -115
- package/src/lib/integrations.test.mjs +88 -107
- package/src/lib/manifest.mjs +5 -2
- package/src/lib/module-loader.mjs +80 -0
- package/src/lib/module-loader.test.mjs +106 -0
- package/src/lib/project.mjs +502 -0
- package/src/lib/project.test.mjs +308 -0
- package/src/lib/site.mjs +20 -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 +56 -85
- 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 -237
- package/src/utils/interactive.mjs +2 -2
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
- 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/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
- package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
- package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
- package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
- package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
- package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
- package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
- package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
- package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
- package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
- package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
- package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
- package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
- package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
- package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
- package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
- package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
- package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
- package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
- package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
- package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
- package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
- package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
- package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
- package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
- package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
- package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
- package/templates/pages/kanban-board/page.tsx +729 -0
- package/templates/pages/kanban-board/template.doc.mjs +12 -0
- 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 +241 -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/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 -464
- package/src/commands/gap-report.test.mjs +0 -168
- package/src/commands/swizzle-gap-safety.test.mjs +0 -273
- package/src/lib/config.mjs +0 -113
- package/src/lib/config.test.mjs +0 -91
- package/src/types/gap-report.d.ts +0 -29
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
- /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
|
+
});
|
|
@@ -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
|
+
});
|