@astryxdesign/cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +49 -55
  3. package/docs/layout.doc.dense.mjs +90 -0
  4. package/docs/layout.doc.mjs +160 -0
  5. package/docs/principles.doc.dense.mjs +2 -2
  6. package/docs/principles.doc.mjs +4 -0
  7. package/docs/principles.doc.zh.mjs +2 -2
  8. package/docs/theme.doc.mjs +2 -2
  9. package/package.json +13 -1
  10. package/src/api/blog.mjs +192 -0
  11. package/src/api/blog.test.mjs +144 -0
  12. package/src/api/component.mjs +253 -12
  13. package/src/api/discover.mjs +14 -7
  14. package/src/api/doctor.mjs +10 -25
  15. package/src/api/index.mjs +1 -0
  16. package/src/api/layout.mjs +11 -10
  17. package/src/api/layout.test.mjs +4 -1
  18. package/src/api/template-integration.test.mjs +225 -0
  19. package/src/api/template.mjs +242 -84
  20. package/src/api/validate-integration.mjs +370 -0
  21. package/src/api/validate-integration.test.mjs +222 -0
  22. package/src/codemod.mjs +93 -0
  23. package/src/codemod.test.mjs +134 -0
  24. package/src/codemods/__tests__/registry.test.mjs +1 -0
  25. package/src/codemods/__tests__/runner.test.mjs +103 -0
  26. package/src/codemods/integration-discovery.mjs +168 -0
  27. package/src/codemods/integration-discovery.test.mjs +234 -0
  28. package/src/codemods/integration-runner.mjs +109 -0
  29. package/src/codemods/registry.mjs +1 -0
  30. package/src/codemods/run-codemod.mjs +207 -0
  31. package/src/codemods/runner.mjs +73 -96
  32. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  33. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
  34. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
  35. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -0
  36. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  37. package/src/codemods/transforms/v0.1.0/index.mjs +43 -6
  38. package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
  39. package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
  40. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  41. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  42. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  43. package/src/commands/agent-docs.mjs +4 -1
  44. package/src/commands/blog.mjs +60 -0
  45. package/src/commands/build-theme.import-path.test.mjs +2 -12
  46. package/src/commands/build-theme.mjs +117 -0
  47. package/src/commands/build-theme.prose.test.mjs +5 -15
  48. package/src/commands/build-theme.watch.test.mjs +149 -0
  49. package/src/commands/component/index.mjs +44 -10
  50. package/src/commands/component-ownership.test.mjs +227 -0
  51. package/src/commands/discover.mjs +5 -16
  52. package/src/commands/doctor.test.mjs +3 -3
  53. package/src/commands/ensure-core-built.mjs +120 -0
  54. package/src/commands/json-contract.test.mjs +0 -32
  55. package/src/commands/swizzle.mjs +224 -235
  56. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  57. package/src/commands/swizzle.routing.test.mjs +279 -0
  58. package/src/commands/template.mjs +33 -37
  59. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  60. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  61. package/src/commands/upgrade.mjs +349 -170
  62. package/src/commands/validate-integration.mjs +110 -0
  63. package/src/commands/validate-integration.test.mjs +124 -0
  64. package/src/config.mjs +7 -20
  65. package/src/config.test.mjs +89 -13
  66. package/src/index.mjs +8 -3
  67. package/src/integration.mjs +19 -0
  68. package/src/lib/component-discovery.mjs +177 -0
  69. package/src/lib/config-cache.mjs +91 -0
  70. package/src/lib/config-cache.test.mjs +83 -0
  71. package/src/lib/config-schema.mjs +41 -55
  72. package/src/lib/error-codes.mjs +15 -12
  73. package/src/lib/integration-warnings.mjs +62 -0
  74. package/src/lib/integration-warnings.test.mjs +102 -0
  75. package/src/lib/integrations.mjs +92 -115
  76. package/src/lib/integrations.test.mjs +88 -107
  77. package/src/lib/manifest.mjs +5 -2
  78. package/src/lib/module-loader.mjs +80 -0
  79. package/src/lib/module-loader.test.mjs +106 -0
  80. package/src/lib/project.mjs +502 -0
  81. package/src/lib/project.test.mjs +308 -0
  82. package/src/lib/site.mjs +20 -0
  83. package/src/template.mjs +73 -0
  84. package/src/template.test.mjs +127 -0
  85. package/src/types/api.d.ts +2 -6
  86. package/src/types/base.d.ts +3 -9
  87. package/src/types/codemod.d.ts +81 -0
  88. package/src/types/component.d.ts +27 -2
  89. package/src/types/config.d.ts +56 -85
  90. package/src/types/error-codes.d.ts +5 -3
  91. package/src/types/index.d.ts +0 -1
  92. package/src/types/integration.d.ts +29 -0
  93. package/src/types/swizzle.d.ts +9 -2
  94. package/src/types/template-api.d.ts +54 -0
  95. package/src/types/template.d.ts +10 -7
  96. package/src/types/upgrade.d.ts +29 -0
  97. package/src/types/validate-integration.d.ts +24 -0
  98. package/src/utils/github.mjs +0 -237
  99. package/src/utils/interactive.mjs +2 -2
  100. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
  101. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
  102. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
  103. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
  104. package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
  105. package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
  106. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
  107. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
  108. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
  109. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
  110. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
  111. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
  112. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
  113. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
  114. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
  115. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
  116. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
  117. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
  118. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
  119. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
  120. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
  121. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
  122. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
  123. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
  124. package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
  125. package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
  126. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  127. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  128. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  129. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  130. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  131. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  132. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
  133. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
  134. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
  135. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
  136. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
  137. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
  138. package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
  139. package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
  140. package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
  141. package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
  142. package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
  143. package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
  144. package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
  145. package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
  146. package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
  147. package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
  148. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
  149. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
  150. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  151. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  152. package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
  153. package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
  154. package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
  155. package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
  156. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
  157. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
  158. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
  159. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
  160. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
  161. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
  162. package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
  163. package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
  164. package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
  165. package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
  166. package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
  167. package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
  168. package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
  169. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
  170. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
  171. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
  172. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
  173. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
  174. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
  175. package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
  176. package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
  177. package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
  178. package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
  179. package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
  180. package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
  181. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
  182. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
  183. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
  184. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
  185. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
  186. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
  187. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
  188. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
  189. package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
  190. package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
  191. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
  192. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
  193. package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
  194. package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
  195. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  196. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  197. package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
  198. package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
  199. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
  200. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
  201. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
  202. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
  203. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
  204. package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
  205. package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
  206. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
  207. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
  208. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
  209. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
  210. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
  211. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
  212. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
  213. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
  214. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
  215. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
  216. package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
  217. package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
  218. package/templates/pages/kanban-board/page.tsx +729 -0
  219. package/templates/pages/kanban-board/template.doc.mjs +12 -0
  220. package/templates/pages/shell-nav/page.tsx +321 -0
  221. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  222. package/templates/pages/shell-side-nav/page.tsx +241 -0
  223. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  224. package/templates/pages/shell-top-nav/page.tsx +224 -0
  225. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  226. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  227. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  228. package/src/commands/gap-report.mjs +0 -464
  229. package/src/commands/gap-report.test.mjs +0 -168
  230. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  231. package/src/lib/config.mjs +0 -113
  232. package/src/lib/config.test.mjs +0 -91
  233. package/src/types/gap-report.d.ts +0 -29
  234. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
  235. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
  236. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
  237. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
  238. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -0,0 +1,106 @@
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 {z} from 'zod';
7
+ import {
8
+ findPresentFiles,
9
+ importUserModule,
10
+ loadModuleWithSchema,
11
+ } from './module-loader.mjs';
12
+
13
+ let tmpDir;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-module-loader-test-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, {recursive: true, force: true});
21
+ });
22
+
23
+ describe('findPresentFiles', () => {
24
+ const basenames = ['a.ts', 'b.mjs', 'c.js'];
25
+
26
+ it('returns [] when none of the basenames are present', () => {
27
+ expect(findPresentFiles(tmpDir, basenames)).toEqual([]);
28
+ });
29
+
30
+ it('returns only the present files, in basenames precedence order', () => {
31
+ // Create out of order to prove the result follows `basenames`, not disk order.
32
+ fs.writeFileSync(path.join(tmpDir, 'c.js'), '');
33
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), '');
34
+ const present = findPresentFiles(tmpDir, basenames);
35
+ expect(present.map(p => path.basename(p))).toEqual(['a.ts', 'c.js']);
36
+ });
37
+
38
+ it('returns absolute paths joined to the given directory', () => {
39
+ fs.writeFileSync(path.join(tmpDir, 'b.mjs'), '');
40
+ const present = findPresentFiles(tmpDir, basenames);
41
+ expect(present).toEqual([path.join(tmpDir, 'b.mjs')]);
42
+ expect(path.isAbsolute(present[0])).toBe(true);
43
+ });
44
+
45
+ it('does not match files in nested subdirectories', () => {
46
+ const nested = path.join(tmpDir, 'nested');
47
+ fs.mkdirSync(nested);
48
+ fs.writeFileSync(path.join(nested, 'a.ts'), '');
49
+ expect(findPresentFiles(tmpDir, basenames)).toEqual([]);
50
+ });
51
+ });
52
+
53
+ describe('importUserModule', () => {
54
+ it('imports a .mjs file and returns its module namespace', async () => {
55
+ const file = path.join(tmpDir, 'mod.mjs');
56
+ fs.writeFileSync(
57
+ file,
58
+ `export default {answer: 42};\nexport const named = 'hi';\n`,
59
+ );
60
+ const mod = await importUserModule(file);
61
+ expect(mod.default).toEqual({answer: 42});
62
+ expect(mod.named).toBe('hi');
63
+ });
64
+ });
65
+
66
+ describe('loadModuleWithSchema', () => {
67
+ // Temp module files live under a repo-local dir (not /tmp): Vite's dynamic
68
+ // import blocks /tmp, so we mirror the existing repo-local temp pattern.
69
+ const schema = z
70
+ .object({
71
+ name: z.string(),
72
+ count: z.number().optional(),
73
+ })
74
+ .strict();
75
+
76
+ it('returns the parsed default export when it satisfies the schema', async () => {
77
+ const file = path.join(tmpDir, 'valid.mjs');
78
+ fs.writeFileSync(file, `export default {name: 'ok', count: 3};\n`);
79
+ const value = await loadModuleWithSchema(file, schema, {label: 'thing'});
80
+ expect(value).toEqual({name: 'ok', count: 3});
81
+ });
82
+
83
+ it('throws a readable error when there is no default export', async () => {
84
+ const file = path.join(tmpDir, 'no-default.mjs');
85
+ fs.writeFileSync(file, `export const named = {name: 'x'};\n`);
86
+ await expect(
87
+ loadModuleWithSchema(file, schema, {label: 'thing'}),
88
+ ).rejects.toThrow(/thing is invalid/i);
89
+ });
90
+
91
+ it('throws a readable error when the default export fails the schema', async () => {
92
+ const file = path.join(tmpDir, 'invalid.mjs');
93
+ fs.writeFileSync(file, `export default {count: 'not-a-number'};\n`);
94
+ await expect(
95
+ loadModuleWithSchema(file, schema, {label: 'thing'}),
96
+ ).rejects.toThrow(/thing is invalid:.*name/i);
97
+ });
98
+
99
+ it('falls back to the file path in the message when no label is given', async () => {
100
+ const file = path.join(tmpDir, 'unlabeled.mjs');
101
+ fs.writeFileSync(file, `export default {bogus: true};\n`);
102
+ await expect(loadModuleWithSchema(file, schema)).rejects.toThrow(
103
+ new RegExp(`${path.basename(file)} is invalid`),
104
+ );
105
+ });
106
+ });
@@ -0,0 +1,502 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Project — the single API for reading resolved project configuration.
5
+ *
6
+ * `Project` is the one entry point a command uses to read everything it needs
7
+ * about a consumer's project: the validated config surface, the configured
8
+ * integrations, and the resolved discovery sets (components, templates,
9
+ * codemods) — plus issue routing (issuesUrl) and the accumulated integration
10
+ * issues. It replaces the old `loadConfig(cwd)` plain-object loader and the
11
+ * per-command fan-out into the various discovery helpers.
12
+ *
13
+ * Design:
14
+ * - `Project.load(cwd, {cache})` is the async factory (constructors can't be
15
+ * async). It does what loadConfig did — find the config sibling-of
16
+ * package.json, import + validate it, load the configured integrations —
17
+ * and nothing more. Discovery is LAZY.
18
+ * - Discovery methods (components/templates/codemods) are MEMOIZED per
19
+ * instance (via the pluggable cache) and orchestrate the EXISTING discovery
20
+ * functions — Project never reimplements discovery.
21
+ * - SKIP + WARN policy: as a discovery method runs, per-integration work is
22
+ * guarded so one broken integration never throws out of discovery. Any
23
+ * AstryxIntegrationIssue encountered is collected into a private set and
24
+ * that integration's contributions are skipped.
25
+ * - `issues()` returns the deduped accumulated set, and (when called
26
+ * directly) fills in validation for any configured integration not yet
27
+ * visited by a discovery call, so it is always complete on demand.
28
+ *
29
+ * @position lib — orchestration over config-schema / integrations /
30
+ * component-discovery / template / codemod discovery; commands consume it.
31
+ */
32
+
33
+ import * as fs from 'node:fs';
34
+ import * as path from 'node:path';
35
+ import {findPresentFiles, loadModuleWithSchema} from './module-loader.mjs';
36
+ import {AstryxConfigSchema} from './config-schema.mjs';
37
+ import {loadIntegrations} from './integrations.mjs';
38
+ import {
39
+ CORE_PACKAGE,
40
+ discoverOwnedComponents,
41
+ discoverIntegrationComponents,
42
+ } from './component-discovery.mjs';
43
+ import {findCoreDir} from '../utils/paths.mjs';
44
+ import {
45
+ discoverTemplates,
46
+ discoverIntegrationTemplatesForOne,
47
+ } from '../api/template.mjs';
48
+ import {getTransformsBetween} from '../codemods/registry.mjs';
49
+ import {
50
+ discoverIntegrationCodemods,
51
+ selectIntegrationCodemods,
52
+ } from '../codemods/integration-discovery.mjs';
53
+ import {validateLoadedIntegration} from '../api/validate-integration.mjs';
54
+ import {
55
+ InMemoryConfigCache,
56
+ cacheKey,
57
+ configContentHash,
58
+ } from './config-cache.mjs';
59
+
60
+ /** Conventional config basenames, in load-precedence order. */
61
+ export const CONFIG_BASENAMES = [
62
+ 'astryx.config.ts',
63
+ 'astryx.config.mjs',
64
+ 'astryx.config.js',
65
+ ];
66
+
67
+ /** Default issue tracker used when neither config nor integration routes one. */
68
+ export const DEFAULT_ISSUES_URL =
69
+ 'https://github.com/facebook/astryx/issues/new';
70
+
71
+ /**
72
+ * Find the directory of the nearest package.json walking up from startDir.
73
+ * @param {string} startDir
74
+ * @returns {string|null}
75
+ */
76
+ function findPackageRoot(startDir) {
77
+ let dir = startDir;
78
+ for (let i = 0; i < 50; i++) {
79
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
80
+ const parent = path.dirname(dir);
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Find astryx.config.{ts,mjs,js} as a sibling of the nearest package.json.
89
+ * Returns the absolute path, or null if none is present. Throws if multiple
90
+ * config files exist at that root.
91
+ * @param {string} [startDir]
92
+ * @returns {string|null}
93
+ */
94
+ export function findConfigPath(startDir = process.cwd()) {
95
+ const root = findPackageRoot(startDir) ?? startDir;
96
+ const present = findPresentFiles(root, CONFIG_BASENAMES);
97
+ if (present.length > 1) {
98
+ throw new Error(
99
+ `Multiple Astryx config files found in ${root} (${present
100
+ .map(p => path.basename(p))
101
+ .join(', ')}). Keep exactly one.`,
102
+ );
103
+ }
104
+ return present.length === 1 ? present[0] : null;
105
+ }
106
+
107
+ /**
108
+ * The single API for reading resolved project configuration. Construct via the
109
+ * async {@link Project.load} factory.
110
+ */
111
+ export class Project {
112
+ /** @type {string} */
113
+ #cwd;
114
+ /** @type {string|null} */
115
+ #configPath;
116
+ /** @type {import('../types/config').AstryxConfig} */
117
+ #config;
118
+ /** @type {string[]} */
119
+ #integrations;
120
+ /** @type {Array<object>} */
121
+ #loadedIntegrations;
122
+ /** @type {import('./config-cache.mjs').ConfigCache} */
123
+ #cache;
124
+ /** @type {string} */
125
+ #hash;
126
+ /** @type {import('../types/integration').AstryxIntegrationIssue[]} */
127
+ #issues = [];
128
+ /**
129
+ * Package names of integrations whose issues have already been collected
130
+ * (via a discovery method or a direct issues() validation), so issues() can
131
+ * fill in only the ones not yet visited and never double-collect.
132
+ * @type {Set<string>}
133
+ */
134
+ #visitedIssues = new Set();
135
+
136
+ /**
137
+ * @param {object} init
138
+ * @param {string} init.cwd
139
+ * @param {string|null} init.configPath
140
+ * @param {object} init.config validated AstryxConfig surface
141
+ * @param {string[]} init.integrations
142
+ * @param {Array<object>} init.loadedIntegrations
143
+ * @param {import('./config-cache.mjs').ConfigCache} init.cache
144
+ * @param {string} init.hash config content hash
145
+ */
146
+ constructor({
147
+ cwd,
148
+ configPath,
149
+ config,
150
+ integrations,
151
+ loadedIntegrations,
152
+ cache,
153
+ hash,
154
+ }) {
155
+ this.#cwd = cwd;
156
+ this.#configPath = configPath;
157
+ this.#config = config;
158
+ this.#integrations = integrations;
159
+ this.#loadedIntegrations = loadedIntegrations;
160
+ this.#cache = cache;
161
+ this.#hash = hash;
162
+ }
163
+
164
+ /**
165
+ * Async factory. Finds + validates the config (the same work loadConfig did)
166
+ * and loads the configured integrations. Discovery is NOT run here — it is
167
+ * lazy and memoized on the returned instance.
168
+ *
169
+ * @param {string} [cwd]
170
+ * @param {{cache?: import('./config-cache.mjs').ConfigCache}} [options]
171
+ * @returns {Promise<Project>}
172
+ */
173
+ static async load(cwd = process.cwd(), {cache} = {}) {
174
+ const resolvedCache = cache ?? new InMemoryConfigCache();
175
+ const configPath = findConfigPath(cwd);
176
+ const hash = configContentHash(configPath);
177
+
178
+ let config = {integrations: []};
179
+ let integrations = [];
180
+ let loadedIntegrations = [];
181
+
182
+ if (configPath) {
183
+ config = await loadModuleWithSchema(configPath, AstryxConfigSchema, {
184
+ label: 'astryx.config',
185
+ });
186
+ const configDir = path.dirname(configPath);
187
+ integrations = config.integrations ?? [];
188
+ loadedIntegrations = await loadIntegrations(integrations, {
189
+ cwd: configDir,
190
+ });
191
+ }
192
+
193
+ return new Project({
194
+ cwd,
195
+ configPath,
196
+ config,
197
+ integrations,
198
+ loadedIntegrations,
199
+ cache: resolvedCache,
200
+ hash,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The validated config surface (same data loadConfig returned, minus the
206
+ * resolved `loadedIntegrations` which is exposed separately).
207
+ * @returns {{integrations?: string[], issuesUrl?: string, hooks?: object, experimental?: object}}
208
+ */
209
+ get config() {
210
+ return this.#config;
211
+ }
212
+
213
+ /** Configured integration package names. @returns {string[]} */
214
+ get integrations() {
215
+ return this.#integrations;
216
+ }
217
+
218
+ /** Resolved loaded integrations (lib/integrations.mjs shape). @returns {Array<object>} */
219
+ get loadedIntegrations() {
220
+ return this.#loadedIntegrations;
221
+ }
222
+
223
+ /** @returns {string} */
224
+ get cwd() {
225
+ return this.#cwd;
226
+ }
227
+
228
+ /**
229
+ * Absolute path to the resolved config file, or null when the project has
230
+ * no config (defaults-only).
231
+ * @returns {string|null}
232
+ */
233
+ get configPath() {
234
+ return this.#configPath;
235
+ }
236
+
237
+ /**
238
+ * Memoize an async producer behind the pluggable cache, keyed by the config
239
+ * content hash + cwd + discovery kind. A sentinel wrapper distinguishes a
240
+ * cached `undefined`/falsy value from a cache miss.
241
+ * @template T
242
+ * @param {string} kind
243
+ * @param {() => Promise<T>} produce
244
+ * @returns {Promise<T>}
245
+ */
246
+ async #memo(kind, produce) {
247
+ const key = cacheKey(this.#hash, this.#cwd, kind);
248
+ const hit = this.#cache.get(key);
249
+ if (hit !== undefined) return hit.value;
250
+ const value = await produce();
251
+ this.#cache.set(key, {value});
252
+ return value;
253
+ }
254
+
255
+ /**
256
+ * Record a single integration issue, deduped by (package, code, message).
257
+ * @param {string} pkg
258
+ * @param {import('../types/integration').AstryxIntegrationIssue} issue
259
+ */
260
+ #pushIssue(pkg, issue) {
261
+ const code = issue?.code ?? 'unknown';
262
+ const message = issue?.message ?? '';
263
+ const exists = this.#issues.some(
264
+ e => e.package === pkg && e.code === code && e.message === message,
265
+ );
266
+ if (exists) return;
267
+ this.#issues.push({
268
+ package: pkg,
269
+ code,
270
+ severity: issue?.severity ?? 'error',
271
+ message,
272
+ });
273
+ }
274
+
275
+ /** Package label for a loaded integration. */
276
+ #pkgLabel(integration) {
277
+ return integration?.name ?? integration?.__spec ?? '(integration)';
278
+ }
279
+
280
+ /**
281
+ * Validate one loaded integration and collect any issues. Marks the
282
+ * integration visited so issues() won't redo the work. Best-effort: a
283
+ * validator throwing is itself recorded as an issue, never propagated.
284
+ * @param {object} integration
285
+ */
286
+ async #collectIssues(integration) {
287
+ const pkg = this.#pkgLabel(integration);
288
+ if (this.#visitedIssues.has(pkg)) return;
289
+ this.#visitedIssues.add(pkg);
290
+ try {
291
+ const found = await validateLoadedIntegration(integration);
292
+ for (const issue of found ?? []) this.#pushIssue(pkg, issue);
293
+ } catch (err) {
294
+ this.#pushIssue(pkg, {
295
+ code: 'integration_error',
296
+ severity: 'error',
297
+ message: err?.message ?? String(err),
298
+ });
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Core + integration component ownership records. Wraps
304
+ * discoverOwnedComponents for core and discoverIntegrationComponents (via
305
+ * discoverOwnedComponents) for integrations, but applies the skip+warn
306
+ * policy per integration: a broken integration's components are skipped and
307
+ * its issues collected, never thrown. Memoized per instance.
308
+ *
309
+ * @returns {Promise<Array<{name: string, package: string, group: string|null, docPath: string|null, sourcePath: string|null, issuesUrl: string|undefined}>>}
310
+ */
311
+ async components() {
312
+ return this.#memo('components', async () => {
313
+ const coreDir = findCoreDir(this.#cwd);
314
+ /** @type {Array<object>} */
315
+ const records = [];
316
+
317
+ // Core records (no integrations) — never integration-broken.
318
+ if (coreDir) {
319
+ try {
320
+ records.push(...discoverOwnedComponents(coreDir, []));
321
+ } catch {
322
+ // Core discovery failure is not an integration issue; surface
323
+ // nothing here (core problems show up via doctor/other paths).
324
+ }
325
+ }
326
+
327
+ // Each integration in isolation so one broken integration is skipped.
328
+ for (const integration of this.#loadedIntegrations) {
329
+ await this.#collectIssues(integration);
330
+ const pkg = this.#pkgLabel(integration);
331
+ const hadError = this.#issues.some(
332
+ i => i.package === pkg && i.severity === 'error',
333
+ );
334
+ if (hadError) continue;
335
+ try {
336
+ // discoverOwnedComponents owns the core+integration record shape;
337
+ // here we add only this integration's records (core is handled
338
+ // above) so a single broken integration can be skipped in isolation.
339
+ for (const rec of discoverIntegrationComponents(integration)) {
340
+ records.push(rec);
341
+ }
342
+ } catch (err) {
343
+ this.#pushIssue(pkg, {
344
+ code: 'invalid_component',
345
+ severity: 'error',
346
+ message: err?.message ?? String(err),
347
+ });
348
+ }
349
+ }
350
+
351
+ return records;
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Core + integration templates, type-tagged. Wraps discoverTemplates (core +
357
+ * external blocks) and discoverIntegrationTemplatesForOne per integration so
358
+ * a broken integration's templates are skipped and its issues collected.
359
+ * Memoized per instance.
360
+ *
361
+ * @returns {Promise<Array<object>>}
362
+ */
363
+ async templates() {
364
+ return this.#memo('templates', async () => {
365
+ /** @type {Array<object>} */
366
+ const templates = [];
367
+
368
+ // Core + external-package templates (discoverTemplates internally also
369
+ // loads integration templates via loadConfig today; we intentionally
370
+ // re-collect integration templates below through the per-integration
371
+ // path so the skip+warn policy and issue collection apply, then dedupe).
372
+ try {
373
+ const core = await discoverTemplates(this.#cwd);
374
+ for (const t of core) {
375
+ // Skip integration-owned templates here; they are re-added (and
376
+ // issue-collected) per integration below to honor skip+warn.
377
+ if (t.package && t.package !== CORE_PACKAGE) continue;
378
+ templates.push(t);
379
+ }
380
+ } catch {
381
+ // Core/template discovery failure contributes no templates.
382
+ }
383
+
384
+ for (const integration of this.#loadedIntegrations) {
385
+ await this.#collectIssues(integration);
386
+ const pkg = this.#pkgLabel(integration);
387
+ const hadError = this.#issues.some(
388
+ i => i.package === pkg && i.severity === 'error',
389
+ );
390
+ if (hadError) continue;
391
+ try {
392
+ const {templates: ts, errors} =
393
+ await discoverIntegrationTemplatesForOne(integration);
394
+ for (const e of errors) {
395
+ this.#pushIssue(pkg, {
396
+ code: 'invalid_template',
397
+ severity: 'error',
398
+ message: e.message,
399
+ });
400
+ }
401
+ // Only contribute templates when the integration had no per-template
402
+ // errors (skip the whole integration's templates on any error).
403
+ if (errors.length === 0) templates.push(...ts);
404
+ } catch (err) {
405
+ this.#pushIssue(pkg, {
406
+ code: 'invalid_template',
407
+ severity: 'error',
408
+ message: err?.message ?? String(err),
409
+ });
410
+ }
411
+ }
412
+
413
+ return templates.sort((a, b) => a.name.localeCompare(b.name));
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Core registry transforms + integration codemods for an upgrade range.
419
+ * Wraps getTransformsBetween (core) and discoverIntegrationCodemods /
420
+ * selectIntegrationCodemods (integrations). A broken integration's codemods
421
+ * are skipped (issue collected) rather than failing the whole resolution.
422
+ * Memoized per (from, to) key.
423
+ *
424
+ * @param {string} fromVersion exclusive lower bound
425
+ * @param {string} toVersion inclusive upper bound
426
+ * @returns {Promise<{core: Array<{version: string, transforms: Array<object>}>, integration: Array<{version: string, codemods: Array<object>}>}>}
427
+ */
428
+ async codemods(fromVersion, toVersion) {
429
+ return this.#memo(`codemods:${fromVersion}..${toVersion}`, async () => {
430
+ const core = await getTransformsBetween(fromVersion, toVersion);
431
+
432
+ // Discover integration codemods per integration so a single broken
433
+ // integration is skipped (issue collected) without losing the others.
434
+ const good = [];
435
+ for (const integration of this.#loadedIntegrations) {
436
+ await this.#collectIssues(integration);
437
+ const pkg = this.#pkgLabel(integration);
438
+ const hadError = this.#issues.some(
439
+ i => i.package === pkg && i.severity === 'error',
440
+ );
441
+ if (hadError) continue;
442
+ if (!integration?.codemods) continue;
443
+ try {
444
+ // Validate this integration's codemods discover cleanly in
445
+ // isolation; if so it is safe to include.
446
+ await discoverIntegrationCodemods([integration]);
447
+ good.push(integration);
448
+ } catch (err) {
449
+ this.#pushIssue(pkg, {
450
+ code: 'invalid_codemod',
451
+ severity: 'error',
452
+ message: err?.message ?? String(err),
453
+ });
454
+ }
455
+ }
456
+
457
+ const byVersion = await discoverIntegrationCodemods(good);
458
+ const integration = selectIntegrationCodemods(
459
+ byVersion,
460
+ fromVersion,
461
+ toVersion,
462
+ );
463
+
464
+ return {core, integration};
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Route an issues URL for a component/source reference.
470
+ *
471
+ * - `package === CORE_PACKAGE` or omitted => this.config.issuesUrl or the
472
+ * default core issues URL.
473
+ * - an integration package => that loaded integration's manifest issuesUrl
474
+ * (which may be undefined when the integration ships none).
475
+ *
476
+ * @param {{package?: string}} [ref]
477
+ * @returns {string|undefined}
478
+ */
479
+ issuesUrl(ref = {}) {
480
+ const pkg = ref?.package;
481
+ if (!pkg || pkg === CORE_PACKAGE) {
482
+ return this.#config.issuesUrl ?? DEFAULT_ISSUES_URL;
483
+ }
484
+ const integration = this.#loadedIntegrations.find(i => i.name === pkg);
485
+ return integration?.issuesUrl;
486
+ }
487
+
488
+ /**
489
+ * The accumulated integration issues (deduped by package, code, message).
490
+ * When called directly, also validates any configured integration not yet
491
+ * visited by a discovery call, so the returned set is complete on demand.
492
+ *
493
+ * @returns {Promise<import('../types/integration').AstryxIntegrationIssue[]>}
494
+ */
495
+ async issues() {
496
+ for (const integration of this.#loadedIntegrations) {
497
+ await this.#collectIssues(integration);
498
+ }
499
+ // Return a stable copy so callers can't mutate internal state.
500
+ return this.#issues.map(i => ({...i}));
501
+ }
502
+ }