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 CHANGED
@@ -4,6 +4,448 @@ All notable changes to graph-indexer are documented here. Dates are in YYYY-MM-D
4
4
 
5
5
  ---
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Install-size fix: heavy optional packages no longer ship by default
10
+
11
+ - **`@huggingface/transformers` (→ onnxruntime-node, ~950 MB with the full model/runtime chain)
12
+ and `hnswlib-node` are no longer `optionalDependencies`.** npm's `optionalDependencies`
13
+ install eagerly regardless of the name — "optional" only means "don't fail the whole install
14
+ if this can't build on this platform" — so every `npm i graph-indexer` was downloading the
15
+ local-embeddings/cross-encoder-rerank stack even for lexical-only and Ollama-only users. They
16
+ are now `devDependencies` (available for this repo's own tests/CI, never installed as a
17
+ dependent). The runtime already lazy-`import()`s them with a clear "install it or change
18
+ provider" error — that design was correct; only the packaging contradicted it. New
19
+ `npm run embed:setup:local` one-liner installs it on demand, mirroring `embed:setup:mlx`; the
20
+ `init.mjs` wizard now offers to install it inline when "Local (in-process)" embeddings are
21
+ selected (or "Auto" falls through because Ollama isn't reachable).
22
+ - **The 15 Tree-sitter language grammars (~250 MB combined) are also no longer bundled.** Same
23
+ fix, larger surface: `parse/languages.mjs` now resolves each grammar ambiently first (this
24
+ repo's own devDependencies, or a project that installed one directly), then from a scoped
25
+ `.graph-indexer/node_modules` install dir, auto-installing whatever's missing there on the
26
+ first `idx-index`/`idx-mcp`/daemon run for the languages actually selected/detected — cached
27
+ from then on, works identically whether graph-indexer is a project dependency, a global
28
+ install, or run via bare `npx` (the install always targets the repo being indexed, not
29
+ graph-indexer's own location). Each grammar installs independently, so one platform-
30
+ incompatible package (no native toolchain) only skips that language, not the whole batch — the
31
+ same graceful degradation `optionalDependencies` used to give for free, now paired with a
32
+ negative-install-cache so a known-unbuildable grammar isn't retried on every run. `--sealed`
33
+ disables auto-install entirely and fails the run with the exact remedy command if a configured
34
+ language's grammar isn't already resolvable, rather than silently shipping a partial index.
35
+ Verified end-to-end: a fresh `npm install graph-indexer` drops from ~1.5 GB to ~27 MB of
36
+ `node_modules`; `test:all` (including `test:languages` and `test:seal`) stays green.
37
+
38
+ ---
39
+
40
+ ## [2.1.0] — 2026-06-25
41
+
42
+ Frontier upgrade (Phases 1–3). Every item is **opt-in**: the default path stays lexical-only,
43
+ in-memory, zero-dependency, with byte-identical `npm run test:eval` output and byte-identical
44
+ memory↔sqlite top-5 parity. Headline additions: code-aware retrieval knobs (B1/B2), an
45
+ inter-procedural receiver-type fixpoint (A3), a **persistent resolved symbol graph** with
46
+ symbol-level **centrality** (A4/A5), a **precise resolver** + cross-file **SCIP** resolution
47
+ (A1/A2), `impact_of_edit` / `explain_symbol` / `tests_for` MCP tools (C1/C4/C5/D1), **sealed mode**
48
+ with a runtime egress guard and **signed attestation** (F1), **taint analysis** (`trace_taint` /
49
+ `find_tainted_sinks`) with index-time serialization across JS/TS/Python/Java/Go (C2), an opt-in
50
+ **learned ranker** (D3, measured a tie — RRF stays the default), **SCIP-backed precise cross-file
51
+ references** for `find_references` (A2 v2), an opt-in **learned-sparse** vocabulary-expansion
52
+ channel (B3, measured net-neutral — lexical stays the default), and a **ColBERT-style
53
+ late-interaction reranker** (B4, measured helps-Go / neutral-JS at production depth — opt-in, default
54
+ off). Every capability ships behind a flag + env + config key with the trade-off printed at startup;
55
+ nothing changes the default path.
56
+
57
+ ### Code-specialized embedding models — documented; NL vector-weight re-tune evaluated and rejected (B1)
58
+
59
+ - **Code-specialized embedders are now documented as a first-class opt-in.** `qwen3-embedding`,
60
+ `nomic-embed-code`, and `jina-embeddings-v2-base-code` embed code semantics more faithfully than
61
+ the general-purpose `nomic-embed-text` default. They are selected with `--embed-model <name>`
62
+ (no new code — Ollama models are chosen by name) and stamped into the index meta so query and
63
+ document vectors always share a space. **Measured (honest):** on a clean `qwen3-embedding:16k`
64
+ re-embed, semantic rank-1 lifted on Go (`gin` 0.20 → 0.40, MRR 0.41 → 0.67) but was neutral on
65
+ Python (`django` rank-1 0.33 → 0.33) and JavaScript (`express-js` 0.43 → 0.43); it also indexes
66
+ substantially slower. So a code embedder is a *measure-first* opt-in, not a default.
67
+ - **Rejected: re-weighting the NL vector channel upward for code embedders.** We implemented an
68
+ opt-in weight profile and swept the natural-language vector weight from 0.4 → 1.2 on
69
+ `gin`/`django` with `qwen3-embedding`. There was **no recall@5 gain at any weight** (gin s@5 flat
70
+ at 1.00, django at 0.83); rank-1 was flat-to-negative and symbolic regressed at higher weights
71
+ (`django` symbolic rank-1 0.72 → 0.61). The existing default weighting (`NL_VECTOR_WEIGHT_PLAIN
72
+ = 0.4`) is already optimal even for the stronger embedder — the vector channel's job here is
73
+ low-weight *rescue*, and recall is already saturated. The profile was therefore **not shipped**
74
+ (no placebo knob); the ranking core is unchanged. Full write-up:
75
+ `docs/internals/IMPROVEMENT_CODE_EMBED.md`.
76
+
77
+ ### Local cross-encoder reranker provider (B2)
78
+
79
+ - **`--rerank-provider cross-encoder`** (`RERANK_PROVIDER` / `rerank.provider` config) adds a
80
+ second, **air-gapped** reranker alongside the generative LLM judge. It scores each
81
+ (query, candidate) pair with a local MS-MARCO cross-encoder
82
+ (`Xenova/ms-marco-MiniLM-L-6-v2`, via the optional `@huggingface/transformers` — the same dep
83
+ as the in-process embedder) and sorts by score. No Ollama, no daemon, **deterministic**, fast
84
+ (~tens of ms per NL query); the model downloads once on first use.
85
+ - **Measured (honest), core suites, lexical channel:** agent-style semantic rank-1 0.19 → 0.26
86
+ (held-out 0.30 → 0.35; held-out s@5 0.48 → 0.52), strongest on Go (gin 0.20 → 0.40) and Python
87
+ (fastapi 0.14 → 0.29). It is **weaker than the generative 7B judge** (0.42 agent-style rank-1)
88
+ and shows the usual reranker behaviour on JavaScript (mixed) plus a small symbolic wobble (long
89
+ symbolic queries that pass the NL gate). The generative judge stays the higher-reasoning tier;
90
+ the cross-encoder is the no-LLM-in-the-loop option. Full write-up:
91
+ `docs/internals/IMPROVEMENT_CROSS_ENCODER.md`.
92
+ - Implementation: `rerankCrossEncoder` / `crossEncoderScore` in `enrichment.mjs` (sibling to the
93
+ generative `rerankResults`, lazy-loads transformers, deterministic score-desc/id-asc sort,
94
+ never mutates the fused score, degrades gracefully); dispatched in `mcp/tools.mjs` on
95
+ `rerank.provider`, reusing the existing over-fetch pool unchanged. Default path (provider
96
+ `generative`, reranker off) is byte-identical; memory↔sqlite parity unaffected. New
97
+ `test/unit.mjs` tests inject the scorer (no model in CI).
98
+
99
+ ### Inter-procedural receiver-type fixpoint (A3)
100
+
101
+ - **`--interprocedural`** (`INDEXER_INTERPROCEDURAL` / `interprocedural` config) adds an
102
+ index-time, whole-program fixpoint that propagates function return types along **factory call
103
+ chains**, so a call-site receiver whose type comes from a multi-hop or unannotated factory
104
+ resolves to its concrete class. Example: `makeRepoIndirect()` returns `makeRepo()` returns
105
+ `new OrderRepo()` — the indirect factory has no return type to read, but the fixpoint resolves
106
+ the chain, so `const m = makeRepoIndirect(); m.save()` is now a **high-confidence** caller of
107
+ `OrderRepo.save` in `get_call_graph` / `find_references` instead of a name-only match.
108
+ - **Design:** a new `parse/interprocedural.mjs` runs a bounded (`MAX_ITERS = 8`), monotone,
109
+ deterministic worklist over a per-symbol return-type lattice (conflicts — ≥2 distinct types —
110
+ are conservatively dropped). It writes `recv_resolved_type` onto the relevant call sites;
111
+ because `call_sites` is serialized identically by both backends, the resolved data is
112
+ **parity-free**. `classifyCallers` prefers `recv_resolved_type` and keeps the existing 1-hop
113
+ `recv_via_call` fallback, so an index built without the pass (or a per-file daemon update) is
114
+ never worse. The transient capture (`_return_via`) is stripped before serialization.
115
+ - **Invariants:** OFF by default → the index and `get_call_graph` output are byte-identical, and
116
+ `npm run test:eval` is unchanged (search ranking never reads receiver types). Air-gapped,
117
+ zero new runtime deps. **Residual limitation (documented):** the watch daemon re-indexes one
118
+ file at a time and does not re-run the whole-program pass, so cross-file chains refresh on the
119
+ next full `idx-index`. New `test/callgraph.mjs` tests prove a 2-hop chain promotes only with the
120
+ fixpoint, the conflict guard holds, and `resolveReturnTypes` is order-independent. Write-up:
121
+ `docs/internals/IMPROVEMENT_INTERPROCEDURAL.md`.
122
+
123
+ ### Test→code mapping + two compound MCP tools (C5 + D1)
124
+
125
+ The MCP tool surface grows from 11 to 13. Both new tools are read-only and compose existing
126
+ store primitives — no ranking, parity, or default-path impact.
127
+
128
+ - **`tests_for(symbol)`** — the test/spec chunks that exercise a symbol: a chunk under a
129
+ test path (`TEST_FILE_RE`) that calls or references it. Reuses `findCallers` + `findReferers`
130
+ filtered to tests, deterministically ordered (file, line, id). Tells an agent which tests to
131
+ run or update before a change, and how the symbol is meant to behave.
132
+ - **`explain_symbol(symbol, target_class?)`** — a single-round-trip overview that previously took
133
+ four tool calls: signature(s) (`resolve_symbol`), callees (the symbol's `calls`), callers /
134
+ blast radius + subclasses + type users (`find_references` / `classifyCallers`), the HTTP routes
135
+ it handles (`find_routes`), the tests that exercise it (`tests_for`), and git recency/co-change.
136
+ Both `markdown` and `json` (`structuredContent`) formats.
137
+ - `test/mcp.mjs` asserts the 13-tool surface; `test/references.mjs` adds a test→code fixture
138
+ proving `tests_for` returns only the test chunk (not the production caller) and `explain_symbol`
139
+ composes definition + callees + callers + tests, in both response formats.
140
+
141
+ ### Persistent resolved symbol graph (A4) — Phase 2
142
+
143
+ - **`--symbol-graph`** (`INDEXER_SYMBOL_GRAPH` / `symbolGraph` config) materializes a resolved
144
+ **chunk→chunk** graph at index time: one edge `{from_chunk_id, to_chunk_id, kind, confidence}`
145
+ per resolved reference, where `kind` ∈ `calls` | `extends` | `type` and `confidence` ∈
146
+ `high` | `name_only`. It is built by **reusing the query-time resolvers** (`classifyCallers` /
147
+ `findReferences`) over every defined symbol, so an edge's confidence is identical to what
148
+ `get_call_graph` / `find_references` report — no logic duplication, no drift.
149
+ - New store method **`getEdges(chunkId, { kind?, direction })`** (`direction: 'in'` = referrers,
150
+ `'out'` = referents) on both backends, deterministically ordered → byte-identical
151
+ memory↔sqlite. Stored in a SQLite `edges` table / the in-memory index JSON.
152
+ - `findCallers` / `findReferers` now **read the edge graph when present**, returning the *same*
153
+ sets as the name-match scan (a set-equivalence test is the load-bearing guard), and **fall back
154
+ to the scan** when the graph is absent or the name is undefined — so the default path is
155
+ byte-identical and an undefined-symbol lookup never returns empty-by-edges.
156
+ - **Invariants:** OFF by default → index + call graph byte-identical, `test:eval` unchanged
157
+ (edges never touch search). Air-gapped, zero new deps. Deterministic edge order. A per-file
158
+ daemon update **invalidates** the graph (it would otherwise go stale) so callers fall back to
159
+ the always-correct scan until the next full `idx-index`. New `test/edges.mjs` (6 tests):
160
+ set-equivalence, getEdges direction/kind/confidence, undefined-name fallback, memory↔sqlite
161
+ parity, daemon invalidation, deterministic ordering. Foundation for `impact_of_edit` (C4) and
162
+ symbol-level PageRank (A5). Write-up: `docs/internals/IMPROVEMENT_SYMBOL_GRAPH.md`.
163
+
164
+ ### `impact_of_edit` — precise blast radius (C1 + C4) — Phase 2
165
+
166
+ - New MCP tool **`impact_of_edit({ symbols?, files? })`** (13 → 14 tools): pass what you are
167
+ about to change and get the **transitively-affected** code (callers, subclasses, type users by
168
+ hop depth), the HTTP routes that reach it, the tests to run, the direct same-named referrers to
169
+ verify, and git co-change — in one call instead of recursively walking `get_call_graph`.
170
+ - It follows **high-confidence** edges transitively (a precise closure that does not explode on
171
+ ambiguous names) and lists direct `name_only` referrers separately. It is **chunk-precise on a
172
+ `--symbol-graph` index** (walks `getEdges`) and falls back to query-time `classifyCallers` /
173
+ `findReferences` otherwise — a new `buildImpact` in `mcp/topology.mjs`, plus a `hasSymbolGraph()`
174
+ store predicate. Deterministic ordering (depth, file, line, id); both response formats.
175
+ - Additive and read-only — no ranking/parity/default-path impact. `test/edges.mjs` adds 4 tests
176
+ (transitive depth via the graph AND the fallback, the two agreeing, and the tool composing
177
+ changed + impacted + tests + routes); `test/mcp.mjs` asserts the 14-tool surface.
178
+
179
+ ### Symbol-level centrality (A5) — Phase 2
180
+
181
+ - On a `--symbol-graph` index, the indexer now also computes **symbol centrality**: a
182
+ confidence-weighted **PageRank over the resolved chunk→chunk edges** (`high` edges weighted
183
+ 1.0, `name_only` 0.5). A definition referenced — called / extended / used as a type — by many
184
+ *other central* symbols scores high; it surfaces the program's load-bearing hubs. New module
185
+ `mcp/centrality.mjs` (`computeSymbolCentrality`). This is **distinct** from the existing
186
+ file-level `computePageRank` (import graph): A5 is symbol-granular and call/type-aware.
187
+ - **Surfaced** through two existing tools (no new tool — the surface stays at 14): `explain_symbol`
188
+ attaches each definition's `{ score, rank, total }` (markdown shows `🎯 centrality #R/T`, with a
189
+ `(hub)` tag in the top decile); `get_repo_map`'s unfiltered view lists the most-central symbols.
190
+ Both **omit** centrality entirely when the graph is absent → default output unchanged.
191
+ - **Parity-free, like the edges:** computed **once at index time** and serialized
192
+ (memory `centrality` map; SQLite `centrality(chunk_id, score, rank)` table), so both backends
193
+ read byte-identical scores and ranks. It never touches `searchHybrid`, so `test:eval` and the
194
+ search parity gate are unaffected. New store methods `hasCentrality()`, `getCentrality(id)`,
195
+ `topCentral(limit)` on both backends. Deterministic (sorted node set, fixed-order power
196
+ iteration, rounded scores, rank tie-broken by id).
197
+ - **Daemon staleness:** a per-file update invalidates the centrality alongside the edges
198
+ (it is a whole-program quantity) — both engines drop it until the next full `idx-index`.
199
+ - `test/edges.mjs` adds 8 tests (PageRank determinism, hub-outranks-leaf, confidence weighting,
200
+ serialization round-trip + store methods, memory↔sqlite parity, daemon invalidation, default-path
201
+ gating, and the two tools surfacing/omitting it). Full write-up:
202
+ `docs/internals/IMPROVEMENT_SYMBOL_CENTRALITY.md`.
203
+
204
+ ### Precise resolver provider + impact precision dial (A1) — Phase 2
205
+
206
+ - **`--resolver precise`** (`INDEXER_RESOLVER` / `resolver` config) adds a pluggable resolver
207
+ provider for the symbol graph. The default `heuristic` provider keeps edge confidence exactly
208
+ `{ high, name_only }` (byte-identical). `precise` lifts the **provably-unambiguous** subset into
209
+ a third, stronger tier — **`resolved`** — a reference is `resolved` when there is no question
210
+ *which* definition it binds to: a **sole definition** (no same-named rival), or a **type-pinned
211
+ receiver** (typed receiver, the A3 inter-procedural fixpoint, `this.m()` inside the class, or an
212
+ explicit target class — type-resolved, so shadow-immune). `high` then means "ambiguous name,
213
+ import/proximity-disambiguated"; `name_only` means "ambiguous, no evidence."
214
+ - **`impact_of_edit` gains a `precision` dial:** `standard` (default) follows the high-confidence
215
+ closure (`resolved` + `high`); **`strict`** follows **only `resolved`** edges — a
216
+ false-positive-free blast radius ("what *definitely* breaks"). Without a graph, `strict` follows
217
+ the `proven` query-time callers, so it degrades gracefully. Measured on the repo's own source:
218
+ of 325 `high` calls/refs edges, 303 are provably unambiguous (→ `resolved`), 22 remain heuristic
219
+ `high`; `name_only` is never promoted.
220
+ - **Additive and parity-free:** the resolver only changes the `confidence` STRING on edges that
221
+ already exist (never adds, drops, or reorders them), so `findCallers`/`findReferers`
222
+ set-equivalence holds and both backends round-trip the `resolved` tier identically. `resolved`
223
+ carries the same A5 centrality weight as `high`, so enabling it does not perturb centrality. The
224
+ symbol-graph dedupe now keeps the **strongest** confidence (`resolved > high > name_only`); on the
225
+ default path the values are unchanged so the dedupe is identical. New module `mcp/resolver.mjs`;
226
+ `classifyCallers` / `findReferences` gain an additive `proven` flag.
227
+ - **Honest scope:** v1 ships the unambiguous-binding tier the engine can already prove from
228
+ index-time signals. Detecting a local variable that **shadows** an import (and *refuting* that
229
+ edge), and cross-file precise resolution via stack-graphs, are the documented next layers — both
230
+ need per-language AST/scope analysis and an external toolchain that does not fit the air-gapped
231
+ default. `tree-sitter-stack-graphs` has no production Node binding, so a literal integration would
232
+ require a Rust CLI sidecar (rejected for v1). `test/edges.mjs` adds 5 tests; full write-up:
233
+ `docs/internals/IMPROVEMENT_PRECISE_RESOLVER.md`.
234
+
235
+ ### Sealed mode (F1) — Phase 3
236
+
237
+ - **`--sealed [strict|local]`** (`INDEXER_SEALED`) turns graph-indexer's air-gapped default from a
238
+ behaviour you *trust* into a guarantee you can *verify and enforce*. Two tiers: **`strict`** =
239
+ zero network egress (lexical-only); **`local`** = loopback only (local Ollama/MLX on this box are
240
+ allowed, nothing leaves it). Bare `--sealed` = `strict`.
241
+ - **Fail-closed**: `resolveConfig` refuses to construct a sealed config whose enabled features would
242
+ egress beyond the tier — the build exits 2 with a message naming each offending feature
243
+ (embeddings/enrichment/rerank/hyde), its reach (loopback vs off-box), and the remedy. No silent
244
+ downgrade.
245
+ - **Runtime egress guard**: a deny-by-default hook on the in-process network paths (`net`/`tls`
246
+ sockets, `http(s)`, `fetch`) installed before any provider loads, so even an *accidental* call is
247
+ caught — turning "we don't call out" into "we *can't*." A best-effort `child_process` denylist
248
+ covers the obvious out-of-process egress tools (documented limitation: a child has its own socket
249
+ layer). `list_index_stats` reports `sealed` + `egress_guard: active`.
250
+ - **Attestation**: `idx-index --attest` prints a deterministic manifest (tier, providers, egress
251
+ posture, any egressing features) — an auditor/CI artifact for regulated/air-gapped deployments.
252
+ - **Opt-in, zero default impact**: `sealed: 'off'` default; sealing only *removes* capability
253
+ (fail-closed), never changes the store or ranking. The default lexical path is already
254
+ strict-compatible. New module `seal.mjs`; `test/seal.mjs` adds 11 tests (loopback classifier,
255
+ per-tier validation, guard block/permit/restore, child denylist, manifest, resolveConfig
256
+ fail-close). Design: `docs/internals/PHASE3_SEALED_MODE.md`.
257
+
258
+ ### Taint analysis (C2) — Phase 3
259
+
260
+ - Two new **security** MCP tools (14 → 16): **`trace_taint`** traces untrusted data from a SOURCE
261
+ (request body/query/params, argv/env, stdin, socket/file reads) to a dangerous SINK (eval/exec,
262
+ SQL, fs/path, HTML, outbound request) across the call graph, returning the concrete source→sink
263
+ path tagged with category (rce/sqli/xss/path/ssrf) and confidence. **`find_tainted_sinks`** lists
264
+ every dangerous sink grouped by category, each flagged with whether an untrusted source reaches
265
+ it — the attack-surface map.
266
+ - **Confidence model**: a same-function source→sink is `high` (`direct`); a source function that
267
+ transitively calls a sink function is `medium` (`reachable`); a sanitizer
268
+ (escape/encode/parameterise/`Number()`/`int()`…) on the path drops it to `low`. Flows are
269
+ deterministically ordered by category severity, then confidence, then location.
270
+ - **Query-time, read-only, zero default cost**: implemented as on-demand tools (like
271
+ `get_call_graph`) — no index-time work, no serialization, **no parity surface**, the default index
272
+ is byte-identical, and the tools cost nothing unless invoked. **Air-gapped** (pure regex catalogs
273
+ + call-graph traversal; no model, no network). Walks A4 `getEdges` when present, else `chunk.calls`.
274
+ - **Honest scope**: a **finder, not a verifier** (JS/TS + Python in v1). It favours precision but
275
+ misses flows through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
276
+ collections — every tool response says so, and "0 findings" ≠ "no vulnerabilities." New modules
277
+ `parse/taint-patterns.mjs`, `mcp/taint.mjs`; `test/taint.mjs` adds 9 tests; `test/mcp.mjs` asserts
278
+ the 16-tool surface. Design: `docs/internals/PHASE3_TAINT_ANALYSIS.md`.
279
+
280
+ ### Learned re-ranker — opt-in, measured ≈neutral, RRF stays default (D3)
281
+
282
+ - **`--ranker learned` / `INDEXER_RANKER`** adds an opt-in, **zero-dependency** post-fusion
283
+ learning-to-rank layer: a small linear model re-orders the fused top-N using the RRF score plus the
284
+ program-structure signals the earlier phases produced — symbol **centrality** (A5), **resolved** /
285
+ SCIP **in-degree** (A4/A1) — and git recency. It is a *post-fusion* re-rank in the tool layer (like
286
+ the git boost / LLM reranker — never inside `db.searchHybrid`), so the **default RRF ordering is
287
+ byte-identical** and inference is a deterministic dot product (parity-safe). New module surface in
288
+ `search-core.mjs` (`learnedRerank`, `scoreLearned`, `extractRankerFeatures`,
289
+ `DEFAULT_RANKER_MODEL`); an offline trainer `bench/train-ranker.mjs` (zero-dep logistic regression,
290
+ **not shipped at runtime**); `test/ranker.mjs` (6 tests).
291
+ - **Honest measurement (the headline):** the trainer was run over all five eval fixtures built with
292
+ `--symbol-graph` (3 161 candidates, 410 positives). Held-out rank-1 (95 queries, grouped per
293
+ query) — **RRF-only 0.579 vs the data-fit model 0.579**: the learned model **ties** RRF, it does
294
+ not beat it. The structure features get near-zero learned weight because the correct symbol is
295
+ usually *not* the most central / most-called one. Per the honest-metrics + sacred-default
296
+ invariants, a tie is no reason to displace the simpler, faster default — **RRF remains the default
297
+ ranker.** What
298
+ ships is the *capability*: the shipped `DEFAULT_RANKER_MODEL` is conservative + RRF-dominant and
299
+ measures **byte-identical to RRF** on the benchmark (a safe, never-regressing opt-in), and the
300
+ trainer lets users fit a *repo-specific* model where their call structure does carry signal. Full
301
+ write-up: `docs/internals/IMPROVEMENT_LEARNED_RANKER.md`.
302
+
303
+ ### Learned-sparse vocabulary expansion — opt-in, measured net-neutral, lexical stays default (B3)
304
+
305
+ - **`--learned-sparse` / `INDEXER_LEARNED_SPARSE`** adds an opt-in, **zero-dependency, air-gapped**
306
+ learned-sparse channel (SPLADE / uniCOIL family): a model expands an NL query into a *weighted term
307
+ set* — the literal terms plus learned associates — fused as an extra sparse channel via RRF, to
308
+ close lexical-gap misses the Porter-stemming bridge cannot (stemming normalises morphology; it
309
+ cannot connect `auth` → `credential`/`token`). Rather than a neural MLM (which needs a downloaded
310
+ model + `@huggingface/transformers` and can't be measured air-gapped), the shipped provider **learns
311
+ the associations FROM THE CORPUS** at index time via positive PMI over chunk co-occurrence — genuine
312
+ learned-sparse, zero new dependency, deterministic. New module `search-sparse.mjs`
313
+ (`buildSparseModel`, `expandSparseQuery`) + a shared `scoreSparseExpanded` in `search-core.mjs`; the
314
+ model serialises as an **isolated `sparse_model` artifact** (memory JSON key / single-row sqlite
315
+ table) so it cannot perturb the lexical postings; `test/sparse.mjs` (7 tests).
316
+ - **Presence-gated + NL-asymmetric + parity-by-construction.** A default index has no model → the
317
+ channel is inert → `test:eval` **byte-identical**. The channel fires only for natural-language
318
+ queries with a real expansion, so symbolic/exact lookups are byte-identical. Both backends score
319
+ through the *same* `scoreSparseExpanded` over identically-ordered terms → memory↔sqlite byte-identical.
320
+ - **Honest measurement (the headline):** in-process A/B over the five core eval suites, swept across
321
+ the fusion weight — **the learned-sparse channel does not beat the lexical baseline at any weight.**
322
+ Higher weights regress semantic s@5 (the co-occurrence associates add noise: axios semantic s@5
323
+ 90.9→72.7, gin 83.3→33.3 at 0.5); the best case is ≈neutral at the shipped weight 0.2 (semantic +
324
+ symbolic rank-1 both exactly preserved). Like BM25F / AST / the code-embedding NL weight before it,
325
+ this measured net-neutral-to-negative, so **lexical stays the default and B3 is opt-in only.** What
326
+ ships is the air-gapped infrastructure + the seam for a neural SPLADE provider + the honest finding;
327
+ a repo with genuine query/code vocabulary mismatch (which the benchmark fixtures lack) may see the
328
+ recall win — *measure on your own repo*. Full write-up:
329
+ `docs/internals/IMPROVEMENT_LEARNED_SPARSE.md`.
330
+
331
+ ### ColBERT-style late-interaction reranker — opt-in, measured helps-Go / neutral-JS (B4)
332
+
333
+ - **`--rerank-provider late-interaction`** adds a third reranker provider (sibling to the generative
334
+ judge and the B2 cross-encoder): the multi-vector **MaxSim** idea from ColBERT, deployed as a
335
+ reranker over the over-fetched pool. It keeps MULTIPLE vectors per side and scores
336
+ `Σ_qi max_dj cos(qi, dj)`, so each query sub-vector finds its best document sub-vector — the
337
+ compositional-query win single-vector pooling loses. New module `colbert.mjs` (`maxSimScore`,
338
+ `encodeMultiVector`, `loadChunkVectors`, `rerankLateInteraction`) + `collectVectorsByKey` in
339
+ `engine/binary.mjs` + `embeddingBinPath()` on both engines; `test/colbert.mjs` (7 tests).
340
+ - **No storage blow-up** (ColBERT's canonical cost): it reuses the document sub-vectors the engine
341
+ ALREADY stores (base + `|s` summary + `|wN` windows), reading a candidate's vectors from the shared
342
+ `.embeddings.bin` in one pass. **Air-gapped** (query multi-vectors from the same local embedder, no
343
+ new model/network), **parity-by-construction** (both backends read the same bin; MaxSim is
344
+ order-independent → identical re-order), and a post-retrieval re-order that **never mutates
345
+ `r.score`** and degrades to the original order on any shortfall → **default path byte-identical**.
346
+ - **Honest measurement (at production retrieval depth):** a reranker can only re-order the pool it is
347
+ handed, so the measurement uses production's over-fetch (pool 15 / topM 12; the harness default of
348
+ 10/8 manufactured a *false* JS regression that vanishes at the right depth). On real Ollama indexes,
349
+ `gin` (Go) improves — semantic rank-1 **0.00 → 0.40**, overall **0.68 → 0.77**, with **no symbolic
350
+ regression** (0.88 → 0.88) — while `express-js` (JS) is **neutral** (every metric unchanged: overall
351
+ 0.65, semantic rank-1 0.43, s@5 0.57). **At production depth B4 helps Go and is neutral on
352
+ JavaScript — the "helps Go/Python" half of the known reranker profile without the JS regression the
353
+ other rerankers show.** Small-n (gin semantic n=5), so directional. It still needs a query-time
354
+ model and does not uniformly win across languages, so it stays opt-in (default off), the trade-off
355
+ is printed at startup, and the default path is byte-identical. Requires `--embeddings` (a no-op
356
+ without it). Approximates true token-level ColBERT with a sentence embedder over query sub-units; a
357
+ token-level model is a clean future drop-in behind `encodeMultiVector`. Full write-up:
358
+ `docs/internals/IMPROVEMENT_LATE_INTERACTION.md`.
359
+
360
+ ### Taint analysis hardening — index-time serialization + Java/Go (C2)
361
+
362
+ - **Opt-in index-time serialization (`--taint` / `INDEXER_TAINT`).** The taint flow set can now be
363
+ precomputed once at index time and **serialized into the index** (a `taint` table in SQLite, a
364
+ `taint` JSON key in memory) so `trace_taint` / `find_tainted_sinks` answer **instantly** instead of
365
+ walking the call graph per query — the flagship win for large, security-sensitive repos. Computed
366
+ against the symbol-graph edges when `--symbol-graph` is on (cross-function precision), else
367
+ `chunk.calls`. Like A4 edges / A5 centrality: **computed once → both backends serve byte-identical
368
+ flows** (`hasTaint()` / `getTaintFlows()` added to the store contract; an incremental file update
369
+ clears it; the tools fall back to a live scan when absent). `buildTaintGraph` gains a fast path
370
+ that serves cached flows filtered to the query's category/depth envelope (a deeper `max_depth` or
371
+ direct-only query transparently recomputes). **Default index byte-identical** (off → no
372
+ serialization, query-time as before); air-gapped; `list_index_stats` surfaces the flow count.
373
+ - **`trace_taint` / `find_tainted_sinks` now cover Java and Go** in addition to JS/TS and Python.
374
+ `parse/taint-patterns.mjs` gains `java` and `go` source / sink / sanitizer catalogs: Java —
375
+ `request.getParameter` / `@RequestParam` / `System.getenv` sources, `Runtime.exec` / `ProcessBuilder`
376
+ (rce), JDBC/JPA statement execute (sqli), servlet/JSP writers (xss), `File`/`Paths`/`Files` (path),
377
+ `URL`/`RestTemplate`/`HttpClient` (ssrf), with `PreparedStatement` / `URLEncoder` / `Integer.parseInt`
378
+ sanitizers; Go — `r.URL.Query()` / `r.FormValue` / gin·gorilla helpers / `os.Getenv` sources,
379
+ `exec.Command` (rce), `db.Query`/`Exec` (sqli, with a lookbehind so `r.URL.Query()` is not mistaken
380
+ for a DB call), response-writer / `template.HTML` (xss), `os.Open`/`filepath.Join` (path),
381
+ `http.Get`/`client.Do` (ssrf), with `strconv.Atoi` / `html.EscapeString` / `filepath.Clean`
382
+ sanitizers. `test/taint.mjs` adds Java + Go scan/flow/sanitizer tests plus serialization tests
383
+ (cache == live, fast-path category/depth filtering, memory↔sqlite parity, incremental-clear).
384
+ Design: `docs/internals/PHASE3_TAINT_ANALYSIS.md`.
385
+
386
+ ### SCIP resolver provider (A2) — Phase 3
387
+
388
+ - **`--resolver scip` + `--scip-index <path>`** (`INDEXER_SCIP_INDEX` / `scipIndex` config)
389
+ completes the A1 precision story with the realistic, air-gapped cross-file backend. Where
390
+ `--resolver precise` only *relabels* edges the heuristic emitted, `scip` ingests a
391
+ **locally-generated SCIP index** (the user's own `scip-typescript` / `scip-python` /
392
+ `scip-java` / `rust-analyzer --scip` — produced out of band, on-box) and resolves edges by
393
+ **real cross-file bindings**: it promotes a confirmed `calls` edge to the `resolved` tier **and
394
+ suppresses the wrong-target fan-out** an ambiguous same-name reference would otherwise emit —
395
+ the disambiguation the index-time heuristic structurally cannot do. (Scoped to `calls` edges in
396
+ v1; `extends`/`type` edges stay heuristic because the binding relation is kind-agnostic.)
397
+ - **Zero new dependency.** The `.scip` protobuf is decoded by a **hand-rolled, zero-dependency**
398
+ wire reader (`parse/scip.mjs`) that walks only the four fields A2 needs; a SCIP-shaped JSON file
399
+ is also accepted. Occurrences align to chunks by 1-based line range (SCIP is 0-based; chunks
400
+ store no columns → line granularity). Air-gapped (a local file read only) — so **`--sealed
401
+ strict` + `--resolver scip` is a valid sealed, precisely-resolved index.**
402
+ - **Parity-free, default byte-identical, never-worse.** Like A1, it runs once at index time and
403
+ serializes one edge list into both backends (no per-backend code; memory↔sqlite identical). The
404
+ `resolveEdges` provider method exists only on the `scip` provider, so the default
405
+ `heuristic`/`precise` path is byte-identical. **Absence ≠ refutation, even under partial
406
+ coverage**: suppression requires that SCIP *actually recorded* the def it drops (`definedChunks`
407
+ gate), so a covered caller referencing an *uncovered* def (a `.ts`-only run that never saw a
408
+ `.js` def, a stale `.scip`) keeps the heuristic edge — never dropped. Coverage (matched docs,
409
+ bindings, resolved-tier count) is printed at index time; a missing/unreadable/0-match index warns
410
+ loudly and falls back to heuristic (the build never fails).
411
+ - **v2 — precise cross-file references for `find_references`.** The same binding relation is
412
+ inverted (`buildScipReferers`) into a precise *referenced-by* map and surfaced by
413
+ `find_references` (and `explain_symbol`) as a **`resolved`-tier reference dimension**. SCIP
414
+ occurrences are kind-agnostic ("symbol used here"), which is exactly what `find_references`
415
+ answers — so this single dimension both **disambiguates** same-named symbols (each referer is
416
+ attached to the one definition SCIP bound it to, not every same-named def the name heuristic
417
+ matches) and **recovers references the AST/name heuristic misses entirely** (the v1 "recall
418
+ concern" — including type usages in languages whose `type_refs` channel is empty). It is
419
+ serialized as an **isolated index artifact** (`scip_refs`), exactly like taint/centrality, so it
420
+ **cannot perturb the A4 edges or A5 centrality** and is parity-free; empty (and so default
421
+ byte-identical) on any index built without a SCIP resolver, and dropped on incremental update.
422
+ `list_index_stats` reports the count.
423
+ - **Module surface:** `parse/scip.mjs` (`loadScip`, `buildScipBindings`, `buildScipReferers`,
424
+ `normalizeScipPath`) + `createScipResolver` in `mcp/resolver.mjs`; the `scip_refs` artifact in
425
+ both engines (`hasScipRefs`/`getScipReferers`/`scipRefCount`). `test/scip.mjs` adds 15 tests
426
+ (protobuf round-trip, alignment, promote + suppress over a real ambiguous-name fixture,
427
+ **partial-coverage soundness**, **calls-only cross-kind isolation**, memory↔sqlite parity, plus
428
+ v2: inversion determinism, precise+attributed `find_references` dimension, references parity,
429
+ **sacred-default byte-identity**, incremental staleness). Write-up:
430
+ `docs/internals/IMPROVEMENT_SCIP_RESOLVER.md`.
431
+
432
+ ### Sealed-mode attestation signing (F1 hardening)
433
+
434
+ - The sealed-mode attestation manifest can now be **cryptographically signed and verified** — turning
435
+ "here is my air-gapped posture" into a tamper-evident, auditor-verifiable statement. `seal.mjs`
436
+ adds `signManifest` (Ed25519 / RSA / EC) → a `{ manifest, signature: { alg, keyType,
437
+ publicKeyFingerprint, value } }` envelope, and `verifySignedManifest` → `{ valid, reason }` (never
438
+ throws). The signature is taken over a `canonicalJson` (recursively key-sorted) form, so it
439
+ survives a file round-trip and any future manifest key-order change.
440
+ - **CLI:** `idx-index --gen-attestation-key <prefix>` writes an Ed25519 keypair (`.key` private /
441
+ `.pub` public); `idx-index --attest --sign-key <path>` prints the signed envelope;
442
+ `idx-index --verify-attestation <file> --pub-key <path>` verifies the signature **and** reports
443
+ whether the attested manifest still matches the current effective config (policy drift → exit 3).
444
+ - **Zero dependency, air-gapped** (`node:crypto` only — no network, works under `--sealed strict`);
445
+ the default path is untouched (these are pre-indexing CLI actions that exit before any build).
446
+ `test/seal.mjs` adds 4 tests (canonical JSON, Ed25519 + RSA sign/verify, tamper/wrong-key/alg
447
+ rejection, JSON round-trip). Design: `docs/internals/PHASE3_SEALED_MODE.md`.
448
+
7
449
  ## [2.0.0] — 2026-06-21
8
450
 
9
451
  This is a major release. The public API (MCP tools, CLI flags, config keys) has grown significantly, but the default behaviour — lexical-only search, in-memory store, zero external dependencies — is backward-compatible. Internal modules were reorganized into `engine/`, `mcp/`, and `parse/` (see [Module reorganization](#module-reorganization-breaking-only-for-deep-imports) below) — breaking only for code that deep-imported internal files.