@astryxdesign/cli 0.7.2-canary.e2909cf → 0.7.2-canary.e4bdd70

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 (194) 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/integration-export-name.test.mjs +644 -0
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +67 -4
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +51 -97
  33. package/api/integration/pack-check.test.mjs +359 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.d.mts +22 -6
  40. package/api/search/search.domain-priority.test.mjs +93 -18
  41. package/api/search/search.integration-check.test.mjs +24 -0
  42. package/api/search/search.mjs +133 -22
  43. package/api/swizzle/copy/copy.mjs +28 -14
  44. package/api/theme/build/build.family.test.mjs +54 -6
  45. package/api/theme/build/build.mjs +114 -17
  46. package/api/theme/themeBuild.doc.mjs +4 -4
  47. package/assets/docs/README.md +10 -0
  48. package/assets/docs/author-a-theme.doc.mjs +1 -1
  49. package/assets/docs/getting-started.doc.mjs +3 -3
  50. package/assets/docs/styling-overview.doc.mjs +1 -1
  51. package/assets/docs/theme.doc.dense.mjs +1 -1
  52. package/assets/docs/theme.doc.mjs +1 -1
  53. package/assets/docs/theme.doc.zh.mjs +1 -1
  54. package/assets/docs/tree/add-a-component.doc.mjs +11 -1
  55. package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
  56. package/assets/docs/tree/add-a-topic.doc.mjs +10 -7
  57. package/assets/docs/tree/agent-guidance.doc.mjs +2 -2
  58. package/assets/docs/tree/block-template.doc.mjs +8 -20
  59. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  60. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  61. package/assets/docs/tree/check-your-docs.doc.mjs +51 -5
  62. package/assets/docs/tree/checks.doc.mjs +24 -16
  63. package/assets/docs/tree/codemods.doc.mjs +4 -4
  64. package/assets/docs/tree/component-family.doc.mjs +8 -6
  65. package/assets/docs/tree/component-imports.doc.mjs +10 -2
  66. package/assets/docs/tree/components.doc.mjs +1 -1
  67. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +7 -18
  68. package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
  69. package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
  70. package/assets/docs/tree/docs.doc.mjs +3 -3
  71. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  72. package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
  73. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  74. package/assets/docs/tree/extend-or-replace.doc.mjs +6 -6
  75. package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
  76. package/assets/docs/tree/generate-a-palette.doc.mjs +5 -3
  77. package/assets/docs/tree/grade-template-with-agent.doc.mjs +1 -1
  78. package/assets/docs/tree/integrations.doc.mjs +8 -0
  79. package/assets/docs/tree/links.doc.mjs +6 -5
  80. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  81. package/assets/docs/tree/page-template.doc.mjs +4 -8
  82. package/assets/docs/tree/publishing.doc.mjs +1 -1
  83. package/assets/docs/tree/quick-start.doc.mjs +37 -21
  84. package/assets/docs/tree/replace-a-core-component.doc.mjs +4 -6
  85. package/assets/docs/tree/replace-a-core-template.doc.mjs +6 -8
  86. package/assets/docs/tree/sections-and-placement.doc.mjs +4 -6
  87. package/assets/docs/tree/see-it-in-an-app.doc.mjs +4 -2
  88. package/assets/docs/tree/short-and-findable.doc.mjs +6 -5
  89. package/assets/docs/tree/single-component.doc.mjs +15 -6
  90. package/assets/docs/tree/start-a-template.doc.mjs +3 -3
  91. package/assets/docs/tree/subcomponent.doc.mjs +6 -6
  92. package/assets/docs/tree/template-assets.doc.mjs +2 -2
  93. package/assets/docs/tree/template-doc-overview.doc.mjs +4 -15
  94. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  95. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  96. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  97. package/assets/docs/tree/template-styles.doc.mjs +2 -2
  98. package/assets/docs/tree/templates.doc.mjs +1 -1
  99. package/assets/docs/tree/test-in-an-app.doc.mjs +3 -3
  100. package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
  101. package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
  102. package/assets/docs/tree/troubleshooting.doc.mjs +13 -5
  103. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  104. package/assets/docs/tree/upgrading.doc.mjs +3 -3
  105. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +3 -1
  106. package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
  107. package/assets/docs/tree/versioning.doc.mjs +7 -6
  108. package/assets/docs/tree/write-the-template-file.doc.mjs +4 -3
  109. package/assets/docs/use-a-theme.doc.mjs +2 -2
  110. package/assets/theme.template.ts +1 -1
  111. package/authoring/codemod/codemod.doc.mjs +7 -3
  112. package/authoring/config/config.doc.mjs +9 -6
  113. package/authoring/config/type.ts +4 -2
  114. package/authoring/discover/discover.doc.mjs +1 -1
  115. package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
  116. package/authoring/doctypes/command/command.doc.mjs +14 -12
  117. package/authoring/doctypes/component/component.doc.mjs +29 -26
  118. package/authoring/doctypes/component/type.ts +13 -8
  119. package/authoring/doctypes/enum/enum.doc.mjs +7 -4
  120. package/authoring/doctypes/function/function.doc.mjs +9 -8
  121. package/authoring/doctypes/hook/hook.doc.mjs +5 -4
  122. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
  123. package/authoring/doctypes/reference/reference.doc.mjs +11 -10
  124. package/authoring/doctypes/schema/schema.doc.mjs +9 -9
  125. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  126. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  127. package/authoring/doctypes/template/parse.d.mts +6 -2
  128. package/authoring/doctypes/template/parse.mjs +3 -1
  129. package/authoring/doctypes/template/template.doc.mjs +3 -3
  130. package/authoring/doctypes/template/type.ts +22 -0
  131. package/authoring/doctypes/theme/theme.doc.mjs +2 -2
  132. package/authoring/gap-report/gap-report.doc.mjs +2 -2
  133. package/authoring/identity/identity.doc.mjs +3 -3
  134. package/authoring/index.d.ts +1 -0
  135. package/authoring/integration/integration.doc.mjs +12 -4
  136. package/authoring/integration/parse.test.mjs +10 -0
  137. package/authoring/integration/schema.d.mts +2 -0
  138. package/authoring/integration/schema.mjs +1 -0
  139. package/authoring/integration/type.ts +4 -0
  140. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  141. package/clients/cli/commands/build-theme.mjs +1 -1
  142. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  143. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  144. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  145. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  146. package/clients/cli/commands/doctor.mjs +190 -0
  147. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  148. package/clients/cli/commands/integration-check.test.mjs +267 -0
  149. package/clients/cli/commands/integration.doc.mjs +6 -2
  150. package/clients/cli/commands/integration.mjs +107 -1
  151. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  152. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  153. package/clients/cli/index.mjs +1 -0
  154. package/foundation/config/project.mjs +82 -0
  155. package/foundation/discovery/authoring-self-docs.mjs +2 -1
  156. package/foundation/discovery/component-discovery.d.mts +178 -10
  157. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  158. package/foundation/discovery/component-discovery.mjs +659 -62
  159. package/foundation/discovery/component-replacement.mjs +18 -0
  160. package/foundation/discovery/component-replacement.test.mjs +40 -0
  161. package/foundation/discovery/doc-types.d.mts +157 -0
  162. package/foundation/discovery/doc-types.mjs +1224 -0
  163. package/foundation/discovery/doc-types.test.mjs +652 -0
  164. package/foundation/discovery/docs-discovery.d.mts +81 -0
  165. package/foundation/discovery/docs-discovery.mjs +57 -0
  166. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  167. package/foundation/discovery/integration-doc-files.mjs +231 -0
  168. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  169. package/foundation/discovery/module-exports.d.mts +27 -0
  170. package/foundation/discovery/module-exports.mjs +356 -31
  171. package/foundation/discovery/module-exports.test.mjs +172 -0
  172. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  173. package/foundation/discovery/package-tsconfig.mjs +228 -0
  174. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  175. package/foundation/discovery/project-typescript.d.mts +23 -0
  176. package/foundation/discovery/project-typescript.mjs +61 -0
  177. package/foundation/discovery/prop-drift.d.mts +278 -0
  178. package/foundation/discovery/prop-drift.mjs +2263 -0
  179. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  180. package/foundation/discovery/type-match.d.mts +53 -0
  181. package/foundation/discovery/type-match.mjs +660 -0
  182. package/foundation/discovery/type-match.test.mjs +169 -0
  183. package/foundation/discovery/type-text.d.mts +68 -0
  184. package/foundation/discovery/type-text.mjs +658 -0
  185. package/foundation/discovery/type-text.test.mjs +196 -0
  186. package/foundation/discovery/unresolved-references.d.mts +62 -0
  187. package/foundation/discovery/unresolved-references.mjs +790 -0
  188. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  189. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  190. package/foundation/doc-compiler/links.d.mts +9 -5
  191. package/foundation/doc-compiler/links.mjs +4 -2
  192. package/foundation/integrations/contribution-inventory.mjs +6 -1
  193. package/foundation/response/response-types.doc.mjs +7 -2
  194. package/package.json +9 -9
