@astryxdesign/cli 0.6.4-canary.f0355e3 → 0.6.4

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 (331) hide show
  1. package/README.md +96 -99
  2. package/api/build/build.doc.mjs +1 -6
  3. package/api/build/build.test.mjs +0 -22
  4. package/api/build/kit/kit.mjs +5 -44
  5. package/api/component/_adapter.d.mts +0 -25
  6. package/api/component/_adapter.mjs +5 -59
  7. package/api/component/component.d.mts +3 -6
  8. package/api/component/component.doc.mjs +17 -37
  9. package/api/component/component.mjs +9 -249
  10. package/api/component/component.type.d.mts +0 -25
  11. package/api/component/component.type.mjs +0 -44
  12. package/api/discover/_adapter.d.mts +6 -114
  13. package/api/discover/_adapter.mjs +17 -372
  14. package/api/discover/detail/detail.d.mts +6 -18
  15. package/api/discover/detail/detail.mjs +13 -67
  16. package/api/discover/detail/detail.test.mjs +0 -85
  17. package/api/discover/discover.d.mts +9 -3
  18. package/api/discover/discover.doc.mjs +18 -61
  19. package/api/discover/discover.mjs +36 -220
  20. package/api/discover/discover.test.mjs +2 -11
  21. package/api/discover/discover.type.d.mts +8 -147
  22. package/api/discover/discover.type.mjs +12 -102
  23. package/api/discover/list/list.d.mts +6 -20
  24. package/api/discover/list/list.mjs +12 -45
  25. package/api/discover/list/list.test.mjs +0 -46
  26. package/api/discover/search/search.d.mts +16 -18
  27. package/api/discover/search/search.mjs +56 -102
  28. package/api/discover/search/search.test.mjs +10 -144
  29. package/api/docs/_adapter.d.mts +3 -8
  30. package/api/docs/_adapter.mjs +6 -14
  31. package/api/docs/docOverlays.test.mjs +1 -27
  32. package/api/docs/docs.doc.mjs +2 -2
  33. package/api/docs/docs.test.mjs +243 -0
  34. package/api/docs/integration-tree.test.mjs +555 -0
  35. package/api/docs/integrationDocs.test.mjs +314 -0
  36. package/api/doctor/doctor.d.mts +3 -8
  37. package/api/doctor/doctor.doc.mjs +8 -17
  38. package/api/doctor/doctor.mjs +9 -90
  39. package/api/doctor/doctor.test.mjs +10 -122
  40. package/api/doctor/doctor.type.d.mts +1 -1
  41. package/api/doctor/doctor.type.mjs +1 -1
  42. package/api/gap-report/gap-report.doc.mjs +10 -19
  43. package/api/hook/hook.doc.mjs +3 -6
  44. package/api/index.d.mts +2 -1
  45. package/api/index.mjs +5 -5
  46. package/api/init/init.doc.mjs +12 -17
  47. package/api/integration/add-helpers.d.mts +2 -5
  48. package/api/integration/add-helpers.mjs +9 -36
  49. package/api/integration/add-theme.mjs +1 -22
  50. package/api/integration/add-theme.test.mjs +0 -34
  51. package/api/integration/authoring-checks.mjs +2 -2
  52. package/api/integration/integrationPackCheck.doc.mjs +3 -3
  53. package/api/integration/pack-check.mjs +9 -82
  54. package/api/integration/pack-check.test.mjs +0 -90
  55. package/api/integration/pack-check.type.mjs +1 -1
  56. package/api/json/assertResponse.doc.mjs +1 -1
  57. package/api/json/index.ts +1 -0
  58. package/api/json/isError.doc.mjs +1 -1
  59. package/api/layout/_adapter.d.mts +34 -0
  60. package/api/layout/_adapter.mjs +148 -0
  61. package/api/layout/check/check.d.mts +16 -0
  62. package/api/layout/check/check.mjs +40 -0
  63. package/api/layout/expand/expand.d.mts +22 -0
  64. package/api/layout/expand/expand.mjs +155 -0
  65. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  66. package/api/layout/grammar/grammar.d.mts +13 -0
  67. package/api/layout/grammar/grammar.mjs +87 -0
  68. package/api/layout/layout.d.mts +6 -0
  69. package/api/layout/layout.mjs +17 -0
  70. package/api/layout/layout.test.mjs +297 -0
  71. package/api/layout/layout.type.d.mts +89 -0
  72. package/api/layout/layout.type.mjs +103 -0
  73. package/api/layout/layoutCheck.doc.d.mts +11 -0
  74. package/api/layout/layoutCheck.doc.mjs +85 -0
  75. package/api/layout/layoutExpand.doc.d.mts +11 -0
  76. package/api/layout/layoutExpand.doc.mjs +107 -0
  77. package/api/layout/layoutGrammar.doc.d.mts +11 -0
  78. package/api/layout/layoutGrammar.doc.mjs +57 -0
  79. package/api/search/search.d.mts +1 -27
  80. package/api/search/search.doc.mjs +2 -2
  81. package/api/search/search.mjs +16 -228
  82. package/api/search/search.test.mjs +512 -0
  83. package/api/swizzle/swizzle.doc.mjs +5 -7
  84. package/api/template/copy/copy.mjs +1 -1
  85. package/api/template/copy/copy.test.mjs +0 -9
  86. package/api/template/template-integration.test.mjs +65 -1
  87. package/api/template/template.doc.mjs +1 -2
  88. package/api/template/template.mjs +1 -1
  89. package/api/theme/add/add.mjs +25 -17
  90. package/api/theme/add/add.staging.test.mjs +23 -40
  91. package/api/theme/build/build.family.test.mjs +12 -7
  92. package/api/theme/build/build.mjs +18 -8
  93. package/api/theme/generateTonalPalette.doc.mjs +2 -1
  94. package/api/theme/listThemes.doc.mjs +1 -1
  95. package/api/theme/themeAdd.doc.mjs +10 -9
  96. package/api/theme/themeBuild.doc.mjs +13 -13
  97. package/api/theme/themeList.doc.mjs +1 -1
  98. package/api/theme/themeListAvailable.doc.mjs +1 -2
  99. package/api/theme/themePaletteGenerate.doc.mjs +8 -15
  100. package/api/theme/themeTargets.doc.mjs +2 -3
  101. package/api/theme/themeTemplate.doc.mjs +1 -2
  102. package/api/upgrade/run/run.mjs +4 -6
  103. package/api/upgrade/upgrade.doc.mjs +22 -24
  104. package/api/upgrade/upgrade.type.mjs +2 -2
  105. package/assets/codemods/__tests__/runner.test.mjs +1 -3
  106. package/assets/codemods/integration-runner.mjs +3 -3
  107. package/assets/codemods/runner.mjs +4 -5
  108. package/assets/docs/README.md +2 -4
  109. package/assets/docs/browser-support.doc.mjs +11 -11
  110. package/assets/docs/color.doc.mjs +2 -8
  111. package/assets/docs/elevation.doc.mjs +4 -6
  112. package/assets/docs/getting-started.doc.mjs +16 -5
  113. package/assets/docs/icons.doc.mjs +21 -2
  114. package/assets/docs/illustrations.doc.mjs +15 -7
  115. package/assets/docs/internationalization.doc.mjs +5 -7
  116. package/assets/docs/layout.doc.dense.mjs +82 -130
  117. package/assets/docs/layout.doc.mjs +77 -133
  118. package/assets/docs/migration.doc.mjs +21 -19
  119. package/assets/docs/motion.doc.mjs +3 -16
  120. package/assets/docs/principles.doc.dense.mjs +5 -5
  121. package/assets/docs/principles.doc.mjs +0 -8
  122. package/assets/docs/principles.doc.zh.mjs +6 -6
  123. package/assets/docs/shape.doc.mjs +3 -8
  124. package/assets/docs/spacing.doc.mjs +2 -7
  125. package/assets/docs/styling-libraries.doc.mjs +2 -6
  126. package/assets/docs/styling.doc.mjs +23 -19
  127. package/assets/docs/theme.doc.dense.mjs +18 -58
  128. package/assets/docs/theme.doc.mjs +46 -56
  129. package/assets/docs/theme.doc.zh.mjs +8 -9
  130. package/assets/docs/tokens.doc.dense.mjs +2 -2
  131. package/assets/docs/tokens.doc.mjs +8 -389
  132. package/assets/docs/tokens.doc.zh.mjs +2 -2
  133. package/assets/docs/tree/integrations.doc.mjs +451 -25
  134. package/assets/docs/tree/integrations.test.mjs +62 -0
  135. package/assets/docs/tree/writing-docs.doc.mjs +286 -0
  136. package/assets/docs/typography.doc.mjs +4 -24
  137. package/assets/docs/working-with-ai.doc.mjs +22 -30
  138. package/assets/templates/blocks/components/InternationalizationProvider/InternationalizationProvider01ShippedLocale.tsx +1 -1
  139. package/authoring/config/config.doc.mjs +2 -10
  140. package/authoring/config/parse.d.mts +0 -2
  141. package/authoring/config/parse.mjs +0 -19
  142. package/authoring/config/parse.test.mjs +0 -8
  143. package/authoring/config/type.ts +2 -13
  144. package/authoring/doctypes/_schema.d.mts +2 -3
  145. package/authoring/doctypes/_schema.mjs +0 -6
  146. package/authoring/doctypes/base/graph-fields.doc.mjs +3 -3
  147. package/authoring/doctypes/base/type.ts +2 -4
  148. package/authoring/doctypes/command/command.doc.mjs +1 -1
  149. package/authoring/doctypes/command/type.ts +1 -1
  150. package/authoring/doctypes/component/component.doc.mjs +0 -6
  151. package/authoring/doctypes/component/type.ts +0 -8
  152. package/authoring/doctypes/reference/reference.doc.mjs +0 -7
  153. package/authoring/doctypes/reference/type.ts +0 -5
  154. package/authoring/doctypes/schema/schema.doc.mjs +2 -2
  155. package/authoring/doctypes/template/template.doc.mjs +1 -1
  156. package/authoring/doctypes/template/type.ts +2 -2
  157. package/authoring/index.d.mts +0 -1
  158. package/authoring/index.d.ts +0 -10
  159. package/authoring/index.mjs +0 -1
  160. package/authoring/integration/integration.doc.mjs +10 -12
  161. package/clients/cli/command-result-coverage.test.mjs +7 -7
  162. package/clients/cli/commands/component/index.mjs +55 -152
  163. package/clients/cli/commands/component-ownership.test.mjs +0 -89
  164. package/clients/cli/commands/component.doc.mjs +9 -27
  165. package/clients/cli/commands/debug-result-summary.test.mjs +2 -2
  166. package/clients/cli/commands/discover.doc.mjs +9 -53
  167. package/clients/cli/commands/discover.mjs +118 -393
  168. package/clients/cli/commands/docs.doc.mjs +1 -1
  169. package/clients/cli/commands/docs.mjs +17 -60
  170. package/clients/cli/commands/docs.test.mjs +294 -0
  171. package/clients/cli/commands/doctor-integration-docs.doc.mjs +2 -3
  172. package/clients/cli/commands/doctor-integration.test.mjs +0 -53
  173. package/clients/cli/commands/doctor.doc.mjs +1 -3
  174. package/clients/cli/commands/doctor.mjs +5 -49
  175. package/clients/cli/commands/gap-report.doc.mjs +9 -10
  176. package/clients/cli/commands/init.doc.mjs +6 -9
  177. package/clients/cli/commands/integration-add.doc.mjs +9 -9
  178. package/clients/cli/commands/integration-authoring.test.mjs +10 -61
  179. package/clients/cli/commands/integration-pack.doc.mjs +9 -5
  180. package/clients/cli/commands/integration-real-world.test.mjs +1 -1
  181. package/clients/cli/commands/integration.doc.mjs +4 -4
  182. package/clients/cli/commands/integration.mjs +43 -74
  183. package/clients/cli/commands/layout-check.doc.mjs +65 -0
  184. package/clients/cli/commands/layout-expand.doc.mjs +83 -0
  185. package/clients/cli/commands/layout-grammar.doc.mjs +30 -0
  186. package/clients/cli/commands/layout.doc.mjs +34 -0
  187. package/clients/cli/commands/layout.error-codes.test.mjs +66 -0
  188. package/clients/cli/commands/layout.exit-parity.test.mjs +41 -0
  189. package/clients/cli/commands/layout.mjs +275 -0
  190. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  191. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  192. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  193. package/clients/cli/commands/manifest.doc.mjs +1 -1
  194. package/clients/cli/commands/search.doc.mjs +3 -10
  195. package/clients/cli/commands/search.mjs +2 -21
  196. package/clients/cli/commands/search.test.mjs +4 -21
  197. package/clients/cli/commands/swizzle.doc.mjs +1 -1
  198. package/clients/cli/commands/template.doc.mjs +1 -1
  199. package/clients/cli/commands/text-json-parity.test.mjs +16 -5
  200. package/clients/cli/commands/theme-add.doc.mjs +1 -1
  201. package/clients/cli/commands/theme-palette-generate.doc.mjs +2 -3
  202. package/clients/cli/commands/theme-palette.doc.mjs +2 -1
  203. package/clients/cli/commands/theme-targets.doc.mjs +2 -2
  204. package/clients/cli/commands/theme.doc.mjs +1 -2
  205. package/clients/cli/commands/upgrade.doc.mjs +3 -62
  206. package/clients/cli/index.mjs +10 -28
  207. package/clients/cli/lib/define-command.mjs +4 -28
  208. package/clients/cli/lib/define-command.test.mjs +0 -54
  209. package/clients/cli/lib/exit-codes.test.mjs +9 -18
  210. package/clients/cli/lib/json-shim.mjs +14 -24
  211. package/clients/cli/lib/json-shim.test.mjs +20 -6
  212. package/clients/cli/lib/manifest.mjs +13 -18
  213. package/clients/cli/lib/manifest.test.mjs +2 -5
  214. package/foundation/agent-docs/agent-docs.mjs +1 -1
  215. package/foundation/agent-docs/agent-docs.test.mjs +1159 -0
  216. package/foundation/discovery/authoring-self-docs.mjs +0 -1
  217. package/foundation/discovery/authoring-self-docs.test.mjs +2 -6
  218. package/foundation/discovery/cli-self-docs.mjs +2 -16
  219. package/foundation/discovery/cli-self-docs.test.mjs +0 -20
  220. package/foundation/discovery/docs-discovery.mjs +1 -5
  221. package/foundation/discovery/docs-discovery.test.mjs +0 -21
  222. package/foundation/discovery/docs-section-key.d.mts +1 -1
  223. package/foundation/discovery/docs-section-key.mjs +1 -1
  224. package/foundation/discovery/template-adapter.mjs +1 -1
  225. package/foundation/doc-compiler/doc-loads.test.mjs +14 -3
  226. package/foundation/doc-compiler/tree.d.mts +0 -4
  227. package/foundation/doc-compiler/tree.mjs +1 -6
  228. package/foundation/doc-compiler/tree.test.mjs +598 -0
  229. package/foundation/integrations/cli-requirement.d.mts +6 -26
  230. package/foundation/integrations/cli-requirement.mjs +11 -46
  231. package/foundation/integrations/cli-requirement.test.mjs +2 -7
  232. package/foundation/integrations/contribution-inventory.mjs +1 -1
  233. package/foundation/integrations/integrations.d.mts +1 -14
  234. package/foundation/integrations/integrations.mjs +1 -41
  235. package/foundation/integrations/integrations.test.mjs +0 -31
  236. package/foundation/response/error-codes.doc.mjs +8 -6
  237. package/foundation/response/error-codes.test.mjs +5 -30
  238. package/foundation/response/response-types.doc.d.mts +3 -4
  239. package/foundation/response/response-types.doc.mjs +27 -40
  240. package/foundation/response/response-types.doc.test.mjs +0 -23
  241. package/foundation/response/response.doc.mjs +10 -11
  242. package/foundation/xle/browser.d.mts +3 -3
  243. package/foundation/xle/browser.mjs +3 -3
  244. package/foundation/xle/expand.mjs +2 -2
  245. package/foundation/xle/parse.mjs +1 -1
  246. package/foundation/xle/print.mjs +2 -2
  247. package/foundation/xle/splice.mjs +1 -1
  248. package/package.json +9 -9
  249. package/api/discover/_adapter.test.mjs +0 -215
  250. package/api/discover/_catalog-view.d.mts +0 -115
  251. package/api/discover/_catalog-view.mjs +0 -203
  252. package/api/discover/_catalog-view.test.mjs +0 -128
  253. package/api/discover/detail/item/item.d.mts +0 -26
  254. package/api/discover/detail/item/item.mjs +0 -78
  255. package/api/discover/detail/item/item.test.mjs +0 -73
  256. package/api/integration/pack-check.lifecycle-output.test.mjs +0 -107
  257. package/api/theme/add/add.rollback.test.mjs +0 -158
  258. package/api/theme/build/build.rollback.test.mjs +0 -148
  259. package/api/upgrade/run/files-changed.test.mjs +0 -111
  260. package/assets/codemods/file-count.test.mjs +0 -163
  261. package/assets/docs/tree/add-a-component.doc.mjs +0 -75
  262. package/assets/docs/tree/add-a-theme.doc.mjs +0 -85
  263. package/assets/docs/tree/add-a-topic.doc.mjs +0 -144
  264. package/assets/docs/tree/agent-guidance.doc.mjs +0 -138
  265. package/assets/docs/tree/block-template.doc.mjs +0 -130
  266. package/assets/docs/tree/build-the-template.doc.mjs +0 -28
  267. package/assets/docs/tree/building-blocks.doc.mjs +0 -46
  268. package/assets/docs/tree/check-your-docs.doc.mjs +0 -137
  269. package/assets/docs/tree/checks.doc.mjs +0 -119
  270. package/assets/docs/tree/codemods.doc.mjs +0 -147
  271. package/assets/docs/tree/component-family.doc.mjs +0 -113
  272. package/assets/docs/tree/component-imports.doc.mjs +0 -69
  273. package/assets/docs/tree/component-lookups.doc.mjs +0 -149
  274. package/assets/docs/tree/components.doc.mjs +0 -23
  275. package/assets/docs/tree/configuration.doc.mjs +0 -23
  276. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +0 -182
  277. package/assets/docs/tree/define-the-theme.doc.mjs +0 -118
  278. package/assets/docs/tree/describe-the-component.doc.mjs +0 -57
  279. package/assets/docs/tree/docs.doc.mjs +0 -21
  280. package/assets/docs/tree/document-the-template.doc.mjs +0 -28
  281. package/assets/docs/tree/document-the-theme.doc.mjs +0 -68
  282. package/assets/docs/tree/export-template-assets.doc.mjs +0 -147
  283. package/assets/docs/tree/extend-or-replace.doc.mjs +0 -103
  284. package/assets/docs/tree/fonts-and-assets.doc.mjs +0 -106
  285. package/assets/docs/tree/generate-a-palette.doc.mjs +0 -66
  286. package/assets/docs/tree/grade-template-with-agent.doc.mjs +0 -105
  287. package/assets/docs/tree/help.doc.mjs +0 -16
  288. package/assets/docs/tree/links.doc.mjs +0 -98
  289. package/assets/docs/tree/package-and-test.doc.mjs +0 -32
  290. package/assets/docs/tree/page-template.doc.mjs +0 -71
  291. package/assets/docs/tree/publishing.doc.mjs +0 -111
  292. package/assets/docs/tree/quick-start.doc.mjs +0 -272
  293. package/assets/docs/tree/replace-a-core-component.doc.mjs +0 -104
  294. package/assets/docs/tree/replace-a-core-template.doc.mjs +0 -172
  295. package/assets/docs/tree/sections-and-placement.doc.mjs +0 -108
  296. package/assets/docs/tree/see-it-in-an-app.doc.mjs +0 -59
  297. package/assets/docs/tree/ship.doc.mjs +0 -16
  298. package/assets/docs/tree/short-and-findable.doc.mjs +0 -108
  299. package/assets/docs/tree/single-component.doc.mjs +0 -165
  300. package/assets/docs/tree/start-a-template.doc.mjs +0 -143
  301. package/assets/docs/tree/subcomponent.doc.mjs +0 -115
  302. package/assets/docs/tree/template-assets.doc.mjs +0 -64
  303. package/assets/docs/tree/template-doc-overview.doc.mjs +0 -109
  304. package/assets/docs/tree/template-fonts.doc.mjs +0 -102
  305. package/assets/docs/tree/template-grading-rubric.doc.mjs +0 -452
  306. package/assets/docs/tree/template-icons.doc.mjs +0 -97
  307. package/assets/docs/tree/template-images-media.doc.mjs +0 -127
  308. package/assets/docs/tree/template-styles.doc.mjs +0 -93
  309. package/assets/docs/tree/templates.doc.mjs +0 -34
  310. package/assets/docs/tree/test-in-an-app.doc.mjs +0 -115
  311. package/assets/docs/tree/test-template-in-app.doc.mjs +0 -128
  312. package/assets/docs/tree/themes.doc.mjs +0 -39
  313. package/assets/docs/tree/troubleshooting.doc.mjs +0 -149
  314. package/assets/docs/tree/upgrading.doc.mjs +0 -103
  315. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +0 -51
  316. package/assets/docs/tree/verify-packed-template.doc.mjs +0 -77
  317. package/assets/docs/tree/versioning.doc.mjs +0 -161
  318. package/assets/docs/tree/write-good-templates.doc.mjs +0 -64
  319. package/assets/docs/tree/write-the-template-file.doc.mjs +0 -154
  320. package/authoring/discover/discover.doc.d.mts +0 -13
  321. package/authoring/discover/discover.doc.mjs +0 -138
  322. package/authoring/discover/parse.d.mts +0 -24
  323. package/authoring/discover/parse.mjs +0 -128
  324. package/authoring/discover/parse.test.mjs +0 -124
  325. package/authoring/discover/type.ts +0 -87
  326. package/clients/cli/commands/component-batch.test.mjs +0 -341
  327. package/clients/cli/commands/discover.sources.test.mjs +0 -267
  328. package/clients/cli/commands/integration-verify.doc.mjs +0 -22
  329. package/clients/cli/lib/parse-error-format.test.mjs +0 -81
  330. package/foundation/response/batch.type.d.mts +0 -33
  331. package/foundation/response/batch.type.mjs +0 -34
