@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,78 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Reading a component or hook doc for search: the compiled doc and its
5
+ * usage guidance. Imported only by the adapter's gatherers.
6
+ */
7
+
8
+ import {readDocView} from '../../foundation/doc-compiler/read.mjs';
9
+
10
+ /** @typedef {import('./_gather-components.mjs').Candidate} Candidate */
11
+
12
+ /**
13
+ * A component or hook doc, compiled, or null when it cannot be read.
14
+ * @param {string} docPath
15
+ * @param {string} [exportName]
16
+ * @param {'components' | 'hooks'} [root]
17
+ * @returns {Promise<any>}
18
+ */
19
+ export async function loadModuleDoc(
20
+ docPath,
21
+ exportName = 'docs',
22
+ root = 'components',
23
+ ) {
24
+ try {
25
+ // Support both the stamped default export and the legacy named export.
26
+ return (
27
+ (await readDocView(docPath, {
28
+ root,
29
+ loader: 'native',
30
+ exports: ['default', exportName],
31
+ })) ?? null
32
+ );
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build component candidates from core's own tree: name + keywords +
40
+ * usage/description from the component's .doc.mjs.
41
+ * @param {string} coreDir
42
+ * @returns {Promise<Candidate[]>}
43
+ */
44
+
45
+ /**
46
+ * Usage guidance from a component doc: its feature list and its best-practice
47
+ * advice, flattened to plain strings.
48
+ *
49
+ * This is where a reader's vocabulary usually lives. `Banner` calls itself "a
50
+ * persistent message" and only its guidance names "form errors, system
51
+ * updates, maintenance notices" — so a search for the words people actually
52
+ * type finds nothing without it.
53
+ *
54
+ * @param {any} doc
55
+ * @returns {string[]}
56
+ */
57
+ export function guidanceFrom(doc) {
58
+ if (!doc) return [];
59
+ const features = Array.isArray(doc.features) ? doc.features : [];
60
+ const practices = Array.isArray(doc.usage?.bestPractices)
61
+ ? doc.usage.bestPractices
62
+ : [];
63
+ return [...features, ...practices]
64
+ .map(entry =>
65
+ typeof entry === 'string'
66
+ ? entry
67
+ : [
68
+ entry?.title,
69
+ entry?.text,
70
+ entry?.description,
71
+ entry?.do,
72
+ entry?.dont,
73
+ ]
74
+ .filter(Boolean)
75
+ .join(' '),
76
+ )
77
+ .filter(Boolean);
78
+ }
@@ -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
+ /**
5
+ * The search response: the lexical matches, each band in the learned order.
6
+ * Nothing is added or dropped, so the count and the scores stay the lexical
7
+ * ranker's; only the order inside a band, and so which matches fill the
8
+ * limit, changes.
9
+ * @param {EntryTable} table
10
+ * @param {number[]} order entry positions, best first, by the searcher's weights
11
+ * @param {import('./search.type.mjs').SearchResponse} lexical every lexical match
12
+ * @param {string} query
13
+ * @param {number} limit
14
+ * @returns {import('./search.type.mjs').SearchResponse}
15
+ */
16
+ export function orderMatches(table: EntryTable, order: number[], lexical: import("./search.type.mjs").SearchResponse, query: string, limit: number): import("./search.type.mjs").SearchResponse;
17
+ /**
18
+ * An integration's matches that the lexical ranker scored the same keep its
19
+ * order among themselves: they have no cards, so the learned order would only
20
+ * guess between them.
21
+ * @param {Item[]} items
22
+ * @returns {Item[]}
23
+ */
24
+ export function integrationTies(items: Item[]): Item[];
25
+ export function bandOf(score: number): number;
26
+ export type Item = {
27
+ res: any;
28
+ i: number;
29
+ band: number;
30
+ learned: number;
31
+ };
32
+ export type EntryTable = import("./_entries.mjs").EntryTable;
@@ -0,0 +1,162 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file How a searcher orders the lexical ranker's matches. Search answers
5
+ * only with what the lexical ranker matched, each with its own score and
6
+ * reason. A match never moves past one of another score band (BANDS below)
7
+ * or another kind, doc or not; between runs the lexical order holds. A band
8
+ * can be wide: 55–99 holds keyword matches beside one-word description hits,
9
+ * and a synonym name match can score under 55, so inside a band a weaker match
10
+ * can lead a stronger one when the weights favor it. Inside such a
11
+ * run the learned order places the matches, with these exceptions: matches
12
+ * that scored the same keep their lexical order, as one block; an entry the
13
+ * query names leads; in a query of one or two words, the matches on every
14
+ * word keep their lexical order; and an integration's matches that scored the
15
+ * same keep theirs.
16
+ */
17
+
18
+ import {entryOf} from './_entries.mjs';
19
+ import {words} from './_query-words.mjs';
20
+ import {searchedComponentsOf} from './_result.mjs';
21
+
22
+ /** @typedef {import('./_entries.mjs').EntryTable} EntryTable */
23
+
24
+ /**
25
+ * Where each lexical band starts, highest first: the whole query as a name,
26
+ * as a keyword, as a phrase in a title, a title in the query, every word
27
+ * matched, a strong partial match, a name or keyword match, and below that,
28
+ * prose alone.
29
+ */
30
+ const BANDS = [195, 188, 170, 160, 151, 100, 55];
31
+ /** The lowest score of the every-word top tier. */
32
+ const FULL_COVERAGE = 151;
33
+
34
+ /** @param {number} score */
35
+ export const bandOf = score => {
36
+ const i = BANDS.findIndex(floor => score >= floor);
37
+ return i === -1 ? BANDS.length : i;
38
+ };
39
+
40
+ /**
41
+ * The search response: the lexical matches, each band in the learned order.
42
+ * Nothing is added or dropped, so the count and the scores stay the lexical
43
+ * ranker's; only the order inside a band, and so which matches fill the
44
+ * limit, changes.
45
+ * @param {EntryTable} table
46
+ * @param {number[]} order entry positions, best first, by the searcher's weights
47
+ * @param {import('./search.type.mjs').SearchResponse} lexical every lexical match
48
+ * @param {string} query
49
+ * @param {number} limit
50
+ * @returns {import('./search.type.mjs').SearchResponse}
51
+ */
52
+ export function orderMatches(table, order, lexical, query, limit) {
53
+ const all = lexical.data.results;
54
+ /** @type {Map<number, number>} */
55
+ const place = new Map(order.map((k, i) => [k, i]));
56
+ // Runs of consecutive lexical matches in one band and of one kind (doc or
57
+ // not) are the only places the learned order acts: a match never crosses
58
+ // into another band, or past a doc or a non-doc the lexical ranker placed.
59
+ let run = -1;
60
+ /** @type {string} */
61
+ let last = '';
62
+ const items = all.map((res, i) => {
63
+ // Each match's own band: the lexical merge can place a weaker match
64
+ // ahead of a stronger doc, and the two must never share a run.
65
+ const key = `${bandOf(res.score ?? 0)}:${res.domain === 'doc'}`;
66
+ if (key !== last) {
67
+ run++;
68
+ last = key;
69
+ }
70
+ const k = entryOf(table, res);
71
+ return {
72
+ res,
73
+ i,
74
+ band: run,
75
+ learned: k === undefined ? Infinity : (place.get(k) ?? Infinity),
76
+ };
77
+ });
78
+ const named = (/** @type {Item} */ it) =>
79
+ (it.res.score ?? 0) >= 95 &&
80
+ /^(exact name|plural of the name|name )/.test(it.res.reason ?? '');
81
+ // Matches the lexical ranker scored the same move as one block, in its
82
+ // order; the learned position of the block's first match places the block
83
+ // in its run (not its best member's, which would let one strong member lift
84
+ // a whole block of weak ones), and an entry the query names leads its run.
85
+ // Moving each match on its own breaks search's existing rules for tied
86
+ // matches (`file required` leads with Field, `ci` with the integration
87
+ // check) and grows answers, so ties move together; where the block lands is
88
+ // measured as spec:AST-083 FR6 requires.
89
+ /** @type {Map<string, number>} */
90
+ const block = new Map();
91
+ const blockOf = (/** @type {Item} */ it) => `${it.band}:${it.res.score}`;
92
+ for (const it of items) {
93
+ const at = named(it) ? -1 : it.learned;
94
+ const k = blockOf(it);
95
+ if (!block.has(k) || at === -1) block.set(k, at);
96
+ }
97
+ let ranked = [...items].sort(
98
+ (x, y) =>
99
+ x.band - y.band ||
100
+ (block.get(blockOf(x)) ?? 0) - (block.get(blockOf(y)) ?? 0) ||
101
+ x.i - y.i,
102
+ );
103
+ if (words(query).length <= 2) {
104
+ ranked = keepLexical(ranked, it => (it.res.score ?? 0) >= FULL_COVERAGE);
105
+ }
106
+ ranked = integrationTies(ranked);
107
+ const response = {
108
+ ...lexical,
109
+ data: {...lexical.data, results: ranked.slice(0, limit).map(it => it.res)},
110
+ };
111
+ const searched = searchedComponentsOf.get(lexical);
112
+ if (searched) searchedComponentsOf.set(response, searched);
113
+ return /** @type {import('./search.type.mjs').SearchResponse} */ (response);
114
+ }
115
+
116
+ /**
117
+ * @typedef {{res: any, i: number, band: number, learned: number}} Item
118
+ */
119
+
120
+ /**
121
+ * The matches `pick` selects keep the places they hold, filled in lexical
122
+ * order.
123
+ * @param {Item[]} items
124
+ * @param {(item: Item) => boolean} pick
125
+ * @returns {Item[]}
126
+ */
127
+ function keepLexical(items, pick) {
128
+ /** @type {number[]} */
129
+ const at = [];
130
+ items.forEach((it, j) => pick(it) && at.push(j));
131
+ if (at.length < 2) return items;
132
+ const sorted = at.map(j => items[j]).sort((a, b) => a.i - b.i);
133
+ const out = [...items];
134
+ at.forEach((j, n) => (out[j] = sorted[n]));
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * An integration's matches that the lexical ranker scored the same keep its
140
+ * order among themselves: they have no cards, so the learned order would only
141
+ * guess between them.
142
+ * @param {Item[]} items
143
+ * @returns {Item[]}
144
+ */
145
+ export function integrationTies(items) {
146
+ /** @type {Map<number, number[]>} */
147
+ const groups = new Map();
148
+ items.forEach((it, j) => {
149
+ const pkg = it.res.package ?? '';
150
+ if (!pkg || pkg.startsWith('@astryxdesign/')) return;
151
+ const key = it.res.score ?? 0;
152
+ if (!groups.has(key)) groups.set(key, []);
153
+ /** @type {number[]} */ (groups.get(key)).push(j);
154
+ });
155
+ const out = [...items];
156
+ for (const at of groups.values()) {
157
+ if (at.length < 2) continue;
158
+ const sorted = at.map(j => items[j]).sort((a, b) => a.i - b.i);
159
+ at.forEach((j, n) => (out[j] = sorted[n]));
160
+ }
161
+ return out;
162
+ }
@@ -0,0 +1,72 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Invariants of search's ordering, checked against the lexical ranker on
5
+ * the real catalog: search answers only with lexical matches, keeps each one's
6
+ * score and reason, and never moves a match past one of another band or kind.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import * as path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {search, searchCore} from './search.mjs';
13
+ import {bandOf} from './_order.mjs';
14
+
15
+ const cwd = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ '../../../..',
18
+ );
19
+
20
+ /** @param {any} r */
21
+ const keyOf = r => `${r.domain}:${r.name}:${r.command}`;
22
+ /** @param {any} r */
23
+ const runOf = r => `${bandOf(r.score ?? 0)}:${r.domain === 'doc'}`;
24
+
25
+ const QUERIES = [
26
+ 'account settings',
27
+ 'table of contents',
28
+ 'content',
29
+ 'dark mode',
30
+ 'illustration',
31
+ 'date picker in a card',
32
+ 'how do I add a theme',
33
+ 'button',
34
+ 'side panel that resizes',
35
+ ];
36
+
37
+ describe('search ordering invariants', () => {
38
+ for (const q of QUERIES) {
39
+ it(`keeps the lexical matches, bands and kinds for "${q}"`, async () => {
40
+ const ours = await search(q, {cwd, limit: 50});
41
+ const lexical = await searchCore(q, {
42
+ cwd,
43
+ limit: Number.MAX_SAFE_INTEGER,
44
+ });
45
+ expect(ours.data.matchCount).toBe(lexical.data.matchCount);
46
+ expect(ours.data.results.length).toBeLessThanOrEqual(
47
+ Math.min(50, lexical.data.matchCount),
48
+ );
49
+ /** @type {Map<string, number>} */
50
+ const at = new Map(lexical.data.results.map((r, i) => [keyOf(r), i]));
51
+ const byKey = new Map(lexical.data.results.map(r => [keyOf(r), r]));
52
+ for (const r of ours.data.results) {
53
+ const own = byKey.get(keyOf(r));
54
+ expect(own, `${keyOf(r)} is a lexical match`).toBeDefined();
55
+ expect(r.score).toBe(own?.score);
56
+ expect(r.reason).toBe(own?.reason);
57
+ }
58
+ // Two results of different runs (band or kind) keep the lexical order.
59
+ const rs = ours.data.results;
60
+ for (let i = 0; i < rs.length; i++) {
61
+ for (let j = i + 1; j < rs.length; j++) {
62
+ if (runOf(rs[i]) === runOf(rs[j])) continue;
63
+ expect(
64
+ /** @type {number} */ (at.get(keyOf(rs[i]))) <
65
+ /** @type {number} */ (at.get(keyOf(rs[j]))),
66
+ `${keyOf(rs[i])} above ${keyOf(rs[j])}`,
67
+ ).toBe(true);
68
+ }
69
+ }
70
+ }, 60_000);
71
+ }
72
+ });
@@ -0,0 +1,50 @@
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('./_lexical.mjs').Candidate} Candidate */
5
+ /**
6
+ * A match's domain priority: 0 (none), 1, or 2 (a doc the whole query names).
7
+ * A component, hook, template, or theme with priority ranks ahead of any doc
8
+ * without it, whatever the two scores are. Docs among themselves rank by
9
+ * priority, then score; everything else ranks among itself by score.
10
+ *
11
+ * A component, hook, template, or theme has priority (1) when one of the
12
+ * query's words, or the whole query, hits its name or an authored keyword at
13
+ * {@link STRONG_TOKEN_SCORE} or above, or when it matches every word.
14
+ *
15
+ * A doc has priority when the reader asked for that doc:
16
+ * - 2: the whole query is the topic's name, its last route segment, or its
17
+ * own title, as words and whatever the plural (`font setup` is
18
+ * typography/font-setup, `side panel` is "Side panels");
19
+ * - 1: a word of the query is the topic's name (`illustration` in a longer
20
+ * question is the illustrations guide);
21
+ * - 1: a query of two or more words is one of the topic's own declared
22
+ * keywords, whatever the plural (`code review` on a guide that declares it);
23
+ * - 1: the query holds one of the doc's titles whole (`resizable side panels`
24
+ * names "Side panels"; `light dark mode button` names "Light/Dark Mode");
25
+ * - 1: the doc matches every word of the query and one of them is a word of
26
+ * its title (`switch to a dark theme` and "Use a theme").
27
+ * A section answers to its topic's name.
28
+ *
29
+ * A doc that matched only by a heading holding the query, by one word, or by
30
+ * words spread through its text has no priority. Docs are split into many
31
+ * small topics, each with its own headings, so a common phrase such as
32
+ * `font size` hits a heading exactly (170) while the component the reader is
33
+ * after matches one word by keyword (`Text`, 98). Ranked on text alone, every
34
+ * split adds another doc above the component. Broad reference pages match every word of many queries
35
+ * in their text the same way. The score stays the text-match strength the
36
+ * result reports, so callers that gate on it (`build`) see the same numbers.
37
+ *
38
+ * @param {string} term - Lowercased search term.
39
+ * @param {string[]} tokens - Content tokens from tokenizeQuery(term).
40
+ * @param {Candidate} candidate
41
+ * @param {{score: number, matched: number, total: number}} hit - The
42
+ * candidate's scoreQuery result.
43
+ * @returns {0 | 1 | 2}
44
+ */
45
+ export function domainPriority(term: string, tokens: string[], candidate: Candidate, hit: {
46
+ score: number;
47
+ matched: number;
48
+ total: number;
49
+ }, queryWords?: string[]): 0 | 1 | 2;
50
+ export type Candidate = import("./_lexical.mjs").Candidate;
@@ -0,0 +1,139 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Domain priority: when a component, hook, template, or theme ranks
5
+ * ahead of docs the reader did not ask for.
6
+ */
7
+
8
+ import {sameWord} from './_words.mjs';
9
+ import {phraseWords, samePhraseWord, samePhrase} from './_query.mjs';
10
+ import {compoundHit, compoundOf} from './_compound.mjs';
11
+ import {
12
+ bestForToken,
13
+ FULL_COVERAGE_SCORE,
14
+ STRONG_TOKEN_SCORE,
15
+ } from './_score.mjs';
16
+ import {TITLE_PHRASE_SCORE, TITLE_IN_QUERY_SCORE} from './_titles.mjs';
17
+ import {scoreCandidate} from './_candidate-score.mjs';
18
+
19
+ /** @typedef {import('./_lexical.mjs').Candidate} Candidate */
20
+
21
+ /**
22
+ * A match's domain priority: 0 (none), 1, or 2 (a doc the whole query names).
23
+ * A component, hook, template, or theme with priority ranks ahead of any doc
24
+ * without it, whatever the two scores are. Docs among themselves rank by
25
+ * priority, then score; everything else ranks among itself by score.
26
+ *
27
+ * A component, hook, template, or theme has priority (1) when one of the
28
+ * query's words, or the whole query, hits its name or an authored keyword at
29
+ * {@link STRONG_TOKEN_SCORE} or above, or when it matches every word.
30
+ *
31
+ * A doc has priority when the reader asked for that doc:
32
+ * - 2: the whole query is the topic's name, its last route segment, or its
33
+ * own title, as words and whatever the plural (`font setup` is
34
+ * typography/font-setup, `side panel` is "Side panels");
35
+ * - 1: a word of the query is the topic's name (`illustration` in a longer
36
+ * question is the illustrations guide);
37
+ * - 1: a query of two or more words is one of the topic's own declared
38
+ * keywords, whatever the plural (`code review` on a guide that declares it);
39
+ * - 1: the query holds one of the doc's titles whole (`resizable side panels`
40
+ * names "Side panels"; `light dark mode button` names "Light/Dark Mode");
41
+ * - 1: the doc matches every word of the query and one of them is a word of
42
+ * its title (`switch to a dark theme` and "Use a theme").
43
+ * A section answers to its topic's name.
44
+ *
45
+ * A doc that matched only by a heading holding the query, by one word, or by
46
+ * words spread through its text has no priority. Docs are split into many
47
+ * small topics, each with its own headings, so a common phrase such as
48
+ * `font size` hits a heading exactly (170) while the component the reader is
49
+ * after matches one word by keyword (`Text`, 98). Ranked on text alone, every
50
+ * split adds another doc above the component. Broad reference pages match every word of many queries
51
+ * in their text the same way. The score stays the text-match strength the
52
+ * result reports, so callers that gate on it (`build`) see the same numbers.
53
+ *
54
+ * @param {string} term - Lowercased search term.
55
+ * @param {string[]} tokens - Content tokens from tokenizeQuery(term).
56
+ * @param {Candidate} candidate
57
+ * @param {{score: number, matched: number, total: number}} hit - The
58
+ * candidate's scoreQuery result.
59
+ * @returns {0 | 1 | 2}
60
+ */
61
+ export function domainPriority(
62
+ term,
63
+ tokens,
64
+ candidate,
65
+ hit,
66
+ queryWords = phraseWords(term),
67
+ ) {
68
+ const words = [term, ...tokens];
69
+ if (candidate.domain === 'doc') {
70
+ const topic = String(candidate._topic ?? candidate.name).toLowerCase();
71
+ // The whole query, read as words, is the topic's route, its last
72
+ // segment, or its own title, whatever the plural: `side panel` is
73
+ // layout/side-panels, and `header and footer` is "Headers and footers".
74
+ // A section's title is a heading inside a topic, not the topic's name.
75
+ const named = [topic, topic.slice(topic.lastIndexOf('/') + 1)];
76
+ if (candidate._section == null && candidate.titles?.[0]) {
77
+ named.push(candidate.titles[0]);
78
+ }
79
+ if (named.some(n => samePhrase(term, n))) {
80
+ return 2;
81
+ }
82
+ // A query of two or more words that is one of the doc's own declared
83
+ // keywords, read as words whatever the plural, is the author naming the
84
+ // doc for that phrase: `code review` on a guide that declares it. Only
85
+ // what the author declared counts: the code terms and table cells a
86
+ // doc's other keywords add (`missing_field`) name what it mentions, not
87
+ // what it is for. One word is left out too, and a section declares
88
+ // nothing.
89
+ if (
90
+ phraseWords(term).length >= 2 &&
91
+ candidate._section == null &&
92
+ (candidate._declared ?? []).some(k => samePhrase(term, String(k)))
93
+ ) {
94
+ return 1;
95
+ }
96
+ if (
97
+ words.some(
98
+ w =>
99
+ (scoreCandidate(w, {name: topic}, {fuzzy: false})?.score ?? 0) >= 95,
100
+ )
101
+ ) {
102
+ return 1;
103
+ }
104
+ // A whole title inside the query is the only path to 160-169.
105
+ if (hit.score >= TITLE_IN_QUERY_SCORE && hit.score < TITLE_PHRASE_SCORE) {
106
+ return 1;
107
+ }
108
+ // Every word matched, and one of them names the doc in its title.
109
+ if (
110
+ hit.score >= FULL_COVERAGE_SCORE &&
111
+ hit.score < TITLE_IN_QUERY_SCORE &&
112
+ hit.matched === hit.total
113
+ ) {
114
+ const titleWords = phraseWords(candidate.titles?.[0] ?? '');
115
+ if (
116
+ (tokens.length ? tokens : [term]).some(t =>
117
+ titleWords.some(w => samePhraseWord(t, w) || sameWord(t, w)),
118
+ )
119
+ ) {
120
+ return 1;
121
+ }
122
+ }
123
+ return 0;
124
+ }
125
+ if (hit.score >= FULL_COVERAGE_SCORE) return 1;
126
+ const compound = compoundOf.has(candidate)
127
+ ? compoundOf.get(candidate)
128
+ : compoundHit(queryWords, candidate);
129
+ if ((compound?.score ?? 0) >= STRONG_TOKEN_SCORE) {
130
+ return 1;
131
+ }
132
+ const fuzzy = tokens.length <= 1;
133
+ return words.some(
134
+ w =>
135
+ (bestForToken(w, candidate, {fuzzy})?.score ?? 0) >= STRONG_TOKEN_SCORE,
136
+ )
137
+ ? 1
138
+ : 0;
139
+ }
@@ -0,0 +1,25 @@
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
+ /** Plural folding: one fixed rule per suffix. @param {string} w */
5
+ export function fold(w: string): string;
6
+ /** A name's words: `DateRangeInput` is date, range, input. @param {string} name */
7
+ export function nameWords(name: string): string[];
8
+ /** @param {string} q @returns {string[]} unique words, then adjacent pairs */
9
+ export function queryTokens(q: string): string[];
10
+ /** The router's inputs: the query's tokens plus four shape flags. @param {string} q */
11
+ export function routerTokens(q: string): string[];
12
+ /**
13
+ * The router's probability for each class: the shared classifier over the
14
+ * query's tokens.
15
+ * @param {import('../../foundation/route/route.mjs').ClassifierWeights} weights
16
+ * @param {string} q
17
+ * @returns {number[]}
18
+ */
19
+ export function routeProbs(weights: import("../../foundation/route/route.mjs").ClassifierWeights, q: string): number[];
20
+ /** The domains, in the order the weights files index them. */
21
+ export const DOMAINS: string[];
22
+ /** The router's classes: the domain a query wants, or none. */
23
+ export const ROUTES: string[];
24
+ export function words(text: string): string[];
25
+ export function queryWords(q: string): string[];
@@ -0,0 +1,92 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file How search's searchers and classifier read a query: its words,
5
+ * folded forms, word pairs, and the classifier's tokens and class
6
+ * probabilities.
7
+ */
8
+
9
+ import {classify} from '../../foundation/route/route.mjs';
10
+
11
+ /** The domains, in the order the weights files index them. */
12
+ export const DOMAINS = ['component', 'hook', 'doc', 'template', 'theme'];
13
+
14
+ /** The router's classes: the domain a query wants, or none. */
15
+ export const ROUTES = [...DOMAINS, 'NONE'];
16
+
17
+ const STOP = new Set(
18
+ 'a an the of for to with and or in on by is it my me i we you this that be as at from use using how what which show make add create get set any can do does not no into via'.split(
19
+ ' ',
20
+ ),
21
+ );
22
+
23
+ const QUESTION = new Set([
24
+ 'how',
25
+ 'what',
26
+ 'why',
27
+ 'when',
28
+ 'where',
29
+ 'can',
30
+ 'does',
31
+ 'is',
32
+ ]);
33
+
34
+ /** @param {string} text @returns {string[]} */
35
+ export const words = text =>
36
+ (text || '').toLowerCase().match(/[a-z0-9]+/g) ?? [];
37
+
38
+ /** Plural folding: one fixed rule per suffix. @param {string} w */
39
+ export function fold(w) {
40
+ if (w.length > 4 && w.endsWith('ies')) return `${w.slice(0, -3)}y`;
41
+ if (w.length > 3 && w.endsWith('es') && 'sxz'.includes(w[w.length - 3])) {
42
+ return w.slice(0, -2);
43
+ }
44
+ if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss'))
45
+ return w.slice(0, -1);
46
+ return w;
47
+ }
48
+
49
+ /** A name's words: `DateRangeInput` is date, range, input. @param {string} name */
50
+ export function nameWords(name) {
51
+ const s = name
52
+ .replace(/[/-]/g, ' ')
53
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
54
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
55
+ return words(s);
56
+ }
57
+
58
+ /** The query's content words, folded. @param {string} q */
59
+ export const queryWords = q =>
60
+ words(q)
61
+ .filter(w => !STOP.has(w))
62
+ .map(fold);
63
+
64
+ /** @param {string} q @returns {string[]} unique words, then adjacent pairs */
65
+ export function queryTokens(q) {
66
+ const qw = queryWords(q);
67
+ const pairs = qw.slice(1).map((w, i) => `${qw[i]}_${w}`);
68
+ return [...new Set([...qw, ...pairs])];
69
+ }
70
+
71
+ /** The router's inputs: the query's tokens plus four shape flags. @param {string} q */
72
+ export function routerTokens(q) {
73
+ const ws = words(q);
74
+ const flags = [`#n${Math.min(ws.length, 6)}`];
75
+ if (ws.length && QUESTION.has(ws[0])) flags.push('#question');
76
+ if (q.split(/\s+/).some(w => /^use[a-z]+/.test(w)) || /\buse[A-Z]/.test(q)) {
77
+ flags.push('#usehook');
78
+ }
79
+ if (/[A-Z][a-z]+[A-Z]/.test(q)) flags.push('#camel');
80
+ return [...queryTokens(q), ...flags, '#bias'];
81
+ }
82
+
83
+ /**
84
+ * The router's probability for each class: the shared classifier over the
85
+ * query's tokens.
86
+ * @param {import('../../foundation/route/route.mjs').ClassifierWeights} weights
87
+ * @param {string} q
88
+ * @returns {number[]}
89
+ */
90
+ export function routeProbs(weights, q) {
91
+ return classify(weights, routerTokens(q));
92
+ }