graph-indexer 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
package/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.
|