@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
@@ -1,11 +1,75 @@
1
1
  // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
2
  // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
3
 
4
+ /** @typedef {import('./_lexical.mjs').Candidate} Candidate */
4
5
  /**
5
6
  * The Core a typed search reads: the project's, else the release's own,
6
- * installed into the per-user cache first when needed. Throws as
7
- * requireCoreDir does.
7
+ * installed into the per-user cache first when needed (a network install and
8
+ * an npm subprocess; spec AST-081 FR3). Throws as requireCoreDir does.
8
9
  * @param {string} cwd
9
10
  * @returns {string}
10
11
  */
11
12
  export function requireSearchCoreDir(cwd: string): string;
13
+ /**
14
+ * Gathers every candidate the query's domains hold.
15
+ *
16
+ * @param {{cwd?: string, type?: string}} options
17
+ * @returns {Promise<{all: Candidate[], components: Candidate[], searchedComponents: boolean, outside: boolean, coreSkipped: string | null}>}
18
+ */
19
+ export function gatherCandidates(options: {
20
+ cwd?: string;
21
+ type?: string;
22
+ }): Promise<{
23
+ all: Candidate[];
24
+ components: Candidate[];
25
+ searchedComponents: boolean;
26
+ outside: boolean;
27
+ coreSkipped: string | null;
28
+ }>;
29
+ /**
30
+ * The search tree's static data, read once: the entry cards and each node's
31
+ * weights file.
32
+ *
33
+ * These files are static, reviewed data that only order matches inside a score
34
+ * band; ORDERING.md beside them says what each holds and how
35
+ * they are refreshed.
36
+ * @returns {SearchData}
37
+ */
38
+ export function loadSearchData(): SearchData;
39
+ export { componentKeywords } from "./_gather-components.mjs";
40
+ export type Candidate = import("./_lexical.mjs").Candidate;
41
+ export type SearcherWeights = {
42
+ w: number[];
43
+ mu: number[];
44
+ sd: number[];
45
+ };
46
+ export type SearchData = {
47
+ /**
48
+ * Each Core component's and hook's source-built card: what it is, the words
49
+ * people type for it, its main props.
50
+ */
51
+ cards: Record<string, {
52
+ what?: string;
53
+ synonyms?: string[];
54
+ props?: string[];
55
+ }>;
56
+ /**
57
+ * The root classifier's weights.
58
+ */
59
+ router: import("../../foundation/route/route.mjs").ClassifierWeights;
60
+ /**
61
+ * The main searcher's weights.
62
+ */
63
+ main: SearcherWeights;
64
+ /**
65
+ * The doc searcher's weights.
66
+ */
67
+ doc: SearcherWeights;
68
+ /**
69
+ * The template searcher's weights.
70
+ */
71
+ template: SearcherWeights;
72
+ };
73
+ export type SearchCatalog = Awaited<ReturnType<typeof gatherCandidates>> & {
74
+ data: SearchData;
75
+ };
@@ -1,25 +1,133 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file Environment adapter for search: locating the Core a search reads,
5
- * including the network install and npm subprocess that fetch a release's
6
- * own Core outside a project (spec AST-081 FR3; architecture:cli-surface
7
- * INV21).
8
- *
9
- * @input the folder a search runs in
10
- * @output the Core folder a search reads
11
- * @position packages/cli/api/search — imported by search.mjs only
4
+ * @file Search's adapter: the only place search reads the environment. It
5
+ * gathers every candidate from the domains a query asks for (one file per
6
+ * domain, _gather-*.mjs) and loads the tree's static data before it runs.
12
7
  */
13
8
 
9
+ import {readFileSync} from 'node:fs';
14
10
  import {requireCoreDir} from '../../foundation/fs/paths.mjs';
11
+ import {isOutsideProject} from '../../foundation/env/project-scope.mjs';
12
+ import {coreDirOrThrow} from '../error.mjs';
13
+ import {CORELESS_DOMAINS} from './_query.mjs';
14
+ import {gatherComponents} from './_gather-components.mjs';
15
+ import {gatherHooks} from './_gather-hooks.mjs';
16
+ import {gatherDocs} from './_gather-docs.mjs';
17
+ import {gatherTemplates, gatherThemes} from './_gather-templates.mjs';
18
+
19
+ export {componentKeywords} from './_gather-components.mjs';
20
+
21
+ /** @typedef {import('./_lexical.mjs').Candidate} Candidate */
15
22
 
