@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,283 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A whole query's score against one candidate: the reserved top tier
5
+ * for multi-word queries, then the per-token scores (see _candidate-score.mjs).
6
+ */
7
+
8
+ import {SYNONYM_INDEX} from './_synonyms.mjs';
9
+ import {joined} from './_words.mjs';
10
+ import {phraseWords} from './_query.mjs';
11
+ import {compoundHit, compoundOf} from './_compound.mjs';
12
+ import {scoreCandidate} from './_candidate-score.mjs';
13
+ import {
14
+ TITLE_PHRASE_SCORE,
15
+ TITLE_IN_QUERY_SCORE,
16
+ headingWithPhrase,
17
+ titleInQuery,
18
+ } from './_titles.mjs';
19
+
20
+ /** @typedef {import('./_lexical.mjs').Candidate} Candidate */
21
+
22
+ /**
23
+ * Minimum per-token score (in the multi-word pass) to count as a real match.
24
+ * 50 = a genuine name/keyword/description hit; below that is loose Levenshtein
25
+ * fuzz that would otherwise turn gibberish queries into noise.
26
+ *
27
+ * Guidance (45) is deliberately BELOW this floor, so it never counts as one of
28
+ * the matched concepts in a multi-word query. Measured: letting it count moved
29
+ * `nested menu` from SideNav to List and `explain why a field is required` from
30
+ * Field to TextInput — in both cases a component whose guidance happens to
31
+ * mention the other word displaced the one that IS the answer. Breadth is not
32
+ * relevance, the same reason `weakKeywords` are capped. Guidance still decides
33
+ * single-word queries and still breaks ties, which is where it earns its place.
34
+ */
35
+ const MIN_TOKEN_SCORE = 50;
36
+
37
+ /**
38
+ * Best score for a token against a candidate, fanning out through synonyms
39
+ * (synonym hits are discounted so a direct hit always wins).
40
+ * @param {string} tok
41
+ * @param {Candidate} candidate
42
+ * @param {{fuzzy?: boolean}} [opts]
43
+ * @returns {{score: number, reason: string} | null}
44
+ */
45
+ export function bestForToken(tok, candidate, opts = {}) {
46
+ let best = scoreCandidate(tok, candidate, opts);
47
+ const syns = SYNONYM_INDEX.get(tok);
48
+ if (syns) {
49
+ for (const s of syns) {
50
+ const h = scoreCandidate(s, candidate, opts);
51
+ if (h) {
52
+ const score = Math.round(h.score * 0.85);
53
+ if (!best || score > best.score)
54
+ best = {score, reason: `${h.reason} (~${tok})`};
55
+ }
56
+ }
57
+ }
58
+ return best;
59
+ }
60
+
61
+ /**
62
+ * The score of a candidate that matches every content word of a multi-word
63
+ * query, before a bonus of up to 8 for how strong its strongest match is: just
64
+ * above anything that matches only some of the words. The token-sum path tops
65
+ * out near 150 for a partial match (a 100 on one word, the per-word bonus, and
66
+ * the coverage term), so an AND-match with one keyword-strength hit (see
67
+ * {@link STRONG_TOKEN_SCORE}) always outranks an OR-match, and stays below the
68
+ * title tiers.
69
+ */
70
+ export const FULL_COVERAGE_SCORE = 151;
71
+
72
+ /**
73
+ * The strongest single-word hit an every-word match needs to take that tier: a
74
+ * keyword substring. Two passing mentions in prose, or the components a page
75
+ * happens to render, are breadth, not relevance; they stay on the token sum,
76
+ * below an exact name or keyword hit on one of the words.
77
+ */
78
+ export const STRONG_TOKEN_SCORE = 70;
79
+
80
+ /**
81
+ * @param {string} term - Lowercased full query.
82
+ * @param {string[]} tokens - Content tokens from tokenizeQuery(term).
83
+ * @param {Candidate} candidate
84
+ * @param {string[]} [words] - The query's words for compound names (see
85
+ * compoundHit); empty when the query names an entry outright.
86
+ * @param {{plain?: boolean}} [opts] - `plain`: score without matches on part
87
+ * of a name or a joined keyword.
88
+ * @returns {{score: number, reason: string, matched: number, total: number, alias?: 'partial' | 'keyword', aliasWords?: number} | null}
89
+ * `matched`/`total` are the query concepts this candidate answered, out of
90
+ * the concepts the query had. Callers that must distinguish "matched one word
91
+ * of three" from "matched all three" — `build`, which gates its pages group
92
+ * on coverage — cannot recover that from the score, because a single strong
93
+ * hit and a broad weak one land on the same number.
94
+ */
95
+ export function scoreQuery(
96
+ term,
97
+ tokens,
98
+ candidate,
99
+ words = phraseWords(term),
100
+ {plain = false} = {},
101
+ ) {
102
+ const total = Math.max(tokens.length, 1);
103
+ // A whole-phrase hit answered the whole query by definition.
104
+ const asFull = (/** @type {{score: number, reason: string}} */ hit) => ({
105
+ ...hit,
106
+ matched: total,
107
+ total,
108
+ });
109
+ // Typo tolerance is for one-word lookups. In a multi-word query a near miss
110
+ // is usually a different word, not a typo. One typed identifier is one
111
+ // word, whatever words it spells.
112
+ const fuzzy = tokens.length <= 1 || !/\s/.test(term);
113
+ const full = scoreCandidate(term, candidate, {
114
+ fuzzy,
115
+ joins: !plain && words.length > 0,
116
+ });
117
+ // A query of several words keeps its phrase tiers below even when stopwords
118
+ // leave one content word: "make an integration" is still the phrase an
119
+ // author declares as a keyword, and "build an integration" still names a
120
+ // title outright, though each tokenizes to `integration` alone.
121
+ const phrase = phraseWords(term).length >= 2 || words.length >= 2;
122
+ let compound = compoundHit(words, candidate);
123
+ if (plain) {
124
+ if (compound?.alias) compound = null;
125
+ } else {
126
+ compoundOf.set(candidate, compound);
127
+ }
128
+
129
+ /** 0–1 content tokens: whole-phrase fuzzy matching (typo tolerance for
130
+ * single words), but if stopwords left exactly one DIFFERENT token (e.g.
131
+ * "pricing page" → "pricing"), score that token too and take the stronger. */
132
+ const fewTokens = () => {
133
+ const single =
134
+ tokens.length === 1 ? bestForToken(tokens[0], candidate, {fuzzy}) : null;
135
+ if (full && (!single || full.score >= single.score)) return asFull(full);
136
+ return single ? asFull(single) : null;
137
+ };
138
+ if (tokens.length <= 1 && !phrase) return fewTokens();
139
+
140
+ // The full (untokenized) query matching a candidate's name or a declared
141
+ // keyword VERBATIM — full.score 90 or 100, the only two scoreCandidate
142
+ // outcomes at or above that mark — is a deliberate, explicit label the
143
+ // author chose for exactly this multi-word concept. Promote it to a
144
+ // reserved top tier, safely above the token-sum path's ceiling below
145
+ // (~151: 100 avg + 36 bonus + 15 coverage), so it always outranks a
146
+ // candidate that merely happens to contain several of the query's
147
+ // individual words. Without this, "table of contents" never surfaces
148
+ // Outline (which declares that exact phrase as a keyword) because dozens
149
+ // of Table-related templates each match "table" and "contents" separately
150
+ // and accumulate a higher raw score (#5239).
151
+ if (full && full.score >= 90) {
152
+ return asFull({
153
+ score: full.score + 100,
154
+ reason: full.reason,
155
+ ...(full.alias ? {alias: full.alias, aliasWords: words.length} : {}),
156
+ });
157
+ }
158
+
159
+ // The whole query standing as a phrase in a doc's title or one of its
160
+ // headings is the next tier down, and still above the token-sum path. The
161
+ // reader named what the section is about, in order: `dark mode` is the
162
+ // "Light/Dark Mode" section. Without this, the title scores a keyword
163
+ // substring (70) and loses to a doc that happens to name `mode` exactly in
164
+ // a code tick (90 on one token, 98 with coverage), so API enum docs outrank
165
+ // the guide section.
166
+ const heading = headingWithPhrase(term, candidate.titles);
167
+ if (heading != null) {
168
+ return asFull({
169
+ score: TITLE_PHRASE_SCORE,
170
+ reason: `title "${heading}" holds the whole query`,
171
+ });
172
+ }
173
+ if (tokens.length <= 1) return fewTokens();
174
+
175
+ // Multi-word natural language: score each content token, counting only
176
+ // strong hits, then reward coverage so candidates matching more terms win.
177
+ let strongest = 0;
178
+ let matched = 0;
179
+ let tokenSum = 0;
180
+ /** @type {string[]} */
181
+ const hitTerms = [];
182
+ // Adjacent words that join into a name or keyword answer each word they
183
+ // join, as strongly as the joined word matches (see compoundHit).
184
+ // Whether a counted word matched only through part of a name or a keyword
185
+ // read joined, not through the entry's own name or a word of it.
186
+ /** @type {'partial' | 'keyword' | false} */
187
+ let alias = false;
188
+ let aliasWords = 0;
189
+ for (const tok of tokens) {
190
+ let h = bestForToken(tok, candidate, {fuzzy});
191
+ if (
192
+ compound?.covered.has(joined(tok)) &&
193
+ compound.score > (h?.score ?? 0)
194
+ ) {
195
+ h = compound;
196
+ if (compound.alias && compound.score >= MIN_TOKEN_SCORE) {
197
+ alias = alias === 'partial' ? alias : compound.alias;
198
+ aliasWords = compound.aliasWords ?? 0;
199
+ }
200
+ }
201
+ if (h && h.score >= MIN_TOKEN_SCORE) {
202
+ if (h.score > strongest) strongest = h.score;
203
+ matched++;
204
+ hitTerms.push(tok);
205
+ tokenSum += h.score;
206
+ }
207
+ }
208
+ // The reverse of the title tier, a step lower: the query holds a whole title
209
+ // of two words or more, so the reader asked a question around the section's
210
+ // name ("how do I add dark mode"). Coverage breaks ties between sections
211
+ // that share a title such as "Best Practices".
212
+ const named = titleInQuery(term, candidate.titles);
213
+ if (named != null) {
214
+ return {
215
+ score: TITLE_IN_QUERY_SCORE + Math.min(matched, 9),
216
+ reason: `the query names the title "${named}"`,
217
+ matched,
218
+ total,
219
+ };
220
+ }
221
+ if (matched === 0) return full ? asFull(full) : null;
222
+
223
+ const reason = `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`;
224
+
225
+ // Every word matched is its own tier. Summed per word, a doc that matches
226
+ // both words of `troubleshoot integration` in its title and description
227
+ // (50 + bonus + coverage = 77) lost to thirty docs that each match
228
+ // `integration` alone, by name or in a code tick (98-108). The reader asked for
229
+ // both; a candidate that has both comes first, ordered among its peers by
230
+ // how strong its matches are. It needs one keyword-strength hit:
231
+ // every word mentioned in prose, or rendered by a page, is breadth, and
232
+ // stays on the token sum below an exact hit on one word.
233
+ //
234
+ // The TOTAL quality of matches orders candidates within this tier, not
235
+ // just the strongest single hit. A doc matching both words by keyword
236
+ // (90 + 90 = 180) outranks one matching keyword + prose (90 + 50 = 140).
237
+ // Before this, every all-word match whose strongest hit was a keyword (90)
238
+ // scored 157, burying the better match among dozens of ties.
239
+ if (matched === tokens.length && strongest >= STRONG_TOKEN_SCORE) {
240
+ return {
241
+ score:
242
+ FULL_COVERAGE_SCORE +
243
+ Math.min(
244
+ Math.floor((tokenSum - matched * MIN_TOKEN_SCORE) / (matched * 5)),
245
+ 8,
246
+ ),
247
+ reason,
248
+ matched,
249
+ total,
250
+ ...(alias ? {alias, aliasWords} : {}),
251
+ };
252
+ }
253
+
254
+ // Base the score on the STRONGEST concept that matched, plus a bonus per
255
+ // additional matched concept and a coverage term.
256
+ //
257
+ // Deliberately not the mean, and deliberately not divided by total query
258
+ // length. Dividing by total length penalizes verbose / low-fidelity prompts.
259
+ // Dividing by the number of MATCHED tokens (what this used to do) made the
260
+ // score non-monotonic: a second, weaker hit could drag the mean down by more
261
+ // than the coverage bonus added it back, so matching fewer terms well beat
262
+ // matching more terms partially. Concretely, `build "file browser"` scored
263
+ // two form wizards matching only "file" at 98, above the actual file browser
264
+ // matching both terms at 97 — and 98 clears the PAGE_DIRECT gate, so the
265
+ // wrong template was returned as a confident match.
266
+ //
267
+ // Taking the max keeps both properties: a verbose prompt is still scored on
268
+ // the concepts it did hit, and matching a superset of another candidate's
269
+ // terms can never score lower, since every term of the expression is
270
+ // non-decreasing in the set of matched tokens.
271
+ const coverage = matched / tokens.length;
272
+ const tokenScore = Math.round(
273
+ strongest + Math.min(matched - 1, 3) * 12 + coverage * 15,
274
+ );
275
+ if (full && full.score >= tokenScore) return asFull(full);
276
+ return {
277
+ score: tokenScore,
278
+ reason,
279
+ matched,
280
+ total,
281
+ ...(alias ? {alias, aliasWords} : {}),
282
+ };
283
+ }
@@ -0,0 +1,21 @@
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
+ export namespace SYNONYMS {
5
+ let dashboard: string[];
6
+ let login: string[];
7
+ let signup: string[];
8
+ let payment: string[];
9
+ let pricing: string[];
10
+ let chat: string[];
11
+ let settings: string[];
12
+ let calendar: string[];
13
+ let table: string[];
14
+ let gallery: string[];
15
+ let hero: string[];
16
+ let form: string[];
17
+ let profile: string[];
18
+ let documentation: string[];
19
+ let navigation: string[];
20
+ }
21
+ export const SYNONYM_INDEX: Map<string, Set<string>>;
@@ -0,0 +1,67 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Search's synonym map: product words an agent types, mapped to the
5
+ * catalog's vocabulary in both directions.
6
+ */
7
+
8
+ /**
9
+ * Synonym / intent map: product-language terms an agent is likely to type,
10
+ * expanded to the catalog's vocabulary so oblique queries still rank. Keys and
11
+ * values are matched bidirectionally (typing any value also pulls in the key
12
+ * and its siblings). Lowercase, single words or short phrases. Exported for
13
+ * `build`, whose page ranker expands a query with the same vocabulary.
14
+ */
15
+ export const SYNONYMS = {
16
+ dashboard: [
17
+ 'overview',
18
+ 'analytics',
19
+ 'kpi',
20
+ 'kpis',
21
+ 'metrics',
22
+ 'stats',
23
+ 'reporting',
24
+ 'insights',
25
+ 'control',
26
+ ],
27
+ login: ['signin', 'auth', 'authentication', 'sso', 'credentials', 'account'],
28
+ signup: ['register', 'registration', 'onboarding'],
29
+ payment: ['checkout', 'billing', 'card', 'pay', 'purchase', 'order'],
30
+ pricing: ['plans', 'plan', 'tiers', 'tier', 'subscription', 'subscriptions'],
31
+ chat: ['messaging', 'message', 'messages', 'conversation', 'inbox', 'dm'],
32
+ settings: ['preferences', 'config', 'configuration', 'account'],
33
+ calendar: ['schedule', 'scheduling', 'events', 'event', 'month', 'agenda'],
34
+ table: ['list', 'rows', 'records', 'grid', 'spreadsheet', 'datatable'],
35
+ gallery: ['photos', 'photo', 'images', 'image', 'pictures'],
36
+ hero: ['banner', 'splash', 'headline', 'landing'],
37
+ form: ['fields', 'input', 'inputs', 'survey'],
38
+ profile: ['bio', 'avatar', 'user'],
39
+ documentation: ['docs', 'reference', 'guide', 'api'],
40
+ navigation: ['nav', 'menu', 'sidebar'],
41
+ };
42
+
43
+ // Flatten into a token -> Set(expansions) lookup (bidirectional).
44
+ export const SYNONYM_INDEX = (() => {
45
+ /** @type {Map<string, Set<string>>} */
46
+ const idx = new Map();
47
+ /**
48
+ * @param {string} a
49
+ * @param {string} b
50
+ */
51
+ const add = (a, b) => {
52
+ let set = idx.get(a);
53
+ if (!set) {
54
+ set = new Set();
55
+ idx.set(a, set);
56
+ }
57
+ set.add(b);
58
+ };
59
+ for (const [key, vals] of Object.entries(SYNONYMS)) {
60
+ for (const v of vals) {
61
+ add(key, v);
62
+ add(v, key);
63
+ for (const v2 of vals) if (v2 !== v) add(v, v2);
64
+ }
65
+ }
66
+ return idx;
67
+ })();
@@ -0,0 +1,4 @@
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
+ export const template: import("../../foundation/route/route.mjs").Searcher<import("./tree.mjs").SearchRequest, import("./_adapter.mjs").SearchCatalog, import("./search.type.mjs").SearchResponse>;
@@ -0,0 +1,23 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The template searcher: the main searcher's signals with its own
5
+ * weights, in _template-searcher.weights.json.
6
+ * @input {query, options} and the gathered catalog with its static data
7
+ * @output the search response, the lexical response's shape and size
8
+ * @position api/search; a topic child of the root dispatcher in tree.mjs
9
+ */
10
+
11
+ import {routeKit} from '../../foundation/route/route.mjs';
12
+ import {asWeights, rankWith} from './_main-searcher.mjs';
13
+
14
+ /** @type {import('./tree.mjs').SearchKit} */
15
+ const {defineSearcher} = routeKit;
16
+
17
+ export const template = defineSearcher({
18
+ id: 'template',
19
+ summary:
20
+ 'Ranks entries for queries that ask for a template, with weights of its own.',
21
+ run: (request, catalog, weights) =>
22
+ rankWith(request, catalog, asWeights(weights) ?? catalog.data.template),
23
+ });
@@ -0,0 +1,40 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Tests for the template searcher (_template-searcher.mjs): with its own weights
5
+ * it ranks a template first for a query that asks for one. Run against the real
6
+ * @astryxdesign/core.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import * as path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {gatherCandidates, loadSearchData} from './_adapter.mjs';
13
+ import {template} from './_template-searcher.mjs';
14
+
15
+ const REPO = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ '../../../..',
18
+ );
19
+
20
+ describe('template searcher', () => {
21
+ it('ranks a template near the top for a template query', async () => {
22
+ const catalog = {
23
+ ...(await gatherCandidates({cwd: REPO})),
24
+ data: loadSearchData(),
25
+ };
26
+ const answer = /** @type {any} */ (
27
+ await template.run(
28
+ {query: 'page of settings with a side menu', options: {cwd: REPO}},
29
+ catalog,
30
+ null,
31
+ )
32
+ );
33
+ expect(
34
+ answer.data.results
35
+ .slice(0, 3)
36
+ .some((/** @type {any} */ r) => r.domain === 'template'),
37
+ ).toBe(true);
38
+ expect(answer.data.results.length).toBeLessThanOrEqual(20);
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ {"w":[0.26388,-0.013032,0.516431,0.121331,0.254512,-0.261785,-0.688666,0.112076,-0.376705,0.111327,0.097715,0.065046,0.095694,-0.016049,-0.236365,0.094383,0.0,-0.003606,0.003684,0.067567,0.024298,-0.030137,0.324971,-0.059765,-0.008739,0.015668,0.011897,0.016649,-0.066041,0.056844,-0.055073,0.054001,0.137491,0.0,0.002283,-0.360264,0.210105,0.137841,0.14848,0.051643,0.401352,0.0,0.0,0.0,0.17537,-0.278616,0.138193,0.041566,0.02038,0.043262,0.134831,0.272071,-0.024414,-0.000472,0.013668,0.0,0.034693,0.004465,0.0],"mu":[0.164885,0.00511,0.225783,0.017021,0.147234,0.050213,0.178723,0.617872,0.005957,0.260431,0.758487,0.118373,0.062975,0.000497,0.08043,0.415077,0.054719,0.001375,4e-06,0.016249,0.004739,0.048162,0.095679,5.7e-05,0.00111,0.000198,0.001298,0.002502,1e-06,0.022962,0.00435,0.075387,0.157731,0.0,0.132838,0.026582,0.222958,0.37547,0.00064,0.098856,0.019517,0.0,0.0,0.0,0.014202,0.002142,0.017938,0.028668,2.6e-05,0.030184,0.001323,0.02033,0.363165,7.4e-05,0.000775,0.0,0.000152,0.000448,0.0],"sd":[0.30267,0.037002,0.399695,0.129352,0.35434,0.218385,0.383122,0.485909,0.076955,1.016246,1.780214,0.698764,0.119979,0.022138,0.262291,0.251643,1e-06,0.037042,0.002117,0.107524,0.058464,0.180391,0.248041,0.00567,0.021262,0.005551,0.018508,0.023617,0.00015,0.335998,0.139746,0.538946,0.804633,1e-06,0.783314,0.348833,1.042717,1.321848,0.07162,0.646141,0.275325,1e-06,1e-06,1e-06,0.066632,0.023566,0.076235,0.079854,0.002538,0.085732,0.008484,0.056206,0.296331,0.001967,0.027817,1e-06,0.012337,0.021154,1e-06]}
@@ -0,0 +1,33 @@
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
+ * The first title or heading that holds every word of the query, in order and
6
+ * side by side, or null.
7
+ * @param {string} term - Lowercased full query.
8
+ * @param {string[] | undefined} titles
9
+ * @returns {string | null}
10
+ */
11
+ export function headingWithPhrase(term: string, titles: string[] | undefined): string | null;
12
+ /**
13
+ * The first title or heading of two words or more that the query holds whole,
14
+ * in order and side by side, or null. A question such as "how do I add dark
15
+ * mode" names the "Dark mode" section outright, around words no title has.
16
+ * @param {string} term - Lowercased full query.
17
+ * @param {string[] | undefined} titles
18
+ * @returns {string | null}
19
+ */
20
+ export function titleInQuery(term: string, titles: string[] | undefined): string | null;
21
+ /**
22
+ * The score of a whole-query phrase inside a doc's title or heading: a keyword
23
+ * substring hit (70) promoted by the same 100 as the exact tier. Below an
24
+ * exact name or keyword (190-200), above the token-sum path (~151 at most).
25
+ */
26
+ export const TITLE_PHRASE_SCORE: 170;
27
+ /**
28
+ * The score of a whole title inside a longer query, before its coverage bonus:
29
+ * one step below {@link TITLE_PHRASE_SCORE}. The bonus (one per query term the
30
+ * candidate matches, at most 9) orders the sections that share a common title,
31
+ * so "best practices for spacing" puts Spacing's Best Practices first.
32
+ */
33
+ export const TITLE_IN_QUERY_SCORE: 160;
@@ -0,0 +1,65 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The title tier of a multi-word query: the whole query as a phrase in
5
+ * a doc's title or heading, or a whole title inside the query.
6
+ */
7
+
8
+ import {phraseWords, samePhraseWord} from './_query.mjs';
9
+
10
+ /**
11
+ * The score of a whole-query phrase inside a doc's title or heading: a keyword
12
+ * substring hit (70) promoted by the same 100 as the exact tier. Below an
13
+ * exact name or keyword (190-200), above the token-sum path (~151 at most).
14
+ */
15
+ export const TITLE_PHRASE_SCORE = 170;
16
+
17
+ /**
18
+ * The score of a whole title inside a longer query, before its coverage bonus:
19
+ * one step below {@link TITLE_PHRASE_SCORE}. The bonus (one per query term the
20
+ * candidate matches, at most 9) orders the sections that share a common title,
21
+ * so "best practices for spacing" puts Spacing's Best Practices first.
22
+ */
23
+ export const TITLE_IN_QUERY_SCORE = 160;
24
+
25
+ /**
26
+ * The first title or heading that holds every word of the query, in order and
27
+ * side by side, or null.
28
+ * @param {string} term - Lowercased full query.
29
+ * @param {string[] | undefined} titles
30
+ * @returns {string | null}
31
+ */
32
+ export function headingWithPhrase(term, titles) {
33
+ const query = phraseWords(term);
34
+ if (query.length < 2 || !titles) return null;
35
+ for (const title of titles) {
36
+ const words = phraseWords(String(title ?? ''));
37
+ for (let i = 0; i + query.length <= words.length; i++) {
38
+ if (query.every((word, j) => samePhraseWord(words[i + j], word)))
39
+ return title;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * The first title or heading of two words or more that the query holds whole,
47
+ * in order and side by side, or null. A question such as "how do I add dark
48
+ * mode" names the "Dark mode" section outright, around words no title has.
49
+ * @param {string} term - Lowercased full query.
50
+ * @param {string[] | undefined} titles
51
+ * @returns {string | null}
52
+ */
53
+ export function titleInQuery(term, titles) {
54
+ const query = phraseWords(term);
55
+ if (!titles) return null;
56
+ for (const title of titles) {
57
+ const words = phraseWords(String(title ?? ''));
58
+ if (words.length < 2 || words.length > query.length) continue;
59
+ for (let i = 0; i + words.length <= query.length; i++) {
60
+ if (words.every((word, j) => samePhraseWord(query[i + j], word)))
61
+ return title;
62
+ }
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,64 @@
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
+ * @file Word matching for search: stems and word forms, word starts,
6
+ * identifier spelling, joined names, and typo tolerance.
7
+ */
8
+ /**
9
+ * Light stemmer: strips common English suffixes so "charts"/"charting" and
10
+ * "chart" share a root. Deliberately crude (no Porter) — good enough to bridge
11
+ * plural/gerund gaps without a dependency.
12
+ * @param {string} w
13
+ * @returns {string}
14
+ */
15
+ export function stem(w: string): string;
16
+ /**
17
+ * The forms of a word that count as the same word: itself, its stem, and its
18
+ * singular when it ends in a plural suffix — so "tables" is "table",
19
+ * "statuses" is "status", and "filtering" is "filter".
20
+ * @param {string} w - Lowercase word.
21
+ * @returns {Set<string>}
22
+ */
23
+ export function wordForms(w: string): Set<string>;
24
+ /**
25
+ * Whether two lowercase words are the same word, up to plural and stem form.
26
+ * @param {string} a
27
+ * @param {string} b
28
+ * @returns {boolean}
29
+ */
30
+ export function sameWord(a: string, b: string): boolean;
31
+ /**
32
+ * The lowercase words of a name or keyword: split at non-alphanumerics and at
33
+ * camelCase boundaries, so "TextInput" is ["text", "input"] and
34
+ * "Dashboard - Analytics" is ["dashboard", "analytics"].
35
+ * @param {string} text
36
+ * @returns {string[]}
37
+ */
38
+ export function wordsOf(text: string): string[];
39
+ /**
40
+ * Whether a term is found inside a name or keyword: it is one of its words, or
41
+ * the start of one (a truncation), and covers at least half of the whole
42
+ * string. Four letters minimum, so a short term never matches by accident.
43
+ * @param {string} term - Lowercase term.
44
+ * @param {string} text - The name or keyword as authored.
45
+ * @returns {boolean}
46
+ */
47
+ export function startsAWordOf(term: string, text: string): boolean;
48
+ /**
49
+ * A query with its camelCase and PascalCase identifiers spelled as words, so
50
+ * `SidebarToggle` reads "sidebar toggle" and `useTablePaginationState` "use
51
+ * table pagination state". Plain words, and identifiers joined with `_` or `-`, are
52
+ * unchanged: a joined token that matches nothing stays unmatched.
53
+ * @param {string} query - The query as typed, before lowercasing.
54
+ * @returns {string} Lowercased.
55
+ */
56
+ export function spellIdentifiers(query: string): string;
57
+ /**
58
+ * A name or keyword with its separators removed, lowercased: `Side Nav`,
59
+ * `side-nav`, and `SideNav` all join to `sidenav`.
60
+ * @param {string} text
61
+ * @returns {string}
62
+ */
63
+ export function joined(text: string): string;
64
+ export function isTypo(a: string, b: string, dist: number): boolean;