@maxgfr/codeindex 2.11.1 → 2.13.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,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
 
@@ -20,12 +21,24 @@ import { join } from "node:path";
20
21
  // graph.json / symbols.json consumers.
21
22
  export const EMBED_VERSION = 1;
22
23
 
23
- // The default model-name a `pull` writes and a `status` reports. The real asset
24
- // URL is intentionally NOT hard-coded yet (see resolveEmbedPullUrl): the asset
25
- // is unpublished, so `pull` fails cleanly asking for CODEINDEX_EMBED_URL rather
26
- // than fetching a phantom.
24
+ // The default sub-directory a `pull` writes into and a `status` reports.
27
25
  export const DEFAULT_EMBED_DIRNAME = "models";
28
26
 
27
+ // The official static-embedding asset, published as the dedicated GitHub release
28
+ // `embed-model-v1` (tag deliberately outside the `v<semver>` semantic-release
29
+ // namespace, so publishing it triggers no CI). `pull` uses this when
30
+ // CODEINDEX_EMBED_URL is unset. Reproduce the asset byte-for-byte with the pinned
31
+ // toolchain in scripts/embed-asset/.
32
+ export const DEFAULT_EMBED_URL =
33
+ "https://github.com/maxgfr/codeindex/releases/download/embed-model-v1/model.json";
34
+
35
+ // sha256 of the published model.json. `pull` verifies the downloaded bytes
36
+ // against this ONLY when it fetched the built-in default (a custom
37
+ // CODEINDEX_EMBED_URL is the user's own mirror and keeps the un-verified
38
+ // behavior). Regenerate alongside DEFAULT_EMBED_URL when re-cutting the asset:
39
+ // `shasum -a 256 model.json`.
40
+ export const EMBED_ASSET_SHA256 = "163ad053eab4e9a80d421ed4164f32292c83290f02fbbe6fe4b9b1cd6ea18d34";
41
+
29
42
  // A loaded static-embedding model. `vocab` maps a wordpiece token to its row
30
43
  // index; `weights` is the flat row-major (vocabSize × dim) matrix as IEEE-754
31
44
  // doubles (float rows widen to double exactly, so mean-pool stays deterministic).
@@ -74,20 +87,17 @@ export function hasEmbedModel(repo?: string): boolean {
74
87
  return resolveEmbedModelDir(repo) !== undefined;
75
88
  }
76
89
 
77
- // Load and validate a static model from a directory containing model.json.
78
- // Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
79
- // loudly at load rather than silently misranking. Returns undefined only when
80
- // the directory has no model.json (the not-present case).
81
- export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
82
- if (!dir) return undefined;
83
- const path = join(dir, "model.json");
84
- if (!existsSync(path)) return undefined;
85
- const raw = JSON.parse(readFileSync(path, "utf8")) as ModelFile;
86
- const { modelId, dim, vocab, weights } = raw;
87
- if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
88
- if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
90
+ // Validate a parsed model.json body and construct the loaded model. Throws on
91
+ // a malformed shape (missing modelId, bad dim, vocab/weights mismatch, ragged
92
+ // rows) with a message citing `source` (a file path, a URL, …) so a corrupt
93
+ // asset fails loudly wherever it enters at load, or at `embed pull` BEFORE
94
+ // the shape-invalid file is ever written to disk.
95
+ export function parseEmbedModel(raw: unknown, source: string): StaticEmbedModel {
96
+ const { modelId, dim, vocab, weights, unk: rawUnk } = (raw ?? {}) as ModelFile;
97
+ if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${source}`);
98
+ if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${source}`);
89
99
  if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
90
- throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
100
+ throw new Error(`embed model: vocab/weights length mismatch in ${source}`);
91
101
  }
92
102
  const vocabSize = vocab.length;
93
103
  const flat = new Float64Array(vocabSize * dim);
@@ -102,16 +112,54 @@ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
102
112
  }
103
113
  for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
104
114
  }
