@maxgfr/codeindex 2.10.0 → 2.11.1

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,50 +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
- function extractReexports(rel: string, content: string): CodeSymbol[] {
260
- if (!JS_TS.has(rel.slice(rel.lastIndexOf(".")))) return [];
261
- const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
262
- const out: CodeSymbol[] = [];
263
- const seen = new Set<string>();
264
- const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
265
-
266
- const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
267
- let m: RegExpExecArray | null;
268
- while ((m = named.exec(content)) && out.length < 60) {
269
- const from = m[2];
270
- for (const part of m[1]!.split(",")) {
271
- const p = part.trim().replace(/^type\s+/, "");
272
- const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
273
- const name = as ? as[2]! : p;
274
- if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
275
- seen.add(name);
276
- out.push({
277
- name, kind: "reexport", file: rel, line: lineAt(m.index),
278
- signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
279
- exported: true, lang,
280
- });
281
- }
282
- }
283
-
284
- const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
285
- while ((m = star.exec(content)) && out.length < 60) {
286
- const ns = m[1];
287
- const from = m[2]!;
288
- const key = "*" + (ns ?? from);
289
- if (seen.has(key)) continue;
290
- seen.add(key);
291
- out.push({
292
- name: ns ?? `* (${from})`, kind: ns ? "reexport" : "reexport-all", file: rel,
293
- line: lineAt(m.index), signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
294
- exported: true, lang,
295
- });
296
- }
297
- return out;
298
- }
299
-
300
257
  // Control-flow and declaration keywords that syntactically precede `(` but are
301
258
  // never call targets — the union across supported languages. Deliberately does
302
259
  // NOT list real builtins (python's `print`, go's `make`…): a false call to a
@@ -319,8 +276,35 @@ const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bm
319
276
  // immediate `receiver.` prefix is captured too (`axios.get(` → receiver
320
277
  // "axios"; `a.b.c(` → receiver "b" — the group anchors to the segment right
321
278
  // before the called name); bare calls carry no receiver.
322
- 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 }[] {
323
306
  const out = new Map<string, { name: string; line: number; receiver?: string }>();
307
+ const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
324
308
  const lines = content.split("\n");
325
309
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
326
310
  for (let i = 0; i < lines.length && out.size < 512; i++) {
@@ -330,14 +314,36 @@ function collectCallsRegex(content: string): { name: string; line: number; recei
330
314
  // regexes — noise resolves to nothing in the global call pass).
331
315
  const trimmed = line.trimStart();
332
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.
333
332
  CALL_RE.lastIndex = 0;
334
333
  let m: RegExpExecArray | null;
334
+ const fallbackExcluded = new Set<string>();
335
335
  while ((m = CALL_RE.exec(line)) !== null && out.size < 512) {
336
336
  const receiver = m[1];
337
337
  const name = m[2]!;
338
338
  if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
339
339
  if (DEF_INTRODUCERS.test(line.slice(0, m.index))) continue;
340
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
+ }
341
347
  if (!out.has(key)) out.set(key, receiver ? { name, line: i + 1, receiver } : { name, line: i + 1 });
342
348
  }
343
349
  }
@@ -354,7 +360,7 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
354
360
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
355
361
  // Add barrel re-exports the local def didn't already cover.
356
362
  const known = new Set(symbols.map((s) => s.name));
357
- const reexports = extractReexports(rel, content).filter((s) => !known.has(s.name));
363
+ const reexports = extractReexports(rel, content, symbols).filter((s) => !known.has(s.name));
358
364
  return {
359
365
  symbols: [...symbols, ...reexports],
360
366
  summary: topDocComment(content),
@@ -370,7 +376,9 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
370
376
  idents: ast?.idents,
371
377
  // AST call sites when a grammar parsed the file; the conservative regex
372
378
  // collector otherwise, so caller indexes exist without the wasm sidecar.
373
- 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),
374
382
  importedNames: ast?.importedNames,
375
383
  };
376
384
  }
