@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.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/LICENSE +201 -0
- package/README.md +284 -2
- package/package.json +47 -4
- package/src/binary/detect.ts +55 -0
- package/src/binary/manager.ts +348 -0
- package/src/binary/release.ts +64 -0
- package/src/config.ts +381 -0
- package/src/hooks/session.ts +165 -0
- package/src/index/brain.ts +225 -0
- package/src/index/corpus.ts +164 -0
- package/src/index/hidden.ts +318 -0
- package/src/index/watcher.ts +333 -0
- package/src/index.ts +723 -0
- package/src/log.ts +88 -0
- package/src/memory/capture.ts +72 -0
- package/src/memory/store.ts +116 -0
- package/src/node/client.ts +361 -0
- package/src/node/supervisor.ts +267 -0
- package/src/paths.ts +202 -0
- package/src/platform.ts +88 -0
- package/src/remote/shell.ts +338 -0
- package/src/remote/spool.ts +217 -0
- package/src/rpc.ts +250 -0
- package/src/search/brains.ts +306 -0
- package/src/search/rank.ts +61 -0
- package/src/search/service.ts +589 -0
- package/src/semver.ts +117 -0
- package/src/stats/counters.ts +55 -0
- package/src/stats/ledger.ts +344 -0
- package/src/telemetry/anomaly.ts +211 -0
- package/src/tools/search.ts +384 -0
- package/src/tui.tsx +362 -0
- package/src/ui/format.ts +234 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
// Query construction, result parsing, and passage formatting.
|
|
2
|
+
//
|
|
3
|
+
// Verified against v1.0.0-rc.100:
|
|
4
|
+
// - `fields: ["_passage"]` returns the matching chunk without the whole
|
|
5
|
+
// `_source`. It is refused for `hybrid` queries because fusion has no single
|
|
6
|
+
// winning passage, so the two legs are run separately and fused here.
|
|
7
|
+
// - `_passage` carries byte offsets, not line numbers. When the record starts at
|
|
8
|
+
// line 1 the offset maps onto the file, so the exact line is readable. A
|
|
9
|
+
// section record that starts elsewhere falls back to its start line.
|
|
10
|
+
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
import type { Logger } from "../log.ts";
|
|
14
|
+
import { XerjClient } from "../node/client.ts";
|
|
15
|
+
import type { BrainSpec } from "../paths.ts";
|
|
16
|
+
import type { BrainGraph } from "./brains.ts";
|
|
17
|
+
import { demoteGenerated } from "./rank.ts";
|
|
18
|
+
|
|
19
|
+
export interface SearchHit {
|
|
20
|
+
readonly index: string;
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly passage: number;
|
|
23
|
+
readonly path: string | null;
|
|
24
|
+
readonly line: number | null;
|
|
25
|
+
readonly title: string | null;
|
|
26
|
+
readonly kind: string | null;
|
|
27
|
+
readonly score: number;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SearchResult {
|
|
32
|
+
readonly hits: ReadonlyArray<SearchHit>;
|
|
33
|
+
readonly bytes: number;
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly strategy: "hybrid" | "lexical" | "semantic" | "definition";
|
|
36
|
+
readonly narrowingBytes: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SearchOptions {
|
|
40
|
+
readonly k?: number;
|
|
41
|
+
readonly maxTokens?: number;
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedHit extends SearchHit {
|
|
46
|
+
readonly startLine: number | null;
|
|
47
|
+
readonly startOffset: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAX_K = 50;
|
|
51
|
+
const MAPPING_TTL_MS = 60_000;
|
|
52
|
+
const RRF_K = 60;
|
|
53
|
+
const MAX_RESOLVED_FILE_BYTES = 512 * 1024;
|
|
54
|
+
const SOURCE_FIELDS = [
|
|
55
|
+
"ax_path",
|
|
56
|
+
"path",
|
|
57
|
+
"title",
|
|
58
|
+
"name",
|
|
59
|
+
"kind",
|
|
60
|
+
"language",
|
|
61
|
+
"start_line",
|
|
62
|
+
"end_line",
|
|
63
|
+
"line",
|
|
64
|
+
"section",
|
|
65
|
+
];
|
|
66
|
+
// Symbol records carry `code` but no `body`; file records carry the whole file
|
|
67
|
+
// in `body`. Only `body` produces a `_passage`, so the search leg stays on it
|
|
68
|
+
// and symbol precision comes from the definition tool.
|
|
69
|
+
const TEXT_FIELD_PRIORITY = ["body", "defs", "impl", "summary", "text", "content"];
|
|
70
|
+
const DEFINITION_SOURCE = [...SOURCE_FIELDS, "code"];
|
|
71
|
+
|
|
72
|
+
interface LegResult {
|
|
73
|
+
readonly hits: ReadonlyArray<ParsedHit>;
|
|
74
|
+
readonly narrowing: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SearchService {
|
|
78
|
+
private mappingCache: {
|
|
79
|
+
at: number;
|
|
80
|
+
indices: Record<string, Record<string, string>>;
|
|
81
|
+
} | null = null;
|
|
82
|
+
private readonly files = new Map<string, string | null>();
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly client: XerjClient,
|
|
86
|
+
private readonly prefixes: ReadonlyArray<string>,
|
|
87
|
+
private readonly logger: Logger,
|
|
88
|
+
private readonly projectRoot: string,
|
|
89
|
+
private readonly brains: BrainGraph | null = null,
|
|
90
|
+
private readonly brainSpecs: ReadonlyArray<BrainSpec> = [],
|
|
91
|
+
private readonly hiddenIndex: string | null = null,
|
|
92
|
+
) {}
|
|
93
|
+
|
|
94
|
+
async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
95
|
+
const trimmed = query.trim();
|
|
96
|
+
if (trimmed.length === 0) return emptyResult("lexical");
|
|
97
|
+
|
|
98
|
+
const k = clamp(options.k ?? 8, 1, MAX_K);
|
|
99
|
+
const budget = budgetChars(options.maxTokens);
|
|
100
|
+
const indices = await this.indices();
|
|
101
|
+
const names = Object.keys(indices);
|
|
102
|
+
const pattern =
|
|
103
|
+
names.length > 0
|
|
104
|
+
? names.join(",")
|
|
105
|
+
: this.prefixes.map((prefix) => `${prefix}-*`).join(",");
|
|
106
|
+
const fields = pickTextFields(indices);
|
|
107
|
+
const semanticCapable = names.some((name) => indices[name]?.body === "semantic_text");
|
|
108
|
+
|
|
109
|
+
const base = { size: k, _source: SOURCE_FIELDS, fields: ["_passage"] };
|
|
110
|
+
|
|
111
|
+
// A single-field `match` is what makes the engine attach `_passage`;
|
|
112
|
+
// `multi_match` returns hits without it, which silently drops the text.
|
|
113
|
+
const lexicalField = fields.includes("body") ? "body" : (fields[0] ?? "body");
|
|
114
|
+
const lexical = await this.leg(
|
|
115
|
+
pattern,
|
|
116
|
+
{ ...base, query: { match: { [lexicalField]: trimmed } } },
|
|
117
|
+
options.signal,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
let semantic: LegResult | null = null;
|
|
121
|
+
if (semanticCapable) {
|
|
122
|
+
try {
|
|
123
|
+
semantic = await this.leg(
|
|
124
|
+
pattern,
|
|
125
|
+
{ ...base, query: { semantic: { field: "body", query: trimmed, k } } },
|
|
126
|
+
options.signal,
|
|
127
|
+
);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.logger.debug("semantic leg failed; keeping lexical only", String(error));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const strategy = semantic ? "hybrid" : "lexical";
|
|
134
|
+
const fused = semantic ? fuse([lexical.hits, semantic.hits], k) : lexical.hits.slice(0, k);
|
|
135
|
+
const located = demoteGenerated(this.locateBrainPaths(fused), trimmed);
|
|
136
|
+
const resolved = await this.resolveLines(located, trimmed);
|
|
137
|
+
const hits = dropShadowedWholeFiles(
|
|
138
|
+
resolved,
|
|
139
|
+
this.hiddenIndex,
|
|
140
|
+
this.brainSpecs,
|
|
141
|
+
);
|
|
142
|
+
const related = this.brains ? await this.brains.related(hits, options.signal) : [];
|
|
143
|
+
const narrowing = lexical.narrowing + (semantic?.narrowing ?? 0);
|
|
144
|
+
|
|
145
|
+
if (related.length === 0) {
|
|
146
|
+
return formatResult(hits, strategy, budget, narrowing);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Related passages get their own slice: the direct hits already fill the
|
|
150
|
+
// budget on their own, so appending without reserving would hide them.
|
|
151
|
+
const reserve = Math.min(800, Math.floor(budget / 4));
|
|
152
|
+
const direct = formatResult(hits, strategy, budget - reserve, narrowing);
|
|
153
|
+
const extra = formatResult(related, strategy, reserve, 0, hits.length);
|
|
154
|
+
const text = [direct.text, extra.text].filter((part) => part.length > 0).join("\n\n");
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...direct,
|
|
158
|
+
hits: dedupeHits([...hits, ...related]),
|
|
159
|
+
text,
|
|
160
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Brain documents are relative to the brain folder; cite from the project. */
|
|
165
|
+
private locateBrainPaths(hits: ReadonlyArray<ParsedHit>): ParsedHit[] {
|
|
166
|
+
return hits.map((hit) => {
|
|
167
|
+
if (hit.path === null) return hit;
|
|
168
|
+
const spec = this.brainSpecs.find((candidate) =>
|
|
169
|
+
hit.index.startsWith(`${candidate.prefix}-`),
|
|
170
|
+
);
|
|
171
|
+
if (!spec) return hit;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
...hit,
|
|
175
|
+
path: projectDocumentPath(this.projectRoot, spec.directory, hit.path),
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Drops the mapping cache; call when a corpus run may have created indices. */
|
|
181
|
+
invalidate(): void {
|
|
182
|
+
this.mappingCache = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async definition(symbol: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
186
|
+
const trimmed = symbol.trim();
|
|
187
|
+
if (trimmed.length === 0) return emptyResult("definition");
|
|
188
|
+
|
|
189
|
+
const k = clamp(options.k ?? 5, 1, MAX_K);
|
|
190
|
+
const budget = budgetChars(options.maxTokens);
|
|
191
|
+
const indices = await this.indices();
|
|
192
|
+
const codeIndices = Object.entries(indices)
|
|
193
|
+
.filter(([, fields]) => "code" in fields || "symbols.name" in fields)
|
|
194
|
+
.map(([name]) => name);
|
|
195
|
+
|
|
196
|
+
if (codeIndices.length === 0) return emptyResult("definition");
|
|
197
|
+
|
|
198
|
+
const result = await this.leg(
|
|
199
|
+
codeIndices.join(","),
|
|
200
|
+
{
|
|
201
|
+
size: k,
|
|
202
|
+
_source: DEFINITION_SOURCE,
|
|
203
|
+
fields: ["_passage"],
|
|
204
|
+
query: {
|
|
205
|
+
bool: {
|
|
206
|
+
should: [
|
|
207
|
+
{ term: { name: trimmed } },
|
|
208
|
+
{ match: { "symbols.name": trimmed } },
|
|
209
|
+
{ match: { code: trimmed } },
|
|
210
|
+
],
|
|
211
|
+
minimum_should_match: 1,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
options.signal,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const hits = await this.resolveLines(result.hits.slice(0, k), trimmed);
|
|
219
|
+
return formatResult(hits, "definition", budget, result.narrowing);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async leg(
|
|
223
|
+
pattern: string,
|
|
224
|
+
body: unknown,
|
|
225
|
+
signal: AbortSignal | undefined,
|
|
226
|
+
): Promise<LegResult> {
|
|
227
|
+
const response = await this.client.search(pattern, body, signal);
|
|
228
|
+
return {
|
|
229
|
+
hits: parseHits(response),
|
|
230
|
+
narrowing: narrowingBytes(response),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Exact line for a record that starts at line 1: the passage offset indexes
|
|
236
|
+
* the file, so count newlines before it. Section records keep their start
|
|
237
|
+
* line instead of inventing one.
|
|
238
|
+
*/
|
|
239
|
+
private async resolveLines(
|
|
240
|
+
hits: ReadonlyArray<ParsedHit>,
|
|
241
|
+
query: string,
|
|
242
|
+
): Promise<SearchHit[]> {
|
|
243
|
+
return Promise.all(hits.map((hit) => this.resolveLine(hit, query)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async resolveLine(hit: ParsedHit, query: string): Promise<SearchHit> {
|
|
247
|
+
const { startLine, startOffset, text, path } = hit;
|
|
248
|
+
const base: SearchHit = {
|
|
249
|
+
index: hit.index,
|
|
250
|
+
id: hit.id,
|
|
251
|
+
passage: hit.passage,
|
|
252
|
+
path: hit.path,
|
|
253
|
+
line: startLine,
|
|
254
|
+
title: hit.title,
|
|
255
|
+
kind: hit.kind,
|
|
256
|
+
score: hit.score,
|
|
257
|
+
text: hit.text,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (startLine === null) return base;
|
|
261
|
+
|
|
262
|
+
const inPassage = newlinesBeforeMatch(text, query);
|
|
263
|
+
if (inPassage === null) return base;
|
|
264
|
+
|
|
265
|
+
if (startLine === 1 && startOffset > 0 && path) {
|
|
266
|
+
const prefixNewlines = await this.newlinesBeforeOffset(path, startOffset);
|
|
267
|
+
if (prefixNewlines !== null) {
|
|
268
|
+
return { ...base, line: 1 + prefixNewlines + inPassage };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (startOffset === 0) {
|
|
273
|
+
return { ...base, line: startLine + inPassage };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return base;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async newlinesBeforeOffset(path: string, offset: number): Promise<number | null> {
|
|
280
|
+
const content = await this.readProjectFile(path);
|
|
281
|
+
if (content === null) return null;
|
|
282
|
+
|
|
283
|
+
const bytes = Buffer.from(content, "utf8");
|
|
284
|
+
const limit = Math.min(offset, bytes.length);
|
|
285
|
+
let newlines = 0;
|
|
286
|
+
for (let index = 0; index < limit; index++) {
|
|
287
|
+
if (bytes[index] === 10) newlines += 1;
|
|
288
|
+
}
|
|
289
|
+
return newlines;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async readProjectFile(path: string): Promise<string | null> {
|
|
293
|
+
if (this.files.has(path)) return this.files.get(path) ?? null;
|
|
294
|
+
|
|
295
|
+
const absolute = this.absolute(path);
|
|
296
|
+
if (absolute === null) {
|
|
297
|
+
this.files.set(path, null);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const content = await readFile(absolute, "utf8").catch(() => null);
|
|
302
|
+
if (content === null || Buffer.byteLength(content, "utf8") > MAX_RESOLVED_FILE_BYTES) {
|
|
303
|
+
this.files.set(path, null);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
this.files.set(path, content);
|
|
308
|
+
return content;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private absolute(path: string): string | null {
|
|
312
|
+
const root = resolve(this.projectRoot);
|
|
313
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(join(root, path));
|
|
314
|
+
if (absolute !== root && !absolute.startsWith(root + sep)) return null;
|
|
315
|
+
return absolute;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Mapping cache keyed by the time it was read. */
|
|
319
|
+
private async indices(): Promise<Record<string, Record<string, string>>> {
|
|
320
|
+
if (this.mappingCache && Date.now() - this.mappingCache.at < MAPPING_TTL_MS) {
|
|
321
|
+
return this.mappingCache.indices;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const merged: Record<string, Record<string, string>> = {};
|
|
325
|
+
for (const prefix of this.prefixes) {
|
|
326
|
+
try {
|
|
327
|
+
Object.assign(merged, await this.client.mappings(`${prefix}-*`));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
// A prefix with no indices answers 404; that is not a failure for the
|
|
330
|
+
// other corpora, so keep going.
|
|
331
|
+
this.logger.debug(`no mappings for ${prefix}`, String(error));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (Object.keys(merged).length > 0) {
|
|
336
|
+
this.mappingCache = { at: Date.now(), indices: merged };
|
|
337
|
+
return merged;
|
|
338
|
+
}
|
|
339
|
+
return this.mappingCache?.indices ?? {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Reciprocal rank fusion over the legs, deduped by document and passage. */
|
|
344
|
+
export function fuse(legs: ReadonlyArray<ReadonlyArray<ParsedHit>>, k: number): ParsedHit[] {
|
|
345
|
+
const merged = new Map<string, ParsedHit>();
|
|
346
|
+
const scores = new Map<string, number>();
|
|
347
|
+
|
|
348
|
+
for (const leg of legs) {
|
|
349
|
+
for (const [rank, hit] of leg.entries()) {
|
|
350
|
+
const key = `${hit.index}:${hit.id}:${hit.passage}`;
|
|
351
|
+
if (!merged.has(key)) merged.set(key, hit);
|
|
352
|
+
scores.set(key, (scores.get(key) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return [...merged.entries()]
|
|
357
|
+
.map(([key, hit]) => ({ ...hit, score: scores.get(key) ?? 0 }))
|
|
358
|
+
.sort((left, right) => right.score - left.score)
|
|
359
|
+
.slice(0, k);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Prefixes a brain-relative path with the folder's project-relative path. */
|
|
363
|
+
export function projectDocumentPath(
|
|
364
|
+
projectRoot: string,
|
|
365
|
+
directory: string,
|
|
366
|
+
path: string,
|
|
367
|
+
): string {
|
|
368
|
+
const root = relative(projectRoot, directory).split(sep).join("/");
|
|
369
|
+
if (root.length === 0 || path.startsWith(`${root}/`)) return path;
|
|
370
|
+
return `${root}/${path}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A hidden document is the whole file, so it shadows every section hit from a
|
|
375
|
+
* brain over the same folder. Prefer the section hits: they are more precise
|
|
376
|
+
* and carry graph expansion.
|
|
377
|
+
*/
|
|
378
|
+
export function dropShadowedWholeFiles(
|
|
379
|
+
hits: ReadonlyArray<SearchHit>,
|
|
380
|
+
hiddenIndex: string | null,
|
|
381
|
+
brainSpecs: ReadonlyArray<BrainSpec>,
|
|
382
|
+
): SearchHit[] {
|
|
383
|
+
if (hiddenIndex === null || brainSpecs.length === 0) return [...hits];
|
|
384
|
+
|
|
385
|
+
const brainPrefixes = brainSpecs.map((spec) => `${spec.prefix}-`);
|
|
386
|
+
const brainPaths = new Set(
|
|
387
|
+
hits
|
|
388
|
+
.filter(
|
|
389
|
+
(hit) =>
|
|
390
|
+
hit.path !== null &&
|
|
391
|
+
brainPrefixes.some((prefix) => hit.index.startsWith(prefix)),
|
|
392
|
+
)
|
|
393
|
+
.map((hit) => hit.path),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
return hits.filter(
|
|
397
|
+
(hit) =>
|
|
398
|
+
!(hit.index === hiddenIndex && hit.path !== null && brainPaths.has(hit.path)),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function dedupeHits(hits: ReadonlyArray<SearchHit>): SearchHit[] {
|
|
403
|
+
const seen = new Set<string>();
|
|
404
|
+
const unique: SearchHit[] = [];
|
|
405
|
+
|
|
406
|
+
for (const hit of hits) {
|
|
407
|
+
const key = `${hit.index}:${hit.id}:${hit.passage}`;
|
|
408
|
+
if (seen.has(key)) continue;
|
|
409
|
+
seen.add(key);
|
|
410
|
+
unique.push(hit);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return unique;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function pickTextFields(indices: Record<string, Record<string, string>>): string[] {
|
|
417
|
+
const available = new Set<string>();
|
|
418
|
+
for (const fields of Object.values(indices)) {
|
|
419
|
+
for (const [name, type] of Object.entries(fields)) {
|
|
420
|
+
if (type === "text" || type === "semantic_text") available.add(name);
|
|
421
|
+
if (type === "keyword" && name === "title") available.add(name);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const ordered = TEXT_FIELD_PRIORITY.filter((name) => available.has(name));
|
|
426
|
+
for (const name of available) {
|
|
427
|
+
if (!ordered.includes(name)) ordered.push(name);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (ordered.length === 0) return ["body"];
|
|
431
|
+
return ordered;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
interface RawHit {
|
|
435
|
+
readonly _index?: string;
|
|
436
|
+
readonly _id?: string;
|
|
437
|
+
readonly _score?: number;
|
|
438
|
+
readonly _source?: Record<string, unknown>;
|
|
439
|
+
readonly fields?: Record<string, unknown>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parseHits(response: unknown): ParsedHit[] {
|
|
443
|
+
if (!isRecord(response)) return [];
|
|
444
|
+
const hits = response.hits;
|
|
445
|
+
if (!isRecord(hits) || !Array.isArray(hits.hits)) return [];
|
|
446
|
+
|
|
447
|
+
const parsed: ParsedHit[] = [];
|
|
448
|
+
for (const raw of hits.hits as RawHit[]) {
|
|
449
|
+
if (!isRecord(raw)) continue;
|
|
450
|
+
const source = isRecord(raw._source) ? raw._source : {};
|
|
451
|
+
const passage = readPassage(raw.fields);
|
|
452
|
+
const text = (passage?.text ?? stringValue(source.body) ?? stringValue(source.code) ?? "").trim();
|
|
453
|
+
if (text.length === 0) continue;
|
|
454
|
+
|
|
455
|
+
parsed.push({
|
|
456
|
+
index: stringValue(raw._index) ?? "",
|
|
457
|
+
id: stringValue(raw._id) ?? "",
|
|
458
|
+
passage: passage?.ordinal ?? 0,
|
|
459
|
+
path: stringValue(source.ax_path) ?? stringValue(source.path) ?? null,
|
|
460
|
+
line: null,
|
|
461
|
+
title: stringValue(source.title) ?? stringValue(source.name) ?? null,
|
|
462
|
+
kind: stringValue(source.kind) ?? null,
|
|
463
|
+
score: numberValue(raw._score) ?? 0,
|
|
464
|
+
text,
|
|
465
|
+
startLine: numberValue(source.start_line) ?? numberValue(source.line) ?? null,
|
|
466
|
+
startOffset: passage?.startOffset ?? 0,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return parsed;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
interface Passage {
|
|
474
|
+
readonly text: string;
|
|
475
|
+
readonly ordinal: number;
|
|
476
|
+
readonly startOffset: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function readPassage(fields: unknown): Passage | null {
|
|
480
|
+
if (!isRecord(fields)) return null;
|
|
481
|
+
const passages = fields._passage;
|
|
482
|
+
if (!Array.isArray(passages) || passages.length === 0) return null;
|
|
483
|
+
|
|
484
|
+
const first = passages[0];
|
|
485
|
+
if (!isRecord(first)) return null;
|
|
486
|
+
const text = stringValue(first.text);
|
|
487
|
+
if (text === null || text.trim().length === 0) return null;
|
|
488
|
+
return {
|
|
489
|
+
text,
|
|
490
|
+
ordinal: numberValue(first.ordinal) ?? 0,
|
|
491
|
+
startOffset: numberValue(first.start_offset) ?? 0,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Newlines from the passage start to the earliest matching query term. */
|
|
496
|
+
export function newlinesBeforeMatch(text: string, query: string): number | null {
|
|
497
|
+
const terms = (query.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []).slice(0, 12);
|
|
498
|
+
if (terms.length === 0) return null;
|
|
499
|
+
|
|
500
|
+
const lower = text.toLowerCase();
|
|
501
|
+
let earliest = -1;
|
|
502
|
+
for (const term of terms) {
|
|
503
|
+
const at = lower.indexOf(term);
|
|
504
|
+
if (at >= 0 && (earliest === -1 || at < earliest)) earliest = at;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (earliest <= 0) return 0;
|
|
508
|
+
let newlines = 0;
|
|
509
|
+
for (let index = 0; index < earliest; index++) {
|
|
510
|
+
if (text.charCodeAt(index) === 10) newlines += 1;
|
|
511
|
+
}
|
|
512
|
+
return newlines;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function formatResult(
|
|
516
|
+
hits: ReadonlyArray<SearchHit>,
|
|
517
|
+
strategy: SearchResult["strategy"],
|
|
518
|
+
budget: number,
|
|
519
|
+
narrowing: number,
|
|
520
|
+
startIndex = 0,
|
|
521
|
+
): SearchResult {
|
|
522
|
+
const blocks: string[] = [];
|
|
523
|
+
let used = 0;
|
|
524
|
+
// Share the budget across the hits instead of letting the first whole-section
|
|
525
|
+
// match consume it; a 6 KB function should not hide the other three results.
|
|
526
|
+
const perHit = Math.max(400, Math.floor(budget / Math.max(hits.length, 1)));
|
|
527
|
+
|
|
528
|
+
for (const [index, hit] of hits.entries()) {
|
|
529
|
+
const location = hit.path ?? hit.index;
|
|
530
|
+
const line = hit.line === null ? "" : `:${hit.line}`;
|
|
531
|
+
const kind = hit.kind === null ? "" : ` [${hit.kind}]`;
|
|
532
|
+
const header = `[${startIndex + index + 1}] ${location}${line}${kind}`;
|
|
533
|
+
const remaining = budget - used - header.length - 2;
|
|
534
|
+
if (remaining < 80) break;
|
|
535
|
+
|
|
536
|
+
const passage = truncate(hit.text, Math.min(remaining, perHit));
|
|
537
|
+
const block = `${header}\n${passage}`;
|
|
538
|
+
blocks.push(block);
|
|
539
|
+
used += block.length + 2;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const text = blocks.join("\n\n");
|
|
543
|
+
return {
|
|
544
|
+
hits,
|
|
545
|
+
text,
|
|
546
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
547
|
+
strategy,
|
|
548
|
+
narrowingBytes: narrowing,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function truncate(text: string, limit: number): string {
|
|
553
|
+
if (text.length <= limit) return text.trimEnd();
|
|
554
|
+
|
|
555
|
+
const cut = text.slice(0, limit);
|
|
556
|
+
const lastNewline = cut.lastIndexOf("\n");
|
|
557
|
+
const body = lastNewline > limit * 0.5 ? cut.slice(0, lastNewline) : cut;
|
|
558
|
+
return `${body.trimEnd()}\n…`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function narrowingBytes(response: unknown): number {
|
|
562
|
+
if (!isRecord(response) || !isRecord(response._savings)) return 0;
|
|
563
|
+
return numberValue(response._savings.bytes) ?? 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function budgetChars(maxTokens: number | undefined): number {
|
|
567
|
+
const tokens = maxTokens ?? 800;
|
|
568
|
+
return Math.max(400, tokens * 4);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function emptyResult(strategy: SearchResult["strategy"]): SearchResult {
|
|
572
|
+
return { hits: [], text: "", bytes: 0, strategy, narrowingBytes: 0 };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function clamp(value: number, min: number, max: number): number {
|
|
576
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function stringValue(value: unknown): string | null {
|
|
580
|
+
return typeof value === "string" ? value : null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function numberValue(value: unknown): number | null {
|
|
584
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
588
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
589
|
+
}
|
package/src/semver.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Semver parsing and comparison. XERJ ships pre-1.0 releases such as
|
|
2
|
+
// `1.0.0-rc.100`, so prerelease identifiers must compare numerically:
|
|
3
|
+
// `rc.99 < rc.100`. A string compare would rank `rc.100` below `rc.99`.
|
|
4
|
+
|
|
5
|
+
export interface Version {
|
|
6
|
+
readonly major: number;
|
|
7
|
+
readonly minor: number;
|
|
8
|
+
readonly patch: number;
|
|
9
|
+
/** Dot-separated prerelease identifiers, numeric ones kept as numbers. */
|
|
10
|
+
readonly prerelease: ReadonlyArray<string | number>;
|
|
11
|
+
readonly raw: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VERSION_PATTERN =
|
|
15
|
+
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
16
|
+
|
|
17
|
+
/** Parses a semver string, with an optional leading `v`. Returns null when invalid. */
|
|
18
|
+
export function parseVersion(input: string): Version | null {
|
|
19
|
+
const match = VERSION_PATTERN.exec(input.trim());
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
|
|
22
|
+
const [, major, minor, patch, prerelease] = match;
|
|
23
|
+
if (major === undefined || minor === undefined || patch === undefined) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
major: Number(major),
|
|
29
|
+
minor: Number(minor),
|
|
30
|
+
patch: Number(patch),
|
|
31
|
+
prerelease: parsePrerelease(prerelease),
|
|
32
|
+
raw: input.trim(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parsePrerelease(value: string | undefined): Array<string | number> {
|
|
37
|
+
if (!value) return [];
|
|
38
|
+
return value.split(".").map((part) => {
|
|
39
|
+
if (/^\d+$/.test(part)) return Number(part);
|
|
40
|
+
return part;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Compares two versions per semver precedence. */
|
|
45
|
+
export function compareVersions(left: Version, right: Version): number {
|
|
46
|
+
for (const key of ["major", "minor", "patch"] as const) {
|
|
47
|
+
if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function comparePrerelease(
|
|
54
|
+
left: ReadonlyArray<string | number>,
|
|
55
|
+
right: ReadonlyArray<string | number>,
|
|
56
|
+
): number {
|
|
57
|
+
// A release outranks any prerelease of the same version.
|
|
58
|
+
if (left.length === 0 && right.length === 0) return 0;
|
|
59
|
+
if (left.length === 0) return 1;
|
|
60
|
+
if (right.length === 0) return -1;
|
|
61
|
+
|
|
62
|
+
const length = Math.min(left.length, right.length);
|
|
63
|
+
for (let index = 0; index < length; index++) {
|
|
64
|
+
const a = left[index];
|
|
65
|
+
const b = right[index];
|
|
66
|
+
if (a === undefined || b === undefined) break;
|
|
67
|
+
if (a === b) continue;
|
|
68
|
+
|
|
69
|
+
const aNumeric = typeof a === "number";
|
|
70
|
+
const bNumeric = typeof b === "number";
|
|
71
|
+
if (aNumeric && bNumeric) return a < b ? -1 : 1;
|
|
72
|
+
if (aNumeric) return -1;
|
|
73
|
+
if (bNumeric) return 1;
|
|
74
|
+
return a < b ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (left.length === right.length) return 0;
|
|
78
|
+
return left.length < right.length ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compares two version strings. Returns null when either side is invalid. */
|
|
82
|
+
export function compareVersionStrings(left: string, right: string): number | null {
|
|
83
|
+
const a = parseVersion(left);
|
|
84
|
+
const b = parseVersion(right);
|
|
85
|
+
if (!a || !b) return null;
|
|
86
|
+
return compareVersions(a, b);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `remote` should replace `installed`.
|
|
91
|
+
*
|
|
92
|
+
* Pre-1.0 projects change behaviour in prerelease and patch releases, so any
|
|
93
|
+
* newer release wins. A major bump is not taken silently.
|
|
94
|
+
*/
|
|
95
|
+
export function shouldUpgrade(installed: string, remote: string): boolean {
|
|
96
|
+
const current = parseVersion(installed);
|
|
97
|
+
const next = parseVersion(remote);
|
|
98
|
+
if (!current || !next) return false;
|
|
99
|
+
if (next.major !== current.major) return false;
|
|
100
|
+
return compareVersions(next, current) > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Returns the highest version in a list, ignoring invalid entries. */
|
|
104
|
+
export function highestVersion(versions: ReadonlyArray<string>): string | null {
|
|
105
|
+
let best: Version | null = null;
|
|
106
|
+
for (const candidate of versions) {
|
|
107
|
+
const parsed = parseVersion(candidate);
|
|
108
|
+
if (!parsed) continue;
|
|
109
|
+
if (!best || compareVersions(parsed, best) > 0) best = parsed;
|
|
110
|
+
}
|
|
111
|
+
return best?.raw ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Strips a leading `v` from a release tag. */
|
|
115
|
+
export function normalizeVersion(tag: string): string {
|
|
116
|
+
return tag.trim().replace(/^v/, "");
|
|
117
|
+
}
|