16
23
  /**
17
24
  * The Core a typed search reads: the project's, else the release's own,
18
- * installed into the per-user cache first when needed. Throws as
19
- * requireCoreDir does.
25
+ * installed into the per-user cache first when needed (a network install and
26
+ * an npm subprocess; spec AST-081 FR3). Throws as requireCoreDir does.
20
27
  * @param {string} cwd
21
28
  * @returns {string}
22
29
  */
23
30
  export function requireSearchCoreDir(cwd) {
24
31
  return requireCoreDir(cwd);
25
32
  }
33
+
34
+ /**
35
+ * Gathers every candidate the query's domains hold.
36
+ *
37
+ * @param {{cwd?: string, type?: string}} options
38
+ * @returns {Promise<{all: Candidate[], components: Candidate[], searchedComponents: boolean, outside: boolean, coreSkipped: string | null}>}
39
+ */
40
+ export async function gatherCandidates(options) {
41
+ const {cwd = process.cwd(), type} = options;
42
+ // `astryx docs` reads docs without @astryxdesign/core, and `astryx theme
43
+ // list` reads themes without it (bundled themes need no project), so a
44
+ // search of either must too. Every other domain reads core: asked for by
45
+ // name, it is an error without core; an open search then covers the docs
46
+ // and themes alone.
47
+ const needsCore = !type || !CORELESS_DOMAINS.includes(type);
48
+ // A domain that reads Core gets it as `component` does: the project's, else
49
+ // the answering release's own, installed into the per-user cache on first
50
+ // need (spec AST-081 FR3). Asked for by name, a Core that cannot be had is
51
+ // the error; an open search answers from the rest and says what it skipped
52
+ // and why, never silently.
53
+ /** @type {string | null} */
54
+ let coreDir = null;
55
+ /** @type {string | null} */
56
+ let coreSkipped = null;
57
+ if (needsCore && type) {
58
+ coreDir = coreDirOrThrow(requireSearchCoreDir, cwd);
59
+ } else if (needsCore) {
60
+ try {
61
+ coreDir = requireSearchCoreDir(cwd);
62
+ } catch (error) {
63
+ coreSkipped = /** @type {Error} */ (error).message;
64
+ }
65
+ }
66
+
67
+ // Gather candidates from each requested domain in parallel.
68
+ /** @param {string} d */
69
+ const wants = d =>
70
+ (!type && (coreDir != null || CORELESS_DOMAINS.includes(d))) || type === d;
71
+ const [components, hooks, docTopics, templates, themes] = await Promise.all([
72
+ wants('component')
73
+ ? gatherComponents(/** @type {string} */ (coreDir), cwd)
74
+ : [],
75
+ wants('hook') ? gatherHooks(/** @type {string} */ (coreDir)) : [],
76
+ wants('doc') ? gatherDocs(cwd) : [],
77
+ wants('template') ? gatherTemplates(cwd) : [],
78
+ wants('theme') ? gatherThemes(cwd) : [],
79
+ ]);
80
+
81
+ const all = [...components, ...hooks, ...docTopics, ...templates, ...themes];
82
+ // Outside a project a theme cannot be added to an app, so its command forks
83
+ // the source instead (spec AST-081 FR3).
84
+ const outside = themes.length > 0 && isOutsideProject(cwd);
85
+ return {
86
+ all,
87
+ components,
88
+ searchedComponents: wants('component'),
89
+ outside,
90
+ coreSkipped,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @typedef {{w: number[], mu: number[], sd: number[]}} SearcherWeights
96
+ * @typedef {object} SearchData
97
+ * @property {Record<string, {what?: string, synonyms?: string[], props?: string[]}>} cards
98
+ * Each Core component's and hook's source-built card: what it is, the words
99
+ * people type for it, its main props.
100
+ * @property {import('../../foundation/route/route.mjs').ClassifierWeights} router The root classifier's weights.
101
+ * @property {SearcherWeights} main The main searcher's weights.
102
+ * @property {SearcherWeights} doc The doc searcher's weights.
103
+ * @property {SearcherWeights} template The template searcher's weights.
104
+ * @typedef {Awaited<ReturnType<typeof gatherCandidates>> & {data: SearchData}} SearchCatalog
105
+ */
106
+
107
+ /** @type {SearchData | null} */
108
+ let searchData = null;
109
+
110
+ /**
111
+ * The search tree's static data, read once: the entry cards and each node's
112
+ * weights file.
113
+ *
114
+ * These files are static, reviewed data that only order matches inside a score
115
+ * band; ORDERING.md beside them says what each holds and how
116
+ * they are refreshed.
117
+ * @returns {SearchData}
118
+ */
119
+ export function loadSearchData() {
120
+ if (!searchData) {
121
+ /** @param {string} file */
122
+ const read = file =>
123
+ JSON.parse(readFileSync(new URL(file, import.meta.url), 'utf8'));
124
+ searchData = {
125
+ cards: read('./_cards.json'),
126
+ router: read('./tree.weights.json'),
127
+ main: read('./_main-searcher.weights.json'),
128
+ doc: read('./_doc-searcher.weights.json'),
129
+ template: read('./_template-searcher.weights.json'),
130
+ };
131
+ }
132
+ return searchData;
133
+ }
@@ -0,0 +1,40 @@
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
+ * Score a single candidate against the search term across name, keywords,
6
+ * and prose signals. Returns the best (highest) score plus a human reason,
7
+ * or null if nothing matched above the floor.
8
+ *
9
+ * @param {string} term - Lowercased search term.
10
+ * @param {object} candidate
11
+ * @param {string} candidate.name - Primary identifier (component/hook name, topic, template name).
12
+ * @param {string[]} [candidate.aliases] - Other names, scored like the name (a theme's display name).
13
+ * @param {string} [candidate.domain] - A component, hook, or template name
14
+ * also matches typed as words: `command palette` is CommandPalette.
15
+ * @param {string[]} [candidate.keywords] - Authored intent (componentsUsed, category words).
16
+ * @param {string[]} [candidate.weakKeywords] - Derived signal (components a page renders).
17
+ * @param {string} [candidate.description]
18
+ * @param {string[]} [candidate.prose] - Extra free-text blobs (doc section text, best practices).
19
+ * @param {string[]} [candidate.guidance] - Usage guidance (features, best practices) — scored a tier below description.
20
+ * @param {{fuzzy?: boolean, joins?: boolean}} [opts] - `fuzzy`: allow edit-distance (typo) matches. Default true; multi-word queries pass false. `joins`: read a spaced query joined into a name or keyword (default true); off when the query names an entry outright.
21
+ * @returns {{score: number, reason: string, alias?: 'partial' | 'keyword'} | null}
22
+ * `alias` marks a hit that read the query's words joined.
23
+ */
24
+ export function scoreCandidate(term: string, { name, aliases, domain, keywords, weakKeywords, description, prose, guidance, }: {
25
+ name: string;
26
+ aliases?: string[] | undefined;
27
+ domain?: string | undefined;
28
+ keywords?: string[] | undefined;
29
+ weakKeywords?: string[] | undefined;
30
+ description?: string | undefined;
31
+ prose?: string[] | undefined;
32
+ guidance?: string[] | undefined;
33
+ }, { fuzzy, joins }?: {
34
+ fuzzy?: boolean;
35
+ joins?: boolean;
36
+ }): {
37
+ score: number;
38
+ reason: string;
39
+ alias?: "partial" | "keyword";
40
+ } | null;
@@ -0,0 +1,238 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file One search term's score against one candidate: name, keyword, prose,
5
+ * and typo signals.
6
+ *
7
+ * Scoring is keyword + fuzzy ranking (NOT semantic / embeddings — that is a
8
+ * deliberate future follow-up). It reuses the same signal weighting as the
9
+ * component fuzzy resolver in lib/string-utils.mjs:
10
+ *
11
+ * 100 exact name match
12
+ * 95 name is the term's plural or stem form ("buttons" -> Button)
13
+ * 90 exact keyword match
14
+ * 88 keyword is the term's plural or stem form
15
+ * 80 name Levenshtein distance 1 (one-word lookups, words of 5+ letters)
16
+ * 70 keyword word prefix / distance 1 (distance: one-word lookups, 5+ letters)
17
+ * 60 name word prefix (>=4 chars, >=50% coverage)
18
+ * 60 exact weak-keyword match
19
+ * 50 description / prose mentions the term
20
+ * 45 usage guidance mentions the term
21
+ * 40 name Levenshtein distance 2 (one-word lookups, 8+ letters)
22
+ * 40 weak-keyword word prefix
23
+ * 30 keyword Levenshtein distance 2 (one-word lookups, 8+ letters)
24
+ * 20 name Levenshtein distance 3 (one-word lookups, 11+ letters)
25
+ *
26
+ * Name + keyword signals always outweigh description/prose, so an exact match
27
+ * sorts above an incidental mention.
28
+ *
29
+ * A term matches inside a name or keyword only at the start of one of its
30
+ * words: "dash" finds "dashboard" and "input" finds "TextInput", but "file"
31
+ * does not find "profile". Edit distance is typo tolerance, so it applies only
32
+ * to a one-word lookup, where a typo is the likely explanation, and only to
33
+ * words long enough that one edit rarely makes another real word. In a
34
+ * sentence, a near miss is usually a different word: "site" is not "side",
35
+ * "cable" is not "table".
36
+ */
37
+
38
+ import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
39
+ import {
40
+ stem,
41
+ wordForms,
42
+ sameWord,
43
+ startsAWordOf,
44
+ joined,
45
+ isTypo,
46
+ } from './_words.mjs';
47
+ import {pluralOf} from './_query.mjs';
48
+
49
+ /**
50
+ * Score a single candidate against the search term across name, keywords,
51
+ * and prose signals. Returns the best (highest) score plus a human reason,
52
+ * or null if nothing matched above the floor.
53
+ *
54
+ * @param {string} term - Lowercased search term.
55
+ * @param {object} candidate
56
+ * @param {string} candidate.name - Primary identifier (component/hook name, topic, template name).
57
+ * @param {string[]} [candidate.aliases] - Other names, scored like the name (a theme's display name).
58
+ * @param {string} [candidate.domain] - A component, hook, or template name
59
+ * also matches typed as words: `command palette` is CommandPalette.
60
+ * @param {string[]} [candidate.keywords] - Authored intent (componentsUsed, category words).
61
+ * @param {string[]} [candidate.weakKeywords] - Derived signal (components a page renders).
62
+ * @param {string} [candidate.description]
63
+ * @param {string[]} [candidate.prose] - Extra free-text blobs (doc section text, best practices).
64
+ * @param {string[]} [candidate.guidance] - Usage guidance (features, best practices) — scored a tier below description.
65
+ * @param {{fuzzy?: boolean, joins?: boolean}} [opts] - `fuzzy`: allow edit-distance (typo) matches. Default true; multi-word queries pass false. `joins`: read a spaced query joined into a name or keyword (default true); off when the query names an entry outright.
66
+ * @returns {{score: number, reason: string, alias?: 'partial' | 'keyword'} | null}
67
+ * `alias` marks a hit that read the query's words joined.
68
+ */
69
+ export function scoreCandidate(
70
+ term,
71
+ {
72
+ name,
73
+ aliases = [],
74
+ domain,
75
+ keywords = [],
76
+ weakKeywords = [],
77
+ description = '',
78
+ prose = [],
79
+ guidance = [],
80
+ },
81
+ {fuzzy = true, joins = true} = {},
82
+ ) {
83
+ let best = 0;
84
+ let reason = '';
85
+ /** @type {'partial' | 'keyword' | false} */
86
+ let alias = false;
87
+ /**
88
+ * @param {number} score
89
+ * @param {string} why
90
+ * @param {'partial' | 'keyword' | false} [byAlias] - the hit reads the
91
+ * query's words joined, into part of a name or into a keyword
92
+ */
93
+ const consider = (score, why, byAlias = false) => {
94
+ if (score > best) {
95
+ best = score;
96
+ reason = why;
97
+ alias = byAlias;
98
+ }
99
+ };
100
+
101
+ // ── Name signals ────────────────────────────────────────────────
102
+ // An alias is a name too: a theme answers to its display name as well as
103
+ // its slug.
104
+ for (const candidateName of [name, ...aliases.filter(Boolean)]) {
105
+ const nameLower = candidateName.toLowerCase();
106
+ // A placed guide's name is its route, and the route's last segment is its
107
+ // name too, as a flat topic's is: `codemods` is cli/integrations/codemods.
108
+ const leafLower = nameLower.slice(nameLower.lastIndexOf('/') + 1);
109
+
110
+ // A plural of the name is the name: `integration` is the `integrations`
111
+ // guides, `tab` the `tabs` doc.
112
+ // A component, hook, or template name typed as words is its name:
113
+ // `command palette` is CommandPalette. A doc's name is a route or key,
114
+ // matched as written.
115
+ const spelled =
116
+ domain !== 'doc' &&
117
+ !/[\s_-]/.test(nameLower) &&
118
+ nameLower === term.replace(/\s+/g, '');
119
+ if (nameLower === term || leafLower === term || spelled) {
120
+ consider(100, 'exact name');
121
+ } else if (
122
+ joins &&
123
+ (domain === 'component' || domain === 'hook') &&
124
+ /\s/.test(term) &&
125
+ nameLower.startsWith(term.replace(/\s+/g, '')) &&
126
+ term.replace(/\s+/g, '').length / nameLower.length >= 0.75
127
+ ) {
128
+ // Most of the name typed as words, cut short: `complex select` is
129
+ // ComplexSelector, ahead of a one-word keyword elsewhere.
130
+ consider(95, `name "${candidateName}" spelled`, 'partial');
131
+ } else if (pluralOf(nameLower, term) || pluralOf(term, nameLower)) {
132
+ // One point under the exact spelling, so the doc named `tokens` still
133
+ // outranks the Token component for `tokens`.
134
+ consider(99, 'plural of the name');
135
+ } else {
136
+ if (sameWord(term, nameLower)) consider(95, `name "${candidateName}"`);
137
+ // The term is a word of the name, or starts one: "input" in TextInput.
138
+ else if (startsAWordOf(term, candidateName)) {
139
+ consider(60, `name contains "${term}"`);
140
+ }
141
+ if (fuzzy) {
142
+ const dist = levenshteinDistance(term, nameLower);
143
+ if (isTypo(term, nameLower, dist)) {
144
+ consider(
145
+ dist === 1 ? 80 : dist === 2 ? 40 : 20,
146
+ `similar name (distance ${dist})`,
147
+ );
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // ── Keyword signals ─────────────────────────────────────────────
154
+ const termJoined = joined(term);
155
+ for (const kw of keywords) {
156
+ const kwLower = String(kw).toLowerCase();
157
+ // A one-word keyword typed as separate words is the keyword: `number
158
+ // field` is `numberfield`. Only a
159
+ // lowercase keyword, one an author declares for a reader to type: a
160
+ // capitalized one names another entry (a block's `componentsUsed`). A
161
+ // doc's keywords include code terms, matched as written.
162
+ if (kwLower === term) {
163
+ consider(90, `keyword "${kw}"`);
164
+ continue;
165
+ }
166
+ if (
167
+ joins &&
168
+ domain !== 'doc' &&
169
+ /\s/.test(term) &&
170
+ String(kw) === termJoined
171
+ ) {
172
+ consider(90, `keyword "${kw}"`, 'keyword');
173
+ continue;
174
+ }
175
+ if (sameWord(term, kwLower)) {
176
+ consider(88, `keyword "${kw}"`);
177
+ continue;
178
+ }
179
+ if (startsAWordOf(term, kw)) consider(70, `keyword "${kw}"`);
180
+ if (fuzzy) {
181
+ const dist = levenshteinDistance(term, kwLower);
182
+ if (isTypo(term, kwLower, dist) && dist <= 2) {
183
+ consider(dist === 1 ? 70 : 30, `keyword "${kw}" (distance ${dist})`);
184
+ }
185
+ }
186
+ }
187
+
188
+ // ── Weak keyword signals (derived, not authored) ─────────────────
189
+ // Capped at 60 so one incidental component match cannot clear the
190
+ // confident-match gate on its own; it still counts toward multi-term
191
+ // coverage, which is where "this page renders that" is real evidence.
192
+ // No Levenshtein tier — fuzzy matching a derived signal is pure noise.
193
+ for (const kw of weakKeywords) {
194
+ const kwLower = String(kw).toLowerCase();
195
+ if (kwLower === term || sameWord(term, kwLower)) {
196
+ consider(60, `renders ${kw}`);
197
+ continue;
198
+ }
199
+ if (startsAWordOf(term, kw)) consider(40, `renders ${kw}`);
200
+ }
201
+
202
+ // ── Prose / description / guidance signals (stem-tolerant whole word) ──
203
+ // Match the term's stem as a whole word, tolerating plural/gerund suffixes
204
+ // so "chart" matches "charts" and "filter" matches "filtering".
205
+ if (term.length >= 3) {
206
+ // Any form of the term, with any common suffix: `spacing` finds
207
+ // "space" and "spaces", `tables` finds "table".
208
+ const roots = [...new Set([stem(term), ...wordForms(term)])]
209
+ .filter(f => f.length >= 3)
210
+ .map(f => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
211
+ const re = new RegExp(`\\b(?:${roots.join('|')})(s|es|ing|ed|ies)?\\b`);
212
+ if (description && re.test(description.toLowerCase())) {
213
+ consider(50, `description mentions "${term}"`);
214
+ } else {
215
+ let matchedProse = false;
216
+ for (const blob of prose) {
217
+ if (blob && re.test(String(blob).toLowerCase())) {
218
+ consider(50, `docs mention "${term}"`);
219
+ matchedProse = true;
220
+ break;
221
+ }
222
+ }
223
+ // A tier below prose: guidance is what a component says about USING it,
224
+ // so the term appearing there is weaker evidence than the component's own
225
+ // summary. Only consulted when nothing stronger matched.
226
+ if (!matchedProse) {
227
+ for (const blob of guidance) {
228
+ if (blob && re.test(String(blob).toLowerCase())) {
229
+ consider(45, `guidance mentions "${term}"`);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ return best > 0 ? {score: best, reason, ...(alias ? {alias} : {})} : null;
238
+ }