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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +31 -8
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +87 -28
  7. package/src/api/doctor.mjs +7 -22
  8. package/src/api/index.mjs +1 -0
  9. package/src/api/layout.mjs +302 -0
  10. package/src/api/layout.test.mjs +241 -0
  11. package/src/api/template-integration.test.mjs +225 -0
  12. package/src/api/template.mjs +421 -122
  13. package/src/api/template.test.mjs +2 -0
  14. package/src/api/theme-add.mjs +182 -0
  15. package/src/api/validate-integration.mjs +370 -0
  16. package/src/api/validate-integration.test.mjs +222 -0
  17. package/src/codemod.mjs +93 -0
  18. package/src/codemod.test.mjs +134 -0
  19. package/src/codemods/__tests__/registry.test.mjs +2 -0
  20. package/src/codemods/__tests__/runner.test.mjs +103 -0
  21. package/src/codemods/integration-discovery.mjs +168 -0
  22. package/src/codemods/integration-discovery.test.mjs +234 -0
  23. package/src/codemods/integration-runner.mjs +109 -0
  24. package/src/codemods/registry.mjs +2 -0
  25. package/src/codemods/run-codemod.mjs +207 -0
  26. package/src/codemods/runner.mjs +73 -96
  27. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  28. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
  29. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  30. package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
  31. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  32. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  33. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  34. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  35. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  36. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  37. package/src/commands/build-theme.import-path.test.mjs +22 -2
  38. package/src/commands/build-theme.mjs +185 -16
  39. package/src/commands/component/index.mjs +44 -10
  40. package/src/commands/component-ownership.test.mjs +227 -0
  41. package/src/commands/discover.mjs +5 -16
  42. package/src/commands/doctor.test.mjs +3 -3
  43. package/src/commands/init.mjs +34 -8
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/json-contract.test.mjs +0 -32
  46. package/src/commands/layout.mjs +139 -0
  47. package/src/commands/swizzle.mjs +241 -224
  48. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  49. package/src/commands/swizzle.routing.test.mjs +279 -0
  50. package/src/commands/template.mjs +33 -37
  51. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  52. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  53. package/src/commands/upgrade.mjs +349 -239
  54. package/src/commands/validate-integration.mjs +110 -0
  55. package/src/commands/validate-integration.test.mjs +124 -0
  56. package/src/config.mjs +18 -0
  57. package/src/config.test.mjs +100 -0
  58. package/src/index.mjs +13 -3
  59. package/src/integration.mjs +19 -0
  60. package/src/lib/component-discovery.mjs +177 -0
  61. package/src/lib/config-cache.mjs +91 -0
  62. package/src/lib/config-cache.test.mjs +83 -0
  63. package/src/lib/config-schema.mjs +105 -0
  64. package/src/lib/error-codes.mjs +18 -12
  65. package/src/lib/integration-warnings.mjs +62 -0
  66. package/src/lib/integration-warnings.test.mjs +102 -0
  67. package/src/lib/integrations.mjs +132 -0
  68. package/src/lib/integrations.test.mjs +135 -0
  69. package/src/lib/levenshtein.mjs +29 -0
  70. package/src/lib/manifest.mjs +15 -2
  71. package/src/lib/module-loader.mjs +80 -0
  72. package/src/lib/module-loader.test.mjs +106 -0
  73. package/src/lib/package-scanner.mjs +31 -7
  74. package/src/lib/project.mjs +502 -0
  75. package/src/lib/project.test.mjs +308 -0
  76. package/src/lib/string-utils.mjs +5 -14
  77. package/src/lib/xle/browser.d.ts +100 -0
  78. package/src/lib/xle/browser.mjs +120 -0
  79. package/src/lib/xle/expand.mjs +622 -0
  80. package/src/lib/xle/parse.mjs +581 -0
  81. package/src/lib/xle/print.mjs +174 -0
  82. package/src/lib/xle/registry-core.mjs +170 -0
  83. package/src/lib/xle/registry.mjs +237 -0
  84. package/src/lib/xle/splice.mjs +137 -0
  85. package/src/lib/xle/validate.mjs +356 -0
  86. package/src/lib/xle/xle.test.mjs +333 -0
  87. package/src/template.mjs +73 -0
  88. package/src/template.test.mjs +127 -0
  89. package/src/types/api.d.ts +2 -6
  90. package/src/types/base.d.ts +3 -9
  91. package/src/types/codemod.d.ts +81 -0
  92. package/src/types/component.d.ts +27 -2
  93. package/src/types/config.d.ts +70 -0
  94. package/src/types/error-codes.d.ts +5 -3
  95. package/src/types/index.d.ts +0 -1
  96. package/src/types/integration.d.ts +29 -0
  97. package/src/types/swizzle.d.ts +9 -2
  98. package/src/types/template-api.d.ts +54 -0
  99. package/src/types/template.d.ts +10 -7
  100. package/src/types/upgrade.d.ts +29 -0
  101. package/src/types/validate-integration.d.ts +24 -0
  102. package/src/utils/github.mjs +0 -252
  103. package/src/utils/interactive.mjs +2 -2
  104. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  105. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  106. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  107. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  108. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  109. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  110. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  111. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  112. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  113. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  114. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  115. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  116. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  117. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  118. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  119. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  120. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  121. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  122. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  123. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  124. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  125. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  126. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  127. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  128. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  129. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  130. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  131. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  132. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  133. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  134. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  135. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  136. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  137. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  138. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  139. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  140. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  141. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  142. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  143. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  144. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  145. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  146. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  147. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  148. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  149. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  150. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  151. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  152. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  153. package/templates/pages/shell-nav/page.tsx +321 -0
  154. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  155. package/templates/pages/shell-side-nav/page.tsx +242 -0
  156. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  157. package/templates/pages/shell-top-nav/page.tsx +224 -0
  158. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  159. package/templates/pages/theme-showcase/page.tsx +8 -19
  160. package/templates/themes/butter/butterTheme.ts +916 -0
  161. package/templates/themes/butter/icons.tsx +77 -0
  162. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  163. package/templates/themes/chocolate/icons.tsx +77 -0
  164. package/templates/themes/gothic/gothicTheme.ts +657 -0
  165. package/templates/themes/gothic/icons.tsx +77 -0
  166. package/templates/themes/manifest.json +90 -0
  167. package/templates/themes/matcha/icons.tsx +67 -0
  168. package/templates/themes/matcha/matchaTheme.ts +247 -0
  169. package/templates/themes/neutral/icons.tsx +77 -0
  170. package/templates/themes/neutral/neutralTheme.ts +603 -0
  171. package/templates/themes/stone/icons.tsx +77 -0
  172. package/templates/themes/stone/stoneTheme.ts +652 -0
  173. package/templates/themes/y2k/icons.tsx +67 -0
  174. package/templates/themes/y2k/y2kTheme.ts +617 -0
  175. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  176. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  177. package/src/commands/gap-report.mjs +0 -456
  178. package/src/commands/gap-report.test.mjs +0 -163
  179. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  180. package/src/lib/config.mjs +0 -86
  181. package/src/lib/config.test.mjs +0 -42
  182. package/src/types/gap-report.d.ts +0 -29
  183. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  184. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
  185. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -74,5 +74,7 @@ describe('template --skeleton component extraction (prefix-agnostic)', () => {
74
74
  expect(result.data.skeleton.trim().length).toBeGreaterThan(0);
75
75
  expect(result.data.skeleton).toMatch(/<[A-Z]\w+/);
76
76
  expect(result.data.skeleton).not.toContain('<XDS');
77
+
78
+ expect(result.data.skeleton).toContain('columns={{minWidth: 200}}');
77
79
  });
