@maxgfr/codeindex 2.12.0 → 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.
package/README.md CHANGED
@@ -129,9 +129,13 @@ and the `embeddings.bin` artifact are byte-identical across builds and platforms
129
129
  It is **opt-in by asset**: with no model on disk the engine silently stays
130
130
  lexical, and `--semantic` without a model returns lexical results on **exit 0**
131
131
  (a stderr note only). Models are **never** shipped in the package; a model is
132
- resolved from `CODEINDEX_EMBED_DIR` or `<repo>/.codeindex/models/`.
132
+ resolved from `CODEINDEX_EMBED_DIR` or `<repo>/.codeindex/models/`. Getting one
133
+ is zero-config: `codeindex embed pull` fetches the official `embed-model-v1`
134
+ release asset, sha256-verified before anything is written.
133
135
 
134
136
  ```sh
137
+ codeindex embed pull --repo . # fetch the official model asset into
138
+ # CODEINDEX_EMBED_DIR (or <repo>/.codeindex/models/); sha256-verified
135
139
  codeindex embed status --repo . # effective mode + reachability (JSON)
136
140
  codeindex embed build --repo . --out .codeindex # write embeddings.bin
137
141
  codeindex search "http client retry" --repo . --semantic
package/docs/MIGRATION.md CHANGED
@@ -93,6 +93,43 @@ embeddings live in a separate `embeddings.bin` sidecar keyed by a dedicated
93
93
  `--semantic` without a model degrades to lexical results on **exit 0** (a stderr
94
94
  note only) — so wiring it on is safe before an asset exists.
95
95
 
96
+ ## v2.12.0 — two return-shape changes (check your call sites)
97
+
98
+ Not additive: two public return shapes changed in this release without a
99
+ compat flag, so a consumer re-pinning across v2.12.0 must check both call
100
+ sites. Artifact schemas are untouched (`SCHEMA_VERSION` / `EMBED_VERSION`
101
+ unchanged) — this is API shape only.
102
+
103
+ - `resolveEmbedPullUrl()` now returns an `EmbedPullTarget`
104
+ (`{ url: string; sha256?: string }`) — it previously returned
105
+ `string | undefined`. It always resolves: `CODEINDEX_EMBED_URL` wins
106
+ outright and carries **no** `sha256` (a custom mirror keeps the
107
+ un-verified behavior); with no env it falls back to the built-in official
108
+ asset **with** its pinned `sha256` so `embed pull` verifies the default
109
+ download. Replace `const url = resolveEmbedPullUrl()` with
110
+ `const { url, sha256 } = resolveEmbedPullUrl()`. The `EmbedPullTarget`
111
+ type is exported from the barrel.
112
+ - MCP `search` with `semantic: true` now returns
113
+ `{ results, tier, degradedReason? }` — it previously returned the bare
114
+ ranked array. `tier` is `"endpoint" | "static" | "lexical"` and
115
+ `degradedReason` is present only when the semantic tier degraded to
116
+ lexical, so a caller can tell "fusion happened" apart from "degraded".
117
+ Plain lexical `search` (no `semantic: true`) still returns the bare
118
+ array, byte-compatible with existing consumers.
119
+
120
+ ## v2.13.0 — `.codeindex` excluded from the walk
121
+
122
+ `.codeindex/` — the engine's own output directory (index artifacts, pulled
123
+ models, MCP memories) — joined `IGNORE_DIRS`, so `walk`/`scanRepo` no longer
124
+ descend into it and memories stop entering BM25/embedding results (previously
125
+ every `write_memory` also churned the scan fingerprint, busting the MCP
126
+ server's memoized embedding index). Access memories through the MCP memory
127
+ tools (`read_memory` / `list_memories` / `delete_memory`), never `search`.
128
+ This is a file-set change only — extraction shape and `SCHEMA_VERSION` are
129
+ untouched, so **no re-pin is required**; a consumer that deliberately wants
130
+ `.codeindex` walked can pass `ignoreDirs` (replace semantics, also new in
131
+ this release) with its own set.
132
+
96
133
  ## Per-skill mapping (what to replace with what)
97
134
 
98
135
  | Skill | Replace | With (engine export) |
package/docs/SEMANTIC.md CHANGED
@@ -78,7 +78,7 @@ rows at one global scale. The download is **sha256-verified** against
78
78
  `EMBED_ASSET_SHA256` (`src/embed/model.ts`) before it is written; a mismatch
79
79
  fails the pull and writes nothing. The asset is **never** committed to git or
80
80
  shipped in the npm tarball — it lives only in the release. Reproduce it
81
- byte-for-byte with the pinned toolchain in [`scripts/embed-asset/`](../scripts/embed-asset/).
81
+ byte-for-byte with the pinned toolchain in [`scripts/embed-asset/`](https://github.com/maxgfr/codeindex/tree/main/scripts/embed-asset).
82
82
 
83
83
  ## CLI
84
84
 
@@ -110,13 +110,20 @@ codeindex search "http client retry" --repo <dir> --semantic
110
110
 
111
111
  ## Artifact: `embeddings.bin`
112
112
 
113
+ ### Layout (the `deserializeEmbeddings` contract)
114
+
113
115
  ```
