@astryxdesign/cli 0.7.2 → 0.7.3-canary.02f5fe4

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 (373) hide show
  1. package/README.md +104 -71
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +127 -3
  4. package/api/build/blocks/blocks.d.mts +92 -0
  5. package/api/build/blocks/blocks.mjs +528 -0
  6. package/api/build/blocks/blocks.test.mjs +238 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/build.type.mjs +1 -1
  10. package/api/build/kit/answer.d.mts +18 -0
  11. package/api/build/kit/answer.mjs +195 -0
  12. package/api/build/kit/answer.test.mjs +108 -0
  13. package/api/build/kit/kind.d.mts +25 -0
  14. package/api/build/kit/kind.mjs +170 -0
  15. package/api/build/kit/kit.d.mts +2 -4
  16. package/api/build/kit/kit.mjs +8 -398
  17. package/api/build/kit/rank.d.mts +3 -32
  18. package/api/build/kit/rank.mjs +25 -342
  19. package/api/build/kit/rank.test.mjs +2 -1
  20. package/api/build/kit/signals.d.mts +79 -0
  21. package/api/build/kit/signals.mjs +295 -0
  22. package/api/build/kit/start.d.mts +32 -0
  23. package/api/build/kit/start.mjs +163 -0
  24. package/api/build/kit/words.d.mts +72 -0
  25. package/api/build/kit/words.mjs +202 -0
  26. package/api/build/tree.d.mts +20 -0
  27. package/api/build/tree.mjs +53 -0
  28. package/api/build/tree.test.mjs +185 -0
  29. package/api/build/tree.weights.json +1 -0
  30. package/api/component/_adapter.mjs +39 -5
  31. package/api/component/component-replacement.test.mjs +1 -0
  32. package/api/component/component.mjs +17 -3
  33. package/api/component/component.test.mjs +15 -0
  34. package/api/component/doc-links.test.mjs +2 -2
  35. package/api/discover/_adapter.d.mts +1 -2
  36. package/api/discover/_adapter.mjs +42 -17
  37. package/api/discover/_adapter.test.mjs +42 -0
  38. package/api/discover/discover.mjs +0 -1
  39. package/api/docs/_adapter.d.mts +2 -2
  40. package/api/docs/_adapter.mjs +82 -23
  41. package/api/docs/docs-homes.test.mjs +127 -0
  42. package/api/docs/docs.doc.mjs +3 -3
  43. package/api/docs/docs.test.mjs +35 -19
  44. package/api/docs/integration-tree.test.mjs +20 -20
  45. package/api/docs/integrationDocs.test.mjs +1 -1
  46. package/api/docs/namespace-section.test.mjs +6 -6
  47. package/api/docs/reference-blocks.test.mjs +2 -2
  48. package/api/doctor/_adapter.d.mts +33 -13
  49. package/api/doctor/_adapter.mjs +55 -25
  50. package/api/doctor/doctor.d.mts +23 -5
  51. package/api/doctor/doctor.mjs +183 -40
  52. package/api/doctor/doctor.test.mjs +95 -12
  53. package/api/hook/hook.mjs +1 -1
  54. package/api/init/init.doc.mjs +20 -6
  55. package/api/init/init.test.mjs +18 -2
  56. package/api/init/init.type.d.mts +3 -3
  57. package/api/init/init.type.mjs +3 -3
  58. package/api/init/remove/remove.mjs +17 -4
  59. package/api/init/remove/remove.test.mjs +114 -2
  60. package/api/init/run/run.mjs +69 -6
  61. package/api/integration/_adapter.d.mts +8 -0
  62. package/api/integration/_adapter.mjs +21 -1
  63. package/api/integration/authoring-checks.mjs +83 -9
  64. package/api/integration/authoring-checks.test.mjs +220 -0
  65. package/api/integration/pack-check.mjs +123 -4
  66. package/api/integration/pack-check.test.mjs +87 -0
  67. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  68. package/api/integration/validate-integration-fixes.test.mjs +6 -5
  69. package/api/integration/validate-integration-gaps.test.mjs +1975 -0
  70. package/api/integration/validate-integration.d.mts +15 -2
  71. package/api/integration/validate-integration.mjs +298 -39
  72. package/api/integration/validate-integration.type.d.mts +90 -1
  73. package/api/integration/validate-integration.type.mjs +67 -2
  74. package/api/integration/validateIntegration.doc.mjs +1 -1
  75. package/api/search/ORDERING.md +28 -0
  76. package/api/search/_adapter.d.mts +82 -2
  77. package/api/search/_adapter.mjs +157 -10
  78. package/api/search/_adapter.test.mjs +173 -0
  79. package/api/search/_candidate-score.d.mts +40 -0
  80. package/api/search/_candidate-score.mjs +238 -0
  81. package/api/search/_cards.json +6098 -0
  82. package/api/search/_compound.d.mts +50 -0
  83. package/api/search/_compound.mjs +170 -0
  84. package/api/search/_doc-searcher.d.mts +4 -0
  85. package/api/search/_doc-searcher.mjs +23 -0
  86. package/api/search/_doc-searcher.test.mjs +36 -0
  87. package/api/search/_doc-searcher.weights.json +1 -0
  88. package/api/search/_doc-text.d.mts +30 -0
  89. package/api/search/_doc-text.mjs +87 -0
  90. package/api/search/_entries.d.mts +63 -0
  91. package/api/search/_entries.mjs +232 -0
  92. package/api/search/_entries.test.mjs +48 -0
  93. package/api/search/_features.d.mts +35 -0
  94. package/api/search/_features.mjs +155 -0
  95. package/api/search/_gather-components.d.mts +26 -0
  96. package/api/search/_gather-components.mjs +284 -0
  97. package/api/search/_gather-docs.d.mts +20 -0
  98. package/api/search/_gather-docs.mjs +254 -0
  99. package/api/search/_gather-hooks.d.mts +12 -0
  100. package/api/search/_gather-hooks.mjs +52 -0
  101. package/api/search/_gather-templates.d.mts +25 -0
  102. package/api/search/_gather-templates.mjs +107 -0
  103. package/api/search/_lexical.d.mts +135 -0
  104. package/api/search/_lexical.mjs +351 -0
  105. package/api/search/_main-searcher.d.mts +19 -0
  106. package/api/search/_main-searcher.mjs +115 -0
  107. package/api/search/_main-searcher.test.mjs +106 -0
  108. package/api/search/_main-searcher.weights.json +1 -0
  109. package/api/search/_module-doc.d.mts +32 -0
  110. package/api/search/_module-doc.mjs +78 -0
  111. package/api/search/_order.d.mts +32 -0
  112. package/api/search/_order.mjs +162 -0
  113. package/api/search/_order.test.mjs +72 -0
  114. package/api/search/_priority.d.mts +50 -0
  115. package/api/search/_priority.mjs +139 -0
  116. package/api/search/_query-words.d.mts +25 -0
  117. package/api/search/_query-words.mjs +92 -0
  118. package/api/search/_query.d.mts +65 -0
  119. package/api/search/_query.mjs +176 -0
  120. package/api/search/_result.d.mts +97 -0
  121. package/api/search/_result.mjs +150 -0
  122. package/api/search/_score.d.mts +60 -0
  123. package/api/search/_score.mjs +283 -0
  124. package/api/search/_synonyms.d.mts +21 -0
  125. package/api/search/_synonyms.mjs +67 -0
  126. package/api/search/_template-searcher.d.mts +4 -0
  127. package/api/search/_template-searcher.mjs +23 -0
  128. package/api/search/_template-searcher.test.mjs +40 -0
  129. package/api/search/_template-searcher.weights.json +1 -0
  130. package/api/search/_titles.d.mts +33 -0
  131. package/api/search/_titles.mjs +65 -0
  132. package/api/search/_words.d.mts +64 -0
  133. package/api/search/_words.mjs +157 -0
  134. package/api/search/search.d.mts +22 -305
  135. package/api/search/search.doc.mjs +8 -6
  136. package/api/search/search.mjs +56 -2482
  137. package/api/search/search.test.mjs +14 -14
  138. package/api/search/search.type.d.mts +6 -1
  139. package/api/search/search.type.mjs +6 -1
  140. package/api/search/tree.d.mts +15 -0
  141. package/api/search/tree.mjs +40 -0
  142. package/api/search/tree.test.mjs +66 -0
  143. package/api/search/tree.weights.json +1 -0
  144. package/api/template/template.mjs +5 -1
  145. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  146. package/api/upgrade/_adapter.d.mts +57 -9
  147. package/api/upgrade/_adapter.mjs +198 -28
  148. package/api/upgrade/registry/registry.d.mts +10 -4
  149. package/api/upgrade/registry/registry.mjs +33 -16
  150. package/api/upgrade/registry/registry.test.mjs +85 -0
  151. package/api/upgrade/run/run.d.mts +4 -2
  152. package/api/upgrade/run/run.mjs +13 -2
  153. package/api/upgrade/run/run.test.mjs +417 -3
  154. package/api/upgrade/upgrade.doc.mjs +10 -5
  155. package/api/upgrade/upgrade.mjs +13 -2
  156. package/api/upgrade/upgrade.test.mjs +194 -0
  157. package/api/upgrade/upgrade.type.d.mts +5 -3
  158. package/api/upgrade/upgrade.type.mjs +8 -6
  159. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  160. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  161. package/assets/docs/author-a-theme.doc.mjs +2 -2
  162. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  163. package/assets/docs/tree/add-a-component.doc.mjs +1 -1
  164. package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
  165. package/assets/docs/tree/add-a-topic.doc.mjs +2 -2
  166. package/assets/docs/tree/agent-guidance.doc.mjs +1 -1
  167. package/assets/docs/tree/api.doc.mjs +6 -0
  168. package/assets/docs/tree/{integrations.doc.mjs → authoring.doc.mjs} +15 -6
  169. package/assets/docs/tree/block-template.doc.mjs +1 -1
  170. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  171. package/assets/docs/tree/building-blocks.doc.mjs +2 -2
  172. package/assets/docs/tree/check-the-props.doc.mjs +1 -1
  173. package/assets/docs/tree/check-your-docs.doc.mjs +2 -2
  174. package/assets/docs/tree/checks.doc.mjs +3 -3
  175. package/assets/docs/tree/ci-and-containers.doc.mjs +13 -0
  176. package/assets/docs/tree/cli-command.doc.mjs +1 -1
  177. package/assets/docs/tree/cli.doc.mjs +1 -1
  178. package/assets/docs/tree/codemods.doc.mjs +1 -1
  179. package/assets/docs/tree/component-family.doc.mjs +1 -1
  180. package/assets/docs/tree/component-imports.doc.mjs +1 -1
  181. package/assets/docs/tree/components.doc.mjs +1 -1
  182. package/assets/docs/tree/configuration.doc.mjs +1 -1
  183. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +1 -1
  184. package/assets/docs/tree/define-the-theme.doc.mjs +1 -1
  185. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  186. package/assets/docs/tree/docs.doc.mjs +1 -1
  187. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  188. package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
  189. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  190. package/assets/docs/tree/extend-or-replace.doc.mjs +1 -1
  191. package/assets/docs/{authoring.doc.mjs → tree/field-reference.doc.mjs} +4 -3
  192. package/assets/docs/tree/fonts-and-assets.doc.mjs +1 -1
  193. package/assets/docs/tree/generate-a-palette.doc.mjs +1 -1
  194. package/assets/docs/tree/global-integrations.doc.mjs +2 -2
  195. package/assets/docs/tree/grade-template-with-agent.doc.mjs +4 -4
  196. package/assets/docs/tree/help.doc.mjs +2 -2
  197. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  198. package/assets/docs/tree/links.doc.mjs +1 -1
  199. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  200. package/assets/docs/tree/page-template.doc.mjs +1 -1
  201. package/assets/docs/tree/publishing.doc.mjs +1 -1
  202. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  203. package/assets/docs/tree/replace-a-core-component.doc.mjs +1 -1
  204. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  205. package/assets/docs/tree/sections-and-placement.doc.mjs +1 -1
  206. package/assets/docs/tree/see-it-in-an-app.doc.mjs +1 -1
  207. package/assets/docs/tree/ship.doc.mjs +2 -2
  208. package/assets/docs/tree/short-and-findable.doc.mjs +1 -1
  209. package/assets/docs/tree/single-component.doc.mjs +1 -1
  210. package/assets/docs/tree/start-a-template.doc.mjs +1 -1
  211. package/assets/docs/tree/subcomponent.doc.mjs +1 -1
  212. package/assets/docs/tree/template-assets.doc.mjs +1 -1
  213. package/assets/docs/tree/template-doc-overview.doc.mjs +2 -2
  214. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  215. package/assets/docs/tree/template-grading-rubric.doc.mjs +17 -38
  216. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  217. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  218. package/assets/docs/tree/template-styles.doc.mjs +1 -1
  219. package/assets/docs/tree/templates.doc.mjs +1 -1
  220. package/assets/docs/tree/test-in-an-app.doc.mjs +1 -1
  221. package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
  222. package/assets/docs/tree/themes.doc.mjs +1 -1
  223. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  224. package/assets/docs/tree/troubleshooting.doc.mjs +1 -1
  225. package/assets/docs/tree/upgrading.doc.mjs +1 -1
  226. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +1 -1
  227. package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
  228. package/assets/docs/tree/versioning.doc.mjs +5 -1
  229. package/assets/docs/tree/write-good-templates.doc.mjs +1 -1
  230. package/assets/docs/tree/write-the-template-file.doc.mjs +1 -1
  231. package/assets/docs/use-integrations.doc.mjs +120 -0
  232. package/assets/docs/working-with-ai.doc.mjs +6 -2
  233. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  234. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  235. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  236. package/authoring/debug/parse.d.mts +1 -1
  237. package/authoring/doctypes/schema/schema.doc.mjs +1 -1
  238. package/authoring/integration/integration.doc.mjs +1 -1
  239. package/authoring/integration/type.ts +1 -1
  240. package/clients/cli/bin/astryx.mjs +7 -78
  241. package/clients/cli/command-result-coverage.test.mjs +6 -5
  242. package/clients/cli/commands/build-theme.color-scheme.test.mjs +1 -1
  243. package/clients/cli/commands/{component/index.mjs → component.mjs} +37 -37
  244. package/clients/cli/commands/component.test.mjs +6 -6
  245. package/clients/cli/commands/docs.doc.mjs +2 -2
  246. package/clients/cli/commands/docs.mjs +1 -1
  247. package/clients/cli/commands/docs.test.mjs +14 -14
  248. package/clients/cli/commands/doctor-integration.test.mjs +49 -1
  249. package/clients/cli/commands/doctor.doc.mjs +1 -1
  250. package/clients/cli/commands/doctor.mjs +40 -0
  251. package/clients/cli/commands/doctor.test.mjs +65 -0
  252. package/clients/cli/commands/{hook/index.mjs → hook.mjs} +22 -22
  253. package/clients/cli/commands/import-hint-correctness.test.mjs +1 -1
  254. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  255. package/clients/cli/commands/init.doc.mjs +28 -9
  256. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  257. package/clients/cli/commands/init.mjs +2 -2
  258. package/clients/cli/commands/integration-check.test.mjs +48 -0
  259. package/clients/cli/commands/integration.doc.mjs +1 -1
  260. package/clients/cli/commands/manifest.doc.mjs +1 -1
  261. package/clients/cli/commands/manifest.mjs +67 -0
  262. package/clients/cli/commands/outside-project.test.mjs +101 -1
  263. package/clients/cli/commands/postinstall.doc.mjs +23 -0
  264. package/clients/cli/commands/postinstall.mjs +31 -0
  265. package/clients/cli/commands/setup-nudge.test.mjs +78 -0
  266. package/clients/cli/commands/{build-theme.mjs → theme.mjs} +1 -1
  267. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  268. package/clients/cli/commands/upgrade.mjs +9 -2
  269. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  270. package/clients/cli/index.mjs +54 -94
  271. package/clients/cli/lib/component-format.mjs +1 -1
  272. package/clients/cli/lib/define-command.mjs +12 -7
  273. package/clients/cli/lib/exit-codes.test.mjs +5 -0
  274. package/clients/cli/lib/manifest.test.mjs +12 -0
  275. package/clients/cli/lib/welcome-box.mjs +50 -0
  276. package/clients/cli/run.mjs +108 -0
  277. package/foundation/agent-docs/agent-doc-state.d.mts +32 -7
  278. package/foundation/agent-docs/agent-doc-state.mjs +58 -9
  279. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  280. package/foundation/agent-docs/agent-docs.mjs +1017 -105
  281. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  282. package/foundation/agent-docs/agent-docs.test.mjs +683 -39
  283. package/foundation/agent-docs/computer-agent-text.d.mts +39 -2
  284. package/foundation/agent-docs/computer-agent-text.mjs +135 -8
  285. package/foundation/agent-docs/computer-agent-text.test.mjs +99 -2
  286. package/foundation/config/project.mjs +20 -22
  287. package/foundation/config/project.test.mjs +56 -0
  288. package/foundation/discovery/authoring-self-docs.d.mts +7 -0
  289. package/foundation/discovery/authoring-self-docs.mjs +16 -4
  290. package/foundation/discovery/authoring-self-docs.test.mjs +15 -3
  291. package/foundation/discovery/authoring-surface.d.mts +1 -1
  292. package/foundation/discovery/authoring-surface.mjs +2 -2
  293. package/foundation/discovery/authoring-surface.test.mjs +1 -1
  294. package/foundation/discovery/cli-self-docs.d.mts +2 -1
  295. package/foundation/discovery/cli-self-docs.mjs +4 -3
  296. package/foundation/discovery/cli-self-docs.test.mjs +2 -2
  297. package/foundation/discovery/component-discovery.d.mts +37 -0
  298. package/foundation/discovery/component-discovery.mjs +80 -5
  299. package/foundation/discovery/component-loader.d.mts +1 -1
  300. package/foundation/discovery/docs-discovery.mjs +14 -1
  301. package/foundation/discovery/module-exports.d.mts +41 -0
  302. package/foundation/discovery/module-exports.mjs +253 -0
  303. package/foundation/discovery/runner-allowlist.d.mts +2 -4
  304. package/foundation/discovery/runner-allowlist.mjs +2 -4
  305. package/foundation/discovery/runner-commands.d.mts +77 -0
  306. package/foundation/discovery/runner-commands.mjs +294 -0
  307. package/foundation/discovery/runner-commands.test.mjs +128 -0
  308. package/foundation/discovery/stray-pointers.test.mjs +1 -1
  309. package/foundation/discovery/template-adapter.mjs +18 -1
  310. package/foundation/discovery/template-needs.mjs +1 -44
  311. package/foundation/discovery/theme-discovery.mjs +147 -3
  312. package/foundation/doc-compiler/doc-loads.test.mjs +33 -9
  313. package/foundation/doc-compiler/read.mjs +59 -1
  314. package/foundation/doc-compiler/read.test.mjs +11 -2
  315. package/foundation/doc-compiler/split-topic-keywords.test.mjs +3 -2
  316. package/foundation/doc-compiler/tree.d.mts +32 -6
  317. package/foundation/doc-compiler/tree.mjs +77 -43
  318. package/foundation/doc-compiler/tree.test.mjs +7 -7
  319. package/foundation/fs/paths.d.mts +70 -8
  320. package/foundation/fs/paths.mjs +556 -38
  321. package/foundation/fs/paths.test.mjs +430 -54
  322. package/foundation/fs/read-record.d.mts +18 -0
  323. package/foundation/fs/read-record.mjs +131 -0
  324. package/foundation/fs/read-record.test.mjs +77 -0
  325. package/foundation/integrations/autolink.d.mts +2 -1
  326. package/foundation/integrations/autolink.mjs +4 -13
  327. package/foundation/integrations/cli-peer-version.d.mts +36 -0
  328. package/foundation/integrations/cli-peer-version.mjs +132 -0
  329. package/foundation/integrations/cli-peer-version.test.mjs +354 -0
  330. package/foundation/integrations/computer-integrations.d.mts +6 -3
  331. package/foundation/integrations/computer-integrations.mjs +38 -9
  332. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  333. package/foundation/integrations/contribution-inventory.mjs +41 -5
  334. package/foundation/integrations/exports-subpaths.d.mts +57 -0
  335. package/foundation/integrations/exports-subpaths.mjs +113 -0
  336. package/foundation/integrations/integrations.d.mts +27 -1
  337. package/foundation/integrations/integrations.mjs +61 -2
  338. package/foundation/integrations/public-imports.d.mts +118 -0
  339. package/foundation/integrations/public-imports.mjs +1769 -0
  340. package/foundation/integrations/public-imports.test.mjs +1793 -0
  341. package/foundation/integrations/validate-contributions.d.mts +115 -0
  342. package/foundation/integrations/validate-contributions.mjs +1168 -13
  343. package/foundation/release/cache.test.mjs +24 -21
  344. package/foundation/release/global-state.d.mts +8 -4
  345. package/foundation/release/global-state.mjs +4 -2
  346. package/foundation/release/handoff.d.mts +23 -4
  347. package/foundation/release/handoff.mjs +175 -8
  348. package/foundation/release/notices.d.mts +62 -8
  349. package/foundation/release/notices.mjs +136 -25
  350. package/foundation/release/notices.test.mjs +21 -0
  351. package/foundation/release/run-context.mjs +14 -0
  352. package/foundation/response/response-types.doc.mjs +1 -1
  353. package/foundation/route/route.d.mts +285 -0
  354. package/foundation/route/route.mjs +324 -0
  355. package/foundation/route/route.test.mjs +153 -0
  356. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  357. package/foundation/runtime/locked-versions.d.mts +32 -0
  358. package/foundation/runtime/locked-versions.mjs +65 -0
  359. package/foundation/runtime/package-root.d.mts +91 -0
  360. package/foundation/runtime/package-root.mjs +174 -0
  361. package/foundation/runtime/package-set.d.mts +62 -0
  362. package/foundation/runtime/package-set.mjs +130 -0
  363. package/foundation/runtime/package-set.test.mjs +432 -0
  364. package/foundation/text/string-utils.d.mts +2 -1
  365. package/foundation/text/string-utils.mjs +16 -5
  366. package/foundation/text/suggest-order.d.mts +72 -0
  367. package/foundation/text/suggest-order.mjs +363 -0
  368. package/foundation/text/suggest-order.test.mjs +259 -0
  369. package/host/door-conformance.test.mjs +274 -0
  370. package/host/index.d.ts +116 -0
  371. package/host/index.mjs +295 -0
  372. package/host/index.test.mjs +302 -0
  373. package/package.json +14 -9
