@astryxdesign/cli 0.7.2-canary.caec7ca → 0.7.2-canary.caf5673

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 (221) hide show
  1. package/README.md +15 -14
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/component.test.mjs +80 -0
  7. package/api/component/integration-export-name.test.mjs +644 -0
  8. package/api/component/list/list.mjs +42 -4
  9. package/api/docs/_adapter.d.mts +11 -0
  10. package/api/docs/_adapter.mjs +244 -1
  11. package/api/docs/integrationDocs.test.mjs +124 -0
  12. package/api/doctor/doctor.mjs +27 -3
  13. package/api/doctor/doctor.test.mjs +43 -0
  14. package/api/doctor/theme-checks.mjs +1 -1
  15. package/api/gap-report/gap-report.mjs +67 -4
  16. package/api/index.d.mts +2 -0
  17. package/api/index.mjs +2 -0
  18. package/api/init/run/run.mjs +2 -2
  19. package/api/integration/_adapter.d.mts +12 -0
  20. package/api/integration/_adapter.mjs +54 -0
  21. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  22. package/api/integration/add-contribution.mjs +8 -4
  23. package/api/integration/add-contribution.test.mjs +4 -2
  24. package/api/integration/authoring-checks.mjs +11 -2
  25. package/api/integration/authoring-checks.test.mjs +39 -0
  26. package/api/integration/authoring-checks.type.d.mts +9 -0
  27. package/api/integration/authoring-checks.type.mjs +14 -1
  28. package/api/integration/check/check.d.mts +96 -0
  29. package/api/integration/check/check.mjs +357 -0
  30. package/api/integration/check/check.test.mjs +489 -0
  31. package/api/integration/integration-check.type.d.mts +118 -0
  32. package/api/integration/integration-check.type.mjs +84 -0
  33. package/api/integration/integrationCheck.doc.d.mts +10 -0
  34. package/api/integration/integrationCheck.doc.mjs +73 -0
  35. package/api/integration/integrationComponentConflicts.doc.mjs +43 -6
  36. package/api/integration/pack-check.mjs +61 -107
  37. package/api/integration/pack-check.test.mjs +359 -0
  38. package/api/integration/validate-integration.mjs +8 -0
  39. package/api/integration/validate-integration.test.mjs +11 -0
  40. package/api/integration/validate-integration.type.d.mts +8 -0
  41. package/api/integration/validate-integration.type.mjs +12 -1
  42. package/api/integration/validateIntegration.doc.mjs +19 -3
  43. package/api/search/search.d.mts +81 -12
  44. package/api/search/search.domain-priority.test.mjs +108 -18
  45. package/api/search/search.integration-check.test.mjs +24 -0
  46. package/api/search/search.mjs +608 -53
  47. package/api/search/search.test.mjs +175 -0
  48. package/api/swizzle/copy/copy.mjs +28 -14
  49. package/api/theme/build/build.family.test.mjs +64 -0
  50. package/api/theme/build/build.mjs +180 -24
  51. package/api/theme/themeBuild.doc.mjs +4 -4
  52. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  53. package/assets/codemods/transforms/next/index.mjs +11 -1
  54. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  55. package/assets/docs/README.md +10 -0
  56. package/assets/docs/author-a-theme.doc.mjs +1 -1
  57. package/assets/docs/getting-started.doc.mjs +3 -3
  58. package/assets/docs/styling-overview.doc.mjs +1 -1
  59. package/assets/docs/theme.doc.dense.mjs +1 -1
  60. package/assets/docs/theme.doc.mjs +1 -1
  61. package/assets/docs/theme.doc.zh.mjs +1 -1
  62. package/assets/docs/tree/add-a-component.doc.mjs +11 -1
  63. package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
  64. package/assets/docs/tree/add-a-topic.doc.mjs +10 -7
  65. package/assets/docs/tree/agent-guidance.doc.mjs +2 -2
  66. package/assets/docs/tree/block-template.doc.mjs +8 -20
  67. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  68. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  69. package/assets/docs/tree/check-your-docs.doc.mjs +51 -5
  70. package/assets/docs/tree/checks.doc.mjs +24 -16
  71. package/assets/docs/tree/codemods.doc.mjs +4 -4
  72. package/assets/docs/tree/component-family.doc.mjs +8 -6
  73. package/assets/docs/tree/component-imports.doc.mjs +10 -2
  74. package/assets/docs/tree/components.doc.mjs +1 -1
  75. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +7 -18
  76. package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
  77. package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
  78. package/assets/docs/tree/docs.doc.mjs +3 -3
  79. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  80. package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
  81. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  82. package/assets/docs/tree/extend-or-replace.doc.mjs +6 -6
  83. package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
  84. package/assets/docs/tree/generate-a-palette.doc.mjs +5 -3
  85. package/assets/docs/tree/grade-template-with-agent.doc.mjs +1 -1
  86. package/assets/docs/tree/integrations.doc.mjs +8 -0
  87. package/assets/docs/tree/links.doc.mjs +6 -5
  88. package/assets/docs/tree/migration-setup.doc.mjs +1 -0
  89. package/assets/docs/tree/migration-verification.doc.mjs +0 -1
  90. package/assets/docs/tree/migration.doc.mjs +0 -1
  91. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  92. package/assets/docs/tree/page-template.doc.mjs +4 -8
  93. package/assets/docs/tree/publishing.doc.mjs +1 -1
  94. package/assets/docs/tree/quick-start.doc.mjs +37 -21
  95. package/assets/docs/tree/replace-a-core-component.doc.mjs +4 -6
  96. package/assets/docs/tree/replace-a-core-template.doc.mjs +6 -8
  97. package/assets/docs/tree/sections-and-placement.doc.mjs +4 -6
  98. package/assets/docs/tree/see-it-in-an-app.doc.mjs +4 -2
  99. package/assets/docs/tree/short-and-findable.doc.mjs +6 -5
  100. package/assets/docs/tree/single-component.doc.mjs +15 -6
  101. package/assets/docs/tree/start-a-template.doc.mjs +3 -3
  102. package/assets/docs/tree/subcomponent.doc.mjs +6 -6
  103. package/assets/docs/tree/template-assets.doc.mjs +2 -2
  104. package/assets/docs/tree/template-doc-overview.doc.mjs +4 -15
  105. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  106. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  107. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  108. package/assets/docs/tree/template-styles.doc.mjs +2 -2
  109. package/assets/docs/tree/templates.doc.mjs +1 -1
  110. package/assets/docs/tree/test-in-an-app.doc.mjs +3 -3
  111. package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
  112. package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
  113. package/assets/docs/tree/troubleshooting.doc.mjs +13 -5
  114. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  115. package/assets/docs/tree/upgrading.doc.mjs +3 -3
  116. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +3 -1
  117. package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
  118. package/assets/docs/tree/versioning.doc.mjs +7 -6
  119. package/assets/docs/tree/write-the-template-file.doc.mjs +4 -3
  120. package/assets/docs/use-a-theme.doc.mjs +2 -2
  121. package/assets/docs/working-with-ai.doc.mjs +43 -4
  122. package/assets/theme.template.ts +1 -1
  123. package/authoring/codemod/codemod.doc.mjs +7 -3
  124. package/authoring/config/config.doc.mjs +9 -6
  125. package/authoring/config/type.ts +4 -2
  126. package/authoring/discover/discover.doc.mjs +1 -1
  127. package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
  128. package/authoring/doctypes/command/command.doc.mjs +14 -12
  129. package/authoring/doctypes/component/component.doc.mjs +29 -26
  130. package/authoring/doctypes/component/type.ts +13 -8
  131. package/authoring/doctypes/enum/enum.doc.mjs +7 -4
  132. package/authoring/doctypes/function/function.doc.mjs +9 -8
  133. package/authoring/doctypes/hook/hook.doc.mjs +5 -4
  134. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
  135. package/authoring/doctypes/reference/reference.doc.mjs +11 -10
  136. package/authoring/doctypes/schema/schema.doc.mjs +9 -9
  137. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  138. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  139. package/authoring/doctypes/template/parse.d.mts +6 -2
  140. package/authoring/doctypes/template/parse.mjs +3 -1
  141. package/authoring/doctypes/template/template.doc.mjs +3 -3
  142. package/authoring/doctypes/template/type.ts +22 -0
  143. package/authoring/doctypes/theme/theme.doc.mjs +2 -2
  144. package/authoring/gap-report/gap-report.doc.mjs +2 -2
  145. package/authoring/identity/identity.doc.mjs +3 -3
  146. package/authoring/index.d.ts +1 -0
  147. package/authoring/integration/integration.doc.mjs +12 -4
  148. package/authoring/integration/parse.test.mjs +10 -0
  149. package/authoring/integration/schema.d.mts +2 -0
  150. package/authoring/integration/schema.mjs +1 -0
  151. package/authoring/integration/type.ts +4 -0
  152. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  153. package/clients/cli/commands/build-theme.mjs +1 -1
  154. package/clients/cli/commands/build-theme.path-safety.test.mjs +224 -6
  155. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  156. package/clients/cli/commands/cli-postinstall.test.mjs +49 -1
  157. package/clients/cli/commands/doctor-integration-components.doc.mjs +17 -4
  158. package/clients/cli/commands/doctor-integration-validate.doc.mjs +13 -2
  159. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  160. package/clients/cli/commands/doctor.mjs +190 -0
  161. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  162. package/clients/cli/commands/integration-check.test.mjs +267 -0
  163. package/clients/cli/commands/integration.doc.mjs +6 -2
  164. package/clients/cli/commands/integration.mjs +107 -1
  165. package/clients/cli/commands/setup-nudge.test.mjs +79 -1
  166. package/clients/cli/commands/swizzle.mjs +5 -3
  167. package/clients/cli/commands/swizzle.routing.test.mjs +18 -1
  168. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  169. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  170. package/clients/cli/index.mjs +30 -5
  171. package/foundation/agent-docs/agent-doc-state.d.mts +14 -0
  172. package/foundation/agent-docs/agent-doc-state.mjs +38 -0
  173. package/foundation/agent-docs/agent-docs.d.mts +2 -1
  174. package/foundation/agent-docs/agent-docs.mjs +8 -6
  175. package/foundation/config/project.mjs +82 -0
  176. package/foundation/discovery/authoring-self-docs.mjs +2 -1
  177. package/foundation/discovery/component-discovery.d.mts +190 -10
  178. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  179. package/foundation/discovery/component-discovery.mjs +864 -56
  180. package/foundation/discovery/component-discovery.test.mjs +96 -0
  181. package/foundation/discovery/component-replacement.mjs +18 -0
  182. package/foundation/discovery/component-replacement.test.mjs +40 -0
  183. package/foundation/discovery/doc-types.d.mts +157 -0
  184. package/foundation/discovery/doc-types.mjs +1224 -0
  185. package/foundation/discovery/doc-types.test.mjs +700 -0
  186. package/foundation/discovery/docs-discovery.d.mts +81 -0
  187. package/foundation/discovery/docs-discovery.mjs +57 -0
  188. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  189. package/foundation/discovery/integration-doc-files.mjs +231 -0
  190. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  191. package/foundation/discovery/module-exports.d.mts +27 -0
  192. package/foundation/discovery/module-exports.mjs +356 -31
  193. package/foundation/discovery/module-exports.test.mjs +172 -0
  194. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  195. package/foundation/discovery/package-tsconfig.mjs +228 -0
  196. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  197. package/foundation/discovery/project-typescript.d.mts +32 -0
  198. package/foundation/discovery/project-typescript.mjs +89 -0
  199. package/foundation/discovery/prop-drift.d.mts +278 -0
  200. package/foundation/discovery/prop-drift.mjs +2263 -0
  201. package/foundation/discovery/prop-drift.test.mjs +2272 -0
  202. package/foundation/discovery/type-match.d.mts +53 -0
  203. package/foundation/discovery/type-match.mjs +660 -0
  204. package/foundation/discovery/type-match.test.mjs +169 -0
  205. package/foundation/discovery/type-text.d.mts +68 -0
  206. package/foundation/discovery/type-text.mjs +658 -0
  207. package/foundation/discovery/type-text.test.mjs +196 -0
  208. package/foundation/discovery/unresolved-references.d.mts +62 -0
  209. package/foundation/discovery/unresolved-references.mjs +790 -0
  210. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  211. package/foundation/doc-compiler/doc-loads.test.mjs +14 -0
  212. package/foundation/doc-compiler/inputs.mjs +23 -0
  213. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  214. package/foundation/doc-compiler/links.d.mts +9 -5
  215. package/foundation/doc-compiler/links.mjs +4 -2
  216. package/foundation/integrations/contribution-inventory.mjs +6 -1
  217. package/foundation/response/response-types.doc.mjs +7 -2
  218. package/foundation/xle/registry.mjs +4 -0
  219. package/foundation/xle/registry.test.mjs +28 -0
  220. package/package.json +9 -9
  221. package/scripts/postinstall.mjs +3 -3