114
- "CIE1" 4-byte ASCII magic
115
- uint32 LE header length
116
- UTF-8 JSON header { embedVersion, modelId, dim, count, records:[{file,symbol,line}] }
117
- int8 body count × dim signed bytes (row-major)
116
+ offset 0 "CIE1" 4-byte ASCII magic
117
+ offset 4 uint32 LE header length (headerLen)
118
+ offset 8 UTF-8 JSON header { embedVersion, modelId, dim, count, records:[{file,symbol,line}] }
119
+ offset 8+headerLen int8 body count × dim signed bytes (row-major)
118
120
  ```
119
121
 
122
+ This is exactly what `deserializeEmbeddings` (`src/embed/index.ts`) reads back:
123
+ it throws on a bad magic (a corrupt or foreign file fails loudly instead of
124
+ being misread), parses the JSON header, and slices the packed body at
125
+ `8 + headerLen`.
126
+
120
127
  The header carries **no absolute path and no timestamp**; records follow scan
121
128
  order (files sorted by `rel`, symbols in declaration order). Two builds of an
122
129
  unchanged repo produce byte-identical `embeddings.bin`. A dedicated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxgfr/codeindex",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Self-contained, deterministic repo-indexing engine: walk + language detection + symbol/import extraction (tree-sitter AST with regex fallback) + import resolution + typed cross-file link-graph + analytics. Ships as a single zero-dependency engine.mjs that consumer tools vendor.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -1,6 +1,6 @@
1
- declare const ENGINE_VERSION = "2.12.0";
1
+ declare const ENGINE_VERSION = "2.13.0";
2
2
  declare const SCHEMA_VERSION = 4;
3
- declare const EXTRACTOR_VERSION = 9;
3
+ declare const EXTRACTOR_VERSION = 10;
4
4
  type FileKind = "code" | "doc" | "config" | "asset" | "other";
5
5
  type EdgeKind = "contains" | "doc-link" | "import" | "call" | "use" | "mention";
6
6
  type Tier = 0 | 1 | 2;
@@ -121,6 +121,7 @@ interface WalkOptions {
121
121
  maxFileBytes?: number;
122
122
  maxFiles?: number;
123
123
  gitignore?: boolean;
124
+ ignoreDirs?: string[];
124
125
  }
125
126
  interface WalkedFile {
126
127
  rel: string;
@@ -153,8 +154,10 @@ interface ScanOptions {
153
154
  exclude?: string[];
154
155
  scope?: string;
155
156
  gitignore?: boolean;
157
+ ignoreDirs?: string[];
156
158
  maxBytes?: number;
157
159
  maxFiles?: number;
160
+ maxCallsPerFile?: number;
158
161
  out?: string;
159
162
  cache?: Map<string, {
160
163
  hash: string;
@@ -202,7 +205,9 @@ interface CodeInfo {
202
205
  }[];
203
206
  importedNames?: string[];
204
207
  }
205
- declare function extractCode(rel: string, ext: string, content: string): CodeInfo;
208
+ declare function extractCode(rel: string, ext: string, content: string, opts?: {
209
+ maxCallsPerFile?: number;
210
+ }): CodeInfo;
206
211
 
207
212
  interface MarkdownInfo {
208
213
  title?: string;
@@ -229,7 +234,9 @@ interface AstResult {
229
234
  }[];
230
235
  importedNames: string[];
231
236
  }
232
- declare function extractAst(rel: string, ext: string, content: string): AstResult | undefined;
237
+ declare function extractAst(rel: string, ext: string, content: string, opts?: {
238
+ maxCalls?: number;
239
+ }): AstResult | undefined;
233
240
 
234
241
  type Resolution = {
235
242
  kind: "resolved";
@@ -705,4 +712,4 @@ declare function rrf<T>(lists: T[][], keyOf: (item: T) => string, k?: number): M
705
712
 
706
713
  declare function runCli(argv: string[]): Promise<void>;
707
714
 
708
- export { type ArchRule, type BuildIndexOptions, type BuiltinRule, type CallerEntry, type CallerIndex, type CallerIndexOptions, type CallerSite, type ChangeCoupling, type CodeInfo, type CodeSymbol, type CouplingOptions, DEFAULT_MAX_FILES, type DeadSymbol, type DiffFile, type DiffSpec, EMBED_VERSION, ENGINE_VERSION, EXTRACTOR_VERSION, type Edge, type EdgeKind, type EditResult, type EmbedEndpointOptions, type EmbeddingIndex, type EmbeddingRecord, type EmbeddingUnit, type FileCategory, type FileKind, type FileNode, type FileRecord, type FindSymbolOptions, type ForbiddenEdgeRule, type Graph, type GrepOptions, type Hotspot, type Hunk, type IgnoreRule, type IndexArtifacts, MARKDOWN_EXT, type MarkdownInfo, type MermaidOptions, type ModuleInfo, type ModuleNode, type RawCallerIndex, type RawCallerSite, type RawRef, type RenderScipOptions, type RepoMapOptions, type RepoScan, type Resolution, type ResolveContext, type RiskHotspot, type RuleSeverity, type RuleViolation, SCHEMA_VERSION, type ScanOptions, type SearchHit, type SearchOptions, type SearchResult, type SemanticSearchOptions, type SemanticSearchResult, type ShResult, type StaticEmbedModel, type SurpriseEdge, type SymbolComplexity, type SymbolIndex, type SymbolMatch, type SymbolReferences, type TestMap, type Tier, type WalkOptions, type WalkResult, type WalkedFile, type WorkspaceInfo, type WorkspaceKind, type WorkspacePackage, allGrammarKeys, applyCentrality, basicTokenize, betweennessOf, buildCallerIndex, buildEmbeddingIndex, buildEndpointIndex, buildGraph, buildIndexArtifacts, buildModules, buildRawCallerIndex, buildResolveContext, buildSymbolIndex, byKey, byStr, categorize, changeCoupling, changedSince, checkRules, classify, clip, clipInline, communityOf, compileGlobs, complexityOfSource, computeImportPairs, computeSurprises, computeSymbolRefs, computeTestMap, deleteMemory, deserializeEmbeddings, detectCommunities, detectWorkspaces, diffFiles, diffHunks, embedEndpointUrl, embedViaEndpoint, embeddingUnits, enclosingSymbol, encode, encodeQueryViaEndpoint, ensureGrammars, escapeRegExp, extToLang, extractAst, extractCode, extractMarkdown, extractSymbols, findDeadCode, findReferences, findSymbol, foldText, gitChurn, grammarKeyForExt, grammarReady, grepRepo, hasEmbedModel, have, headCommit, healthzUrl, insertAfterSymbol, insertBeforeSymbol, intDot, isCode, isDoc, isGitWorktree, isIgnored, isSurprising, isTestFile, isTestPath, keywords, languageOf, listMemories, loadEmbedModel, pagerankOf, parseGitignore, parseRules, probeEndpoint, quantize, rankHotspots, rankedKeywords, readMemory, readText, renderGraphJson, renderMermaid, renderRepoMap, renderScip, renderSymbolsJson, replaceSymbolBody, resolveBaseRef, resolveCallEdges, resolveDocLink, resolveEmbedEndpoint, resolveEmbedModelDir, resolveEmbedPullUrl, resolveImport, resolveUniqueSymbol, riskHotspots, roundHalfToEven, rrf, runCli, runMcpServer, scanRepo, searchIndex, searchSemantic, serializeEmbeddings, sh, sha1, shortHash, slugify, subtokens, symbolComplexity, symbolsOverview, testsForModule, tierForPath, tokenize, uniqueSymbolDefs, untestedModules, untrackedFiles, walk, wordpiece, writeMemory };
715
+ export { type ArchRule, type BuildIndexOptions, type BuiltinRule, type CallerEntry, type CallerIndex, type CallerIndexOptions, type CallerSite, type ChangeCoupling, type CodeInfo, type CodeSymbol, type CouplingOptions, DEFAULT_MAX_FILES, type DeadSymbol, type DiffFile, type DiffSpec, EMBED_VERSION, ENGINE_VERSION, EXTRACTOR_VERSION, type Edge, type EdgeKind, type EditResult, type EmbedEndpointOptions, type EmbedPullTarget, type EmbeddingIndex, type EmbeddingRecord, type EmbeddingUnit, type FileCategory, type FileKind, type FileNode, type FileRecord, type FindSymbolOptions, type ForbiddenEdgeRule, type Graph, type GrepOptions, type Hotspot, type Hunk, type IgnoreRule, type IndexArtifacts, MARKDOWN_EXT, type MarkdownInfo, type MermaidOptions, type ModuleInfo, type ModuleNode, type RawCallerIndex, type RawCallerSite, type RawRef, type RenderScipOptions, type RepoMapOptions, type RepoScan, type Resolution, type ResolveContext, type RiskHotspot, type RuleSeverity, type RuleViolation, SCHEMA_VERSION, type ScanOptions, type SearchHit, type SearchOptions, type SearchResult, type SemanticSearchOptions, type SemanticSearchResult, type ShResult, type StaticEmbedModel, type SurpriseEdge, type SymbolComplexity, type SymbolIndex, type SymbolMatch, type SymbolReferences, type TestMap, type Tier, type WalkOptions, type WalkResult, type WalkedFile, type WorkspaceInfo, type WorkspaceKind, type WorkspacePackage, allGrammarKeys, applyCentrality, basicTokenize, betweennessOf, buildCallerIndex, buildEmbeddingIndex, buildEndpointIndex, buildGraph, buildIndexArtifacts, buildModules, buildRawCallerIndex, buildResolveContext, buildSymbolIndex, byKey, byStr, categorize, changeCoupling, changedSince, checkRules, classify, clip, clipInline, communityOf, compileGlobs, complexityOfSource, computeImportPairs, computeSurprises, computeSymbolRefs, computeTestMap, deleteMemory, deserializeEmbeddings, detectCommunities, detectWorkspaces, diffFiles, diffHunks, embedEndpointUrl, embedViaEndpoint, embeddingUnits, enclosingSymbol, encode, encodeQueryViaEndpoint, ensureGrammars, escapeRegExp, extToLang, extractAst, extractCode, extractMarkdown, extractSymbols, findDeadCode, findReferences, findSymbol, foldText, gitChurn, grammarKeyForExt, grammarReady, grepRepo, hasEmbedModel, have, headCommit, healthzUrl, insertAfterSymbol, insertBeforeSymbol, intDot, isCode, isDoc, isGitWorktree, isIgnored, isSurprising, isTestFile, isTestPath, keywords, languageOf, listMemories, loadEmbedModel, pagerankOf, parseGitignore, parseRules, probeEndpoint, quantize, rankHotspots, rankedKeywords, readMemory, readText, renderGraphJson, renderMermaid, renderRepoMap, renderScip, renderSymbolsJson, replaceSymbolBody, resolveBaseRef, resolveCallEdges, resolveDocLink, resolveEmbedEndpoint, resolveEmbedModelDir, resolveEmbedPullUrl, resolveImport, resolveUniqueSymbol, riskHotspots, roundHalfToEven, rrf, runCli, runMcpServer, scanRepo, searchIndex, searchSemantic, serializeEmbeddings, sh, sha1, shortHash, slugify, subtokens, symbolComplexity, symbolsOverview, testsForModule, tierForPath, tokenize, uniqueSymbolDefs, untestedModules, untrackedFiles, walk, wordpiece, writeMemory };
@@ -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.12.0";
17
+ ENGINE_VERSION = "2.13.0";
18
18
  SCHEMA_VERSION = 4;
19
- EXTRACTOR_VERSION = 9;
19
+ EXTRACTOR_VERSION = 10;
20
20
  }
21
21
  });
22
22
 
@@ -320,6 +320,7 @@ function walk(root, opts = {}) {
320
320
  const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024;
321
321
  const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
322
322
  const useGitignore = opts.gitignore !== false;
323
+ const ignoreDirs = opts.ignoreDirs ? new Set(opts.ignoreDirs) : IGNORE_DIRS;
323
324
  const out2 = [];
324
325
  let capped = false;
325
326
  let excluded = 0;
@@ -368,7 +369,7 @@ function walk(root, opts = {}) {
368
369
  continue;
369
370
  }
370
371
  if (st.isDirectory()) {
371
- if (IGNORE_DIRS.has(name2)) continue;
372
+ if (ignoreDirs.has(name2)) continue;
372
373
  if (isLink) continue;
373
374
  if (useGitignore && rules.length && isIgnored(rules, rel, true)) continue;
374
375
  stack.push({ dir: abs, rel, rules });
@@ -463,6 +464,7 @@ var init_walk = __esm({
463
464
  ".cache",
464
465
  "tmp",
465
466
  ".ultraindex",
467
+ ".codeindex",
466
468
  "Pods",
467
469
  "DerivedData",
468
470
  ".terraform",
@@ -901,7 +903,7 @@ var init_js_ts = __esm({
901
903
  RULES = [
902
904
  { re: /^\s*export\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
903
905
  { re: /^\s*export\s+default\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
904
- { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
906
+ { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?!extends\b)(?<name>[\w$]+)/, kind: "class", exported: true },
905
907
  { re: /^\s*(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: false },
906
908
  { re: /^\s*export\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
907
909
  { re: /^\s*(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: false },
@@ -5718,7 +5720,7 @@ function readReceiver(node) {
5718
5720
  const name2 = obj ? readName(obj) : void 0;
5719
5721
  return name2 && /^[A-Za-z_]\w*$/.test(name2) ? name2 : void 0;
5720
5722
  }
5721
- function collectCalls(root, spec) {
5723
+ function collectCalls(root, spec, maxCalls = MAX_CALLS) {
5722
5724
  if (!spec.calls) return [];
5723
5725
  const out2 = [];
5724
5726
  const seen = /* @__PURE__ */ new Set();
@@ -5749,7 +5751,7 @@ function collectCalls(root, spec) {
5749
5751
  };
5750
5752
  visit(root);
5751
5753
  out2.sort((a, b) => byStr(a.name, b.name) || a.line - b.line);
5752
- return out2.slice(0, MAX_CALLS);
5754
+ return out2.slice(0, maxCalls);
5753
5755
  }
5754
5756
  function collectImportedNames(root, spec) {
5755
5757
  if (!spec.imports?.import_statement) return [];
@@ -5776,7 +5778,7 @@ function collectImportedNames(root, spec) {
5776
5778
  visit(root);
5777
5779
  return [...found].sort(byStr).slice(0, MAX_IMPORTED_NAMES);
5778
5780
  }
5779
- function extractAst(rel, ext, content) {
5781
+ function extractAst(rel, ext, content, opts = {}) {
5780
5782
  const key = grammarKeyForExt(ext);
5781
5783
  if (!key || !grammarReady(key)) return void 0;
5782
5784
  const spec = SPECS[key];
@@ -5962,7 +5964,7 @@ function extractAst(rel, ext, content) {
5962
5964
  }
5963
5965
  const refs = collectImports(root, spec);
5964
5966
  const idents = collectRefIdents(root, new Set(symbols.map((s) => s.name)));
5965
- const calls = collectCalls(root, spec);
5967
+ const calls = collectCalls(root, spec, opts.maxCalls);
5966
5968
  const importedNames = collectImportedNames(root, spec);
5967
5969
  let pkg;
5968
5970
  if (spec.lang === "java") {
@@ -6398,12 +6400,12 @@ function extractImports(ext, content) {
6398
6400
  }
6399
6401
  return [...specs].map((spec) => ({ kind: "import", spec }));
6400
6402
  }
6401
- function collectCallsRegex(content, symbols = []) {
6403
+ function collectCallsRegex(content, symbols = [], maxCalls = 512) {
6402
6404
  const out2 = /* @__PURE__ */ new Map();
6403
6405
  const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
6404
6406
  const lines = content.split("\n");
6405
6407
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
6406
- for (let i2 = 0; i2 < lines.length && out2.size < 512; i2++) {
6408
+ for (let i2 = 0; i2 < lines.length && out2.size < maxCalls; i2++) {
6407
6409
  const line = lines[i2];
6408
6410
  const trimmed = line.trimStart();
6409
6411
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) continue;
@@ -6418,7 +6420,7 @@ function collectCallsRegex(content, symbols = []) {
6418
6420
  CALL_RE.lastIndex = 0;
6419
6421
  let m;
6420
6422
  const fallbackExcluded = /* @__PURE__ */ new Set();
6421
- while ((m = CALL_RE.exec(line)) !== null && out2.size < 512) {
6423
+ while ((m = CALL_RE.exec(line)) !== null && out2.size < maxCalls) {
6422
6424
  const receiver = m[1];
6423
6425
  const name2 = m[2];
6424
6426
  if (name2.length < 2 || CALL_KEYWORDS.has(name2)) continue;
@@ -6435,8 +6437,8 @@ function collectCallsRegex(content, symbols = []) {
6435
6437
  }
6436
6438
  return [...out2.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : a.line - b.line);
6437
6439
  }
6438
- function extractCode(rel, ext, content) {
6439
- const ast = extractAst(rel, ext, content);
6440
+ function extractCode(rel, ext, content, opts = {}) {
6441
+ const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile });
6440
6442
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
6441
6443
  const known = new Set(symbols.map((s) => s.name));
6442
6444
  const reexports = extractReexports(rel, content, symbols).filter((s) => !known.has(s.name));
@@ -6452,7 +6454,7 @@ function extractCode(rel, ext, content) {
6452
6454
  // collector otherwise, so caller indexes exist without the wasm sidecar.
6453
6455
  // `symbols` (this file's own regex-extracted defs) lets the collector
6454
6456
  // exclude a definition's own name+line from its call candidates.
6455
- calls: ast ? ast.calls : collectCallsRegex(content, symbols),
6457
+ calls: ast ? ast.calls : collectCallsRegex(content, symbols, opts.maxCallsPerFile),
6456
6458
  importedNames: ast?.importedNames
6457
6459
  };
6458
6460
  }
@@ -6524,7 +6526,8 @@ function scanRepo(root, opts = {}) {
6524
6526
  const { files: walked, capped, excluded } = walk(root, {
6525
6527
  maxFileBytes: opts.maxBytes,
6526
6528
  maxFiles: opts.maxFiles,
6527
- gitignore: opts.gitignore
6529
+ gitignore: opts.gitignore,
6530
+ ignoreDirs: opts.ignoreDirs
6528
6531
  });
6529
6532
  const outPrefix = opts.out ? opts.out.replace(/\/+$/, "") + "/" : null;
6530
6533
  const files = [];
@@ -6573,7 +6576,7 @@ function scanRepo(root, opts = {}) {
6573
6576
  } else if (kind === "doc") {
6574
6577
  record.title = basename(f.rel);
6575
6578
  } else if (kind === "code") {
6576
- const code = extractCode(f.rel, f.ext, content);
6579
+ const code = extractCode(f.rel, f.ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
6577
6580
  record.title = basename(f.rel);
6578
6581
  record.summary = code.summary;
6579
6582
  record.symbols = code.symbols;
@@ -9418,16 +9421,12 @@ function resolveEmbedModelDir(repo) {
9418
9421
  function hasEmbedModel(repo) {
9419
9422
  return resolveEmbedModelDir(repo) !== void 0;
9420
9423
  }
9421
- function loadEmbedModel(dir) {
9422
- if (!dir) return void 0;
9423
- const path = join10(dir, "model.json");
9424
- if (!existsSync3(path)) return void 0;
9425
- const raw = JSON.parse(readFileSync5(path, "utf8"));
9426
- const { modelId, dim, vocab, weights } = raw;
9427
- if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
9428
- if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
9424
+ function parseEmbedModel(raw, source) {
9425
+ const { modelId, dim, vocab, weights, unk: rawUnk } = raw ?? {};
9426
+ if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${source}`);
9427
+ if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${source}`);
9429
9428
  if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
9430
- throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
9429
+ throw new Error(`embed model: vocab/weights length mismatch in ${source}`);
9431
9430
  }
9432
9431
  const vocabSize = vocab.length;
9433
9432
  const flat = new Float64Array(vocabSize * dim);
@@ -9442,10 +9441,17 @@ function loadEmbedModel(dir) {
9442
9441
  }
9443
9442
  for (let d = 0; d < dim; d++) flat[i2 * dim + d] = Number(row[d]);
9444
9443
  }
9445
- const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
9444
+ const unk = typeof rawUnk === "string" ? rawUnk : "[UNK]";
9446
9445
  const unkId = vmap.has(unk) ? vmap.get(unk) : -1;
9447
9446
  return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
9448
9447
  }
9448
+ function loadEmbedModel(dir) {
9449
+ if (!dir) return void 0;
9450
+ const path = join10(dir, "model.json");
9451
+ if (!existsSync3(path)) return void 0;
9452
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
9453
+ return parseEmbedModel(raw, path);
9454
+ }
9449
9455
  function resolveEmbedPullUrl() {
9450
9456
  const env = process.env.CODEINDEX_EMBED_URL;
9451
9457
  if (env && env.trim()) return { url: env.trim() };
@@ -10172,10 +10178,13 @@ var init_viz = __esm({
10172
10178
  // src/mcp.ts
10173
10179
  var mcp_exports = {};
10174
10180
  __export(mcp_exports, {
10181
+ memoizedEmbedModel: () => memoizedEmbedModel,
10175
10182
  memoizedEmbeddingIndex: () => memoizedEmbeddingIndex,
10176
10183
  runMcpServer: () => runMcpServer,
10177
10184
  scanFingerprint: () => scanFingerprint
10178
10185
  });
10186
+ import { statSync as statSync4 } from "fs";
10187
+ import { join as join12 } from "path";
10179
10188
  import { createInterface } from "readline";
10180
10189
  function str(v) {
10181
10190
  return typeof v === "string" && v ? v : void 0;
@@ -10196,6 +10205,19 @@ async function memoizedEmbeddingIndex(key, build) {
10196
10205
  embeddingIndexCache = { key: cacheKey, index };
10197
10206
  return index;
10198
10207
  }
10208
+ function memoizedEmbedModel(modelDir) {
10209
+ let stat;
10210
+ try {
10211
+ stat = statSync4(join12(modelDir, "model.json"));
10212
+ } catch {
10213
+ return void 0;
10214
+ }
10215
+ const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
10216
+ if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
10217
+ const model = loadEmbedModel(modelDir);
10218
+ if (model) embedModelCache = { key, model };
10219
+ return model;
10220
+ }
10199
10221
  async function callTool(name2, args2) {
10200
10222
  const repo = str(args2.repo);
10201
10223
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
@@ -10350,7 +10372,7 @@ async function callTool(name2, args2) {
10350
10372
  }
10351
10373
  }
10352
10374
  const modelDir = resolveEmbedModelDir(repo);
10353
- const model = modelDir ? loadEmbedModel(modelDir) : void 0;
10375
+ const model = modelDir ? memoizedEmbedModel(modelDir) : void 0;
10354
10376
  if (model) {
10355
10377
  const index = await memoizedEmbeddingIndex(
10356
10378
  { mode: "static", identity: `${modelDir}#${model.modelId}`, scan: scan2 },
