@maxgfr/codeindex 2.11.0 → 2.12.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.
@@ -1,6 +1,7 @@
1
1
  import type { CodeSymbol, RawRef } from "../types.js";
2
2
  import { extractSymbols } from "../lang/registry.js";
3
3
  import { extractAst } from "../ast/extract.js";
4
+ import { extractReexports } from "../lang/common.js";
4
5
 
5
6
  export interface CodeInfo {
6
7
  symbols: CodeSymbol[];
@@ -253,62 +254,6 @@ function extractImports(ext: string, content: string): RawRef[] {
253
254
  return [...specs].map((spec) => ({ kind: "import" as const, spec }));
254
255
  }
255
256
 
256
- // Barrel re-exports (`export { A, B as C } from './x'`, `export * from './y'`).
257
- // The line-based lang extractor can't capture multi-name lists, but these ARE
258
- // the public facade of a module — so list them as exported symbols here.
259
- //
260
- // An ALIAS with no `from` clause (`export { b as c }`) renames an in-file
261
- // declaration — `localSymbols` (already extracted by the AST or regex tier)
262
- // lets us resolve `b` and mirror ITS kind onto `c` (e.g. "function"), so the
263
- // alias reads as the real symbol it is rather than the generic "reexport".
264
- // A true cross-module re-export (`export { b as c } from "./mod"`) has no
265
- // local `b` to resolve — and an alias the local pass genuinely can't see
266
- // (destructured/ambient/etc.) falls back the same way — both keep "reexport".
267
- function extractReexports(rel: string, content: string, localSymbols: CodeSymbol[]): CodeSymbol[] {
268
- if (!JS_TS.has(rel.slice(rel.lastIndexOf(".")))) return [];
269
- const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
270
- const out: CodeSymbol[] = [];
271
- const seen = new Set<string>();
272
- const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
273
- const localKindOf = new Map<string, string>();
274
- for (const s of localSymbols) if (!localKindOf.has(s.name)) localKindOf.set(s.name, s.kind);
275
-
276
- const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
277
- let m: RegExpExecArray | null;
278
- while ((m = named.exec(content)) && out.length < 60) {
279
- const from = m[2];
280
- for (const part of m[1]!.split(",")) {
281
- const p = part.trim().replace(/^type\s+/, "");
282
- const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
283
- const orig = as ? as[1]! : p;
284
- const name = as ? as[2]! : p;
285
- if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
286
- seen.add(name);
287
- const mirroredKind = !from ? localKindOf.get(orig) : undefined;
288
- out.push({
289
- name, kind: mirroredKind ?? "reexport", file: rel, line: lineAt(m.index),
290
- signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
291
- exported: true, lang,
292
- });
293
- }
294
- }
295
-
296
- const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
297
- while ((m = star.exec(content)) && out.length < 60) {
298
- const ns = m[1];
299
- const from = m[2]!;
300
- const key = "*" + (ns ?? from);
301
- if (seen.has(key)) continue;
302
- seen.add(key);
303
- out.push({
304
- name: ns ?? `* (${from})`, kind: ns ? "reexport" : "reexport-all", file: rel,
305
- line: lineAt(m.index), signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
306
- exported: true, lang,
307
- });
308
- }
309
- return out;
310
- }
311
-
312
257
  // Control-flow and declaration keywords that syntactically precede `(` but are
313
258
  // never call targets — the union across supported languages. Deliberately does
314
259
  // NOT list real builtins (python's `print`, go's `make`…): a false call to a
@@ -331,8 +276,35 @@ const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bm
331
276
  // immediate `receiver.` prefix is captured too (`axios.get(` → receiver
332
277
  // "axios"; `a.b.c(` → receiver "b" — the group anchors to the segment right
333
278
  // before the called name); bare calls carry no receiver.
334
- function collectCallsRegex(content: string): { name: string; line: number; receiver?: string }[] {
279
+ //
280
+ // `symbols` is the file's OWN regex-extracted symbols (name + definition
281
+ // line). DEF_INTRODUCERS already excludes definitions that read `function
282
+ // foo(`/`def foo(`/etc. (per OCCURRENCE, wherever on the line it sits), but
283
+ // C/C++ function definitions have no such introducer (`void load(void) {`) —
284
+ // the bare name reads exactly like a call to itself on its own definition
285
+ // line. For those, a call candidate whose (name, line) exactly matches one of
286
+ // `symbols` is excluded too — but ONLY its first (leftmost) occurrence on that
287
+ // line, never every same-named occurrence: dense/minified one-liners can pack
288
+ // a genuine call to the same name on the same physical line as its own
289
+ // definition (`function aa(){}function bb(){return aa()+cc()}function cc(){}`
290
+ // — bb's calls to aa() and cc() must survive), and even a bodyless
291
+ // single-line recursive definition (`function foo(){foo();}`) has a real self
292
+ // -call to keep. Two-tier: if ANY occurrence of a def'd name on this line is
293
+ // already caught by DEF_INTRODUCERS (JS/Python/…), that occurrence alone is
294
+ // excluded (existing per-occurrence check below) and no further exclusion is
295
+ // applied — every OTHER occurrence is a genuine call. Only when NO occurrence
296
+ // carries an introducer (C/C++) does this fall back to excluding just the
297
+ // first occurrence: C/C++'s own definition regex requires column 0, so on a
298
+ // line where it matches at all, the first occurrence IS that definition.
299
+ // Exported for direct testing (extraction-v8.test.ts): once a wasm sidecar/
300
+ // grammar is loaded, extractCode never reaches this path for C/C++, so tests
301
+ // exercise it directly rather than through extractCode.
302
+ export function collectCallsRegex(
303
+ content: string,
304
+ symbols: Pick<CodeSymbol, "name" | "line">[] = [],
305
+ ): { name: string; line: number; receiver?: string }[] {
335
306
  const out = new Map<string, { name: string; line: number; receiver?: string }>();
307
+ const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
336
308
  const lines = content.split("\n");
337
309
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
338
310
  for (let i = 0; i < lines.length && out.size < 512; i++) {
@@ -342,14 +314,36 @@ function collectCallsRegex(content: string): { name: string; line: number; recei
342
314
  // regexes — noise resolves to nothing in the global call pass).
343
315
  const trimmed = line.trimStart();
344
316
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) continue;
317
+
318
+ // Pass 1: which own-def keys on this line have at least one occurrence
319
+ // DEF_INTRODUCERS already catches? Those are fully handled per-occurrence
320
+ // below — no fallback exclusion needed (or wanted) for them.
321
+ CALL_RE.lastIndex = 0;
322
+ let probe: RegExpExecArray | null;
323
+ const introducerCaught = new Set<string>();
324
+ while ((probe = CALL_RE.exec(line)) !== null) {
325
+ const name = probe[2]!;
326
+ const key = `${name} ${i + 1}`;
327
+ if (ownDefLines.has(key) && DEF_INTRODUCERS.test(line.slice(0, probe.index))) introducerCaught.add(key);
328
+ }
329
+
330
+ // Pass 2: the real collection. Own-def keys with no introducer occurrence
331
+ // fall back to excluding just their first occurrence on the line.
345
332
  CALL_RE.lastIndex = 0;
346
333
  let m: RegExpExecArray | null;
334
+ const fallbackExcluded = new Set<string>();
347
335
  while ((m = CALL_RE.exec(line)) !== null && out.size < 512) {
348
336
  const receiver = m[1];
349
337
  const name = m[2]!;
350
338
  if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
351
339
  if (DEF_INTRODUCERS.test(line.slice(0, m.index))) continue;
352
340
  const key = `${name} ${i + 1}`;
341
+ if (ownDefLines.has(key) && !introducerCaught.has(key)) {
342
+ if (!fallbackExcluded.has(key)) {
343
+ fallbackExcluded.add(key);
344
+ continue;
345
+ }
346
+ }
353
347
  if (!out.has(key)) out.set(key, receiver ? { name, line: i + 1, receiver } : { name, line: i + 1 });
354
348
  }
355
349
  }
@@ -382,7 +376,9 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
382
376
  idents: ast?.idents,
383
377
  // AST call sites when a grammar parsed the file; the conservative regex
384
378
  // collector otherwise, so caller indexes exist without the wasm sidecar.
385
- calls: ast ? ast.calls : collectCallsRegex(content),
379
+ // `symbols` (this file's own regex-extracted defs) lets the collector
380
+ // exclude a definition's own name+line from its call candidates.
381
+ calls: ast ? ast.calls : collectCallsRegex(content, symbols),
386
382
  importedNames: ast?.importedNames,
387
383
  };
388
384
  }
@@ -66,3 +66,78 @@ const EXT_LANG: Record<string, string> = {
66
66
  export function extToLang(ext: string): string {
67
67
  return EXT_LANG[ext] ?? "other";
68
68
  }
69
+
70
+ const REEXPORT_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
71
+
72
+ // Barrel re-exports (`export { A, B as C } from './x'`, `export * from './y'`).
73
+ // The line-based lang extractor can't capture multi-name lists, but these ARE
74
+ // the public facade of a module — so list them as exported symbols here.
75
+ //
76
+ // An ALIAS with no `from` clause (`export { b as c }`) renames an in-file
77
+ // declaration — `localSymbols` (already extracted by the AST or regex tier)
78
+ // lets us resolve `b` and mirror ITS kind, declaration line, and endLine (AST
79
+ // tier only) onto `c` (e.g. "function" at b's own line), so the alias reads
80
+ // as the real symbol it is — citeable at its actual declaration — rather than
81
+ // the generic "reexport" pinned to the export statement's line.
82
+ // A true cross-module re-export (`export { b as c } from "./mod"`) has no
83
+ // local `b` to resolve — and an alias the local pass genuinely can't see
84
+ // (destructured/ambient/etc.) falls back the same way — both keep "reexport"
85
+ // and cite the export statement's own line, the only line they have.
86
+ //
87
+ // Shared by extractCode (extract/code.ts) AND the standalone extractSymbols
88
+ // (lang/registry.ts) — ultradoc and other direct extractSymbols consumers hit
89
+ // the same barrels a repo scan does, so both entry points must agree; this is
90
+ // the one place the alias-mirroring logic lives, reused rather than
91
+ // reimplemented on either side.
92
+ export function extractReexports(rel: string, content: string, localSymbols: CodeSymbol[]): CodeSymbol[] {
93
+ if (!REEXPORT_EXTS.has(rel.slice(rel.lastIndexOf(".")))) return [];
94
+ const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
95
+ const out: CodeSymbol[] = [];
96
+ const seen = new Set<string>();
97
+ const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
98
+ // Keyed on the whole CodeSymbol (not just kind) so the alias branch below
99
+ // can also cite the resolved declaration's own line/endLine, not just mirror
100
+ // its kind.
101
+ const localDeclOf = new Map<string, CodeSymbol>();
102
+ for (const s of localSymbols) if (!localDeclOf.has(s.name)) localDeclOf.set(s.name, s);
103
+
104
+ const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
105
+ let m: RegExpExecArray | null;
106
+ while ((m = named.exec(content)) && out.length < 60) {
107
+ const from = m[2];
108
+ for (const part of m[1]!.split(",")) {
109
+ const p = part.trim().replace(/^type\s+/, "");
110
+ const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
111
+ const orig = as ? as[1]! : p;
112
+ const name = as ? as[2]! : p;
113
+ if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
114
+ seen.add(name);
115
+ // A resolved decl means this is a same-file alias: cite ITS line (and
116
+ // endLine, when the AST tier populated one) rather than the export
117
+ // statement's — an unresolved alias or a `from`-clause re-export has no
118
+ // local declaration to point at, so it keeps lineAt(m.index) below.
119
+ const decl = !from ? localDeclOf.get(orig) : undefined;
120
+ out.push({
121
+ name, kind: decl?.kind ?? "reexport", file: rel, line: decl ? decl.line : lineAt(m.index),
122
+ ...(decl?.endLine !== undefined ? { endLine: decl.endLine } : {}),
123
+ signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
124
+ exported: true, lang,
125
+ });
126
+ }
127
+ }
128
+
129
+ const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
130
+ while ((m = star.exec(content)) && out.length < 60) {
131
+ const ns = m[1];
132
+ const from = m[2]!;
133
+ const key = "*" + (ns ?? from);
134
+ if (seen.has(key)) continue;
135
+ seen.add(key);
136
+ out.push({
137
+ name: ns ?? `* (${from})`, kind: ns ? "reexport" : "reexport-all", file: rel,
138
+ line: lineAt(m.index), signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
139
+ exported: true, lang,
140
+ });
141
+ }
142
+ return out;
143
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CodeSymbol } from "../types.js";
2
- import { extToLang } from "./common.js";
2
+ import { extToLang, extractReexports } from "./common.js";
3
3
  import { jsTs } from "./js-ts.js";
4
4
  import { python } from "./python.js";
5
5
  import { go } from "./go.js";
@@ -35,14 +35,29 @@ for (const e of EXTRACTORS) for (const ext of e.exts) BY_EXT.set(ext, e);
35
35
 
36
36
  // Extract declared symbols from one file. Returns [] for languages without a
37
37
  // dedicated extractor (their content is still fully searchable via ripgrep).
38
+ //
39
+ // Also mirrors extractCode's barrel re-export/alias pass (extractReexports):
40
+ // a direct extractSymbols consumer (ultradoc, or any tool that doesn't go
41
+ // through extractCode) hits the same `export { a, b as c }` barrels a repo
42
+ // scan does, and should see the same alias-mirrors-b's-own-kind symbols —
43
+ // not the bare "reexport" kind extractReexports falls back to when there's
44
+ // no local declaration to mirror. extractCode already runs extractReexports
45
+ // itself and filters by name, so this can't double-add: whichever entry point
46
+ // adds "c" first wins, the other's re-run is a no-op duplicate filtered out.
38
47
  export function extractSymbols(rel: string, ext: string, content: string): CodeSymbol[] {
39
48
  const extractor = BY_EXT.get(ext);
40
- if (!extractor) return [];
41
- try {
42
- return extractor.extract(rel, content);
43
- } catch {
44
- return [];
49
+ let symbols: CodeSymbol[];
50
+ if (!extractor) symbols = [];
51
+ else {
52
+ try {
53
+ symbols = extractor.extract(rel, content);
54
+ } catch {
55
+ symbols = [];
56
+ }
45
57
  }
58
+ const known = new Set(symbols.map((s) => s.name));
59
+ const reexports = extractReexports(rel, content, symbols).filter((s) => !known.has(s.name));
60
+ return reexports.length ? [...symbols, ...reexports] : symbols;
46
61
  }
47
62
 
48
63
  // Human-readable language label for an extension (used for the language
package/src/mcp.ts CHANGED
@@ -10,7 +10,7 @@ import { ENGINE_VERSION } from "./types.js";
10
10
  import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
11
11
  import { buildIndexArtifacts } from "./pipeline.js";
12
12
  import { renderGraphJson } from "./render/graph-json.js";
13
- import { scanRepo } from "./scan.js";
13
+ import { scanRepo, type RepoScan } from "./scan.js";
14
14
  import { buildCallerIndex } from "./callers.js";
15
15
  import { detectWorkspaces } from "./workspaces.js";
16
16
  import { gitChurn } from "./git.js";
@@ -26,9 +26,10 @@ import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js
26
26
  import { searchIndex } from "./bm25.js";
27
27
  import { checkRules, parseRules } from "./rules.js";
28
28
  import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
29
- import { buildEmbeddingIndex } from "./embed/index.js";
29
+ import { buildEmbeddingIndex, type EmbeddingIndex } from "./embed/index.js";
30
30
  import { searchSemantic } from "./embed/search.js";
31
31
  import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
32
+ import { sha1 } from "./hash.js";
32
33
 
33
34
  interface RpcRequest {
34
35
  jsonrpc: "2.0";
@@ -270,7 +271,7 @@ const TOOLS = [
270
271
  {
271
272
  name: "search",
272
273
  description:
273
- 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse the deterministic static-embedding tier when a model asset is present (degrades to lexical otherwise see embed_status).',
274
+ 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse an embedding tier (HTTP endpoint, else a local static model) with lexical the response then wraps the ranked list as `{ results, tier, degradedReason? }`, `tier` being "endpoint"/"static" when fusion happened or "lexical" (with `degradedReason`) when it did not (see embed_status). Without `semantic`, the response is the bare ranked array, unchanged.',
274
275
  inputSchema: {
275
276
  type: "object",
276
277
  properties: {
@@ -286,7 +287,7 @@ const TOOLS = [
286
287
  semantic: {
287
288
  type: "boolean",
288
289
  description:
289
- "RRF-fuse an embedding tier with lexical (default false). Precedence: the HTTP endpoint (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model. Degrades silently to lexical-only when neither is available/reachable — see embed_status.",
290
+ 'RRF-fuse an embedding tier with lexical (default false). Precedence: the HTTP endpoint (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model. The response reports the effective tier as a top-level `tier` field ("endpoint"/"static" on success, "lexical" plus `degradedReason` when neither is available/reachable) instead of degrading silently — see embed_status.',
290
291
  },
291
292
  },
292
293
  required: ["repo", "query"],
@@ -320,6 +321,50 @@ function str(v: unknown): string | undefined {
320
321
  function strArray(v: unknown): string[] | undefined {
321
322
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
322
323
  }
324
+ function errMessage(e: unknown): string {
325
+ return e instanceof Error ? e.message : String(e);
326
+ }
327
+
328
+ // --- embedding index memoization --------------------------------------------
329
+ // The MCP server process is long-lived, but every `search` call used to redo
330
+ // the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
331
+ // or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
332
+ // repo changed between requests. Memoize the last build behind a fingerprint
333
+ // of the scan contents plus the tier's identity, so an unchanged repo reuses
334
+ // the cached index and any file add/edit/remove (or a switch of endpoint/model)
335
+ // still rebuilds. RepoScan carries no fingerprint of its own (checked
336
+ // scan.ts/types.ts) — every FileRecord already carries a content hash, so
337
+ // hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
338
+ export function scanFingerprint(scan: RepoScan): string {
339
+ return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
340
+ }
341
+
342
+ export interface EmbeddingIndexCacheKey {
343
+ mode: "endpoint" | "static";
344
+ // Distinguishes cache entries across configs sharing the same scan: the
345
+ // endpoint URL, or the model dir + modelId for the static tier.
346
+ identity: string;
347
+ scan: RepoScan;
348
+ }
349
+
350
+ // A SINGLE entry — never an unbounded map — holding the most recent build.
351
+ let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
352
+
353
+ // Reuse the cached index when (mode, identity, scanFingerprint) matches the
354
+ // last build; otherwise call `build` and cache its result. A failed build is
355
+ // NEVER cached (matches today's per-call error behavior: the next request
356
+ // retries from scratch, and a still-valid previous entry — under a different
357
+ // key — is left untouched).
358
+ export async function memoizedEmbeddingIndex(
359
+ key: EmbeddingIndexCacheKey,
360
+ build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
361
+ ): Promise<EmbeddingIndex> {
362
+ const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
363
+ if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
364
+ const index = await build();
365
+ embeddingIndexCache = { key: cacheKey, index };
366
+ return index;
367
+ }
323
368
 
324
369
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
325
370
  const repo = str(args.repo);
@@ -459,25 +504,49 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
459
504
  const limit = typeof args.limit === "number" ? args.limit : undefined;
460
505
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
461
506
  if (args.semantic === true) {
507
+ // semantic:true changes the response SHAPE (wraps the ranked list with a
508
+ // `tier`/`degradedReason?`) so a caller can tell "fusion happened" apart
509
+ // from "degraded to lexical" — see the `search` tool description. This
510
+ // branch is the ONLY place that shape appears; plain lexical search below
511
+ // stays the bare array, byte-compat for existing consumers.
462
512
  const endpoint = resolveEmbedEndpoint();
463
513
  if (endpoint) {
464
514
  // Rich tier — endpoint takes PRECEDENCE over a local static model. An
465
- // unreachable/malformed endpoint degrades to lexical (no throw).
515
+ // unreachable/malformed endpoint degrades to lexical, now with a reason.
516
+ // The corpus index is memoized per (endpoint, scan state) — the query
517
+ // itself is always re-encoded fresh (it differs per call).
466
518
  try {
467
- const index = await buildEndpointIndex(scan);
519
+ const index = await memoizedEmbeddingIndex({ mode: "endpoint", identity: endpoint, scan }, () => buildEndpointIndex(scan));
468
520
  const queryVec = await encodeQueryViaEndpoint(query);
469
- return JSON.stringify(searchSemantic(scan, query, index, { queryVec, limit, fuzzy }), null, 2);
470
- } catch {
471
- return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
521
+ const results = searchSemantic(scan, query, index, { queryVec, limit, fuzzy });
522
+ return JSON.stringify({ results, tier: "endpoint" }, null, 2);
523
+ } catch (e) {
524
+ const results = searchIndex(scan, query, { limit, fuzzy });
525
+ return JSON.stringify(
526
+ { results, tier: "lexical", degradedReason: `embedding endpoint failed: ${errMessage(e)}` },
527
+ null,
528
+ 2,
529
+ );
472
530
  }
473
531
  }
474
532
  const modelDir = resolveEmbedModelDir(repo);
475
533
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
476
534
  if (model) {
477
- const index = buildEmbeddingIndex(scan, model);
478
- return JSON.stringify(searchSemantic(scan, query, index, { model, limit, fuzzy }), null, 2);
535
+ const index = await memoizedEmbeddingIndex(
536
+ { mode: "static", identity: `${modelDir}#${model.modelId}`, scan },
537
+ () => buildEmbeddingIndex(scan, model),
538
+ );
539
+ const results = searchSemantic(scan, query, index, { model, limit, fuzzy });
540
+ return JSON.stringify({ results, tier: "static" }, null, 2);
479
541
  }
480
- // Degrade to lexical (opt-in tier not activated) — no throw.
542
+ // Opt-in tier not activated (no endpoint, no model asset) — degrade to
543
+ // lexical with a reason instead of failing silently.
544
+ const results = searchIndex(scan, query, { limit, fuzzy });
545
+ return JSON.stringify(
546
+ { results, tier: "lexical", degradedReason: "no embedding endpoint or static model configured — see embed_status" },
547
+ null,
548
+ 2,
549
+ );
481
550
  }
482
551
  return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
483
552
  }
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single source of truth for the engine version the bundle reports. Kept in
2
2
  // lockstep with package.json by the release pipeline. Do not edit by hand
3
3
  // outside a release.
4
- export const ENGINE_VERSION = "2.11.0";
4
+ export const ENGINE_VERSION = "2.12.0";
5
5
 
6
6
  // Bumped whenever the on-disk artifact shape changes, so a consumer can reject
7
7
  // an index written by an incompatible engine instead of misreading it. The
@@ -27,8 +27,18 @@ export const SCHEMA_VERSION = 4;
27
27
  // named after the file stem, `export default Foo` marking the declaration);
28
28
  // v7 makes an export-alias symbol (`export { b as c }`) mirror the aliased
29
29
  // local declaration's own kind (e.g. "function") instead of the generic
30
- // "reexport" when it resolves in-file.
31
- export const EXTRACTOR_VERSION = 7;
30
+ // "reexport" when it resolves in-file. v8 fixes the C/C++ regex tier
31
+ // reporting a function DEFINITION as a call to itself (`void load(void) {`
32
+ // yielding a spurious call `load@<defline>`, found during the ultrasec
33
+ // consumer migration) by excluding, from call candidates, only the
34
+ // definition's own token on its definition line — not every occurrence of
35
+ // that name+line pair, which would also drop genuine same-line calls (dense
36
+ // one-liners packing several definitions, one-line recursion); v9 makes a
37
+ // resolved export-alias symbol also cite the original declaration's own line
38
+ // (and endLine, when the AST tier populated one) instead of the export
39
+ // statement's line — a citation-precision fix (issue #9) for consumers
40
+ // (ultradoc) that use file:line as evidence.
41
+ export const EXTRACTOR_VERSION = 9;
32
42
 
33
43
  // How a file is classified. `code` gets symbol/import extraction; `doc` gets
34
44
  // link/heading extraction; the rest are catalogued but not deeply parsed.