@@ -616,6 +616,42 @@ describe('search leaf — a theme is found by its names; its description is pros
616
616
  );
617
617
  });
618
618
 
619
+ describe('search leaf — components whose docs live in a family folder', () => {
620
+ it("indexes a family member from its own entry, not the family's keywords", async () => {
621
+ // HStack and VStack are documented in Stack's doc, whose keywords
622
+ // (horizontal, vertical, row, column, ...) describe Stack.
623
+ const reasons = async (/** @type {string} */ query) =>
624
+ Object.fromEntries(
625
+ (await search(query, {cwd, type: 'component'})).data.results.map(r => [
626
+ r.name,
627
+ r.reason,
628
+ ]),
629
+ );
630
+ const column = await reasons('column');
631
+ expect(column.HStack ?? '').not.toMatch(/keyword/);
632
+ expect(column.VStack ?? '').not.toMatch(/keyword/);
633
+ expect(column.Stack).toMatch(/keyword/);
634
+ const vertical = (await search('vertical', {cwd, type: 'component'})).data.results.map(
635
+ r => r.name,
636
+ );
637
+ expect(vertical.indexOf('VStack')).toBeGreaterThanOrEqual(0);
638
+ if (vertical.includes('HStack')) {
639
+ expect(vertical.indexOf('VStack')).toBeLessThan(vertical.indexOf('HStack'));
640
+ }
641
+ }, SLOW);
642
+
643
+ it('ranks VStack, HStack and Heading first for their own names, with their own import', async () => {
644
+ for (const name of ['VStack', 'HStack', 'Heading']) {
645
+ const r = await search(name.toLowerCase(), {cwd, type: 'component'});
646
+ expect(r.data.results[0]).toMatchObject({
647
+ domain: 'component',
648
+ name,
649
+ import: `@astryxdesign/core/${name}`,
650
+ });
651
+ }
652
+ }, SLOW);
653
+ });
654
+
619
655
  describe('search leaf — integration components', () => {
620
656
  /**
621
657
  * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
@@ -827,3 +863,142 @@ describe('search — usage guidance is indexed, a tier below description', () =>
827
863
  expect(hit?.score).toBe(45);
828
864
  });
829
865
  });
866
+
867
+ describe('search leaf — compound names, other names, and word forms', () => {
868
+ it('finds a compound name typed as separate words inside a longer query', async () => {
869
+ const {data} = await search('table header cell with sort arrow', {cwd});
870
+ expect(
871
+ data.results.slice(0, 3).map(r => r.name),
872
+ ).toContain('TableHeaderCell');
873
+ }, SLOW);
874
+
875
+ it('finds a one-word keyword typed as separate words', async () => {
876
+ // MetadataList declares `keyvalue`.
877
+ const {data} = await search('key value pairs', {cwd});
878
+ expect(data.results[0]).toMatchObject({
879
+ domain: 'component',
880
+ name: 'MetadataList',
881
+ });
882
+ }, SLOW);
883
+
884
+ it('finds a hook named by its words without the `use` prefix', async () => {
885
+ const {data} = await search('persisted table column settings', {cwd});
886
+ expect(data.results[0]).toMatchObject({
887
+ domain: 'hook',
888
+ name: 'useTableColumnSettings',
889
+ });
890
+ }, SLOW);
891
+
892
+ it('reads a camelCase identifier as the words it spells', async () => {
893
+ const {data} = await search('useTablePaginationState', {cwd});
894
+ expect(data.results[0]).toMatchObject({
895
+ domain: 'hook',
896
+ name: 'useTablePagination',
897
+ });
898
+ }, SLOW);
899
+
900
+ it('keeps each identifier of a multi-word query whole', async () => {
901
+ const {data} = await search('ChatMessageList ChatComposer', {cwd});
902
+ expect(data.results.slice(0, 2).map(r => r.name).sort()).toEqual([
903
+ 'ChatComposer',
904
+ 'ChatMessageList',
905
+ ]);
906
+ }, SLOW);
907
+
908
+ it("finds a component by another library's name for it", async () => {
909
+ for (const [q, name] of [
910
+ ['ScrollArea viewport', 'ScrollableArea'],
911
+ ['toggle group of buttons', 'ToggleButtonGroup'],
912
+ ['circular progress for uploads', 'Spinner'],
913
+ ]) {
914
+ const {data} = await search(q, {cwd});
915
+ expect(data.results[0], q).toMatchObject({domain: 'component', name});
916
+ }
917
+ }, SLOW);
918
+
919
+ it('reads a one-identifier other name as typed', async () => {
920
+ const {data} = await search('AppHeader', {cwd});
921
+ expect(data.results[0]).toMatchObject({
922
+ domain: 'component',
923
+ name: 'LayoutHeader',
924
+ });
925
+ }, SLOW);
926
+
927
+ it('adds no near siblings beside an entry the query names outright', async () => {
928
+ const {data} = await search('top nav mega menu', {cwd});
929
+ expect(data.results[0]).toMatchObject({name: 'TopNavMegaMenu'});
930
+ // Its sub-parts start with its name; read joined they would score in the
931
+ // whole-name tier right under it.
932
+ const siblings = data.results.filter(
933
+ r => r.name !== 'TopNavMegaMenu' && r.name.startsWith('TopNavMegaMenu'),
934
+ );
935
+ for (const sibling of siblings) expect(sibling.score).toBeLessThan(190);
936
+ }, SLOW);
937
+
938
+ it('ranks an entry the query names above a match on part of another name', async () => {
939
+ // `table header` names TableHeader; useTableStickyColumns only holds the
940
+ // words `sticky columns`.
941
+ const {data} = await search('table header with sticky columns', {cwd});
942
+ const names = data.results.map(r => r.name);
943
+ expect(names.indexOf('TableHeader')).toBeLessThan(
944
+ names.indexOf('useTableStickyColumns') === -1
945
+ ? Infinity
946
+ : names.indexOf('useTableStickyColumns'),
947
+ );
948
+ }, SLOW);
949
+
950
+ it('keeps an entry a query word names by its whole name at its own rank', async () => {
951
+ // Two words join into TextInput, and TextArea also matches two words
952
+ // through its joined keyword; one word is still TextArea's whole name.
953
+ const {data} = await search('text input and textarea with multi line', {
954
+ cwd,
955
+ });
956
+ expect(data.results[0]).toMatchObject({name: 'TextArea'});
957
+ }, SLOW);
958
+
959
+ it('ranks a match on more of the query above a shorter named entry', async () => {
960
+ const {data} = await search('dropdown menu radio options', {cwd});
961
+ expect(data.results[0]).toMatchObject({name: 'DropdownMenuRadioItem'});
962
+ }, SLOW);
963
+
964
+ it('lets a plural start a name through its singular', () => {
965
+ expect(
966
+ scoreCandidate('tokens', {domain: 'component', name: 'Tokenizer'}),
967
+ ).toMatchObject({score: 60});
968
+ });
969
+
970
+ it('finds Icon by a glyph name', async () => {
971
+ for (const q of ['cog', 'lightning bolt']) {
972
+ const {data} = await search(q, {cwd});
973
+ expect(data.results[0], q).toMatchObject({
974
+ domain: 'component',
975
+ name: 'Icon',
976
+ });
977
+ }
978
+ }, SLOW);
979
+
980
+ it('matches a verb form against its verb', () => {
981
+ // "spacing" lost the final e of "space".
982
+ expect(
983
+ scoreCandidate('spacing', {domain: 'component', name: 'Gap', keywords: ['space']}),
984
+ ).toMatchObject({score: 88});
985
+ expect(
986
+ scoreCandidate('dragging', {domain: 'component', name: 'Gap', keywords: ['drag']}),
987
+ ).toMatchObject({score: 88});
988
+ });
989
+
990
+ it('lets a plural start a longer word through its singular', () => {
991
+ expect(
992
+ scoreCandidate('chips', {domain: 'component', name: 'Chipset'}),
993
+ ).toMatchObject({score: 60});
994
+ // A four-letter singular is too short: "stats" does not start "statusbar".
995
+ expect(
996
+ scoreCandidate('stats', {domain: 'component', name: 'Statusbar'}),
997
+ ).toBeNull();
998
+ });
999
+
1000
+ it('leaves an identifier joined with _ unmatched when nothing has it', async () => {
1001
+ const {data} = await search('qqzz_no_match_here', {cwd});
1002
+ expect(data.results).toEqual([]);
1003
+ }, SLOW);
1004
+ });
@@ -21,8 +21,10 @@ import {checkGhCli} from '../_github.mjs';
21
21
  import {Project} from '../../../foundation/config/project.mjs';
22
22
  import {
23
23
  CORE_PACKAGE,
24
- findIntegrationComponentDoc,
25
- findIntegrationComponentSource,
24
+ docStem,
25
+ findComponentReadme,
26
+ findIntegrationComponent,
27
+ unscopedComponentOwners,
26
28
  } from '../../../foundation/discovery/component-discovery.mjs';
27
29
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
28
30
  import {AstryxError, writeFailed} from '../../error.mjs';
@@ -126,33 +128,38 @@ async function loadConfigSafely(cwd) {
126
128
 
127
129
  /**
128
130
  * Build the set of OWNER packages that provide a component named `name` across
129
- * core + every loaded integration.
131
+ * core + every loaded integration. Core holds every name it documents, but has
132
+ * source to copy (`sourceDir`) only for a name with its own folder.
130
133
  * @param {string} coreDir
131
134
  * @param {Array<{name: string, components?: string, issuesUrl?: string}>} loadedIntegrations
132
135
  * @param {string} name
133
136
  * @param {string|undefined} coreIssuesUrl
134
- * @returns {Array<{package: string, sourceDir: string|null, ownerPackage: string, issuesUrl: string|undefined}>}
137
+ * @returns {Array<{package: string, sourceDir: string|null, docPath: string|null, ownerPackage: string, issuesUrl: string|undefined, hold: string}>}
135
138
  */
136
139
  function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
137
140
  const owners = [];
138
141
  const coreComponentDir = path.join(coreDir, 'src', name);
139
- if (fs.existsSync(coreComponentDir)) {
142
+ const coreFolder = fs.existsSync(coreComponentDir);
143
+ if (coreFolder || findComponentReadme(coreDir, name)) {
140
144
  owners.push({
141
145
  package: CORE_PACKAGE,
142
- sourceDir: coreComponentDir,
146
+ sourceDir: coreFolder ? coreComponentDir : null,
147
+ docPath: null,
143
148
  ownerPackage: CORE_PACKAGE,
144
149
  issuesUrl: coreIssuesUrl || DEFAULT_ISSUES_URL,
150
+ hold: 'core',
145
151
  });
146
152
  }
147
153
  for (const integration of loadedIntegrations) {
148
- const docPath = findIntegrationComponentDoc(integration, name);
149
- if (!docPath) continue;
150
- const sourcePath = findIntegrationComponentSource(integration, name);
154
+ const found = findIntegrationComponent(integration, name);
155
+ if (!found) continue;
151
156
  owners.push({
152
157
  package: integration.name,
153
- sourceDir: sourcePath ? path.dirname(sourcePath) : null,
158
+ sourceDir: found.sourcePath ? path.dirname(found.sourcePath) : null,
159
+ docPath: found.docPath,
154
160
  ownerPackage: integration.name,
155
161
  issuesUrl: integration.issuesUrl,
162
+ hold: found.hold,
156
163
  });
157
164
  }
158
165
  return owners;
@@ -222,25 +229,32 @@ export async function swizzleCopy(component, options = {}) {
222
229
  : undefined;
223
230
  // An active integration replacement answers to the Core name it replaces,
224
231
  // bare or qualified by its own package (spec:AST-035 FR11): copy it under its
225
- // own name. `--package @astryxdesign/core` still copies the Core original.
232
+ // doc's file name, the folder it has always gone to. `--package
233
+ // @astryxdesign/core` still copies the Core original.
226
234
  if (pkg !== CORE_PACKAGE) {
227
235
  const replacement = await resolveComponentReplacement(
228
236
  coreDir,
229
237
  loadedIntegrations,
230
238
  dirName,
231
239
  );
240
+ const copyName = replacement ? docStem(replacement.docPath) : null;
232
241
  if (
233
242
  replacement &&
234
- (!pkg || (pkg === replacement.package && replacement.name !== dirName))
243
+ copyName &&
244
+ (!pkg || (pkg === replacement.package && copyName !== dirName))
235
245
  ) {
236
- return swizzleCopy(replacement.name, {
246
+ return swizzleCopy(copyName, {
237
247
  ...options,
238
248
  package: replacement.package,
239
249
  });
240
250
  }
241
251
  }
242
252
 
243
- const allOwners = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
253
+ const found = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
254
+ // The owner rule, then a Core name without its own folder drops out.
255
+ const allOwners = (pkg ? found : unscopedComponentOwners(found)).filter(
256
+ owner => owner.package !== CORE_PACKAGE || owner.sourceDir != null,
257
+ );
244
258
 
245
259
  if (allOwners.length === 0) {
246
260
  throw new AstryxError(
@@ -13,6 +13,7 @@ import {themeBuild, themeBuildFamily} from './build.mjs';
13
13
  import {interceptCore} from './core-interception.mjs';
14
14
  import {ensureCoreBuilt} from '../../../clients/cli/commands/ensure-core-built.mjs';
15
15
  import {runCli} from '../../../test-utils/run-cli.mjs';
16
+ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
16
17
  vi.setConfig({testTimeout: 120_000});
17
18
  const CLI_ROOT = path.resolve(import.meta.dirname, '../../..');
18
19
  const FAMILY_KEY = 'ocean-family';
@@ -252,6 +253,69 @@ describe('themeBuildFamily()', () => {
252
253
  expect(fs.existsSync(dtsPath)).toBe(false);
253
254
  });
254
255
 
256
+ it('refuses to write family outputs outside cwd, and checks them read-only', async () => {
257
+ const dir = makeDir();
258
+ const files = writeFamily(dir).map(file => path.join(dir, file));
259
+ const app = path.join(dir, 'app');
260
+ fs.mkdirSync(app);
261
+
262
+ await expect(
263
+ themeBuildFamily(files, {familyKey: FAMILY_KEY}, {cwd: app}),
264
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
265
+ const checked = await themeBuildFamily(
266
+ files,
267
+ {familyKey: FAMILY_KEY, check: true},
268
+ {cwd: app},
269
+ );
270
+ expect(checked?.type).toBe('theme.build.check');
271
+ expect(checked?.data.upToDate).toBe(false);
272
+ // The `.css.d.ts` import stub may be absent in check mode.
273
+ expect(checked?.data.stale).toEqual(
274
+ OUTPUTS.filter(file => !file.endsWith('.css.d.ts')).map(file => ({
275
+ path: `../themes/${file}`,
276
+ reason: 'missing',
277
+ })),
278
+ );
279
+ for (const output of OUTPUTS) {
280
+ expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(false);
281
+ }
282
+ });
283
+
284
+ it('ends a stale family report for outputs outside cwd with a remedy that works', async () => {
285
+ const dir = makeDir();
286
+ const files = writeFamily(dir).map(file => path.join(dir, file));
287
+ const app = path.join(dir, 'app');
288
+ fs.mkdirSync(app);
289
+ const family = ['--family', ...files, '--family-key', FAMILY_KEY];
290
+
291
+ const stale = await runCli(['theme', 'build', ...family, '--check'], app);
292
+ expect(stale.code).toBe(1);
293
+ // The old remedy ran the build from cwd, which refuses these outputs.
294
+ expect(stale.stderr).not.toContain('Rebuild with:');
295
+
296
+ const lines = stale.stderr.split('\n').map(line => line.trim());
297
+ const runDir = lines.find(line => line.startsWith('cd '))?.slice(3);
298
+ expect(runDir).toBe(fs.realpathSync(path.join(dir, 'themes')));
299
+ const stem = `${getCliInvocation(runDir)} theme build `;
300
+ const [buildArgs, checkArgs] = lines
301
+ .filter(line => line.startsWith(stem))
302
+ .map(line => line.slice(stem.length).split(' '));
303
+ expect(checkArgs).toEqual([...buildArgs, '--check']);
304
+
305
+ // Following the remedy builds, and the check from there passes.
306
+ const built = await runCli(['theme', 'build', ...buildArgs], runDir);
307
+ expect(built.code).toBe(0);
308
+ for (const output of OUTPUTS) {
309
+ expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(true);
310
+ }
311
+ const checked = await runCli(
312
+ ['--json', 'theme', 'build', ...checkArgs],
313
+ runDir,
314
+ );
315
+ expect(checked.code).toBe(0);
316
+ expect(JSON.parse(checked.stdout).data.upToDate).toBe(true);
317
+ });
318
+
255
319
  it('fails without publishing a mixed trio when staging a write fails', async () => {
256
320
  const dir = makeDir();
257
321
  const files = writeFamily(dir);
@@ -27,6 +27,10 @@
27
27
  * files; an internal callback lets the CLI make only the process status fail.
28
28
  * Human progress is emitted through the shared `logger` (silent by default), so
29
29
  * the CLI keeps its exact output while a programmatic caller stays quiet.
30
+ * Every output a build writes, explicit or beside the theme file, stays inside
31
+ * `cwd` (`ERR_PATH_TRAVERSAL` otherwise). Check mode writes nothing, so it
32
+ * compares outputs outside `cwd` read-only; only a relative `out` path that
33
+ * escapes `cwd` is refused there too.
30
34
  *
31
35
  * The installed `@astryxdesign/core` is an independently versioned optional
32
36
  * peer, so it can be older than the CLI. A theme that uses only baseline
@@ -58,7 +62,10 @@ import {isBuiltin} from 'node:module';
58
62
  import {fileURLToPath, pathToFileURL} from 'node:url';
59
63
  import {parse} from '@babel/parser';
60
64
  import {createJiti} from 'jiti';
61
- import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
65
+ import {
66
+ formatCliCommand,
67
+ getCliInvocation,
68
+ } from '../../../foundation/env/package-manager.mjs';
62
69
  import {
63
70
  CLI_ROOT,
64
71
  findCoreDir,
@@ -270,6 +277,25 @@ function realOrSelf(file) {
270
277
  }
271
278
  }
272
279
 
280
+ /**
281
+ * `target` spelled through real directories: the deepest ancestor that exists
282
+ * is resolved through its symlinks, and the missing rest is appended as given.
283
+ * @param {string} target absolute path
284
+ * @returns {string}
285
+ */
286
+ function realLocation(target) {
287
+ /** @type {string[]} */
288
+ const missing = [];
289
+ let existing = target;
290
+ while (!fs.existsSync(existing)) {
291
+ const parent = path.dirname(existing);
292
+ if (parent === existing) break;
293
+ missing.unshift(path.basename(existing));
294
+ existing = parent;
295
+ }
296
+ return path.join(realOrSelf(existing), ...missing);
297
+ }
298
+
273
299
  /**
274
300
  * The Core the project at `cwd` installed, when it differs from the one the
275
301
  * CLI's own import found and loads. The app's `<Theme>` runs on the app's
@@ -506,6 +532,127 @@ function checkedBuildOutputs(writes, cwd) {
506
532
  .map(write => path.relative(cwd, write.dest));
507
533
  }
508
534
 
535
+ /** Remedies that end a confinement error, by where the output path came from. */
536
+ const OUT_REMEDY = 'Pass an --out path inside it.';
537
+ const SOURCE_REMEDY =
538
+ 'Without --out the outputs go beside the theme file, so run the build from a directory that contains the theme file, or pass an --out path inside the working directory.';
539
+ const FAMILY_REMEDY =
540
+ "Family outputs go beside the family's root theme file, so run the build from a directory that contains it.";
541
+
542
+ /**
543
+ * Refuse an output outside `root`: every write is confined, whether `out` names
544
+ * the path or it defaults to the theme file's directory (architecture:cli-surface
545
+ * INV10). Both sides are compared by real location, so an absolute path that
546
+ * reaches the working directory through a symlink is inside it, and a symlink
547
+ * inside it that leads elsewhere is not.
548
+ * @param {string} dest absolute output path
549
+ * @param {string} root the directory writes stay inside: the working directory
550
+ * @param {string} remedy what to do instead; ends the error message
551
+ * @param {string} [shown] the path as the caller wrote it, for the message
552
+ */
553
+ function confineOutput(dest, root, remedy, shown = dest) {
554
+ try {
555
+ assertWithin(realLocation(dest), realOrSelf(path.resolve(root)), {
556
+ allowAbsolute: true,
557
+ label: 'output path',
558
+ });
559
+ } catch (err) {
560
+ if (!(err instanceof PathSafetyError)) throw err;
561
+ throw new AstryxError(
562
+ err.code === ERROR_CODES.ERR_PATH_TRAVERSAL
563
+ ? `Output path "${shown}" is outside the working directory (${root}). ${remedy}`
564
+ : err.message,
565
+ undefined,
566
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
567
+ );
568
+ }
569
+ }
570
+
571
+ /** @param {Array<{dest: string}>} writes @param {string} root @param {string} remedy */
572
+ function confineOutputs(writes, root, remedy) {
573
+ for (const write of writes) confineOutput(write.dest, root, remedy);
574
+ }
575
+
576
+ /** @param {string} dest absolute output path @param {string} root @returns {boolean} whether a build at `root` refuses to write `dest` */
577
+ function refusedOutput(dest, root) {
578
+ try {
579
+ confineOutput(dest, root, '');
580
+ return false;
581
+ } catch (err) {
582
+ if (
583
+ err instanceof AstryxError &&
584
+ err.code === ERROR_CODES.ERR_PATH_TRAVERSAL
585
+ )
586
+ return true;
587
+ throw err;
588
+ }
589
+ }
590
+
591
+ /** @param {string[]} dirs absolute directories @returns {string | null} the deepest directory containing all of them, or null across roots */
592
+ function commonDirectory(dirs) {
593
+ let common = dirs[0];
594
+ for (const dir of dirs.slice(1)) {
595
+ while (
596
+ path.relative(common, dir).startsWith('..') ||
597
+ path.isAbsolute(path.relative(common, dir))
598
+ ) {
599
+ const parent = path.dirname(common);
600
+ if (parent === common) return null;
601
+ common = parent;
602
+ }
603
+ }
604
+ return common;
605
+ }
606
+
607
+ /** @param {string} value @returns {string} `value` as one shell word */
608
+ function shellWord(value) {
609
+ if (/^[A-Za-z0-9@%+=:,./_-]+$/.test(value)) return value;
610
+ return `'${value.replace(/'/g, `'\\''`)}'`;
611
+ }
612
+
613
+ /**
614
+ * How a stale check report ends when outputs sit outside the write root. A
615
+ * build there refuses them, and each output's `Source:` header records the
616
+ * directory its build ran from, so the build and the check both run from the
617
+ * nearest directory that contains every theme file and output.
618
+ * @param {Array<{dest: string}>} writes
619
+ * @param {string[]} sources absolute theme files the build reads
620
+ * @param {string} root the directory a build here keeps its writes inside
621
+ * @param {(relative: (file: string) => string) => string[]} args the arguments after `theme build`, given each path relative to the directory to run from
622
+ * @returns {string[] | null} report lines, or null when every output is inside `root`
623
+ */
624
+ function outsideCheckRemedy(writes, sources, root, args) {
625
+ if (!writes.some(write => refusedOutput(write.dest, root))) return null;
626
+ const real = (/** @type {string} */ file) => realLocation(path.resolve(file));
627
+ const dir = commonDirectory([
628
+ ...sources.map(file => path.dirname(real(file))),
629
+ ...writes.map(write => path.dirname(real(write.dest))),
630
+ ]);
631
+ if (!dir) {
632
+ return [
633
+ 'These outputs are outside the working directory, so a build here refuses them. Build and check them from one directory that contains the theme files and the outputs.',
634
+ ];
635
+ }
636
+ const command = (/** @type {string[]} */ extra) =>
637
+ formatCliCommand(
638
+ [
639
+ 'theme',
640
+ 'build',
641
+ ...args(file => path.relative(dir, real(file))),
642
+ ...extra,
643
+ ]
644
+ .map(shellWord)
645
+ .join(' '),
646
+ dir,
647
+ );
648
+ return [
649
+ 'These outputs are outside the working directory, so a build here refuses them, and each output records the directory it was built from. Build and check them from a directory that contains them:',
650
+ ` cd ${shellWord(dir)}`,
651
+ ` ${command([])}`,
652
+ ` ${command(['--check'])}`,
653
+ ];
654
+ }
655
+
509
656
  /** @param {Array<{dest: string, content: string}>} writes */
510
657
  function writeBuildOutputs(writes) {
511
658
  if (writes.length === 0) return;
@@ -2344,9 +2491,10 @@ function validateHeadingTypeAugmentationSupport(themeDef) {
2344
2491
  * `logger` (silent by default).
2345
2492
  *
2346
2493
  * @param {string} file - Theme file path, resolved against `cwd`.
2347
- * @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void}} [options] -
2494
+ * @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void, __writeRoot?: string}} [options] -
2348
2495
  * `out` overrides the output CSS path; `check` compares against on-disk outputs
2349
- * instead of writing. `iconsSpecifier` overrides the icon registry import
2496
+ * instead of writing. `__writeRoot` replaces `cwd` as the directory every
2497
+ * output must stay inside, for a CLI-internal scratch build. `iconsSpecifier` overrides the icon registry import
2350
2498
  * specifier in the generated module (e.g. `./icons.mjs`); when omitted, the
2351
2499
  * direct registry specifiers from the theme source are emitted unchanged.
2352
2500
  * @param {{cwd?: string}} [ctx]
@@ -2791,24 +2939,17 @@ async function themeBuildInternal(
2791
2939
  // Derive the default CSS name from the theme name so .css/.js/.d.ts
2792
2940
  // share one scheme; an explicit --out still wins.
2793
2941
  const baseName = themeDef.name;
2942
+ // Every write stays inside cwd. A CLI-internal caller that renders into a
2943
+ // scratch directory it creates and removes names that directory instead.
2944
+ const writeRoot = options.__writeRoot ?? cwd;
2794
2945
  let outPath;
2795
2946
  if (options.out) {
2796
2947
  outPath = path.resolve(cwd, options.out);
2797
- // Guard: relative paths must not escape cwd via `../`. Absolute paths are
2798
- // trusted (the user explicitly controls where output goes, like gcc -o).
2799
- if (!path.isAbsolute(options.out)) {
2800
- try {
2801
- assertWithin(options.out, cwd, {label: 'output path'});
2802
- } catch (err) {
2803
- if (err instanceof PathSafetyError) {
2804
- throw new AstryxError(
2805
- err.message,
2806
- undefined,
2807
- ERROR_CODES.ERR_PATH_TRAVERSAL,
2808
- );
2809
- }
2810
- throw err;
2811
- }
2948
+ // A build keeps the explicit output inside the write root, relative or
2949
+ // absolute. Check mode writes nothing, so it refuses only a relative path
2950
+ // that escapes cwd.
2951
+ if (!options.check || !path.isAbsolute(options.out)) {
2952
+ confineOutput(outPath, writeRoot, OUT_REMEDY, options.out);
2812
2953
  }
2813
2954
  } else {
2814
2955
  outPath = path.join(path.dirname(filePath), `${baseName}.css`);
@@ -2905,6 +3046,13 @@ async function themeBuildInternal(
2905
3046
  writes.push({dest: variantDtsPath, content: variantContent});
2906
3047
  }
2907
3048
 
3049
+ // Every output a build writes stays inside cwd, including the defaults
3050
+ // beside the theme file. Check mode writes nothing, so it compares outputs
3051
+ // outside cwd read-only.
3052
+ if (!options.check) {
3053
+ confineOutputs(writes, writeRoot, options.out ? OUT_REMEDY : SOURCE_REMEDY);
3054
+ }
3055
+
2908
3056
  // Check mode: compare generated content against what's on disk instead of
2909
3057
  // writing. A file is "stale" if it's missing or its content differs once the
2910
3058
  // volatile @generated `Command:` line is ignored. Returns a
@@ -2923,7 +3071,10 @@ async function themeBuildInternal(
2923
3071
  ` ${s.reason === 'missing' ? 'missing' : 'stale'}: ${s.path}`,
2924
3072
  );
2925
3073
  }
2926
- logger.error(`\n Rebuild with: ${buildCommand}`);
3074
+ // prettier-ignore
3075
+ const remedy = outsideCheckRemedy(writes, [filePath], writeRoot, relative => [relative(filePath), ...(options.out ? ['--out', relative(outPath)] : []), ...(options.iconsSpecifier ? ['--icons-specifier', options.iconsSpecifier] : [])]);
3076
+ if (remedy) logger.error(`\n ${remedy.join('\n ')}`);
3077
+ else logger.error(`\n Rebuild with: ${buildCommand}`);
2927
3078
  }
2928
3079
  return {
2929
3080
  type: 'theme.build.check',
@@ -3036,7 +3187,7 @@ function printBuildTrailer(trailer) {
3036
3187
  logger.log(`
3037
3188
  Install in your app (paths are relative to a file in src/; adjust if yours lives elsewhere):
3038
3189
 
3039
- import { Theme } from '@astryxdesign/core';
3190
+ import { Theme } from '@astryxdesign/core/theme';
3040
3191
  import { ${trailer.exportName} } from '${trailer.jsImport}';
3041
3192
  import '${trailer.cssImport}';
3042
3193
 
@@ -3046,7 +3197,7 @@ Install in your app (paths are relative to a file in src/; adjust if yours lives
3046
3197
 
3047
3198
  Or with a <link> tag:
3048
3199
 
3049
- import { Theme } from '@astryxdesign/core';
3200
+ import { Theme } from '@astryxdesign/core/theme';
3050
3201
  import { ${trailer.exportName} } from '${trailer.jsImport}';
3051
3202
 
3052
3203
  <link rel="stylesheet" href="${trailer.cssImport}" />
@@ -3072,7 +3223,7 @@ export function printBatchTrailer(trailers) {
3072
3223
  logger.log(`
3073
3224
  Install in your app (paths are relative to a file in src/; adjust if yours lives elsewhere):
3074
3225
 
3075
- import { Theme } from '@astryxdesign/core';
3226
+ import { Theme } from '@astryxdesign/core/theme';
3076
3227
  import { ${first.exportName} } from '${first.jsImport}';
3077
3228
  import '${first.cssImport}';
3078
3229
 
@@ -3082,7 +3233,7 @@ Install in your app (paths are relative to a file in src/; adjust if yours lives
3082
3233
 
3083
3234
  Or with a <link> tag:
3084
3235
 
3085
- import { Theme } from '@astryxdesign/core';
3236
+ import { Theme } from '@astryxdesign/core/theme';
3086
3237
  import { ${first.exportName} } from '${first.jsImport}';
3087
3238
 
3088
3239
  <link rel="stylesheet" href="${first.cssImport}" />
@@ -3449,6 +3600,8 @@ export async function themeBuildFamily(
3449
3600
  js: path.relative(cwd, jsPath),
3450
3601
  dts: path.relative(cwd, dtsPath),
3451
3602
  };
3603
+ // A family build's outputs stay inside cwd too; check mode only reads them.
3604
+ if (!options.check) confineOutputs(writes, cwd, FAMILY_REMEDY);
3452
3605
  if (options.check) {
3453
3606
  const stale = staleBuildOutputs(writes, cwd);
3454
3607
  const upToDate = stale.length === 0;
@@ -3465,7 +3618,10 @@ export async function themeBuildFamily(
3465
3618
  ` ${entry.reason === 'missing' ? 'missing' : 'stale'}: ${entry.path}`,
3466
3619
  );
3467
3620
  }
3468
- logger.error(`\n Rebuild with: ${buildCommand}`);
3621
+ // prettier-ignore
3622
+ const remedy = outsideCheckRemedy(writes, members.map(member => member.filePath), cwd, relative => ['--family', ...members.map(member => relative(member.filePath)), '--family-key', baseName, ...(options.iconsSpecifier ? ['--icons-specifier', options.iconsSpecifier] : [])]);
3623
+ if (remedy) logger.error(`\n ${remedy.join('\n ')}`);
3624
+ else logger.error(`\n Rebuild with: ${buildCommand}`);
3469
3625
  }
3470
3626
  return {
3471
3627
  type: 'theme.build.check',