@@ -10370,7 +10392,7 @@ async function callTool(name2, args2) {
10370
10392
  }
10371
10393
  if (name2 === "embed_status") {
10372
10394
  const modelDir = resolveEmbedModelDir(repo);
10373
- const model = modelDir ? loadEmbedModel(modelDir) : void 0;
10395
+ const model = modelDir ? memoizedEmbedModel(modelDir) : void 0;
10374
10396
  const endpoint = resolveEmbedEndpoint();
10375
10397
  const mode = endpoint ? "endpoint" : model ? "static" : "none";
10376
10398
  const status = {
@@ -10445,7 +10467,7 @@ async function runMcpServer() {
10445
10467
  }
10446
10468
  }
10447
10469
  }
10448
- var repoProp, scopeProps, TOOLS, embeddingIndexCache;
10470
+ var repoProp, scopeProps, TOOLS, embeddingIndexCache, embedModelCache;
10449
10471
  var init_mcp = __esm({
10450
10472
  "src/mcp.ts"() {
10451
10473
  "use strict";
@@ -11148,7 +11170,7 @@ init_pipeline();
11148
11170
  init_graph_json();
11149
11171
  init_symbols_json();
11150
11172
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
11151
- import { join as join12, resolve } from "path";
11173
+ import { join as join13, resolve } from "path";
11152
11174
  init_scan();
11153
11175
  init_callers();
11154
11176
  init_workspaces();
@@ -11216,8 +11238,11 @@ Flags:
11216
11238
  --exclude <glob> Exclude matching paths (repeatable)
11217
11239
  --scope <dir> Restrict to one directory (sugar for --include '<dir>/**')
11218
11240
  --no-gitignore Do not honor .gitignore files (default: honored)
11241
+ --ignore-dir <name> Directory names to skip (repeatable) \u2014 REPLACES the
11242
+ default ignored-directory set, never merges with it
11219
11243
  --max-files <n> Cap walked files (default 20000)
11220
11244
  --max-bytes <n> Skip files above this size (default 1 MiB)
11245
+ --max-calls <n> Per-file call-site cap for extraction (default 512)
11221
11246
  --no-ast Skip tree-sitter grammars even when present (regex tier)
11222
11247
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, \u2026}])
11223
11248
  --limit <n> Max results for \`search\` (default 20)
@@ -11232,7 +11257,7 @@ Flags:
11232
11257
  each site corroborated|unique-name
11233
11258
  `;