@@ -66,3 +66,67 @@ 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 onto `c` (e.g. "function"), so the
79
+ // alias reads as the real symbol it is rather than the generic "reexport".
80
+ // A true cross-module re-export (`export { b as c } from "./mod"`) has no
81
+ // local `b` to resolve — and an alias the local pass genuinely can't see
82
+ // (destructured/ambient/etc.) falls back the same way — both keep "reexport".
83
+ //
84
+ // Shared by extractCode (extract/code.ts) AND the standalone extractSymbols
85
+ // (lang/registry.ts) — ultradoc and other direct extractSymbols consumers hit
86
+ // the same barrels a repo scan does, so both entry points must agree; this is
87
+ // the one place the alias-mirroring logic lives, reused rather than
88
+ // reimplemented on either side.
89
+ export function extractReexports(rel: string, content: string, localSymbols: CodeSymbol[]): CodeSymbol[] {
90
+ if (!REEXPORT_EXTS.has(rel.slice(rel.lastIndexOf(".")))) return [];
91
+ const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
92
+ const out: CodeSymbol[] = [];
93
+ const seen = new Set<string>();
94
+ const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
95
+ const localKindOf = new Map<string, string>();
96
+ for (const s of localSymbols) if (!localKindOf.has(s.name)) localKindOf.set(s.name, s.kind);
97
+
98
+ const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
99
+ let m: RegExpExecArray | null;
100
+ while ((m = named.exec(content)) && out.length < 60) {
101
+ const from = m[2];
102
+ for (const part of m[1]!.split(",")) {
103
+ const p = part.trim().replace(/^type\s+/, "");
104
+ const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
105
+ const orig = as ? as[1]! : p;
106
+ const name = as ? as[2]! : p;
107
+ if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
108
+ seen.add(name);
109
+ const mirroredKind = !from ? localKindOf.get(orig) : undefined;
110
+ out.push({
111
+ name, kind: mirroredKind ?? "reexport", file: rel, line: lineAt(m.index),
112
+ signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
113
+ exported: true, lang,
114
+ });
115
+ }
116
+ }
117
+
118
+ const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
119
+ while ((m = star.exec(content)) && out.length < 60) {
120
+ const ns = m[1];
121
+ const from = m[2]!;
122
+ const key = "*" + (ns ?? from);
123
+ if (seen.has(key)) continue;
124
+ seen.add(key);
125
+ out.push({
126
+ name: ns ?? `* (${from})`, kind: ns ? "reexport" : "reexport-all", file: rel,
127
+ line: lineAt(m.index), signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
128
+ exported: true, lang,
129
+ });
130
+ }
131
+ return out;
132
+ }
@@ -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
@@ -28,6 +28,7 @@ import { checkRules, parseRules } from "./rules.js";
28
28
  import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
29
29
  import { buildEmbeddingIndex } from "./embed/index.js";
30
30
  import { searchSemantic } from "./embed/search.js";
31
+ import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
31
32
 
