@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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 (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -0,0 +1,351 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The lexical ranker: keyword and fuzzy scoring of every gathered
5
+ * candidate, as one pure function (`rankLexical`). `searchCore` (search.mjs)
6
+ * runs it alone, and search's searchers read its ranking. No I/O: the adapter
7
+ * gathers the candidates. Word handling lives in _words.mjs, _synonyms.mjs and
8
+ * _query.mjs; a candidate's score in _score.mjs and _candidate-score.mjs.
9
+ *
10
+ *
11
+ * Returns the same typed envelope { type, data } that `xds --json search`
12
+ * outputs. The CLI command handler is a thin wrapper around this function.
13
+ *
14
+ * Scoring is keyword + fuzzy ranking; the per-term signal weights are
15
+ * listed in _candidate-score.mjs.
16
+ *
17
+ * A multi-word query has a reserved top tier (see {@link scoreQuery}): the
18
+ * whole query as a candidate's name or keyword (190-200), then the whole query
19
+ * as a phrase inside a doc's title or one of its headings (170), then a whole
20
+ * title of two words or more inside the query (160-169), then a candidate that
21
+ * matches every word of the query, at least one of them by name or keyword
22
+ * (151-159). Below those sits everything else: a partial match, or every word
23
+ * matched only in prose or through the components a page renders. A section titled "Light/Dark Mode" answers `dark
24
+ * mode` better than any doc that merely names `mode` in code, however exactly;
25
+ * "Dark mode" answers `how do I add dark mode`; and a guide whose title and
26
+ * description hold both words of `troubleshoot integration` answers it better
27
+ * than a doc named `integration`.
28
+ *
29
+ * Between domains, ranking adds one rule ahead of the score: domain priority
30
+ * (see {@link domainPriority}). A component, hook, template, or theme whose
31
+ * name or keyword a query word hits ranks ahead of every doc the reader did not
32
+ * ask for by name, title, or a declared phrase, so `font size` finds `Text`
33
+ * before a token page whose heading holds the phrase. A doc the query names,
34
+ * whose title the query holds, or that declares the whole query as a keyword
35
+ * keeps its place.
36
+ *
37
+ * Description and guidance are separate tiers on purpose. A component's own
38
+ * one-line description saying "notification" is a claim about what it IS; the
39
+ * same word inside another component's best-practice advice is a passing
40
+ * mention. Scored equally, `Toast` — "a brief, non-blocking notification" —
41
+ * ties with `Card`, `Dialog` and `Item`, which merely mention notifications in
42
+ * their guidance, and ties break alphabetically, so Toast falls off the end of
43
+ * its own best query.
44
+ *
45
+ * `keywords` carry AUTHORED intent — a block's `componentsUsed`, a page's
46
+ * `category` words. `weakKeywords` are DERIVED: the components a page template
47
+ * happens to render, scraped out of its JSX. Derived signal is deliberately
48
+ * capped below the confident-match gate on its own, because breadth is not
49
+ * relevance. At full keyword strength every rendered component is an
50
+ * independent 90-point shot with no penalty for how many a template has, so
51
+ * the broadest pages (a theme showcase rendering one of everything) win
52
+ * queries they have nothing to do with.
53
+ */
54
+
55
+ import {spellIdentifiers, joined} from './_words.mjs';
56
+ import {tokenizeQuery, phraseWords} from './_query.mjs';
57
+ import {MAX_RUN_WORDS} from './_compound.mjs';
58
+ import {scoreQuery} from './_score.mjs';
59
+ import {scoreCandidate} from './_candidate-score.mjs';
60
+ import {domainPriority} from './_priority.mjs';
61
+ import {toResult, searchedComponentsOf} from './_result.mjs';
62
+
63
+ /**
64
+ * A search candidate gathered from one content domain. Extra underscore-
65
+ * prefixed fields carry domain-specific payload used only by {@link toResult}.
66
+ * @typedef {object} Candidate
67
+ * @property {'component'|'hook'|'doc'|'template'|'theme'} domain
68
+ * @property {string} name
69
+ * @property {string[]} [aliases] - Other names the candidate answers to, scored
70
+ * with the same name signals: a theme's display name.
71
+ * @property {string[]} [keywords]
72
+ * @property {string[]} [_declared] - A doc's own declared keywords: what its
73
+ * author lists in `keywords`, without the code terms and table cells its
74
+ * other keywords add.
75
+ * @property {string[]} [weakKeywords]
76
+ * @property {string} [description]
77
+ * @property {string[]} [prose]
78
+ * @property {string[]} [guidance]
79
+ * @property {string[]} [titles] - A doc's title and the headings inside it:
80
+ * the lines a reader scans to pick it. The whole query standing in one of
81
+ * them, or one of them standing whole in the query, is a top-tier match.
82
+ * @property {string} [_import]
83
+ * @property {string} [_title]
84
+ * @property {string} [_topic] - A doc result's topic or docs-tree route.
85
+ * @property {string} [_section] - A doc result's section key, when it is one section.
86
+ * @property {string} [_command] - The command that reads exactly this doc part.
87
+ * @property {string} [_parent] - The command that opens the level above a doc
88
+ * part: its topic's section list, or the docs-tree namespace it sits in.
89
+ * @property {string} [_package] - The npm package that owns the candidate:
90
+ * Core's package for its components, hooks, and templates, the integration's
91
+ * for what it contributed, and for a doc part the package that wrote it.
92
+ * @property {string} [_displayName]
93
+ * @property {'page'|'block'} [_kind]
94
+ * @property {string} [_resultName]
95
+ * @property {string} [_commandName]
96
+ * @property {'file'|'doc'} [_hold] - How an integration component holds its
97
+ * name, for the owner rule.
98
+ * @property {string} [_docPath] - An integration component's doc.
99
+ */
100
+
101
+ /**
102
+ * True when the query is the candidate's whole name or another name of it,
103
+ * typed as is or, for anything but a doc, with its spaces removed. Such a
104
+ * query names that entry outright.
105
+ * @param {Candidate} candidate
106
+ * @param {string} term the query, trimmed and lowercased
107
+ * @returns {boolean}
108
+ */
109
+ export function namesWhole(candidate, term) {
110
+ return [candidate.name, ...(candidate.aliases ?? [])].some(name => {
111
+ const lower = String(name).toLowerCase();
112
+ return (
113
+ lower === term ||
114
+ (candidate.domain !== 'doc' &&
115
+ !/[\s_-]/.test(lower) &&
116
+ lower === term.replace(/\s+/g, ''))
117
+ );
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Today's lexical ranking of a gathered catalog: the search response
123
+ * `searchCore` returns.
124
+ *
125
+ * @param {string} query
126
+ * @param {{limit?: number}} options
127
+ * @param {{all: Candidate[], components: Candidate[], searchedComponents: boolean, outside?: boolean, coreSkipped?: string | null}} catalog
128
+ * @returns {import('./search.type.mjs').SearchResponse}
129
+ */
130
+ export function rankLexical(query, options, catalog) {
131
+ const {limit = 20} = options;
132
+ const term = String(query).trim().toLowerCase();
133
+ const {all, components} = catalog;
134
+
135
+ // A query that is one identifier counts as the words it spells
136
+ // (`SidebarToggle`), and still matches whole as typed. In a query of several
137
+ // words, each identifier stays one term (`ChatComposer ChatLayout` names two
138
+ // components), and its words still join into names (see compoundHit). Both
139
+ // only help a query that names no entry outright: when the query is an
140
+ // entry's name, typed whole or as its words, that entry answers it, and
141
+ // neither reading may add near siblings beside it.
142
+ const spelled = spellIdentifiers(String(query).trim());
143
+ const exact = all.some(candidate => namesWhole(candidate, term));
144
+ // One identifier is read as its words only when, read whole, it matches
145
+ // nothing: a prop or hook name a doc already mentions keeps the
146
+ // entries that mention it.
147
+ const split =
148
+ !exact &&
149
+ !/\s/.test(term) &&
150
+ spelled !== term &&
151
+ !all.some(candidate => scoreCandidate(term, candidate));
152
+ const tokens = tokenizeQuery(split ? spelled : term);
153
+ /** @type {string[]} */
154
+ const words = exact
155
+ ? []
156
+ : phraseWords(split || /\s/.test(term) ? spelled : term);
157
+ // The entries the query names: a component, hook, or template whose whole
158
+ // name is adjacent query words joined (`icon button in a card header` names
159
+ // IconButton), or one identifier typed as such. A name inside a longer named
160
+ // run does not count (`icon` there is part of IconButton).
161
+ const queryWords = phraseWords(spelled);
162
+ const typedIdentifiers = new Set(
163
+ (String(query).match(/[A-Za-z0-9]+/g) ?? [])
164
+ .filter(w => /[A-Z]/.test(w))
165
+ .map(w => w.toLowerCase()),
166
+ );
167
+ /** @type {Map<string, [number, number]>} each run joined, longest first */
168
+ const spans = new Map();
169
+ for (let len = Math.min(queryWords.length, MAX_RUN_WORDS); len >= 1; len--) {
170
+ for (let a = 0; a + len <= queryWords.length; a++) {
171
+ // One plain word names too much (`list`, `field`); a named entry is a
172
+ // run of joined words, or an identifier typed as one.
173
+ if (len === 1 && !typedIdentifiers.has(queryWords[a])) continue;
174
+ const run = queryWords.slice(a, a + len).join('');
175
+ if (!spans.has(run)) spans.set(run, [a, a + len]);
176
+ }
177
+ }
178
+ /**
179
+ * Where a query word is the candidate's whole name, or null: such an entry
180
+ * keeps the rank its name earns, whatever else it matched.
181
+ * @param {Candidate} candidate
182
+ * @returns {[number, number] | null}
183
+ */
184
+ const wordNamed = candidate => {
185
+ if (candidate.domain === 'doc') return null;
186
+ for (const name of [candidate.name, ...(candidate.aliases ?? [])]) {
187
+ const n = joined(name);
188
+ const at = queryWords.findIndex(w => w === n || w === `${n}s`);
189
+ if (at >= 0) return [at, at + 1];
190
+ }
191
+ return null;
192
+ };
193
+ /** @param {Candidate} candidate @returns {[number, number] | null} */
194
+ const namedSpan = candidate => {
195
+ if (candidate.domain === 'doc') return null;
196
+ /** @type {[number, number] | null} */
197
+ let best = null;
198
+ for (const name of [candidate.name, ...(candidate.aliases ?? [])]) {
199
+ const n = joined(name);
200
+ const span =
201
+ spans.get(n) ??
202
+ spans.get(`${n}s`) ??
203
+ (/(?:s|x|z|ch|sh)$/.test(n) ? spans.get(`${n}es`) : undefined);
204
+ if (span && (!best || span[1] - span[0] > best[1] - best[0])) {
205
+ best = span;
206
+ }
207
+ }
208
+ return best;
209
+ };
210
+
211
+ // Score every candidate on its own merits. The consumer groups results by
212
+ // role (page / block / component) and takes the top of each, so there's no
213
+ // cross-role competition to engineer — a target page only needs to be the
214
+ // strongest PAGE, not outrank every component.
215
+ /** @type {{result: any, priority: 0 | 1 | 2, key: number, alias: 'partial' | 'keyword' | false, aliasWords: number, candidate: Candidate, span: [number, number] | null, word: [number, number] | null}[]} */
216
+ const ranked = [];
217
+ for (const candidate of all) {
218
+ const hit = scoreQuery(term, tokens, candidate, words);
219
+ if (hit)
220
+ ranked.push({
221
+ result: toResult(
222
+ candidate,
223
+ hit.score,
224
+ hit.reason,
225
+ hit.matched,
226
+ hit.total,
227
+ catalog.outside,
228
+ ),
229
+ priority: domainPriority(term, tokens, candidate, hit, words),
230
+ key: hit.score,
231
+ alias: hit.alias ?? false,
232
+ aliasWords: hit.aliasWords ?? 0,
233
+ candidate,
234
+ span: namedSpan(candidate),
235
+ word: wordNamed(candidate),
236
+ });
237
+ }
238
+ // A match on a keyword read joined never outranks an entry the query names,
239
+ // and neither does a match on part of another name unless it spans more
240
+ // query words than any named run: `table header with sticky columns` keeps
241
+ // TableHeader above useTableStickyColumns, which only holds the words
242
+ // `sticky columns`. Only the order changes; each result keeps the score its
243
+ // text earned.
244
+ /** @param {[number, number]} inner @param {[number, number]} outer */
245
+ const inside = (inner, outer) =>
246
+ outer[0] <= inner[0] &&
247
+ outer[1] >= inner[1] &&
248
+ outer[1] - outer[0] > inner[1] - inner[0];
249
+ const named = ranked.filter(r => {
250
+ const span = r.span;
251
+ return (
252
+ span != null &&
253
+ !ranked.some(o => o !== r && o.span != null && inside(span, o.span))
254
+ );
255
+ });
256
+ /** @param {{key: number}[]} rows */
257
+ const floorOf = rows =>
258
+ rows.length ? Math.min(...rows.map(r => r.key)) : Infinity;
259
+ const namedFloor = floorOf(named);
260
+ const longestNamed = Math.max(
261
+ 0,
262
+ ...named.map(r => (r.span ? r.span[1] - r.span[0] : 0)),
263
+ );
264
+ for (const r of ranked) {
265
+ if (named.includes(r)) continue;
266
+ if (!r.alias || r.word) continue;
267
+ // A match on part of a longer name that spans more words than any named
268
+ // run reads more of the query than the named entry does (`dropdown menu
269
+ // radio` and DropdownMenuRadioItem, against DropdownMenu), so it is not
270
+ // held back. A joined keyword always is.
271
+ if (r.alias === 'partial' && r.aliasWords > longestNamed) continue;
272
+ // It keeps what it earns without those matches.
273
+ const own =
274
+ scoreQuery(term, tokens, r.candidate, words, {plain: true})?.score ?? 0;
275
+ r.key = Math.max(own, Math.min(r.key, namedFloor - 0.5));
276
+ }
277
+
278
+ // Docs and everything else are each sorted by score desc, then domain
279
+ // (stable order), then name. The two are merged: at each step the stronger
280
+ // head goes next, except that a head with domain priority goes ahead of a
281
+ // doc head without it (see domainPriority). Each side keeps its own order.
282
+ /** @type {Record<string, number>} */
283
+ const domainOrder = {component: 0, hook: 1, doc: 2, template: 3, theme: 4};
284
+ /**
285
+ * @param {{result: any, key: number}} x
286
+ * @param {{result: any, key: number}} y
287
+ */
288
+ const byScore = ({result: a, key: ka}, {result: b, key: kb}) =>
289
+ kb - ka ||
290
+ (domainOrder[a.domain] ?? 9) - (domainOrder[b.domain] ?? 9) ||
291
+ a.name.localeCompare(b.name);
292
+ const others = ranked.filter(r => r.result.domain !== 'doc').sort(byScore);
293
+ // Against other domains, a doc the reader asked for competes ahead of docs
294
+ // they did not, so it is not held behind a broad page that outscores it.
295
+ // Docs alone keep their text-match order, with a doc the whole query names
296
+ // first.
297
+ const docs = ranked
298
+ .filter(r => r.result.domain === 'doc')
299
+ .sort((x, y) =>
300
+ others.length > 0
301
+ ? y.priority - x.priority || byScore(x, y)
302
+ : Number(y.priority === 2) - Number(x.priority === 2) || byScore(x, y),
303
+ );
304
+ const scored = [];
305
+ let i = 0;
306
+ let j = 0;
307
+ while (i < others.length || j < docs.length) {
308
+ const other = others[i];
309
+ const doc = docs[j];
310
+ const otherFirst =
311
+ !doc ||
312
+ (other != null &&
313
+ ((other.priority && !doc.priority) || byScore(other, doc) < 0));
314
+ scored.push(otherFirst ? others[i++].result : docs[j++].result);
315
+ }
316
+
317
+ // `results` is bounded by `limit` so a caller (and the recorded run that
318
+ // quotes it) never carries an unbounded payload. `matchCount` is the number
319
+ // of matches that bound was applied TO — reporting `results.length` there
320
+ // would report the cap back as if it were the answer, so a query matching
321
+ // 57 things and one matching exactly 20 would be indistinguishable.
322
+ const limited = scored.slice(0, limit);
323
+
324
+ /** @type {import('./search.type.mjs').SearchResponse} */
325
+ const response = {
326
+ type: 'search',
327
+ data: {
328
+ query: String(query).trim(),
329
+ matchCount: scored.length,
330
+ // toResult gives every domain its command and domain fields.
331
+ results: /** @type {import('./search.type.mjs').SearchResultEntry[]} */ (
332
+ limited
333
+ ),
334
+ },
335
+ };
336
+ if (catalog.coreSkipped) {
337
+ response.meta = {
338
+ partial: {
339
+ skipped: ['component', 'hook', 'template'],
340
+ reason: catalog.coreSkipped,
341
+ },
342
+ };
343
+ }
344
+ if (catalog.searchedComponents) {
345
+ searchedComponentsOf.set(
346
+ response,
347
+ components.map(c => ({name: c.name, keywords: c.keywords ?? []})),
348
+ );
349
+ }
350
+ return response;
351
+ }
@@ -0,0 +1,19 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * Ranks with one searcher's weights. Shared by the main searcher and every
6
+ * topic searcher, which differ only in their weights.
7
+ * @param {import('./tree.mjs').SearchRequest} request
8
+ * @param {import('./tree.mjs').SearchCatalog} catalog
9
+ * @param {SearcherWeights} weights
10
+ * @returns {import('./search.type.mjs').SearchResponse}
11
+ */
12
+ export function rankWith(request: import("./tree.mjs").SearchRequest, catalog: import("./tree.mjs").SearchCatalog, weights: SearcherWeights): import("./search.type.mjs").SearchResponse;
13
+ export function asWeights(weights: unknown): SearcherWeights | null;
14
+ export const main: import("../../foundation/route/route.mjs").Searcher<import("./tree.mjs").SearchRequest, import("./_adapter.mjs").SearchCatalog, import("./search.type.mjs").SearchResponse>;
15
+ export type SearcherWeights = {
16
+ w: number[];
17
+ mu: number[];
18
+ sd: number[];
19
+ };
@@ -0,0 +1,115 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The main searcher: ranks every entry by a weighted sum of plain
5
+ * signals (see _features.mjs): the lexical ranker's score and rank, BM25 over
6
+ * each entry's name, docs text and source-built card, query-word coverage,
7
+ * whether the query holds the entry's name, and the router's guess of the
8
+ * domain the query wants. Its weights are static numbers in
9
+ * _main-searcher.weights.json.
10
+ * @input {query, options} and the gathered catalog with its static data
11
+ * @output the search response, the lexical response's shape and size
12
+ * @position api/search; a child of the root dispatcher in tree.mjs
13
+ */
14
+
15
+ import {routeKit} from '../../foundation/route/route.mjs';
16
+ import {rank, signals} from './_features.mjs';
17
+ import {entryTable} from './_entries.mjs';
18
+ import {orderMatches} from './_order.mjs';
19
+ import {routeProbs} from './_query-words.mjs';
20
+ import {namesWhole, rankLexical} from './_lexical.mjs';
21
+
22
+ /** @typedef {{w: number[], mu: number[], sd: number[]}} SearcherWeights */
23
+
24
+ /**
25
+ * A tuning override for a searcher, or null for its shipped weights.
26
+ * @param {unknown} weights
27
+ * @returns {SearcherWeights | null}
28
+ */
29
+ export const asWeights = weights =>
30
+ /** @type {SearcherWeights | null} */ (weights);
31
+
32
+ /**
33
+ * A name read loosely: lowercase, with dashes, underscores and slashes read as
34
+ * spaces.
35
+ * @param {string} text
36
+ * @returns {string}
37
+ */
38
+ const looseName = text =>
39
+ text
40
+ .toLowerCase()
41
+ .replace(/[-_/]+/g, ' ')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+
45
+ /**
46
+ * True when the query, read loosely, is the candidate's name, another name of
47
+ * it, its title, or the last part of its route (`use-a-theme` typed as
48
+ * `use a theme`). Such a query names that entry too, though the lexical
49
+ * ranker's own gate (namesWhole) needs the name exactly.
50
+ * @param {import('./_lexical.mjs').Candidate} candidate
51
+ * @param {string} loose the query, read loosely
52
+ * @returns {boolean}
53
+ */
54
+ function namesLoosely(candidate, loose) {
55
+ if (!loose) return false;
56
+ const names = [
57
+ candidate.name,
58
+ ...(candidate.aliases ?? []),
59
+ candidate._title,
60
+ candidate._topic,
61
+ candidate._resultName,
62
+ candidate._commandName,
63
+ ];
64
+ return names.some(name => {
65
+ if (!name) return false;
66
+ const whole = looseName(String(name));
67
+ const last = looseName(String(name).split('/').pop() ?? '');
68
+ return whole === loose || last === loose;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Ranks with one searcher's weights. Shared by the main searcher and every
74
+ * topic searcher, which differ only in their weights.
75
+ * @param {import('./tree.mjs').SearchRequest} request
76
+ * @param {import('./tree.mjs').SearchCatalog} catalog
77
+ * @param {SearcherWeights} weights
78
+ * @returns {import('./search.type.mjs').SearchResponse}
79
+ */
80
+ export function rankWith(request, catalog, weights) {
81
+ const {query, options} = request;
82
+ // A query that is an entry's whole name gets the lexical answer as is: that
83
+ // entry first, with no near siblings added beside it.
84
+ const term = String(query).trim().toLowerCase();
85
+ const loose = looseName(term);
86
+ if (catalog.all.some(c => namesWhole(c, term) || namesLoosely(c, loose))) {
87
+ return rankLexical(query, options, catalog);
88
+ }
89
+ const table = entryTable(catalog.all, catalog.data.cards);
90
+ const lexical = rankLexical(
91
+ query,
92
+ {...options, limit: Number.MAX_SAFE_INTEGER},
93
+ catalog,
94
+ );
95
+ const route = routeProbs(catalog.data.router, query);
96
+ const rows = signals(table, query, lexical.data.results, route);
97
+ return orderMatches(
98
+ table,
99
+ rank(table, rows, weights),
100
+ lexical,
101
+ query,
102
+ options.limit ?? 20,
103
+ );
104
+ }
105
+
106
+ /** @type {import('./tree.mjs').SearchKit} */
107
+ const {defineSearcher} = routeKit;
108
+
109
+ export const main = defineSearcher({
110
+ id: 'main',
111
+ summary:
112
+ 'Ranks every component, hook, doc, template, and theme by a weighted sum of plain word-match signals.',
113
+ run: (request, catalog, weights) =>
114
+ rankWith(request, catalog, asWeights(weights) ?? catalog.data.main),
115
+ });
@@ -0,0 +1,106 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Tests for the main searcher (_main-searcher.mjs) through search():
5
+ * it ranks by plain signals, answers the same every time, never grows the
6
+ * response past the lexical ranker's, and touches no network. Run against the
7
+ * real @astryxdesign/core.
8
+ */
9
+
10
+ import {describe, it, expect, vi, afterEach} from 'vitest';
11
+ import * as path from 'node:path';
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import net from 'node:net';
15
+ import {fileURLToPath} from 'node:url';
16
+ import {search, searchCore} from './search.mjs';
17
+
18
+ const REPO = path.resolve(
19
+ path.dirname(fileURLToPath(import.meta.url)),
20
+ '../../../..',
21
+ );
22
+ const QUERIES = [
23
+ 'date chooser',
24
+ 'show a confirmation before deleting',
25
+ 'toast notice for a saved draft',
26
+ 'grid of rows you can sort by column',
27
+ 'how do I install a theme',
28
+ 'page of settings with a side menu',
29
+ 'Bookmarkz',
30
+ 'qzxv',
31
+ ];
32
+ /** @param {unknown} r */
33
+ const top = r =>
34
+ /** @type {any} */ (r).data.results.map(x => `${x.domain}:${x.name}`);
35
+
36
+ afterEach(() => vi.restoreAllMocks());
37
+
38
+ describe('main searcher', () => {
39
+ it('finds a component from what it does', async () => {
40
+ expect(
41
+ top(await search('show a confirmation before deleting', {cwd: REPO}))[0],
42
+ ).toBe('component:AlertDialog');
43
+ expect(top(await search('date chooser', {cwd: REPO}))[0]).toBe(
44
+ 'component:DateInput',
45
+ );
46
+ });
47
+
48
+ it('answers a whole-name query exactly as the lexical ranker does', async () => {
49
+ for (const q of ['Button', 'Dialog', 'useTheme', 'tokens']) {
50
+ expect(top(await search(q, {cwd: REPO}))).toEqual(
51
+ top(await searchCore(q, {cwd: REPO})),
52
+ );
53
+ }
54
+ });
55
+
56
+ it('answers a name typed with spaces for its dashes as the lexical ranker does', async () => {
57
+ for (const q of ['author a theme', 'tokens data']) {
58
+ expect(top(await search(q, {cwd: REPO}))).toEqual(
59
+ top(await searchCore(q, {cwd: REPO})),
60
+ );
61
+ }
62
+ });
63
+
64
+ it('answers the same every time', async () => {
65
+ for (const q of QUERIES.slice(0, 3)) {
66
+ expect(top(await search(q, {cwd: REPO}))).toEqual(
67
+ top(await search(q, {cwd: REPO})),
68
+ );
69
+ }
70
+ });
71
+
72
+ it(
73
+ 'never returns more results or more bytes than the lexical ranker',
74
+ {timeout: 120_000},
75
+ async () => {
76
+ let mine = 0;
77
+ let core = 0;
78
+ for (const q of QUERIES) {
79
+ const [a, b] = [
80
+ await search(q, {cwd: REPO}),
81
+ await searchCore(q, {cwd: REPO}),
82
+ ];
83
+ expect(a.data.results.length).toBeLessThanOrEqual(
84
+ b.data.results.length,
85
+ );
86
+ mine += JSON.stringify(a).length;
87
+ core += JSON.stringify(b).length;
88
+ }
89
+ expect(mine).toBeLessThanOrEqual(core);
90
+ },
91
+ );
92
+
93
+ it('touches no network', async () => {
94
+ const fail = () => {
95
+ throw new Error('search reached the network');
96
+ };
97
+ vi.spyOn(http, 'request').mockImplementation(fail);
98
+ vi.spyOn(https, 'request').mockImplementation(fail);
99
+ vi.spyOn(net, 'connect').mockImplementation(fail);
100
+ vi.spyOn(net.Socket.prototype, 'connect').mockImplementation(fail);
101
+ vi.spyOn(globalThis, 'fetch').mockImplementation(fail);
102
+ expect(
103
+ top(await search('toast notice for a saved draft', {cwd: REPO}))[0],
104
+ ).toBe('component:Toast');
105
+ });
106
+ });
@@ -0,0 +1 @@
1
+ {"w":[0.1414,-0.004648,0.300865,0.056056,0.392736,0.10546,-0.147371,-0.284355,0.209928,0.04428,0.047343,0.064332,0.189238,-0.023495,-0.108461,0.121128,0.0,0.004331,0.0008,0.088292,0.072215,0.1392,0.24194,0.008913,-0.000386,0.006744,-0.003374,-0.001714,-0.001373,0.046662,0.011763,0.051916,0.155163,0.00274,-0.018366,-0.017329,0.166314,0.186888,0.021662,0.066943,0.046315,0.0,0.0,0.0,0.222553,0.09503,0.121672,0.164462,-0.00524,0.050195,0.059338,0.325721,0.112113,0.05238,0.010799,0.002423,0.003703,0.000179,0.00274],"mu":[0.130015,0.004813,0.187932,0.01647,0.147234,0.050213,0.178723,0.617872,0.005957,0.150607,0.600003,0.113058,0.050851,0.000815,0.06067,0.179093,0.074597,0.001104,1.1e-05,0.016463,0.00663,0.038722,0.068087,0.000114,0.001394,0.000461,0.001084,0.001865,8e-06,0.021395,0.005404,0.028145,0.095511,0.000152,0.125196,0.036065,0.147049,0.290649,0.001044,0.087703,0.025355,0.0,0.0,0.0,0.014431,0.003255,0.013062,0.020055,4.8e-05,0.078997,0.004913,0.028451,0.066588,0.000143,0.000763,3e-05,0.000194,0.000102,1.5e-05],"sd":[0.268781,0.036771,0.372756,0.127248,0.35434,0.218385,0.383122,0.485909,0.076955,0.803588,1.602279,0.680163,0.11702,0.02842,0.22979,0.22327,1e-06,0.033186,0.003276,0.107253,0.069348,0.158982,0.208221,0.008485,0.025221,0.012654,0.015171,0.018277,0.002347,0.32287,0.162291,0.335994,0.644886,0.039907,0.762585,0.446869,0.840132,1.16098,0.094205,0.600728,0.32812,1e-06,1e-06,1e-06,0.071304,0.032858,0.067147,0.071859,0.003777,0.214543,0.045987,0.102079,0.082198,0.00474,0.027608,0.005436,0.013914,0.010108,0.003818]}
@@ -0,0 +1,32 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /** @typedef {import('./_gather-components.mjs').Candidate} Candidate */
5
+ /**
6
+ * A component or hook doc, compiled, or null when it cannot be read.
7
+ * @param {string} docPath
8
+ * @param {string} [exportName]
9
+ * @param {'components' | 'hooks'} [root]
10
+ * @returns {Promise<any>}
11
+ */
12
+ export function loadModuleDoc(docPath: string, exportName?: string, root?: "components" | "hooks"): Promise<any>;
13
+ /**
14
+ * Build component candidates from core's own tree: name + keywords +
15
+ * usage/description from the component's .doc.mjs.
16
+ * @param {string} coreDir
17
+ * @returns {Promise<Candidate[]>}
18
+ */
19
+ /**
20
+ * Usage guidance from a component doc: its feature list and its best-practice
21
+ * advice, flattened to plain strings.
22
+ *
23
+ * This is where a reader's vocabulary usually lives. `Banner` calls itself "a
24
+ * persistent message" and only its guidance names "form errors, system
25
+ * updates, maintenance notices" — so a search for the words people actually
26
+ * type finds nothing without it.
27
+ *
28
+ * @param {any} doc
29
+ * @returns {string[]}
30
+ */
31
+ export function guidanceFrom(doc: any): string[];
32
+ export type Candidate = import("./_gather-components.mjs").Candidate;