@@ -0,0 +1,512 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Colocated tests for the `search` leaf (api/search/search.mjs), run
5
+ * against the real @astryxdesign/core registry. `search` had no api-level tests;
6
+ * this locks the envelope, ranking invariants, the `--type`/limit handling, and
7
+ * the error paths.
8
+ *
9
+ * The API validates its own inputs (not just the CLI): a non-positive/non-integer
10
+ * `limit`, an empty query, and a bad `--type` all throw AstryxError with the
11
+ * ERR_INVALID_ARGUMENT code, so a direct `@astryxdesign/cli/api` caller gets the
12
+ * same contract as `astryx search` on the command line.
13
+ *
14
+ * The last describe block covers integration-contributed components, using the
15
+ * same temp-consumer harness as template-integration.test.mjs. Before this,
16
+ * `search`/`build` only ever scanned @astryxdesign/core — an integration's own
17
+ * components were invisible to both, even though `component --list` and
18
+ * `component <Name>` already resolved them. The two discovery paths silently
19
+ * disagreed.
20
+ */
21
+
22
+ import {describe, it, expect} from 'vitest';
23
+ import * as fs from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import {fileURLToPath} from 'node:url';
27
+ import {
28
+ search,
29
+ scoreCandidate,
30
+ scoreQuery,
31
+ tokenizeQuery,
32
+ SEARCH_DOMAINS,
33
+ } from './search.mjs';
34
+
35
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
36
+ const cwd = REPO;
37
+ const SLOW = 30_000;
38
+
39
+ describe('search leaf — envelope + ranking', () => {
40
+ it('returns a `search` envelope with query + results', async () => {
41
+ const r = await search('button', {cwd});
42
+ expect(r.type).toBe('search');
43
+ expect(r.data.query).toBe('button');
44
+ expect(Array.isArray(r.data.results)).toBe(true);
45
+ expect(r.data.results.length).toBeGreaterThan(0);
46
+ }, SLOW);
47
+
48
+ it('keeps tokenizer coverage out of the public result shape', async () => {
49
+ const r = await search('table of contents', {cwd});
50
+ expect(r.data.results.length).toBeGreaterThan(0);
51
+ for (const result of r.data.results) {
52
+ expect(result).not.toHaveProperty('matchedTerms');
53
+ expect(result).not.toHaveProperty('queryTerms');
54
+ }
55
+ }, SLOW);
56
+
57
+ it('returns an empty result set (not an error) for a no-match query', async () => {
58
+ const r = await search('zzznomatch99', {cwd});
59
+ expect(r.type).toBe('search');
60
+ expect(r.data.results).toEqual([]);
61
+ }, SLOW);
62
+
63
+ it('defaults to at most 20 results', async () => {
64
+ const r = await search('button', {cwd});
65
+ expect(r.data.results.length).toBeLessThanOrEqual(20);
66
+ }, SLOW);
67
+
68
+ it('caps results to a positive limit', async () => {
69
+ const r = await search('button', {cwd, limit: 2});
70
+ expect(r.data.results.length).toBeLessThanOrEqual(2);
71
+ }, SLOW);
72
+ });
73
+
74
+ describe('search leaf — per-domain result fields', () => {
75
+ it('carries import for components and hooks, title for docs, displayName and kind for templates', async () => {
76
+ const r = await search('theme', {cwd, limit: 60});
77
+ expect(new Set(r.data.results.map(res => res.domain))).toEqual(
78
+ new Set(SEARCH_DOMAINS),
79
+ );
80
+ for (const res of r.data.results) {
81
+ expect(typeof res.command).toBe('string');
82
+ expect(typeof res.description).toBe('string');
83
+ if (res.domain === 'component' || res.domain === 'hook') {
84
+ expect(res.import).toMatch(/\S/);
85
+ } else if (res.domain === 'doc') {
86
+ expect(res.title).toMatch(/\S/);
87
+ const read = `astryx docs ${res.name}`;
88
+ expect([read, `${read} --index`, `${read} ${res.section}`]).toContain(res.command);
89
+ } else {
90
+ expect(res.displayName).toMatch(/\S/);
91
+ expect(['page', 'block']).toContain(res.kind);
92
+ }
93
+ }
94
+ }, SLOW);
95
+ });
96
+
97
+ describe('search leaf — docs at the grain a reader reads them', () => {
98
+ it(
99
+ 'finds one section of a guide, and a docs-tree leaf by its own name',
100
+ async () => {
101
+ const guide = await search('codemod protected files', {cwd, type: 'doc'});
102
+ expect(guide.data.results.slice(0, 3)).toContainEqual(
103
+ expect.objectContaining({
104
+ domain: 'doc',
105
+ name: 'cli/integrations',
106
+ section: 'codemods',
107
+ title: 'Astryx CLI › CLI Integrations › Codemods',
108
+ parent: 'astryx docs cli/integrations --index',
109
+ command: 'astryx docs cli/integrations codemods',
110
+ }),
111
+ );
112
+ const block = await search('token-ref', {cwd});
113
+ expect(block.data.results[0]).toMatchObject({
114
+ name: 'authoring',
115
+ section: 'reference-doc',
116
+ command: 'astryx docs authoring reference-doc',
117
+ });
118
+ const fn = await search('assertResponse', {cwd, type: 'doc'});
119
+ expect(fn.data.results[0]).toMatchObject({
120
+ name: 'cli/api/functions/assert-response',
121
+ title: 'Astryx CLI › API › Functions › assertResponse()',
122
+ parent: 'astryx docs cli/api/functions',
123
+ command: 'astryx docs cli/api/functions/assert-response',
124
+ });
125
+ expect(fn.data.results[0]).not.toHaveProperty('section');
126
+ },
127
+ SLOW,
128
+ );
129
+
130
+ it(
131
+ 'gives a top-level namespace hit the topic list as its parent',
132
+ async () => {
133
+ for (const [query, route] of [['unorganized', 'unorganized'], ['Astryx CLI', 'cli']]) {
134
+ const r = await search(query, {cwd, type: 'doc'});
135
+ expect(r.data.results).toContainEqual(
136
+ expect.objectContaining({
137
+ name: route,
138
+ command: `astryx docs ${route}`,
139
+ parent: 'astryx docs',
140
+ }),
141
+ );
142
+ }
143
+ },
144
+ SLOW,
145
+ );
146
+
147
+ it(
148
+ 'points a topic hit at its index, never a whole-topic read',
149
+ async () => {
150
+ const r = await search('cli/integrations', {cwd, type: 'doc'});
151
+ expect(r.data.results[0]).toMatchObject({
152
+ name: 'cli/integrations',
153
+ command: 'astryx docs cli/integrations --index',
154
+ });
155
+ expect(r.data.results[0]).not.toHaveProperty('section');
156
+ },
157
+ SLOW,
158
+ );
159
+
160
+ it(
161
+ 'searches docs where @astryxdesign/core is not installed',
162
+ async () => {
163
+ const bare = fs.mkdtempSync(
164
+ path.join(os.tmpdir(), 'astryx-search-bare-'),
165
+ );
166
+ try {
167
+ const r = await search('assertResponse', {cwd: bare, type: 'doc'});
168
+ expect(r.data.results[0].name).toBe(
169
+ 'cli/api/functions/assert-response',
170
+ );
171
+ } finally {
172
+ fs.rmSync(bare, {recursive: true, force: true});
173
+ }
174
+ },
175
+ SLOW,
176
+ );
177
+ });
178
+
179
+ describe('search leaf — matchCount is the total, not the cap', () => {
180
+ it('reports every match while `results` stays bounded by the limit', async () => {
181
+ // The regression: `matchCount` used to be `results.length`, so a query
182
+ // matching 57 things reported 20 — the cap read back as the answer. Any
183
+ // consumer counting matches (the recorded run, a caller paginating) then
184
+ // could not tell a capped answer from an exactly-cap-sized one.
185
+ const capped = await search('button', {cwd, limit: 2});
186
+ expect(capped.data.results.length).toBe(2);
187
+ expect(capped.data.matchCount).toBeGreaterThan(2);
188
+
189
+ // Same query, no meaningful cap: the count is stable across limits, which
190
+ // is what makes it a count of MATCHES rather than of what was returned.
191
+ const full = await search('button', {cwd, limit: 500});
192
+ expect(full.data.matchCount).toBe(capped.data.matchCount);
193
+ expect(full.data.results.length).toBe(full.data.matchCount);
194
+ }, SLOW);
195
+
196
+ it('reports 0 for a no-match query', async () => {
197
+ const r = await search('zzznomatch99', {cwd});
198
+ expect(r.data.matchCount).toBe(0);
199
+ }, SLOW);
200
+
201
+ it('counts only the requested domain under --type', async () => {
202
+ const all = await search('button', {cwd, limit: 500});
203
+ const components = await search('button', {
204
+ cwd,
205
+ type: 'component',
206
+ limit: 500,
207
+ });
208
+ expect(components.data.matchCount).toBe(components.data.results.length);
209
+ expect(components.data.matchCount).toBeLessThanOrEqual(all.data.matchCount);
210
+ }, SLOW);
211
+ });
212
+
213
+ describe('search leaf — --type filter', () => {
214
+ it('restricts results to the requested domain', async () => {
215
+ const r = await search('button', {cwd, type: 'component'});
216
+ expect(r.data.results.every(x => x.domain === 'component')).toBe(true);
217
+ }, SLOW);
218
+
219
+ it('exposes the valid domain list', () => {
220
+ expect(SEARCH_DOMAINS).toEqual(expect.arrayContaining(['component', 'hook', 'doc', 'template']));
221
+ });
222
+ });
223
+
224
+ describe('search leaf — exact keyword phrase outranks incidental token matches (issue #5239)', () => {
225
+ it('surfaces Outline for its own declared keyword "table of contents", ranked first', async () => {
226
+ // Before the fix, "table" and "contents" each separately matched dozens
227
+ // of unrelated Table-related templates by coincidence, and their
228
+ // combined token-sum score outranked Outline's single exact match,
229
+ // pushing it out of the results entirely at the default limit.
230
+ const r = await search('table of contents', {cwd});
231
+ expect(r.data.results[0]?.name).toBe('Outline');
232
+ }, SLOW);
233
+
234
+ it('surfaces Outline for its own declared keyword "heading navigation", ranked first', async () => {
235
+ const r = await search('heading navigation', {cwd});
236
+ expect(r.data.results[0]?.name).toBe('Outline');
237
+ }, SLOW);
238
+
239
+ it('preserves query coverage metadata on the promoted exact phrase', () => {
240
+ const query = 'table of contents';
241
+ const tokens = tokenizeQuery(query);
242
+ expect(
243
+ scoreQuery(query, tokens, {
244
+ name: 'Outline',
245
+ keywords: [query],
246
+ }),
247
+ ).toMatchObject({matched: tokens.length, total: tokens.length});
248
+ });
249
+
250
+ it('still returns no results for a nonsense query (the fix does not loosen matching)', async () => {
251
+ const r = await search('zzzzqqqx', {cwd});
252
+ expect(r.data.results).toEqual([]);
253
+ }, SLOW);
254
+ });
255
+
256
+ describe('search leaf — error paths (pinned)', () => {
257
+ it('throws ERR_INVALID_ARGUMENT when the query is empty/whitespace', async () => {
258
+ await expect(search(' ', {cwd})).rejects.toMatchObject({
259
+ code: 'ERR_INVALID_ARGUMENT',
260
+ message: expect.stringMatching(/query is required/i),
261
+ });
262
+ }, SLOW);
263
+
264
+ it('throws ERR_INVALID_ARGUMENT for an unknown --type', async () => {
265
+ await expect(
266
+ search('button', {cwd, type: /** @type {any} */ ('bogus')}),
267
+ ).rejects.toMatchObject({code: 'ERR_INVALID_ARGUMENT'});
268
+ }, SLOW);
269
+
270
+ it('throws ERR_CORE_NOT_FOUND when @astryxdesign/core cannot be found', async () => {
271
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-search-no-core-'));
272
+ try {
273
+ await expect(search('button', {cwd: empty})).rejects.toMatchObject({
274
+ code: 'ERR_CORE_NOT_FOUND',
275
+ });
276
+ } finally {
277
+ fs.rmSync(empty, {recursive: true, force: true});
278
+ }
279
+ }, SLOW);
280
+ });
281
+
282
+ describe('search leaf — limit validation (API matches the CLI contract)', () => {
283
+ it('throws ERR_INVALID_ARGUMENT for a limit of 0 (no longer returns everything)', async () => {
284
+ await expect(search('button', {cwd, limit: 0})).rejects.toMatchObject({
285
+ code: 'ERR_INVALID_ARGUMENT',
286
+ });
287
+ }, SLOW);
288
+
289
+ it('throws ERR_INVALID_ARGUMENT for a negative limit', async () => {
290
+ await expect(search('button', {cwd, limit: -5})).rejects.toMatchObject({
291
+ code: 'ERR_INVALID_ARGUMENT',
292
+ });
293
+ }, SLOW);
294
+
295
+ it('throws ERR_INVALID_ARGUMENT for a non-integer limit', async () => {
296
+ await expect(search('button', {cwd, limit: 2.5})).rejects.toMatchObject({
297
+ code: 'ERR_INVALID_ARGUMENT',
298
+ });
299
+ }, SLOW);
300
+ });
301
+
302
+ describe('search leaf — integration components', () => {
303
+ /**
304
+ * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
305
+ * resolves without needing the real package) plus an installed
306
+ * `@acme/widgets` integration that contributes one component.
307
+ */
308
+ function makeConsumerWithIntegrationComponent() {
309
+ const dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-search-it-'));
310
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'consumer'}));
311
+ fs.writeFileSync(
312
+ path.join(dir, 'astryx.config.mjs'),
313
+ `export default { integrations: ['@acme/widgets'] };\n`,
314
+ );
315
+
316
+ // Stub core: just needs to exist with an (empty) src/ so discoverComponents
317
+ // doesn't throw. Its own component list is irrelevant to this test.
318
+ const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
319
+ fs.mkdirSync(path.join(coreDir, 'src'), {recursive: true});
320
+
321
+ const widgetsDir = path.join(dir, 'node_modules', '@acme', 'widgets');
322
+ fs.mkdirSync(path.join(widgetsDir, 'components'), {recursive: true});
323
+ fs.writeFileSync(
324
+ path.join(widgetsDir, 'package.json'),
325
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
326
+ );
327
+ fs.writeFileSync(
328
+ path.join(widgetsDir, 'astryx.integration.mjs'),
329
+ `export default { components: './components' };\n`,
330
+ );
331
+ fs.writeFileSync(
332
+ path.join(widgetsDir, 'components', 'FancyGizmo.doc.mjs'),
333
+ `export default {
334
+ type: 'component',
335
+ name: 'FancyGizmo',
336
+ keywords: ['gizmo', 'widget'],
337
+ usage: {description: 'A fancy gizmo widget.'},
338
+ props: [],
339
+ };\n`,
340
+ );
341
+ fs.writeFileSync(
342
+ path.join(widgetsDir, 'components', 'FancyGizmo.tsx'),
343
+ `export function FancyGizmo() { return null; }\n`,
344
+ );
345
+
346
+ return dir;
347
+ }
348
+
349
+ it('includes a component contributed by a configured integration', async () => {
350
+ const dir = makeConsumerWithIntegrationComponent();
351
+ try {
352
+ const r = await search('gizmo', {cwd: dir, type: 'component'});
353
+ expect(r.data.results.some(x => x.name === 'FancyGizmo')).toBe(true);
354
+ } finally {
355
+ fs.rmSync(dir, {recursive: true, force: true});
356
+ }
357
+ }, SLOW);
358
+
359
+ it('reports the contributing package as the import hint', async () => {
360
+ const dir = makeConsumerWithIntegrationComponent();
361
+ try {
362
+ const r = await search('FancyGizmo', {cwd: dir, type: 'component'});
363
+ const hit = r.data.results.find(x => x.name === 'FancyGizmo');
364
+ expect(hit?.import).toBe('@acme/widgets');
365
+ } finally {
366
+ fs.rmSync(dir, {recursive: true, force: true});
367
+ }
368
+ }, SLOW);
369
+ });
370
+
371
+ describe('search scoring — multi-token aggregation is monotonic', () => {
372
+ /**
373
+ * @param {string} q
374
+ * @param {object} candidate
375
+ * @returns {number}
376
+ */
377
+ const score = (q, candidate) => scoreQuery(q, tokenizeQuery(q), candidate)?.score ?? 0;
378
+
379
+ it('ranks matching both terms above matching only the stronger one', () => {
380
+ // The shipped regression: scoring averaged over MATCHED tokens, so the
381
+ // weaker second hit pulled the mean down further than the coverage bonus
382
+ // pushed it up. `build "file browser"` put two form wizards that matched
383
+ // only "file" (98) above the actual file browser that matched both (97),
384
+ // and 98 clears the confident-match gate.
385
+ const partial = {name: 'form-wizard-vertical', keywords: ['file']};
386
+ const complete = {
387
+ name: 'file-explorer',
388
+ keywords: ['file'],
389
+ description: 'Column browser for nested folders',
390
+ };
391
+ expect(score('file browser', complete)).toBeGreaterThan(score('file browser', partial));
392
+ });
393
+
394
+ it('never scores a superset of matched terms below a subset', () => {
395
+ const subset = {name: 'zzz-none', keywords: ['alpha']};
396
+ const supersets = [
397
+ {name: 'zzz-none', keywords: ['alpha'], description: 'beta things'},
398
+ {name: 'zzz-none', keywords: ['alpha', 'beta']},
399
+ {name: 'zzz-none', keywords: ['alpha'], weakKeywords: ['beta']},
400
+ ];
401
+ for (const superset of supersets) {
402
+ expect(score('alpha beta', superset)).toBeGreaterThanOrEqual(score('alpha beta', subset));
403
+ }
404
+ });
405
+
406
+ it('still scores a verbose prompt on the concepts it did hit', () => {
407
+ // Guards the reason the mean was used: a long prompt matching one concept
408
+ // strongly must not be crushed by dividing across every query token.
409
+ const candidate = {name: 'kanban', keywords: ['kanban']};
410
+ expect(score('i need a kanban somewhere in this rambling request', candidate))
411
+ .toBeGreaterThanOrEqual(90);
412
+ });
413
+ });
414
+
415
+ describe('search scoring — derived keywords rank below authored ones', () => {
416
+ it('scores an authored keyword above a derived one', () => {
417
+ const authored = scoreCandidate('dialog', {name: 'x', keywords: ['Dialog']});
418
+ const derived = scoreCandidate('dialog', {name: 'x', weakKeywords: ['Dialog']});
419
+ expect(authored?.score).toBe(90);
420
+ expect(derived?.score).toBe(60);
421
+ });
422
+
423
+ it('keeps one incidental derived match below the confident-match gate', () => {
424
+ // PAGE_DIRECT in api/build/kit is 95: at full keyword strength a page that
425
+ // renders one of everything got a 90-point shot per component and was
426
+ // returned as a confident match for queries it had nothing to do with.
427
+ const derived = scoreCandidate('dialog', {name: 'x', weakKeywords: ['Dialog']});
428
+ expect(derived?.score).toBeLessThan(95);
429
+ });
430
+
431
+ it('still surfaces a derived match above the page floor', () => {
432
+ // PAGE_FLOOR is 50 — weakening the signal must not make it invisible.
433
+ const derived = scoreCandidate('dialog', {name: 'x', weakKeywords: ['Dialog']});
434
+ expect(derived?.score).toBeGreaterThanOrEqual(50);
435
+ });
436
+
437
+ it('explains a derived hit as something the template renders', () => {
438
+ const derived = scoreCandidate('dialog', {name: 'x', weakKeywords: ['Dialog']});
439
+ expect(derived?.reason).toMatch(/renders Dialog/);
440
+ });
441
+ });
442
+
443
+ describe('search — usage guidance is indexed, a tier below description', () => {
444
+ // The vocabulary a reader types usually lives in a component's guidance, not
445
+ // in its one-line description. Banner calls itself "a persistent message";
446
+ // only its best practices name "caution", "problems", "form errors". Before
447
+ // this, none of those words found it.
448
+ const banner = {
449
+ name: 'Banner',
450
+ keywords: ['alert', 'notification'],
451
+ description: 'A persistent message shown above content.',
452
+ guidance: [
453
+ 'Pick a status that matches the message: info for updates, warning for caution.',
454
+ 'Use error for problems the reader must resolve before continuing.',
455
+ ],
456
+ };
457
+
458
+ it('finds a term that appears ONLY in guidance', () => {
459
+ // Red before this change: guidance was never read, so this scored null.
460
+ const hit = scoreCandidate('caution', banner);
461
+ expect(hit).not.toBeNull();
462
+ expect(hit?.reason).toMatch(/guidance mentions "caution"/);
463
+ });
464
+
465
+ it('scores guidance BELOW description, so a component about X outranks one that merely mentions X', () => {
466
+ const own = scoreCandidate('persistent', banner);
467
+ const mention = scoreCandidate('caution', banner);
468
+ expect(own?.score).toBe(50);
469
+ expect(mention?.score).toBe(45);
470
+ expect(mention.score).toBeLessThan(own.score);
471
+ });
472
+
473
+ it('never lets guidance outrank a name or keyword hit', () => {
474
+ expect(scoreCandidate('banner', banner)?.score).toBe(100);
475
+ expect(scoreCandidate('notification', banner)?.score).toBe(90);
476
+ });
477
+
478
+ it('prefers the stronger signal when a term is in both description and guidance', () => {
479
+ const both = scoreCandidate('message', banner);
480
+ expect(both?.score).toBe(50);
481
+ expect(both?.reason).toMatch(/description mentions/);
482
+ });
483
+
484
+ it('stays below MIN_TOKEN_SCORE, so guidance never counts as a matched CONCEPT', () => {
485
+ // Measured regression this prevents: counting guidance as a matched term
486
+ // moved `nested menu` from SideNav to List, and `explain why a field is
487
+ // required` from Field to TextInput — in both cases a component whose
488
+ // guidance happens to mention the other word displaced the real answer.
489
+ // Guidance decides single-word queries; it must not win multi-word ones on
490
+ // breadth. Same reason weakKeywords are capped.
491
+ expect(scoreCandidate('caution', banner)?.score).toBeLessThan(50);
492
+ // Both words are in this candidate's guidance and nowhere else, so if
493
+ // guidance counted as a concept this would come back as a 2/2 match.
494
+ const multi = scoreQuery('caution problems', tokenizeQuery('caution problems'), banner);
495
+ expect(multi).toBeNull();
496
+ });
497
+
498
+ it('lets a real description hit still win the multi-word pass', () => {
499
+ // The floor must exclude guidance without muting the tiers above it.
500
+ const hit = scoreQuery('persistent message', tokenizeQuery('persistent message'), banner);
501
+ expect(hit?.reason).toMatch(/matches 2\/2 terms/);
502
+ });
503
+
504
+ it('tolerates the object-shaped bestPractices entries core actually ships', () => {
505
+ // Core writes `{guidance: true, description: '...'}`, not plain strings.
506
+ const hit = scoreCandidate('resolve', {
507
+ name: 'X',
508
+ guidance: ['Use error for problems the reader must resolve.'],
509
+ });
510
+ expect(hit?.score).toBe(45);
511
+ });
512
+ });
@@ -29,19 +29,17 @@ export const doc = {
29
29
  name: 'component',
30
30
  type: 'string',
31
31
  description:
32
- "Component name to copy (e.g. 'Button'). Omit to list the swizzlable components.",
32
+ 'Bare or XDS-prefixed component name to copy. Omit to list the swizzlable components.',
33
33
  },