@@ -75,14 +75,75 @@ async function placedGuides(route, seen = new Set()) {
75
75
  }
76
76
 
77
77
  describe('search domain priority — the rule', () => {
78
- it('gives a doc no priority for a keyword or heading match', () => {
78
+ it('gives a doc no priority for a heading match or a one-word keyword', () => {
79
79
  const topic = {
80
80
  domain: 'doc',
81
81
  name: 'typography/font-setup',
82
- keywords: ['font size'],
82
+ keywords: ['size'],
83
83
  titles: ['Font Setup', 'Font Sizes'],
84
84
  };
85
85
  expect(priority('font size', topic)).toBe(0);
86
+ expect(priority('size', topic)).toBe(0);
87
+ });
88
+
89
+ it('gives a doc priority when the query is one of its declared keywords', () => {
90
+ const guide = {
91
+ domain: 'doc',
92
+ name: 'guides/quality',
93
+ titles: ['Is my code good?'],
94
+ keywords: ['code review', 'review my code'],
95
+ _declared: ['code review', 'review my code'],
96
+ };
97
+ expect(priority('code review', guide)).toBe(1);
98
+ expect(priority('Code Reviews', guide)).toBe(1);
99
+ expect(priority('review my code', guide)).toBe(1);
100
+ // A section's keywords are its headings and code terms.
101
+ const section = {
102
+ ...guide,
103
+ name: 'before-you-ship',
104
+ _topic: 'guides/quality',
105
+ _section: 'before-you-ship',
106
+ };
107
+ expect(priority('code review', section)).toBe(0);
108
+ });
109
+
110
+ it("gives a doc no priority for a code term or table cell it didn't declare", () => {
111
+ // A typed doc's keywords hold the first column of its tables and the code
112
+ // terms its text mentions; `missing_field` reads as two words.
113
+ const reference = {
114
+ domain: 'doc',
115
+ name: 'cli/api/functions/validate-integration',
116
+ titles: ['validateIntegration'],
117
+ keywords: ['validate-integration', 'missing_field', 'file required'],
118
+ _declared: [],
119
+ _topic: 'cli/api/functions/validate-integration',
120
+ };
121
+ expect(priority('missing field', reference)).toBe(0);
122
+ expect(priority('file required', reference)).toBe(0);
123
+ expect(
124
+ priority('missing field', {...reference, _declared: ['missing field']}),
125
+ ).toBe(1);
126
+ });
127
+
128
+ it('keeps a component the query names exactly above a doc that declares it', async () => {
129
+ const tokens = tokenizeQuery('empty state');
130
+ const component = {domain: 'component', name: 'EmptyState'};
131
+ const doc = {
132
+ domain: 'doc',
133
+ name: 'illustrations',
134
+ keywords: ['empty state'],
135
+ _declared: ['empty state'],
136
+ };
137
+ const c = scoreQuery('empty state', tokens, component);
138
+ const d = scoreQuery('empty state', tokens, doc);
139
+ expect(
140
+ domainPriority('empty state', tokens, /** @type {any} */ (component), c),
141
+ ).toBe(1);
142
+ expect(
143
+ domainPriority('empty state', tokens, /** @type {any} */ (doc), d),
144
+ ).toBe(1);
145
+ // Both have priority, so the stronger match wins: the exact name.
146
+ expect(c?.score).toBeGreaterThan(d?.score ?? 0);
86
147
  });
87
148
 
88
149
  it('gives a doc priority when the query names it', () => {
@@ -190,32 +251,46 @@ describe('search domain priority — the rule', () => {
190
251
 
191
252
  describe('search domain priority — real docs and components', () => {
192
253
  it(
193
- 'finds the Text component before the typography guides for `font size`',
254
+ 'finds a guide that declares the whole phrase above a component that matches one word',
194
255
  async () => {
195
- for (const q of ['font size', 'font weight']) {
196
- const results = (await search(q, {cwd})).data.results;
197
- expect(results[0], q).toMatchObject({
198
- domain: 'component',
199
- name: 'Text',
200
- });
201
- const guide = results.findIndex(
202
- r => r.domain === 'doc' && /^typography(?:\/|$)/.test(r.name),
203
- );
204
- // The guide still matches, below the component.
205
- expect(guide, q).toBeGreaterThan(0);
256
+ const results = (await search('font size', {cwd})).data.results;
257
+ expect(results[0]).toMatchObject({domain: 'doc'});
258
+ expect(results[0].name).toMatch(/^typography(?:\/|$)/);
259
+ // The component the phrase points to stays near the top.
260
+ expect(
261
+ results
262
+ .slice(0, 3)
263
+ .some(r => r.domain === 'component' && r.name === 'Text'),
264
+ ).toBe(true);
265
+ },
266
+ SLOW,
267
+ );
268
+
269
+ it(
270
+ 'keeps a component above a reference doc whose table or code terms hold the phrase',
271
+ async () => {
272
+ for (const q of ['missing field', 'file required']) {
273
+ const [top] = (await search(q, {cwd})).data.results;
274
+ expect(top, q).toMatchObject({domain: 'component', name: 'Field'});
206
275
  }
207
276
  },
208
277
  SLOW,
209
278
  );
210
279
 
211
280
  it(
212
- 'reports the text-match score unchanged',
281
+ 'keeps a component above docs that match only by a heading, with scores unchanged',
213
282
  async () => {
214
283
  const results = (await search('font size', {cwd})).data.results;
215
- const guide = results.find(
216
- r => r.domain === 'doc' && /^typography(?:\/|$)/.test(r.name),
284
+ const text = results.findIndex(
285
+ r => r.domain === 'component' && r.name === 'Text',
217
286
  );
218
- expect(guide?.score).toBeGreaterThan(results[0].score);
287
+ expect(text).toBeGreaterThanOrEqual(0);
288
+ // A doc below Text outscores it on text alone: priority moved it, not the score.
289
+ expect(
290
+ results
291
+ .slice(text + 1)
292
+ .some(r => r.domain === 'doc' && r.score > results[text].score),
293
+ ).toBe(true);
219
294
  },
220
295
  SLOW,
221
296
  );
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A reader looking for the check a build runs finds
5
+ * `astryx integration check` first.
6
+ */
7
+
8
+ import {describe, expect, it} from 'vitest';
9
+ import {search} from './search.mjs';
10
+
11
+ describe('search finds integration check for CI', () => {
12
+ it.each([
13
+ 'ci',
14
+ 'continuous integration',
15
+ 'typecheck integration',
16
+ 'integration check',
17
+ ])('`%s` ranks it first', async query => {
18
+ const {results} = (await search(query)).data;
19
+ expect(results[0], JSON.stringify(results.slice(0, 3))).toMatchObject({
20
+ domain: 'doc',
21
+ name: 'cli/commands/integration-check',
22
+ });
23
+ });
24
+ });
@@ -57,9 +57,10 @@
57
57
  * Between domains, ranking adds one rule ahead of the score: domain priority
58
58
  * (see {@link domainPriority}). A component, hook, template, or theme whose
59
59
  * name or keyword a query word hits ranks ahead of every doc the reader did not
60
- * ask for by name or title, so `font size` finds `Text` before a typography
61
- * topic that declares the phrase. A doc the query names, or whose title the
62
- * query holds, keeps its place.
60
+ * ask for by name, title, or a declared phrase, so `font size` finds `Text`
61
+ * before a token page whose heading holds the phrase. A doc the query names,
62
+ * whose title the query holds, or that declares the whole query as a keyword
63
+ * keeps its place.
63
64
  *
64
65
  * Description and guidance are separate tiers on purpose. A component's own
65
66
  * one-line description saying "notification" is a claim about what it IS; the
@@ -81,12 +82,20 @@
81
82
 
82
83
  import {withListingCache} from '../../foundation/fs/listing-cache.mjs';
83
84
  import {readDocView} from '../../foundation/doc-compiler/read.mjs';
84
- import {findCoreDir} from '../../foundation/fs/paths.mjs';
85
+ import {
86
+ discoverExternalPackages,
87
+ findCoreDir,
88
+ } from '../../foundation/fs/paths.mjs';
85
89
  import {
86
90
  discoverComponents,
87
91
  discoverValidIntegrationComponents,
92
+ docStem,
88
93
  familyMember,
89
94
  findComponentReadme,
95
+ forEachIntegrationDoc,
96
+ integrationMemberEntry,
97
+ integrationRecordHold,
98
+ isIntegrationMember,
90
99
  resolveImportPath,
91
100
  resolveIntegrationImportPath,
92
101
  CORE_PACKAGE,
@@ -98,6 +107,7 @@ import {
98
107
  import {
99
108
  loadComponentReplacements,
100
109
  loadIntegrationsSafely,
110
+ resolveLegacyExternalDoc,
101
111
  } from '../component/_adapter.mjs';
102
112
  import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
103
113
  import {discoverTemplates, extractComponents} from '../template/template.mjs';
@@ -129,6 +139,9 @@ import {setResultCoverage} from './coverage.mjs';
129
139
  * @property {string[]} [aliases] - Other names the candidate answers to, scored
130
140
  * with the same name signals: a theme's display name.
131
141
  * @property {string[]} [keywords]
142
+ * @property {string[]} [_declared] - A doc's own declared keywords: what its
143
+ * author lists in `keywords`, without the code terms and table cells its
144
+ * other keywords add.
132
145
  * @property {string[]} [weakKeywords]
133
146
  * @property {string} [description]
134
147
  * @property {string[]} [prose]
@@ -150,6 +163,9 @@ import {setResultCoverage} from './coverage.mjs';
150
163
  * @property {'page'|'block'} [_kind]
151
164
  * @property {string} [_resultName]
152
165
  * @property {string} [_commandName]
166
+ * @property {'file'|'doc'} [_hold] - How an integration component holds its
167
+ * name, for the owner rule.
168
+ * @property {string} [_docPath] - An integration component's doc.
153
169
  */
154
170
 
155
171
  /**
@@ -501,19 +517,20 @@ const STRONG_TOKEN_SCORE = 70;
501
517
  * typography/font-setup, `side panel` is "Side panels");
502
518
  * - 1: a word of the query is the topic's name (`illustration` in a longer
503
519
  * question is the illustrations guide);
520
+ * - 1: a query of two or more words is one of the topic's own declared
521
+ * keywords, whatever the plural (`code review` on a guide that declares it);
504
522
  * - 1: the query holds one of the doc's titles whole (`resizable side panels`
505
523
  * names "Side panels"; `light dark mode button` names "Light/Dark Mode");
506
524
  * - 1: the doc matches every word of the query and one of them is a word of
507
525
  * its title (`switch to a dark theme` and "Use a theme").
508
526
  * A section answers to its topic's name.
509
527
  *
510
- * A doc that matched only by keyword, by a heading holding the query, or by
528
+ * A doc that matched only by a heading holding the query, by one word, or by
511
529
  * words spread through its text has no priority. Docs are split into many
512
- * small topics, and each declares its own keywords and headings, so a common
513
- * phrase such as `font size` hits a guide's keyword or heading exactly
514
- * (170-190) while the component the reader is after matches one word by
515
- * keyword (`Text`, 98). Ranked on text alone, every split adds another doc
516
- * above the component. Broad reference pages match every word of many queries
530
+ * small topics, each with its own headings, so a common phrase such as
531
+ * `font size` hits a heading exactly (170) while the component the reader is
532
+ * after matches one word by keyword (`Text`, 98). Ranked on text alone, every
533
+ * split adds another doc above the component. Broad reference pages match every word of many queries
517
534
  * in their text the same way. The score stays the text-match strength the
518
535
  * result reports, so callers that gate on it (`build`) see the same numbers.
519
536
  *
@@ -539,6 +556,20 @@ export function domainPriority(term, tokens, candidate, hit) {
539
556
  if (named.some(n => samePhrase(term, n))) {
540
557
  return 2;
541
558
  }
559
+ // A query of two or more words that is one of the doc's own declared
560
+ // keywords, read as words whatever the plural, is the author naming the
561
+ // doc for that phrase: `code review` on a guide that declares it. Only
562
+ // what the author declared counts: the code terms and table cells a
563
+ // doc's other keywords add (`missing_field`) name what it mentions, not
564
+ // what it is for. One word is left out too, and a section declares
565
+ // nothing.
566
+ if (
567
+ phraseWords(term).length >= 2 &&
568
+ candidate._section == null &&
569
+ (candidate._declared ?? []).some(k => samePhrase(term, String(k)))
570
+ ) {
571
+ return 1;
572
+ }
542
573
  if (
543
574
  words.some(
544
575
  w =>
@@ -1110,20 +1141,57 @@ async function gatherCoreComponents(coreDir) {
1110
1141
  * usage/description from each component's .doc.mjs, same as core. Without
1111
1142
  * this, an integration component is invisible to `search`/`build` even
1112
1143
  * though `component --list`/`component <Name>` already resolve it — the two
1113
- * discovery paths silently disagreed.
1144
+ * discovery paths silently disagreed. Also returns, per name, the docs that
1145
+ * hold it by their file name and the loadable docs that only record it.
1114
1146
  * @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
1115
- * @returns {Promise<Candidate[]>}
1147
+ * @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
1116
1148
  */
1117
1149
  async function gatherIntegrationComponents(loadedIntegrations) {
1118
1150
  /** @type {Candidate[]} */
1119
1151
  const candidates = [];
1152
+ // What the owner rule reads: the docs that hold a name by their file name,
1153
+ // loadable or not and hidden ones too, and the loadable docs that only
1154
+ // record it.
1155
+ /** @type {Map<string, Set<string>>} */
1156
+ const fileNamed = new Map();
1157
+ /** @type {Map<string, Set<string>>} */
1158
+ const documented = new Map();
1159
+ /** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
1160
+ const hold = (map, name, docPath) =>
1161
+ map.set(name, (map.get(name) ?? new Set()).add(docPath));
1120
1162
  for (const integration of loadedIntegrations) {
1121
- const {components} = await discoverValidIntegrationComponents(integration);
1163
+ const {components, discovered} =
1164
+ await discoverValidIntegrationComponents(integration);
1165
+ const renamed = new Set(
1166
+ discovered
1167
+ .filter(rec => integrationRecordHold(rec) === 'doc')
1168
+ .filter(rec => !isIntegrationMember(rec))
1169
+ .map(rec => rec.docPath),
1170
+ );
1171
+ forEachIntegrationDoc(integration.components, (docPath, stem) => {
1172
+ if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
1173
+ });
1122
1174
  for (const rec of components) {
1123
- const doc = await loadModuleDoc(rec.docPath);
1175
+ const loaded = await loadModuleDoc(rec.docPath);
1176
+ // A family member is indexed from its own entry: the family's keywords
1177
+ // and guidance describe the family.
1178
+ const member = isIntegrationMember(rec);
1179
+ const doc = member ? integrationMemberEntry(loaded, rec) : loaded;
1180
+ // A doc that names another export of its file still answers to the
1181
+ // file's name, as `component <file name>` does.
1182
+ const fileName = docStem(rec.docPath);
1183
+ if (integrationRecordHold(rec) === 'doc') {
1184
+ hold(documented, rec.name, rec.docPath);
1185
+ }
1186
+ if (renamed.has(rec.docPath) && !member) {
1187
+ hold(documented, fileName, rec.docPath);
1188
+ }
1124
1189
  candidates.push({
1125
1190
  domain: 'component',
1126
1191
  name: rec.name,
1192
+ aliases: !member && fileName !== rec.name ? [fileName] : [],
1193
+ _hold: integrationRecordHold(rec),
1194
+ _docPath: rec.docPath,
1127
1195
  keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
1128
1196
  description: doc ? doc.usage?.description || doc.description || '' : '',
1129
1197
  guidance: guidanceFrom(doc),
@@ -1143,12 +1211,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1143
1211
  packageName: rec.package,
1144
1212
  },
1145
1213
  rec.name,
1146
- doc?.import,
1214
+ loaded?.import,
1147
1215
  ),
1148
1216
  });
1149
1217
  }
1150
1218
  }
1151
- return candidates;
1219
+ return {candidates, fileNamed, documented};
1152
1220
  }
1153
1221
 
1154
1222
  /**
@@ -1160,11 +1228,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1160
1228
  */
1161
1229
  async function gatherComponents(coreDir, cwd) {
1162
1230
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1163
- const [core, integrations, replacements] = await Promise.all([
1231
+ const [core, gathered, replacements] = await Promise.all([
1164
1232
  gatherCoreComponents(coreDir),
1165
1233
  gatherIntegrationComponents(loadedIntegrations),
1166
1234
  loadComponentReplacements(coreDir, loadedIntegrations),
1167
1235
  ]);
1236
+ const {candidates: integrations, fileNamed, documented} = gathered;
1168
1237
  // An active replacement answers to the Core name it replaces, so a search
1169
1238
  // for that name finds the replacement and not the Core original
1170
1239
  // (spec:AST-035 FR11).
@@ -1175,9 +1244,43 @@ async function gatherComponents(coreDir, cwd) {
1175
1244
  active.target,
1176
1245
  ]),
1177
1246
  );
1247
+ // The owner rule, as component detail applies it: a candidate `component
1248
+ // <name>` does not open names its package and follows the owner, and a
1249
+ // file-name alias that opens another owner is dropped.
1250
+ const coreNames = new Set(core.map(candidate => candidate.name));
1251
+ /** @param {string} name @param {Candidate} candidate */
1252
+ const opens = (name, candidate) =>
1253
+ !coreNames.has(name) &&
1254
+ findComponentReadme(coreDir, name) == null &&
1255
+ [...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
1256
+ [...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
1257
+ // A legacy docs package keeps an exact name over an export name a doc records.
1258
+ /** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
1259
+ let externals;
1260
+ /** @param {string} name */
1261
+ const legacyDocuments = name =>
1262
+ (externals ??= discoverExternalPackages(cwd)).some(
1263
+ ext => resolveLegacyExternalDoc(ext, name) != null,
1264
+ );
1265
+ const ruled = integrations.map(candidate => {
1266
+ const aliases = (candidate.aliases ?? []).filter(alias =>
1267
+ opens(alias, candidate),
1268
+ );
1269
+ return candidate._hold === 'doc' &&
1270
+ (!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
1271
+ ? {
1272
+ ...candidate,
1273
+ aliases,
1274
+ _command: `astryx component ${candidate.name} --package ${candidate._package}`,
1275
+ }
1276
+ : {...candidate, aliases};
1277
+ });
1178
1278
  return [
1179
1279
  ...core.filter(candidate => !replacements.forTarget(candidate.name)),
1180
- ...integrations.map(candidate => {
1280
+ ...[
1281
+ ...ruled.filter(candidate => !candidate._command),
1282
+ ...ruled.filter(candidate => candidate._command),
1283
+ ].map(candidate => {
1181
1284
  const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
1182
1285
  if (target != null) {
1183
1286
  return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
@@ -1244,10 +1347,13 @@ export async function componentKeywords(coreDir, cwd) {
1244
1347
  const integrations = loadedIntegrations.map(async integration => {
1245
1348
  const {components} = await discoverValidIntegrationComponents(integration);
1246
1349
  return Promise.all(
1247
- components.map(async rec => ({
1248
- name: rec.name,
1249
- keywords: keywordsOf(await loadModuleDoc(rec.docPath)),
1250
- })),
1350
+ components.map(async rec => {
1351
+ const loaded = await loadModuleDoc(rec.docPath);
1352
+ return {
1353
+ name: rec.name,
1354
+ keywords: keywordsOf(integrationMemberEntry(loaded, rec) ?? loaded),
1355
+ };
1356
+ }),
1251
1357
  );
1252
1358
  });
1253
1359
  return [
@@ -1421,6 +1527,10 @@ async function gatherDocs(cwd) {
1421
1527
  ...defined,
1422
1528
  ...codeTerms({content}),
1423
1529
  ],
1530
+ _declared: [
1531
+ ...(Array.isArray(selfDoc?.keywords) ? selfDoc.keywords : []),
1532
+ ...(Array.isArray(node.keywords) ? node.keywords : []),
1533
+ ],
1424
1534
  description: node.summary || '',
1425
1535
  prose: sectionProse({title: node.title, content}),
1426
1536
  titles: [node.title],
@@ -1557,6 +1667,7 @@ function topicCandidates(
1557
1667
  ...(doc?.title || title ? [doc?.title || title] : []),
1558
1668
  ...(Array.isArray(doc?.keywords) ? doc.keywords : []),
1559
1669
  ],
1670
+ _declared: Array.isArray(doc?.keywords) ? doc.keywords : [],
1560
1671
  description: doc?.description || summary,
1561
1672
  prose: split
1562
1673
  ? sections.map(section => section.title).filter(Boolean)
@@ -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,22 +253,69 @@ describe('themeBuildFamily()', () => {
252
253
  expect(fs.existsSync(dtsPath)).toBe(false);
253
254
  });
254
255
 
255
- it('refuses family outputs outside cwd before checking or writing', async () => {
256
+ it('refuses to write family outputs outside cwd, and checks them read-only', async () => {
256
257
  const dir = makeDir();
257
258
  const files = writeFamily(dir).map(file => path.join(dir, file));
258
259
  const app = path.join(dir, 'app');
259
260
  fs.mkdirSync(app);
260
261
 
261
- for (const check of [false, true]) {
262
- await expect(
263
- themeBuildFamily(files, {familyKey: FAMILY_KEY, check}, {cwd: app}),
264
- ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
265
- }
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
+ );
266
279
  for (const output of OUTPUTS) {
267
280
  expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(false);
268
281
  }
269
282
  });
270
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
+
271
319
  it('fails without publishing a mixed trio when staging a write fails', async () => {
272
320
  const dir = makeDir();
273
321
  const files = writeFamily(dir);