@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,370 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Programmatic API for `astryx validate-integration`.
|
|
5
|
+
*
|
|
6
|
+
* Validates exactly ONE integration package at a time and reports findings
|
|
7
|
+
* using the AstryxIntegrationIssue model
|
|
8
|
+
* ({ code, severity: 'warning'|'error', message }; see
|
|
9
|
+
* types/integration.d.ts). Two entry points:
|
|
10
|
+
*
|
|
11
|
+
* - validateLocalIntegration(cwd) — the package rooted at `cwd` (nearest
|
|
12
|
+
* package.json + sibling astryx.integration.{ts,mjs,js}).
|
|
13
|
+
* - validateInstalledIntegration(spec, cwd) — an installed package resolved
|
|
14
|
+
* from `cwd`/node_modules.
|
|
15
|
+
*
|
|
16
|
+
* Both return a { found, name, version, manifestFile, issues } result. `found`
|
|
17
|
+
* is false only for the no-manifest local case, which is guidance (not an
|
|
18
|
+
* error) so `validate-integration` can stay exit-0 in a non-integration dir.
|
|
19
|
+
*
|
|
20
|
+
* Validators are intentionally small and independent so more checks can be
|
|
21
|
+
* appended without reshaping the result. Issue `code`s are stable public
|
|
22
|
+
* strings.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
findManifestPaths,
|
|
29
|
+
loadManifestObject,
|
|
30
|
+
resolvePackageDir,
|
|
31
|
+
} from '../lib/integrations.mjs';
|
|
32
|
+
import {discoverIntegrationCodemods} from '../codemods/integration-discovery.mjs';
|
|
33
|
+
import {discoverIntegrationTemplatesForOne} from './template.mjs';
|
|
34
|
+
import * as componentDiscovery from '../lib/component-discovery.mjs';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {import('../types/integration').AstryxIntegrationIssue} Issue
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} ValidateResult
|
|
42
|
+
* @property {boolean} found Whether an integration manifest was located.
|
|
43
|
+
* @property {string} [name] Integration package name (from package.json).
|
|
44
|
+
* @property {string} [version] Integration package version.
|
|
45
|
+
* @property {string} [manifestFile] Absolute path to the loaded manifest.
|
|
46
|
+
* @property {Issue[]} issues
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find the nearest package.json starting from `cwd` and walking up.
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
* @returns {string | null} absolute path to the package.json, or null.
|
|
53
|
+
*/
|
|
54
|
+
function findNearestPackageJson(cwd) {
|
|
55
|
+
let dir = path.resolve(cwd);
|
|
56
|
+
for (;;) {
|
|
57
|
+
const candidate = path.join(dir, 'package.json');
|
|
58
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
59
|
+
const parent = path.dirname(dir);
|
|
60
|
+
if (parent === dir) return null;
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {string} code @param {string} message @returns {Issue} */
|
|
66
|
+
function error(code, message) {
|
|
67
|
+
return {code, severity: 'error', message};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} code @param {string} message @returns {Issue} */
|
|
71
|
+
function warning(code, message) {
|
|
72
|
+
return {code, severity: 'warning', message};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify each declared contribution root exists on disk. A declared-but-missing
|
|
77
|
+
* root is a `missing_root` error.
|
|
78
|
+
* @param {{components?: string, templates?: string, codemods?: string}} resolved
|
|
79
|
+
* absolute resolved roots (undefined when not declared)
|
|
80
|
+
* @param {Issue[]} issues
|
|
81
|
+
*/
|
|
82
|
+
function checkRoots(resolved, issues) {
|
|
83
|
+
for (const kind of ['components', 'templates', 'codemods']) {
|
|
84
|
+
const root = resolved[kind];
|
|
85
|
+
if (root == null) continue;
|
|
86
|
+
if (!fs.existsSync(root)) {
|
|
87
|
+
issues.push(
|
|
88
|
+
error(
|
|
89
|
+
'missing_root',
|
|
90
|
+
`Declared ${kind} root does not exist on disk: ${root}`,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate the integration's codemods via the landed discovery. Discovery is
|
|
99
|
+
* strict (throws on bad export / duplicate id); we convert any throw into an
|
|
100
|
+
* `invalid_codemod` error.
|
|
101
|
+
* @param {object} integration loaded-integration-shaped object
|
|
102
|
+
* @param {Issue[]} issues
|
|
103
|
+
*/
|
|
104
|
+
async function checkCodemods(integration, issues) {
|
|
105
|
+
if (!integration.codemods || !fs.existsSync(integration.codemods)) return;
|
|
106
|
+
try {
|
|
107
|
+
await discoverIntegrationCodemods([integration]);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
issues.push(error('invalid_codemod', err.message));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate the integration's templates via the landed discovery. Per-template
|
|
115
|
+
* problems are reported as `invalid_template` errors.
|
|
116
|
+
* @param {object} integration loaded-integration-shaped object
|
|
117
|
+
* @param {Issue[]} issues
|
|
118
|
+
*/
|
|
119
|
+
async function checkTemplates(integration, issues) {
|
|
120
|
+
if (!integration.templates || !fs.existsSync(integration.templates)) return;
|
|
121
|
+
try {
|
|
122
|
+
const {errors} = await discoverIntegrationTemplatesForOne(integration);
|
|
123
|
+
for (const e of errors) {
|
|
124
|
+
issues.push(error('invalid_template', e.message));
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
issues.push(error('invalid_template', err.message));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate the integration's components via the landed ownership discovery.
|
|
133
|
+
* Feature-detected: if the component-ownership export isn't present in this
|
|
134
|
+
* build (sibling PR not yet merged), component validation is skipped rather
|
|
135
|
+
* than hard-failing.
|
|
136
|
+
*
|
|
137
|
+
* `discoverIntegrationComponents` returns ownership records and does not throw
|
|
138
|
+
* on a missing same-stem source — it records `sourcePath: null`. We surface
|
|
139
|
+
* each such record as an `invalid_component` error.
|
|
140
|
+
* @param {object} integration loaded-integration-shaped object
|
|
141
|
+
* @param {Issue[]} issues
|
|
142
|
+
*/
|
|
143
|
+
async function checkComponents(integration, issues) {
|
|
144
|
+
if (!integration.components || !fs.existsSync(integration.components)) return;
|
|
145
|
+
const discover = componentDiscovery.discoverIntegrationComponents;
|
|
146
|
+
if (typeof discover !== 'function') return; // feature not present yet
|
|
147
|
+
try {
|
|
148
|
+
const records = (await discover(integration)) ?? [];
|
|
149
|
+
for (const record of records) {
|
|
150
|
+
if (record?.sourcePath == null) {
|
|
151
|
+
issues.push(
|
|
152
|
+
error(
|
|
153
|
+
'invalid_component',
|
|
154
|
+
`Component "${record?.name}" is missing its same-stem source file ${record?.name}.tsx.`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
issues.push(error('invalid_component', err.message));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run every contribution validator against a loaded-integration-shaped object.
|
|
166
|
+
* @param {object} integration
|
|
167
|
+
* @param {Issue[]} issues
|
|
168
|
+
*/
|
|
169
|
+
async function runContributionChecks(integration, issues) {
|
|
170
|
+
await checkCodemods(integration, issues);
|
|
171
|
+
await checkTemplates(integration, issues);
|
|
172
|
+
await checkComponents(integration, issues);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Validate an already-LOADED integration (as produced by
|
|
177
|
+
* `loadIntegrations` in lib/integrations.mjs — absolute contribution roots
|
|
178
|
+
* plus identity) and return its issues. This is the reuse seam for everyday
|
|
179
|
+
* commands that have already loaded the configured integrations and want the
|
|
180
|
+
* SAME validators that `validate-integration` runs, without re-resolving the
|
|
181
|
+
* manifest from disk.
|
|
182
|
+
*
|
|
183
|
+
* The manifest schema is intentionally NOT re-validated here: `loadIntegrations`
|
|
184
|
+
* already validated it (and throws otherwise), so by the time a command holds a
|
|
185
|
+
* loaded integration the manifest is known-good. We re-run the on-disk
|
|
186
|
+
* contribution checks (roots + codemods/templates/components) because those can
|
|
187
|
+
* regress independently of the manifest (a deleted directory, a broken template).
|
|
188
|
+
*
|
|
189
|
+
* @param {object} loaded loaded-integration-shaped object
|
|
190
|
+
* @returns {Promise<Issue[]>}
|
|
191
|
+
*/
|
|
192
|
+
export async function validateLoadedIntegration(loaded) {
|
|
193
|
+
/** @type {Issue[]} */
|
|
194
|
+
const issues = [];
|
|
195
|
+
if (!loaded || typeof loaded !== 'object') return issues;
|
|
196
|
+
checkRoots(
|
|
197
|
+
{
|
|
198
|
+
components: loaded.components,
|
|
199
|
+
templates: loaded.templates,
|
|
200
|
+
codemods: loaded.codemods,
|
|
201
|
+
},
|
|
202
|
+
issues,
|
|
203
|
+
);
|
|
204
|
+
await runContributionChecks(loaded, issues);
|
|
205
|
+
return issues;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validate a single integration given its package directory and identity.
|
|
210
|
+
* Shared core for the local and installed entry points.
|
|
211
|
+
* @param {string} packageDir
|
|
212
|
+
* @param {{name: string, version?: string}} identity
|
|
213
|
+
* @returns {Promise<ValidateResult>}
|
|
214
|
+
*/
|
|
215
|
+
async function validateAtPackageDir(packageDir, identity) {
|
|
216
|
+
/** @type {Issue[]} */
|
|
217
|
+
const issues = [];
|
|
218
|
+
const result = {
|
|
219
|
+
found: true,
|
|
220
|
+
name: identity.name,
|
|
221
|
+
version: identity.version,
|
|
222
|
+
manifestFile: undefined,
|
|
223
|
+
issues,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const manifests = findManifestPaths(packageDir);
|
|
227
|
+
if (manifests.length === 0) {
|
|
228
|
+
issues.push(
|
|
229
|
+
error(
|
|
230
|
+
'missing_manifest',
|
|
231
|
+
`No astryx.integration.{ts,mjs,js} found next to package.json in ${packageDir}.`,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
if (manifests.length > 1) {
|
|
237
|
+
issues.push(
|
|
238
|
+
error(
|
|
239
|
+
'multiple_manifests',
|
|
240
|
+
`Multiple root manifests present (${manifests
|
|
241
|
+
.map(m => path.basename(m))
|
|
242
|
+
.join(', ')}). Keep exactly one.`,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const manifestFile = manifests[0];
|
|
249
|
+
result.manifestFile = manifestFile;
|
|
250
|
+
|
|
251
|
+
// loadManifestObject loads the default export and validates it against the
|
|
252
|
+
// integration schema (the shared load boundary). A missing default export or
|
|
253
|
+
// a schema failure throws; we convert either into a single invalid_manifest
|
|
254
|
+
// error issue so validate-integration stays exit-1-but-not-crash.
|
|
255
|
+
let manifest;
|
|
256
|
+
try {
|
|
257
|
+
manifest = await loadManifestObject(
|
|
258
|
+
manifestFile,
|
|
259
|
+
`Integration manifest (${path.basename(manifestFile)})`,
|
|
260
|
+
);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
issues.push(error('invalid_manifest', err.message));
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const resolveRoot = value =>
|
|
267
|
+
value == null ? undefined : path.resolve(packageDir, value);
|
|
268
|
+
|
|
269
|
+
const loaded = {
|
|
270
|
+
name: identity.name,
|
|
271
|
+
version: identity.version,
|
|
272
|
+
components: resolveRoot(manifest.components),
|
|
273
|
+
templates: resolveRoot(manifest.templates),
|
|
274
|
+
codemods: resolveRoot(manifest.codemods),
|
|
275
|
+
issuesUrl: manifest.issuesUrl,
|
|
276
|
+
__spec: identity.name,
|
|
277
|
+
__packageDir: packageDir,
|
|
278
|
+
__manifestFile: manifestFile,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Roots + contribution checks are shared with validateLoadedIntegration so
|
|
282
|
+
// the everyday-command nudge runs the exact same validators.
|
|
283
|
+
issues.push(...(await validateLoadedIntegration(loaded)));
|
|
284
|
+
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate the LOCAL integration package rooted at `cwd`: nearest package.json
|
|
290
|
+
* + a single sibling astryx.integration.{ts,mjs,js}. A missing manifest yields
|
|
291
|
+
* `found: false` (guidance, not an error) so callers stay exit-0.
|
|
292
|
+
* @param {string} [cwd]
|
|
293
|
+
* @returns {Promise<ValidateResult>}
|
|
294
|
+
*/
|
|
295
|
+
export async function validateLocalIntegration(cwd = process.cwd()) {
|
|
296
|
+
const pkgJsonPath = findNearestPackageJson(cwd);
|
|
297
|
+
if (!pkgJsonPath) {
|
|
298
|
+
return {found: false, issues: []};
|
|
299
|
+
}
|
|
300
|
+
const packageDir = path.dirname(pkgJsonPath);
|
|
301
|
+
|
|
302
|
+
const manifests = findManifestPaths(packageDir);
|
|
303
|
+
if (manifests.length === 0) {
|
|
304
|
+
// No manifest next to package.json — guidance, not an error.
|
|
305
|
+
return {found: false, issues: []};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let pkg = {};
|
|
309
|
+
try {
|
|
310
|
+
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
311
|
+
} catch {
|
|
312
|
+
// Identity falls back to undefined; the manifest checks still run.
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return validateAtPackageDir(packageDir, {
|
|
316
|
+
name: pkg.name ?? '(local package)',
|
|
317
|
+
version: pkg.version,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Validate an INSTALLED integration package resolved from `cwd`/node_modules.
|
|
323
|
+
* @param {string} spec package name
|
|
324
|
+
* @param {string} [cwd]
|
|
325
|
+
* @returns {Promise<ValidateResult>}
|
|
326
|
+
*/
|
|
327
|
+
export async function validateInstalledIntegration(spec, cwd = process.cwd()) {
|
|
328
|
+
const packageDir = resolvePackageDir(spec, cwd);
|
|
329
|
+
const pkgJsonPath = path.join(packageDir, 'package.json');
|
|
330
|
+
|
|
331
|
+
let pkg;
|
|
332
|
+
try {
|
|
333
|
+
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
334
|
+
} catch {
|
|
335
|
+
return {
|
|
336
|
+
found: true,
|
|
337
|
+
name: spec,
|
|
338
|
+
version: undefined,
|
|
339
|
+
issues: [
|
|
340
|
+
error(
|
|
341
|
+
'package_not_found',
|
|
342
|
+
`Could not find installed integration package "${spec}" at ${pkgJsonPath}. Install it first.`,
|
|
343
|
+
),
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return validateAtPackageDir(packageDir, {
|
|
349
|
+
name: pkg.name ?? spec,
|
|
350
|
+
version: pkg.version,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Summarize issues by severity.
|
|
356
|
+
* @param {Issue[]} issues
|
|
357
|
+
* @returns {{errors: number, warnings: number}}
|
|
358
|
+
*/
|
|
359
|
+
export function summarizeIssues(issues) {
|
|
360
|
+
let errors = 0;
|
|
361
|
+
let warnings = 0;
|
|
362
|
+
for (const issue of issues) {
|
|
363
|
+
if (issue.severity === 'error') errors += 1;
|
|
364
|
+
else if (issue.severity === 'warning') warnings += 1;
|
|
365
|
+
}
|
|
366
|
+
return {errors, warnings};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Re-export issue constructors for callers/tests that want them.
|
|
370
|
+
export {error as integrationError, warning as integrationWarning};
|
|
@@ -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
|
+
}
|