@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.0

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.
package/src/paths.mjs ADDED
@@ -0,0 +1,122 @@
1
+ import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Repo-relative paths in corpus.json are POSIX, normalized, and confined to
6
+ * the repository: no absolute paths, no `..` segments, no backslashes.
7
+ * Returns an error string, or null when the path is acceptable.
8
+ */
9
+ export function repoPathError(value) {
10
+ if (typeof value !== 'string' || value.length === 0)
11
+ return 'must be a non-empty string';
12
+ if (value.includes('\0')) return 'must not contain NUL';
13
+ if (value.includes('\\')) return 'must use forward slashes';
14
+ if (path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value))
15
+ return 'must be relative to the repository root, not absolute';
16
+ if (value.split('/').includes('..'))
17
+ return 'must not contain ".." (paths stay inside the repository)';
18
+ if (path.posix.normalize(value) !== value || value.endsWith('/'))
19
+ return `must be a normalized path (did you mean "${path.posix.normalize(value).replace(/\/$/, '')}"?)`;
20
+ return null;
21
+ }
22
+
23
+ /** The same confinement rules for globs, which may contain wildcards. */
24
+ export function repoGlobError(value) {
25
+ if (typeof value !== 'string' || value.trim().length === 0)
26
+ return 'must be a non-empty string';
27
+ if (value.includes('\\')) return 'must use forward slashes';
28
+ if (value.startsWith('/') || /^[A-Za-z]:/.test(value))
29
+ return 'must be relative to the repository root, not absolute';
30
+ if (value.split('/').includes('..'))
31
+ return 'must not contain ".." (globs match paths inside the repository)';
32
+ return null;
33
+ }
34
+
35
+ /** Absolute filesystem path for a repo-relative POSIX path. */
36
+ export function toFsPath(root, repoPath) {
37
+ return path.join(root, ...repoPath.split('/').filter(Boolean));
38
+ }
39
+
40
+ /**
41
+ * Stat a repo-relative path only if it exists with exactly this spelling and
42
+ * resolves inside the root. Case-insensitive filesystems (macOS, Windows)
43
+ * would otherwise accept `Docs/A.md` locally and break on Linux CI and GitHub.
44
+ * Returns { kind: 'file' | 'dir' } or null.
45
+ */
46
+ export function statInRepo(root, repoPath) {
47
+ const segments = repoPath.split('/').filter(Boolean);
48
+ let current = root;
49
+ for (const segment of segments) {
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(current);
53
+ } catch {
54
+ return null;
55
+ }
56
+ if (!entries.includes(segment)) return null;
57
+ current = path.join(current, segment);
58
+ }
59
+ try {
60
+ const realRoot = realpathSync(root);
61
+ const real = realpathSync(current);
62
+ const relative = path.relative(realRoot, real);
63
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
64
+ const stat = statSync(current);
65
+ if (stat.isFile()) return { kind: 'file' };
66
+ if (stat.isDirectory()) return { kind: 'dir' };
67
+ return null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** True when the repo-relative path is a regular file inside the root. */
74
+ export function isRepoFile(root, repoPath) {
75
+ return statInRepo(root, repoPath)?.kind === 'file';
76
+ }
77
+
78
+ /** Every `*.md` under a repo-relative directory, as sorted repo-relative paths. */
79
+ export function listMarkdownFiles(root, directory) {
80
+ const found = [];
81
+ const start = toFsPath(root, directory);
82
+ try {
83
+ if (!lstatSync(start).isDirectory()) return found;
84
+ } catch {
85
+ return found;
86
+ }
87
+ const visit = (fsDir, repoDir) => {
88
+ for (const entry of readdirSync(fsDir, { withFileTypes: true })) {
89
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
90
+ const repoPath = `${repoDir}/${entry.name}`;
91
+ if (entry.isDirectory()) visit(path.join(fsDir, entry.name), repoPath);
92
+ else if (entry.isFile() && entry.name.endsWith('.md')) found.push(repoPath);
93
+ }
94
+ };
95
+ visit(start, directory);
96
+ return found.sort();
97
+ }
98
+
99
+ /** Every file under root (repo-relative), skipping .git and node_modules. Fallback outside git. */
100
+ export function listFiles(root) {
101
+ const found = [];
102
+ const visit = (fsDir, repoDir) => {
103
+ for (const entry of readdirSync(fsDir, { withFileTypes: true })) {
104
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
105
+ const repoPath = repoDir ? `${repoDir}/${entry.name}` : entry.name;
106
+ if (entry.isDirectory()) visit(path.join(fsDir, entry.name), repoPath);
107
+ else if (entry.isFile()) found.push(repoPath);
108
+ }
109
+ };
110
+ visit(root, '');
111
+ return found.sort();
112
+ }
113
+
114
+ /** Relative URL from one generated page to another generated file. */
115
+ export function relativeUrl(fromOutput, toOutput) {
116
+ const relative = path.posix.relative(path.posix.dirname(fromOutput), toOutput);
117
+ return encodePath(relative || path.posix.basename(toOutput));
118
+ }
119
+
120
+ export function encodePath(repoPath) {
121
+ return repoPath.split('/').map(encodeURIComponent).join('/');
122
+ }
package/src/search.mjs ADDED
@@ -0,0 +1,508 @@
1
+ import { lexWithSlugs, plainText } from './markdown.mjs';
2
+
3
+ // Ranked search over docs bundles.
4
+ //
5
+ // The approach (BM25 over a part's fields, a part per table row that names a
6
+ // thing, half-weight synonyms, and a light stemmer applied to both sides) is
7
+ // adapted from pmndrs/pine's `src/manual.ts` (https://github.com/pmndrs/pine,
8
+ // MIT License, Copyright (c) 2026 Paul Henschel). `stem` follows pine's
9
+ // stemmer closely, and some suite synonym groups start from pine's list; the
10
+ // rest is written for Corpus bundles. pine's license, for those portions:
11
+ //
12
+ // MIT License
13
+ //
14
+ // Copyright (c) 2026 Paul Henschel
15
+ //
16
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ // of this software and associated documentation files (the "Software"), to deal
18
+ // in the Software without restriction, including without limitation the rights
19
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
+ // copies of the Software, and to permit persons to whom the Software is
21
+ // furnished to do so, subject to the following conditions:
22
+ //
23
+ // The above copyright notice and this permission notice shall be included in all
24
+ // copies or substantial portions of the Software.
25
+ //
26
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ // SOFTWARE.
33
+ //
34
+ // A bundle is indexed the first time it is searched and the index is kept for
35
+ // as long as the bundle object lives. Row parts are derived from each
36
+ // document's Markdown at that point, so they need nothing from the bundle
37
+ // format: a bundle built by any Corpus version gets them.
38
+
39
+ /**
40
+ * How much a hit in each field counts (`weight`, BM25F style) and how strongly
41
+ * a long field is held against it (`b`). The weights are the ones term
42
+ * matching used before BM25: title 5, id/source/tag 3, summary 2, body 1.
43
+ * Headings and inline code are new fields and count like a summary.
44
+ */
45
+ export const FIELDS = [
46
+ { name: 'title', weight: 5, b: 0.3 },
47
+ { name: 'meta', weight: 3, b: 0.4 },
48
+ { name: 'summary', weight: 2, b: 0.5 },
49
+ { name: 'headings', weight: 2, b: 0.5 },
50
+ { name: 'code', weight: 2, b: 0.6 },
51
+ { name: 'body', weight: 1, b: 0.75 },
52
+ ];
53
+ const K1 = 1.2;
54
+ const SYNONYM_WEIGHT = 0.5;
55
+ const SNIPPET_WIDTH = 220;
56
+
57
+ /**
58
+ * Words that say the same thing across the Kontour suite. A query word in a
59
+ * group also finds the others at half weight. A product adds its own groups
60
+ * with `synonyms` in corpus.json, which `build` copies into the bundle.
61
+ */
62
+ export const SUITE_SYNONYMS = [
63
+ ['schedule', 'cron', 'timer', 'recurring', 'periodic', 'interval', 'daily', 'hourly', 'nightly'],
64
+ ['approval', 'approve', 'permission', 'consent', 'confirm', 'confirmation', 'allow', 'authorize', 'authorization', 'ask'],
65
+ ['bypass', 'skip', 'override', 'waive', 'waiver', 'exception'],
66
+ ['preview', 'plan', 'dryrun', 'simulate'],
67
+ ['gate', 'check', 'guard', 'guardrail', 'block', 'checkpoint'],
68
+ ['evidence', 'proof', 'receipt', 'attestation'],
69
+ ['error', 'failure', 'fail', 'broken', 'problem', 'diagnostic'],
70
+ ['troubleshoot', 'debug', 'diagnose', 'recover', 'recovery', 'fix'],
71
+ ['install', 'setup', 'download'],
72
+ ['configuration', 'config', 'configure', 'setting', 'option', 'preference'],
73
+ ['command', 'cli', 'terminal', 'shell'],
74
+ ['flag', 'option', 'argument', 'switch', 'parameter'],
75
+ ['plugin', 'extension', 'addon'],
76
+ ['mcp', 'tool', 'connector', 'integration'],
77
+ ['session', 'conversation', 'chat', 'thread'],
78
+ ['agent', 'assistant', 'bot', 'engine'],
79
+ ['model', 'llm', 'provider', 'inference'],
80
+ ['secret', 'credential', 'token', 'password', 'apikey'],
81
+ ['document', 'doc', 'page', 'documentation', 'manual'],
82
+ ['publish', 'deploy', 'release', 'ship'],
83
+ ['search', 'find', 'lookup', 'query', 'retrieve'],
84
+ ['find', 'discover', 'discovery', 'detect', 'locate', 'lookup'],
85
+ ['website', 'site', 'web', 'webpage'],
86
+ ['delete', 'remove', 'erase', 'purge', 'uninstall'],
87
+ ['retry', 'rerun', 'resume', 'again'],
88
+ ['phone', 'mobile', 'android', 'ios'],
89
+ ['keyboard', 'shortcut', 'hotkey', 'keybinding'],
90
+ ['link', 'url', 'href'],
91
+ ['stale', 'outdated', 'freshness', 'obsolete'],
92
+ ['notification', 'notify', 'alert'],
93
+ ['workflow', 'process', 'pipeline'],
94
+ ['decision', 'adr'],
95
+ ['repository', 'repo', 'checkout'],
96
+ ['folder', 'directory', 'dir'],
97
+ ['offline', 'local'],
98
+ ];
99
+
100
+ /** Words too common in questions to say anything about the answer. */
101
+ const STOP_WORDS = new Set(
102
+ (
103
+ 'a an the and or of to in on for with by is are was were be been being how do does did i my me it its that this these those from as at into ' +
104
+ 'via can could should would will what which when where who whom why you your we our us there here if then than so not no yes use using ' +
105
+ 'want need about have has had mean means tell please just one some any each also way something anything everything thing things happen happens see anyway still may might'
106
+ ).split(' '),
107
+ );
108
+
109
+ /** Short forms that are the same word, not a synonym: they stem to the long form's stem. */
110
+ const SHORT_FORMS = { doc: 'document', docs: 'document', repo: 'repository', repos: 'repository', dir: 'directory', dirs: 'directory', env: 'environment' };
111
+
112
+ /**
113
+ * A word without its ending, so subscribe, subscribes, subscribed and
114
+ * subscribing are one word. Deliberately rough (plurals, -ing, -ed, a doubled
115
+ * final consonant, a final e) and applied the same way to the bundle and the
116
+ * query, so a mistake on one side is made on the other too. Words with
117
+ * anything but a-z, and words of three letters or fewer, are left alone.
118
+ */
119
+ export function stem(word) {
120
+ if (Object.hasOwn(SHORT_FORMS, word)) return SHORT_FORMS[word];
121
+ if (word.length <= 3 || !/^[a-z]+$/.test(word)) return word;
122
+ let w = word;
123
+ if (w.length > 4 && w.endsWith('ies')) w = `${w.slice(0, -3)}y`;
124
+ else if (/(?:x|ch|sh|ss|zz|[^aeiou]o)es$/.test(w)) w = w.slice(0, -2);
125
+ else if (w.endsWith('s') && !/(?:ss|us|is)$/.test(w)) w = w.slice(0, -1);
126
+ if (w.length > 5 && w.endsWith('ing')) w = w.slice(0, -3);
127
+ else if (w.length > 4 && w.endsWith('ed')) w = w.slice(0, -2);
128
+ if (w.length > 3 && /([bdfgklmnprtz])\1$/.test(w)) w = w.slice(0, -1);
129
+ if (w.length > 3 && w.endsWith('e')) w = w.slice(0, -1);
130
+ return w;
131
+ }
132
+
133
+ /** A run of letters and digits, split at camelCase humps, lowercased. */
134
+ function pieces(run) {
135
+ return run
136
+ .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1 $2')
137
+ .replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
138
+ .toLowerCase()
139
+ .split(' ');
140
+ }
141
+
142
+ /** Lowercased words of a text: punctuation, snake_case, kebab-case and camelCase split. Not stemmed. */
143
+ export function splitWords(text) {
144
+ return (String(text ?? '').match(/[\p{L}\p{N}]+/gu) ?? []).flatMap(pieces);
145
+ }
146
+
147
+ /** Names written in one piece (`requireAnchors`, `base-url`, `list_topics`), joined and lowercased. */
148
+ function compounds(text) {
149
+ // Runs joined by single `_` or `-` match without backtracking (separators and
150
+ // word characters never overlap); a run is a compound when it has a separator
151
+ // or a lower-case letter or digit followed by a capital. One regex doing both
152
+ // backtracked quadratically on long one-piece words.
153
+ return (String(text ?? '').match(/[\p{L}\p{N}]+(?:[_-][\p{L}\p{N}]+)*/gu) ?? [])
154
+ .filter((run) => /[_-]/.test(run) || /[\p{Ll}\p{N}]\p{Lu}/u.test(run))
155
+ .map((name) => name.replace(/[_-]/g, '').toLowerCase());
156
+ }
157
+
158
+ /** Stemmed index terms of a text; `withCompounds` also adds names written in one piece. */
159
+ function terms(text, withCompounds = false) {
160
+ const list = splitWords(text);
161
+ if (withCompounds) list.push(...compounds(text));
162
+ return list.map(stem);
163
+ }
164
+
165
+ /** Every stem a synonym group joins to each of its words. */
166
+ function synonymMap(groups, into = new Map()) {
167
+ for (const group of groups) {
168
+ const stems = [...new Set(group.flatMap((word) => splitWords(word)).map(stem))];
169
+ for (const s of stems) {
170
+ const set = into.get(s) ?? new Set();
171
+ for (const other of stems) if (other !== s) set.add(other);
172
+ into.set(s, set);
173
+ }
174
+ }
175
+ return into;
176
+ }
177
+
178
+ const SUITE_MAP = synonymMap(SUITE_SYNONYMS);
179
+
180
+ /**
181
+ * Error for a `synonyms` value, or null. Shared by the manifest and the bundle
182
+ * reader: an array of groups, each an array of at least two distinct single
183
+ * words (letters and digits only, so a group never needs the tokenizer to
184
+ * agree on where a phrase splits). `label` names the value in the message.
185
+ */
186
+ export function synonymsError(value, label = 'synonyms') {
187
+ if (!Array.isArray(value)) return `${label} must be an array of synonym groups`;
188
+ for (const [index, group] of value.entries()) {
189
+ const at = `${label}[${index}]`;
190
+ if (!Array.isArray(group) || group.length < 2) return `${at} must be an array of at least two words`;
191
+ for (const word of group)
192
+ if (typeof word !== 'string' || !/^[\p{L}\p{N}]+$/u.test(word)) return `${at} has ${JSON.stringify(word)}; each entry must be one word of letters and digits`;
193
+ if (new Set(group.map((word) => word.toLowerCase())).size !== group.length) return `${at} lists a word more than once`;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /** Inline code spans of up to 64 characters: names of keys, commands and flags. */
199
+ function codeSpans(markdown) {
200
+ return (markdown.match(/`[^`\n]{1,64}`/g) ?? []).join(' ');
201
+ }
202
+
203
+ /** The first code span in some inline tokens, looking inside emphasis, links and other inline formatting. */
204
+ function firstCodespan(tokens = []) {
205
+ for (const token of tokens) {
206
+ if (token.type === 'codespan') return token;
207
+ const inner = firstCodespan(token.tokens);
208
+ if (inner) return inner;
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Cut Markdown into passages at its headings of levels 1 to 3 (deeper
215
+ * headings stay inside their passage), and collect the table rows that name
216
+ * a thing: a row whose first cell has a code span (a key, a tool, a command,
217
+ * a flag), even inside bold or a link, and at least one more cell. A row's
218
+ * title is its first cell as it reads and its name the first code span. Each
219
+ * carries the headings above it. Only headings and tables outside lists and
220
+ * quotes count, as for topics.
221
+ *
222
+ * `owned` holds slugs of headings whose sections a topic already covers, at
223
+ * any level: a section runs from its heading to the next heading of the same
224
+ * or a higher level, as the builder cuts topics (`extractSection`). Their
225
+ * text is left out of the passages (rows are still collected), so a topic's
226
+ * text never ranks again as its document, whatever level its heading is.
227
+ */
228
+ export function outline(markdown, owned = null) {
229
+ const tokens = lexWithSlugs(markdown);
230
+ const passages = [];
231
+ const rows = [];
232
+ const stack = []; // { depth, text, slug } of the headings above
233
+ let current = { headings: [], text: '' };
234
+ let ownedDepth = 0; // depth of the topic heading whose section this is, or 0
235
+ for (const token of tokens) {
236
+ if (token.type === 'heading') {
237
+ if (ownedDepth > 0 && token.depth <= ownedDepth) ownedDepth = 0;
238
+ if (ownedDepth === 0 && owned?.has(token.slugId)) ownedDepth = token.depth;
239
+ while (stack.length > 0 && stack.at(-1).depth >= token.depth) stack.pop();
240
+ stack.push({ depth: token.depth, text: plainText(token.tokens).trim(), slug: token.slugId });
241
+ if (token.depth <= 3) {
242
+ if (current.text.trim()) passages.push(current);
243
+ current = { headings: [...stack], text: '' };
244
+ }
245
+ }
246
+ if (ownedDepth === 0) current.text += token.raw;
247
+ if (token.type !== 'table') continue;
248
+ const header = token.header.map((cell) => plainText(cell.tokens).trim());
249
+ for (const row of token.rows) {
250
+ const name = firstCodespan(row[0]?.tokens);
251
+ if (!name || row.length < 2) continue;
252
+ const told = row
253
+ .slice(1)
254
+ .map((cell, index) => {
255
+ const text = plainText(cell.tokens).replace(/\s+/g, ' ').trim();
256
+ return text && header[index + 1] ? `${header[index + 1]}: ${text}` : text;
257
+ })
258
+ .filter(Boolean);
259
+ rows.push({
260
+ name: plainText([name]).trim(),
261
+ title: plainText(row[0].tokens).replace(/\s+/g, ' ').trim(),
262
+ headings: [...stack],
263
+ code: row.map((cell) => cell.text).join(' '),
264
+ text: told.join('\n'),
265
+ line: [row[0].text.trim(), ...told].join(' · '),
266
+ });
267
+ }
268
+ }
269
+ if (current.text.trim()) passages.push(current);
270
+ return { passages, rows };
271
+ }
272
+
273
+ function countTerms(list) {
274
+ const counts = new Map();
275
+ for (const term of list) counts.set(term, (counts.get(term) ?? 0) + 1);
276
+ return counts;
277
+ }
278
+
279
+ /** One passage's term counts per FIELDS entry: the unit's own fields, then the passage's headings, code and text. */
280
+ function passage(unitFields, headings, text, extra = {}) {
281
+ const lists = [...unitFields, terms(headings.map((heading) => heading.text).join('\n')), terms(codeSpans(text), true), terms(text)];
282
+ return { fields: lists.map((list) => ({ tf: countTerms(list), len: list.length })), ...extra };
283
+ }
284
+
285
+ const anchorUrl = (url, anchor) => `${url.replace(/#.*$/, '')}#${encodeURIComponent(anchor)}`;
286
+
287
+ /**
288
+ * The bundle's searchable units (topics, documents, table rows), each with
289
+ * the passages it is scored by. Document text that a topic of the same source
290
+ * covers (the section under the topic's heading, or the whole file) belongs
291
+ * to that topic only, so one passage never ranks twice. A document with no
292
+ * text at all still has one empty passage, so its title and source find it.
293
+ * Rows come from documents.
294
+ */
295
+ function buildIndex(bundle) {
296
+ const units = [];
297
+ const topicsBySource = new Map();
298
+ for (const topic of bundle.topics) {
299
+ const [path, ...rest] = topic.source.split('#');
300
+ const anchor = rest.length > 0 ? rest.join('#') : null;
301
+ if (!topicsBySource.has(path)) topicsBySource.set(path, []);
302
+ topicsBySource.get(path).push(anchor);
303
+ // The bundle's own fields only, so a stray `kind` or `meta` key in a bundle cannot add to what is searched.
304
+ const own = [terms(topic.title, true), terms([topic.id, topic.source, ...(topic.tags ?? [])].join(' '), true), terms(topic.summary ?? '')];
305
+ const { passages } = outline(topic.markdown);
306
+ units.push({
307
+ result: { kind: 'topic', topic },
308
+ kindRank: 0,
309
+ passages: (passages.length > 0 ? passages : [{ headings: [], text: topic.markdown }]).map((p) => passage(own, p.headings, p.text, { markdown: p.text })),
310
+ });
311
+ }
312
+ for (const document of bundle.documents) {
313
+ const covering = topicsBySource.get(document.source) ?? [];
314
+ const wholeFile = covering.includes(null);
315
+ const own = [terms(document.title, true), terms(document.source, true), []];
316
+ const { passages, rows } = outline(document.markdown, new Set(covering));
317
+ let free = wholeFile ? [] : passages;
318
+ if (!wholeFile && !document.markdown.trim()) free = [{ headings: [], text: '' }];
319
+ if (free.length > 0) units.push({ result: { kind: 'document', document }, kindRank: 2, passages: free.map((p) => passage(own, p.headings, p.text, { markdown: p.text })) });
320
+ for (const row of rows) {
321
+ const anchor = row.headings.at(-1)?.slug;
322
+ units.push({
323
+ result: { kind: 'row', document, row, url: anchor ? anchorUrl(document.url, anchor) : document.url },
324
+ kindRank: 1,
325
+ passages: [passage([terms(row.title, true), terms(document.source, true), []], row.headings, `${row.code}\n${row.text}`, { line: row.line })],
326
+ });
327
+ }
328
+ }
329
+ const df = new Map();
330
+ const lengths = FIELDS.map(() => 0);
331
+ let count = 0;
332
+ for (const unit of units)
333
+ for (const entry of unit.passages) {
334
+ count++;
335
+ const seen = new Set();
336
+ entry.fields.forEach((field, index) => {
337
+ lengths[index] += field.len;
338
+ for (const term of field.tf.keys()) seen.add(term);
339
+ });
340
+ for (const term of seen) df.set(term, (df.get(term) ?? 0) + 1);
341
+ }
342
+ const synonyms = Array.isArray(bundle.synonyms) ? synonymMap(bundle.synonyms, synonymMap(SUITE_SYNONYMS)) : SUITE_MAP;
343
+ return { units, count, df, lengths, synonyms };
344
+ }
345
+
346
+ const indexes = new WeakMap();
347
+
348
+ function indexFor(bundle) {
349
+ let index = indexes.get(bundle);
350
+ if (!index) {
351
+ index = buildIndex(bundle);
352
+ indexes.set(bundle, index);
353
+ }
354
+ return index;
355
+ }
356
+
357
+ /**
358
+ * What a query asks for. `words` are its stemmed words without stop words
359
+ * (all of them when nothing else is left); each is one share of the coverage.
360
+ * `bonus` are names written in one piece, from the query's own compounds and,
361
+ * for two to four words, the words run together ("base url" finds `baseUrl`);
362
+ * they add score but no coverage, since a part that has them has the words too.
363
+ */
364
+ function parseQuery(query) {
365
+ const raw = splitWords(query);
366
+ const kept = raw.filter((word) => !STOP_WORDS.has(word));
367
+ const words = [...new Set((kept.length > 0 ? kept : raw).map(stem))];
368
+ const bonus = new Set(compounds(query).map(stem));
369
+ if (kept.length >= 2 && kept.length <= 4) bonus.add(stem(kept.join('')));
370
+ for (const word of words) bonus.delete(word);
371
+ return { words, bonus: [...bonus] };
372
+ }
373
+
374
+ /** Plain-text snippet around the first word whose stem the query asked for, else one of its synonyms. */
375
+ function snippetFor(markdown, wanted, alternates) {
376
+ let at = -1;
377
+ let fallback = -1;
378
+ for (const match of markdown.matchAll(/[\p{L}\p{N}]+/gu)) {
379
+ const stems = pieces(match[0]).map(stem);
380
+ if (stems.some((s) => wanted.has(s))) {
381
+ at = match.index;
382
+ break;
383
+ }
384
+ if (fallback === -1 && stems.some((s) => alternates.has(s))) fallback = match.index;
385
+ }
386
+ if (at === -1) at = Math.max(fallback, 0);
387
+ const start = Math.max(0, at - 80);
388
+ const text = markdown.slice(start, start + SNIPPET_WIDTH).replace(/\s+/g, ' ').trim();
389
+ return `${start > 0 ? '…' : ''}${text}${start + SNIPPET_WIDTH < markdown.length ? '…' : ''}`;
390
+ }
391
+
392
+ function rowSnippet(line) {
393
+ return line.length > 300 ? `${line.slice(0, 299)}…` : line;
394
+ }
395
+
396
+ /** The optional hierarchy fields a topic carries, for list and search results. */
397
+ export function hierarchyFields(topic) {
398
+ return {
399
+ ...(topic.parentId !== undefined ? { parentId: topic.parentId } : {}),
400
+ ...(topic.tags !== undefined ? { tags: topic.tags } : {}),
401
+ ...(topic.summary !== undefined ? { summary: topic.summary } : {}),
402
+ };
403
+ }
404
+
405
+ /** Source identity on every result: the bundle sha, plus `dirty: true` for an uncommitted checkout. */
406
+ export function stamp(bundle) {
407
+ return bundle.dirty ? { sha: bundle.sha, dirty: true } : { sha: bundle.sha };
408
+ }
409
+
410
+ function resultFor(bundle, entry) {
411
+ const { kind } = entry.result;
412
+ if (kind === 'topic') {
413
+ const { topic } = entry.result;
414
+ return { product: bundle.product, kind, id: topic.id, ...hierarchyFields(topic), title: topic.title, source: topic.source, url: topic.url, ...stamp(bundle) };
415
+ }
416
+ const { document } = entry.result;
417
+ if (kind === 'document') return { product: bundle.product, kind, title: document.title, source: document.source, url: document.url, ...stamp(bundle) };
418
+ const { row } = entry.result;
419
+ return { product: bundle.product, kind, name: row.name, title: row.title, source: document.source, headings: row.headings.map((heading) => heading.text), url: entry.result.url, ...stamp(bundle) };
420
+ }
421
+
422
+ /**
423
+ * Ranked search over the topics, documents and table rows of some bundles.
424
+ *
425
+ * Each part scores BM25 over its fields (FIELDS), with the field weights as
426
+ * BM25F boosts and the document frequencies and average lengths taken over
427
+ * every part of the bundles searched, so scores compare across products. A
428
+ * query word also finds its synonyms (the suite's and the bundle's own) at
429
+ * half weight, and both sides are stemmed. The total is then scaled by the
430
+ * share of query words a part has (a word found only through a synonym counts
431
+ * half). Equal scores rank a topic before a table row before a document.
432
+ */
433
+ export function searchBundles(bundles, query, limit = 10) {
434
+ const { words, bonus } = parseQuery(query);
435
+ if (words.length === 0) return [];
436
+ const indexed = bundles.map((bundle) => ({ bundle, index: indexFor(bundle) }));
437
+ const total = indexed.reduce((sum, { index }) => sum + index.count, 0) || 1;
438
+ const average = FIELDS.map((_, field) => indexed.reduce((sum, { index }) => sum + index.lengths[field], 0) / total || 1);
439
+ const idfCache = new Map();
440
+ const idf = (term) => {
441
+ if (!idfCache.has(term)) {
442
+ const n = indexed.reduce((sum, { index }) => sum + (index.df.get(term) ?? 0), 0);
443
+ idfCache.set(term, Math.log(1 + (total - n + 0.5) / (n + 0.5)));
444
+ }
445
+ return idfCache.get(term);
446
+ };
447
+ const bm25 = (entry, term) => {
448
+ let combined = 0;
449
+ entry.fields.forEach((field, index) => {
450
+ const count = field.tf.get(term);
451
+ if (count) combined += (FIELDS[index].weight * count) / (1 - FIELDS[index].b + (FIELDS[index].b * field.len) / average[index]);
452
+ });
453
+ return combined === 0 ? 0 : (idf(term) * combined * (K1 + 1)) / (combined + K1);
454
+ };
455
+ const wanted = new Set(words);
456
+ const results = [];
457
+ for (const { bundle, index } of indexed) {
458
+ // [term, weight, which query word it stands for, or -1 for a bonus term]
459
+ const weighted = words.map((word, origin) => [word, 1, origin]);
460
+ const alternates = new Set();
461
+ words.forEach((word, origin) => {
462
+ for (const synonym of index.synonyms.get(word) ?? []) {
463
+ if (wanted.has(synonym)) continue;
464
+ weighted.push([synonym, SYNONYM_WEIGHT, origin]);
465
+ alternates.add(synonym);
466
+ }
467
+ });
468
+ for (const term of bonus) weighted.push([term, 1, -1]);
469
+ const passageScore = (entry) => {
470
+ let score = 0;
471
+ const direct = new Set();
472
+ const viaSynonym = new Set();
473
+ for (const [term, weight, origin] of weighted) {
474
+ const termScore = bm25(entry, term);
475
+ if (termScore === 0) continue;
476
+ score += weight * termScore;
477
+ if (origin === -1) continue;
478
+ (weight === 1 ? direct : viaSynonym).add(origin);
479
+ }
480
+ // Soft "and": a passage with half of the query's words keeps half of its score.
481
+ const covered = direct.size + [...viaSynonym].filter((origin) => !direct.has(origin)).length * SYNONYM_WEIGHT;
482
+ return score * (covered / words.length);
483
+ };
484
+ for (const unit of index.units) {
485
+ let best = null;
486
+ let bestScore = 0;
487
+ for (const entry of unit.passages) {
488
+ const score = passageScore(entry);
489
+ if (score > bestScore) {
490
+ best = entry;
491
+ bestScore = score;
492
+ }
493
+ }
494
+ if (best === null) continue;
495
+ results.push({
496
+ ...resultFor(bundle, unit),
497
+ score: Math.round(bestScore * 100) / 100,
498
+ snippet: best.line !== undefined ? rowSnippet(best.line) : snippetFor(best.markdown, wanted, alternates),
499
+ kindRank: unit.kindRank,
500
+ });
501
+ }
502
+ }
503
+ results.sort(
504
+ (a, b) =>
505
+ b.score - a.score || a.kindRank - b.kindRank || a.product.localeCompare(b.product) || a.source.localeCompare(b.source) || a.title.localeCompare(b.title),
506
+ );
507
+ return results.slice(0, limit).map(({ kindRank, ...result }) => result);
508
+ }