@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.
@@ -14,9 +14,9 @@ var ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION;
14
14
  var init_types = __esm({
15
15
  "src/types.ts"() {
16
16
  "use strict";
17
- ENGINE_VERSION = "2.11.0";
17
+ ENGINE_VERSION = "2.12.0";
18
18
  SCHEMA_VERSION = 4;
19
- EXTRACTOR_VERSION = 7;
19
+ EXTRACTOR_VERSION = 9;
20
20
  }
21
21
  });
22
22
 
@@ -731,7 +731,58 @@ function scan(rel, content, lang, rules) {
731
731
  function extToLang(ext) {
732
732
  return EXT_LANG[ext] ?? "other";
733
733
  }
734
- var EXT_LANG;
734
+ function extractReexports(rel, content, localSymbols) {
735
+ if (!REEXPORT_EXTS.has(rel.slice(rel.lastIndexOf(".")))) return [];
736
+ const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
737
+ const out2 = [];
738
+ const seen = /* @__PURE__ */ new Set();
739
+ const lineAt = (idx) => content.slice(0, idx).split(/\r?\n/).length;
740
+ const localDeclOf = /* @__PURE__ */ new Map();
741
+ for (const s of localSymbols) if (!localDeclOf.has(s.name)) localDeclOf.set(s.name, s);
742
+ const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
743
+ let m;
744
+ while ((m = named.exec(content)) && out2.length < 60) {
745
+ const from = m[2];
746
+ for (const part of m[1].split(",")) {
747
+ const p = part.trim().replace(/^type\s+/, "");
748
+ const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
749
+ const orig = as ? as[1] : p;
750
+ const name2 = as ? as[2] : p;
751
+ if (!/^[A-Za-z_$][\w$]*$/.test(name2) || name2 === "default" || seen.has(name2)) continue;
752
+ seen.add(name2);
753
+ const decl = !from ? localDeclOf.get(orig) : void 0;
754
+ out2.push({
755
+ name: name2,
756
+ kind: decl?.kind ?? "reexport",
757
+ file: rel,
758
+ line: decl ? decl.line : lineAt(m.index),
759
+ ...decl?.endLine !== void 0 ? { endLine: decl.endLine } : {},
760
+ signature: from ? `export { ${name2} } from "${from}"` : `export { ${name2} }`,
761
+ exported: true,
762
+ lang
763
+ });
764
+ }
765
+ }
766
+ const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
767
+ while ((m = star.exec(content)) && out2.length < 60) {
768
+ const ns = m[1];
769
+ const from = m[2];
770
+ const key = "*" + (ns ?? from);
771
+ if (seen.has(key)) continue;
772
+ seen.add(key);
773
+ out2.push({
774
+ name: ns ?? `* (${from})`,
775
+ kind: ns ? "reexport" : "reexport-all",
776
+ file: rel,
777
+ line: lineAt(m.index),
778
+ signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
779
+ exported: true,
780
+ lang
781
+ });
782
+ }
783
+ return out2;
784
+ }
785
+ var EXT_LANG, REEXPORT_EXTS;
735
786
  var init_common = __esm({
736
787
  "src/lang/common.ts"() {
737
788
  "use strict";
@@ -799,6 +850,7 @@ var init_common = __esm({
799
850
  ".svelte": "svelte",
800
851
  ".astro": "astro"
801
852
  };
853
+ REEXPORT_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
802
854
  }
803
855
  });
804
856
 
@@ -1231,12 +1283,18 @@ var init_scala = __esm({
1231
1283
  // src/lang/registry.ts
1232
1284
  function extractSymbols(rel, ext, content) {
1233
1285
  const extractor = BY_EXT.get(ext);
1234
- if (!extractor) return [];
1235
- try {
1236
- return extractor.extract(rel, content);
1237
- } catch {
1238
- return [];
1286
+ let symbols;
1287
+ if (!extractor) symbols = [];
1288
+ else {
1289
+ try {
1290
+ symbols = extractor.extract(rel, content);
1291
+ } catch {
1292
+ symbols = [];
1293
+ }
1239
1294
  }
1295
+ const known = new Set(symbols.map((s) => s.name));
1296
+ const reexports = extractReexports(rel, content, symbols).filter((s) => !known.has(s.name));
1297
+ return reexports.length ? [...symbols, ...reexports] : symbols;
1240
1298
  }
1241
1299
  function languageOf(ext) {
1242
1300
  return BY_EXT.get(ext)?.lang ?? extToLang(ext);
@@ -6340,58 +6398,9 @@ function extractImports(ext, content) {
6340
6398
  }
6341
6399
  return [...specs].map((spec) => ({ kind: "import", spec }));
6342
6400
  }
6343
- function extractReexports(rel, content, localSymbols) {
6344
- if (!JS_TS.has(rel.slice(rel.lastIndexOf(".")))) return [];
6345
- const lang = /\.(ts|tsx|mts|cts)$/.test(rel) ? "typescript" : "javascript";
6346
- const out2 = [];
6347
- const seen = /* @__PURE__ */ new Set();
6348
- const lineAt = (idx) => content.slice(0, idx).split(/\r?\n/).length;
6349
- const localKindOf = /* @__PURE__ */ new Map();
6350
- for (const s of localSymbols) if (!localKindOf.has(s.name)) localKindOf.set(s.name, s.kind);
6351
- const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
6352
- let m;
6353
- while ((m = named.exec(content)) && out2.length < 60) {
6354
- const from = m[2];
6355
- for (const part of m[1].split(",")) {
6356
- const p = part.trim().replace(/^type\s+/, "");
6357
- const as = /^(\S+)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(p);
6358
- const orig = as ? as[1] : p;
6359
- const name2 = as ? as[2] : p;
6360
- if (!/^[A-Za-z_$][\w$]*$/.test(name2) || name2 === "default" || seen.has(name2)) continue;
6361
- seen.add(name2);
6362
- const mirroredKind = !from ? localKindOf.get(orig) : void 0;
6363
- out2.push({
6364
- name: name2,
6365
- kind: mirroredKind ?? "reexport",
6366
- file: rel,
6367
- line: lineAt(m.index),
6368
- signature: from ? `export { ${name2} } from "${from}"` : `export { ${name2} }`,
6369
- exported: true,
6370
- lang
6371
- });
6372
- }
6373
- }
6374
- const star = /export\s*\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s+)?from\s*['"]([^'"]+)['"]/g;
6375
- while ((m = star.exec(content)) && out2.length < 60) {
6376
- const ns = m[1];
6377
- const from = m[2];
6378
- const key = "*" + (ns ?? from);
6379
- if (seen.has(key)) continue;
6380
- seen.add(key);
6381
- out2.push({
6382
- name: ns ?? `* (${from})`,
6383
- kind: ns ? "reexport" : "reexport-all",
6384
- file: rel,
6385
- line: lineAt(m.index),
6386
- signature: `export * ${ns ? `as ${ns} ` : ""}from "${from}"`,
6387
- exported: true,
6388
- lang
6389
- });
6390
- }
6391
- return out2;
6392
- }
6393
- function collectCallsRegex(content) {
6401
+ function collectCallsRegex(content, symbols = []) {
6394
6402
  const out2 = /* @__PURE__ */ new Map();
6403
+ const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
6395
6404
  const lines = content.split("\n");
6396
6405
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
6397
6406
  for (let i2 = 0; i2 < lines.length && out2.size < 512; i2++) {
@@ -6399,13 +6408,28 @@ function collectCallsRegex(content) {
6399
6408
  const trimmed = line.trimStart();
6400
6409
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) continue;
6401
6410
  CALL_RE.lastIndex = 0;
6411
+ let probe;
6412
+ const introducerCaught = /* @__PURE__ */ new Set();
6413
+ while ((probe = CALL_RE.exec(line)) !== null) {
6414
+ const name2 = probe[2];
6415
+ const key = `${name2} ${i2 + 1}`;
6416
+ if (ownDefLines.has(key) && DEF_INTRODUCERS.test(line.slice(0, probe.index))) introducerCaught.add(key);
6417
+ }
6418
+ CALL_RE.lastIndex = 0;
6402
6419
  let m;
6420
+ const fallbackExcluded = /* @__PURE__ */ new Set();
6403
6421
  while ((m = CALL_RE.exec(line)) !== null && out2.size < 512) {
6404
6422
  const receiver = m[1];
6405
6423
  const name2 = m[2];
6406
6424
  if (name2.length < 2 || CALL_KEYWORDS.has(name2)) continue;
6407
6425
  if (DEF_INTRODUCERS.test(line.slice(0, m.index))) continue;
6408
6426
  const key = `${name2} ${i2 + 1}`;
6427
+ if (ownDefLines.has(key) && !introducerCaught.has(key)) {
6428
+ if (!fallbackExcluded.has(key)) {
6429
+ fallbackExcluded.add(key);
6430
+ continue;
6431
+ }
6432
+ }
6409
6433
  if (!out2.has(key)) out2.set(key, receiver ? { name: name2, line: i2 + 1, receiver } : { name: name2, line: i2 + 1 });
6410
6434
  }
6411
6435
  }
@@ -6426,7 +6450,9 @@ function extractCode(rel, ext, content) {
6426
6450
  idents: ast?.idents,
6427
6451
  // AST call sites when a grammar parsed the file; the conservative regex
6428
6452
  // collector otherwise, so caller indexes exist without the wasm sidecar.
6429
- calls: ast ? ast.calls : collectCallsRegex(content),
6453
+ // `symbols` (this file's own regex-extracted defs) lets the collector
6454
+ // exclude a definition's own name+line from its call candidates.
6455
+ calls: ast ? ast.calls : collectCallsRegex(content, symbols),
6430
6456
  importedNames: ast?.importedNames
6431
6457
  };
6432
6458
  }
@@ -6436,6 +6462,7 @@ var init_code = __esm({
6436
6462
  "use strict";
6437
6463
  init_registry();
6438
6464
  init_extract();
6465
+ init_common();
6439
6466
  JS_TS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
6440
6467
  PY = /* @__PURE__ */ new Set([".py", ".pyi"]);
6441
6468
  C_CPP = /* @__PURE__ */ new Set([".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"]);
@@ -7705,8 +7732,11 @@ function buildCallerIndex(scan2, importPairs, opts = {}) {
7705
7732
  function enclosingSymbol(scan2, file, line) {
7706
7733
  const f = scan2.files.find((x) => x.rel === file);
7707
7734
  if (!f?.symbols.length) return void 0;
7735
+ return enclosingAmong(f.symbols, line);
7736
+ }
7737
+ function enclosingAmong(symbols, line) {
7708
7738
  let best;
7709
- for (const s of f.symbols) {
7739
+ for (const s of symbols) {
7710
7740
  if (REFERENCE_KINDS3.has(s.kind)) continue;
7711
7741
  if (s.line > line) continue;
7712
7742
  if (s.endLine !== void 0 && line > s.endLine) continue;
@@ -7716,6 +7746,29 @@ function enclosingSymbol(scan2, file, line) {
7716
7746
  }
7717
7747
  return best;
7718
7748
  }
7749
+ function buildRawCallerIndex(scan2) {
7750
+ const byName = /* @__PURE__ */ new Map();
7751
+ for (const f of scan2.files) {
7752
+ if (!f.calls?.length) continue;
7753
+ const symbols = f.symbols.filter((s) => !REFERENCE_KINDS3.has(s.kind));
7754
+ for (const c2 of f.calls) {
7755
+ const site = { file: f.rel, line: c2.line };
7756
+ if (c2.receiver !== void 0) site.receiver = c2.receiver;
7757
+ const enc = enclosingAmong(symbols, c2.line);
7758
+ if (enc) site.enclosingSymbol = enc;
7759
+ let arr = byName.get(c2.name);
7760
+ if (!arr) byName.set(c2.name, arr = []);
7761
+ arr.push(site);
7762
+ }
7763
+ }
7764
+ const index = /* @__PURE__ */ new Map();
7765
+ for (const name2 of [...byName.keys()].sort(byStr)) {
7766
+ const sites = byName.get(name2);
7767
+ sites.sort((a, b) => byStr(a.file, b.file) || a.line - b.line);
7768
+ index.set(name2, sites);
7769
+ }
7770
+ return index;
7771
+ }
7719
7772
  var REFERENCE_KINDS3;
7720
7773
  var init_callers = __esm({
7721
7774
  "src/callers.ts"() {
@@ -9348,6 +9401,7 @@ var init_bm25 = __esm({
9348
9401
  });
9349
9402
 
9350
9403
  // src/embed/model.ts
9404
+ import { createHash as createHash2 } from "crypto";
9351
9405
  import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
9352
9406
  import { join as join10 } from "path";
9353
9407
  function resolveEmbedModelDir(repo) {
@@ -9393,15 +9447,30 @@ function loadEmbedModel(dir) {
9393
9447
  return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
9394
9448
  }
9395
9449
  function resolveEmbedPullUrl() {
9396
- const url = process.env.CODEINDEX_EMBED_URL;
9397
- return url && url.trim() ? url.trim() : void 0;
9450
+ const env = process.env.CODEINDEX_EMBED_URL;
9451
+ if (env && env.trim()) return { url: env.trim() };
9452
+ return { url: DEFAULT_EMBED_URL, sha256: EMBED_ASSET_SHA256 };
9453
+ }
9454
+ async function fetchEmbedModel(url, expectedSha256) {
9455
+ const res = await fetch(url);
9456
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
9457
+ const body2 = await res.text();
9458
+ if (expectedSha256) {
9459
+ const got = createHash2("sha256").update(body2).digest("hex");
9460
+ if (got !== expectedSha256) {
9461
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${got}`);
9462
+ }
9463
+ }
9464
+ return body2;
9398
9465
  }
9399
- var EMBED_VERSION, DEFAULT_EMBED_DIRNAME;
9466
+ var EMBED_VERSION, DEFAULT_EMBED_DIRNAME, DEFAULT_EMBED_URL, EMBED_ASSET_SHA256;
9400
9467
  var init_model = __esm({
9401
9468
  "src/embed/model.ts"() {
9402
9469
  "use strict";
9403
9470
  EMBED_VERSION = 1;
9404
9471
  DEFAULT_EMBED_DIRNAME = "models";
9472
+ DEFAULT_EMBED_URL = "https://github.com/maxgfr/codeindex/releases/download/embed-model-v1/model.json";
9473
+ EMBED_ASSET_SHA256 = "163ad053eab4e9a80d421ed4164f32292c83290f02fbbe6fe4b9b1cd6ea18d34";
9405
9474
  }
9406
9475
  });
9407
9476
 
@@ -10103,7 +10172,9 @@ var init_viz = __esm({
10103
10172
  // src/mcp.ts
10104
10173
  var mcp_exports = {};
10105
10174
  __export(mcp_exports, {
10106
- runMcpServer: () => runMcpServer
10175
+ memoizedEmbeddingIndex: () => memoizedEmbeddingIndex,
10176
+ runMcpServer: () => runMcpServer,
10177
+ scanFingerprint: () => scanFingerprint
10107
10178
  });
10108
10179
  import { createInterface } from "readline";
10109
10180
  function str(v) {
@@ -10112,6 +10183,19 @@ function str(v) {
10112
10183
  function strArray(v) {
10113
10184
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? v : void 0;
10114
10185
  }
10186
+ function errMessage(e) {
10187
+ return e instanceof Error ? e.message : String(e);
10188
+ }
10189
+ function scanFingerprint(scan2) {
10190
+ return sha1(scan2.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
10191
+ }
10192
+ async function memoizedEmbeddingIndex(key, build) {
10193
+ const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
10194
+ if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
10195
+ const index = await build();
10196
+ embeddingIndexCache = { key: cacheKey, index };
10197
+ return index;
10198
+ }
10115
10199
  async function callTool(name2, args2) {
10116
10200
  const repo = str(args2.repo);
10117
10201
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
@@ -10252,19 +10336,35 @@ async function callTool(name2, args2) {
10252
10336
  const endpoint = resolveEmbedEndpoint();
10253
10337
  if (endpoint) {
10254
10338
  try {
10255
- const index = await buildEndpointIndex(scan2);
10339
+ const index = await memoizedEmbeddingIndex({ mode: "endpoint", identity: endpoint, scan: scan2 }, () => buildEndpointIndex(scan2));
10256
10340
  const queryVec = await encodeQueryViaEndpoint(query);
10257
- return JSON.stringify(searchSemantic(scan2, query, index, { queryVec, limit, fuzzy }), null, 2);
10258
- } catch {
10259
- return JSON.stringify(searchIndex(scan2, query, { limit, fuzzy }), null, 2);
10341
+ const results2 = searchSemantic(scan2, query, index, { queryVec, limit, fuzzy });
10342
+ return JSON.stringify({ results: results2, tier: "endpoint" }, null, 2);
10343
+ } catch (e) {
10344
+ const results2 = searchIndex(scan2, query, { limit, fuzzy });
10345
+ return JSON.stringify(
10346
+ { results: results2, tier: "lexical", degradedReason: `embedding endpoint failed: ${errMessage(e)}` },
10347
+ null,
10348
+ 2
10349
+ );
10260
10350
  }
10261
10351
  }
10262
10352
  const modelDir = resolveEmbedModelDir(repo);
10263
10353
  const model = modelDir ? loadEmbedModel(modelDir) : void 0;
10264
10354
  if (model) {
10265
- const index = buildEmbeddingIndex(scan2, model);
10266
- return JSON.stringify(searchSemantic(scan2, query, index, { model, limit, fuzzy }), null, 2);
10355
+ const index = await memoizedEmbeddingIndex(
10356
+ { mode: "static", identity: `${modelDir}#${model.modelId}`, scan: scan2 },
10357
+ () => buildEmbeddingIndex(scan2, model)
10358
+ );
10359
+ const results2 = searchSemantic(scan2, query, index, { model, limit, fuzzy });
10360
+ return JSON.stringify({ results: results2, tier: "static" }, null, 2);
10267
10361
  }
10362
+ const results = searchIndex(scan2, query, { limit, fuzzy });
10363
+ return JSON.stringify(
10364
+ { results, tier: "lexical", degradedReason: "no embedding endpoint or static model configured \u2014 see embed_status" },
10365
+ null,
10366
+ 2
10367
+ );
10268
10368
  }
10269
10369
  return JSON.stringify(searchIndex(scan2, query, { limit, fuzzy }), null, 2);
10270
10370
  }
@@ -10345,7 +10445,7 @@ async function runMcpServer() {
10345
10445
  }
10346
10446
  }
10347
10447
  }
10348
- var repoProp, scopeProps, TOOLS;
10448
+ var repoProp, scopeProps, TOOLS, embeddingIndexCache;
10349
10449
  var init_mcp = __esm({
10350
10450
  "src/mcp.ts"() {
10351
10451
  "use strict";
@@ -10372,6 +10472,7 @@ var init_mcp = __esm({
10372
10472
  init_embed();
10373
10473
  init_search();
10374
10474
  init_endpoint();
10475
+ init_hash();
10375
10476
  repoProp = { repo: { type: "string", description: "Absolute path to the repository root" } };
10376
10477
  scopeProps = {
10377
10478
  scope: { type: "string", description: "Restrict to one directory (repo-relative)" },
@@ -10584,7 +10685,7 @@ var init_mcp = __esm({
10584
10685
  },
10585
10686
  {
10586
10687
  name: "search",
10587
- description: '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 \u2014 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 \u2014 see embed_status).',
10688
+ description: '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 \u2014 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 \u2014 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.',
10588
10689
  inputSchema: {
10589
10690
  type: "object",
10590
10691
  properties: {
@@ -10598,7 +10699,7 @@ var init_mcp = __esm({
10598
10699
  },
10599
10700
  semantic: {
10600
10701
  type: "boolean",
10601
- description: "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 \u2014 see embed_status."
10702
+ description: '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 \u2014 see embed_status.'
10602
10703
  }
10603
10704
  },
10604
10705
  required: ["repo", "query"]
@@ -11090,8 +11191,9 @@ Commands:
11090
11191
  embed status Effective mode (none/static/endpoint), model +
11091
11192
  EMBED_VERSION, and endpoint reachability (JSON)
11092
11193
  embed build Write embeddings.bin into --out <dir> (static tier)
11093
- embed pull Fetch the model asset into CODEINDEX_EMBED_DIR (or
11094
- <repo>/.codeindex/models/) \u2014 needs CODEINDEX_EMBED_URL
11194
+ embed pull Fetch the official model asset into CODEINDEX_EMBED_DIR
11195
+ (or <repo>/.codeindex/models/); sha256-verified. Override
11196
+ the source with CODEINDEX_EMBED_URL
11095
11197
  embed serve Print (or --run) the docker command that starts the
11096
11198
  containerized embedding server (rich tier)
11097
11199
  rules Architecture rules (forbidden edges, cycles, orphans) validated
@@ -11371,26 +11473,20 @@ async function runCli(argv) {
11371
11473
  process.stderr.write(`codeindex: ${index.records.length} embedding records \u2192 ${flags2.out}/embeddings.bin (model ${model.modelId})
11372
11474
  `);
11373
11475
  } else if (sub === "pull") {
11374
- const url = resolveEmbedPullUrl();
11375
- if (!url) {
11376
- process.stderr.write(
11377
- "codeindex: no model URL configured. The official static-embedding asset is not published yet.\nSet CODEINDEX_EMBED_URL to a model.json URL (optionally CODEINDEX_EMBED_DIR as the destination), then re-run `codeindex embed pull`.\n"
11378
- );
11379
- process.exitCode = 1;
11380
- return;
11381
- }
11476
+ const { url, sha256 } = resolveEmbedPullUrl();
11382
11477
  const destDir = process.env.CODEINDEX_EMBED_DIR ?? join12(flags2.repo, ".codeindex", "models");
11383
11478
  mkdirSync2(destDir, { recursive: true });
11384
11479
  process.stderr.write(`codeindex: fetching model from ${url} \u2192 ${join12(destDir, "model.json")}
11385
11480
  `);
11386
- const res = await fetch(url);
11387
- if (!res.ok) {
11388
- process.stderr.write(`codeindex: pull failed \u2014 HTTP ${res.status} from ${url}
11481
+ let body2;
11482
+ try {
11483
+ body2 = await fetchEmbedModel(url, sha256);
11484
+ } catch (e) {
11485
+ process.stderr.write(`codeindex: pull failed \u2014 ${e instanceof Error ? e.message : String(e)} (nothing written)
11389
11486
  `);
11390
11487
  process.exitCode = 1;
11391
11488
  return;
11392
11489
  }
11393
- const body2 = await res.text();
11394
11490
  try {
11395
11491
  JSON.parse(body2);
11396
11492
  } catch {
@@ -11482,6 +11578,7 @@ export {
11482
11578
  buildGraph,
11483
11579
  buildIndexArtifacts,
11484
11580
  buildModules,
11581
+ buildRawCallerIndex,
11485
11582
  buildResolveContext,
11486
11583
  buildSymbolIndex,
11487
11584
  byKey,
package/src/callers.ts CHANGED
Binary file
@@ -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).
@@ -107,11 +120,37 @@ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
107
120
  return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
108
121
  }
109
122
 
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;
123
+ // What `embed pull` fetches, and whether to verify it. CODEINDEX_EMBED_URL wins
124
+ // outright (the user's explicit override / private mirror) and carries NO
125
+ // sha256, so a custom asset keeps the current un-verified behavior. With no env,
126
+ // we fall back to the built-in official asset AND its pinned sha256, so `pull`
127
+ // can prove integrity of the default download.
128
+ export interface EmbedPullTarget {
129
+ url: string;
130
+ sha256?: string; // present only for the built-in default → verify
131
+ }
132
+
133
+ export function resolveEmbedPullUrl(): EmbedPullTarget {
134
+ const env = process.env.CODEINDEX_EMBED_URL;
135
+ if (env && env.trim()) return { url: env.trim() };
136
+ return { url: DEFAULT_EMBED_URL, sha256: EMBED_ASSET_SHA256 };
137
+ }
138
+
139
+ // Fetch a model.json body over HTTP, following redirects (GitHub release assets
140
+ // 302 to a CDN — native fetch follows by default). When `expectedSha256` is
141
+ // given, the downloaded bytes are hashed and MUST match or this throws with a
142
+ // clear message and returns nothing — so a corrupt/tampered default asset never
143
+ // gets written. A non-2xx response also throws. Verification and I/O are split
144
+ // from the CLI so both the success and the sha-mismatch paths are unit-testable.
145
+ export async function fetchEmbedModel(url: string, expectedSha256?: string): Promise<string> {
146
+ const res = await fetch(url);
147
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
148
+ const body = await res.text();
149
+ if (expectedSha256) {
150
+ const got = createHash("sha256").update(body).digest("hex");
151
+ if (got !== expectedSha256) {
152
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${got}`);
153
+ }
154
+ }
155
+ return body;
117
156
  }
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, 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
@@ -387,26 +388,21 @@ export async function runCli(argv: string[]): Promise<void> {
387
388
  writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
388
389
  process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
389
390
  } 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
- }
391
+ // Default: the official published asset + its pinned sha256. A user-set
392
+ // CODEINDEX_EMBED_URL overrides both (mirror/custom model, no verification).
393
+ const { url, sha256 } = resolveEmbedPullUrl();
400
394
  const destDir = process.env.CODEINDEX_EMBED_DIR ?? join(flags.repo, ".codeindex", "models");
401
395
  mkdirSync(destDir, { recursive: true });
402
396
  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`);
397
+ let body: string;
398
+ try {
399
+ // Follows redirects (GitHub CDN) and verifies sha256 for the default asset.
400
+ body = await fetchEmbedModel(url, sha256);
401
+ } catch (e) {
402
+ process.stderr.write(`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`);
406
403
  process.exitCode = 1;
407
404
  return;
408
405
  }
409
- const body = await res.text();
410
406
  try {
411
407
  JSON.parse(body);
412
408
  } catch {
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";
@@ -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";