11234
11259
  function parseFlags(args2) {
11235
- const flags2 = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
11260
+ const flags2 = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
11236
11261
  for (let i2 = 0; i2 < args2.length; i2++) {
11237
11262
  const a = args2[i2];
11238
11263
  const next = () => {
@@ -11255,8 +11280,10 @@ function parseFlags(args2) {
11255
11280
  else if (a === "--exclude") flags2.exclude.push(next());
11256
11281
  else if (a === "--scope") flags2.scope = next();
11257
11282
  else if (a === "--no-gitignore") flags2.gitignore = false;
11283
+ else if (a === "--ignore-dir") flags2.ignoreDirs.push(next());
11258
11284
  else if (a === "--max-files") flags2.maxFiles = num();
11259
11285
  else if (a === "--max-bytes") flags2.maxBytes = num();
11286
+ else if (a === "--max-calls") flags2.maxCalls = num();
11260
11287
  else if (a === "--ignore-case") flags2.ignoreCase = true;
11261
11288
  else if (a === "--max-hits") flags2.maxHits = num();
11262
11289
  else if (a === "--budget-tokens") flags2.budgetTokens = num();
@@ -11283,8 +11310,10 @@ function scanOptions(flags2) {
11283
11310
  exclude: flags2.exclude.length ? flags2.exclude : void 0,
11284
11311
  scope: flags2.scope,
11285
11312
  gitignore: flags2.gitignore,
11313
+ ignoreDirs: flags2.ignoreDirs.length ? flags2.ignoreDirs : void 0,
11286
11314
  maxFiles: flags2.maxFiles,
11287
- maxBytes: flags2.maxBytes
11315
+ maxBytes: flags2.maxBytes,
11316
+ maxCallsPerFile: flags2.maxCalls
11288
11317
  };
11289
11318
  }
11290
11319
  async function runCli(argv) {
@@ -11309,7 +11338,7 @@ async function runCli(argv) {
11309
11338
  if (!flags2.out) throw new Error("index needs --out <dir>");
11310
11339
  const outDir = flags2.out;
11311
11340
  mkdirSync2(outDir, { recursive: true });
11312
- const cachePath = join12(outDir, "cache.json");
11341
+ const cachePath = join13(outDir, "cache.json");
11313
11342
  let cache;
11314
11343
  try {
11315
11344
  const parsed = JSON.parse(readFileSync6(cachePath, "utf8"));
@@ -11319,8 +11348,8 @@ async function runCli(argv) {
11319
11348
  } catch {
11320
11349
  }
11321
11350
  const { scan: scan2, graph, symbols } = buildIndexArtifacts(flags2.repo, { ...scanOptions(flags2), cache, out: outDir });
11322
- writeFileSync3(join12(outDir, "graph.json"), renderGraphJson(graph));
11323
- writeFileSync3(join12(outDir, "symbols.json"), renderSymbolsJson(symbols));
11351
+ writeFileSync3(join13(outDir, "graph.json"), renderGraphJson(graph));
11352
+ writeFileSync3(join13(outDir, "symbols.json"), renderSymbolsJson(symbols));
11324
11353
  const files = {};
11325
11354
  for (const f of scan2.files) {
11326
11355
  const entry = { hash: f.hash, record: f, size: f.size };
@@ -11337,7 +11366,7 @@ async function runCli(argv) {
11337
11366
  const model = modelDir ? loadEmbedModel(modelDir) : void 0;
11338
11367
  if (model) {
11339
11368
  const index = buildEmbeddingIndex(scan2, model);
11340
- writeFileSync3(join12(outDir, "embeddings.bin"), serializeEmbeddings(index));
11369
+ writeFileSync3(join13(outDir, "embeddings.bin"), serializeEmbeddings(index));
11341
11370
  embedNote = ` + embeddings.bin (${index.records.length} records, model ${model.modelId})`;
11342
11371
  }
11343
11372
  process.stderr.write(`codeindex: ${scan2.files.length} files \u2192 ${outDir}/graph.json + symbols.json${embedNote}${scan2.capped ? " (capped)" : ""}
@@ -11469,14 +11498,14 @@ async function runCli(argv) {
11469
11498
  mkdirSync2(flags2.out, { recursive: true });
11470
11499
  const scan2 = scanRepo(flags2.repo, scanOptions(flags2));
11471
11500
  const index = buildEmbeddingIndex(scan2, model);
11472
- writeFileSync3(join12(flags2.out, "embeddings.bin"), serializeEmbeddings(index));
11501
+ writeFileSync3(join13(flags2.out, "embeddings.bin"), serializeEmbeddings(index));
11473
11502
  process.stderr.write(`codeindex: ${index.records.length} embedding records \u2192 ${flags2.out}/embeddings.bin (model ${model.modelId})
11474
11503
  `);
11475
11504
  } else if (sub === "pull") {
11476
11505
  const { url, sha256 } = resolveEmbedPullUrl();
11477
- const destDir = process.env.CODEINDEX_EMBED_DIR ?? join12(flags2.repo, ".codeindex", "models");
11506
+ const destDir = process.env.CODEINDEX_EMBED_DIR ?? join13(flags2.repo, ".codeindex", "models");
11478
11507
  mkdirSync2(destDir, { recursive: true });
11479
- process.stderr.write(`codeindex: fetching model from ${url} \u2192 ${join12(destDir, "model.json")}
11508
+ process.stderr.write(`codeindex: fetching model from ${url} \u2192 ${join13(destDir, "model.json")}
11480
11509
  `);
11481
11510
  let body2;
11482
11511
  try {
@@ -11488,14 +11517,17 @@ async function runCli(argv) {
11488
11517
  return;
11489
11518
  }
11490
11519
  try {
11491
- JSON.parse(body2);
11492
- } catch {
11493
- process.stderr.write("codeindex: pull failed \u2014 response is not a valid model.json (expected JSON)\n");
11520
+ parseEmbedModel(JSON.parse(body2), url);
11521
+ } catch (e) {
11522
+ process.stderr.write(
11523
+ `codeindex: pull failed \u2014 response is not a valid model.json (${e instanceof Error ? e.message : String(e)}) (nothing written)
11524
+ `
11525
+ );
11494
11526
  process.exitCode = 1;
11495
11527
  return;
11496
11528
  }
11497
- writeFileSync3(join12(destDir, "model.json"), body2);
11498
- process.stderr.write(`codeindex: model written to ${join12(destDir, "model.json")}
11529
+ writeFileSync3(join13(destDir, "model.json"), body2);
11530
+ process.stderr.write(`codeindex: model written to ${join13(destDir, "model.json")}
11499
11531
  `);
11500
11532
  } else {
11501
11533
  throw new Error("embed needs a subcommand: status | build | pull | serve");
@@ -399,8 +399,9 @@ function readReceiver(node: TSNode | null): string | undefined {
399
399
  // dedicated member-call node's `name`; "constructor" reads the constructed type.
400
400
  // A qualified call also carries the immediate `receiver` name (see readReceiver).
401
401
  // Names are filtered to plausible identifiers (≥ 2 chars), deduped by name+line,
402
- // sorted, and capped, so the set stays small and deterministic.
403
- function collectCalls(root: TSNode, spec: LangSpec): { name: string; line: number; receiver?: string }[] {
402
+ // sorted, and capped (default MAX_CALLS; overridable via maxCalls), so the set
403
+ // stays small and deterministic.
404
+ function collectCalls(root: TSNode, spec: LangSpec, maxCalls: number = MAX_CALLS): { name: string; line: number; receiver?: string }[] {
404
405
  if (!spec.calls) return [];
405
406
  const out: { name: string; line: number; receiver?: string }[] = [];
406
407
  const seen = new Set<string>();
@@ -439,7 +440,7 @@ function collectCalls(root: TSNode, spec: LangSpec): { name: string; line: numbe
439
440
  };
440
441
  visit(root);
441
442
  out.sort((a, b) => byStr(a.name, b.name) || a.line - b.line);
442
- return out.slice(0, MAX_CALLS);
443
+ return out.slice(0, maxCalls);
443
444
  }
444
445
 
445
446
  // Collect JS/TS named-import bindings: `import { a, b as c } from "x"` →
@@ -476,7 +477,8 @@ function collectImportedNames(root: TSNode, spec: LangSpec): string[] {
476
477
  // Extract declared symbols from one file via its committed grammar. Returns
477
478
  // undefined when no grammar is loaded for the extension (caller falls back to the
478
479
  // regex extractor). Walks top-level declarations plus one level of nested members.
479
- export function extractAst(rel: string, ext: string, content: string): AstResult | undefined {
480
+ // `opts.maxCalls` overrides the per-file call-site cap (default MAX_CALLS).
481
+ export function extractAst(rel: string, ext: string, content: string, opts: { maxCalls?: number } = {}): AstResult | undefined {
480
482
  const key = grammarKeyForExt(ext);
481
483
  if (!key || !grammarReady(key)) return undefined;
482
484
  const spec = SPECS[key];
@@ -697,7 +699,7 @@ export function extractAst(rel: string, ext: string, content: string): AstResult
697
699
 
698
700
  const refs = collectImports(root, spec);
699
701
  const idents = collectRefIdents(root, new Set(symbols.map((s) => s.name)));
700
- const calls = collectCalls(root, spec);
702
+ const calls = collectCalls(root, spec, opts.maxCalls);
701
703
  const importedNames = collectImportedNames(root, spec);
702
704
  let pkg: string | undefined;
703
705
  if (spec.lang === "java") {
package/src/callers.ts CHANGED
Binary file
@@ -87,20 +87,17 @@ export function hasEmbedModel(repo?: string): boolean {
87
87
  return resolveEmbedModelDir(repo) !== undefined;
88
88
  }
89
89
 
90
- // Load and validate a static model from a directory containing model.json.
91
- // Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
92
- // loudly at load rather than silently misranking. Returns undefined only when
93
- // the directory has no model.json (the not-present case).
94
- export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
95
- if (!dir) return undefined;
96
- const path = join(dir, "model.json");
97
- if (!existsSync(path)) return undefined;
98
- const raw = JSON.parse(readFileSync(path, "utf8")) as ModelFile;
99
- const { modelId, dim, vocab, weights } = raw;
100
- if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
101
- 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}`);
102
99
  if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
103
- throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
100
+ throw new Error(`embed model: vocab/weights length mismatch in ${source}`);
104
101
  }
105
102
  const vocabSize = vocab.length;
106
103
  const flat = new Float64Array(vocabSize * dim);
@@ -115,11 +112,23 @@ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
115
112
  }
116
113
  for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
117
114
  }
118
- const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
115
+ const unk = typeof rawUnk === "string" ? rawUnk : "[UNK]";
119
116
  const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
120
117
  return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
121
118
  }
122
119
 
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
+
123
132
  // What `embed pull` fetches, and whether to verify it. CODEINDEX_EMBED_URL wins
124
133
  // outright (the user's explicit override / private mirror) and carries NO
125
134
  // sha256, so a custom asset keeps the current un-verified behavior. With no env,
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, fetchEmbedModel } 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 {
@@ -80,8 +80,11 @@ Flags:
80
80
  --exclude <glob> Exclude matching paths (repeatable)
81
81
  --scope <dir> Restrict to one directory (sugar for --include '<dir>/**')
82
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
83
85
  --max-files <n> Cap walked files (default 20000)
84
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)
85
88
  --no-ast Skip tree-sitter grammars even when present (regex tier)
86
89
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
87
90
  --limit <n> Max results for \`search\` (default 20)
@@ -103,8 +106,10 @@ interface CliFlags {
103
106
  exclude: string[];
104
107
  scope?: string;
105
108
  gitignore: boolean;
109
+ ignoreDirs: string[];
106
110
  maxFiles?: number;
107
111
  maxBytes?: number;
112
+ maxCalls?: number;
108
113
  noAst: boolean;
109
114
  since?: string;
110
115
  ignoreCase?: boolean;
@@ -121,7 +126,7 @@ interface CliFlags {
121
126
  }
122
127
 
123
128
  function parseFlags(args: string[]): CliFlags {
124
- 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 };
125
130
  for (let i = 0; i < args.length; i++) {
126
131
  const a = args[i]!;
127
132
  const next = (): string => {
@@ -144,8 +149,10 @@ function parseFlags(args: string[]): CliFlags {
144
149
  else if (a === "--exclude") flags.exclude.push(next());
145
150
  else if (a === "--scope") flags.scope = next();
146
151
  else if (a === "--no-gitignore") flags.gitignore = false;
152
+ else if (a === "--ignore-dir") flags.ignoreDirs.push(next());
147
153
  else if (a === "--max-files") flags.maxFiles = num();
148
154
  else if (a === "--max-bytes") flags.maxBytes = num();
155
+ else if (a === "--max-calls") flags.maxCalls = num();
149
156
  else if (a === "--ignore-case") flags.ignoreCase = true;
150
157
  else if (a === "--max-hits") flags.maxHits = num();
151
158
  else if (a === "--budget-tokens") flags.budgetTokens = num();
@@ -174,8 +181,10 @@ function scanOptions(flags: CliFlags): BuildIndexOptions {
174
181
  exclude: flags.exclude.length ? flags.exclude : undefined,
175
182
  scope: flags.scope,
176
183
  gitignore: flags.gitignore,
184
+ ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
177
185
  maxFiles: flags.maxFiles,
178
186
  maxBytes: flags.maxBytes,
187
+ maxCallsPerFile: flags.maxCalls,
179
188
  };
180
189
  }
181
190
 
@@ -404,9 +413,14 @@ export async function runCli(argv: string[]): Promise<void> {
404
413
  return;
405
414
  }
406
415
  try {
407
- JSON.parse(body);
408
- } catch {
409
- 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
+ );
410
424
  process.exitCode = 1;
411
425
  return;
412
426
  }
package/src/engine.ts CHANGED
@@ -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";
@@ -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
  }
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,6 +5,8 @@
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";
@@ -25,7 +27,7 @@ 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";
30
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "./embed/model.js";
29
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";
@@ -366,6 +368,31 @@ export async function memoizedEmbeddingIndex(
366
368
  return index;
367
369
  }
368
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
+ }
395
+
369
396
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
370
397
  const repo = str(args.repo);
371
398
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
@@ -530,7 +557,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
530
557
  }
531
558
  }
532
559
  const modelDir = resolveEmbedModelDir(repo);
533
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
560
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
534
561
  if (model) {
535
562
  const index = await memoizedEmbeddingIndex(
536
563
  { mode: "static", identity: `${modelDir}#${model.modelId}`, scan },
@@ -552,7 +579,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
552
579
  }
553
580
  if (name === "embed_status") {
554
581
  const modelDir = resolveEmbedModelDir(repo);
555
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
582
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
556
583
  const endpoint = resolveEmbedEndpoint();
557
584
  const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
558
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;
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.12.0";
4
+ export const ENGINE_VERSION = "2.13.0";
5
5
 
6
6
  // Bumped whenever the on-disk artifact shape changes, so a consumer can reject
7
7
  // an index written by an incompatible engine instead of misreading it. The
@@ -37,8 +37,11 @@ export const SCHEMA_VERSION = 4;
37
37
  // resolved export-alias symbol also cite the original declaration's own line
38
38
  // (and endLine, when the AST tier populated one) instead of the export
39
39
  // statement's line — a citation-precision fix (issue #9) for consumers
40
- // (ultradoc) that use file:line as evidence.
41
- export const EXTRACTOR_VERSION = 9;
40
+ // (ultradoc) that use file:line as evidence; v10 stops the regex tier
41
+ // emitting a spurious exported class symbol named "extends" for
42
+ // `export default class extends Base {}` (issue #11) — the file-stem
43
+ // default symbol is unchanged.
44
+ export const EXTRACTOR_VERSION = 10;
42
45
 
43
46
  // How a file is classified. `code` gets symbol/import extraction; `doc` gets
44
47
  // link/heading extraction; the rest are catalogued but not deeply parsed.
package/src/walk.ts CHANGED
@@ -4,12 +4,15 @@ import { parseGitignore, isIgnored, type IgnoreRule } from "./ignore.js";
4
4
 
5
5
  // Directories that never carry signal for a documentation/code question and
6
6
  // would bloat the index (dependencies, build output, VCS internals, caches).
7
+ // .codeindex is the engine's OWN output (index artifacts, pulled models, MCP
8
+ // memories) — indexing it would feed memories into search and churn the scan
9
+ // fingerprint on every write_memory (issue #12).
7
10
  // Exported so grep.ts can align ripgrep's universe with the walker's.
8
11
  export const IGNORE_DIRS = new Set([
9
12
  ".git", "node_modules", ".pnpm", "bower_components", "vendor", "dist", "build", "out",
10
13
  "target", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "__pycache__", ".venv",
11
14
  "venv", ".tox", ".mypy_cache", ".pytest_cache", ".gradle", ".idea", ".vscode", ".cache",
12
- "tmp", ".ultraindex", "Pods", "DerivedData", ".terraform", "elm-stuff", ".dart_tool",
15
+ "tmp", ".ultraindex", ".codeindex", "Pods", "DerivedData", ".terraform", "elm-stuff", ".dart_tool",
13
16
  ]);
14
17
 
15
18
  // Lockfiles: huge, machine-generated, and pure noise for a code/docs question —
@@ -36,6 +39,13 @@ export interface WalkOptions {
36
39
  // semantics — see ignore.ts). Default TRUE: an ignored file is noise for
37
40
  // every consumer; pass false to index generated/ignored trees deliberately.
38
41
  gitignore?: boolean;
42
+ // Directory names to skip, REPLACING the default set entirely (not merging
43
+ // with it). IGNORE_DIRS is a public export, so consumers compose
44
+ // `[...IGNORE_DIRS, "extra"]` — or filter it — themselves; replace is the
45
+ // simplest contract. Deliberate scope boundary: grep.ts (the ripgrep
46
+ // universe) and the MCP server keep the DEFAULT set — recall consumers
47
+ // (e.g. ultrasec) consume scan/extract, not grep.
48
+ ignoreDirs?: string[];
39
49
  }
40
50
 
41
51
  export interface WalkedFile {
@@ -65,6 +75,9 @@ export function walk(root: string, opts: WalkOptions = {}): WalkResult {
65
75
  const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024;
66
76
  const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
67
77
  const useGitignore = opts.gitignore !== false;
78
+ // Effective ignored-directory set, built once: the caller's replacement when
79
+ // given (see WalkOptions.ignoreDirs — replace, never merge), else the default.
80
+ const ignoreDirs = opts.ignoreDirs ? new Set(opts.ignoreDirs) : IGNORE_DIRS;
68
81
  const out: WalkedFile[] = [];
69
82
  let capped = false;
70
83
  let excluded = 0;
@@ -125,7 +138,7 @@ export function walk(root: string, opts: WalkOptions = {}): WalkResult {
125
138
  continue;
126
139
  }
127
140
  if (st.isDirectory()) {
128
- if (IGNORE_DIRS.has(name)) continue;
141
+ if (ignoreDirs.has(name)) continue;
129
142
  // An in-repo DIRECTORY symlink is skipped entirely: its target is (or
130
143
  // will be) walked under its canonical name, and letting both paths race
131
144
  // through the cycle guard would keep whichever readdir served first —