@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,324 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The small routing helper the commands that pick among candidates
5
+ * share (build, search): two node kinds and the runner that walks them.
6
+ *
7
+ * A searcher answers a request in its command's own result shape, or returns
8
+ * null to pass. A dispatcher scores nothing: it names the children that should
9
+ * answer, best first, with a confidence from 0 to 1. Below its floor, or when
10
+ * none of the children it named answers, its fallback searcher answers. A
11
+ * dispatcher's children may be dispatchers, so trees nest. A classifier is a
12
+ * dispatcher whose route is a static word-weight classifier: the request's
13
+ * tokens vote for a class, and a class with a searcher takes the request when
14
+ * the classifier is sure enough.
15
+ *
16
+ * A tree file types the helpers for its command once, by taking them from
17
+ * `routeKit` typed as `RouteKit<Request, Context, Answer>` (see
18
+ * api/search/tree.mjs); each node is then plain `{id, summary, run}`.
19
+ *
20
+ * Every node gets the same context, so any node can be run, measured, and
21
+ * tuned on its own: `runTree` takes an optional `tuning` with weights that
22
+ * replace a node's shipped weights for one call, and a trace callback that
23
+ * sees every step. Neither ever reaches a command's output.
24
+ *
25
+ * @input a tree of nodes, a request, and the context the command's adapter
26
+ * gathered
27
+ * @output the first answer a searcher gives
28
+ * @position foundation; api/build and api/search build their trees with it
29
+ */
30
+
31
+ /**
32
+ * @template Request, Context, Answer
33
+ * @typedef {object} Searcher
34
+ * @property {'searcher'} kind
35
+ * @property {string} id
36
+ * @property {string} summary One plain line: what it answers.
37
+ * @property {(request: Request, ctx: Context, weights: unknown) => Answer | null | Promise<Answer | null>} run
38
+ * `weights` is a tuning override for this node, or null for its shipped weights.
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} Route
43
+ * @property {readonly string[]} ids The children to try, best first.
44
+ * @property {number} confidence From 0 to 1.
45
+ */
46
+
47
+ /**
48
+ * @template Request, Context
49
+ * @typedef {object} Dispatcher
50
+ * @property {'dispatcher'} kind
51
+ * @property {string} id
52
+ * @property {string} summary
53
+ * @property {readonly string[]} children
54
+ * @property {number} floor Below this confidence the fallback answers.
55
+ * @property {string} fallback A searcher's id.
56
+ * @property {(request: Request, ctx: Context, weights: unknown) => Route | Promise<Route>} route
57
+ */
58
+
59
+ /**
60
+ * @template Request, Context, Answer
61
+ * @typedef {Searcher<Request, Context, Answer> | Dispatcher<Request, Context>} TreeNode
62
+ */
63
+
64
+ /**
65
+ * @template Request, Context, Answer
66
+ * @typedef {object} Tree
67
+ * @property {string} root
68
+ * @property {ReadonlyMap<string, TreeNode<Request, Context, Answer>>} nodes
69
+ */
70
+
71
+ /**
72
+ * One step the runner took.
73
+ * @typedef {object} TraceStep
74
+ * @property {string} id
75
+ * @property {'searcher' | 'dispatcher'} kind
76
+ * @property {number} depth
77
+ * @property {boolean} fallback Whether this step ran as a fallback.
78
+ * @property {Route} [route] A dispatcher's route.
79
+ * @property {boolean} [answered] Whether a searcher answered.
80
+ */
81
+
82
+ /**
83
+ * @typedef {object} Tuning
84
+ * @property {Readonly<Record<string, unknown>>} [weights] Weights by node id, replacing that node's shipped weights for this call.
85
+ * @property {(step: TraceStep) => void} [onTrace]
86
+ */
87
+
88
+ /**
89
+ * Static classifier weights: one row of numbers per token, one number per
90
+ * class. A request's score for a class is the sum of its tokens' numbers.
91
+ * @typedef {object} ClassifierWeights
92
+ * @property {readonly string[]} classes
93
+ * @property {Readonly<Record<string, readonly number[]>>} tokens
94
+ */
95
+
96
+ /**
97
+ * @template Request, Context
98
+ * @typedef {object} ClassifierSpec
99
+ * @property {string} id
100
+ * @property {string} summary
101
+ * @property {Readonly<Record<string, string>>} searchers Class name -> the
102
+ * searcher that answers it. A class with no searcher goes to the fallback.
103
+ * @property {string} fallback The searcher that keeps every other request.
104
+ * @property {number} floor A searcher takes a request only when its class's
105
+ * probability is at least this.
106
+ * @property {(request: Request, ctx: Context) => readonly string[]} features
107
+ * The request's tokens.
108
+ * @property {(ctx: Context) => ClassifierWeights} weights The shipped weights,
109
+ * which the command's adapter put in the context.
110
+ */
111
+
112
+ /**
113
+ * The helpers, typed for one command's request, context, and answer.
114
+ * @template Request, Context, Answer
115
+ * @typedef {object} RouteKit
116
+ * @property {(node: Omit<Searcher<Request, Context, Answer>, 'kind'>) => Searcher<Request, Context, Answer>} defineSearcher
117
+ * @property {(node: Omit<Dispatcher<Request, Context>, 'kind'>) => Dispatcher<Request, Context>} defineDispatcher
118
+ * @property {(spec: ClassifierSpec<Request, Context>) => Dispatcher<Request, Context>} defineClassifier
119
+ * @property {(root: string, nodes: readonly TreeNode<Request, Context, Answer>[]) => Tree<Request, Context, Answer>} defineTree
120
+ */
121
+
122
+ /**
123
+ * The probability of each class for these tokens: a softmax over the summed
124
+ * rows of the tokens the weights know.
125
+ * @param {ClassifierWeights} weights
126
+ * @param {readonly string[]} tokens
127
+ * @returns {number[]}
128
+ */
129
+ export function classify(weights, tokens) {
130
+ const score = weights.classes.map(() => 0);
131
+ for (const token of tokens) {
132
+ const row = Object.hasOwn(weights.tokens, token)
133
+ ? weights.tokens[token]
134
+ : undefined;
135
+ if (row) for (let k = 0; k < score.length; k++) score[k] += row[k];
136
+ }
137
+ const top = Math.max(...score);
138
+ const e = score.map(x => Math.exp(x - top));
139
+ const sum = e.reduce((a, b) => a + b, 0);
140
+ return e.map(x => x / sum);
141
+ }
142
+
143
+ /**
144
+ * A dispatcher whose route is a static classifier. The most probable class
145
+ * (the first on a tie) is handed to its searcher; a class with no searcher,
146
+ * or the fallback's own class, stays with the fallback. Tuning weights for
147
+ * this node replace its shipped weights.
148
+ * @template Request, Context
149
+ * @param {ClassifierSpec<Request, Context>} spec
150
+ * @returns {Dispatcher<Request, Context>}
151
+ */
152
+ export function defineClassifier(spec) {
153
+ const {searchers, fallback} = spec;
154
+ return defineDispatcher({
155
+ id: spec.id,
156
+ summary: spec.summary,
157
+ children: [...new Set([fallback, ...Object.values(searchers)])],
158
+ floor: spec.floor,
159
+ fallback,
160
+ route: (request, ctx, override) => {
161
+ const weights =
162
+ /** @type {ClassifierWeights | null} */ (override) ?? spec.weights(ctx);
163
+ const p = classify(weights, spec.features(request, ctx));
164
+ const best = p.indexOf(Math.max(...p));
165
+ const target = searchers[weights.classes[best]];
166
+ return target && target !== fallback
167
+ ? {ids: [target], confidence: p[best]}
168
+ : {ids: [fallback], confidence: 1};
169
+ },
170
+ });
171
+ }
172
+
173
+ /**
174
+ * @template Request, Context, Answer
175
+ * @param {Omit<Searcher<Request, Context, Answer>, 'kind'>} node
176
+ * @returns {Searcher<Request, Context, Answer>}
177
+ */
178
+ export function defineSearcher(node) {
179
+ return Object.freeze({...node, kind: /** @type {const} */ ('searcher')});
180
+ }
181
+
182
+ /**
183
+ * @template Request, Context
184
+ * @param {Omit<Dispatcher<Request, Context>, 'kind'>} node
185
+ * @returns {Dispatcher<Request, Context>}
186
+ */
187
+ export function defineDispatcher(node) {
188
+ return Object.freeze({...node, kind: /** @type {const} */ ('dispatcher')});
189
+ }
190
+
191
+ /**
192
+ * A tree from its nodes. Throws on a duplicate id, a missing root, child, or
193
+ * fallback, a fallback that is not a searcher, or a dispatcher cycle.
194
+ * @template Request, Context, Answer
195
+ * @param {string} root
196
+ * @param {readonly TreeNode<Request, Context, Answer>[]} list
197
+ * @returns {Tree<Request, Context, Answer>}
198
+ */
199
+ export function defineTree(root, list) {
200
+ /** @type {Map<string, TreeNode<Request, Context, Answer>>} */
201
+ const nodes = new Map();
202
+ for (const node of list) {
203
+ if (nodes.has(node.id)) throw new Error(`duplicate node "${node.id}"`);
204
+ nodes.set(node.id, node);
205
+ }
206
+ if (!nodes.has(root)) throw new Error(`no root node "${root}"`);
207
+ for (const node of nodes.values()) {
208
+ if (node.kind !== 'dispatcher') continue;
209
+ for (const id of [...node.children, node.fallback]) {
210
+ if (!nodes.has(id)) {
211
+ throw new Error(`"${node.id}" names a missing node "${id}"`);
212
+ }
213
+ }
214
+ if (nodes.get(node.fallback)?.kind !== 'searcher') {
215
+ throw new Error(
216
+ `"${node.id}" falls back to "${node.fallback}", which is not a searcher`,
217
+ );
218
+ }
219
+ }
220
+ /** @param {string} id @param {string[]} path */
221
+ const visit = (id, path) => {
222
+ if (path.includes(id)) {
223
+ throw new Error(`dispatcher cycle ${[...path, id].join(' -> ')}`);
224
+ }
225
+ const node = nodes.get(id);
226
+ if (node?.kind === 'dispatcher') {
227
+ for (const child of node.children) visit(child, [...path, id]);
228
+ }
229
+ };
230
+ visit(root, []);
231
+ return Object.freeze({root, nodes});
232
+ }
233
+
234
+ /**
235
+ * Runs the tree from its root, or from `start` to run one node alone: a
236
+ * dispatcher below its floor runs its fallback; otherwise the children it
237
+ * names run in order and the first answer wins; when none answers, the
238
+ * fallback runs.
239
+ * @template Request, Context, Answer
240
+ * @param {Tree<Request, Context, Answer>} tree
241
+ * @param {Request} request
242
+ * @param {Context} ctx
243
+ * @param {{tuning?: Tuning, start?: string}} [options]
244
+ * @returns {Promise<Answer | null>}
245
+ */
246
+ export async function runTree(tree, request, ctx, options = {}) {
247
+ const start = options.start ?? tree.root;
248
+ if (!tree.nodes.has(start)) throw new Error(`no node "${start}"`);
249
+ return runNode(tree, start, request, ctx, options.tuning, 0, false);
250
+ }
251
+
252
+ /**
253
+ * @template Request, Context, Answer
254
+ * @param {Tree<Request, Context, Answer>} tree
255
+ * @param {string} id
256
+ * @param {Request} request
257
+ * @param {Context} ctx
258
+ * @param {Tuning | undefined} tuning
259
+ * @param {number} depth
260
+ * @param {boolean} fallback
261
+ * @returns {Promise<Answer | null>}
262
+ */
263
+ async function runNode(tree, id, request, ctx, tuning, depth, fallback) {
264
+ const node = /** @type {TreeNode<Request, Context, Answer>} */ (
265
+ tree.nodes.get(id)
266
+ );
267
+ const weights =
268
+ tuning?.weights && Object.hasOwn(tuning.weights, id)
269
+ ? tuning.weights[id]
270
+ : null;
271
+ if (node.kind === 'searcher') {
272
+ const answer = (await node.run(request, ctx, weights)) ?? null;
273
+ tuning?.onTrace?.({
274
+ id,
275
+ kind: 'searcher',
276
+ depth,
277
+ fallback,
278
+ answered: answer !== null,
279
+ });
280
+ return answer;
281
+ }
282
+ const route = await node.route(request, ctx, weights);
283
+ for (const child of route.ids) {
284
+ if (!node.children.includes(child)) {
285
+ throw new Error(
286
+ `"${id}" routed to "${child}", which is not one of its children`,
287
+ );
288
+ }
289
+ }
290
+ tuning?.onTrace?.({
291
+ id,
292
+ kind: 'dispatcher',
293
+ depth,
294
+ fallback,
295
+ route: {ids: [...route.ids], confidence: route.confidence},
296
+ });
297
+ if (route.confidence >= node.floor) {
298
+ for (const child of route.ids) {
299
+ const answer = await runNode(
300
+ tree,
301
+ child,
302
+ request,
303
+ ctx,
304
+ tuning,
305
+ depth + 1,
306
+ false,
307
+ );
308
+ if (answer !== null) return answer;
309
+ }
310
+ }
311
+ return runNode(tree, node.fallback, request, ctx, tuning, depth + 1, true);
312
+ }
313
+
314
+ /**
315
+ * The helpers in one object, for a tree file to type once (see the file
316
+ * comment).
317
+ * @type {RouteKit<any, any, any>}
318
+ */
319
+ export const routeKit = {
320
+ defineSearcher,
321
+ defineDispatcher,
322
+ defineClassifier,
323
+ defineTree,
324
+ };
@@ -0,0 +1,153 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {
5
+ classify,
6
+ defineDispatcher,
7
+ defineSearcher,
8
+ defineTree,
9
+ runTree,
10
+ } from './route.mjs';
11
+
12
+ /** @typedef {{query: string}} Req */
13
+ /** @typedef {{names: string[]}} Ctx */
14
+
15
+ const ctx = {names: ['Button', 'ButtonGroup', 'Card']};
16
+
17
+ const main = defineSearcher({
18
+ id: 'main',
19
+ summary: 'Names that contain the query.',
20
+ /** @param {Req} request @param {Ctx} c */
21
+ run: (request, c) => {
22
+ const results = c.names.filter(n =>
23
+ n.toLowerCase().includes(request.query),
24
+ );
25
+ return results.length ? results : null;
26
+ },
27
+ });
28
+
29
+ const exact = defineSearcher({
30
+ id: 'exact',
31
+ summary: 'The one name the query spells.',
32
+ /** @param {Req} request @param {Ctx} c @param {unknown} weights */
33
+ run: (request, c, weights) => {
34
+ const hit = c.names.find(n => n === request.query);
35
+ const prefix =
36
+ /** @type {{prefix?: string} | null} */ (weights)?.prefix ?? '';
37
+ return hit ? [`${prefix}${hit}`] : null;
38
+ },
39
+ });
40
+
41
+ const root = (confidence = 1) =>
42
+ defineDispatcher({
43
+ id: 'root',
44
+ summary: 'Identifiers go to exact lookup.',
45
+ children: ['exact', 'main'],
46
+ floor: 0.5,
47
+ fallback: 'main',
48
+ /** @param {Req} request */
49
+ route: request =>
50
+ /^[A-Z]/.test(request.query)
51
+ ? {ids: ['exact'], confidence}
52
+ : {ids: [], confidence: 0},
53
+ });
54
+
55
+ const tree = (confidence = 1) =>
56
+ defineTree('root', [root(confidence), exact, main]);
57
+
58
+ describe('defineTree', () => {
59
+ it('stamps each kind', () => {
60
+ expect(main.kind).toBe('searcher');
61
+ expect(root().kind).toBe('dispatcher');
62
+ });
63
+ it('rejects a duplicate, a missing child, a fallback that is not a searcher, and a cycle', () => {
64
+ expect(() => defineTree('main', [main, main])).toThrow(/duplicate/);
65
+ expect(() => defineTree('root', [root(), main])).toThrow(
66
+ /missing node "exact"/,
67
+ );
68
+ const toRoot = defineDispatcher({...root(), fallback: 'root'});
69
+ expect(() => defineTree('root', [toRoot, exact, main])).toThrow(
70
+ /not a searcher/,
71
+ );
72
+ const a = defineDispatcher({...root(), id: 'a', children: ['b']});
73
+ const b = defineDispatcher({...root(), id: 'b', children: ['a']});
74
+ expect(() => defineTree('a', [a, b, main])).toThrow(/cycle a -> b -> a/);
75
+ });
76
+ });
77
+
78
+ describe('runTree', () => {
79
+ it('takes the first routed answer at or above the floor', async () => {
80
+ expect(await runTree(tree(), {query: 'Button'}, ctx)).toEqual(['Button']);
81
+ });
82
+ it('runs the fallback below the floor', async () => {
83
+ /** @type {any[]} */
84
+ const steps = [];
85
+ const answer = await runTree(tree(0.2), {query: 'Button'}, ctx, {
86
+ tuning: {onTrace: step => steps.push(step)},
87
+ });
88
+ expect(answer).toBeNull();
89
+ expect(steps.map(s => [s.id, s.fallback])).toEqual([
90
+ ['root', false],
91
+ ['main', true],
92
+ ]);
93
+ });
94
+ it('runs the fallback when no routed child answers', async () => {
95
+ expect(await runTree(tree(), {query: 'card'}, ctx)).toEqual(['Card']);
96
+ expect(await runTree(tree(), {query: 'Car'}, ctx)).toBeNull();
97
+ });
98
+ it('passes a node its tuning weights and traces every step', async () => {
99
+ /** @type {any[]} */
100
+ const steps = [];
101
+ const answer = await runTree(tree(), {query: 'Button'}, ctx, {
102
+ tuning: {
103
+ weights: {exact: {prefix: '*'}},
104
+ onTrace: step => steps.push(step),
105
+ },
106
+ });
107
+ expect(answer).toEqual(['*Button']);
108
+ expect(steps).toEqual([
109
+ {
110
+ id: 'root',
111
+ kind: 'dispatcher',
112
+ depth: 0,
113
+ fallback: false,
114
+ route: {ids: ['exact'], confidence: 1},
115
+ },
116
+ {
117
+ id: 'exact',
118
+ kind: 'searcher',
119
+ depth: 1,
120
+ fallback: false,
121
+ answered: true,
122
+ },
123
+ ]);
124
+ });
125
+ it('runs one node alone with start', async () => {
126
+ expect(
127
+ await runTree(tree(), {query: 'button'}, ctx, {start: 'main'}),
128
+ ).toEqual(['Button', 'ButtonGroup']);
129
+ });
130
+ it('rejects a route to a node that is not a child', async () => {
131
+ const stray = defineDispatcher({
132
+ ...root(),
133
+ route: () => ({ids: ['root'], confidence: 1}),
134
+ });
135
+ await expect(
136
+ runTree(defineTree('root', [stray, exact, main]), {query: 'x'}, ctx),
137
+ ).rejects.toThrow(/not one of its children/);
138
+ });
139
+ });
140
+
141
+ describe('classify', () => {
142
+ it('ignores a token that is only an Object.prototype name', () => {
143
+ const weights = {classes: ['a', 'b'], tokens: {button: [2, 0]}};
144
+ const probs = classify(weights, [
145
+ 'button',
146
+ 'constructor',
147
+ '__proto__',
148
+ 'toString',
149
+ ]);
150
+ expect(probs.every(Number.isFinite)).toBe(true);
151
+ expect(probs).toEqual(classify(weights, ['button']));
152
+ });
153
+ });