105
- const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
115
+ const unk = typeof rawUnk === "string" ? rawUnk : "[UNK]";
106
116
  const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
107
117
  return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
108
118
  }
109
119
 
110
- // Resolve the URL `embed pull` fetches from. The official asset is not published
111
- // yet, so there is no built-in default: the user must point CODEINDEX_EMBED_URL
112
- // at a model.json (or a directory serving one). Returns undefined when unset —
113
- // the caller turns that into a clean, actionable failure, never a crash.
114
- export function resolveEmbedPullUrl(): string | undefined {
115
- const url = process.env.CODEINDEX_EMBED_URL;
116
- return url && url.trim() ? url.trim() : undefined;
120
+ // Load and validate a static model from a directory containing model.json.
121
+ // Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
122
+ // loudly at load rather than silently misranking. Returns undefined only when
123
+ // the directory has no model.json (the not-present case).
124
+ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
125
+ if (!dir) return undefined;
126
+ const path = join(dir, "model.json");
127
+ if (!existsSync(path)) return undefined;
128
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
129
+ return parseEmbedModel(raw, path);
130
+ }
131
+
132
+ // What `embed pull` fetches, and whether to verify it. CODEINDEX_EMBED_URL wins
133
+ // outright (the user's explicit override / private mirror) and carries NO
134
+ // sha256, so a custom asset keeps the current un-verified behavior. With no env,
135
+ // we fall back to the built-in official asset AND its pinned sha256, so `pull`
136
+ // can prove integrity of the default download.
137
+ export interface EmbedPullTarget {
138
+ url: string;
139
+ sha256?: string; // present only for the built-in default → verify
140
+ }
141
+
142
+ export function resolveEmbedPullUrl(): EmbedPullTarget {
143
+ const env = process.env.CODEINDEX_EMBED_URL;
144
+ if (env && env.trim()) return { url: env.trim() };
145
+ return { url: DEFAULT_EMBED_URL, sha256: EMBED_ASSET_SHA256 };
146
+ }
147
+
148
+ // Fetch a model.json body over HTTP, following redirects (GitHub release assets
149
+ // 302 to a CDN — native fetch follows by default). When `expectedSha256` is
150
+ // given, the downloaded bytes are hashed and MUST match or this throws with a
151
+ // clear message and returns nothing — so a corrupt/tampered default asset never
152
+ // gets written. A non-2xx response also throws. Verification and I/O are split
153
+ // from the CLI so both the success and the sha-mismatch paths are unit-testable.
154
+ export async function fetchEmbedModel(url: string, expectedSha256?: string): Promise<string> {
155
+ const res = await fetch(url);
156
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
157
+ const body = await res.text();
158
+ if (expectedSha256) {
159
+ const got = createHash("sha256").update(body).digest("hex");
160
+ if (got !== expectedSha256) {
161
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${got}`);
162
+ }
163
+ }
164
+ return body;
117
165
  }
package/src/engine-cli.ts CHANGED
@@ -19,7 +19,7 @@ import { symbolComplexity, riskHotspots } from "./complexity.js";
19
19
  import { renderMermaid } from "./viz.js";
20
20
  import { searchIndex } from "./bm25.js";
21
21
  import { checkRules, parseRules } from "./rules.js";
22
- import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, resolveEmbedPullUrl } from "./embed/model.js";
22
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, parseEmbedModel, resolveEmbedPullUrl, fetchEmbedModel } from "./embed/model.js";
23
23
  import { buildEmbeddingIndex, serializeEmbeddings } from "./embed/index.js";
24
24
  import { searchSemantic } from "./embed/search.js";
25
25
  import {
@@ -55,8 +55,9 @@ Commands:
55
55
  embed status Effective mode (none/static/endpoint), model +
56
56
  EMBED_VERSION, and endpoint reachability (JSON)
57
57
  embed build Write embeddings.bin into --out <dir> (static tier)
58
- embed pull Fetch the model asset into CODEINDEX_EMBED_DIR (or
59
- <repo>/.codeindex/models/) needs CODEINDEX_EMBED_URL
58
+ embed pull Fetch the official model asset into CODEINDEX_EMBED_DIR
59
+ (or <repo>/.codeindex/models/); sha256-verified. Override
60
+ the source with CODEINDEX_EMBED_URL
60
61
  embed serve Print (or --run) the docker command that starts the
61
62
  containerized embedding server (rich tier)
62
63
  rules Architecture rules (forbidden edges, cycles, orphans) validated
@@ -79,8 +80,11 @@ Flags:
79
80
  --exclude <glob> Exclude matching paths (repeatable)
80
81
  --scope <dir> Restrict to one directory (sugar for --include '<dir>/**')
81
82
  --no-gitignore Do not honor .gitignore files (default: honored)
83
+ --ignore-dir <name> Directory names to skip (repeatable) — REPLACES the
84
+ default ignored-directory set, never merges with it
82
85
  --max-files <n> Cap walked files (default 20000)
83
86
  --max-bytes <n> Skip files above this size (default 1 MiB)
87
+ --max-calls <n> Per-file call-site cap for extraction (default 512)
84
88
  --no-ast Skip tree-sitter grammars even when present (regex tier)
85
89
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
86
90
  --limit <n> Max results for \`search\` (default 20)
@@ -102,8 +106,10 @@ interface CliFlags {
102
106
  exclude: string[];
103
107
  scope?: string;
104
108
  gitignore: boolean;
109
+ ignoreDirs: string[];
105
110
  maxFiles?: number;
106
111
  maxBytes?: number;
112
+ maxCalls?: number;
107
113
  noAst: boolean;
108
114
  since?: string;
109
115
  ignoreCase?: boolean;
@@ -120,7 +126,7 @@ interface CliFlags {
120
126
  }
121
127
 
122
128
  function parseFlags(args: string[]): CliFlags {
123
- const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
129
+ const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
124
130
  for (let i = 0; i < args.length; i++) {
125
131
  const a = args[i]!;
126
132
  const next = (): string => {
@@ -143,8 +149,10 @@ function parseFlags(args: string[]): CliFlags {
143
149
  else if (a === "--exclude") flags.exclude.push(next());
144
150
  else if (a === "--scope") flags.scope = next();
145
151
  else if (a === "--no-gitignore") flags.gitignore = false;
152
+ else if (a === "--ignore-dir") flags.ignoreDirs.push(next());
146
153
  else if (a === "--max-files") flags.maxFiles = num();
147
154
  else if (a === "--max-bytes") flags.maxBytes = num();
155
+ else if (a === "--max-calls") flags.maxCalls = num();
148
156
  else if (a === "--ignore-case") flags.ignoreCase = true;
149
157
  else if (a === "--max-hits") flags.maxHits = num();
150
158
  else if (a === "--budget-tokens") flags.budgetTokens = num();
@@ -173,8 +181,10 @@ function scanOptions(flags: CliFlags): BuildIndexOptions {
173
181
  exclude: flags.exclude.length ? flags.exclude : undefined,
174
182
  scope: flags.scope,
175
183
  gitignore: flags.gitignore,
184
+ ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
176
185
  maxFiles: flags.maxFiles,
177
186
  maxBytes: flags.maxBytes,
187
+ maxCallsPerFile: flags.maxCalls,
178
188
  };
179
189
  }
180
190
 
@@ -387,30 +397,30 @@ export async function runCli(argv: string[]): Promise<void> {
387
397
  writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
388
398
  process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
389
399
  } else if (sub === "pull") {
390
- const url = resolveEmbedPullUrl();
391
- if (!url) {
392
- // Clean, actionable failure the official asset is not published yet.
393
- process.stderr.write(
394
- "codeindex: no model URL configured. The official static-embedding asset is not published yet.\n" +
395
- "Set CODEINDEX_EMBED_URL to a model.json URL (optionally CODEINDEX_EMBED_DIR as the destination), then re-run `codeindex embed pull`.\n",
396
- );
397
- process.exitCode = 1;
398
- return;
399
- }
400
+ // Default: the official published asset + its pinned sha256. A user-set
401
+ // CODEINDEX_EMBED_URL overrides both (mirror/custom model, no verification).
402
+ const { url, sha256 } = resolveEmbedPullUrl();
400
403
  const destDir = process.env.CODEINDEX_EMBED_DIR ?? join(flags.repo, ".codeindex", "models");
401
404
  mkdirSync(destDir, { recursive: true });
402
405
  process.stderr.write(`codeindex: fetching model from ${url} → ${join(destDir, "model.json")}\n`);
403
- const res = await fetch(url);
404
- if (!res.ok) {
405
- process.stderr.write(`codeindex: pull failed HTTP ${res.status} from ${url}\n`);
406
+ let body: string;
407
+ try {
408
+ // Follows redirects (GitHub CDN) and verifies sha256 for the default asset.
409
+ body = await fetchEmbedModel(url, sha256);
410
+ } catch (e) {
411
+ process.stderr.write(`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`);
406
412
  process.exitCode = 1;
407
413
  return;
408
414
  }
409
- const body = await res.text();
410
415
  try {
411
- JSON.parse(body);
412
- } catch {
413
- process.stderr.write("codeindex: pull failed response is not a valid model.json (expected JSON)\n");
416
+ // Shape-validate BEFORE writing: a JSON-valid but shape-invalid asset
417
+ // would otherwise land on disk and turn every later semantic search
418
+ // into a hard loadEmbedModel error instead of the documented degrade.
419
+ parseEmbedModel(JSON.parse(body), url);
420
+ } catch (e) {
421
+ process.stderr.write(
422
+ `codeindex: pull failed — response is not a valid model.json (${e instanceof Error ? e.message : String(e)}) (nothing written)\n`,
423
+ );
414
424
  process.exitCode = 1;
415
425
  return;
416
426
  }
package/src/engine.ts CHANGED
@@ -55,7 +55,7 @@ export { buildModules, isTestFile, tierForPath } from "./modules.js";
55
55
  export type { ModuleInfo } from "./modules.js";
56
56
  export { buildGraph, uniqueSymbolDefs } from "./graph.js";
57
57
  export { resolveCallEdges } from "./calls.js";
58
- export { buildCallerIndex, enclosingSymbol, computeImportPairs } from "./callers.js";
58
+ export { buildCallerIndex, buildRawCallerIndex, enclosingSymbol, computeImportPairs } from "./callers.js";
59
59
  export { symbolsOverview, findSymbol, findReferences } from "./query.js";
60
60
  export { resolveUniqueSymbol, replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit.js";
61
61
  export type { EditResult } from "./edit.js";
@@ -114,7 +114,7 @@ export {
114
114
  loadEmbedModel,
115
115
  resolveEmbedPullUrl,
116
116
  } from "./embed/model.js";
117
- export type { StaticEmbedModel } from "./embed/model.js";
117
+ export type { StaticEmbedModel, EmbedPullTarget } from "./embed/model.js";
118
118
  export { encode, quantize, tokenize, wordpiece, basicTokenize, roundHalfToEven, intDot } from "./embed/encode.js";
119
119
  export { buildEmbeddingIndex, serializeEmbeddings, deserializeEmbeddings, embeddingUnits } from "./embed/index.js";
120
120
  export type { EmbeddingIndex, EmbeddingRecord, EmbeddingUnit } from "./embed/index.js";
@@ -143,6 +143,10 @@ export type { ArchRule, ForbiddenEdgeRule, BuiltinRule, RuleSeverity, RuleViolat
143
143
  // Caller-index recall mode (issue #7) — options for buildCallerIndex above.
144
144
  export type { CallerIndexOptions } from "./callers.js";
145
145
 
146
+ // Raw-recall caller index (issue #8) — ungated, def-resolution-free companion
147
+ // to buildCallerIndex above; see its JSDoc in callers.ts for the contract.
148
+ export type { RawCallerIndex, RawCallerSite } from "./callers.js";
149
+
146
150
  // Behavioral analytics (git-history mining) + the token-budgeted repo map.
147
151
  export { changeCoupling, rankHotspots } from "./coupling.js";
148
152
  export type { ChangeCoupling, CouplingOptions, Hotspot } from "./coupling.js";
@@ -302,12 +302,13 @@ const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bm
302
302
  export function collectCallsRegex(
303
303
  content: string,
304
304
  symbols: Pick<CodeSymbol, "name" | "line">[] = [],
305
+ maxCalls: number = 512,
305
306
  ): { name: string; line: number; receiver?: string }[] {
306
307
  const out = new Map<string, { name: string; line: number; receiver?: string }>();
307
308
  const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
308
309
  const lines = content.split("\n");
309
310
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
310
- for (let i = 0; i < lines.length && out.size < 512; i++) {
311
+ for (let i = 0; i < lines.length && out.size < maxCalls; i++) {
311
312
  const line = lines[i]!;
312
313
  // Cheap comment guard: a line-leading comment marker means no calls here
313
314
  // (block-comment interiors and strings stay best-effort, like the symbol
@@ -332,7 +333,7 @@ export function collectCallsRegex(
332
333
  CALL_RE.lastIndex = 0;
333
334
  let m: RegExpExecArray | null;
334
335
  const fallbackExcluded = new Set<string>();
335
- while ((m = CALL_RE.exec(line)) !== null && out.size < 512) {
336
+ while ((m = CALL_RE.exec(line)) !== null && out.size < maxCalls) {
336
337
  const receiver = m[1];
337
338
  const name = m[2]!;
338
339
  if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
@@ -350,13 +351,16 @@ export function collectCallsRegex(
350
351
  return [...out.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.line - b.line));
351
352
  }
352
353
 
353
- export function extractCode(rel: string, ext: string, content: string): CodeInfo {
354
+ // `opts.maxCallsPerFile` overrides the per-file call-site cap (default 512) on
355
+ // BOTH extraction tiers — AST and regex — so recall-oriented consumers can raise
356
+ // it. Dedup/sort semantics are unchanged; absent, output is byte-identical.
357
+ export function extractCode(rel: string, ext: string, content: string, opts: { maxCallsPerFile?: number } = {}): CodeInfo {
354
358
  // Symbols come from tree-sitter when a grammar is loaded for this extension
355
359
  // (AST-exact: real nesting, precise kinds, structural export), else the regex
356
360
  // extractors. Imports/pkg stay on the battle-tested regex path here — their
357
361
  // resolution is covered by resolve tests and the e2e ratchet; the new-language
358
362
  // AST importers land with their resolvers.
359
- const ast = extractAst(rel, ext, content);
363
+ const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile });
360
364
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
361
365
  // Add barrel re-exports the local def didn't already cover.
362
366
  const known = new Set(symbols.map((s) => s.name));
@@ -378,7 +382,7 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
378
382
  // collector otherwise, so caller indexes exist without the wasm sidecar.
379
383
  // `symbols` (this file's own regex-extracted defs) lets the collector
380
384
  // exclude a definition's own name+line from its call candidates.
381
- calls: ast ? ast.calls : collectCallsRegex(content, symbols),
385
+ calls: ast ? ast.calls : collectCallsRegex(content, symbols, opts.maxCallsPerFile),
382
386
  importedNames: ast?.importedNames,
383
387
  };
384
388
  }
@@ -75,11 +75,14 @@ const REEXPORT_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".m
75
75
  //
76
76
  // An ALIAS with no `from` clause (`export { b as c }`) renames an in-file
77
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".
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.
80
82
  // A true cross-module re-export (`export { b as c } from "./mod"`) has no
81
83
  // 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".
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.
83
86
  //
84
87
  // Shared by extractCode (extract/code.ts) AND the standalone extractSymbols
85
88
  // (lang/registry.ts) — ultradoc and other direct extractSymbols consumers hit
@@ -92,8 +95,11 @@ export function extractReexports(rel: string, content: string, localSymbols: Cod
92
95
  const out: CodeSymbol[] = [];
93
96
  const seen = new Set<string>();
94
97
  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);
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);
97
103
 
98
104
  const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
99
105
  let m: RegExpExecArray | null;
@@ -106,9 +112,14 @@ export function extractReexports(rel: string, content: string, localSymbols: Cod
106
112
  const name = as ? as[2]! : p;
107
113
  if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
108
114
  seen.add(name);
109
- const mirroredKind = !from ? localKindOf.get(orig) : undefined;
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;
110
120
  out.push({
111
- name, kind: mirroredKind ?? "reexport", file: rel, line: lineAt(m.index),
121
+ name, kind: decl?.kind ?? "reexport", file: rel, line: decl ? decl.line : lineAt(m.index),
122
+ ...(decl?.endLine !== undefined ? { endLine: decl.endLine } : {}),
112
123
  signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
113
124
  exported: true, lang,
114
125
  });
package/src/lang/js-ts.ts CHANGED
@@ -7,7 +7,7 @@ import { scan, type Rule } from "./common.js";
7
7
  const RULES: Rule[] = [
8
8
  { re: /^\s*export\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
9
9
  { re: /^\s*export\s+default\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
10
- { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
10
+ { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?!extends\b)(?<name>[\w$]+)/, kind: "class", exported: true },
11
11
  { re: /^\s*(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: false },
12
12
  { re: /^\s*export\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
13
13
  { re: /^\s*(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: false },
package/src/mcp.ts CHANGED
@@ -5,12 +5,14 @@
5
5
  // `repo` path and returns JSON text content.
6
6
  //
7
7
  // Register in an MCP client as: node scripts/engine.mjs mcp
8
+ import { statSync } from "node:fs";
9
+ import { join } from "node:path";
8
10
  import { createInterface } from "node:readline";
9
11
  import { ENGINE_VERSION } from "./types.js";
10
12
  import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
11
13
  import { buildIndexArtifacts } from "./pipeline.js";
12
14
  import { renderGraphJson } from "./render/graph-json.js";
13
- import { scanRepo } from "./scan.js";
15
+ import { scanRepo, type RepoScan } from "./scan.js";
14
16
  import { buildCallerIndex } from "./callers.js";
15
17
  import { detectWorkspaces } from "./workspaces.js";
16
18
  import { gitChurn } from "./git.js";
@@ -25,10 +27,11 @@ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit
25
27
  import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
26
28
  import { searchIndex } from "./bm25.js";
27
29
  import { checkRules, parseRules } from "./rules.js";
28
- import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
29
- import { buildEmbeddingIndex } from "./embed/index.js";
30
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "./embed/model.js";
31
+ import { buildEmbeddingIndex, type EmbeddingIndex } from "./embed/index.js";
30
32
  import { searchSemantic } from "./embed/search.js";
31
33
  import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
34
+ import { sha1 } from "./hash.js";
32
35
 
33
36
  interface RpcRequest {
34
37
  jsonrpc: "2.0";
@@ -270,7 +273,7 @@ const TOOLS = [
270
273
  {
271
274
  name: "search",
272
275
  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).',
276
+ '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
277
  inputSchema: {
275
278
  type: "object",
276
279
  properties: {
@@ -286,7 +289,7 @@ const TOOLS = [
286
289
  semantic: {
287
290
  type: "boolean",
288
291
  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.",
292
+ '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
293
  },
291
294
  },
292
295
  required: ["repo", "query"],
@@ -320,6 +323,75 @@ function str(v: unknown): string | undefined {
320
323
  function strArray(v: unknown): string[] | undefined {
321
324
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
322
325
  }
326
+ function errMessage(e: unknown): string {
327
+ return e instanceof Error ? e.message : String(e);
328
+ }
329
+
330
+ // --- embedding index memoization --------------------------------------------
331
+ // The MCP server process is long-lived, but every `search` call used to redo
332
+ // the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
333
+ // or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
334
+ // repo changed between requests. Memoize the last build behind a fingerprint
335
+ // of the scan contents plus the tier's identity, so an unchanged repo reuses
336
+ // the cached index and any file add/edit/remove (or a switch of endpoint/model)
337
+ // still rebuilds. RepoScan carries no fingerprint of its own (checked
338
+ // scan.ts/types.ts) — every FileRecord already carries a content hash, so
339
+ // hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
340
+ export function scanFingerprint(scan: RepoScan): string {
341
+ return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
342
+ }
343
+
344
+ export interface EmbeddingIndexCacheKey {
345
+ mode: "endpoint" | "static";
346
+ // Distinguishes cache entries across configs sharing the same scan: the
347
+ // endpoint URL, or the model dir + modelId for the static tier.
348
+ identity: string;
349
+ scan: RepoScan;
350
+ }
351
+
352
+ // A SINGLE entry — never an unbounded map — holding the most recent build.
353
+ let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
354
+
355
+ // Reuse the cached index when (mode, identity, scanFingerprint) matches the
356
+ // last build; otherwise call `build` and cache its result. A failed build is
357
+ // NEVER cached (matches today's per-call error behavior: the next request
358
+ // retries from scratch, and a still-valid previous entry — under a different
359
+ // key — is left untouched).
360
+ export async function memoizedEmbeddingIndex(
361
+ key: EmbeddingIndexCacheKey,
362
+ build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
363
+ ): Promise<EmbeddingIndex> {
364
+ const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
365
+ if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
366
+ const index = await build();
367
+ embeddingIndexCache = { key: cacheKey, index };
368
+ return index;
369
+ }
370
+
371
+ // A SINGLE entry — never an unbounded map — holding the most recent parse.
372
+ let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
373
+
374
+ // model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
375
+ // EVERY request dominates static-tier latency, so the parsed model is memoized
376
+ // across requests. One statSync per request keys the cache on
377
+ // (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
378
+ // Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
379
+ // the throw propagates and the cache is left as it was, so the next request
380
+ // retries from scratch. A missing model.json returns undefined (the
381
+ // not-present case, exactly like loadEmbedModel).
382
+ export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
383
+ let stat;
384
+ try {
385
+ stat = statSync(join(modelDir, "model.json"));
386
+ } catch {
387
+ return undefined;
388
+ }
389
+ const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
390
+ if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
391
+ const model = loadEmbedModel(modelDir);
392
+ if (model) embedModelCache = { key, model };
393
+ return model;
394
+ }
323
395
 
324
396
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
325
397
  const repo = str(args.repo);
@@ -459,31 +531,55 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
459
531
  const limit = typeof args.limit === "number" ? args.limit : undefined;
460
532
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
461
533
  if (args.semantic === true) {
534
+ // semantic:true changes the response SHAPE (wraps the ranked list with a
535
+ // `tier`/`degradedReason?`) so a caller can tell "fusion happened" apart
536
+ // from "degraded to lexical" — see the `search` tool description. This
537
+ // branch is the ONLY place that shape appears; plain lexical search below
538
+ // stays the bare array, byte-compat for existing consumers.
462
539
  const endpoint = resolveEmbedEndpoint();
463
540
  if (endpoint) {
464
541
  // Rich tier — endpoint takes PRECEDENCE over a local static model. An
465
- // unreachable/malformed endpoint degrades to lexical (no throw).
542
+ // unreachable/malformed endpoint degrades to lexical, now with a reason.
543
+ // The corpus index is memoized per (endpoint, scan state) — the query
544
+ // itself is always re-encoded fresh (it differs per call).
466
545
  try {
467
- const index = await buildEndpointIndex(scan);
546
+ const index = await memoizedEmbeddingIndex({ mode: "endpoint", identity: endpoint, scan }, () => buildEndpointIndex(scan));
468
547
  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);
548
+ const results = searchSemantic(scan, query, index, { queryVec, limit, fuzzy });
549
+ return JSON.stringify({ results, tier: "endpoint" }, null, 2);
550
+ } catch (e) {
551
+ const results = searchIndex(scan, query, { limit, fuzzy });
552
+ return JSON.stringify(
553
+ { results, tier: "lexical", degradedReason: `embedding endpoint failed: ${errMessage(e)}` },
554
+ null,
555
+ 2,
556
+ );
472
557
  }
473
558
  }
474
559
  const modelDir = resolveEmbedModelDir(repo);
475
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
560
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
476
561
  if (model) {
477
- const index = buildEmbeddingIndex(scan, model);
478
- return JSON.stringify(searchSemantic(scan, query, index, { model, limit, fuzzy }), null, 2);
562
+ const index = await memoizedEmbeddingIndex(
563
+ { mode: "static", identity: `${modelDir}#${model.modelId}`, scan },
564
+ () => buildEmbeddingIndex(scan, model),
565
+ );
566
+ const results = searchSemantic(scan, query, index, { model, limit, fuzzy });
567
+ return JSON.stringify({ results, tier: "static" }, null, 2);
479
568
  }
480
- // Degrade to lexical (opt-in tier not activated) — no throw.
569
+ // Opt-in tier not activated (no endpoint, no model asset) — degrade to
570
+ // lexical with a reason instead of failing silently.
571
+ const results = searchIndex(scan, query, { limit, fuzzy });
572
+ return JSON.stringify(
573
+ { results, tier: "lexical", degradedReason: "no embedding endpoint or static model configured — see embed_status" },
574
+ null,
575
+ 2,
576
+ );
481
577
  }