34
34
  {
35
35
  name: 'options.cwd',
36
36
  type: 'string',
37
37
  description: 'Directory to resolve @astryxdesign/core from.',
38
- default: 'process.cwd()',
39
38
  },
40
39
  {
41
40
  name: 'options.output',
42
41
  type: 'string',
43
- description:
44
- 'Output directory, relative to cwd. An absolute path, or one that resolves outside cwd, throws ERR_PATH_TRAVERSAL.',
42
+ description: 'Output directory; must resolve inside cwd.',
45
43
  default: "'./components/astryx'",
46
44
  },
47
45
  {
@@ -73,7 +71,7 @@ export const doc = {
73
71
  {
74
72
  type: 'swizzle.copy',
75
73
  description:
76
- 'A receipt after copying the component into the project: the component name, owning package, output directory, files-copied count, the written file names, whether any file uses StyleX, and, when the owner has an issues URL, feedback ({issuesUrl, ghCommand?}): where to report the gap that led to swizzling.',
74
+ 'A receipt after copying the component into the project: the component name, owning package, output directory, files-copied count, the written file names, whether any file uses StyleX, and an optional maintainer-feedback note.',
77
75
  },
78
76
  ],
79
77
  throws: [
@@ -83,7 +81,7 @@ export const doc = {
83
81
  },
84
82
  {
85
83
  code: 'ERR_PATH_TRAVERSAL',
86
- when: 'the component name contains a path separator or traversal, output is absolute or resolves outside cwd, or an existing output file or directory is a symlink that resolves outside cwd',
84
+ when: 'the component name contains a path separator or traversal, output resolves outside cwd, or an existing output file or directory is a symlink that resolves outside cwd',
87
85
  },
88
86
  {
89
87
  code: 'ERR_UNKNOWN_COMPONENT',
@@ -110,7 +108,7 @@ export const doc = {
110
108
  {label: 'Eject a component', code: "await swizzle('Button');"},
111
109
  {
112
110
  label: 'Disambiguate by package',
113
- code: "await swizzle('Button', {package: '@astryxdesign/core', overwrite: true});",
111
+ code: "await swizzle('Button', {package: '@astryxdesign/core'});",
114
112
  },
115
113
  {
116
114
  label: 'Custom output directory',
@@ -74,7 +74,7 @@ export function templateCopy(match, {targetPath, cwd, overwrite = false}) {
74
74
  if (!overwrite && fs.existsSync(outputFilePath)) {
75
75
  const rel = path.relative(cwd, outputFilePath) || outputFilePath;
76
76
  throw new AstryxError(
77
- `Refusing to overwrite existing file ${rel}. Re-run with --overwrite (or -f) to replace it.`,
77
+ `Refusing to overwrite existing file ${rel}. Re-run with overwrite to replace it.`,
78
78
  undefined,
79
79
  ERROR_CODES.ERR_FILE_EXISTS,
80
80
  );
@@ -35,15 +35,6 @@ describe('template.copy — overwrite + path safety', () => {
35
35
  expect(fs.readFileSync(path.join(dir, 'mine.tsx'), 'utf-8')).toBe('USER CODE');
36
36
  }, SLOW);
37
37
 
38
- it('names the flag that replaces the file, as swizzle and theme add do', async () => {
39
- fs.writeFileSync(path.join(dir, 'mine.tsx'), 'USER CODE');
40
- await expect(
41
- template('blank', {targetPath: './mine.tsx', cwd: dir}),
42
- ).rejects.toThrow(
43
- 'Refusing to overwrite existing file mine.tsx. Re-run with --overwrite (or -f) to replace it.',
44
- );
45
- }, SLOW);
46
-
47
38
  it('overwrites when overwrite:true is passed', async () => {
48
39
  fs.writeFileSync(path.join(dir, 'mine.tsx'), 'USER CODE');
49
40
  const res = await template('blank', {targetPath: './mine.tsx', overwrite: true, cwd: dir});
@@ -19,6 +19,7 @@ import {
19
19
  } from './template.mjs';
20
20
  import {search} from '../search/search.mjs';
21
21
  import {build} from '../build/build.mjs';
22
+ import {layoutExpand} from '../layout/layout.mjs';
22
23
  import {runCli} from '../../test-utils/run-cli.mjs';
23
24
 
24
25
  let tmpDir;
@@ -517,7 +518,53 @@ describe('integration template discovery', () => {
517
518
  });
518
519
  });
519
520
 
520
- it('resolves chained block aliases consistently in template', async () => {
521
+ it('resolves a replacement block through the layout alias', async () => {
522
+ const pkgDir = installWidgets(tmpDir);
523
+ writeTemplate(pkgDir, 'acme-card-callout', {
524
+ kind: 'block',
525
+ source:
526
+ 'export default function AcmeCardCallout() { return <span>Acme replacement block</span>; }\n',
527
+ });
528
+ declareReplaces(pkgDir, {
529
+ 'acme-card-callout': 'CardCallout',
530
+ });
531
+
532
+ const accidental = path.join(tmpDir, 'node_modules', '@acme', 'extra');
533
+ fs.mkdirSync(path.join(accidental, 'templates'), {recursive: true});
534
+ fs.writeFileSync(
535
+ path.join(accidental, 'package.json'),
536
+ JSON.stringify({name: '@acme/extra', version: '1.0.0'}),
537
+ );
538
+ fs.writeFileSync(
539
+ path.join(accidental, 'astryx.integration.mjs'),
540
+ `export default {templates: './templates'};\n`,
541
+ );
542
+ writeTemplate(accidental, 'CardCallout', {
543
+ kind: 'block',
544
+ body: `export default {type: 'block', name: 'ZZZ accidental block', description: 'collision'};\n`,
545
+ source:
546
+ 'export default function Accidental() { return <span>Accidental block</span>; }\n',
547
+ });
548
+ fs.writeFileSync(
549
+ path.join(tmpDir, 'astryx.config.mjs'),
550
+ `export default { integrations: ['@acme/widgets', '@acme/extra'] };\n`,
551
+ );
552
+
553
+ const result = await layoutExpand('C{card-callout}', {
554
+ name: 'ReplacementLayout',
555
+ cwd: tmpDir,
556
+ });
557
+
558
+ expect(result.data.code).toContain('Acme replacement block');
559
+ expect(result.data.code).not.toContain('Accidental block');
560
+ expect(result.data.blocksReferenced).toEqual(
561
+ expect.arrayContaining([
562
+ expect.objectContaining({name: 'CardCallout', mode: 'splice'}),
563
+ ]),
564
+ );
565
+ }, 30_000);
566
+
567
+ it('resolves chained block aliases consistently in template and layout', async () => {
521
568
  const [firstTarget, secondTarget] = (await discoverCoreTemplates()).filter(
522
569
  candidate => candidate.type === 'block',
523
570
  );
@@ -545,12 +592,29 @@ describe('integration template discovery', () => {
545
592
  cwd: tmpDir,
546
593
  });
547
594
  expect(firstTemplate.data.source).toContain('First chain replacement');
595
+ const layoutId = id =>
596
+ id.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase();
597
+ const firstLayout = await layoutExpand(
598
+ `C{${layoutId(firstTarget.dirName)}}`,
599
+ {
600
+ cwd: tmpDir,
601
+ },
602
+ );
603
+ expect(firstLayout.data.code).toContain('First chain replacement');
604
+ expect(firstLayout.data.code).not.toContain('Final chain replacement');
548
605
 
549
606
  const secondTemplate = await template(secondTarget.dirName, {
550
607
  show: true,
551
608
  cwd: tmpDir,
552
609
  });
553
610
  expect(secondTemplate.data.source).toContain('Final chain replacement');
611
+ const secondLayout = await layoutExpand(
612
+ `C{${layoutId(secondTarget.dirName)}}`,
613
+ {
614
+ cwd: tmpDir,
615
+ },
616
+ );
617
+ expect(secondLayout.data.code).toContain('Final chain replacement');
554
618
  }, 30_000);
555
619
 
556
620
  it('keeps the old discovery behavior when no replacement is declared', async () => {