ctxman 3.1.1 → 3.2.7

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 (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. package/package.json +1 -1
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Hybrid Code Search: Reciprocal Rank Fusion (RRF) over BM25 lexical chunks and
3
+ * local vector embeddings (`askCodebase`).
4
+ *
5
+ * Combines exact identifier/keyword recall (`BM25Index` over methods and 40-line windows)
6
+ * with conceptual semantic similarity (`askCodebase` file embeddings):
7
+ *
8
+ * score_RRF(chunk) = w_bm25 / (k + rank_bm25) + w_semantic / (k + rank_semantic)
9
+ *
10
+ * Graceful degradation:
11
+ * - `mode: 'bm25'` -> pure lexical BM25 (instant, zero external packages)
12
+ * - `mode: 'hybrid'` -> RRF fusion of BM25 + semantic; if optional RAG dependencies
13
+ * (`@xenova/transformers` / `@lancedb/lancedb`) are unavailable,
14
+ * falls back to BM25 with `semanticAvailable: false` and a warning
15
+ * - `mode: 'semantic'` -> pure vector search projected onto code chunks (falls back to BM25
16
+ * only when `fallbackToBm25: true`, otherwise throws)
17
+ */
18
+
19
+ import path from 'path';
20
+ import {
21
+ getProjectIndex,
22
+ sliceLines,
23
+ fitToTokenBudget,
24
+ DEFAULT_LIMIT,
25
+ DEFAULT_MAX_TOKENS,
26
+ WINDOW_LINES,
27
+ } from './bm25.js';
28
+ import { askCodebase } from '../rag/ask.js';
29
+ import { PolicyConfigError } from '../policy/index.js';
30
+ import TokenUtils from '../utils/token-utils.js';
31
+ import { redactSecrets } from '../utils/secret-redactor.js';
32
+
33
+ export const SEARCH_MODES = ['bm25', 'semantic', 'hybrid'];
34
+ export const DEFAULT_RRF_K = 60;
35
+
36
+ const toPosix = (p) => String(p).split(path.sep).join('/');
37
+
38
+ function chunkKey(posixFile, startLine, endLine) {
39
+ return `${posixFile}:${startLine}-${endLine}`;
40
+ }
41
+
42
+ /**
43
+ * Pick the best representative chunk from a project file entry when a semantic match had
44
+ * zero lexical overlap in BM25: prefers the first named function/method chunk, else window 0.
45
+ */
46
+ function representativeChunk(entry, fallbackText) {
47
+ if (entry && Array.isArray(entry.chunks) && entry.chunks.length > 0) {
48
+ const named = entry.chunks.find((chunk) => Boolean(chunk.name));
49
+ return named ?? entry.chunks[0];
50
+ }
51
+ const lineCount = Math.max(1, String(fallbackText || '').split('\n').length);
52
+ return {
53
+ name: null,
54
+ kind: 'window',
55
+ startLine: 1,
56
+ endLine: Math.min(WINDOW_LINES, lineCount),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Run BM25, semantic, or Reciprocal Rank Fusion (RRF) hybrid search over a project.
62
+ *
63
+ * @param {string} root - Project root directory
64
+ * @param {string} query - Natural-language or identifier query
65
+ * @param {object} [options]
66
+ * @param {'hybrid'|'bm25'|'semantic'} [options.mode='hybrid']
67
+ * @param {number} [options.limit=10] - Maximum chunks returned
68
+ * @param {number} [options.maxTokens=4000] - Token budget across returned chunk texts
69
+ * @param {boolean} [options.redact=true] - Redact secrets in returned code
70
+ * @param {number} [options.windowLines=40] - Fallback window size for code outside methods
71
+ * @param {number} [options.rrfK=60] - RRF smoothing constant k
72
+ * @param {number} [options.bm25Weight=1] - Weight applied to BM25 RRF component
73
+ * @param {number} [options.semanticWeight=1] - Weight applied to semantic RRF component
74
+ * @param {boolean} [options.fallbackToBm25=false] - Fall back to BM25 even in `'semantic'` mode
75
+ * @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
76
+ * @param {(storePath: string) => object} [options.createStore] - Injectable vector store factory
77
+ * @returns {Promise<{
78
+ * query: string,
79
+ * mode: 'bm25'|'semantic'|'hybrid',
80
+ * effectiveMode: 'bm25'|'semantic'|'hybrid',
81
+ * semanticAvailable: boolean,
82
+ * indexed: boolean,
83
+ * root: string,
84
+ * files: number,
85
+ * chunks: number,
86
+ * tokens: number,
87
+ * warnings: string[],
88
+ * results: Array<{
89
+ * file: string,
90
+ * name: string|null,
91
+ * kind: string,
92
+ * parent?: string,
93
+ * startLine: number,
94
+ * endLine: number,
95
+ * score: number,
96
+ * bm25Rank: number|null,
97
+ * semanticRank: number|null,
98
+ * bm25Score: number|null,
99
+ * semanticScore: number|null,
100
+ * tokens: number,
101
+ * text: string|null,
102
+ * truncated?: boolean
103
+ * }>
104
+ * }>}
105
+ */
106
+ export async function hybridSearch(root, query, options = {}) {
107
+ if (typeof query !== 'string' || !query.trim()) {
108
+ throw new Error('The search query is empty');
109
+ }
110
+ const mode = options.mode ?? 'hybrid';
111
+ if (!SEARCH_MODES.includes(mode)) {
112
+ throw new Error(`Invalid search mode "${mode}" (expected one of: ${SEARCH_MODES.join(', ')})`);
113
+ }
114
+
115
+ const limit =
116
+ Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_LIMIT;
117
+ const maxTokens =
118
+ Number.isInteger(options.maxTokens) && options.maxTokens > 0
119
+ ? options.maxTokens
120
+ : DEFAULT_MAX_TOKENS;
121
+ const redact = options.redact !== false;
122
+ const windowLines = options.windowLines ?? WINDOW_LINES;
123
+ const rrfK = Number.isFinite(options.rrfK) && options.rrfK > 0 ? options.rrfK : DEFAULT_RRF_K;
124
+ const bm25Weight =
125
+ Number.isFinite(options.bm25Weight) && options.bm25Weight >= 0 ? options.bm25Weight : 1;
126
+ const semanticWeight =
127
+ Number.isFinite(options.semanticWeight) && options.semanticWeight >= 0
128
+ ? options.semanticWeight
129
+ : 1;
130
+
131
+ const project = getProjectIndex(root, { windowLines });
132
+ const filesByPosix = new Map();
133
+ for (const entry of project.files.values()) {
134
+ filesByPosix.set(toPosix(entry.relativePath), entry);
135
+ }
136
+
137
+ const candidatePoolSize = Math.max(limit * 3, 20);
138
+ const warnings = [];
139
+
140
+ // 1. Collect BM25 candidates when mode includes lexical ranking (or as fallback pool)
141
+ const bm25Hits =
142
+ mode === 'bm25' || mode === 'hybrid'
143
+ ? project.index.search(query, { limit: candidatePoolSize })
144
+ : [];
145
+
146
+ // 2. Collect semantic candidates when mode is 'hybrid' or 'semantic'
147
+ let semanticResult = null;
148
+ let semanticAvailable = false;
149
+ if (mode === 'hybrid' || mode === 'semantic') {
150
+ try {
151
+ semanticResult = await askCodebase(project.root, query, {
152
+ limit: candidatePoolSize,
153
+ redact,
154
+ onProgress: options.onProgress,
155
+ createStore: options.createStore,
156
+ });
157
+ semanticAvailable = true;
158
+ } catch (error) {
159
+ if (error instanceof PolicyConfigError) throw error;
160
+ if (mode === 'semantic' && !options.fallbackToBm25) {
161
+ throw error;
162
+ }
163
+ warnings.push(
164
+ `Semantic search unavailable (${error.message}); fell back to BM25 lexical ranking.`
165
+ );
166
+ }
167
+ }
168
+
169
+ const effectiveMode = mode === 'bm25' ? 'bm25' : semanticAvailable ? mode : 'bm25';
170
+
171
+ // If semantic failed and we fell back from 'semantic' -> 'bm25', compute BM25 hits now
172
+ const activeBm25Hits =
173
+ effectiveMode === 'bm25' && bm25Hits.length === 0
174
+ ? project.index.search(query, { limit: candidatePoolSize })
175
+ : bm25Hits;
176
+
177
+ // 3. Fuse candidates in a unified map keyed by `posixFile:startLine-endLine`
178
+ const candidates = new Map();
179
+ const bestBm25KeyPerFile = new Map();
180
+
181
+ activeBm25Hits.forEach(({ doc: { entry, chunk }, score }, idx) => {
182
+ const posixFile = toPosix(entry.relativePath);
183
+ const key = chunkKey(posixFile, chunk.startLine, chunk.endLine);
184
+ const rank = idx + 1;
185
+ if (!bestBm25KeyPerFile.has(posixFile)) {
186
+ bestBm25KeyPerFile.set(posixFile, key);
187
+ }
188
+ candidates.set(key, {
189
+ file: entry.relativePath,
190
+ posixFile,
191
+ entry,
192
+ chunk,
193
+ fallbackText: null,
194
+ bm25Rank: rank,
195
+ bm25Score: score,
196
+ semanticRank: null,
197
+ semanticScore: null,
198
+ });
199
+ });
200
+
201
+ if (semanticAvailable && semanticResult) {
202
+ semanticResult.results.forEach((semHit, idx) => {
203
+ const posixFile = toPosix(semHit.file);
204
+ const rank = idx + 1;
205
+ const existingKey = bestBm25KeyPerFile.get(posixFile);
206
+
207
+ if (existingKey && candidates.has(existingKey)) {
208
+ const item = candidates.get(existingKey);
209
+ if (item.semanticRank === null) {
210
+ item.semanticRank = rank;
211
+ item.semanticScore = semHit.score;
212
+ }
213
+ return;
214
+ }
215
+
216
+ const entry = filesByPosix.get(posixFile) ?? null;
217
+ const chunk = representativeChunk(entry, semHit.text);
218
+ const key = chunkKey(posixFile, chunk.startLine, chunk.endLine);
219
+ if (candidates.has(key)) {
220
+ const item = candidates.get(key);
221
+ if (item.semanticRank === null) {
222
+ item.semanticRank = rank;
223
+ item.semanticScore = semHit.score;
224
+ }
225
+ return;
226
+ }
227
+
228
+ candidates.set(key, {
229
+ file: entry ? entry.relativePath : semHit.file,
230
+ posixFile,
231
+ entry,
232
+ chunk,
233
+ fallbackText: semHit.text,
234
+ bm25Rank: null,
235
+ bm25Score: null,
236
+ semanticRank: rank,
237
+ semanticScore: semHit.score,
238
+ });
239
+ });
240
+ }
241
+
242
+ // 4. Score and sort candidates
243
+ const scored = [];
244
+ for (const item of candidates.values()) {
245
+ let finalScore;
246
+ if (effectiveMode === 'bm25') {
247
+ finalScore = item.bm25Score ?? 0;
248
+ } else if (effectiveMode === 'semantic') {
249
+ finalScore =
250
+ item.semanticScore !== null && item.semanticScore !== undefined
251
+ ? Number(item.semanticScore)
252
+ : 1 / (rrfK + (item.semanticRank ?? candidatePoolSize));
253
+ } else {
254
+ const bm25Rrf = item.bm25Rank !== null ? bm25Weight / (rrfK + item.bm25Rank) : 0;
255
+ const semRrf = item.semanticRank !== null ? semanticWeight / (rrfK + item.semanticRank) : 0;
256
+ finalScore = bm25Rrf + semRrf;
257
+ }
258
+ scored.push({ ...item, finalScore });
259
+ }
260
+
261
+ scored.sort((a, b) => {
262
+ if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
263
+ // Tie-break: matched in both signals first, then better BM25 rank, then lexical path
264
+ const aBoth = a.bm25Rank !== null && a.semanticRank !== null ? 1 : 0;
265
+ const bBoth = b.bm25Rank !== null && b.semanticRank !== null ? 1 : 0;
266
+ if (bBoth !== aBoth) return bBoth - aBoth;
267
+ if ((a.bm25Rank ?? Infinity) !== (b.bm25Rank ?? Infinity)) {
268
+ return (a.bm25Rank ?? Infinity) - (b.bm25Rank ?? Infinity);
269
+ }
270
+ return a.posixFile < b.posixFile ? -1 : a.posixFile > b.posixFile ? 1 : 0;
271
+ });
272
+
273
+ // 5. Materialize source snippets within maxTokens budget
274
+ let used = 0;
275
+ const results = scored.slice(0, limit).map((item) => {
276
+ const filePathForTokens = item.entry ? item.entry.path : path.join(project.root, item.file);
277
+ const raw = item.entry
278
+ ? sliceLines(item.entry.content, item.chunk.startLine, item.chunk.endLine)
279
+ : sliceLines(item.fallbackText ?? '', item.chunk.startLine, item.chunk.endLine);
280
+ let text = redact ? redactSecrets(raw).text : raw;
281
+ let tokens = TokenUtils.calculate(text, filePathForTokens);
282
+
283
+ const out = {
284
+ file: item.file,
285
+ name: item.chunk.name,
286
+ kind: item.chunk.kind,
287
+ ...(item.chunk.parent ? { parent: item.chunk.parent } : {}),
288
+ startLine: item.chunk.startLine,
289
+ endLine: item.chunk.endLine,
290
+ score: Math.round(item.finalScore * 10000) / 10000,
291
+ bm25Rank: item.bm25Rank,
292
+ semanticRank: item.semanticRank,
293
+ bm25Score: item.bm25Score !== null ? Math.round(item.bm25Score * 10000) / 10000 : null,
294
+ semanticScore:
295
+ item.semanticScore !== null && item.semanticScore !== undefined
296
+ ? Math.round(Number(item.semanticScore) * 10000) / 10000
297
+ : null,
298
+ };
299
+
300
+ if (used + tokens > maxTokens) {
301
+ text = fitToTokenBudget(text, maxTokens - used, filePathForTokens);
302
+ tokens = text ? TokenUtils.calculate(text, filePathForTokens) : 0;
303
+ out.truncated = true;
304
+ }
305
+ used += tokens;
306
+ return { ...out, tokens, text: text || null };
307
+ });
308
+
309
+ return {
310
+ query,
311
+ mode,
312
+ effectiveMode,
313
+ semanticAvailable,
314
+ indexed: Boolean(semanticResult?.indexed),
315
+ root: project.root,
316
+ files: project.files.size,
317
+ chunks: project.index.size,
318
+ tokens: used,
319
+ warnings,
320
+ results,
321
+ };
322
+ }
323
+
324
+ export default hybridSearch;
@@ -6,14 +6,31 @@
6
6
  * lookup to methods of that class.
7
7
  */
8
8
 
9
+ import fs from 'fs';
9
10
  import path from 'path';
10
11
  import TokenUtils from '../utils/token-utils.js';
11
12
  import { redactSecrets } from '../utils/secret-redactor.js';
12
13
  import { compareStrings, fitToTokenBudget, getProjectIndex, sliceLines } from './bm25.js';
14
+ import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
13
15
 
14
16
  export const DEFAULT_SYMBOL_LIMIT = 10;
15
17
  export const DEFAULT_SYMBOL_MAX_TOKENS = 20000;
16
18
  const MAX_SUGGESTIONS = 10;
19
+ const toPosix = (p) => String(p).split(path.sep).join('/');
20
+
21
+ function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
22
+ const realPrimary = fs.existsSync(primaryRoot)
23
+ ? fs.realpathSync(primaryRoot)
24
+ : path.resolve(primaryRoot);
25
+ const isContainerOfExtra = extraRepos.some((item) => {
26
+ const raw = typeof item === 'string' ? item : item?.path;
27
+ if (typeof raw !== 'string' || !raw.trim()) return false;
28
+ const resolved = path.resolve(raw);
29
+ const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
30
+ return real !== realPrimary && real.startsWith(realPrimary + path.sep);
31
+ });
32
+ return isContainerOfExtra ? [...extraRepos] : [primaryRoot, ...extraRepos];
33
+ }
17
34
 
18
35
  /**
19
36
  * Name without an accessor prefix: `get value` -> `value`
@@ -81,6 +98,7 @@ function suggest(candidates, name) {
81
98
  name: unit.name,
82
99
  kind: unit.kind,
83
100
  ...(unit.parent ? { parent: unit.parent } : {}),
101
+ ...(entry.repo ? { repo: entry.repo } : {}),
84
102
  file: entry.relativePath,
85
103
  startLine: unit.startLine,
86
104
  distance,
@@ -123,35 +141,77 @@ export function lookupSymbols(candidates, query) {
123
141
  }
124
142
 
125
143
  /**
126
- * Source of the methods, functions or classes with a given name in a project.
144
+ * Source of the methods, functions or classes with a given name in a project (or multi-repo workspace).
127
145
  *
128
146
  * @param {string} root - Project directory (files found with the Scanner, so the ignore
129
147
  * rules apply)
130
148
  * @param {string} query - Symbol name, or `Class.method`
131
149
  * @param {Object} [options]
132
- * @param {string} [options.file] - Only this project-relative file
150
+ * @param {string} [options.file] - Only this project-relative (or `<alias>/<path>`) file
133
151
  * @param {number} [options.limit=10] - Maximum number of symbols returned with source
134
152
  * @param {number} [options.maxTokens=20000] - Token budget for the sources; sources past it
135
153
  * are cut (`truncated: true`)
136
154
  * @param {boolean} [options.redact=true] - Redact secrets in the sources
137
- * @returns {{query: string, match: string, total: number, symbols: Array,
138
- * suggestions?: Array}}
155
+ * @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional repository roots for multi-repo symbol lookup
156
+ * @returns {{query: string, match: string, total: number, repos?: Array<{alias: string, root: string}>,
157
+ * symbols: Array, suggestions?: Array}}
139
158
  * @throws {Error} When the query is empty or `file` is not a project file
140
159
  */
141
160
  export function getSymbol(
142
161
  root,
143
162
  query,
144
- { file, limit = DEFAULT_SYMBOL_LIMIT, maxTokens = DEFAULT_SYMBOL_MAX_TOKENS, redact = true } = {}
163
+ {
164
+ file,
165
+ limit = DEFAULT_SYMBOL_LIMIT,
166
+ maxTokens = DEFAULT_SYMBOL_MAX_TOKENS,
167
+ redact = true,
168
+ repos,
169
+ } = {}
145
170
  ) {
146
171
  const wanted = typeof query === 'string' ? query.trim() : '';
147
172
  if (!wanted) throw new Error('The symbol name is empty');
148
173
 
149
- const project = getProjectIndex(root);
150
- let entries = [...project.files.values()];
151
- if (file) {
152
- const target = path.normalize(file);
153
- entries = entries.filter((entry) => path.normalize(entry.relativePath) === target);
154
- if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
174
+ const isWorkspace = Array.isArray(repos) && repos.length > 0;
175
+ let entries;
176
+ let resolvedRepos = null;
177
+
178
+ if (isWorkspace) {
179
+ resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(root, repos));
180
+ enforceWorkspacePolicies(resolvedRepos, {
181
+ filesByRepo: new Map(),
182
+ tokens: 0,
183
+ redact,
184
+ });
185
+
186
+ entries = [];
187
+ for (const repo of resolvedRepos) {
188
+ const project = getProjectIndex(repo.root);
189
+ for (const entry of project.files.values()) {
190
+ const rawRel = toPosix(entry.relativePath);
191
+ entries.push({
192
+ ...entry,
193
+ repo: repo.alias,
194
+ relativePath: `${repo.alias}/${rawRel}`,
195
+ rawRelativePath: rawRel,
196
+ });
197
+ }
198
+ }
199
+
200
+ if (file) {
201
+ const target = toPosix(path.normalize(file)).replace(/^\/+/, '');
202
+ entries = entries.filter(
203
+ (entry) => entry.relativePath === target || entry.rawRelativePath === target
204
+ );
205
+ if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
206
+ }
207
+ } else {
208
+ const project = getProjectIndex(root);
209
+ entries = [...project.files.values()];
210
+ if (file) {
211
+ const target = path.normalize(file);
212
+ entries = entries.filter((entry) => path.normalize(entry.relativePath) === target);
213
+ if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
214
+ }
155
215
  }
156
216
 
157
217
  const candidates = entries.flatMap((entry) => entry.units.map((unit) => ({ entry, unit })));
@@ -166,6 +226,7 @@ export function getSymbol(
166
226
  name: unit.name,
167
227
  kind: unit.kind,
168
228
  ...(unit.parent ? { parent: unit.parent } : {}),
229
+ ...(entry.repo ? { repo: entry.repo } : {}),
169
230
  file: entry.relativePath,
170
231
  startLine: unit.startLine,
171
232
  endLine: unit.endLine,
@@ -183,6 +244,9 @@ export function getSymbol(
183
244
  query: wanted,
184
245
  match,
185
246
  total: found.length,
247
+ ...(resolvedRepos
248
+ ? { repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })) }
249
+ : {}),
186
250
  symbols,
187
251
  ...(match === 'none' ? { suggestions } : {}),
188
252
  };