482
578
  return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
483
579
  }
484
580
  if (name === "embed_status") {
485
581
  const modelDir = resolveEmbedModelDir(repo);
486
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
582
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
487
583
  const endpoint = resolveEmbedEndpoint();
488
584
  const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
489
585
  const status: Record<string, unknown> = {
package/src/scan.ts CHANGED
@@ -36,8 +36,15 @@ export interface ScanOptions {
36
36
  scope?: string;
37
37
  // Honor .gitignore files (default true — see WalkOptions.gitignore).
38
38
  gitignore?: boolean;
39
+ // Directory names to skip — REPLACES the default set (see
40
+ // WalkOptions.ignoreDirs; compose with the IGNORE_DIRS export to extend it).
41
+ ignoreDirs?: string[];
39
42
  maxBytes?: number;
40
43
  maxFiles?: number;
44
+ // Per-file call-site cap for extraction (default 512, both AST and regex
45
+ // tiers). Raising it trades index size for call-graph recall; dedup/sort
46
+ // semantics are unchanged. Absent, output is byte-identical to before.
47
+ maxCallsPerFile?: number;
41
48
  out?: string; // absolute output dir to exclude from the scan (self-index guard)
42
49
  // Previous build's extraction cache (rel → {hash, record, size?, mtimeMs?}). A
43
50
  // file whose (size,mtime) key matches skips read+hash entirely (the stat
@@ -65,6 +72,7 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
65
72
  maxFileBytes: opts.maxBytes,
66
73
  maxFiles: opts.maxFiles,
67
74
  gitignore: opts.gitignore,
75
+ ignoreDirs: opts.ignoreDirs,
68
76
  });
69
77
  // Never index our own output (e.g. a committed `docs/ultraindex/`), or builds
70
78
  // would describe the encyclopedia instead of the code.
@@ -144,7 +152,7 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
144
152
  // Non-markdown prose (.rst/.txt): title from basename, no link graph.
145
153
  record.title = basename(f.rel);
146
154
  } else if (kind === "code") {
147
- const code = extractCode(f.rel, f.ext, content);
155
+ const code = extractCode(f.rel, f.ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
148
156
  record.title = basename(f.rel);
149
157
  record.summary = code.summary;
150
158
  record.symbols = code.symbols;