@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.db4e378

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 (206) hide show
  1. package/README.md +86 -72
  2. package/api/build/build.doc.mjs +3 -3
  3. package/api/build/build.test.mjs +14 -3
  4. package/api/build/build.type.d.mts +35 -1
  5. package/api/build/build.type.mjs +24 -3
  6. package/api/build/help/help.d.mts +8 -5
  7. package/api/build/help/help.mjs +56 -6
  8. package/api/component/component.doc.mjs +12 -3
  9. package/api/component/component.mjs +31 -2
  10. package/api/component/component.test.mjs +38 -0
  11. package/api/component/component.type.d.mts +16 -5
  12. package/api/component/component.type.mjs +13 -5
  13. package/api/component/detail/blocks/blocks.d.mts +2 -1
  14. package/api/component/detail/blocks/blocks.mjs +4 -3
  15. package/api/component/list/list.d.mts +0 -5
  16. package/api/component/list/list.mjs +37 -9
  17. package/api/discover/discover.doc.mjs +1 -1
  18. package/api/gap-report/gap-report.doc.mjs +7 -4
  19. package/api/init/init.doc.mjs +4 -0
  20. package/api/init/init.test.mjs +41 -1
  21. package/api/init/remove/remove.mjs +1 -1
  22. package/api/init/run/run.mjs +20 -10
  23. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  24. package/api/integration/add-contribution.mjs +11 -0
  25. package/api/integration/add-contribution.test.mjs +80 -0
  26. package/api/integration/integrationAdd.doc.mjs +7 -4
  27. package/api/integration/integrationAddComponent.doc.mjs +1 -1
  28. package/api/integration/validate-integration.mjs +2 -0
  29. package/api/integration/validate-integration.test.mjs +91 -0
  30. package/api/integration/validateIntegration.doc.mjs +1 -1
  31. package/api/json/envelope-types.test.mjs +76 -0
  32. package/api/json/parseResponse.doc.mjs +2 -2
  33. package/api/layout/expand/expand.mjs +7 -5
  34. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  35. package/api/layout/grammar/grammar.mjs +2 -1
  36. package/api/layout/layoutExpand.doc.mjs +1 -1
  37. package/api/search/search-return-type.test.mjs +54 -0
  38. package/api/search/search.d.mts +2 -9
  39. package/api/search/search.doc.mjs +5 -1
  40. package/api/search/search.mjs +10 -3
  41. package/api/search/search.test.mjs +34 -0
  42. package/api/swizzle/copy/copy.mjs +28 -11
  43. package/api/swizzle/swizzle.doc.mjs +1 -1
  44. package/api/template/copy/copy.mjs +17 -23
  45. package/api/template/copy/copy.test.mjs +17 -0
  46. package/api/template/template.doc.mjs +2 -1
  47. package/api/theme/add/add.binary.test.mjs +91 -0
  48. package/api/theme/add/add.mjs +20 -3
  49. package/api/theme/add/add.staging.test.mjs +66 -0
  50. package/api/theme/build/build.mjs +114 -37
  51. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  52. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  53. package/api/theme/build/font-warning.mjs +3 -3
  54. package/api/theme/build/font-warning.test.mjs +5 -2
  55. package/api/theme/palette/generate/generate.mjs +7 -2
  56. package/api/theme/palette/generate/generate.test.mjs +96 -0
  57. package/api/theme/palette/generate/generator.mjs +8 -1
  58. package/api/theme/palette/generate/generator.test.mjs +10 -0
  59. package/api/theme/template/template.mjs +11 -2
  60. package/api/theme/template/template.test.mjs +15 -0
  61. package/api/theme/themeBuild.doc.mjs +7 -4
  62. package/api/theme/themeTemplate.doc.mjs +4 -1
  63. package/api/upgrade/_adapter.mjs +27 -4
  64. package/api/upgrade/list/list.mjs +2 -1
  65. package/api/upgrade/list/list.test.mjs +73 -0
  66. package/api/upgrade/status/status.mjs +2 -2
  67. package/api/upgrade/upgrade.doc.mjs +2 -1
  68. package/api/upgrade/upgrade.type.d.mts +4 -0
  69. package/api/upgrade/upgrade.type.mjs +1 -0
  70. package/assets/codemods/term-log.mjs +32 -8
  71. package/assets/codemods/term-log.test.mjs +19 -1
  72. package/assets/codemods/transform-prop.mjs +109 -0
  73. package/assets/codemods/transform-prop.test.mjs +95 -0
  74. package/assets/codemods/transforms/v0.0.14/__tests__/rename-status-variants.test.mjs +86 -165
  75. package/assets/codemods/transforms/v0.0.14/rename-status-variants.mjs +72 -210
  76. package/assets/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +83 -115
  77. package/assets/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +57 -186
  78. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  79. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  80. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.doc.mjs +14 -0
  81. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.tsx +61 -0
  82. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.doc.mjs +14 -0
  83. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.tsx +126 -0
  84. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.doc.mjs +15 -0
  85. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.tsx +86 -0
  86. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.doc.mjs +14 -0
  87. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.tsx +99 -0
  88. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.doc.mjs +14 -0
  89. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.tsx +122 -0
  90. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.doc.mjs +14 -0
  91. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.tsx +95 -0
  92. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  93. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  94. package/authoring/config/config.doc.mjs +1 -1
  95. package/authoring/config/debug-composition.test.mjs +92 -0
  96. package/authoring/config/type.ts +15 -3
  97. package/clients/cli/command-load-failure.test.mjs +83 -0
  98. package/clients/cli/commands/blog.mjs +23 -8
  99. package/clients/cli/commands/blog.test.mjs +42 -1
  100. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  101. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  102. package/clients/cli/commands/build-theme.mjs +5 -5
  103. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  104. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  105. package/clients/cli/commands/build.doc.mjs +4 -1
  106. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  107. package/clients/cli/commands/build.mjs +32 -47
  108. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  109. package/clients/cli/commands/build.text-fields.test.mjs +40 -0
  110. package/clients/cli/commands/component/index.mjs +2 -7
  111. package/clients/cli/commands/component-package.test.mjs +46 -0
  112. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  113. package/clients/cli/commands/component.test.mjs +19 -0
  114. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  115. package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
  116. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  117. package/clients/cli/commands/discover.doc.mjs +4 -2
  118. package/clients/cli/commands/discover.mjs +3 -3
  119. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  120. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  121. package/clients/cli/commands/doctor.mjs +6 -16
  122. package/clients/cli/commands/doctor.test.mjs +42 -0
  123. package/clients/cli/commands/gap-report.doc.mjs +16 -5
  124. package/clients/cli/commands/gap-report.test.mjs +72 -0
  125. package/clients/cli/commands/hook/index.mjs +7 -17
  126. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  127. package/clients/cli/commands/init.doc.mjs +19 -8
  128. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  129. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  130. package/clients/cli/commands/integration-add.doc.mjs +21 -5
  131. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  132. package/clients/cli/commands/json-contract.test.mjs +33 -0
  133. package/clients/cli/commands/layout-check.doc.mjs +14 -3
  134. package/clients/cli/commands/layout-expand.doc.mjs +21 -4
  135. package/clients/cli/commands/layout.doc.mjs +2 -2
  136. package/clients/cli/commands/layout.mjs +21 -9
  137. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  138. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  139. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  140. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  141. package/clients/cli/commands/search.doc.mjs +6 -3
  142. package/clients/cli/commands/search.mjs +17 -7
  143. package/clients/cli/commands/search.test.mjs +75 -0
  144. package/clients/cli/commands/swizzle.doc.mjs +2 -1
  145. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  146. package/clients/cli/commands/template.doc.mjs +28 -6
  147. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  148. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  149. package/clients/cli/commands/theme-add.doc.mjs +2 -1
  150. package/clients/cli/commands/theme-build.doc.mjs +7 -6
  151. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  152. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  153. package/clients/cli/commands/theme-template.doc.mjs +1 -1
  154. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +17 -6
  156. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  157. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  158. package/clients/cli/index.mjs +13 -30
  159. package/clients/cli/latest-version-env.test.mjs +50 -0
  160. package/clients/cli/lib/cli-error.test.mjs +7 -0
  161. package/clients/cli/lib/component-format.mjs +9 -9
  162. package/clients/cli/lib/component-format.test.mjs +1 -1
  163. package/clients/cli/lib/define-command.mjs +32 -6
  164. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  165. package/clients/cli/lib/exit-codes.test.mjs +97 -0
  166. package/clients/cli/lib/hook-format.mjs +5 -5
  167. package/clients/cli/lib/json-shim.mjs +38 -2
  168. package/clients/cli/lib/json-shim.test.mjs +83 -0
  169. package/clients/cli/lib/manifest.d.ts +2 -0
  170. package/clients/cli/lib/manifest.mjs +22 -0
  171. package/clients/cli/lib/manifest.test.mjs +17 -0
  172. package/foundation/agent-docs/agent-docs.d.mts +4 -0
  173. package/foundation/agent-docs/agent-docs.mjs +69 -3
  174. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  175. package/foundation/config/integration-debug.test.mjs +28 -3
  176. package/foundation/config/project.mjs +123 -9
  177. package/foundation/config/project.test.mjs +126 -0
  178. package/foundation/discovery/template-adapter.d.mts +14 -5
  179. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  180. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  181. package/foundation/discovery/template-adapter.mjs +292 -25
  182. package/foundation/discovery/theming-targets.test.mjs +4 -0
  183. package/foundation/fs/module-loader.d.mts +1 -0
  184. package/foundation/fs/module-loader.mjs +50 -1
  185. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  186. package/foundation/fs/path-safety.d.mts +3 -2
  187. package/foundation/fs/path-safety.mjs +49 -19
  188. package/foundation/fs/path-safety.test.mjs +50 -0
  189. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  190. package/foundation/integrations/autolink.mjs +6 -1
  191. package/foundation/response/base.d.ts +8 -4
  192. package/foundation/response/error-codes.doc.mjs +1 -2
  193. package/foundation/response/error-codes.mjs +1 -1
  194. package/foundation/response/error-codes.test.mjs +83 -0
  195. package/foundation/response/json-contract.test.mjs +11 -0
  196. package/foundation/response/json.d.mts +4 -2
  197. package/foundation/response/json.mjs +8 -10
  198. package/foundation/response/response-types.doc.mjs +10 -10
  199. package/foundation/response/response-types.doc.test.mjs +158 -0
  200. package/foundation/xle/expand.d.mts +2 -0
  201. package/foundation/xle/expand.mjs +3 -2
  202. package/foundation/xle/expand.test.mjs +54 -0
  203. package/package.json +9 -9
  204. package/clients/cli/lib/update-check.mjs +0 -83
  205. package/clients/cli/lib/update-check.test.mjs +0 -137
  206. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -73,7 +73,7 @@ export const doc = {
73
73
  {
74
74
  type: 'theme.build',
75
75
  description:
76
- 'Build receipt: theme name, token- and component-override counts, output size in KB, the written outputs {css, js, dts, and variantsDts when custom prop values were augmented}, and validation warnings—including exact canonical replacements for deprecated component target keys. Resolves to null instead when the theme produced no CSS (nothing to build).',
76
+ 'Build receipt {name, tokenCount, componentCount, sizeKB, outputs, warnings, notices}: the theme name; how many tokens (portable plus theme-local) and component targets it overrides; the CSS size in KB; the written outputs {css, js, dts, and variantsDts when custom prop values were augmented}; warnings, the defects the author should fix, including exact canonical replacements for deprecated component target keys and declarations the generator dropped; and notices, advisories about a correct theme, such as a font it names but does not load. Resolves to null instead when the theme produced no CSS (nothing to build).',
77
77
  },
78
78
  {
79
79
  type: 'theme.build.check',
@@ -87,10 +87,13 @@ export const doc = {
87
87
  code: 'ERR_THEME_LOAD',
88
88
  when: 'the file cannot be loaded or parsed into a defineTheme() result',
89
89
  },
90
- {code: 'ERR_THEME_INVALID', when: 'the resolved theme has no name'},
90
+ {
91
+ code: 'ERR_THEME_INVALID',
92
+ when: 'the resolved theme is invalid, for example it has no name, or a custom Heading type has no standalone rule with a declaration the compiler can emit',
93
+ },
91
94
  {
92
95
  code: 'ERR_PATH_TRAVERSAL',
93
- when: 'the theme name contains a path separator or traversal marker',
96
+ when: 'the theme name contains a path separator or traversal marker, or a relative `out` path resolves outside cwd (including through a symlink)',
94
97
  },
95
98
  {
96
99
  code: 'ERR_CORE_NOT_FOUND',
@@ -102,7 +105,7 @@ export const doc = {
102
105
  },
103
106
  {
104
107
  code: 'ERR_WRITE_FAILED',
105
- when: 'writing the outputs fails (staged temp files are rolled back)',
108
+ when: 'creating the output directory or writing the outputs fails (staged temp files are rolled back)',
106
109
  },
107
110
  ],
108
111
  examples: [
@@ -51,7 +51,10 @@ export const doc = {
51
51
  'Receipt: the path (relative to cwd), whether it was written, and the reason it was not. `exists` when a file was already there, which is a success, not a failure.',
52
52
  },
53
53
  ],
54
- throws: [{code: 'ERR_PATH_TRAVERSAL', when: 'the target path escapes cwd'}],
54
+ throws: [
55
+ {code: 'ERR_PATH_TRAVERSAL', when: 'the target path escapes cwd'},
56
+ {code: 'ERR_WRITE_FAILED', when: 'the template file cannot be written'},
57
+ ],
55
58
  examples: [
56
59
  {label: 'Write it at the project root', code: 'themeTemplate();'},
57
60
  {
@@ -104,6 +104,27 @@ export function uniqueFiles(files) {
104
104
  ];
105
105
  }
106
106
 
107
+ /**
108
+ * Run app code with its stdout writes sent to stderr. Stdout carries only the
109
+ * command's own output, so a hook that prints cannot corrupt `--json`.
110
+ * @template T
111
+ * @param {() => T | Promise<T>} fn
112
+ * @returns {Promise<T>}
113
+ */
114
+ async function withStdoutOnStderr(fn) {
115
+ const realWrite = process.stdout.write;
116
+ process.stdout.write = /** @type {any} */ (
117
+ function (/** @type {any} */ chunk, /** @type {any[]} */ ...rest) {
118
+ return process.stderr.write(chunk, ...rest);
119
+ }
120
+ );
121
+ try {
122
+ return await fn();
123
+ } finally {
124
+ process.stdout.write = realWrite;
125
+ }
126
+ }
127
+
107
128
  /**
108
129
  * Run the app config's post-codemod hooks (config.hooks.postCodemod).
109
130
  * Dry-run PREVIEWS (buildCommand still called, so a throw fails); apply executes.
@@ -123,7 +144,9 @@ export async function runPostCodemodHooks(hooks, context) {
123
144
  );
124
145
  }
125
146
 
126
- const cmd = await hook.buildCommand({packageDir, files});
147
+ const cmd = await withStdoutOnStderr(() =>
148
+ hook.buildCommand({packageDir, files}),
149
+ );
127
150
  if (!cmd) {
128
151
  logger.log(`Post-codemod hook ${label} produced no command; skipping.`);
129
152
  continue;
@@ -147,7 +170,7 @@ export async function runPostCodemodHooks(hooks, context) {
147
170
  env: {...process.env, ...(cmd.options?.env ?? {})},
148
171
  }),
149
172
  );
150
- logger.log(`✓ Post-codemod hook ${label} completed.`);
173
+ logger.log(`[ok] Post-codemod hook ${label} completed.`);
151
174
  }
152
175
  }
153
176
 
@@ -186,7 +209,7 @@ export async function prepareAgentDocsRefresh({
186
209
  if (initial.status === 'missing') {
187
210
  summary.action = 'nudge-init';
188
211
  logger.warn(
189
- `No Astryx agent-docs block found — AI agents have no component index. Run \`${formatCliCommand('astryx init --features agents')}\` to install it.`,
212
+ `No Astryx agent-docs block found - AI agents have no component index. Run \`${formatCliCommand('astryx init --features agents')}\` to install it.`,
190
213
  );
191
214
  return {cwd, renderedBlock: null, summary};
192
215
  }
@@ -240,7 +263,7 @@ export function applyAgentDocsRefresh(plan) {
240
263
  summary.files = written;
241
264
  summary.action = summary.refreshed ? 'refreshed' : 'error';
242
265
  if (summary.refreshed) {
243
- logger.log(`✓ Agent docs refreshed → ${written.join(', ')}`);
266
+ logger.log(`[ok] Agent docs refreshed -> ${written.join(', ')}`);
244
267
  } else {
245
268
  logger.warn(
246
269
  `Agent docs look stale but couldn't be refreshed. Run \`${formatCliCommand('astryx init --features agents')}\` to reinstall the block.`,
@@ -10,6 +10,7 @@
10
10
 
11
11
  import {collectAllCodemods} from '../_adapter.mjs';
12
12
  import {logger} from '../../logger.mjs';
13
+ import {toAscii} from '../../../assets/codemods/term-log.mjs';
13
14
 
14
15
  /**
15
16
  * List every available codemod (oldest→newest).
@@ -19,7 +20,7 @@ export async function list() {
19
20
  const codemods = await collectAllCodemods();
20
21
  logger.log('Available codemods:');
21
22
  for (const {name, title, pr, optional} of codemods) {
22
- logger.log(` ${name} — ${title}${optional ? ' (optional)' : ''} (${pr})`);
23
+ logger.log(` ${name} - ${toAscii(title)}${optional ? ' (optional)' : ''} (${pr})`);
23
24
  }
24
25
  logger.log('Done\n');
25
26
  return {
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Colocated tests for the upgrade.list leaf: every field an entry
5
+ * carries is declared on the canonical `UpgradeListEntry` type, and every
6
+ * declared field is present on every entry.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import * as fs from 'node:fs';
11
+ import {fileURLToPath} from 'node:url';
12
+ import ts from 'typescript';
13
+ import {list} from './list.mjs';
14
+
15
+ const TYPE_FILE = fileURLToPath(
16
+ new URL('../upgrade.type.mjs', import.meta.url),
17
+ );
18
+
19
+ /**
20
+ * Property names a JSDoc `@typedef {object}` declares, parsed from the file.
21
+ * @param {string} typedefName
22
+ * @returns {string[]}
23
+ */
24
+ function declaredProperties(typedefName) {
25
+ const source = ts.createSourceFile(
26
+ TYPE_FILE,
27
+ fs.readFileSync(TYPE_FILE, 'utf8'),
28
+ ts.ScriptTarget.Latest,
29
+ true,
30
+ ts.ScriptKind.JS,
31
+ );
32
+ /** @type {string[]} */
33
+ const names = [];
34
+ for (const statement of source.statements) {
35
+ for (const doc of /** @type {any} */ (statement).jsDoc ?? []) {
36
+ for (const tag of doc.tags ?? []) {
37
+ if (
38
+ !ts.isJSDocTypedefTag(tag) ||
39
+ tag.name?.getText(source) !== typedefName
40
+ ) {
41
+ continue;
42
+ }
43
+ const literal = tag.typeExpression;
44
+ if (literal && ts.isJSDocTypeLiteral(literal)) {
45
+ for (const property of literal.jsDocPropertyTags ?? []) {
46
+ names.push(property.name.getText(source));
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return names.sort();
53
+ }
54
+
55
+ describe('upgrade.list entries', () => {
56
+ it('carry exactly the fields UpgradeListEntry declares', async () => {
57
+ const declared = declaredProperties('UpgradeListEntry');
58
+ expect(declared.length).toBeGreaterThan(0);
59
+
60
+ const {type, data} = await list();
61
+ expect(type).toBe('upgrade.list');
62
+ expect(data.length).toBeGreaterThan(0);
63
+ for (const entry of data) {
64
+ expect(Object.keys(entry).sort()).toEqual(declared);
65
+ }
66
+ });
67
+
68
+ it('marks optional codemods with a boolean', async () => {
69
+ const {data} = await list();
70
+ for (const entry of data) expect(typeof entry.optional).toBe('boolean');
71
+ expect(data.some(entry => entry.optional)).toBe(true);
72
+ });
73
+ });
@@ -24,7 +24,7 @@ import {logger} from '../../logger.mjs';
24
24
  * @returns {import('../upgrade.type.mjs').UpgradeStatusResponse}
25
25
  */
26
26
  export function statusUpToDate({from, to, agentDocs, registryCompositions}) {
27
- logger.log('✓ Already up to date — no codemods to run.');
27
+ logger.log('[ok] Already up to date - no codemods to run.');
28
28
  logger.log('Use --force to run codemods anyway.');
29
29
  logger.log(registryCompositions?.ok === false ? 'Finished with unresolved registry items\n' : 'Done\n');
30
30
  return {
@@ -45,7 +45,7 @@ export function statusUpToDate({from, to, agentDocs, registryCompositions}) {
45
45
  * @returns {import('../upgrade.type.mjs').UpgradeStatusResponse}
46
46
  */
47
47
  export function statusNoCodemods({from, to, agentDocs, registryCompositions}) {
48
- logger.log('✓ No codemods available for this version range.');
48
+ logger.log('[ok] No codemods available for this version range.');
49
49
  logger.log(registryCompositions?.ok === false ? 'Finished with unresolved registry items\n' : 'Done\n');
50
50
  return {
51
51
  type: 'upgrade.status',
@@ -80,7 +80,8 @@ export const doc = {
80
80
  {
81
81
  name: 'options.installDeps',
82
82
  type: 'boolean',
83
- description: 'Auto-install jscodeshift without prompting.',
83
+ description:
84
+ 'Install jscodeshift when it is missing; otherwise a missing jscodeshift throws ERR_DEP_MISSING.',
84
85
  },
85
86
  {
86
87
  name: 'options.registry',
@@ -12,6 +12,10 @@ export type UpgradeListEntry = {
12
12
  name: string;
13
13
  title: string;
14
14
  version: string;
15
+ /**
16
+ * True when the codemod runs only if named with `--codemod`.
17
+ */
18
+ optional: boolean;
15
19
  };
16
20
  /**
17
21
  * State of the managed agent-docs block (`<!-- ASTRYX:START --> … END -->`)
@@ -25,6 +25,7 @@
25
25
  * @property {string} name
26
26
  * @property {string} title
27
27
  * @property {string} version
28
+ * @property {boolean} optional True when the codemod runs only if named with `--codemod`.
28
29
  */
29
30
 
30
31
  /**
@@ -11,6 +11,8 @@
11
11
  * `p.log.step(...)`, `p.log.success(...)`, etc. All output routes through
12
12
  * `humanLog`, the CLI's stdout-discipline primitive, which is a no-op in
13
13
  * `--json` mode — so these human logs can never corrupt a JSON envelope.
14
+ * Every line is plain ASCII: level prefixes are ASCII and typographic glyphs
15
+ * in a message are transliterated before it is printed.
14
16
  *
15
17
  * The side-effecting API commands (upgrade/init/themeBuild) do NOT use this;
16
18
  * they write through the shared `logger` (api/logger.mjs).
@@ -18,8 +20,30 @@
18
20
 
19
21
  import {humanLog} from '../../foundation/response/json.mjs';
20
22
 
21
- /** @param {unknown} msg */
22
- const toStr = (msg) => (msg === undefined || msg === null ? '' : String(msg));
23
+ /** @type {Array<[RegExp, string]>} */
24
+ const ASCII_GLYPHS = [
25
+ [/[\u2014\u2013]/g, '-'],
26
+ [/\u2192/g, '->'],
27
+ [/\u2713/g, '[ok]'],
28
+ [/\u2717/g, '!!'],
29
+ [/\u26a0\ufe0f?/g, '!'],
30
+ [/\u2022/g, '-'],
31
+ [/\u2026/g, '...'],
32
+ [/[\u2018\u2019]/g, "'"],
33
+ [/[\u201c\u201d]/g, '"'],
34
+ [/\u00a0/g, ' '],
35
+ ];
36
+
37
+ /**
38
+ * A message as plain ASCII: typographic glyphs become their ASCII spelling.
39
+ * @param {unknown} msg
40
+ * @returns {string}
41
+ */
42
+ export function toAscii(msg) {
43
+ let text = msg === undefined || msg === null ? '' : String(msg);
44
+ for (const [glyph, ascii] of ASCII_GLYPHS) text = text.replace(glyph, ascii);
45
+ return text;
46
+ }
23
47
 
24
48
  /**
25
49
  * Human-facing log surface (the small `log` API codemods use). All lines go to
@@ -28,15 +52,15 @@ const toStr = (msg) => (msg === undefined || msg === null ? '' : String(msg));
28
52
  */
29
53
  export const log = {
30
54
  /** @param {unknown} msg */
31
- message: (msg) => humanLog(toStr(msg)),
55
+ message: (msg) => humanLog(toAscii(msg)),
32
56
  /** @param {unknown} msg */
33
- info: (msg) => humanLog(toStr(msg)),
57
+ info: (msg) => humanLog(toAscii(msg)),
34
58
  /** @param {unknown} msg */
35
- step: (msg) => humanLog(toStr(msg)),
59
+ step: (msg) => humanLog(toAscii(msg)),
36
60
  /** @param {unknown} msg */
37
- success: (msg) => humanLog(`✓ ${toStr(msg)}`),
61
+ success: (msg) => humanLog(`[ok] ${toAscii(msg)}`),
38
62
  /** @param {unknown} msg */
39
- warn: (msg) => humanLog(`⚠ ${toStr(msg)}`),
63
+ warn: (msg) => humanLog(`! ${toAscii(msg)}`),
40
64
  /** @param {unknown} msg */
41
- error: (msg) => humanLog(`✗ ${toStr(msg)}`),
65
+ error: (msg) => humanLog(`!! ${toAscii(msg)}`),
42
66
  };
@@ -51,7 +51,7 @@ describe('sanity: helpers are live in human mode', () => {
51
51
  setJsonMode(false);
52
52
  p.log.success('done');
53
53
  expect(logSpy).toHaveBeenCalledTimes(1);
54
- expect(logSpy).toHaveBeenCalledWith('✓ done');
54
+ expect(logSpy).toHaveBeenCalledWith('[ok] done');
55
55
  });
56
56
 
57
57
  it('every log helper emits exactly one stdout line in human mode', () => {
@@ -60,3 +60,21 @@ describe('sanity: helpers are live in human mode', () => {
60
60
  expect(logSpy).toHaveBeenCalledTimes(6);
61
61
  });
62
62
  });
63
+
64
+ describe('ASCII output', () => {
65
+ it('prints ASCII level prefixes and transliterates glyphs in messages', () => {
66
+ setJsonMode(false);
67
+ p.log.success('src/a.tsx');
68
+ p.log.warn('stale \u2014 refresh');
69
+ p.log.error(' \u2717 src/b.tsx \u2014 rename \u2192 failed');
70
+ p.log.info(' \u2022 name \u2014 title\u2026');
71
+ const lines = logSpy.mock.calls.map(call => call.join(' '));
72
+ expect(lines).toEqual([
73
+ '[ok] src/a.tsx',
74
+ '! stale - refresh',
75
+ '!! !! src/b.tsx - rename -> failed',
76
+ ' - name - title...',
77
+ ]);
78
+ for (const line of lines) expect(line).not.toMatch(/[\u0080-\uFFFF]/);
79
+ });
80
+ });
@@ -0,0 +1,109 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * Find imported components, resolve their local JSX names (including aliases),
5
+ * and yield matching JSX prop paths to a visitor.
6
+ *
7
+ * This helper intentionally stops at the JSX boundary. It does not follow
8
+ * variables, helper calls, object spreads, wrapper components, or types.
9
+ *
10
+ * @param {any} root jscodeshift collection for one source file
11
+ * @param {any} j configured jscodeshift instance
12
+ * @param {{
13
+ * matchesImport: (source: string) => boolean,
14
+ * components: ReadonlyMap<string, ReadonlySet<string>>,
15
+ * normalizeComponentName?: (name: string) => string,
16
+ * }} options
17
+ * @param {(propPath: any, context: {component: string, localName: string}) => void} visit
18
+ */
19
+ export function transformProp(root, j, options, visit) {
20
+ const normalize = options.normalizeComponentName ?? (name => name);
21
+ /** @type {Map<string, {component: string, props: ReadonlySet<string>}>} */
22
+ const directBindings = new Map();
23
+ /** @type {Set<string>} */
24
+ const namespaceBindings = new Set();
25
+
26
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ importPath) => {
27
+ const source = importPath.node.source.value;
28
+ if (
29
+ importPath.node.importKind === 'type' ||
30
+ typeof source !== 'string' ||
31
+ !options.matchesImport(source)
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ const subpathName = normalize(source.split('/').at(-1) ?? '');
37
+ const subpathProps = options.components.get(subpathName);
38
+
39
+ for (const specifier of importPath.node.specifiers ?? []) {
40
+ if (specifier.importKind === 'type') continue;
41
+
42
+ if (specifier.type === 'ImportSpecifier') {
43
+ const importedName =
44
+ specifier.imported?.name ?? specifier.imported?.value;
45
+ if (typeof importedName !== 'string') continue;
46
+ const component = normalize(importedName);
47
+ const props = options.components.get(component);
48
+ if (!props) continue;
49
+ directBindings.set(specifier.local?.name ?? importedName, {
50
+ component,
51
+ props,
52
+ });
53
+ } else if (specifier.type === 'ImportDefaultSpecifier' && subpathProps) {
54
+ directBindings.set(specifier.local.name, {
55
+ component: subpathName,
56
+ props: subpathProps,
57
+ });
58
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
59
+ namespaceBindings.add(specifier.local.name);
60
+ }
61
+ }
62
+ });
63
+
64
+ if (directBindings.size === 0 && namespaceBindings.size === 0) return;
65
+
66
+ root.find(j.JSXOpeningElement).forEach((/** @type {any} */ jsxPath) => {
67
+ const name = jsxPath.node.name;
68
+ let localName;
69
+ let component;
70
+ let props;
71
+
72
+ if (name.type === 'JSXIdentifier') {
73
+ localName = name.name;
74
+ const binding = directBindings.get(localName);
75
+ if (!binding) return;
76
+ ({component, props} = binding);
77
+ } else if (
78
+ name.type === 'JSXMemberExpression' &&
79
+ name.object?.type === 'JSXIdentifier' &&
80
+ name.property?.type === 'JSXIdentifier' &&
81
+ namespaceBindings.has(name.object.name)
82
+ ) {
83
+ localName = name.object.name;
84
+ component = normalize(name.property.name);
85
+ props = options.components.get(component);
86
+ if (!props) return;
87
+ } else {
88
+ return;
89
+ }
90
+
91
+ // A parameter or local declaration with the imported name shadows the
92
+ // module binding. Only JSX tags whose root binding resolves to Program are
93
+ // considered usages of the import discovered above.
94
+ const bindingScope = jsxPath.scope.lookup(localName);
95
+ if (!bindingScope || bindingScope.path.node.type !== 'Program') return;
96
+
97
+ for (let index = 0; index < jsxPath.node.attributes.length; index++) {
98
+ const attribute = jsxPath.node.attributes[index];
99
+ if (
100
+ attribute.type !== 'JSXAttribute' ||
101
+ attribute.name?.type !== 'JSXIdentifier' ||
102
+ !props.has(attribute.name.name)
103
+ ) {
104
+ continue;
105
+ }
106
+ visit(jsxPath.get('attributes', index), {component, localName});
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,95 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import jscodeshift from 'jscodeshift';
5
+ import {transformProp} from './transform-prop.mjs';
6
+
7
+ const j = jscodeshift.withParser('tsx');
8
+ const components = new Map([
9
+ ['Widget', new Set(['tone'])],
10
+ ['Group', new Set(['size'])],
11
+ ]);
12
+
13
+ function apply(source) {
14
+ const root = j(source);
15
+ transformProp(
16
+ root,
17
+ j,
18
+ {
19
+ matchesImport: value => /^@example\/core(?:\/|$)/.test(value),
20
+ components,
21
+ normalizeComponentName: name =>
22
+ name.startsWith('XDS') ? name.slice(3) : name,
23
+ },
24
+ propPath => {
25
+ propPath.node.name.name = `migrated${propPath.node.name.name}`;
26
+ },
27
+ );
28
+ return root.toSource({quote: 'single'});
29
+ }
30
+
31
+ describe('transformProp', () => {
32
+ it('maps named import aliases to their JSX usages', () => {
33
+ const output = apply(`import {Widget as Card} from '@example/core';
34
+ const view = <Card tone="old" other="old" />;`);
35
+
36
+ expect(output).toContain('<Card migratedtone="old" other="old"');
37
+ });
38
+
39
+ it('maps default subpath imports', () => {
40
+ const output = apply(`import Card from '@example/core/Widget';
41
+ const view = <Card tone="old" />;`);
42
+
43
+ expect(output).toContain('<Card migratedtone="old"');
44
+ });
45
+
46
+ it('ignores declaration-level type imports', () => {
47
+ const output = apply(`import type {Widget} from '@example/core';
48
+ const Widget = props => <div {...props} />;
49
+ const view = <Widget tone="old" />;`);
50
+
51
+ expect(output).toContain('<Widget tone="old"');
52
+ expect(output).not.toContain('migratedtone');
53
+ });
54
+
55
+ it('ignores type specifiers in mixed imports', () => {
56
+ const output = apply(`import {type Widget, Group} from '@example/core';
57
+ const Widget = props => <div {...props} />;
58
+ const view = <><Widget tone="old" /><Group size="old" /></>;`);
59
+
60
+ expect(output).toContain('<Widget tone="old"');
61
+ expect(output).toContain('<Group migratedsize="old"');
62
+ expect(output).not.toContain('<Widget migratedtone');
63
+ });
64
+
65
+ it('maps namespace imports', () => {
66
+ const output = apply(`import * as Core from '@example/core';
67
+ const view = <Core.Group size="old" />;`);
68
+
69
+ expect(output).toContain('<Core.Group migratedsize="old"');
70
+ });
71
+
72
+ it('ignores same-named imports from other packages', () => {
73
+ const source = `import {Widget} from '@other/core';
74
+ const view = <Widget tone="old" />;`;
75
+ expect(apply(source)).toBe(source);
76
+ });
77
+
78
+ it('ignores a local binding that shadows an imported alias', () => {
79
+ const output = apply(`import {Widget as Card} from '@example/core';
80
+ const migrated = <Card tone="old" />;
81
+ function Local(Card) { return <Card tone="local" />; }`);
82
+
83
+ expect(output).toContain('<Card migratedtone="old"');
84
+ expect(output).toContain('<Card tone="local"');
85
+ });
86
+
87
+ it('yields only configured props on configured components', () => {
88
+ const output = apply(`import {Widget, Group} from '@example/core';
89
+ const a = <Widget size="old" tone="old" />;
90
+ const b = <Group tone="old" size="old" />;`);
91
+
92
+ expect(output).toContain('<Widget size="old" migratedtone="old"');
93
+ expect(output).toContain('<Group tone="old" migratedsize="old"');
94
+ });
95
+ });