@@ -0,0 +1,1793 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Source-level public-import checks run by
5
+ * `astryx doctor integration validate` (and so `integration check --strict`).
6
+ *
7
+ * Each test stands up a small integration package under the repo root (so the
8
+ * repo's own Core is the installed Core) and validates it.
9
+ */
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import {spawnSync} from 'node:child_process';
15
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
16
+ import {
17
+ validateIntegration,
18
+ validateLocalIntegration,
19
+ } from '../../api/integration/validate-integration.mjs';
20
+ import {readImports} from './public-imports.mjs';
21
+ import {subpathDeclaration} from './exports-subpaths.mjs';
22
+ import {
23
+ integrationAddComponent,
24
+ integrationAddTemplate,
25
+ } from '../../api/integration/add-contribution.mjs';
26
+ import {
27
+ integrationCheck,
28
+ strictIssues,
29
+ } from '../../api/integration/check/check.mjs';
30
+ import {CLI_ROOT} from '../fs/paths.mjs';
31
+
32
+ const CODES = new Set([
33
+ 'component_import_not_exported',
34
+ 'component_import_other_package',
35
+ 'component_import_missing_name',
36
+ 'example_import_not_exported',
37
+ 'example_import_missing_name',
38
+ 'template_import_not_exported',
39
+ 'template_import_outside_root',
40
+ 'template_private_import',
41
+ 'template_import_missing_name',
42
+ 'template_no_default_export',
43
+ ]);
44
+
45
+ let dir;
46
+
47
+ /** @param {string} file @param {string} text */
48
+ function write(file, text) {
49
+ fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive: true});
50
+ fs.writeFileSync(path.join(dir, file), text);
51
+ }
52
+
53
+ /** @param {(pkg: any) => void} edit */
54
+ function pkg(edit) {
55
+ const file = path.join(dir, 'package.json');
56
+ const value = JSON.parse(fs.readFileSync(file, 'utf-8'));
57
+ edit(value);
58
+ fs.writeFileSync(file, JSON.stringify(value, null, 2));
59
+ }
60
+
61
+ const DOC = (
62
+ importPath = '@acme/widgets/components/AcmeWidget',
63
+ example = 'import {AcmeWidget} from \'@acme/widgets/components/AcmeWidget\';\\n\\n<AcmeWidget label=\\"Save\\" />',
64
+ ) =>
65
+ `export default {
66
+ type: 'component',
67
+ name: 'AcmeWidget',
68
+ displayName: 'Acme Widget',
69
+ import: '${importPath}',
70
+ usage: {description: 'A pressable widget.'},
71
+ props: [{name: 'label', type: 'string', description: 'Text.', required: true}],
72
+ examples: [{label: 'Basic', code: "${example}"}],
73
+ };
74
+ `;
75
+
76
+ const BLOCK = () =>
77
+ `export default {
78
+ type: 'block',
79
+ name: 'account-card',
80
+ displayName: 'Account Card',
81
+ description: 'Account Card template.',
82
+ aspectRatio: 1,
83
+ };
84
+ `;
85
+
86
+ beforeEach(() => {
87
+ dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-public-imports-'));
88
+ write(
89
+ 'package.json',
90
+ JSON.stringify({
91
+ name: '@acme/widgets',
92
+ version: '1.0.0',
93
+ type: 'module',
94
+ peerDependencies: {
95
+ '@astryxdesign/core': '>=0.1.0',
96
+ '@astryxdesign/cli': '>=0.1.0',
97
+ },
98
+ exports: {
99
+ './components/AcmeWidget': './components/AcmeWidget.tsx',
100
+ './templates/account-card': './templates/account-card.tsx',
101
+ },
102
+ }),
103
+ );
104
+ write(
105
+ 'astryx.integration.mjs',
106
+ "export default {components: './components', templates: './templates'};\n",
107
+ );
108
+ write('components/AcmeWidget.doc.mjs', DOC());
109
+ write(
110
+ 'components/AcmeWidget.tsx',
111
+ 'export type AcmeWidgetProps = {label: string};\nexport function AcmeWidget({label}: AcmeWidgetProps) {\n return <button>{label}</button>;\n}\n',
112
+ );
113
+ write('templates/account-card.doc.mjs', BLOCK());
114
+ write(
115
+ 'templates/account-card.tsx',
116
+ 'import {AcmeWidget} from \'@acme/widgets/components/AcmeWidget\';\nexport default function AccountCard() {\n return <AcmeWidget label="Account" />;\n}\n',
117
+ );
118
+ });
119
+
120
+ afterEach(() => {
121
+ fs.rmSync(dir, {recursive: true, force: true});
122
+ });
123
+
124
+ /** The public-import findings, listed as notes on validate's result. */
125
+ async function found() {
126
+ const result = await validateLocalIntegration(dir);
127
+ return (result.notes ?? []).filter(note => CODES.has(note.code));
128
+ }
129
+
130
+ async function codes() {
131
+ return (await found()).map(issue => issue.code).sort();
132
+ }
133
+
134
+ describe('public imports: clean packages', () => {
135
+ it('passes the known-good package', async () => {
136
+ const result = await validateLocalIntegration(dir);
137
+ expect(result.issues).toEqual([]);
138
+ });
139
+
140
+ it('passes a condition object with import (types first) or default', async () => {
141
+ pkg(p => {
142
+ p.exports['./components/AcmeWidget'] = {
143
+ types: './components/AcmeWidget.tsx',
144
+ import: './components/AcmeWidget.tsx',
145
+ };
146
+ });
147
+ expect(await codes()).toEqual([]);
148
+ pkg(p => {
149
+ p.exports['./components/AcmeWidget'] = {
150
+ default: './components/AcmeWidget.tsx',
151
+ import: './missing.js',
152
+ };
153
+ });
154
+ expect(await codes()).toEqual([]);
155
+ });
156
+
157
+ it('passes a wildcard that covers the component', async () => {
158
+ pkg(p => {
159
+ delete p.exports['./components/AcmeWidget'];
160
+ p.exports['./components/*'] = './components/*.tsx';
161
+ });
162
+ expect(await codes()).toEqual([]);
163
+ });
164
+
165
+ it('checks a package whose name is not a bare package name as nameless', async () => {
166
+ pkg(p => {
167
+ p.name = 'acme/kit';
168
+ });
169
+ const result = await validateIntegration(undefined, {cwd: dir});
170
+ expect(await codes()).toEqual([]);
171
+ expect(result.data.publicImports?.unchecked).toContainEqual(
172
+ expect.objectContaining({
173
+ file: 'package.json',
174
+ reason: expect.stringContaining(
175
+ '"acme/kit" is not a bare package name',
176
+ ),
177
+ }),
178
+ );
179
+ });
180
+
181
+ it('reads "exports": null as no exports field', async () => {
182
+ pkg(p => {
183
+ p.exports = null;
184
+ });
185
+ // A doc module loads once per run, so the doc is written before checking.
186
+ write(
187
+ 'components/AcmeWidget.doc.mjs',
188
+ DOC('@acme/widgets/components/Missing'),
189
+ );
190
+ const issue = (await found()).find(
191
+ i => i.code === 'component_import_not_exported',
192
+ );
193
+ expect(issue?.message).toContain(
194
+ 'no file exists at "./components/Missing"',
195
+ );
196
+ // The template's deep path to its own file still resolves.
197
+ expect((await found()).filter(i => i.code.startsWith('template_'))).toEqual(
198
+ [],
199
+ );
200
+ });
201
+
202
+ it('keeps legacy deep paths when package.json has no exports', async () => {
203
+ pkg(p => delete p.exports);
204
+ expect(await codes()).toEqual([]);
205
+ });
206
+
207
+ it('flags a deep path with no file when package.json has no exports', async () => {
208
+ pkg(p => delete p.exports);
209
+ write(
210
+ 'components/AcmeWidget.doc.mjs',
211
+ DOC('@acme/widgets/components/Missing'),
212
+ );
213
+ const issue = (await found()).find(
214
+ i => i.code === 'component_import_not_exported',
215
+ );
216
+ expect(issue?.message).toContain(
217
+ 'no file exists at "./components/Missing"',
218
+ );
219
+ });
220
+
221
+ it('lists a deep path into build output not built yet as not checked', async () => {
222
+ pkg(p => {
223
+ delete p.exports;
224
+ p.files = ['dist', 'components', 'templates'];
225
+ });
226
+ write(
227
+ 'components/AcmeWidget.doc.mjs',
228
+ DOC(
229
+ '@acme/widgets/dist/AcmeWidget.js',
230
+ "import {AcmeWidget} from '@acme/widgets/dist/AcmeWidget.js';\\n<AcmeWidget />",
231
+ ),
232
+ );
233
+ const result = await validateIntegration(undefined, {cwd: dir});
234
+ expect(
235
+ (result.data.notes ?? []).filter(i => i.code.includes('_not_exported')),
236
+ ).toEqual([]);
237
+ expect(result.data.publicImports?.unchecked).toEqual([
238
+ expect.objectContaining({
239
+ file: 'components/AcmeWidget.doc.mjs',
240
+ specifier: '@acme/widgets/dist/AcmeWidget.js',
241
+ reason: expect.stringContaining('build output'),
242
+ }),
243
+ expect.objectContaining({
244
+ specifier: '@acme/widgets/dist/AcmeWidget.js',
245
+ }),
246
+ ]);
247
+ });
248
+
249
+ it('never maps a built .js path to the .tsx source in a fix', async () => {
250
+ pkg(p => delete p.exports);
251
+ write(
252
+ 'components/AcmeWidget.doc.mjs',
253
+ DOC('@acme/widgets/components/AcmeWidget.js'),
254
+ );
255
+ const issue = (await found()).find(
256
+ i => i.code === 'component_import_not_exported',
257
+ );
258
+ expect(issue?.message).not.toContain(
259
+ '"./components/AcmeWidget.js": "./components/AcmeWidget.tsx"',
260
+ );
261
+ });
262
+
263
+ it('never requires an exports target to exist (build output)', async () => {
264
+ pkg(p => {
265
+ p.exports['./components/AcmeWidget'] = {
266
+ types: './dist/AcmeWidget.d.ts',
267
+ import: './dist/AcmeWidget.js',
268
+ };
269
+ });
270
+ expect(await codes()).toEqual([]);
271
+ });
272
+ });
273
+
274
+ describe('public imports: component doc import', () => {
275
+ it('flags a missing exports key with the entry to add', async () => {
276
+ pkg(p => delete p.exports['./components/AcmeWidget']);
277
+ const issues = await found();
278
+ const issue = issues.find(i => i.code === 'component_import_not_exported');
279
+ expect(issue).not.toHaveProperty('severity');
280
+ expect(issue?.message).toContain(
281
+ '"./components/AcmeWidget": "./components/AcmeWidget.tsx"',
282
+ );
283
+ });
284
+
285
+ it('lists its findings only: neither validate nor --strict counts or fails on them', async () => {
286
+ pkg(p => {
287
+ delete p.exports['./components/AcmeWidget'];
288
+ });
289
+ write(
290
+ 'templates/account-card.tsx',
291
+ "import {X} from '../components/AcmeWidget';\nexport default function A() {\n return <X />;\n}\n",
292
+ );
293
+ const issues = await found();
294
+ expect(issues.length).toBeGreaterThan(2);
295
+ expect(issues.every(i => !('severity' in i))).toBe(true);
296
+ const validated = await validateLocalIntegration(dir);
297
+ expect(validated.issues.filter(issue => CODES.has(issue.code))).toEqual([]);
298
+ const plain = await integrationCheck({cwd: dir});
299
+ expect(plain.data.errors).toBe(0);
300
+ expect(plain.data.warnings).toBe(0);
301
+ const strict = await integrationCheck({cwd: dir, strict: true});
302
+ expect(strict.data.failed).toBe(false);
303
+ expect(strict.data.failures).toEqual([]);
304
+ });
305
+
306
+ it('flags a require-only condition', async () => {
307
+ pkg(p => {
308
+ p.exports['./components/AcmeWidget'] = {
309
+ require: './components/AcmeWidget.tsx',
310
+ };
311
+ });
312
+ const issue = (await found()).find(
313
+ i => i.code === 'component_import_not_exported',
314
+ );
315
+ expect(issue?.message).toContain('only the "require" condition');
316
+ });
317
+
318
+ it('names the conditions a types-only entry has', async () => {
319
+ pkg(p => {
320
+ p.exports['./components/AcmeWidget'] = {types: './a.d.ts'};
321
+ });
322
+ const issue = (await found()).find(
323
+ i => i.code === 'component_import_not_exported',
324
+ );
325
+ expect(issue?.message).toContain('only the "types" condition');
326
+ expect(issue?.message).not.toContain('require');
327
+ });
328
+
329
+ it('flags a bare-string exports field', async () => {
330
+ pkg(p => {
331
+ p.exports = './components/AcmeWidget.tsx';
332
+ });
333
+ const all = await codes();
334
+ expect(all).toContain('component_import_not_exported');
335
+ expect(all).toContain('template_import_not_exported');
336
+ });
337
+
338
+ it('flags a wildcard that does not match', async () => {
339
+ pkg(p => {
340
+ delete p.exports['./components/AcmeWidget'];
341
+ p.exports['./widgets/*'] = './components/*.tsx';
342
+ });
343
+ expect(await codes()).toContain('component_import_not_exported');
344
+ });
345
+
346
+ it('points an undeclared import at the subpath already exported', async () => {
347
+ write('components/AcmeWidget.doc.mjs', DOC('@acme/widgets/AcmeWidget'));
348
+ const issue = (await found()).find(
349
+ i => i.code === 'component_import_not_exported',
350
+ );
351
+ expect(issue?.message).toContain(
352
+ "set import: '@acme/widgets/components/AcmeWidget' (already exported)",
353
+ );
354
+ });
355
+
356
+ it('flags an import that names another package', async () => {
357
+ write(
358
+ 'components/AcmeWidget.doc.mjs',
359
+ DOC('@acme/other/components/AcmeWidget'),
360
+ );
361
+ const issue = (await found()).find(
362
+ i => i.code === 'component_import_other_package',
363
+ );
364
+ expect(issue?.message).toContain(
365
+ "set import: '@acme/widgets/components/AcmeWidget'",
366
+ );
367
+ });
368
+ });
369
+
370
+ describe('public imports: component examples', () => {
371
+ it('flags a name the package does not export', async () => {
372
+ write(
373
+ 'components/AcmeWidget.doc.mjs',
374
+ DOC(
375
+ undefined,
376
+ "import {AcmeGadget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeGadget />",
377
+ ),
378
+ );
379
+ expect(await codes()).toEqual(['example_import_missing_name']);
380
+ const issue = (await found()).find(
381
+ i => i.code === 'example_import_missing_name',
382
+ );
383
+ expect(issue?.message).toContain(
384
+ 'import a name that module exports ("AcmeWidget" for this doc)',
385
+ );
386
+ });
387
+
388
+ it("points a missing-name example at the doc's import only when that module exports the missing name", async () => {
389
+ pkg(p => {
390
+ p.exports['./other'] = './lib/other.ts';
391
+ });
392
+ write('lib/other.ts', 'export const Unrelated = 1;\n');
393
+ write(
394
+ 'components/AcmeWidget.tsx',
395
+ 'export function AcmeWidget() {\n return null;\n}\nexport function AcmeGadget() {\n return null;\n}\n',
396
+ );
397
+ write(
398
+ 'components/AcmeWidget.doc.mjs',
399
+ DOC(
400
+ undefined,
401
+ "import {AcmeGadget} from '@acme/widgets/other';\\n<AcmeGadget />",
402
+ ),
403
+ );
404
+ const issue = (await found()).find(
405
+ i => i.code === 'example_import_missing_name',
406
+ );
407
+ expect(issue?.message).not.toContain('for this doc');
408
+ expect(issue?.message).toContain(
409
+ "import from '@acme/widgets/components/AcmeWidget', as the doc's import does",
410
+ );
411
+ // Without the name there, the doc's import is no fix.
412
+ write(
413
+ 'components/AcmeWidget.tsx',
414
+ 'export function AcmeWidget() {\n return null;\n}\n',
415
+ );
416
+ const generic = (await found()).find(
417
+ i => i.code === 'example_import_missing_name',
418
+ );
419
+ expect(generic?.message).toContain('Fix: Export the name from it.');
420
+ expect(generic?.message).not.toContain("as the doc's import does");
421
+ });
422
+
423
+ it('flags a subpath exports does not declare', async () => {
424
+ write(
425
+ 'components/AcmeWidget.doc.mjs',
426
+ DOC(
427
+ undefined,
428
+ "import {AcmeWidget} from '@acme/widgets/src/AcmeWidget';\\n<AcmeWidget />",
429
+ ),
430
+ );
431
+ expect(await codes()).toEqual(['example_import_not_exported']);
432
+ });
433
+
434
+ it('points an undeclared example import at the doc import when that is declared', async () => {
435
+ write(
436
+ 'components/AcmeWidget.doc.mjs',
437
+ DOC(
438
+ undefined,
439
+ "import {AcmeWidget} from '@acme/widgets/src/AcmeWidget';\\n<AcmeWidget />",
440
+ ),
441
+ );
442
+ const issue = (await found()).find(
443
+ i => i.code === 'example_import_not_exported',
444
+ );
445
+ expect(issue?.message).toContain(
446
+ "Fix: import from '@acme/widgets/components/AcmeWidget' in the example, as the doc's import does.",
447
+ );
448
+ });
449
+
450
+ it('never points an example back at the same undeclared doc import', async () => {
451
+ pkg(p => delete p.exports['./components/AcmeWidget']);
452
+ const issue = (await found()).find(
453
+ i => i.code === 'example_import_not_exported',
454
+ );
455
+ expect(issue?.message).not.toContain('in the example, as the doc');
456
+ expect(issue?.message).toContain(
457
+ 'add "./components/AcmeWidget": "./components/AcmeWidget.tsx" to package.json "exports"',
458
+ );
459
+ });
460
+
461
+ it('gives the exports entry for a hooks path when the doc import lacks the hook', async () => {
462
+ write(
463
+ 'components/useAcmeTheme.ts',
464
+ 'export function useAcmeTheme() {\n return null;\n}\n',
465
+ );
466
+ write(
467
+ 'components/AcmeWidget.doc.mjs',
468
+ DOC(
469
+ undefined,
470
+ "import {useAcmeTheme} from '@acme/widgets/components/useAcmeTheme';\\nuseAcmeTheme();",
471
+ ),
472
+ );
473
+ const issue = (await found()).find(
474
+ i => i.code === 'example_import_not_exported',
475
+ );
476
+ expect(issue?.message).not.toContain(
477
+ "import from '@acme/widgets/components/AcmeWidget'",
478
+ );
479
+ expect(issue?.message).toContain(
480
+ 'add "./components/useAcmeTheme": "./components/useAcmeTheme.ts" to package.json "exports"',
481
+ );
482
+ });
483
+
484
+ it('never points a side-effect import of an undeclared subpath at another subpath', async () => {
485
+ write('styles.css', '.acme {}\n');
486
+ write(
487
+ 'components/AcmeWidget.doc.mjs',
488
+ DOC(undefined, "import '@acme/widgets/styles.css';\\n<AcmeWidget />"),
489
+ );
490
+ const issue = (await found()).find(
491
+ i => i.code === 'example_import_not_exported',
492
+ );
493
+ expect(issue?.message).not.toContain('in the example');
494
+ expect(issue?.message).toContain(
495
+ 'add "./styles.css": "./styles.css" to package.json "exports"',
496
+ );
497
+ });
498
+
499
+ it('points an example at the subpath the source is already exported under', async () => {
500
+ pkg(p => {
501
+ delete p.exports['./components/AcmeWidget'];
502
+ p.exports['./widget'] = './components/AcmeWidget.tsx';
503
+ });
504
+ write(
505
+ 'components/AcmeWidget.doc.mjs',
506
+ DOC(
507
+ '@acme/widgets/nope',
508
+ "import {AcmeWidget} from '@acme/widgets/nope';\\n<AcmeWidget />",
509
+ ),
510
+ );
511
+ const issue = (await found()).find(
512
+ i => i.code === 'example_import_not_exported',
513
+ );
514
+ expect(issue?.message).toContain(
515
+ "Fix: import from '@acme/widgets/widget' in the example (already exported).",
516
+ );
517
+ });
518
+
519
+ it('leaves a CommonJS target unjudged for names', async () => {
520
+ write(
521
+ 'components/AcmeWidget.doc.mjs',
522
+ DOC(
523
+ undefined,
524
+ "import {AcmeGadget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeGadget />",
525
+ ),
526
+ );
527
+ write(
528
+ 'components/AcmeWidget.tsx',
529
+ 'module.exports = {AcmeWidget: () => null, AcmeGadget: () => null};\n',
530
+ );
531
+ expect(await codes()).toEqual([]);
532
+ pkg(p => {
533
+ p.exports['./components/AcmeWidget'] = './components/AcmeWidget.cjs';
534
+ });
535
+ write('components/AcmeWidget.cjs', 'export const Other = 1;\n');
536
+ expect(await codes()).toEqual([]);
537
+ });
538
+
539
+ it('accepts aliased, type-only and other-package imports', async () => {
540
+ write(
541
+ 'components/AcmeWidget.doc.mjs',
542
+ DOC(
543
+ undefined,
544
+ "import {AcmeWidget as W, type AcmeWidgetProps} from '@acme/widgets/components/AcmeWidget';\\nimport {Button} from '@astryxdesign/core/Button';\\nimport x from 'left-pad';\\n<W label=\\\"a\\\" />",
545
+ ),
546
+ );
547
+ expect(await codes()).toEqual([]);
548
+ });
549
+
550
+ it('reads named, default, aliased, namespace and type-only imports', () => {
551
+ const statements = readImports(
552
+ "import D, {A as B, type T} from 'p/a';\nimport type {U} from 'p/b';\nimport * as N from 'p/c';\n...\n<D />",
553
+ );
554
+ expect(statements.map(s => s.source)).toEqual(['p/a', 'p/b', 'p/c']);
555
+ expect(statements[0].names).toEqual([
556
+ {imported: 'default', typeOnly: false},
557
+ {imported: 'A', typeOnly: false},
558
+ {imported: 'T', typeOnly: true},
559
+ ]);
560
+ expect(statements[1].names).toEqual([{imported: 'U', typeOnly: true}]);
561
+ expect(statements[2].namespace).toBe(true);
562
+ });
563
+ });
564
+
565
+ describe('public imports: template sources', () => {
566
+ it('flags a relative import that leaves the templates root', async () => {
567
+ write(
568
+ 'templates/account-card.tsx',
569
+ 'import {AcmeWidget} from \'../components/AcmeWidget\';\nexport default function A() {\n return <AcmeWidget label="x" />;\n}\n',
570
+ );
571
+ expect(await codes()).toEqual(['template_import_outside_root']);
572
+ });
573
+
574
+ it('flags a Core path Core does not export, and accepts a public one', async () => {
575
+ write(
576
+ 'templates/account-card.tsx',
577
+ 'import {Button} from \'@astryxdesign/core/src/Button/Button\';\nexport default function A() {\n return <Button label="x" />;\n}\n',
578
+ );
579
+ const issue = (await found()).find(
580
+ i => i.code === 'template_private_import',
581
+ );
582
+ expect(issue?.message).toContain("'@astryxdesign/core/Button'");
583
+ write(
584
+ 'templates/account-card.tsx',
585
+ 'import {Button} from \'@astryxdesign/core/Button\';\nexport default function A() {\n return <Button label="x" />;\n}\n',
586
+ );
587
+ expect(await codes()).toEqual([]);
588
+ });
589
+
590
+ it('flags a name its own package does not export', async () => {
591
+ write(
592
+ 'templates/account-card.tsx',
593
+ 'import {AcmeGadget} from \'@acme/widgets/components/AcmeWidget\';\nexport default function A() {\n return <AcmeGadget label="x" />;\n}\n',
594
+ );
595
+ expect(await codes()).toEqual(['template_import_missing_name']);
596
+ });
597
+
598
+ it('leaves a built CommonJS dist file reached through exports unjudged for names', async () => {
599
+ pkg(p => {
600
+ p.exports['./components/AcmeWidget'] = {
601
+ types: './dist/AcmeWidget.d.ts',
602
+ default: './dist/AcmeWidget.js',
603
+ };
604
+ });
605
+ write(
606
+ 'dist/AcmeWidget.js',
607
+ "'use strict';\nObject.defineProperty(exports, '__esModule', {value: true});\nexports.AcmeWidget = void 0;\nconst AcmeWidget = () => null;\nexports.AcmeWidget = AcmeWidget;\n",
608
+ );
609
+ write(
610
+ 'templates/account-card.tsx',
611
+ 'import {AcmeGadget} from \'@acme/widgets/components/AcmeWidget\';\nexport default function A() {\n return <AcmeGadget label="x" />;\n}\n',
612
+ );
613
+ write(
614
+ 'components/AcmeWidget.doc.mjs',
615
+ DOC(
616
+ undefined,
617
+ "import {AcmeGadget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeGadget />",
618
+ ),
619
+ );
620
+ expect(await codes()).toEqual([]);
621
+ });
622
+
623
+ it('leaves a module that also assigns exports.* unjudged for names', async () => {
624
+ write(
625
+ 'components/AcmeWidget.tsx',
626
+ 'export function AcmeWidget() {\n return null;\n}\nexports.AcmeGadget = AcmeWidget;\n',
627
+ );
628
+ write(
629
+ 'templates/account-card.tsx',
630
+ 'import {AcmeGadget} from \'@acme/widgets/components/AcmeWidget\';\nexport default function A() {\n return <AcmeGadget label="x" />;\n}\n',
631
+ );
632
+ expect(await codes()).toEqual([]);
633
+ });
634
+
635
+ it('leaves a CommonJS target of a template import unjudged for names', async () => {
636
+ write('components/AcmeWidget.tsx', 'exports.AcmeWidget = () => null;\n');
637
+ write(
638
+ 'templates/account-card.tsx',
639
+ 'import {AcmeGadget} from \'@acme/widgets/components/AcmeWidget\';\nexport default function A() {\n return <AcmeGadget label="x" />;\n}\n',
640
+ );
641
+ expect(await codes()).toEqual([]);
642
+ });
643
+
644
+ it('flags a template whose public import exports does not declare', async () => {
645
+ pkg(p => delete p.exports['./templates/account-card']);
646
+ const issue = (await found()).find(
647
+ i => i.code === 'template_import_not_exported',
648
+ );
649
+ expect(issue?.message).toContain(
650
+ '"./templates/account-card": "./templates/account-card.tsx"',
651
+ );
652
+ });
653
+ });
654
+
655
+ describe('template default export', () => {
656
+ it('lists a template source with no default export, naming the export to add', async () => {
657
+ write(
658
+ 'templates/account-card.tsx',
659
+ 'export function AccountCard() {\n return <div>Account</div>;\n}\n',
660
+ );
661
+ const issues = await found();
662
+ expect(issues.map(i => i.code)).toEqual(['template_no_default_export']);
663
+ expect(issues[0].message).toContain('export default AccountCard;');
664
+ });
665
+
666
+ it('suggests a value, never a type, as the default export', async () => {
667
+ write(
668
+ 'templates/account-card.tsx',
669
+ 'export interface AccountCardProps {\n title: string;\n}\nexport type AccountCardSize = "sm";\nexport function AccountCard() {\n return <div>Account</div>;\n}\n',
670
+ );
671
+ const issue = (await found()).find(
672
+ i => i.code === 'template_no_default_export',
673
+ );
674
+ expect(issue?.message).toContain('export default AccountCard;');
675
+ expect(issue?.message).not.toContain('AccountCardProps;');
676
+ });
677
+
678
+ it('accepts a re-exported or type-annotated default', async () => {
679
+ write(
680
+ 'templates/account-card.tsx',
681
+ 'function AccountCard() {\n return <div>Account</div>;\n}\nexport {AccountCard as default};\n',
682
+ );
683
+ expect(await codes()).toEqual([]);
684
+ });
685
+ });
686
+
687
+ describe('subpathDeclaration', () => {
688
+ it('reads each way exports can declare a subpath', () => {
689
+ expect(subpathDeclaration(undefined, './a').state).toBe('no_exports');
690
+ expect(subpathDeclaration('./i.js', './a').state).toBe('root_only');
691
+ expect(subpathDeclaration({import: './i.js'}, './a').state).toBe(
692
+ 'root_only',
693
+ );
694
+ expect(subpathDeclaration({'./a': null}, './a').state).toBe('blocked');
695
+ expect(subpathDeclaration({'./a': {require: './a.cjs'}}, './a').state).toBe(
696
+ 'not_importable',
697
+ );
698
+ expect(subpathDeclaration({'./*': './src/*.ts'}, './a').state).toBe(
699
+ 'declared',
700
+ );
701
+ expect(
702
+ subpathDeclaration(
703
+ {'./*': './src/*.ts', './private/*': null},
704
+ './private/a',
705
+ ).state,
706
+ ).toBe('blocked');
707
+ });
708
+ });
709
+
710
+ describe('derived component imports', () => {
711
+ const NO_IMPORT = `export default {
712
+ type: 'component',
713
+ name: 'AcmeWidget',
714
+ displayName: 'Acme Widget',
715
+ usage: {description: 'A pressable widget.'},
716
+ props: [{name: 'label', type: 'string', description: 'Text.', required: true}],
717
+ };
718
+ `;
719
+
720
+ it('warns when the derived root import does not export the component, naming the subpath that does', async () => {
721
+ write('components/AcmeWidget.doc.mjs', NO_IMPORT);
722
+ write('index.js', 'export const Other = 1;\n');
723
+ pkg(p => {
724
+ p.exports['.'] = './index.js';
725
+ });
726
+ const issues = (await found()).filter(
727
+ i => i.code === 'component_import_missing_name',
728
+ );
729
+ expect(issues).toHaveLength(1);
730
+ expect(issues[0].message).toContain(
731
+ 'has no import, so apps are told to import from "@acme/widgets"',
732
+ );
733
+ expect(issues[0].message).toContain(
734
+ "set import: '@acme/widgets/components/AcmeWidget' in the doc",
735
+ );
736
+ });
737
+
738
+ it('names the wildcard subpath the component is already exported under', async () => {
739
+ write('index.js', 'export const Other = 1;\n');
740
+ pkg(p => {
741
+ p.exports = {
742
+ '.': './index.js',
743
+ './components/*': './components/*.tsx',
744
+ './templates/account-card': './templates/account-card.tsx',
745
+ };
746
+ });
747
+ write('components/AcmeWidget.doc.mjs', DOC('@acme/widgets'));
748
+ const issue = (await found()).find(
749
+ i => i.code === 'component_import_missing_name',
750
+ );
751
+ expect(issue?.message).toContain(
752
+ "set import: '@acme/widgets/components/AcmeWidget' in the doc (that subpath exports it)",
753
+ );
754
+ expect(issue?.message).not.toContain('add "./components/AcmeWidget"');
755
+ });
756
+
757
+ it('warns when the derived root import is not declared', async () => {
758
+ write('components/AcmeWidget.doc.mjs', NO_IMPORT);
759
+ const issue = (await found()).find(
760
+ i => i.code === 'component_import_not_exported',
761
+ );
762
+ expect(issue?.message).toContain(
763
+ 'has no import, so apps are told to import from "@acme/widgets"',
764
+ );
765
+ });
766
+
767
+ it('warns when a written import does not export the component', async () => {
768
+ write('index.js', 'export const Other = 1;\n');
769
+ pkg(p => {
770
+ p.exports['.'] = './index.js';
771
+ });
772
+ write('components/AcmeWidget.doc.mjs', DOC('@acme/widgets'));
773
+ expect(await codes()).toContain('component_import_missing_name');
774
+ });
775
+
776
+ it('warns when a written import does not export the component, even while the package augments a module', async () => {
777
+ write('index.js', 'export const Other = 1;\n');
778
+ write(
779
+ 'types/augment.d.ts',
780
+ "declare module '@acme/widgets' {\n interface AcmeTone {}\n}\nexport {};\n",
781
+ );
782
+ pkg(p => {
783
+ p.exports['.'] = './index.js';
784
+ });
785
+ write('components/AcmeWidget.doc.mjs', DOC('@acme/widgets'));
786
+ expect(await codes()).toContain('component_import_missing_name');
787
+ });
788
+
789
+ it('lists a derived import whose target is not built as not checked', async () => {
790
+ write('components/AcmeWidget.doc.mjs', NO_IMPORT);
791
+ pkg(p => {
792
+ p.exports['.'] = {types: './dist/index.d.ts', import: './dist/index.js'};
793
+ });
794
+ const result = await validateIntegration(undefined, {cwd: dir});
795
+ expect(
796
+ (result.data.notes ?? []).filter(i =>
797
+ i.code.startsWith('component_import'),
798
+ ),
799
+ ).toEqual([]);
800
+ expect(result.data.publicImports?.unchecked).toEqual([
801
+ expect.objectContaining({
802
+ file: 'components/AcmeWidget.doc.mjs',
803
+ specifier: '@acme/widgets',
804
+ reason: expect.stringContaining('build output'),
805
+ }),
806
+ ]);
807
+ const check = await integrationCheck({cwd: dir, strict: true});
808
+ expect(check.data.unchecked).toBe(1);
809
+ expect(check.data.failed).toBe(false);
810
+ });
811
+ });
812
+
813
+ describe('fixes for two components in one folder', () => {
814
+ const docFor = (/** @type {string} */ name) => `export default {
815
+ type: 'component',
816
+ name: '${name}',
817
+ displayName: '${name}',
818
+ usage: {description: 'A widget.'},
819
+ props: [],
820
+ };
821
+ `;
822
+
823
+ beforeEach(() => {
824
+ for (const name of ['AcmeAlpha', 'AcmeBeta']) {
825
+ write(`components/${name}.doc.mjs`, docFor(name));
826
+ write(
827
+ `components/${name}.tsx`,
828
+ `export function ${name}() {\n return null;\n}\n`,
829
+ );
830
+ }
831
+ });
832
+
833
+ it('gives each import-less component its own entry, never the root', async () => {
834
+ const fixes = (await found())
835
+ .filter(i => i.code === 'component_import_not_exported')
836
+ .map(i => i.message.split('Fix: ')[1]);
837
+ expect(fixes).toEqual(
838
+ expect.arrayContaining([
839
+ `add "./components/AcmeAlpha": "./components/AcmeAlpha.tsx" to package.json "exports" and set import: '@acme/widgets/components/AcmeAlpha' in the doc.`,
840
+ `add "./components/AcmeBeta": "./components/AcmeBeta.tsx" to package.json "exports" and set import: '@acme/widgets/components/AcmeBeta' in the doc.`,
841
+ ]),
842
+ );
843
+ for (const fix of fixes) expect(fix).not.toContain('".":');
844
+ });
845
+
846
+ it('gives each a per-component entry when the derived folder subpath lacks it', async () => {
847
+ write('components/index.js', 'export const Other = 1;\n');
848
+ pkg(p => {
849
+ p.exports['./components'] = './components/index.js';
850
+ p.exports['.'] = './components/index.js';
851
+ });
852
+ const fixes = (await found())
853
+ .filter(i => i.code === 'component_import_missing_name')
854
+ .map(i => i.message.split('Fix: ')[1]);
855
+ expect(fixes).toHaveLength(2);
856
+ expect(fixes[0]).toContain(
857
+ `add "./components/AcmeAlpha": "./components/AcmeAlpha.tsx" to package.json "exports" and set import: '@acme/widgets/components/AcmeAlpha' in the doc`,
858
+ );
859
+ expect(fixes[1]).toContain(
860
+ '"./components/AcmeBeta": "./components/AcmeBeta.tsx"',
861
+ );
862
+ for (const fix of fixes) expect(fix).not.toContain('"./components":');
863
+ });
864
+
865
+ it('never offers a per-component entry when the source exports the component only as default', async () => {
866
+ write('components/index.js', 'export const Other = 1;\n');
867
+ write(
868
+ 'components/AcmeAlpha.tsx',
869
+ 'export default function AcmeAlpha() {\n return null;\n}\n',
870
+ );
871
+ pkg(p => {
872
+ p.exports['./components'] = './components/index.js';
873
+ p.exports['.'] = './components/index.js';
874
+ });
875
+ const fix = (await found())
876
+ .filter(i => i.code === 'component_import_missing_name')
877
+ .map(i => i.message.split('Fix: ')[1])
878
+ .find(text => text.includes('"AcmeAlpha"'));
879
+ expect(fix).toBe(
880
+ 'export "AcmeAlpha" from the module "@acme/widgets/components" loads.',
881
+ );
882
+ });
883
+ });
884
+
885
+ describe('type-only imports', () => {
886
+ it.each([
887
+ [
888
+ 'a statement-level import type',
889
+ {'./types': {types: './types.d.ts'}},
890
+ {'types.d.ts': 'export type AcmeWidgetSize = "sm" | "md";\n'},
891
+ "import type {AcmeWidgetSize} from '@acme/widgets/types';",
892
+ ],
893
+ [
894
+ 'an import whose every binding is an inline type',
895
+ {'./types': {types: './types.d.ts'}},
896
+ {'types.d.ts': 'export type AcmeWidgetSize = "sm" | "md";\n'},
897
+ "import {type AcmeWidgetSize} from '@acme/widgets/types';",
898
+ ],
899
+ [
900
+ 'a plain import of names the types file declares as types',
901
+ {'./types': {types: './types.d.ts'}},
902
+ {
903
+ 'types.d.ts':
904
+ 'export type AcmeWidgetSize = "sm" | "md";\ninterface AcmeTone {}\nexport {type AcmeTone as Tone};\n',
905
+ },
906
+ "import {AcmeWidgetSize, Tone} from '@acme/widgets/types';",
907
+ ],
908
+ [
909
+ 'a plain import of a declared type (wildcard entry)',
910
+ {'./types/*': {types: './types/*.d.ts'}},
911
+ {'types/size.d.ts': 'export type AcmeWidgetSize = "sm" | "md";\n'},
912
+ "import {AcmeWidgetSize} from '@acme/widgets/types/size';",
913
+ ],
914
+ [
915
+ 'a statement-level import type (types beside only require)',
916
+ {'./legacy': {types: './legacy.d.ts', require: './legacy.cjs'}},
917
+ {'legacy.d.ts': 'export type AcmeLegacy = string;\n'},
918
+ "import type {AcmeLegacy} from '@acme/widgets/legacy';",
919
+ ],
920
+ [
921
+ 'a plain import of a declared type (types beside only require)',
922
+ {'./legacy': {types: './legacy.d.ts', require: './legacy.cjs'}},
923
+ {'legacy.d.ts': 'export type AcmeLegacy = string;\n'},
924
+ "import {AcmeLegacy} from '@acme/widgets/legacy';",
925
+ ],
926
+ ])(
927
+ 'accepts %s from a subpath with a types target',
928
+ async (_label, entries, files, typeImport) => {
929
+ pkg(p => {
930
+ Object.assign(p.exports, entries);
931
+ });
932
+ for (const [file, text] of Object.entries(files)) write(file, text);
933
+ write(
934
+ 'components/AcmeWidget.doc.mjs',
935
+ DOC(
936
+ undefined,
937
+ `${typeImport}\\nimport {AcmeWidget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeWidget />`,
938
+ ),
939
+ );
940
+ expect(await codes()).toEqual([]);
941
+ const report = (await validateLocalIntegration(dir)).publicImports;
942
+ expect(report?.unchecked).toEqual([]);
943
+ },
944
+ );
945
+
946
+ it('notes an import type whose only types target sits under require', async () => {
947
+ pkg(p => {
948
+ p.exports['./legacy'] = {
949
+ require: {types: './legacy.d.cts', default: './legacy.cjs'},
950
+ };
951
+ });
952
+ write('legacy.d.cts', 'export type AcmeLegacy = string;\n');
953
+ write(
954
+ 'components/AcmeWidget.doc.mjs',
955
+ DOC(
956
+ undefined,
957
+ "import type {AcmeLegacy} from '@acme/widgets/legacy';\\nimport {AcmeWidget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeWidget />",
958
+ ),
959
+ );
960
+ expect(await codes()).not.toEqual([]);
961
+ });
962
+
963
+ it('lists a value import from a subpath with types beside only require as not checked', async () => {
964
+ pkg(p => {
965
+ p.exports['./legacy'] = {types: './legacy.d.ts', require: './legacy.cjs'};
966
+ });
967
+ write('legacy.d.ts', 'export declare const acmeLegacy: string;\n');
968
+ write(
969
+ 'components/AcmeWidget.doc.mjs',
970
+ DOC(
971
+ undefined,
972
+ "import {acmeLegacy} from '@acme/widgets/legacy';\\nimport {AcmeWidget} from '@acme/widgets/components/AcmeWidget';\\n<AcmeWidget />",
973
+ ),
974
+ );
975
+ expect(await codes()).toEqual([]);
976
+ const report = (await validateLocalIntegration(dir)).publicImports;
977
+ expect(report?.unchecked.map(u => u.specifier)).toEqual([
978
+ '@acme/widgets/legacy',
979
+ ]);
980
+ });
981
+
982
+ it('lists a value or unreadable import from a types-only subpath as not checked, never a note', async () => {
983
+ pkg(p => {
984
+ p.exports['./types'] = {types: './types.d.ts'};
985
+ p.exports['./gone'] = {types: './gone.d.ts'};
986
+ });
987
+ write(
988
+ 'types.d.ts',
989
+ 'export type AcmeWidgetSize = "sm";\nexport declare const ACME_SIZES: string[];\n',
990
+ );
991
+ write(
992
+ 'components/AcmeWidget.doc.mjs',
993
+ DOC(
994
+ undefined,
995
+ "import {type AcmeWidgetSize, ACME_SIZES} from '@acme/widgets/types';\\nimport {Gone} from '@acme/widgets/gone';\\nimport type {X} from '@acme/widgets/nope';\\n<AcmeWidget />",
996
+ ),
997
+ );
998
+ const notes = await found();
999
+ expect(notes.map(n => n.code)).toEqual(['example_import_not_exported']);
1000
+ expect(notes[0].message).toContain('@acme/widgets/nope');
1001
+ expect(notes[0].message).not.toContain('"default"');
1002
+ const report = (await validateLocalIntegration(dir)).publicImports;
1003
+ expect(report?.unchecked.map(u => u.specifier).sort()).toEqual([
1004
+ '@acme/widgets/gone',
1005
+ '@acme/widgets/types',
1006
+ ]);
1007
+ expect(
1008
+ report?.unchecked.find(u => u.specifier === '@acme/widgets/types')
1009
+ ?.reason,
1010
+ ).toContain('ACME_SIZES');
1011
+ });
1012
+ });
1013
+
1014
+ describe('an empty exports map', () => {
1015
+ it('names the missing subpath and adds only that entry', async () => {
1016
+ pkg(p => {
1017
+ p.exports = {};
1018
+ });
1019
+ const issue = (await found()).find(
1020
+ i => i.code === 'component_import_not_exported',
1021
+ );
1022
+ expect(issue?.message).toContain(
1023
+ 'package.json "exports" does not declare "./components/AcmeWidget"',
1024
+ );
1025
+ expect(issue?.message).toContain(
1026
+ 'add "./components/AcmeWidget": "./components/AcmeWidget.tsx" to package.json "exports"',
1027
+ );
1028
+ expect(issue?.message).not.toContain('subpath map');
1029
+ expect(subpathDeclaration({}, '.').state).toBe('missing');
1030
+ expect(subpathDeclaration({}, './a').state).toBe('missing');
1031
+ });
1032
+ });
1033
+
1034
+ describe('imports listed as not checked', () => {
1035
+ it('lists a template import of Core when Core is not installed', () => {
1036
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-no-core-'));
1037
+ try {
1038
+ const put = (/** @type {string} */ rel, /** @type {string} */ text) => {
1039
+ fs.mkdirSync(path.dirname(path.join(out, rel)), {recursive: true});
1040
+ fs.writeFileSync(path.join(out, rel), text);
1041
+ };
1042
+ put(
1043
+ 'package.json',
1044
+ JSON.stringify({
1045
+ name: '@acme/widgets',
1046
+ version: '1.0.0',
1047
+ type: 'module',
1048
+ exports: {'./templates/account-card': './templates/account-card.tsx'},
1049
+ }),
1050
+ );
1051
+ put(
1052
+ 'astryx.integration.mjs',
1053
+ "export default {templates: './templates'};\n",
1054
+ );
1055
+ put('templates/account-card.doc.mjs', BLOCK());
1056
+ put(
1057
+ 'templates/account-card.tsx',
1058
+ "import {Button} from '@astryxdesign/core/src/Button';\nexport default function A() {\n return <Button />;\n}\n",
1059
+ );
1060
+ const run = spawnSync(
1061
+ process.execPath,
1062
+ [
1063
+ path.join(CLI_ROOT, 'clients/cli/bin/astryx.mjs'),
1064
+ '--json',
1065
+ 'doctor',
1066
+ 'integration',
1067
+ 'validate',
1068
+ ],
1069
+ {cwd: out, encoding: 'utf-8', timeout: 60_000},
1070
+ );
1071
+ const {data} = JSON.parse(run.stdout);
1072
+ expect(
1073
+ data.issues.filter(
1074
+ (/** @type {{code: string}} */ i) =>
1075
+ i.code === 'template_private_import',
1076
+ ),
1077
+ ).toEqual([]);
1078
+ expect(data.publicImports.unchecked).toEqual([
1079
+ expect.objectContaining({
1080
+ file: 'templates/account-card.tsx',
1081
+ specifier: '@astryxdesign/core/src/Button',
1082
+ }),
1083
+ ]);
1084
+ } finally {
1085
+ fs.rmSync(out, {recursive: true, force: true});
1086
+ }
1087
+ }, 60_000);
1088
+
1089
+ it('lists template and example imports whose names cannot be judged', async () => {
1090
+ pkg(p => {
1091
+ p.exports['./built'] = './dist/built.js';
1092
+ p.exports['./cjs'] = './cjs.cjs';
1093
+ });
1094
+ write('cjs.cjs', 'module.exports = {A: 1};\n');
1095
+ write(
1096
+ 'templates/account-card.tsx',
1097
+ "import {Built} from '@acme/widgets/built';\nimport {A} from '@acme/widgets/cjs';\nimport {AcmeWidget} from '@acme/widgets/components/AcmeWidget';\nexport default function C() {\n return <AcmeWidget label=\"x\" />;\n}\n",
1098
+ );
1099
+ const result = await validateIntegration(undefined, {cwd: dir});
1100
+ expect(result.data.issues).toEqual([]);
1101
+ expect(
1102
+ result.data.publicImports?.unchecked.map(u => [u.file, u.specifier]),
1103
+ ).toEqual([
1104
+ ['templates/account-card.tsx', '@acme/widgets/built'],
1105
+ ['templates/account-card.tsx', '@acme/widgets/cjs'],
1106
+ ]);
1107
+ const check = await integrationCheck({cwd: dir, strict: true});
1108
+ expect(check.data.unchecked).toBe(2);
1109
+ expect(check.data.failed).toBe(false);
1110
+ });
1111
+ });
1112
+
1113
+ describe('a root import in a package with no exports', () => {
1114
+ it('says the package has no exports map', async () => {
1115
+ pkg(p => delete p.exports);
1116
+ write(
1117
+ 'templates/account-card.tsx',
1118
+ 'import {AcmeWidget} from \'@acme/widgets\';\nexport default function C() {\n return <AcmeWidget label="x" />;\n}\n',
1119
+ );
1120
+ const result = await validateIntegration(undefined, {cwd: dir});
1121
+ const entry = result.data.publicImports?.unchecked.find(
1122
+ u => u.file === 'templates/account-card.tsx',
1123
+ );
1124
+ expect(entry?.reason).toContain('package.json has no "exports"');
1125
+ expect(entry?.reason).not.toContain('build output');
1126
+ });
1127
+ });
1128
+
1129
+ describe('a fresh scaffold passes the checks', () => {
1130
+ for (const exportsMap of [undefined, {'.': './index.js'}]) {
1131
+ it(`with ${exportsMap ? 'an' : 'no'} exports map`, async () => {
1132
+ fs.rmSync(dir, {recursive: true, force: true});
1133
+ fs.mkdirSync(dir);
1134
+ write(
1135
+ 'package.json',
1136
+ JSON.stringify({
1137
+ name: '@acme/fresh',
1138
+ version: '1.0.0',
1139
+ type: 'module',
1140
+ ...(exportsMap ? {exports: exportsMap} : {}),
1141
+ }),
1142
+ );
1143
+ write('index.js', 'export {};\n');
1144
+ await integrationAddComponent('AcmeThing', {cwd: dir});
1145
+ await integrationAddTemplate('acme-page', {cwd: dir});
1146
+ const check = await integrationCheck({cwd: dir, strict: true});
1147
+ expect(check.data.failures).toEqual([]);
1148
+ });
1149
+ }
1150
+ });
1151
+
1152
+ describe('export * cycles and repeated checks', () => {
1153
+ beforeEach(() => {
1154
+ pkg(p => {
1155
+ p.exports['./a'] = './lib/a.ts';
1156
+ p.exports['./b'] = './lib/b.ts';
1157
+ });
1158
+ write('lib/a.ts', "export const A = 1;\nexport * from './b';\n");
1159
+ write('lib/b.ts', "export * from './a';\nexport const B = 2;\n");
1160
+ });
1161
+
1162
+ it('reads every name through an export * cycle, from either side', async () => {
1163
+ write(
1164
+ 'templates/account-card.tsx',
1165
+ "import {A, B} from '@acme/widgets/a';\nimport {A as A2, B as B2} from '@acme/widgets/b';\nimport {AcmeWidget} from '@acme/widgets/components/AcmeWidget';\nexport default function C() {\n return <AcmeWidget label={String(A + B + A2 + B2)} />;\n}\n",
1166
+ );
1167
+ expect(await codes()).toEqual([]);
1168
+ });
1169
+
1170
+ it('rereads a re-exported module on a second check in the same process', async () => {
1171
+ write('lib/b.ts', 'export const B = 2;\n');
1172
+ write('lib/c.ts', "export * from './b';\n");
1173
+ pkg(p => {
1174
+ p.exports['./c'] = './lib/c.ts';
1175
+ });
1176
+ write(
1177
+ 'templates/account-card.tsx',
1178
+ "import {C} from '@acme/widgets/c';\nexport default function T() {\n return <div>{C}</div>;\n}\n",
1179
+ );
1180
+ expect(await codes()).toEqual(['template_import_missing_name']);
1181
+ // Fix only the re-exported module: c.ts itself does not change.
1182
+ write('lib/b.ts', 'export const B = 2;\nexport const C = 3;\n');
1183
+ expect(await codes()).toEqual([]);
1184
+ });
1185
+ });
1186
+
1187
+ describe('export import declarations', () => {
1188
+ it('counts `export import Alias = Inner.Value` as an exported name', async () => {
1189
+ pkg(p => {
1190
+ p.exports['./ns'] = './lib/ns.ts';
1191
+ });
1192
+ write(
1193
+ 'lib/ns.ts',
1194
+ 'namespace Inner {\n export const Value = 1;\n}\nexport const Other = 2;\nexport import Alias = Inner.Value;\n',
1195
+ );
1196
+ write(
1197
+ 'templates/account-card.tsx',
1198
+ "import {Alias} from '@acme/widgets/ns';\nexport default function C() {\n return <div>{Alias}</div>;\n}\n",
1199
+ );
1200
+ expect(await codes()).toEqual([]);
1201
+ });
1202
+
1203
+ it('leaves a module with a type-only export import unjudged', async () => {
1204
+ pkg(p => {
1205
+ p.exports['./ns'] = './lib/ns.ts';
1206
+ });
1207
+ write(
1208
+ 'lib/ns.ts',
1209
+ "export const Kept = 1;\nexport import type Alias = require('./other');\n",
1210
+ );
1211
+ write(
1212
+ 'templates/account-card.tsx',
1213
+ "import {Other} from '@acme/widgets/ns';\nexport default function C() {\n return <div>{Other}</div>;\n}\n",
1214
+ );
1215
+ const result = await validateIntegration(undefined, {cwd: dir});
1216
+ expect(
1217
+ (result.data.notes ?? []).filter(
1218
+ i => i.code === 'template_import_missing_name',
1219
+ ),
1220
+ ).toEqual([]);
1221
+ expect(
1222
+ result.data.publicImports?.unchecked.map(u => u.specifier),
1223
+ ).toContain('@acme/widgets/ns');
1224
+ });
1225
+ });
1226
+
1227
+ describe('types from a built package', () => {
1228
+ it('reads a type re-exported through export * in a declaration file', async () => {
1229
+ pkg(p => {
1230
+ p.exports['./widget'] = {
1231
+ types: './dist/widget.d.ts',
1232
+ import: './dist/widget.js',
1233
+ };
1234
+ });
1235
+ write(
1236
+ 'dist/widget.js',
1237
+ "export * from './types.js';\nexport const AcmeWidget = () => null;\n",
1238
+ );
1239
+ write(
1240
+ 'dist/widget.d.ts',
1241
+ "export * from './types.js';\nexport declare const AcmeWidget: () => null;\n",
1242
+ );
1243
+ write('dist/types.js', 'export {};\n');
1244
+ write(
1245
+ 'dist/types.d.ts',
1246
+ 'export interface AcmeWidgetProps {\n label: string;\n}\n',
1247
+ );
1248
+ write(
1249
+ 'templates/account-card.tsx',
1250
+ "import {AcmeWidget, AcmeWidgetProps} from '@acme/widgets/widget';\nexport default function C(props: AcmeWidgetProps) {\n return <AcmeWidget {...props} />;\n}\n",
1251
+ );
1252
+ expect(await codes()).toEqual([]);
1253
+ });
1254
+
1255
+ it('reads the declaration file beside a .js export target', async () => {
1256
+ pkg(p => {
1257
+ p.exports['./built'] = './dist/built.js';
1258
+ });
1259
+ write('dist/built.js', 'export const AcmeThing = 1;\n');
1260
+ write(
1261
+ 'dist/built.d.ts',
1262
+ 'export declare const AcmeThing: number;\nexport type AcmeThingSize = "sm" | "md";\n',
1263
+ );
1264
+ write(
1265
+ 'templates/account-card.tsx',
1266
+ "import {AcmeThing, AcmeThingSize} from '@acme/widgets/built';\nexport default function C() {\n const size: AcmeThingSize = 'sm';\n return <div>{AcmeThing}{size}</div>;\n}\n",
1267
+ );
1268
+ expect(await codes()).toEqual([]);
1269
+ });
1270
+
1271
+ it('still warns on a name neither the module nor its declarations export', async () => {
1272
+ pkg(p => {
1273
+ p.exports['./built'] = './dist/built.js';
1274
+ });
1275
+ write('dist/built.js', 'export const AcmeThing = 1;\n');
1276
+ write('dist/built.d.ts', 'export declare const AcmeThing: number;\n');
1277
+ write(
1278
+ 'templates/account-card.tsx',
1279
+ "import {AcmeOther} from '@acme/widgets/built';\nexport default function C() {\n return <div>{AcmeOther}</div>;\n}\n",
1280
+ );
1281
+ expect(await codes()).toEqual(['template_import_missing_name']);
1282
+ });
1283
+ });
1284
+
1285
+ describe('which Core and which package', () => {
1286
+ it("lists Core imports as not checked when the found core is a workspace's own package", async () => {
1287
+ // An npm-workspaces monorepo: its own packages/core is @acme/core, and
1288
+ // the integration lives in packages/widgets.
1289
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-workspace-'));
1290
+ try {
1291
+ const put = (/** @type {string} */ rel, /** @type {string} */ text) => {
1292
+ fs.mkdirSync(path.dirname(path.join(root, rel)), {recursive: true});
1293
+ fs.writeFileSync(path.join(root, rel), text);
1294
+ };
1295
+ put(
1296
+ 'package.json',
1297
+ JSON.stringify({
1298
+ name: 'acme-monorepo',
1299
+ private: true,
1300
+ workspaces: ['packages/*'],
1301
+ }),
1302
+ );
1303
+ put(
1304
+ 'packages/core/package.json',
1305
+ JSON.stringify({
1306
+ name: '@acme/core',
1307
+ version: '1.0.0',
1308
+ exports: {'.': './index.js'},
1309
+ }),
1310
+ );
1311
+ put(
1312
+ 'packages/widgets/package.json',
1313
+ JSON.stringify({
1314
+ name: '@acme/widgets',
1315
+ version: '1.0.0',
1316
+ type: 'module',
1317
+ exports: {'./templates/account-card': './templates/account-card.tsx'},
1318
+ }),
1319
+ );
1320
+ put(
1321
+ 'packages/widgets/astryx.integration.mjs',
1322
+ "export default {templates: './templates'};\n",
1323
+ );
1324
+ put('packages/widgets/templates/account-card.doc.mjs', BLOCK());
1325
+ put(
1326
+ 'packages/widgets/templates/account-card.tsx',
1327
+ "import {Button} from '@astryxdesign/core/Button';\nexport default function A() {\n return <Button />;\n}\n",
1328
+ );
1329
+ const result = await validateIntegration(undefined, {
1330
+ cwd: path.join(root, 'packages/widgets'),
1331
+ });
1332
+ expect(
1333
+ (result.data.notes ?? []).filter(
1334
+ i => i.code === 'template_private_import',
1335
+ ),
1336
+ ).toEqual([]);
1337
+ expect(result.data.publicImports?.unchecked).toEqual([
1338
+ expect.objectContaining({
1339
+ file: 'templates/account-card.tsx',
1340
+ specifier: '@astryxdesign/core/Button',
1341
+ }),
1342
+ ]);
1343
+ } finally {
1344
+ fs.rmSync(root, {recursive: true, force: true});
1345
+ }
1346
+ });
1347
+
1348
+ it('with no package name, runs only the checks that need none and lists the rest as not checked', async () => {
1349
+ pkg(p => delete p.name);
1350
+ write(
1351
+ 'templates/account-card.tsx',
1352
+ "import {AcmeWidget} from '../components/AcmeWidget';\nimport {X} from '@acme/widgets/nope';\nexport default function C() {\n return <AcmeWidget label={X} />;\n}\n",
1353
+ );
1354
+ const result = await validateIntegration(undefined, {cwd: dir});
1355
+ expect(await codes()).toEqual(['template_import_outside_root']);
1356
+ expect(result.data.publicImports?.status).toBe('checked');
1357
+ expect(result.data.publicImports?.unchecked).toContainEqual(
1358
+ expect.objectContaining({
1359
+ file: 'package.json',
1360
+ reason: expect.stringContaining('no "name"'),
1361
+ }),
1362
+ );
1363
+ // Nothing from the public-import check fails `integration check
1364
+ // --strict`: no skip, no warning (other checks may report the missing
1365
+ // name themselves).
1366
+ const {warnings, skipped} = strictIssues('validate', result);
1367
+ expect(
1368
+ [...warnings, ...skipped].filter(
1369
+ each => CODES.has(each.code) || each.code === 'public_imports_skipped',
1370
+ ),
1371
+ ).toEqual([]);
1372
+ });
1373
+ });
1374
+
1375
+ describe('what the export-list reader does not model is not checked', () => {
1376
+ const template = (/** @type {string} */ names, /** @type {string} */ from) =>
1377
+ `import {${names}} from '${from}';\nexport default function C() {\n return <div />;\n}\n`;
1378
+
1379
+ it('lists a name as not checked when a declared types target is not built yet', async () => {
1380
+ pkg(p => {
1381
+ p.exports['./widget'] = {
1382
+ types: './dist/widget.d.ts',
1383
+ import: './src/widget.ts',
1384
+ };
1385
+ });
1386
+ write('src/widget.ts', 'export const AcmeThing = 1;\n');
1387
+ write(
1388
+ 'templates/account-card.tsx',
1389
+ template('AcmeThing, AcmeThingProps', '@acme/widgets/widget'),
1390
+ );
1391
+ const result = await validateIntegration(undefined, {cwd: dir});
1392
+ expect(await codes()).toEqual([]);
1393
+ expect(result.data.publicImports?.unchecked).toEqual([
1394
+ expect.objectContaining({
1395
+ specifier: '@acme/widgets/widget',
1396
+ reason: expect.stringContaining('does not exist yet'),
1397
+ }),
1398
+ ]);
1399
+ });
1400
+
1401
+ it('resolves a .js specifier in TypeScript source to the .ts file beside it', async () => {
1402
+ pkg(p => {
1403
+ p.exports['./widget'] = './src/widget.ts';
1404
+ });
1405
+ write('src/widget.ts', "export * from './inner.js';\n");
1406
+ write('src/inner.ts', 'export const Inner = 1;\nexport const Newer = 2;\n');
1407
+ // A stale build of inner beside its source, without the newer export.
1408
+ write('src/inner.js', 'export const Inner = 1;\n');
1409
+ write(
1410
+ 'templates/account-card.tsx',
1411
+ template('Newer', '@acme/widgets/widget'),
1412
+ );
1413
+ expect(await codes()).toEqual([]);
1414
+ });
1415
+
1416
+ it('reads the declaration file beside a module an export * reaches', async () => {
1417
+ pkg(p => {
1418
+ p.exports['./widget'] = './dist/index.js';
1419
+ });
1420
+ write('dist/index.js', "export * from './inner.js';\n");
1421
+ write('dist/inner.js', 'export const Inner = 1;\n');
1422
+ write(
1423
+ 'dist/inner.d.ts',
1424
+ 'export declare const Inner: number;\nexport interface InnerProps {\n size: string;\n}\n',
1425
+ );
1426
+ write(
1427
+ 'templates/account-card.tsx',
1428
+ template('Inner, InnerProps', '@acme/widgets/widget'),
1429
+ );
1430
+ expect(await codes()).toEqual([]);
1431
+ });
1432
+
1433
+ it('does not judge a name a JavaScript module without declarations lacks, since it may be a type', async () => {
1434
+ pkg(p => {
1435
+ p.exports['./widget'] = './dist/widget.js';
1436
+ });
1437
+ write('dist/widget.js', 'export const AcmeThing = 1;\n');
1438
+ write(
1439
+ 'templates/account-card.tsx',
1440
+ template('AcmeThing, AcmeThingProps', '@acme/widgets/widget'),
1441
+ );
1442
+ const result = await validateIntegration(undefined, {cwd: dir});
1443
+ expect(await codes()).toEqual([]);
1444
+ expect(result.data.publicImports?.unchecked.map(u => u.specifier)).toEqual([
1445
+ '@acme/widgets/widget',
1446
+ ]);
1447
+ });
1448
+ });
1449
+
1450
+ describe('declaration files follow TypeScript', () => {
1451
+ const template = (/** @type {string} */ names) =>
1452
+ `import {${names}} from '@acme/widgets/widget';\nexport default function C() {\n return <div />;\n}\n`;
1453
+
1454
+ beforeEach(() => {
1455
+ pkg(p => {
1456
+ p.exports['./widget'] = './dist/widget.js';
1457
+ });
1458
+ write('dist/widget.js', 'export const AcmeWidget = () => null;\n');
1459
+ });
1460
+
1461
+ it('counts every top-level declaration of a declaration-file module as exported', async () => {
1462
+ write(
1463
+ 'dist/widget.d.ts',
1464
+ 'interface AcmeWidgetProps {\n label: string;\n}\nexport declare const AcmeWidget: (p: AcmeWidgetProps) => null;\n',
1465
+ );
1466
+ write(
1467
+ 'templates/account-card.tsx',
1468
+ template('AcmeWidget, AcmeWidgetProps'),
1469
+ );
1470
+ expect(await codes()).toEqual([]);
1471
+ });
1472
+
1473
+ it('exports only what is listed once the declaration file has an export list', async () => {
1474
+ write(
1475
+ 'dist/widget.d.ts',
1476
+ 'interface AcmeWidgetProps {\n label: string;\n}\ndeclare const AcmeWidget: (p: AcmeWidgetProps) => null;\nexport {AcmeWidget};\n',
1477
+ );
1478
+ write(
1479
+ 'templates/account-card.tsx',
1480
+ template('AcmeWidget, AcmeWidgetProps'),
1481
+ );
1482
+ expect(await codes()).toEqual(['template_import_missing_name']);
1483
+ });
1484
+
1485
+ // A `declare module './widget.js'` block in a file widget.d.ts loads adds
1486
+ // names widget.d.ts itself does not list.
1487
+ const AUGMENT =
1488
+ "import './widget.js';\ndeclare module './widget.js' {\n interface AcmeWidgetSize {\n value: string;\n }\n}\nexport {};\n";
1489
+ const LISTED =
1490
+ 'declare const AcmeWidget: () => null;\nexport {AcmeWidget};\n';
1491
+
1492
+ for (const [form, head] of [
1493
+ ['a side-effect import', "import './augment.js';\n"],
1494
+ ['a reference directive', '/// <reference path="./augment.d.ts" />\n'],
1495
+ ]) {
1496
+ it(`does not report a name added by augmentation in a file loaded through ${form}`, async () => {
1497
+ write('dist/augment.d.ts', AUGMENT);
1498
+ write('dist/widget.d.ts', `${head}${LISTED}`);
1499
+ write(
1500
+ 'templates/account-card.tsx',
1501
+ template('AcmeWidget, AcmeWidgetSize'),
1502
+ );
1503
+ const result = await validateIntegration(undefined, {cwd: dir});
1504
+ expect(await codes()).toEqual([]);
1505
+ expect(
1506
+ result.data.publicImports?.unchecked.map(u => u.specifier),
1507
+ ).toEqual(['@acme/widgets/widget']);
1508
+ });
1509
+ }
1510
+
1511
+ it('still reports a missing name when no file in the package augments a module', async () => {
1512
+ write('dist/widget.d.ts', LISTED);
1513
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1514
+ expect(await codes()).toEqual(['template_import_missing_name']);
1515
+ });
1516
+
1517
+ it.each([
1518
+ [
1519
+ 'a `declare module` example in a JSDoc comment',
1520
+ "/**\n * @example\n * declare module './widget.js' {\n * interface AcmeWidgetSize {}\n * }\n */\nexport type AcmeTone = 'a';\n",
1521
+ ],
1522
+ [
1523
+ 'a wildcard `declare module`',
1524
+ "declare module '*.doc.mjs' {\n const doc: unknown;\n export default doc;\n}\n",
1525
+ ],
1526
+ [
1527
+ 'a `declare global` block',
1528
+ 'declare global {\n interface AcmeGlobal {}\n}\nexport {};\n',
1529
+ ],
1530
+ [
1531
+ "an augmentation of another package's module",
1532
+ "declare module '@astryxdesign/core' {\n interface AcmeTone {}\n}\nexport {};\n",
1533
+ ],
1534
+ ])('still reports a missing name beside %s', async (_label, text) => {
1535
+ write('dist/augment.d.ts', text);
1536
+ write('dist/widget.d.ts', LISTED);
1537
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1538
+ expect(await codes()).toEqual(['template_import_missing_name']);
1539
+ });
1540
+
1541
+ it("lists a name as not checked when a file augments the package's own name", async () => {
1542
+ write(
1543
+ 'types/augment.d.ts',
1544
+ "declare module '@acme/widgets/widget' {\n interface AcmeWidgetSize {}\n}\nexport {};\n",
1545
+ );
1546
+ write('dist/widget.d.ts', LISTED);
1547
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1548
+ const result = await validateIntegration(undefined, {cwd: dir});
1549
+ expect(await codes()).toEqual([]);
1550
+ expect(
1551
+ result.data.publicImports?.unchecked.map(u => u.reason).join('\n'),
1552
+ ).toContain('"types/augment.d.ts"');
1553
+ });
1554
+
1555
+ it('lists a name as not checked whenever any file in the package augments a module, even one nothing loads', async () => {
1556
+ write('dist/augment.d.ts', AUGMENT);
1557
+ write('dist/widget.d.ts', LISTED);
1558
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1559
+ const result = await validateIntegration(undefined, {cwd: dir});
1560
+ expect(await codes()).toEqual([]);
1561
+ expect(result.data.publicImports?.unchecked).toEqual([
1562
+ expect.objectContaining({
1563
+ specifier: '@acme/widgets/widget',
1564
+ reason: expect.stringContaining('"dist/augment.d.ts"'),
1565
+ }),
1566
+ ]);
1567
+ });
1568
+
1569
+ it('lists a name as not checked when the module re-exports from the augmenting file', async () => {
1570
+ write('dist/augment.d.ts', AUGMENT);
1571
+ write(
1572
+ 'dist/widget.d.ts',
1573
+ `${LISTED}export {AcmeWidgetTone} from './augment.js';\n`,
1574
+ );
1575
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1576
+ const result = await validateIntegration(undefined, {cwd: dir});
1577
+ expect(await codes()).toEqual([]);
1578
+ expect(
1579
+ result.data.publicImports?.unchecked.map(u => u.reason).join('\n'),
1580
+ ).toContain('"dist/augment.d.ts"');
1581
+ });
1582
+
1583
+ it('lists a name as not checked when the augmentation is reached through another declaration file', async () => {
1584
+ write('dist/augment.d.ts', AUGMENT);
1585
+ write(
1586
+ 'dist/types.d.ts',
1587
+ "import './augment.js';\nexport type AcmeTone = 'a';\n",
1588
+ );
1589
+ write(
1590
+ 'dist/widget.d.ts',
1591
+ "import type {AcmeTone} from './types.js';\ndeclare const AcmeWidget: (t: AcmeTone) => null;\nexport {AcmeWidget};\n",
1592
+ );
1593
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1594
+ const result = await validateIntegration(undefined, {cwd: dir});
1595
+ expect(await codes()).toEqual([]);
1596
+ expect(
1597
+ result.data.publicImports?.unchecked.map(u => u.reason).join('\n'),
1598
+ ).toContain('"dist/augment.d.ts"');
1599
+ });
1600
+
1601
+ it('lists a name as not checked when TypeScript source augments the module it exports', async () => {
1602
+ pkg(p => {
1603
+ p.exports['./widget'] = './src/widget.ts';
1604
+ });
1605
+ write(
1606
+ 'src/widget.ts',
1607
+ "import './augment.js';\nexport const AcmeWidget = () => null;\n",
1608
+ );
1609
+ write(
1610
+ 'src/augment.ts',
1611
+ "declare module './widget.js' {\n export interface AcmeWidgetSize {\n value: string;\n }\n}\nexport {};\n",
1612
+ );
1613
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1614
+ const result = await validateIntegration(undefined, {cwd: dir});
1615
+ expect(await codes()).toEqual([]);
1616
+ expect(
1617
+ result.data.publicImports?.unchecked.map(u => u.reason).join('\n'),
1618
+ ).toContain('"src/augment.ts"');
1619
+ });
1620
+
1621
+ it('reads a `.ts` file with an angle-bracket assertion for its augmentation', async () => {
1622
+ write(
1623
+ 'src/augment.ts',
1624
+ "declare const value: unknown;\nconst s = <string>value;\ndeclare module './widget.js' {\n interface AcmeWidgetSize {}\n}\nexport {s};\n",
1625
+ );
1626
+ write('dist/widget.d.ts', LISTED);
1627
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1628
+ const result = await validateIntegration(undefined, {cwd: dir});
1629
+ expect(await codes()).toEqual([]);
1630
+ expect(result.data.publicImports?.unchecked).toEqual([
1631
+ expect.objectContaining({
1632
+ specifier: '@acme/widgets/widget',
1633
+ reason: expect.stringContaining('"src/augment.ts"'),
1634
+ }),
1635
+ ]);
1636
+ });
1637
+
1638
+ it('counts a file with `declare module` that does not parse as augmenting', async () => {
1639
+ write(
1640
+ 'src/augment.ts',
1641
+ "declare module './widget.js' {\n interface AcmeWidgetSize {\n",
1642
+ );
1643
+ write('dist/widget.d.ts', LISTED);
1644
+ write('templates/account-card.tsx', template('AcmeWidget, AcmeWidgetSize'));
1645
+ const result = await validateIntegration(undefined, {cwd: dir});
1646
+ expect(await codes()).toEqual([]);
1647
+ expect(
1648
+ result.data.publicImports?.unchecked.map(u => u.reason).join('\n'),
1649
+ ).toContain('"src/augment.ts"');
1650
+ });
1651
+
1652
+ it('leaves a declaration file with no import or export (a global script) unchecked', async () => {
1653
+ write(
1654
+ 'dist/widget.d.ts',
1655
+ 'interface AcmeWidgetProps {\n label: string;\n}\ndeclare const AcmeWidget: (p: AcmeWidgetProps) => null;\n',
1656
+ );
1657
+ write(
1658
+ 'templates/account-card.tsx',
1659
+ template('AcmeWidget, AcmeWidgetProps'),
1660
+ );
1661
+ const result = await validateIntegration(undefined, {cwd: dir});
1662
+ expect(await codes()).toEqual([]);
1663
+ expect(result.data.publicImports?.unchecked.map(u => u.specifier)).toEqual([
1664
+ '@acme/widgets/widget',
1665
+ ]);
1666
+ });
1667
+ });
1668
+
1669
+ describe('declaration files: default exports', () => {
1670
+ const template = (/** @type {string} */ names) =>
1671
+ `import {${names}} from '@acme/widgets/widget';\nexport default function C() {\n return <div />;\n}\n`;
1672
+ const HEAD =
1673
+ 'interface AcmeWidgetProps {\n label: string;\n}\nexport declare const AcmeWidget: (p: AcmeWidgetProps) => null;\n';
1674
+
1675
+ beforeEach(() => {
1676
+ pkg(p => {
1677
+ p.exports['./widget'] = './dist/widget.js';
1678
+ });
1679
+ write('dist/widget.js', 'export const AcmeWidget = () => null;\n');
1680
+ write(
1681
+ 'templates/account-card.tsx',
1682
+ template('AcmeWidget, AcmeWidgetProps'),
1683
+ );
1684
+ });
1685
+
1686
+ for (const [form, tail] of [
1687
+ ['export default function', 'export default function f(): void;\n'],
1688
+ ['export default class', 'export default class K {}\n'],
1689
+ ['export default interface', 'export default interface I {\n a: 1;\n}\n'],
1690
+ ]) {
1691
+ it(`keeps implicit exports after ${form}`, async () => {
1692
+ write('dist/widget.d.ts', HEAD + tail);
1693
+ expect(await codes()).toEqual([]);
1694
+ });
1695
+ }
1696
+
1697
+ for (const [form, tail] of [
1698
+ [
1699
+ 'export default <expression>',
1700
+ 'declare const d: number;\nexport default d;\n',
1701
+ ],
1702
+ ['export {} list', 'export {};\n'],
1703
+ ['export … from', "export {X} from './x';\n"],
1704
+ ['export *', "export * from './x';\n"],
1705
+ ]) {
1706
+ it(`ends implicit exports at ${form}`, async () => {
1707
+ write('dist/widget.d.ts', HEAD + tail);
1708
+ write('dist/x.d.ts', 'export declare const X: number;\n');
1709
+ expect(await codes()).toEqual(['template_import_missing_name']);
1710
+ });
1711
+ }
1712
+ });
1713
+
1714
+ describe('the default-export fix names a value', () => {
1715
+ it('prefers an exported function or class declaration', async () => {
1716
+ write(
1717
+ 'templates/account-card.tsx',
1718
+ 'export const ACCOUNT_LIMIT = 3;\nexport const Size = "sm";\nexport class AccountCard {}\n',
1719
+ );
1720
+ const [issue] = await found();
1721
+ expect(issue?.message).toContain('export default AccountCard;');
1722
+ });
1723
+
1724
+ it('skips ALL-CAPS names and falls back to the generic fix with none left', async () => {
1725
+ write(
1726
+ 'templates/account-card.tsx',
1727
+ 'export const ACCOUNT_LIMIT = 3;\nexport const API = 1;\n',
1728
+ );
1729
+ const [issue] = await found();
1730
+ expect(issue?.message).not.toContain('export default ACCOUNT_LIMIT');
1731
+ expect(issue?.message).toContain('`export default function …`');
1732
+ });
1733
+
1734
+ it('names no hook, helper or type-only name, and falls back to the generic fix', async () => {
1735
+ write(
1736
+ 'templates/account-card.tsx',
1737
+ 'export function useAccount() {\n return null;\n}\nexport function formatBalance(n: number) {\n return String(n);\n}\nexport const ACCOUNT_LIMIT = 3;\nexport type AccountCardProps = {title: string};\nexport interface AccountCardSize {\n value: string;\n}\n',
1738
+ );
1739
+ const [issue] = await found();
1740
+ expect(issue?.code).toBe('template_no_default_export');
1741
+ expect(issue?.message).not.toContain('export default useAccount;');
1742
+ expect(issue?.message).not.toContain('formatBalance');
1743
+ expect(issue?.message).not.toContain('export default AccountCard');
1744
+ expect(issue?.message).toContain('`export default function …`');
1745
+ });
1746
+
1747
+ it('suggests a PascalCase const when no function or class fits', async () => {
1748
+ write(
1749
+ 'templates/account-card.tsx',
1750
+ 'export function useAccount() {\n return null;\n}\nexport const AccountCard = () => null;\n',
1751
+ );
1752
+ const [issue] = await found();
1753
+ expect(issue?.message).toContain('export default AccountCard;');
1754
+ });
1755
+
1756
+ it('names no public import when package.json has no name', async () => {
1757
+ pkg(p => delete p.name);
1758
+ write(
1759
+ 'templates/account-card.tsx',
1760
+ 'export function AccountCard() {\n return null;\n}\n',
1761
+ );
1762
+ const result = await validateLocalIntegration(dir);
1763
+ const note = (result.notes ?? []).find(
1764
+ n => n.code === 'template_no_default_export',
1765
+ );
1766
+ expect(note?.message).toContain(
1767
+ 'anything that renders the template gets nothing',
1768
+ );
1769
+ expect(note?.message).not.toContain('null/');
1770
+ });
1771
+ });
1772
+
1773
+ describe('a doc name that differs from its file name', () => {
1774
+ it('leaves the export-name judgment to the doc-name check', async () => {
1775
+ write(
1776
+ 'components/AcmeWidget.doc.mjs',
1777
+ "export default {type: 'component', name: 'AcmeWidgets', import: '@acme/widgets/components/AcmeWidget', usage: {description: 'A widget.'}, props: []};\n",
1778
+ );
1779
+ write(
1780
+ 'components/AcmeWidget.tsx',
1781
+ "export {AcmeWidgets} from '@acme/ui';\n",
1782
+ );
1783
+ const result = await validateIntegration(undefined, {cwd: dir});
1784
+ expect(
1785
+ (await codes()).filter(code => code === 'component_import_missing_name'),
1786
+ ).toEqual([]);
1787
+ expect(
1788
+ (result.data.publicImports?.unchecked ?? []).filter(
1789
+ u => u.file === 'components/AcmeWidget.doc.mjs',
1790
+ ),
1791
+ ).toEqual([]);
1792
+ });
1793
+ });