78
80
  });
@@ -0,0 +1,182 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Programmatic API for `astryx theme add` — copies a bundled theme's
5
+ * source (`templates/themes/`, see scripts/generate-cli-themes.mjs) into the
6
+ * consumer's project so they own it, without needing the theme package.
7
+ */
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import {CLI_ROOT} from '../utils/paths.mjs';
12
+ import {assertWithin, PathSafetyError} from '../utils/path-safety.mjs';
13
+ import {AstryxError} from './error.mjs';
14
+ import {ERROR_CODES} from '../lib/error-codes.mjs';
15
+
16
+ const THEMES_DIR = path.join(CLI_ROOT, 'templates', 'themes');
17
+ const MANIFEST_PATH = path.join(THEMES_DIR, 'manifest.json');
18
+
19
+ // Stripped from scaffolded files so the consumer's copy doesn't carry our
20
+ // repo boilerplate (mirrors the docsite). Preserves a leading BOM/shebang.
21
+ const META_COPYRIGHT_HEADER_RE =
22
+ /^(\uFEFF?(?:#![^\r\n]*(?:\r?\n))?)\/\/ Copyright \(c\) Meta Platforms, Inc\. and affiliates\.\r?\n(?:\r?\n)*/;
23
+
24
+ function stripCopyrightHeader(source) {
25
+ return source.replace(META_COPYRIGHT_HEADER_RE, '$1');
26
+ }
27
+
28
+ /** Parsed `themes` array from the bundle manifest (empty if not generated). */
29
+ export function listThemes() {
30
+ if (!fs.existsSync(MANIFEST_PATH)) return [];
31
+ let manifest;
32
+ try {
33
+ manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
34
+ } catch (err) {
35
+ throw new AstryxError(
36
+ `Theme bundle manifest is unreadable (${MANIFEST_PATH}): ${err.message}`,
37
+ undefined,
38
+ ERROR_CODES.ERR_NO_SOURCE,
39
+ );
40
+ }
41
+ return Array.isArray(manifest.themes) ? manifest.themes : [];
42
+ }
43
+
44
+ function findTheme(slug) {
45
+ if (!slug) return undefined;
46
+ const lc = String(slug).toLowerCase();
47
+ return listThemes().find(t => t.slug.toLowerCase() === lc);
48
+ }
49
+
50
+ function defaultTargetDir(slug) {
51
+ return path.join('src', 'themes', slug);
52
+ }
53
+
54
+ /**
55
+ * Resolve the scaffold plan for `theme add`. Returns the theme list when no
56
+ * slug (or `list`); otherwise copies the theme's files into the destination
57
+ * (defaults to `src/themes/<slug>/`).
58
+ *
59
+ * @param {string} [slug]
60
+ * @param {{list?: boolean, targetPath?: string, overwrite?: boolean, cwd?: string}} [options]
61
+ * @returns {Promise<{type: string, data: unknown}>}
62
+ */
63
+ export async function themeAdd(slug, options = {}) {
64
+ const {list = false, targetPath, overwrite = false, cwd = process.cwd()} =
65
+ options;
66
+ const themes = listThemes();
67
+
68
+ if (list || !slug) {
69
+ return {
70
+ type: 'theme.list',
71
+ data: themes.map(t => ({
72
+ slug: t.slug,
73
+ displayName: t.displayName,
74
+ description: t.description,
75
+ maintained: t.maintained,
76
+ })),
77
+ };
78
+ }
79
+
80
+ const match = findTheme(slug);
81
+ if (!match) {
82
+ throw new AstryxError(
83
+ `Unknown theme "${slug}"`,
84
+ themes.map(t => ({
85
+ name: t.slug,
86
+ reason: t.maintained ? 'maintained theme' : 'example theme',
87
+ })),
88
+ ERROR_CODES.ERR_UNKNOWN_THEME,
89
+ );
90
+ }
91
+
92
+ const themeSrcDir = path.join(THEMES_DIR, match.slug);
93
+
94
+ // Path-safe destination; reject traversal outside cwd.
95
+ const rawTarget = targetPath || defaultTargetDir(match.slug);
96
+ let resolvedDir;
97
+ try {
98
+ resolvedDir = assertWithin(rawTarget, cwd, {label: 'theme target path'});
99
+ } catch (err) {
100
+ if (err instanceof PathSafetyError) {
101
+ throw new AstryxError(
102
+ err.message,
103
+ undefined,
104
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
105
+ );
106
+ }
107
+ throw err;
108
+ }
109
+
110
+ const writes = match.files.map(name => ({
111
+ name,
112
+ src: path.join(themeSrcDir, name),
113
+ dest: path.join(resolvedDir, name),
114
+ }));
115
+ for (const w of writes) {
116
+ if (!fs.existsSync(w.src)) {
117
+ throw new AstryxError(
118
+ `Theme "${match.slug}" is missing bundled file "${w.name}". ` +
119
+ `Re-run \`node scripts/generate-cli-themes.mjs\` to rebuild the bundle.`,
120
+ undefined,
121
+ ERROR_CODES.ERR_NO_SOURCE,
122
+ );
123
+ }
124
+ }
125
+
126
+ // Refuse to clobber unless --overwrite.
127
+ if (!overwrite) {
128
+ const existing = writes.find(w => fs.existsSync(w.dest));
129
+ if (existing) {
130
+ const rel = path.relative(cwd, existing.dest) || existing.dest;
131
+ throw new AstryxError(
132
+ `Refusing to overwrite existing file ${rel}. ` +
133
+ `Re-run with --overwrite (or -f) to replace it.`,
134
+ undefined,
135
+ ERROR_CODES.ERR_FILE_EXISTS,
136
+ );
137
+ }
138
+ }
139
+
140
+ // Stage to temp files then rename, rolling back partials on failure so a
141
+ // failed write never leaves a half-written theme.
142
+ fs.mkdirSync(resolvedDir, {recursive: true});
143
+ const staged = [];
144
+ try {
145
+ for (const w of writes) {
146
+ const tmp = `${w.dest}.${process.pid}.tmp`;
147
+ const contents = stripCopyrightHeader(fs.readFileSync(w.src, 'utf-8'));
148
+ fs.writeFileSync(tmp, contents);
149
+ staged.push({tmp, dest: w.dest});
150
+ }
151
+ for (const s of staged) {
152
+ fs.renameSync(s.tmp, s.dest);
153
+ }
154
+ } catch (err) {
155
+ for (const s of staged) {
156
+ try {
157
+ fs.rmSync(s.tmp, {force: true});
158
+ } catch {
159
+ /* best-effort */
160
+ }
161
+ }
162
+ throw new AstryxError(
163
+ `Failed to write theme files: ${err.message}`,
164
+ undefined,
165
+ ERROR_CODES.ERR_WRITE_FAILED,
166
+ );
167
+ }
168
+
169
+ const relDir = path.relative(cwd, resolvedDir) || '.';
170
+ return {
171
+ type: 'theme.add',
172
+ data: {
173
+ slug: match.slug,
174
+ displayName: match.displayName,
175
+ maintained: match.maintained,
176
+ outputDir: relDir,
177
+ entry: match.entry,
178
+ exportName: match.exportName,
179
+ files: match.files,
180
+ },
181
+ };
182
+ }
@@ -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};