graph-indexer 1.2.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
package/CHANGELOG.md ADDED
@@ -0,0 +1,854 @@
1
+ # Changelog
2
+
3
+ All notable changes to graph-indexer are documented here. Dates are in YYYY-MM-DD format.
4
+
5
+ ---
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
+
449
+ ## [2.0.0] — 2026-06-21
450
+
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.
452
+
453
+ ### Onboarding (`graph-indexer init`) — environment-agnostic setup
454
+
455
+ The first-run setup now produces a working configuration in **any** repository, not just a Node repo that already has graph-indexer installed.
456
+
457
+ - **Self-contained MCP launch command.** The wired command is now decided per-project. When graph-indexer is resolvable as a local dependency, the wiring keeps using `npm run mcp:start`. Otherwise — **non-Node repos** (Python, Go, Rust, …) and **npx-only Node repos** — it wires a self-contained `npx -y -p graph-indexer idx-mcp --repo <abs path>` that launches the real server regardless of ecosystem. The same decision drives the npm scripts (wrapped in `npx -p` when not a local dep, so they aren't broken) and the global Claude Desktop config.
458
+ - **Subcommand dispatch.** `graph-indexer <idx-mcp|idx-index|idx-watch|idx-daemon> …` now delegates to the real bin (previously these tokens were swallowed by the init wizard, because `npx <pkg> <x>` runs the package's same-named bin, not the `<x>` bin). This makes the documented `npx -y graph-indexer idx-mcp` form work too.
459
+ - **Target a repo + CI flags.** `init` now honors a positional path and `--repo <path>` (resolved to an absolute root used everywhere), adds `--help`, and adds `--yes` / `--non-interactive` (a non-TTY stdin already implied this) for scripted/CI runs.
460
+ - **Claude Code MCP target.** Project-scoped servers are now written to `.mcp.json` (the file Claude Code reads for project MCP servers) instead of `.claude/settings.json`.
461
+ - **Claude Desktop on Linux.** Added the `~/.config/Claude` path (honoring `$XDG_CONFIG_HOME`); Linux users with Claude Desktop installed now get wired instead of reported "not installed".
462
+ - **Node version guard.** Selecting (or auto-resolving on a large repo to) the SQLite backend on Node < 22 now surfaces a clear warning in the summary instead of failing later at runtime.
463
+ - **Faster default path + ready to use.** The engine step opens with a single "Use recommended defaults?" confirm that skips the ~10 follow-up prompts, and the run ends by offering to build the index so the flow finishes genuinely ready to use. Editor wiring now distinguishes "detected" from "ready if you use it" in the summary.
464
+
465
+ Idempotency, merge-safe config writes, air-gapped defaults, and `--dry-run` are all preserved.
466
+
467
+ ### New MCP tools
468
+
469
+ | Tool | Purpose |
470
+ |------|---------|
471
+ | `find_references` | Where a symbol is used: callers, subclasses, and type-annotation references, split into high-confidence vs name-only blast radius. |
472
+ | `find_routes` | HTTP routes mapped to their handler chunks — NestJS, Express/Koa, FastAPI/Flask, and Spring (Java); the controller/router prefix is joined onto the method path. Attribute-routed C#/ASP.NET, PHP (Laravel/Symfony), Rails, and Django URLconf are out of scope. |
473
+ | `get_subgraph` | Bounded connected subgraph (callers + callees + type/inheritance referers) around a seed symbol, in one call. Replaces multi-hop `search_code → get_call_graph → find_references` round-trips. |
474
+
475
+ HTTP route extraction (`parse/routes.mjs`) parses route definitions from the AST so `find_routes` can resolve an endpoint straight to its handler chunk; coverage is per-framework as noted above.
476
+
477
+ **All tools** now support a `response_format: 'json'` parameter that returns typed structured fields instead of markdown prose, for programmatic clients.
478
+
479
+ All tool responses carry an **index-freshness signal** (age, commit it was built at, pending uncommitted changes). The freshness footer is omitted on up-to-date indexes to keep card sizes lean.
480
+
481
+ ### New language support
482
+
483
+ Added three languages, bringing the total to 14:
484
+
485
+ - **C** (`.c`, `.h`) — struct/function/declaration extraction with declarator name descender
486
+ - **Bash** (`.sh`, `.bash`) — command and function extraction with built-ins filter
487
+ - **Swift** (`.swift`) — class/struct/enum/function extraction
488
+
489
+ Existing language support: TypeScript/JavaScript, Python, Go, Rust, Java, Kotlin, C#, Ruby, PHP, CSS/SCSS.
490
+
491
+ ### Prompt suite (new `prompts/` directory)
492
+
493
+ Replaced the single `PROMPT.md` with a structured, composable **3-layer prompt suite**:
494
+
495
+ - **`prompts/CORE.md`** — universal rules (4-call budget, batch-don't-iterate, consume-before-call, rule-of-one). Always required.
496
+ - **`prompts/languages/`** — 13 language-specific overlays: BASH, C, C#, CSS/SCSS, Go, Java, JavaScript/TypeScript, Kotlin, PHP, Python, Ruby, Rust, Swift
497
+ - **`prompts/frameworks/`** — 8 framework-specific overlays: Android, ASP.NET Core, FastAPI/Django, Laravel/Symfony, Node/Express/NestJS, Rails, React, Spring Boot
498
+ - **`prompts/DOMAIN_TEMPLATE.md`** — a copy-and-fill template for project-specific rules
499
+ - **`prompts/INTEGRATION.md`** — guide explaining the 3-layer architecture and how to compose the layers
500
+
501
+ ### Embedding provider abstraction (`embeddings.mjs`)
502
+
503
+ New module with an `auto` provider selection policy:
504
+
505
+ 1. Ollama (user's running daemon, highest quality)
506
+ 2. In-process local model via the optional `@huggingface/transformers` dependency (`Xenova/all-MiniLM-L6-v2` by default) — runs with no external daemon
507
+ 3. Lexical-only fallback (logged, never silent)
508
+
509
+ Embeddings remain **off by default**. The index stamps the resolved `{ provider, model, dim }` into its metadata; the server reads it back on load so index time and query time always use the same model.
510
+
511
+ New `--embed-provider <auto|ollama|local|off>` flag and `INDEXER_EMBED_PROVIDER` env variable to override provider selection.
512
+
513
+ New `mlx` provider uses a dedicated Python virtualenv (`embedders/venv-mlx/`) and the `mlx_embeddings` library to embed via the Apple Metal GPU as a persistent subprocess — no Ollama daemon required. Measured throughput on an **Apple M2 Mac mini (24 GB)**, `express-js` fixture, median of 3 cold builds: `mlx` **~42 ch/s** vs in-process Xenova **~18 ch/s** vs Ollama/nomic **~14 ch/s** (throughput is hardware-dependent — expect different figures on other chips). Note: on Apple Silicon, Ollama also uses the Metal GPU internally; the `mlx` provider's advantage is a smaller 4-bit model and no HTTP round-trip, not GPU vs CPU.
514
+
515
+ **Bug fix:** `indexer.mjs` (and the eval harness) now calls `_resetSubprocesses()` after encoding completes so the MLX Python subprocess is killed and the Node.js event loop can drain. Previously `npx idx-index --embed-provider mlx` would hang indefinitely after building a correct index.
516
+
517
+ **Setup & onboarding.** Install the MLX virtualenv with `npm run embed:setup:mlx` (`node embedders/setup-mlx.mjs`), or accept it during `graph-indexer init` when you select the MLX provider (onboarding also lets you choose the MLX embed model, default `mlx-community/all-MiniLM-L6-v2-4bit`, 384-dim). New MLX implementation files: `embedders/setup-mlx.mjs` (venv installer), `embedders/python/mlx_embed_server.py` (persistent MLX subprocess server), `embedders/python/requirements-mlx.txt`, `embedders/python/test_servers.mjs`.
518
+
519
+ **Graceful per-batch degradation.** Embedding generation no longer aborts the whole index when a batch fails or times out — those chunks are indexed lexical-only and retried on the next run, with a per-batch warning and an end-of-run summary of how many batches were skipped.
520
+
521
+ ### Git signals (`git-signals.mjs`)
522
+
523
+ New module that computes three per-file ranking signals from the local git log (no network, no remote):
524
+
525
+ - **churn** — commit count per file
526
+ - **recency** — time since last commit
527
+ - **co-change** — files that historically change together
528
+
529
+ Signals are collected once at index time into a sidecar file (`code-index.git.json`). The co-change hint is surfaced in tool blast-radius responses. The recency/churn ranking weight is **off by default** (`gitRankBoost: 0`) — enable with `--git-rank-boost <0..1>` or `INDEXER_GIT_RANK_BOOST`.
530
+
531
+ ### Daemon control CLI (`daemon-ctl.mjs` / `idx-daemon`)
532
+
533
+ New `idx-daemon` binary with subcommands:
534
+
535
+ ```
536
+ idx-daemon start Start the watch daemon (no-op if already running)
537
+ idx-daemon stop Gracefully stop it
538
+ idx-daemon restart Stop then start
539
+ idx-daemon status Show daemon + index state (default)
540
+ idx-daemon logs [-f] Print recent daemon logs
541
+ ```
542
+
543
+ New `daemon-lock.mjs` ensures exactly one daemon runs per project — `start` is safe to call repeatedly. New npm scripts: `mcp:daemon`, `mcp:daemon:start`, `mcp:daemon:stop`, `mcp:daemon:restart`, `mcp:daemon:status`, `mcp:daemon:logs`.
544
+
545
+ ### Directory layout (`layout.mjs`)
546
+
547
+ New `layout.mjs` is the single source of truth for on-disk paths. All machine-generated runtime files (index, vectors, SQLite database, enrichment cache, daemon PID/log, resolved config) now live under `.graph-indexer/` at the project root instead of being scattered at the root level. `migrateLegacyLayout()` relocates pre-v1.4 artifacts transparently on next run. The config file is `config.json` inside that directory; the legacy `.graph-indexer.json` root file is still read as a fallback for backward compatibility.
548
+
549
+ ### CLI UI (`cli-ui.mjs`)
550
+
551
+ New dependency-free shared console styling module for CLI utilities (init, daemon control). Provides ANSI colour with graceful degradation when stdout is not a TTY or `NO_COLOR` is set, plus boxed banners, rules, and status glyphs for a consistent look.
552
+
553
+ ### Receiver-aware call graph
554
+
555
+ `get_call_graph` now uses receiver hints captured by the parser (`this.X()`, `Receiver.X()`, free-function `X()`) to classify callers as **high-confidence** (the real blast radius) vs **name-only** (callers of a same-named symbol elsewhere). Eliminates false positives on methods with common names (e.g., `save`, `find`, `validate`).
556
+
557
+ ### Lexical search improvements
558
+
559
+ - **Porter stemming** — language-agnostic Porter stemmer (steps 1–5 plus an agent-noun `-or` rule for code) bridging vocabulary gaps: `"intercepting"` ↔ `Interceptor`, `"managing"` ↔ `Manager`, `"injection"` ↔ `Injectable`. Applied **additively** (raw token always emitted alongside the stem) so exact matches, IDF statistics, and name boosts are byte-for-byte unchanged.
560
+ - **IDF-gated path boost** — for NL queries, the path-segment boost is restricted to file-path terms that are rare in the corpus (high IDF). Symbol/keyword queries keep the original path boost unchanged — the IDF gate, not the boost itself, is NL-only.
561
+ - **NL-adaptive vector weight** — the semantic channel weight is `0.4` for plain lexical text and `0.6` for LLM-enriched chunks (the joint optimum across the eval suites), so the embedding signal matters most where the index has a rich semantic representation.
562
+ - **Dense-channel window sub-chunking** — oversized definitions (beyond the single-vector context limit) are embedded as up to 4 overlapping windows and retrieved by the maximum cosine across windows (max-sim), recovering tail recall that single-vector truncation lost. Window 0 is byte-identical to the prior single vector, so existing head vectors are unchanged; extra windows are stored under `<key>|w1..|wN` keys in the embeddings binary.
563
+
564
+ ### LLM rerank: over-fetch + pool rescue
565
+
566
+ When `rerank` is enabled, the server now **over-fetches** a deeper candidate pool (configurable `poolSize`, default 15) and lets the LLM judge reorder it before truncating to `top_k`. This lets the reranker rescue a correct-but-deep result that would never reach the agent otherwise.
567
+
568
+ ### HyDE (Hypothetical Document Embedding)
569
+
570
+ New opt-in query-side technique: generate a hypothetical code snippet for the NL query, embed it, and blend with the query vector to bridge vocabulary gaps. Off by default. Enable with `hyde: { enabled: true }` in config or the `hyde` parameter in `search_code`. The HyDE prompt is **language-aware** — the repo's dominant language (detected from extension counts and chunk metadata) selects a per-language hypothetical-snippet template, with a generic, never-regressing fallback when the language is unknown.
571
+
572
+ ### Configuration overhaul
573
+
574
+ Default values changed:
575
+
576
+ | Key | Old default | New default | Reason |
577
+ |-----|-------------|-------------|--------|
578
+ | `storage` | `'memory'` | `'auto'` | Auto-selects SQLite above ~15k chunks |
579
+ | `embeddings` | (on unless `INDEXER_EMBEDDINGS=off`) | `false` | Explicit opt-in, lexical-first default |
580
+ | `embedProvider` | (none — Ollama was the only path) | `'auto'` | Falls back to local model, then lexical |
581
+ | `embedModel` | `'nomic-embed-text'` | `'nomic-embed-text'` | Unchanged |
582
+ | `rerank.topM` | `8` | `12` | Wider rerank pool |
583
+ | `rerank.poolSize` | (none) | `15` | New over-fetch parameter |
584
+
585
+ CLI flag rename: `--llm-enrichment` (and `--enrich`) → `--enrichment`. The old forms are no longer accepted.
586
+
587
+ Startup logging now prints the **effective configuration** (storage backend, model names, which optional features are active) and emits a warning for any opt-in feature with a known trade-off.
588
+
589
+ New embedding/enrichment flags and environment variables:
590
+
591
+ - `--mlx-embed-model` / `INDEXER_MLX_EMBED_MODEL` — MLX model id (default `mlx-community/all-MiniLM-L6-v2-4bit`).
592
+ - `EMBED_MODEL` — env alias for `--embed-model`.
593
+ - `ENRICH_MODEL` — env that enables enrichment and names its model. Setting `enrichment.model: 'auto'` probes the local Ollama and picks the strongest available code model (preference ladder `qwen2.5-coder:7b → 3b → deepseek-coder-v2 → 1.5b`), falling back to the `qwen2.5-coder:1.5b` floor — best-effort and non-throwing.
594
+ - `INDEXER_EMBED_CONCURRENCY` (parallel embed batches, default `4`), `INDEXER_EMBED_TIMEOUT_MS` (per-batch timeout, default `120000`), `INDEXER_MLX_BATCH_SIZE` (default `32`), `INDEXER_EMBED_STARTUP_TIMEOUT_MS` (default `120000`).
595
+
596
+ Fixed the Ollama endpoint in the embeddings test scripts from the non-default `:11435` to Ollama's real default `:11434` (`OLLAMA_HOST`).
597
+
598
+ ### Symbol references (`find_references`)
599
+
600
+ `find_references` (`findReferences` in `mcp/topology.mjs`) fuses three reference kinds, combining `classifyCallers` (calls) with the new `db.findReferers` storage method (which returns the non-call referers — `extends` and `type_refs` matches):
601
+
602
+ - **calls** — `findCallers` + high/name-only classification
603
+ - **inherits** — chunks whose `extends` names the target (subclasses / implementers)
604
+ - **types** — chunks whose `type_refs` names the target (params, returns, fields)
605
+
606
+ Heritage is indexed for TS/JS, Python, Java, C#, Kotlin, Swift, Ruby, PHP, Rust, and Go (Go via struct/interface embedding rather than an inheritance keyword). Type-annotation users for TS/JS, Python, Java, C#, Kotlin, Swift, PHP, Rust, Go, and C (C# is field-precise: params, fields, properties, returns, base list).
607
+
608
+ ### Symlink path traversal hardening
609
+
610
+ `get_file_skeleton` now resolves symlinks with `realpath` on both the project root and the requested target, then re-verifies containment. A symlink inside the project pointing outside it can no longer escape the sandbox.
611
+
612
+ ### Benchmarking harness (`bench/`)
613
+
614
+ New reproducible benchmarking suite:
615
+
616
+ - `bench/cell.mjs` — cold-build per config, runs one fixture × one config combination
617
+ - `bench/configs.mjs` — config matrix definitions
618
+ - `bench/synth.mjs` / `bench/synth-agent.mjs` / `bench/synth-best.mjs` — report generators (cross-language, agent-facing, per-fixture best-config)
619
+ - `bench/tokens.mjs` — token-savings measurement (drives the token-savings tables below)
620
+ - `bench/repeat-score.mjs`, `bench/query.mjs`, `bench/dump-chunks.mjs`, `bench/fixtures-doc.mjs`, `bench/_confidence.mjs` — scoring, query, and reporting helpers
621
+ - `bench/verify-suite.mjs` / `bench/verify-ground-truth.mjs` / `bench/verify-structural.mjs` — fabrication guards: ensure query answers actually exist in the index
622
+ - `bench/structural.mjs` — structural fixture verifier
623
+ - `bench/parity.mjs` — byte-identical parity check between backends
624
+ - `bench/provenance.mjs` / `bench/provenance.json` — query provenance tracking
625
+ - Shell runners: `run-all.sh`, `run-costly.sh`, `run-final.sh`, `run-focused.sh`, `run-list.sh` (plus internal `_rerun-v2.sh`)
626
+
627
+ ### Test suite expansion
628
+
629
+ New test files:
630
+
631
+ | File | What it covers |
632
+ |------|----------------|
633
+ | `test/callgraph.mjs` | Receiver-aware call graph, `classifyCallers` |
634
+ | `test/references.mjs` | `find_references` / `findReferers` |
635
+ | `test/routes.mjs` | `find_routes` / HTTP route extraction per framework |
636
+ | `test/json-output.mjs` | `response_format: 'json'` across all tools |
637
+ | `test/git-signals.mjs` | Git churn/recency/co-change sidecar |
638
+ | `test/security.mjs` | Path-traversal / symlink escape guard |
639
+ | `test/embeddings.mjs` | Embedding provider abstraction |
640
+ | `test/languages.mjs` | Per-language parsing (all 14 languages) |
641
+
642
+ (`test/unit.mjs` and `test/metrics.mjs` already existed in v1.2.0 and were extended, not added.)
643
+
644
+ New test suites for additional frameworks/languages: `alamofire`, `android`, `aspnet`, `cjson`, `css`, `django`, `laravel`, `nvm`, `rails`, `react`, `rust`, `spring`, `symfony`. (The `axios`, `express-js`, `fastapi`, `gin`, and `nestjs` suites already shipped in v1.2.0 and had their query sets expanded.)
645
+
646
+ New npm test scripts: `test:callgraph`, `test:references`, `test:routes`, `test:jsonout`, `test:gitsignals`, `test:security`, `test:embed`, `test:languages`.
647
+
648
+ ### Agent benchmark harness (`test/agent/`)
649
+
650
+ New end-to-end agent benchmark that drives real MCP tools and traces an agent's call chain:
651
+
652
+ - `test/agent/agent-cli.mjs` — CLI entry point
653
+ - `test/agent/benchmark.config.mjs` — fixture and config registry
654
+ - `test/agent/fixtures.manifest.mjs` / `test/agent/setup-fixtures.mjs` — fixture manifest and setup
655
+ - `test/agent/search-cases.mjs` — search case definitions
656
+ - `test/agent/search-eval.mjs` — search evaluation with MRR/success@k scoring
657
+ - `test/agent/parse-eval.mjs` / `test/agent/score-answers.mjs` / `test/agent/se-mrr.mjs` — eval parsing and scoring
658
+ - `test/agent/analyze.mjs` / `assemble.mjs` — analysis pipeline
659
+ - `test/agent/tool-bridge.mjs` — bridges the agent to the MCP server under test
660
+
661
+ ### Documentation
662
+
663
+ - **README.md** — rewritten to roughly a third its previous length (~700 → ~310 lines). Focused on the quick start, MCP tool reference, configuration trade-offs table, CLI flags, and environment variables. Benchmark numbers verifiable via `npm run test:eval`.
664
+ - **`docs/benchmarks/`** — new directory with detailed benchmark reports: `BENCH_BASELINE.md`, `BENCH_FULL_SUITE.md`, `BENCH_LANGUAGES.md`, `BENCH_SUMMARY.md`, `BENCH_AGENT.md`, `BENCH_PER_FIXTURE.md`, `FIXTURES.md`.
665
+ - **`docs/internals/IMPROVEMENT_STEMMING.md`** — internal design note for the Porter stemming bridge.
666
+ - **SECURITY.md** — updated to document the git-signals subprocess, the one-time model-weight download for the local embedding provider, and the strengthened symlink path guard.
667
+
668
+ ### CI
669
+
670
+ - `.github/workflows/ci.yml` — verifies all 14 languages are present in `parse/languages.mjs`, checks the `.graph-indexer/` data directory is created (not the legacy root layout), validates that `prompts/CORE.md` and `prompts/INTEGRATION.md` are present, and runs a package-integrity step that `npm pack`s the tarball and asserts the `engine/`, `mcp/`, and `parse/` subdir modules ship and every entrypoint resolves its imports from the package.
671
+ - Removed `.github/workflows/publish-with-provenance.yml` (deprecated).
672
+
673
+ ### Dependencies
674
+
675
+ New optional and production dependencies:
676
+
677
+ | Package | Version | Type | Purpose |
678
+ |---------|---------|------|---------|
679
+ | `tree-sitter-bash` | 0.23.3 | optional | Bash language parser |
680
+ | `tree-sitter-c` | 0.21.4 | optional | C language parser |
681
+ | `tree-sitter-swift` | 0.5.0 | optional | Swift language parser |
682
+ | `tree-sitter-scss` | 1.0.0 | optional | SCSS language parser |
683
+ | `@huggingface/transformers` | 3.8.1 | optional | In-process local embedding model |
684
+
685
+ (`hnswlib-node` 3.0.0, the optional approximate-nearest-neighbour vector index, already shipped in v1.2.0 — it is not new in v2.0.0.)
686
+
687
+ ### Module reorganization (breaking only for deep imports)
688
+
689
+ Internal source files were grouped into directories. The CLI bins, the MCP server, and the default behaviour are unchanged; only code that imported a specific internal file path is affected. The package entry point (`import 'graph-indexer'`) now resolves to `engine/memory.mjs`.
690
+
691
+ | Old path | New path |
692
+ |----------|----------|
693
+ | `core-engine.mjs` | `engine/memory.mjs` |
694
+ | `sqlite-store.mjs` | `engine/sqlite.mjs` |
695
+ | `mcp-tools.mjs` | `mcp/tools.mjs` |
696
+ | `parser-utils.mjs` | split into `parse/extractor.mjs`, `parse/imports.mjs`, `parse/languages.mjs`, `parse/metadata.mjs`, `parse/routes.mjs` |
697
+
698
+ New modules added in the reorg: `engine/binary.mjs` (embedding binary codec + vector index), `mcp/format.mjs`, `mcp/topology.mjs`, `embeddings.mjs`, `git-signals.mjs`, `layout.mjs`, `daemon-ctl.mjs`, `daemon-lock.mjs`, `cli-ui.mjs`. (`config.mjs` and `storage.mjs` already existed in v1.2.0 and were extended, not added.)
699
+
700
+ ---
701
+
702
+ ### Metrics
703
+
704
+ #### Honesty note
705
+
706
+ v1.2.0 reported benchmark numbers using the enrichment + rerank path (the "best" configuration). v2.0.0 adopts a lexical-first honest baseline: the default path is lexical-only (zero dependencies), and opt-in configurations (embeddings, rerank, enrichment) are measured separately.
707
+
708
+ #### 5-suite benchmark (axios, express, NestJS, FastAPI, gin — 100 queries: 69 symbolic + 31 semantic)
709
+
710
+ This is the primary eval harness (`npm run test:eval`). All numbers are strict symbol-level (a hit requires the exact symbol, not just the correct file).
711
+
712
+ | Channel | s@1 | s@5 | MRR | Semantic rank-1 | Semantic s@5 | File-only inflation |
713
+ |---|---|---|---|---|---|---|
714
+ | **v1.2.0 — Hybrid + enrichment + rerank (best path)** | — | **0.82** | 0.73 | 0.35 | 0.65 | ~11.6% |
715
+ | **v2.0.0 — Lexical default (+ stemming + IDF path boost)** | 0.58 | **0.81** | 0.65 | 0.19 | **0.61** | ~11.6% |
716
+ | v2.0.0 — Hybrid nomic, no rerank | 0.60 | 0.79 | 0.68 | 0.23 | 0.58 | ~11.6% |
717
+ | v2.0.0 — Hybrid nomic + LLM rerank | 0.64 | 0.80 | 0.71 | 0.35 | 0.61 | ~11.6% |
718
+
719
+ Key changes vs v1.2.0:
720
+
721
+ - **Lexical s@5 reaches 0.81** (within 1 point of v1.2.0's best path of 0.82) with zero external dependencies, driven by Porter stemming and the IDF-gated path boost.
722
+ - **Semantic s@5 (lexical):** 0.48 floor → **0.61** from stemming + the IDF-gated path boost combined (+13 points, +27%); the path boost alone contributes +0.097 (0.516 → 0.613).
723
+ - **Overall s@5 (lexical):** 0.775 → **0.807** after IDF-gated path boost (+0.032, ≈+3 points).
724
+ - **Symbolic rank-1 unchanged** at 0.75 (byte-identical — stemming and path boost are additive and non-destructive to exact-match ranking).
725
+ - **File-only inflation corrected:** the v1.2.0 `0.1%` figure was a 100× display-formatting bug (`fmtPct` rendered a 0–1 fraction without the ×100 scale); the true lexical value is **~11.6%** overall, while the held-out validation set has **0.0%** inflation. The hybrid path does not increase inflation over lexical.
726
+ - **Backend parity:** memory vs SQLite results are byte-identical on all 100 queries (enforced in CI).
727
+
728
+ #### Stemming: held-out semantic recall
729
+
730
+ Porter stemming was validated on the **held-out query set** (15 queries, never used for tuning):
731
+
732
+ | Metric | Before stemming | After stemming | Δ |
733
+ |---|---|---|---|
734
+ | Held-out rank-1 (overall) | 0.733 | **0.800** | **+0.067** |
735
+ | Held-out semantic rank-1 | 0.200 | **0.400** | **+0.20 (+100%)** |
736
+ | Held-out MRR (overall) | 0.789 | **0.833** | +0.044 |
737
+ | Symbolic rank-1 / MRR (strict, tuning) | 0.755 / 0.807 | 0.755 / 0.807 | 0 (byte-identical) |
738
+
739
+ #### IDF-gated path boost delta (lexical, tuning channel)
740
+
741
+ | Metric | Before | After | Δ |
742
+ |---|---|---|---|
743
+ | Overall s@5 | 0.7749 | **0.8065** | **+0.0316** |
744
+ | Semantic s@5 (31q) | 0.5161 | **0.6129** | **+0.0968** |
745
+ | Semantic rank-1 | 0.1935 | 0.1935 | 0 |
746
+ | Symbolic rank-1 | 0.7536 | 0.7536 | 0 (byte-identical) |
747
+
748
+ #### Embedding quality: qwen3-embedding:4b (express suite, 21 queries)
749
+
750
+ | Channel | Semantic rank-1 | Semantic s@5 | Symbolic rank-1 |
751
+ |---|---|---|---|
752
+ | Lexical floor | 0.43 | 0.57 | 0.79 |
753
+ | nomic-embed-text (768-dim) | 0.43 | 0.57 | 0.79 |
754
+ | **qwen3-embedding:4b (2560-dim)** | 0.43 | **0.71** | 0.79 |
755
+
756
+ qwen3-embedding:4b raises express semantic s@5 from 0.57 to 0.71 (+24%) while holding rank-1 and symbolic precision. Cost: qwen3 4b is **~23× slower** than nomic-embed-text. The embedding channel is **off by default**; enable with `--embed-provider ollama --embed-model qwen3-embedding:4b` or the equivalent config.
757
+
758
+ #### Enrichment + HyDE (gin suite, semantic, opt-in)
759
+
760
+ | Config | Semantic rank-1 | Semantic MRR | Semantic s@5 |
761
+ |---|---|---|---|
762
+ | Lexical floor | 0.20 | 0.30 | 0.60 |
763
+ | Plain + rerank (7B) | 0.60 | 0.80 | 1.00 |
764
+ | Enriched (7B) + rerank | **0.80** | **0.87** | 1.00 |
765
+ | HyDE (qwen2.5-coder:7B), enriched corpus | **0.00** | 0.34 | **1.00** |
766
+
767
+ Key finding: enrichment **inverts** depending on embedder strength — it helps with weak embedders (MiniLM) but **regresses** with strong ones (qwen3). Enrichment and rerank remain correctly off by default.
768
+
769
+ #### Cross-language benchmark (18 fixtures, new in v2.0.0)
770
+
771
+ A new reproducible 18-language × 8-config matrix (`bench/`), covering all 14 supported languages and 8 frameworks on pinned OSS fixtures. All cells are cold, isolated builds; scoring is strict (exact symbol). Backend parity is 18/18 byte-identical.
772
+
773
+ | Metric (default lexical path, 18 languages) | Mean | Min | Max |
774
+ |---|---|---|---|
775
+ | Symbolic rank-1 | **70%** | 43% | 86% |
776
+ | Semantic rank-1 | **22%** | 0% | 67% |
777
+ | Semantic s@5 | **60%** | 0% | 100% |
778
+
779
+ Per-language symbolic rank-1 highlights (default path):
780
+
781
+ | Language | Fixture | Symbolic rank-1 | Semantic rank-1 | Semantic s@5 |
782
+ |---|---|---|---|---|
783
+ | Go | gin | 85% | 20% | 100% |
784
+ | Java/Spring | spring | 86% | 25% | 100% |
785
+ | Python/Django | django | 83% | 33% | 67% |
786
+ | Swift | alamofire | 78% | 67% | 100% |
787
+ | TypeScript | nestjs | 57% | 14% | 43% |
788
+ | SCSS | css | 43% | 25% | 50% |
789
+ | Bash | nvm | 44% | 33% | 67% |
790
+
791
+ Known structural gaps (invocation-verified, per `docs/benchmarks/BENCH_LANGUAGES.md` — the single source of truth): `get_call_graph` is effectively empty for SCSS (only 6 trivial `@include`/`@function` edges) and degraded to class-granularity for Java/Spring (call edges resolve, but methods aren't their own chunks). C#/ASP.NET, PHP/Laravel, and PHP/Symfony all resolve call edges (the Tier-1 C# and PHP call-graph fixes landed in v2.0.0). The `find_references` type-usage (`type_refs`) channel is empty for express-js, Django, Rails, SCSS, and Bash.
792
+
793
+ #### Token savings (18 languages)
794
+
795
+ Savings are measured as the token footprint of top-5 compact cards vs reading the full source files of those hits. The amortized column models one `get_chunk` full-body expansion (the recommended agent pattern).
796
+
797
+ | Language | Top-5 vs full files | Amortized (net 1 `get_chunk`) |
798
+ |---|---|---|
799
+ | Bash | 95.9% | 98.2% |
800
+ | C | 91.9% | 96.8% |
801
+ | Rust | 89.2% | 95.0% |
802
+ | Express (JS) | 88.9% | 93.5% |
803
+ | Go | 87.9% | 91.0% |
804
+ | FastAPI (Python) | 84.0% | 91.4% |
805
+ | Swift | 83.1% | 94.2% |
806
+ | Django (Python) | 78.1% | 90.9% |
807
+ | axios (JS) | 73.1% | 85.2% |
808
+ | NestJS (TS) | 66.7% | 84.4% |
809
+ | SCSS | 65.4% | 79.4% |
810
+ | Kotlin/Android | 62.7% | 82.3% |
811
+ | Ruby/Rails | 58.6% | 81.3% |
812
+ | PHP/Symfony | 57.6% | 86.1% |
813
+ | React (TS) | 56.4% | 82.1% |
814
+ | Java/Spring | 50.8% | 79.8% |
815
+ | PHP/Laravel | 32.4% | 72.7% |
816
+ | C#/ASP.NET | 19.3% | 65.1% |
817
+
818
+ v1.2.0 reported a mean of 79.0% (gross, 5 suites). v2.0.0's gross mean across the original 5 suites is 79.6%. The amortized figure (new metric) accounts for get_chunk expansions and is 65%–98% across languages.
819
+
820
+ #### End-to-end agent trace (real MCP tools, LLM sub-agent)
821
+
822
+ New in v2.0.0. Tested on 3 fixtures with real MCP tool calls traced through a sub-agent:
823
+
824
+ | Fixture | Tasks | Success | Avg tool calls | Avg tokens/task |
825
+ |---|---|---|---|---|
826
+ | gin (Go) | 2 | 100% | 4.0 | 4,286 |
827
+ | express-js (JS) | 2 | 100% | 3.5 | 2,089 |
828
+ | django (Python) | 2 | 100% | 5.0 | 1,763 |
829
+
830
+ ---
831
+
832
+ ## [1.2.0] — (main branch baseline)
833
+
834
+ Initial stable release with:
835
+
836
+ - AST indexing for 11 languages (TypeScript/JavaScript, Python, Go, Rust, Java, Kotlin, C#, Ruby, PHP, CSS/SCSS)
837
+ - Hybrid lexical + optional dense-vector search with RRF fusion
838
+ - 8 MCP tools: `search_code`, `get_chunk`, `resolve_symbol`, `get_chunk_summary`, `get_file_skeleton`, `get_call_graph`, `get_repo_map`, `list_index_stats`
839
+ - In-memory and SQLite storage backends with backend parity
840
+ - Optional LLM enrichment and reranker
841
+ - Watch daemon with file-change tracking
842
+ - Interactive `npx graph-indexer` setup wizard
843
+
844
+ ### Metrics (v1.2.0, 5 suites — 100 queries)
845
+
846
+ Measured with the enrichment + rerank path active (the "best" configuration at the time).
847
+
848
+ | Channel | Strict s@5 | Rank-1 | MRR | Symbolic rank-1 | Semantic rank-1 | Semantic s@5 |
849
+ |---|---|---|---|---|---|---|
850
+ | Hybrid + enrichment + rerank | 0.82 | 0.66 | 0.73 | 0.80 | 0.35 | 0.65 |
851
+ | Hybrid (no rerank) | — | — | — | — | 0.23 | 0.55 |
852
+ | Lexical-only | — | — | — | — | 0.19 | 0.48 |
853
+
854
+ Token savings: 79.0% (gross, top-5 cards vs full-file reads). Loose recall@5: 0.90.