graph-indexer 2.0.0 → 2.1.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/CHANGELOG.md +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
package/README.md
CHANGED
|
@@ -23,13 +23,14 @@ Graph Indexer is a local [Model Context Protocol](https://modelcontextprotocol.i
|
|
|
23
23
|
|
|
24
24
|
- **Up to 98.2% Token Savings:** Delivers the exact AST chunk needed instead of dumping 1M token context files.
|
|
25
25
|
- **Blast radius before every edit.** `get_call_graph` and `find_references` surface every caller, subclass, and dependency a change would touch, so the agent reasons about impact on code it never opened.
|
|
26
|
-
- **Private by default.** Everything runs locally; the default path makes zero network calls and needs no model — your code never leaves your machine.
|
|
27
|
-
- **One command, any language.** Guided setup wires your editors in seconds, and the
|
|
26
|
+
- **Private by default.** Everything runs locally; the default path makes zero network calls and needs no model — your code never leaves your machine. (The very first index build fetches only the Tree-sitter grammar(s) for languages actually in your repo — see [Language grammars](#language-grammars) — cached from then on; `--sealed` enforces zero network hard.)
|
|
27
|
+
- **One command, any language.** Guided setup wires your editors in seconds, and the lexical engine indexes 14 languages — installing each one's grammar on first use, not bundled, so a fresh `npm install graph-indexer` stays small.
|
|
28
28
|
|
|
29
29
|
## 📦 Prerequisites
|
|
30
30
|
|
|
31
31
|
* **Node.js** 18+ (22+ recommended if you hit >15k chunks for automatic SQLite scaling).
|
|
32
32
|
* **OS:** Agnostic. macOS Apple Silicon required only for the optional MLX GPU acceleration.
|
|
33
|
+
* **Network (first index build only):** installs the Tree-sitter grammar(s) for languages in your repo (a few MB each) — see [Language grammars](#language-grammars). Cached after; fully offline from then on, or pre-install before going air-gapped.
|
|
33
34
|
* **Optional:** Ollama for embeddings, enrichment, and reranking (see [Ollama setup](#ollama-setup)).
|
|
34
35
|
* **Optional:** Python 3.10+ for the MLX embedder (macOS Apple Silicon only).
|
|
35
36
|
|
|
@@ -141,7 +142,7 @@ Before the steps, it scans the repo to pre-select your stack — languages from
|
|
|
141
142
|
|
|
142
143
|
It finishes with a grouped summary (created / updated / kept / skipped / warnings), offers to **build the index now**, and prints your next steps.
|
|
143
144
|
|
|
144
|
-
**Most repos need nothing past step 3's default** — the lexical engine
|
|
145
|
+
**Most repos need nothing past step 3's default** — the lexical engine works in any language, installing that language's grammar on the first index build. Step 3 only branches when you decline the defaults:
|
|
145
146
|
|
|
146
147
|
- **Storage** — `auto` (in-memory, promoting to SQLite past ~15k chunks), or force in-memory / SQLite.
|
|
147
148
|
- **Embeddings** — `off` (default), `auto`, **Ollama**, **local** (in-process MiniLM, ~25 MB on first index), or **Apple Metal (MLX)** on macOS. Ollama is probed only when you actually pick it; choosing MLX offers to provision its Python venv on the spot.
|
|
@@ -165,6 +166,10 @@ Setup also runs non-interactively whenever stdin isn't a TTY, so piping into it
|
|
|
165
166
|
- **Call graph.** `get_call_graph` returns the callers and callees of a symbol — the *blast radius* of a change — so an agent can see what it might break before editing code it never read.
|
|
166
167
|
- **Backend parity.** The in-memory and SQLite backends share the same ranking core, so they return identical top-5 results for the same query (enforced by `test/sqlite.mjs`).
|
|
167
168
|
|
|
169
|
+
### Language grammars
|
|
170
|
+
|
|
171
|
+
Tree-sitter grammars aren't bundled with the package — a fresh `npm install graph-indexer` doesn't download all 14 languages' native bindings, just the ~5 MB core engine. Instead, the first `idx-index` run installs the grammar(s) for whichever languages you actually selected (or, un-narrowed, whatever's detected) into `.graph-indexer/node_modules`, and reuses that cache on every later run — no re-download, no re-build. This works the same way whether graph-indexer is a project dependency, a global install, or run ad hoc via `npx`, because the install target is always your repo's own `.graph-indexer/` directory, not graph-indexer's own location. A platform-incompatible grammar (e.g. a missing native toolchain) is skipped on its own — other languages still install and index normally, the same graceful degradation `npm`'s `optionalDependencies` used to give for free. Under `--sealed`, auto-install is disabled entirely: an unresolvable grammar fails the run with the exact `npm install --prefix .graph-indexer <pkg>@<version>` command to run first, rather than silently shipping a partial index.
|
|
172
|
+
|
|
168
173
|
### MCP tools
|
|
169
174
|
|
|
170
175
|
| Tool | Returns |
|
|
@@ -175,11 +180,16 @@ Setup also runs non-interactively whenever stdin isn't a TTY, so piping into it
|
|
|
175
180
|
| `resolve_symbol` | Exact, case-insensitive symbol lookup by name. |
|
|
176
181
|
| `get_file_skeleton` | The top-level structure (symbols + signatures) of a file. |
|
|
177
182
|
| `get_call_graph` | Callers and callees of a symbol — the blast radius. |
|
|
178
|
-
| `find_references` | Where a symbol is used: callers, subclasses, and type references. |
|
|
183
|
+
| `find_references` | Where a symbol is used: callers, subclasses, and type references. On a `--resolver scip` index, adds a precise cross-file **`resolved`-tier** dimension that disambiguates same-named symbols and recovers references the name heuristics miss. |
|
|
179
184
|
| `find_routes` | HTTP routes mapped to their handler chunks (NestJS, FastAPI/Flask, Spring, Express/Koa). |
|
|
180
185
|
| `get_subgraph` | A bounded connected subgraph around a seed symbol — its callees, high-confidence callers, and type/inheritance users, in one call. |
|
|
181
|
-
| `get_repo_map` | A high-level map of the repository's modules and topology. |
|
|
186
|
+
| `get_repo_map` | A high-level map of the repository's modules and topology. With `--symbol-graph`, also lists the most-central symbols (PageRank). |
|
|
182
187
|
| `list_index_stats` | Index health: chunk/file/symbol/vector counts and the active config. |
|
|
188
|
+
| `tests_for` | The test/spec chunks that exercise a symbol (call or reference it) — which tests to run or update before changing it. |
|
|
189
|
+
| `explain_symbol` | One-call overview of a symbol: signature, callees, callers (blast radius), subclasses/type users, routes it handles, tests, git recency/co-change, and (with `--symbol-graph`) its symbol-centrality rank. |
|
|
190
|
+
| `impact_of_edit` | The precise blast radius of a change: pass the symbols/files you're about to edit → transitively-affected code, the routes that reach it, the tests to run, and git co-change. Most precise with `--symbol-graph`; `precision: 'strict'` follows only provably-unambiguous (`resolved`) edges (best on a `--resolver precise` or `--resolver scip` index). |
|
|
191
|
+
| `trace_taint` | Security: trace untrusted data from a source (request body/query/params, argv/env, stdin) to a dangerous sink (eval/exec, SQL, fs/path, HTML, outbound request) across the call graph — injection-class risks (rce/sqli/xss/path/ssrf) with the source→sink path. JS/TS, Python, Java, Go; heuristic finder, not a verifier. |
|
|
192
|
+
| `find_tainted_sinks` | Security orientation: every dangerous sink grouped by category, each flagged with whether an untrusted source reaches it — map the attack surface, then `trace_taint` a specific one. |
|
|
183
193
|
|
|
184
194
|
## Configuration
|
|
185
195
|
|
|
@@ -190,13 +200,20 @@ Everything beyond the lexical default is opt-in. The server, indexer, and daemon
|
|
|
190
200
|
| Option | Default | When to enable | Cost |
|
|
191
201
|
|--------|---------|----------------|------|
|
|
192
202
|
| `--embeddings` | off | Larger repos where recall matters; lifts success@5 | Requires Ollama or the in-process MiniLM model; slower indexing |
|
|
193
|
-
| `--embed-model qwen3-embedding:4b` | `nomic-embed-text` |
|
|
203
|
+
| `--embed-model qwen3-embedding:4b` | `nomic-embed-text` | A code-specialized embedder — lifts agent-style recall on Go (and some code-heavy repos); measure first | slower indexing; requires Ollama; neutral on JS/Python in our tests |
|
|
194
204
|
| `--enrichment` | off | Only useful paired with `--rerank`; alone it regresses | slowest indexing |
|
|
195
205
|
| `--rerank` | off | Go/Python repos with weak semantic recall; regresses JS repos | Requires an Ollama 7B model; adds query latency |
|
|
206
|
+
| `--rerank --rerank-provider cross-encoder` | `generative` | Want a reranker's semantic lift with **no LLM/daemon** — air-gapped, deterministic, fast | Optional `@huggingface/transformers`; smaller lift than the 7B judge |
|
|
196
207
|
| `--use-sqlite` | `auto` | Repos past ~15k chunks or memory-constrained environments | Slightly higher query latency; needs Node 22+ |
|
|
197
208
|
|
|
198
209
|
For most repos, the default (lexical + stemming, no embeddings) is the right starting point. Enable embeddings when you notice the agent missing chunks it should find. Enable the reranker only on Go or Python repos after measuring whether it helps — it is known to regress JavaScript repositories.
|
|
199
210
|
|
|
211
|
+
**Reranker providers.** The reranker reorders the over-fetched candidate pool on natural-language queries; three providers are available (`--rerank-provider`):
|
|
212
|
+
|
|
213
|
+
- **`generative`** (default) — a local LLM judge (Ollama 7B). The strongest reranker in our benchmarks (agent-style semantic rank-1 0.19 → 0.42 across the core suites; no symbolic regression), but it needs a running model and its output is non-deterministic.
|
|
214
|
+
- **`cross-encoder`** — a local MS-MARCO cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`, via the optional `@huggingface/transformers`). It scores each (query, candidate) pair and sorts. **Air-gapped, deterministic, and fast** (no Ollama/daemon; the model downloads once). It captures part of the lift — agent-style semantic rank-1 0.19 → 0.26, and notably Go (gin) 0.20 → 0.40 and Python (fastapi) 0.14 → 0.29 — but less than the 7B judge, and like all rerankers it is mixed on JavaScript. Pick it when you want a reranker without an LLM in the loop. Measure on your repo: `npm run test:eval -- --rerank --rerank-provider cross-encoder`.
|
|
215
|
+
- **`late-interaction`** (B4) — a **ColBERT-style multi-vector MaxSim** reranker. It encodes the query into several vectors (the whole query + content sub-units, via the same local embedder) and scores `Σ_qi max_dj cos(qi, dj)` against each candidate's stored document sub-vectors — **no storage blow-up** (it reuses the base/summary/window vectors `--embeddings` already writes), air-gapped, deterministic, parity-safe. **Requires `--embeddings`** (a no-op without it). Measured at production retrieval depth: **helps Go** (gin semantic rank-1 0.00 → 0.40, overall 0.68 → 0.77, no symbolic regression) and **neutral on JavaScript** (express unchanged) — the "helps Go/Python" half of the reranker profile without the JS regression. Approximates true token-level ColBERT with a sentence embedder. Measure on your repo: `npm run test:eval -- --embeddings --rerank --rerank-provider late-interaction`.
|
|
216
|
+
|
|
200
217
|
### All CLI flags
|
|
201
218
|
|
|
202
219
|
| Flag | Default | Effect |
|
|
@@ -211,7 +228,19 @@ For most repos, the default (lexical + stemming, no embeddings) is the right sta
|
|
|
211
228
|
| `--enrich-model <model>` | `qwen2.5-coder:1.5b` | Model used for enrichment. |
|
|
212
229
|
| `--enrich-max <n>` | 500 | Cap on new LLM calls per index run. |
|
|
213
230
|
| `--enrich-concurrency <n>` | 4 | Parallel Ollama requests during enrichment. |
|
|
214
|
-
| `--rerank` | off | Enable the
|
|
231
|
+
| `--rerank` | off | Enable the reranker (reorders the over-fetched pool on NL queries). |
|
|
232
|
+
| `--rerank-provider <generative\|cross-encoder\|late-interaction>` | `generative` | `generative` = local LLM judge; `cross-encoder` = local air-gapped MS-MARCO cross-encoder (optional `@huggingface/transformers`, no Ollama); `late-interaction` = ColBERT-style multi-vector MaxSim over the stored doc vectors (needs `--embeddings`; air-gapped, deterministic, no storage blow-up; measured helps-Go / neutral-JS at production depth). |
|
|
233
|
+
| `--interprocedural` | off | Index-time inter-procedural receiver-type fixpoint: propagate return types along factory call chains so multi-hop receivers resolve in `get_call_graph` / `find_references`. Air-gapped, deterministic; default index byte-identical; search ranking unaffected. |
|
|
234
|
+
| `--symbol-graph` | off | Persist a resolved chunk→chunk symbol graph (edges with kind + confidence) at index time, exposed via `getEdges`. `findCallers`/`findReferers` read it (identical sets, fall back to a scan). Also computes symbol-level centrality (confidence-weighted PageRank over the edges), surfaced by `explain_symbol` and `get_repo_map`. Air-gapped, deterministic; default index byte-identical; search ranking unaffected. |
|
|
235
|
+
| `--taint` | off | Precompute and serialize the taint flow set (C2) at index time, so `trace_taint` / `find_tainted_sinks` answer instantly instead of walking the call graph per query. Computed against the symbol-graph edges when `--symbol-graph` is on (cross-function precision), else `chunk.calls`. Both backends serve byte-identical flows; default index byte-identical; air-gapped. |
|
|
236
|
+
| `--ranker <rrf\|learned>` | `rrf` | Search ranker (D3). `rrf` (default) = the RRF + boost-ladder fusion. `learned` = an opt-in, zero-dependency post-fusion linear re-rank over the RRF score + symbol centrality + resolved/SCIP in-degree + git (best paired with `--symbol-graph`). **Measured ≈neutral on the benchmark** — RRF is the default; the learned ranker is provided for users to retrain on their own repo (`bench/train-ranker.mjs`). Default path byte-identical. |
|
|
237
|
+
| `--learned-sparse` | off | Learn a corpus co-occurrence (PMI) association model (B3) at index time and serialize it, adding a **learned-sparse vocabulary-expansion** channel: NL queries expand into a weighted term set (literal terms + learned associates) fused via RRF, to close lexical-gap misses stemming can't. Zero new dependency, air-gapped, NL-asymmetric (symbolic lookups byte-identical), parity-safe. **Measured net-neutral on the strict benchmark** (does not beat lexical — like BM25F/AST); shipped opt-in for repos with genuine query/code vocabulary mismatch. Default index byte-identical. |
|
|
238
|
+
| `--resolver <heuristic\|precise\|scip>` | `heuristic` | Resolver provider for the symbol graph. `precise` lifts provably-unambiguous edges (sole definition, or a type-pinned receiver) from `high` to a `resolved` tier. `scip` ingests a locally-generated SCIP index (`--scip-index`) and resolves edges by *real cross-file bindings* — promoting confirmed edges to `resolved` **and suppressing the wrong-target fan-out** an ambiguous same-name reference would otherwise emit, **and** adding a precise cross-file **`resolved`-tier** dimension to `find_references` (disambiguates same-named symbols, recovers references the name heuristics miss). Both power `impact_of_edit(precision: 'strict')`. Only meaningful with `--symbol-graph`. |
|
|
239
|
+
| `--scip-index <path>` | — | Path to a SCIP index generated locally by your own toolchain (`scip-typescript`, `scip-python`, `scip-java`, `rust-analyzer --scip`), consumed by `--resolver scip`. Air-gapped (a local file read — sealed-compatible). Files the index doesn't cover fall back to heuristic confidence (never worse); coverage is printed at index time. Accepts the `.scip` protobuf or a SCIP-shaped JSON. |
|
|
240
|
+
| `--sealed [strict\|local]` | off | **Sealed mode** — air-gapped *enforced and verifiable*. `strict` = zero network egress (lexical-only); `local` = loopback only (local LLM/embedders OK, nothing off-box). Fail-closed: refuses to start if any enabled feature would egress beyond the tier, and installs a deny-by-default runtime egress guard. Bare `--sealed` = `strict`. |
|
|
241
|
+
| `--attest` | — | Print the deterministic sealed-mode manifest (tier, providers, egress posture) and exit — an auditor/CI artifact. Add `--sign-key <path>` to print a **cryptographically signed** envelope instead (Ed25519/RSA/EC via `node:crypto`; air-gapped). |
|
|
242
|
+
| `--gen-attestation-key <prefix>` | — | Write an Ed25519 attestation keypair (`<prefix>.key` private, `<prefix>.pub` public) and exit. |
|
|
243
|
+
| `--verify-attestation <file> --pub-key <path>` | — | Verify a signed attestation's signature and report whether it still matches the current effective config (policy drift → non-zero exit). |
|
|
215
244
|
| `--no-git-signals` | (signals on) | Skip collecting local git churn/recency/co-change. |
|
|
216
245
|
| `--git-rank-boost <0..1>` | 0 | Opt-in weight for git recency/churn in ranking (0 = ranking unchanged). |
|
|
217
246
|
| `--llm-provider <ollama\|mlx>` | `ollama` | LLM backend for enrichment, reranking, and HyDE. `mlx` routes calls to a local `mlx_lm.server`. |
|
|
@@ -230,8 +259,18 @@ For most repos, the default (lexical + stemming, no embeddings) is the right sta
|
|
|
230
259
|
| `INDEXER_MLX_BATCH_SIZE` | 32 | Texts per batch sent to the MLX subprocess (raise on large unified memory). |
|
|
231
260
|
| `INDEXER_STORAGE` | `auto` | `auto` \| `memory` \| `sqlite`. |
|
|
232
261
|
| `ENRICH_MODEL` | (unset) | Naming a model enables enrichment and selects it. |
|
|
233
|
-
| `RERANK_MODEL` | (unset) | Naming a model enables the reranker and selects it. |
|
|
262
|
+
| `RERANK_MODEL` | (unset) | Naming a model enables the (generative) reranker and selects it. |
|
|
263
|
+
| `RERANK_PROVIDER` | `generative` | `generative` (LLM judge), `cross-encoder` (local air-gapped), or `late-interaction` (ColBERT-style MaxSim, needs embeddings); setting it enables reranking. |
|
|
264
|
+
| `RERANK_CROSS_ENCODER_MODEL` | `Xenova/ms-marco-MiniLM-L-6-v2` | Cross-encoder model id (downloaded on first use). |
|
|
234
265
|
| `INDEXER_GIT_SIGNALS` | (on) | Set to `off` to skip git-signal collection. |
|
|
266
|
+
| `INDEXER_INTERPROCEDURAL` | (off) | `on` enables the index-time inter-procedural receiver-type fixpoint (`--interprocedural`). |
|
|
267
|
+
| `INDEXER_SYMBOL_GRAPH` | (off) | `on` builds the persistent resolved symbol graph (`--symbol-graph`). |
|
|
268
|
+
| `INDEXER_TAINT` | (off) | `on` precomputes + serializes the taint flow set at index time (`--taint`). |
|
|
269
|
+
| `INDEXER_RANKER` | `rrf` | `learned` enables the opt-in post-fusion learned re-rank (`--ranker`). |
|
|
270
|
+
| `INDEXER_LEARNED_SPARSE` | (off) | `on` learns + serializes the corpus association model for the learned-sparse expansion channel (`--learned-sparse`). |
|
|
271
|
+
| `INDEXER_RESOLVER` | `heuristic` | `precise` enables the precise resolver; `scip` enables the SCIP cross-file resolver (`resolved` edge tier) for the symbol graph (`--resolver`). |
|
|
272
|
+
| `INDEXER_SCIP_INDEX` | (unset) | Path to a locally-generated SCIP index for `--resolver scip` (`--scip-index`). |
|
|
273
|
+
| `INDEXER_SEALED` | (off) | `strict` (or `on`) / `local` enables sealed mode (`--sealed`): fail-closed validation + a runtime egress guard. |
|
|
235
274
|
| `INDEXER_GIT_RANK_BOOST` | 0 | Opt-in git recency/churn ranking weight (0..1). |
|
|
236
275
|
| `INDEXER_LLM_PROVIDER` | `ollama` | LLM backend for enrichment, reranking, and HyDE: `ollama` or `mlx`. |
|
|
237
276
|
| `INDEXER_MLX_LM_HOST` | `http://localhost:8080` | Endpoint for the `mlx_lm.server` when `INDEXER_LLM_PROVIDER=mlx`. |
|
|
@@ -257,6 +296,8 @@ Set the backend with `--embed-provider` (or `INDEXER_EMBED_PROVIDER`). `mlx` is
|
|
|
257
296
|
|
|
258
297
|
The index records which provider/model built it (in the `code-index.embeddings.bin.meta.json` sidecar) and queries with the same one, so vectors never get mixed across models. Switching providers or the MLX model between builds is detected and triggers a clean re-embed.
|
|
259
298
|
|
|
299
|
+
**Code-specialized embedding models.** The default `nomic-embed-text` is a general-purpose embedder. Models trained on code — `qwen3-embedding`, `nomic-embed-code`, `jina-embeddings-v2-base-code` — embed code semantics more faithfully and can lift agent-style (natural-language) recall. Select one with `--embed-model <name>` (Ollama); it is stamped into the index meta and queried with the same model so vectors never get mixed. **Measure before adopting** — in our benchmarks `qwen3-embedding` lifted Go (gin) semantic rank-1 0.20 → 0.40 but was neutral on Python (django) and JavaScript (express), and it indexes substantially slower than `nomic-embed-text`. Run `npm run test:eval -- --embeddings` on your own repo to confirm the trade-off. We also evaluated *re-weighting the vector channel upward* for these stronger embedders and found **no measurable recall@5 gain** (it regressed rank-1/symbolic at higher weights) — the existing default weighting already extracts the benefit, so there is no separate "code weight" knob. `jina-embeddings-v2-base-code` remains opt-in only: it helped some fixtures, regressed others, and indexes far slower, so it is never a default.
|
|
300
|
+
|
|
260
301
|
**macOS Apple Silicon (recommended):**
|
|
261
302
|
|
|
262
303
|
```bash
|
|
@@ -275,7 +316,7 @@ Every number here is produced by the eval harness on cold, isolated builds — n
|
|
|
275
316
|
What the measurements say, across 18 real-world fixtures spanning every supported language:
|
|
276
317
|
|
|
277
318
|
- **The zero-dependency lexical default is the right starting point.** It wins the held-out metric outright on 2 fixtures, and 6 more need only a cheap in-process embedder — so **8 of 18 run with no Ollama and no network**. Most repos never need more.
|
|
278
|
-
- **Symbolic lookups are strong** (mean rank-1 ≈ 0.70). Behavioural, natural-language queries are harder (≈ 0.30
|
|
319
|
+
- **Symbolic lookups are strong** (mean rank-1 ≈ 0.70). Behavioural, natural-language queries are harder (≈ 0.30 overall (held-out semantic rank-1 0.40 with stemming)) — that's where the optional embedding and reranker channels earn their keep.
|
|
279
320
|
- **Optional features are measured, not assumed.** Dense embeddings lift recall on larger repos; the LLM reranker is language- and repo-dependent (it lifts 8 fixtures but taxes JavaScript and Spring); enrichment helps only where proven (just rust). Setup surfaces each trade-off before you enable it.
|
|
280
321
|
- **Backend parity:** the in-memory and SQLite backends return byte-identical top-5 on all 18 fixtures (gated by `test/sqlite.mjs`).
|
|
281
322
|
|
|
@@ -288,14 +329,16 @@ Per-fixture best configs, 3× spreads, and copy-paste enable flags live in **[do
|
|
|
288
329
|
| Capability | Strong | Limited |
|
|
289
330
|
|---|---|---|
|
|
290
331
|
| **Call graph** (callers/callees) | resolves on every supported language | Java/Spring is class-granular; SCSS effectively none |
|
|
291
|
-
| **Caller precision** | receiver-aware: TS/JS, Python, C#, Swift, PHP | name-only: Go, Rust, Kotlin, Ruby, Bash, C |
|
|
332
|
+
| **Caller precision** | receiver-aware: TS/JS, Python, C#, Swift, PHP, Java (call-site level — but its call graph is class-granular) | name-only: Go, Rust, Kotlin, Ruby, Bash, C |
|
|
292
333
|
| **Typed `find_references`** | precise: TS/JS, Python · field-precise: C# | heuristic: Java/PHP/Kotlin/Swift/Rust/Go/C · empty: Ruby, Bash, SCSS, dynamic JS/Python |
|
|
293
334
|
|
|
294
335
|
AST chunking and lexical search cover **every** supported language; only the typed cross-reference channel narrows for dynamic ones.
|
|
295
336
|
|
|
337
|
+
**Multi-hop receivers (`--interprocedural`).** Caller precision relies on resolving a call's receiver type. Intra-procedurally, graph-indexer already resolves `const r = makeRepo(); r.save()` one hop (reading `makeRepo`'s recorded return type). The opt-in `--interprocedural` flag adds an index-time fixpoint that propagates return types *along factory call chains*, so a receiver whose type comes from a multi-hop or unannotated factory (`makeRepoIndirect()` → `makeRepo()` → `new OrderRepo()`) still resolves to its concrete class — promoting those callers from *name-only* to *high-confidence* in `get_call_graph` / `find_references`. It is air-gapped and deterministic, the result is serialized into the index (both backends identical), and it leaves search ranking and the default index byte-identical. Note: the watch daemon updates one file at a time and does not re-run the whole-program pass, so cross-file chains refresh on the next full `idx-index` (degrading to single-hop until then, never worse).
|
|
338
|
+
|
|
296
339
|
### Where it's weakest (honestly)
|
|
297
340
|
|
|
298
|
-
- Behavioural, natural-language recall on the default path (rank-1 ≈ 0.30) — closing it needs the embedding/reranker channel, not lexical tuning.
|
|
341
|
+
- Behavioural, natural-language recall on the default path (rank-1 ≈ 0.30 overall; held-out semantic rank-1 0.40 — the embedding/reranker channel closes it further) — closing it needs the embedding/reranker channel, not lexical tuning.
|
|
299
342
|
- The hardest fixtures stay below 0.65 held-out success@5 even with the full stack (rails 0.50, rust 0.61).
|
|
300
343
|
- Java/Spring `get_call_graph` reports at class granularity (the god-class split only fires ≥200 lines); SCSS has no meaningful call graph.
|
|
301
344
|
- LLM enrichment without reranking regresses precision — enable it only paired with `--rerank`.
|
package/SECURITY.md
CHANGED
|
@@ -36,6 +36,16 @@ shaped by a few deliberate choices:
|
|
|
36
36
|
from the local cache. Your source code is never sent anywhere — only model weights
|
|
37
37
|
are fetched. For strictly air-gapped installs, set `embedProvider` to `"ollama"` or
|
|
38
38
|
`"off"`, or pre-populate the model cache on a connected machine.
|
|
39
|
+
- **Sealed mode (opt-in, fail-closed enforcement).** For regulated or strictly
|
|
40
|
+
air-gapped installs, `--sealed strict` (`INDEXER_SEALED`) enforces **zero** network
|
|
41
|
+
egress (lexical-only), and `--sealed local` permits **loopback only** (a local
|
|
42
|
+
Ollama/MLX on this box is allowed, nothing leaves it). It is *fail-closed*: the build
|
|
43
|
+
refuses to start if any enabled feature (embeddings/enrichment/rerank/HyDE) would
|
|
44
|
+
egress beyond the tier, and it installs a **deny-by-default runtime egress guard** on
|
|
45
|
+
the in-process network paths (`net`/`tls` sockets, `http(s)`, `fetch`) so even an
|
|
46
|
+
accidental call is blocked — turning "we don't call out" into "we *can't*."
|
|
47
|
+
`idx-index --attest` prints a deterministic attestation manifest (optionally
|
|
48
|
+
Ed25519/RSA-**signed** and CI-verifiable) of the egress posture. See `seal.mjs`.
|
|
39
49
|
- **No code execution.** Source files are parsed into ASTs with Tree-sitter. The
|
|
40
50
|
indexer never imports, evaluates, or runs the code it indexes. The one subprocess
|
|
41
51
|
it spawns is a local, read-only `git log` (see "git signals" below) — never the
|
|
@@ -57,6 +67,13 @@ shaped by a few deliberate choices:
|
|
|
57
67
|
sensitivity as the repository itself and do not commit or share them.
|
|
58
68
|
- **MCP transport.** The server communicates over stdio with the local MCP client
|
|
59
69
|
(your IDE/agent). It does not open a network socket.
|
|
70
|
+
- **Taint analysis is advisory, not a guarantee.** The opt-in `trace_taint` /
|
|
71
|
+
`find_tainted_sinks` MCP tools statically trace untrusted sources to dangerous sinks
|
|
72
|
+
over the call graph using pure regex catalogs + graph traversal (no model, no network,
|
|
73
|
+
read-only). They are a **finder, not a verifier**: they favour precision and miss flows
|
|
74
|
+
through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
|
|
75
|
+
collections — **"0 findings" is not proof of safety.** Use them for orientation, not as
|
|
76
|
+
a security gate.
|
|
60
77
|
|
|
61
78
|
## Operational guidance
|
|
62
79
|
|
package/colbert.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file colbert.mjs
|
|
3
|
+
* @description B4 — ColBERT-style LATE-INTERACTION reranker (multi-vector MaxSim). A third,
|
|
4
|
+
* opt-in reranker provider (rerank.provider === 'late-interaction'), sibling to the
|
|
5
|
+
* generative judge and the cross-encoder (B2). Where a single-vector dense channel
|
|
6
|
+
* pools the query and the document each into ONE vector (losing which part matched
|
|
7
|
+
* where), late interaction keeps MULTIPLE vectors per side and scores them with MaxSim:
|
|
8
|
+
*
|
|
9
|
+
* score(q, d) = Σ_{qi} max_{dj} cos(qi, dj)
|
|
10
|
+
*
|
|
11
|
+
* i.e. every query sub-vector finds its best-matching document sub-vector and the hits
|
|
12
|
+
* sum — the compositional-query win ColBERT is known for. It reuses the document
|
|
13
|
+
* sub-vectors the engine ALREADY stores (the base + `|s` summary + `|wN` window vectors
|
|
14
|
+
* of each chunk), so there is **no storage blow-up** — the canonical ColBERT cost. It is
|
|
15
|
+
* deployed as a RERANKER over the over-fetched candidate pool (the standard
|
|
16
|
+
* retrieve-then-late-interact pattern), so it is a post-retrieval re-order — the default
|
|
17
|
+
* ranking is untouched and parity is preserved (both backends read the SAME bin → the
|
|
18
|
+
* SAME doc vectors → the SAME MaxSim → the SAME order).
|
|
19
|
+
*
|
|
20
|
+
* AIR-GAPPED / HONEST SCOPE: the query multi-vectors are produced by the SAME local
|
|
21
|
+
* embedder the dense channel already uses (no new model, no network). A true ColBERT
|
|
22
|
+
* uses a token-level CONTEXTUAL encoder; a general sentence embedder over query
|
|
23
|
+
* sub-units only APPROXIMATES that — so this is "late interaction over the existing
|
|
24
|
+
* encoder," with a real token-level ColBERT model a clean future drop-in behind
|
|
25
|
+
* `encodeMultiVector`. Requires --embeddings (the doc vectors + the embedder); a no-op
|
|
26
|
+
* (returns the original order) when either is absent.
|
|
27
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
28
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
29
|
+
* @license MIT
|
|
30
|
+
*/
|
|
31
|
+
import { collectVectorsByKey } from './engine/binary.mjs';
|
|
32
|
+
import { embeddingKeyFor, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS } from './search-core.mjs';
|
|
33
|
+
|
|
34
|
+
// Query sub-unit extraction is intentionally light (no stemming — surface forms, like the dense
|
|
35
|
+
// channel). A tiny stopword guard keeps function words from spending a query vector slot.
|
|
36
|
+
const STOP = new Set([
|
|
37
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
|
|
38
|
+
'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what', 'when', 'where', 'which',
|
|
39
|
+
'who', 'why', 'does', 'do', 'it', 'its', 'into', 'if', 'then', 'there', 'all', 'any',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Cosine similarity of two equal-length vectors (0 when either is degenerate). */
|
|
43
|
+
export function cosine(a, b) {
|
|
44
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
45
|
+
let dot = 0, na = 0, nb = 0;
|
|
46
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
47
|
+
if (na === 0 || nb === 0) return 0;
|
|
48
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* MaxSim late-interaction score: Σ over query vectors of the max cosine to any document vector.
|
|
53
|
+
* Order-independent (a sum of maxes) → no parity dependence on vector ordering.
|
|
54
|
+
* @param {Float32Array[]} qVecs query sub-vectors
|
|
55
|
+
* @param {Float32Array[]} dVecs document sub-vectors
|
|
56
|
+
* @returns {number} 0 when either side is empty
|
|
57
|
+
*/
|
|
58
|
+
export function maxSimScore(qVecs, dVecs) {
|
|
59
|
+
if (!qVecs?.length || !dVecs?.length) return 0;
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (const q of qVecs) {
|
|
62
|
+
let best = -Infinity;
|
|
63
|
+
for (const d of dVecs) { const s = cosine(q, d); if (s > best) best = s; }
|
|
64
|
+
if (best > -Infinity) total += best;
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The embedding keys that hold a chunk's stored sub-vectors (base + summary + windows). */
|
|
70
|
+
function chunkVectorKeys(chunk) {
|
|
71
|
+
const vecKey = embeddingKeyFor(chunk);
|
|
72
|
+
const keys = [vecKey, chunk.content_hash, vecKey + SUMMARY_VEC_SUFFIX];
|
|
73
|
+
for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) keys.push(vecKey + WINDOW_VEC_SUFFIX + i);
|
|
74
|
+
return { vecKey, keys };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load each chunk's stored sub-vectors from the shared `.embeddings.bin` in a SINGLE pass (one bin
|
|
79
|
+
* read per rerank, not per candidate). Identical for both backends (same file, same keys) →
|
|
80
|
+
* parity-by-construction. @returns {Map<chunkId, Float32Array[]>}
|
|
81
|
+
*/
|
|
82
|
+
export function loadChunkVectors(binPath, chunks) {
|
|
83
|
+
const meta = new Map(); // chunkId → { vecKey, hash }
|
|
84
|
+
const allKeys = new Set();
|
|
85
|
+
for (const c of chunks) {
|
|
86
|
+
if (!c || !c.content_hash) { meta.set(c?.id, null); continue; }
|
|
87
|
+
const { vecKey, keys } = chunkVectorKeys(c);
|
|
88
|
+
meta.set(c.id, { vecKey, hash: c.content_hash });
|
|
89
|
+
for (const k of keys) allKeys.add(k);
|
|
90
|
+
}
|
|
91
|
+
const found = collectVectorsByKey(binPath, allKeys);
|
|
92
|
+
const out = new Map();
|
|
93
|
+
for (const [id, m] of meta) {
|
|
94
|
+
if (!m) { out.set(id, []); continue; }
|
|
95
|
+
const vecs = [];
|
|
96
|
+
const base = found.get(m.vecKey) || found.get(m.hash); // deterministic order: base, summary, windows
|
|
97
|
+
if (base) vecs.push(base);
|
|
98
|
+
const s = found.get(m.vecKey + SUMMARY_VEC_SUFFIX); if (s) vecs.push(s);
|
|
99
|
+
for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) { const w = found.get(m.vecKey + WINDOW_VEC_SUFFIX + i); if (w) vecs.push(w); }
|
|
100
|
+
out.set(id, vecs);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Encode a query into MULTIPLE vectors (the whole query plus its content sub-units) with the SAME
|
|
107
|
+
* local embedder the dense channel uses. Best-effort: returns [] on any failure / no embedder.
|
|
108
|
+
* @param {{embedDocuments?:Function, embedQuery?:Function}} embedder
|
|
109
|
+
* @param {string} text
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @returns {Promise<Float32Array[]>}
|
|
112
|
+
*/
|
|
113
|
+
export async function encodeMultiVector(embedder, text, { maxVecs = 8 } = {}) {
|
|
114
|
+
if (!embedder || typeof embedder.embedDocuments !== 'function') return [];
|
|
115
|
+
const words = String(text).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
|
|
116
|
+
const units = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const w of words) {
|
|
119
|
+
if (w.length < 3 || STOP.has(w) || seen.has(w)) continue;
|
|
120
|
+
seen.add(w); units.push(w);
|
|
121
|
+
if (units.length >= maxVecs - 1) break;
|
|
122
|
+
}
|
|
123
|
+
units.unshift(String(text)); // the holistic query vector always leads
|
|
124
|
+
let vecs;
|
|
125
|
+
try { vecs = await embedder.embedDocuments(units); }
|
|
126
|
+
catch { return []; }
|
|
127
|
+
return (vecs || []).filter(v => v && v.length).map(v => v instanceof Float32Array ? v : new Float32Array(v));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Late-interaction RERANK of the over-fetched pool. Re-orders the top-`topM` by MaxSim of the query
|
|
132
|
+
* multi-vectors against each candidate's stored doc sub-vectors; the tail is preserved. Best-effort
|
|
133
|
+
* and never throws: any shortfall (no embedder, <2 candidates with vectors, empty query vectors)
|
|
134
|
+
* returns the ORIGINAL order, so the channel can only help, never corrupt. Never mutates `r.score`
|
|
135
|
+
* (protects the downstream git-boost / formatting), exactly like rerankCrossEncoder.
|
|
136
|
+
*
|
|
137
|
+
* @param {Array<{chunk:object, score:number}>} results
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {Float32Array[]} opts.qVecs query multi-vectors (from encodeMultiVector)
|
|
140
|
+
* @param {string} opts.binPath the `.embeddings.bin` path (db.embeddingBinPath())
|
|
141
|
+
* @param {number} [opts.topM]
|
|
142
|
+
* @returns {Array}
|
|
143
|
+
*/
|
|
144
|
+
export function rerankLateInteraction(results, { qVecs, binPath, topM = 12 } = {}) {
|
|
145
|
+
const head = results.slice(0, topM);
|
|
146
|
+
if (head.length < 2 || !qVecs?.length || !binPath) return results;
|
|
147
|
+
const vecsById = loadChunkVectors(binPath, head.map(r => r.chunk));
|
|
148
|
+
const scored = head.map((r, i) => {
|
|
149
|
+
const dVecs = vecsById.get(r.chunk.id) || [];
|
|
150
|
+
return { i, id: r.chunk.id, s: dVecs.length ? maxSimScore(qVecs, dVecs) : NaN };
|
|
151
|
+
});
|
|
152
|
+
// "Can only help, never corrupt": treat no-signal as a shortfall. Re-order only when at least two
|
|
153
|
+
// candidates carry a FINITE and DISTINCT MaxSim — so all-zero scores (e.g. a dim mismatch making
|
|
154
|
+
// every cosine 0) or undifferentiated ties fall through to the original order instead of being
|
|
155
|
+
// scrambled into id-lexicographic order by the tie-break.
|
|
156
|
+
const finite = scored.filter(x => Number.isFinite(x.s));
|
|
157
|
+
if (finite.length < 2 || new Set(finite.map(x => x.s)).size < 2) return results;
|
|
158
|
+
const order = scored
|
|
159
|
+
// finite MaxSim first (desc); candidates without vectors sink but keep a stable id order
|
|
160
|
+
.sort((a, b) => {
|
|
161
|
+
const af = Number.isFinite(a.s), bf = Number.isFinite(b.s);
|
|
162
|
+
if (af && bf) return (b.s - a.s) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
163
|
+
if (af) return -1; if (bf) return 1;
|
|
164
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
165
|
+
})
|
|
166
|
+
.map(x => x.i);
|
|
167
|
+
return [...order.map(i => head[i]), ...results.slice(topM)];
|
|
168
|
+
}
|