32
33
  interface RpcRequest {
33
34
  jsonrpc: "2.0";
@@ -285,7 +286,7 @@ const TOOLS = [
285
286
  semantic: {
286
287
  type: "boolean",
287
288
  description:
288
- "RRF-fuse the deterministic static-embedding tier with lexical when a model asset is present (default false; silently lexical-only when no model)",
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.",
289
290
  },
290
291
  },
291
292
  required: ["repo", "query"],
@@ -294,7 +295,7 @@ const TOOLS = [
294
295
  {
295
296
  name: "embed_status",
296
297
  description:
297
- "Report the deterministic static-embedding tier: whether a model asset is resolved (opt-in, never shipped in the package), its modelId/dim, EMBED_VERSION, and any configured HTTP endpoint. Use to check whether `search` with semantic:true will actually fuse embeddings or degrade to lexical.",
298
+ "Report the embedding tier: the effective mode (none/static/endpoint; endpoint > static model), the resolved model (opt-in, never shipped in the package) with its modelId/dim, EMBED_VERSION, and the configured HTTP endpoint with its reachability. Use to check whether `search` with semantic:true will fuse embeddings or degrade to lexical.",
298
299
  inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
299
300
  },
300
301
  {
@@ -320,7 +321,7 @@ function strArray(v: unknown): string[] | undefined {
320
321
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
321
322
  }
322
323
 
323
- function callTool(name: string, args: Record<string, unknown>): string {
324
+ async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
324
325
  const repo = str(args.repo);
325
326
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
326
327
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
@@ -458,6 +459,18 @@ function callTool(name: string, args: Record<string, unknown>): string {
458
459
  const limit = typeof args.limit === "number" ? args.limit : undefined;
459
460
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
460
461
  if (args.semantic === true) {
462
+ const endpoint = resolveEmbedEndpoint();
463
+ if (endpoint) {
464
+ // Rich tier — endpoint takes PRECEDENCE over a local static model. An
465
+ // unreachable/malformed endpoint degrades to lexical (no throw).
466
+ try {
467
+ const index = await buildEndpointIndex(scan);
468
+ 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);
472
+ }
473
+ }
461
474
  const modelDir = resolveEmbedModelDir(repo);
462
475
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
463
476
  if (model) {
@@ -471,17 +484,18 @@ function callTool(name: string, args: Record<string, unknown>): string {
471
484
  if (name === "embed_status") {
472
485
  const modelDir = resolveEmbedModelDir(repo);
473
486
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
474
- return JSON.stringify(
475
- {
476
- embedVersion: EMBED_VERSION,
477
- model: model
478
- ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
479
- : { present: false },
480
- endpoint: process.env.CODEINDEX_EMBED_ENDPOINT ?? null,
481
- },
482
- null,
483
- 2,
484
- );
487
+ const endpoint = resolveEmbedEndpoint();
488
+ const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
489
+ const status: Record<string, unknown> = {
490
+ embedVersion: EMBED_VERSION,
491
+ mode,
492
+ model: model
493
+ ? { present: true, dir: modelDir, modelId: model.modelId, dim: model.dim, vocabSize: model.vocabSize }
494
+ : { present: false },
495
+ endpoint: endpoint ?? null,
496
+ };
497
+ if (endpoint) status.endpointReachable = await probeEndpoint(endpoint);
498
+ return JSON.stringify(status, null, 2);
485
499
  }
486
500
  if (name === "check_rules") {
487
501
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
@@ -512,10 +526,10 @@ export async function runMcpServer(): Promise<void> {
512
526
  // JSON-RPC 2.0 batch: answer each member (a batching client would
513
527
  // otherwise hang forever on a silently dropped array).
514
528
  const requests = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
515
- for (const req of requests) handle(req);
529
+ for (const req of requests) await handle(req);
516
530
  }
517
531
 
518
- function handle(req: RpcRequest): void {
532
+ async function handle(req: RpcRequest): Promise<void> {
519
533
  if (req.id === undefined || req.id === null) return; // notification — no response
520
534
 
521
535
  try {
@@ -537,7 +551,7 @@ export async function runMcpServer(): Promise<void> {
537
551
  const name = str(params.name) ?? "";
538
552
  const args = (params.arguments ?? {}) as Record<string, unknown>;
539
553
  try {
540
- const text = callTool(name, args);
554
+ const text = await callTool(name, args);
541
555
  send({ id: req.id, result: { content: [{ type: "text", text }] } });
542
556
  } catch (e) {
543
557
  send({
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.10.0";
4
+ export const ENGINE_VERSION = "2.11.1";
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
@@ -24,8 +24,15 @@ export const SCHEMA_VERSION = 4;
24
24
  // (the immediate receiver of a qualified call, both tiers) and JS/TS export
25
25
  // parity with ultradoc (CJS `exports.foo =` / `module.exports = {…}` named
26
26
  // exports, `export { a, b as c }` local marking, anonymous `export default`
27
- // named after the file stem, `export default Foo` marking the declaration).
28
- export const EXTRACTOR_VERSION = 6;
27
+ // named after the file stem, `export default Foo` marking the declaration);
28
+ // v7 makes an export-alias symbol (`export { b as c }`) mirror the aliased
29
+ // local declaration's own kind (e.g. "function") instead of the generic
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 call candidates whose name+line match one
34
+ // of the file's own extracted symbols.
35
+ export const EXTRACTOR_VERSION = 8;
29
36
 
30
37
  // How a file is classified. `code` gets symbol/import extraction; `doc` gets
31
38
  // link/heading extraction; the rest are catalogued but not deeply parsed.