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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<rules>
|
|
12
12
|
<rule name="hidden-calls">Rust inserts calls the source never spells: `?`→From::from, operators→Add::add etc., Deref coercion→deref, scope end→Drop::drop, `.await`→poll. None appear as edges — never call a trait method unused from an empty call graph.</rule>
|
|
13
|
-
<rule name="trait-dispatch">Calls through generics (T: Trait) or dyn Trait bind to the method NAME: get_call_graph("execute") mixes every execute. Disambiguate by the impl target in each signature. Enumerate implementors: ONE search_code(query: "impl TraitName for", exact_tokens: "TraitName").</rule>
|
|
13
|
+
<rule name="trait-dispatch">Calls through generics (T: Trait) or dyn Trait bind to the method NAME: get_call_graph("execute") mixes every execute. Disambiguate by the impl target in each signature. Enumerate implementors: ONE search_code(query: "impl TraitName for", exact_tokens: "TraitName"). If your index was built --resolver scip (rust-analyzer --scip), find_references returns a precise 🎯 SCIP-resolved set that separates same-named methods — trust it over name-only.</rule>
|
|
14
14
|
<rule name="re-exports">`pub use` hubs (crate roots, preludes) produce re_export nodes: internal origin → ONE hop; external crate → stop.</rule>
|
|
15
15
|
<rule name="closures-and-generics">Logic inside closures passed to combinators has no edges — behavioural search. A generic fn is ONE chunk regardless of instantiations; per-type behaviour comes from its trait bounds — resolve the bound trait's method ONCE, per-type copies don't exist.</rule>
|
|
16
16
|
<rule name="query-style">snake_case splits well; pin PascalCase trait/type names with exact_tokens. For error flow, search the error variant name or the thiserror/anyhow message string — far more unique than "error". Trust card file paths over guessed `crate::` paths.</rule>
|
package/seal.mjs
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file seal.mjs
|
|
3
|
+
* @description Sealed mode (F1): turn "air-gapped by default" from a behaviour you trust into a
|
|
4
|
+
* guarantee you can verify and enforce. Two opt-in tiers:
|
|
5
|
+
* • strict — zero network egress; lexical-only (the default path).
|
|
6
|
+
* • local — loopback only; local LLM/embedders on this box are allowed, nothing
|
|
7
|
+
* leaves it.
|
|
8
|
+
*
|
|
9
|
+
* Three mechanisms:
|
|
10
|
+
* 1. assertSealCompatible(config) — FAIL-CLOSED validation: refuse to start if any
|
|
11
|
+
* enabled feature would egress beyond the tier, naming the feature + remedy.
|
|
12
|
+
* 2. installEgressGuard({allow}) — a deny-by-default runtime hook on the in-process
|
|
13
|
+
* network paths (net/tls sockets, http(s), fetch) so an *accidental* call is
|
|
14
|
+
* caught, not just the known ones. A best-effort child_process denylist covers
|
|
15
|
+
* the obvious out-of-process egress tools (documented limitation: a child
|
|
16
|
+
* process has its own socket layer the parent cannot hook).
|
|
17
|
+
* 3. sealManifest(config) — a deterministic attestation document (tier, providers,
|
|
18
|
+
* egress posture) an auditor/CI can diff against policy.
|
|
19
|
+
*
|
|
20
|
+
* Sealed mode is itself opt-in and can only REMOVE capability (fail-closed); it never
|
|
21
|
+
* changes ranking, the store, or parity. The default path is already strict-compatible.
|
|
22
|
+
*
|
|
23
|
+
* Language grammars (parse/languages.mjs) are a separate, asset-availability form of
|
|
24
|
+
* fail-closed: they are not bundled dependencies (installed on demand into
|
|
25
|
+
* .graph-indexer/node_modules), so under sealed mode auto-install is disabled and
|
|
26
|
+
* indexer.mjs throws SealViolation if a configured language's grammar isn't already
|
|
27
|
+
* resolvable — pre-install before sealing rather than silently shipping a partial index.
|
|
28
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
29
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
30
|
+
* @license MIT
|
|
31
|
+
*/
|
|
32
|
+
import http from 'node:http';
|
|
33
|
+
import https from 'node:https';
|
|
34
|
+
import net from 'node:net';
|
|
35
|
+
import cp from 'node:child_process';
|
|
36
|
+
import crypto from 'node:crypto';
|
|
37
|
+
|
|
38
|
+
/** Thrown by the fail-closed validation and by the runtime guard. */
|
|
39
|
+
export class SealViolation extends Error {
|
|
40
|
+
constructor(message) { super(message); this.name = 'SealViolation'; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LOOPBACK_RE = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|0:0:0:0:0:0:0:1)$/i;
|
|
44
|
+
|
|
45
|
+
/** Is a bare host string a loopback address? (IPv6 brackets tolerated.) */
|
|
46
|
+
export function isLoopbackHost(host) {
|
|
47
|
+
if (!host) return false;
|
|
48
|
+
const h = String(host).replace(/^\[|\]$/g, '').trim().toLowerCase();
|
|
49
|
+
return LOOPBACK_RE.test(h);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is a URL string loopback-bound? Unparseable → false (treated as non-loopback). */
|
|
53
|
+
export function isLoopbackUrl(url) {
|
|
54
|
+
if (!url) return false;
|
|
55
|
+
try { return isLoopbackHost(new URL(String(url)).hostname); } catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The LLM endpoint a feature would hit, given the active provider. */
|
|
59
|
+
function llmHost(config) {
|
|
60
|
+
return config.llmProvider === 'mlx' ? config.mlxLmHost : config.ollamaHost;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Enumerate the enabled features that make network calls, each tagged with its reach
|
|
65
|
+
* ('loopback' = this box only, 'remote' = off-box) and a remedy. The default config (every
|
|
66
|
+
* optional feature off) returns [].
|
|
67
|
+
*
|
|
68
|
+
* @param {object} config Resolved config.
|
|
69
|
+
* @returns {Array<{feature:string,reach:'loopback'|'remote',target:string,remedy:string}>}
|
|
70
|
+
*/
|
|
71
|
+
export function egressingFeatures(config) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const reachOf = (host) => (isLoopbackHost(host) || isLoopbackUrl(host) ? 'loopback' : 'remote');
|
|
74
|
+
|
|
75
|
+
if (config.embeddingsEnabled) {
|
|
76
|
+
const p = config.embedProvider;
|
|
77
|
+
if (p === 'ollama' || p === 'auto') {
|
|
78
|
+
out.push({
|
|
79
|
+
feature: `embeddings (${p === 'auto' ? 'auto→ollama' : 'ollama'})`,
|
|
80
|
+
reach: reachOf(config.ollamaHost), target: config.ollamaHost,
|
|
81
|
+
remedy: 'drop --embeddings, or point OLLAMA_HOST at loopback under --sealed local',
|
|
82
|
+
});
|
|
83
|
+
} else if (p === 'local') {
|
|
84
|
+
out.push({
|
|
85
|
+
feature: 'embeddings (local MiniLM)', reach: 'remote',
|
|
86
|
+
target: 'model CDN (first-run download)',
|
|
87
|
+
remedy: 'pre-warm the model cache offline, or drop --embeddings under sealed mode',
|
|
88
|
+
});
|
|
89
|
+
} else if (p === 'code-local') {
|
|
90
|
+
// Same in-process @huggingface/transformers path as 'local' — the code model is
|
|
91
|
+
// fetched from the model CDN on the FIRST run (air-gapped only once cached).
|
|
92
|
+
out.push({
|
|
93
|
+
feature: 'embeddings (code-local code-specialized)', reach: 'remote',
|
|
94
|
+
target: 'model CDN (first-run download)',
|
|
95
|
+
remedy: 'pre-warm the code model cache offline, or drop --embeddings under sealed mode',
|
|
96
|
+
});
|
|
97
|
+
} else if (p === 'mlx') {
|
|
98
|
+
out.push({
|
|
99
|
+
feature: 'embeddings (mlx subprocess)', reach: 'loopback',
|
|
100
|
+
target: 'local mlx subprocess',
|
|
101
|
+
remedy: 'drop --embeddings under --sealed strict (local compute only under --sealed local)',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (config.enrichment?.enabled) {
|
|
106
|
+
out.push({
|
|
107
|
+
feature: 'enrichment (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
|
|
108
|
+
remedy: 'drop --enrichment under sealed mode',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (config.rerank?.enabled) {
|
|
112
|
+
if (config.rerank.provider === 'cross-encoder') {
|
|
113
|
+
out.push({
|
|
114
|
+
feature: 'rerank (cross-encoder)', reach: 'remote',
|
|
115
|
+
target: 'model CDN (first-run download)',
|
|
116
|
+
remedy: 'pre-warm the model cache offline, or drop --rerank under sealed mode',
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
out.push({
|
|
120
|
+
feature: 'rerank (generative LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
|
|
121
|
+
remedy: 'drop --rerank under sealed mode',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (config.hyde?.enabled) {
|
|
126
|
+
out.push({
|
|
127
|
+
feature: 'hyde (LLM)', reach: reachOf(llmHost(config)), target: llmHost(config),
|
|
128
|
+
remedy: 'disable hyde under sealed mode',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* FAIL-CLOSED validation. Throws SealViolation (listing every offending feature) if the config
|
|
136
|
+
* would egress beyond its tier. No-op when sealed is 'off'. Called from resolveConfig so a
|
|
137
|
+
* sealed-incompatible config can never be constructed.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} config Resolved config (reads config.sealed ∈ {'off','local','strict'}).
|
|
140
|
+
*/
|
|
141
|
+
export function assertSealCompatible(config) {
|
|
142
|
+
const tier = config.sealed || 'off';
|
|
143
|
+
if (tier === 'off') return;
|
|
144
|
+
const feats = egressingFeatures(config);
|
|
145
|
+
// strict forbids ANY egress; local forbids only off-box ('remote') reach.
|
|
146
|
+
const violations = tier === 'strict' ? feats : feats.filter(f => f.reach === 'remote');
|
|
147
|
+
if (violations.length === 0) return;
|
|
148
|
+
const detail = violations.map(v => ` • ${v.feature} → ${v.reach} (${v.target}); ${v.remedy}`).join('\n');
|
|
149
|
+
throw new SealViolation(
|
|
150
|
+
`--sealed ${tier} refuses to start — ${violations.length} enabled feature(s) would egress`
|
|
151
|
+
+ (tier === 'strict' ? ' (strict = lexical-only, zero network):\n' : ' beyond loopback:\n')
|
|
152
|
+
+ detail);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Runtime egress guard ─────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function hostFromHttpArgs(args) {
|
|
158
|
+
const a = args[0];
|
|
159
|
+
if (typeof a === 'string') { try { return new URL(a).hostname; } catch { return 'localhost'; } }
|
|
160
|
+
if (a && typeof a === 'object') {
|
|
161
|
+
if (typeof a.href === 'string') { try { return new URL(a.href).hostname; } catch { /* fall through */ } }
|
|
162
|
+
const h = a.hostname || a.host || 'localhost';
|
|
163
|
+
return String(h).replace(/:\d+$/, '');
|
|
164
|
+
}
|
|
165
|
+
return 'localhost';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hostFromNetArgs(args) {
|
|
169
|
+
const a = args[0];
|
|
170
|
+
if (a && typeof a === 'object') {
|
|
171
|
+
if (a.path) return null; // IPC / unix socket — local, not network
|
|
172
|
+
return a.host || 'localhost';
|
|
173
|
+
}
|
|
174
|
+
if (typeof a === 'number') return typeof args[1] === 'string' ? args[1] : 'localhost';
|
|
175
|
+
if (typeof a === 'string') return null; // IPC path
|
|
176
|
+
return 'localhost';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hostFromFetch(input) {
|
|
180
|
+
if (typeof input === 'string') { try { return new URL(input).hostname; } catch { return null; } }
|
|
181
|
+
if (input && typeof input === 'object') {
|
|
182
|
+
if (typeof input.url === 'string') { try { return new URL(input.url).hostname; } catch { return null; } }
|
|
183
|
+
try { return new URL(String(input)).hostname; } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const EGRESS_BINS = /^(curl|wget|nc|ncat|telnet|ssh|scp|sftp|rsync)$/i;
|
|
189
|
+
const GIT_NET = /^(fetch|pull|push|clone|remote|ls-remote|submodule)$/i;
|
|
190
|
+
const PKG_MGRS = /^(npm|pnpm|yarn|pip|pip3|gem|cargo|go)$/i;
|
|
191
|
+
const PKG_NET = /\b(install|add|update|upgrade|ci|fetch|download|get)\b/i;
|
|
192
|
+
|
|
193
|
+
/** Best-effort: would this child process reach the network? (documented as not airtight.) */
|
|
194
|
+
export function commandWouldEgress(cmd, args = []) {
|
|
195
|
+
const base = String(cmd || '').split(/[\\/]/).pop();
|
|
196
|
+
if (EGRESS_BINS.test(base)) return true;
|
|
197
|
+
if (/^git$/i.test(base) && args[0] && GIT_NET.test(String(args[0]))) return true;
|
|
198
|
+
if (PKG_MGRS.test(base) && args.some(a => PKG_NET.test(String(a)))) return true;
|
|
199
|
+
// shell strings (exec/execSync) arrive as the whole command in `cmd`.
|
|
200
|
+
if (!args.length && /\b(curl|wget|nc|ncat|ssh|scp|rsync)\b/i.test(String(cmd))) return true;
|
|
201
|
+
if (!args.length && /\bgit\s+(fetch|pull|push|clone|remote|ls-remote)\b/i.test(String(cmd))) return true;
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let _installed = null;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Install the deny-by-default egress guard. Idempotent. The in-process socket/http/fetch hooks are
|
|
209
|
+
* airtight for THIS process; the child_process denylist is best-effort (a child has its own socket
|
|
210
|
+
* layer). Returns a handle with restore() (used by tests + an orderly shutdown).
|
|
211
|
+
*
|
|
212
|
+
* @param {object} [opts]
|
|
213
|
+
* @param {Array<'loopback'>} [opts.allow] [] (strict) → block all; ['loopback'] (local) → permit loopback.
|
|
214
|
+
*/
|
|
215
|
+
export function installEgressGuard({ allow = [] } = {}) {
|
|
216
|
+
if (_installed) return _installed;
|
|
217
|
+
const allowLoopback = allow.includes('loopback');
|
|
218
|
+
const permit = (host) => host !== null && allowLoopback && isLoopbackHost(host);
|
|
219
|
+
const block = (host, api) => {
|
|
220
|
+
throw new SealViolation(`sealed mode blocked a network connection to ${host || '(unparseable host)'} via ${api}`);
|
|
221
|
+
};
|
|
222
|
+
// A null host from net means IPC/unix socket (local) → allow; from fetch means unparseable → block.
|
|
223
|
+
const guardNet = (host, api, orig, ctx, args) => {
|
|
224
|
+
if (host === null || permit(host)) return orig.apply(ctx, args);
|
|
225
|
+
return block(host, api);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const orig = {
|
|
229
|
+
httpRequest: http.request, httpGet: http.get,
|
|
230
|
+
httpsRequest: https.request, httpsGet: https.get,
|
|
231
|
+
netConnect: net.Socket.prototype.connect,
|
|
232
|
+
fetch: globalThis.fetch,
|
|
233
|
+
spawn: cp.spawn, spawnSync: cp.spawnSync,
|
|
234
|
+
exec: cp.exec, execSync: cp.execSync,
|
|
235
|
+
execFile: cp.execFile, execFileSync: cp.execFileSync,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
http.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpRequest.apply(this, args) : block(h, 'http.request'); };
|
|
239
|
+
http.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpGet.apply(this, args) : block(h, 'http.get'); };
|
|
240
|
+
https.request = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsRequest.apply(this, args) : block(h, 'https.request'); };
|
|
241
|
+
https.get = function (...args) { const h = hostFromHttpArgs(args); return permit(h) ? orig.httpsGet.apply(this, args) : block(h, 'https.get'); };
|
|
242
|
+
net.Socket.prototype.connect = function (...args) { return guardNet(hostFromNetArgs(args), 'net.connect', orig.netConnect, this, args); };
|
|
243
|
+
if (orig.fetch) globalThis.fetch = function (input, init) { const h = hostFromFetch(input); return permit(h) ? orig.fetch.call(this, input, init) : block(h, 'fetch'); };
|
|
244
|
+
|
|
245
|
+
const guardChild = (name, origFn) => function (cmd, args, ...rest) {
|
|
246
|
+
const argv = Array.isArray(args) ? args : [];
|
|
247
|
+
if (commandWouldEgress(cmd, argv)) throw new SealViolation(`sealed mode blocked a network-reaching child process via ${name}: ${cmd}`);
|
|
248
|
+
return origFn.call(this, cmd, args, ...rest);
|
|
249
|
+
};
|
|
250
|
+
cp.spawn = guardChild('spawn', orig.spawn);
|
|
251
|
+
cp.spawnSync = guardChild('spawnSync', orig.spawnSync);
|
|
252
|
+
cp.exec = guardChild('exec', orig.exec);
|
|
253
|
+
cp.execSync = guardChild('execSync', orig.execSync);
|
|
254
|
+
cp.execFile = guardChild('execFile', orig.execFile);
|
|
255
|
+
cp.execFileSync = guardChild('execFileSync', orig.execFileSync);
|
|
256
|
+
|
|
257
|
+
_installed = {
|
|
258
|
+
allow,
|
|
259
|
+
restore() {
|
|
260
|
+
http.request = orig.httpRequest; http.get = orig.httpGet;
|
|
261
|
+
https.request = orig.httpsRequest; https.get = orig.httpsGet;
|
|
262
|
+
net.Socket.prototype.connect = orig.netConnect;
|
|
263
|
+
if (orig.fetch) globalThis.fetch = orig.fetch;
|
|
264
|
+
cp.spawn = orig.spawn; cp.spawnSync = orig.spawnSync;
|
|
265
|
+
cp.exec = orig.exec; cp.execSync = orig.execSync;
|
|
266
|
+
cp.execFile = orig.execFile; cp.execFileSync = orig.execFileSync;
|
|
267
|
+
_installed = null;
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
return _installed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Whether the guard is currently installed (for stats / idempotency checks). */
|
|
274
|
+
export function egressGuardActive() { return _installed != null; }
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Deterministic attestation document — the effective sealed posture an auditor/CI can diff against
|
|
278
|
+
* policy. No timestamps in the body (stamp outside if needed) so it is reproducible.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} config Resolved config.
|
|
281
|
+
*/
|
|
282
|
+
export function sealManifest(config) {
|
|
283
|
+
const tier = config.sealed || 'off';
|
|
284
|
+
return {
|
|
285
|
+
sealed: tier,
|
|
286
|
+
egress: tier === 'strict' ? 'none' : tier === 'local' ? 'loopback-only' : 'unrestricted',
|
|
287
|
+
providers: {
|
|
288
|
+
embeddings: config.embeddingsEnabled ? config.embedProvider : 'off',
|
|
289
|
+
enrichment: config.enrichment?.enabled ? config.llmProvider : 'off',
|
|
290
|
+
rerank: config.rerank?.enabled ? config.rerank.provider : 'off',
|
|
291
|
+
llm: config.llmProvider || 'ollama',
|
|
292
|
+
},
|
|
293
|
+
egressing_features: egressingFeatures(config).map(f => ({ feature: f.feature, reach: f.reach, target: f.target })),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Attestation signing (F1 hardening) ───────────────────────────────────────────────────────
|
|
298
|
+
// Turn the manifest from a document you trust into one you can VERIFY: a detached signature over
|
|
299
|
+
// its canonical bytes, so an auditor/CI proves the attested posture is authentic and untampered.
|
|
300
|
+
// Pure local crypto (node:crypto) — no dependency, no network; works under --sealed strict.
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Deterministic JSON: object keys sorted recursively → stable bytes for signing/verifying. (The
|
|
304
|
+
* manifest is already emitted in a fixed order, but signing over a canonical form makes the
|
|
305
|
+
* signature robust to any future key-order change and to a round-trip through a file.)
|
|
306
|
+
*/
|
|
307
|
+
export function canonicalJson(value) {
|
|
308
|
+
const norm = (v) => {
|
|
309
|
+
if (Array.isArray(v)) return v.map(norm);
|
|
310
|
+
if (v && typeof v === 'object') {
|
|
311
|
+
const out = {};
|
|
312
|
+
for (const k of Object.keys(v).sort()) out[k] = norm(v[k]);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
return v;
|
|
316
|
+
};
|
|
317
|
+
return JSON.stringify(norm(value));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Map an asymmetric key type to its (node digest, label) signing spec. Ed25519 uses a null digest. */
|
|
321
|
+
function signSpec(keyType) {
|
|
322
|
+
switch (keyType) {
|
|
323
|
+
case 'ed25519': return { digest: null, alg: 'ed25519' };
|
|
324
|
+
case 'ed448': return { digest: null, alg: 'ed448' };
|
|
325
|
+
case 'rsa':
|
|
326
|
+
case 'rsa-pss': return { digest: 'sha256', alg: 'rsa-sha256' };
|
|
327
|
+
case 'ec': return { digest: 'sha256', alg: 'ecdsa-sha256' };
|
|
328
|
+
default: throw new Error(`unsupported attestation key type: ${keyType}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** SHA-256 fingerprint (first 16 bytes, hex) of a public key's SPKI/DER encoding — a stable key id. */
|
|
333
|
+
export function publicKeyFingerprint(publicKey) {
|
|
334
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
335
|
+
return crypto.createHash('sha256').update(der).digest('hex').slice(0, 32);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Sign a seal manifest with a PEM private key (Ed25519 / RSA / EC). Returns a signed envelope
|
|
340
|
+
* `{ manifest, signature: { alg, keyType, publicKeyFingerprint, value } }`. The signature covers the
|
|
341
|
+
* CANONICAL JSON of the manifest, so it is reproducible and tamper-evident.
|
|
342
|
+
*
|
|
343
|
+
* @param {object} manifest Output of sealManifest(config).
|
|
344
|
+
* @param {string|Buffer} privateKeyPem A PEM-encoded private key.
|
|
345
|
+
*/
|
|
346
|
+
export function signManifest(manifest, privateKeyPem) {
|
|
347
|
+
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
|
348
|
+
const spec = signSpec(privateKey.asymmetricKeyType);
|
|
349
|
+
const data = Buffer.from(canonicalJson(manifest), 'utf8');
|
|
350
|
+
const value = crypto.sign(spec.digest, data, privateKey).toString('base64');
|
|
351
|
+
const publicKey = crypto.createPublicKey(privateKey);
|
|
352
|
+
return {
|
|
353
|
+
manifest,
|
|
354
|
+
signature: {
|
|
355
|
+
alg: spec.alg,
|
|
356
|
+
keyType: privateKey.asymmetricKeyType,
|
|
357
|
+
publicKeyFingerprint: publicKeyFingerprint(publicKey),
|
|
358
|
+
value,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Verify a signed manifest envelope against a PEM public key. Returns `{ valid, reason }` (never
|
|
365
|
+
* throws — a malformed envelope/key is a clean `valid:false`). `valid` is true only if the signature
|
|
366
|
+
* matches the canonical JSON of `envelope.manifest` under the declared algorithm and key.
|
|
367
|
+
*
|
|
368
|
+
* @param {object} envelope Output of signManifest().
|
|
369
|
+
* @param {string|Buffer} publicKeyPem A PEM-encoded public key.
|
|
370
|
+
*/
|
|
371
|
+
export function verifySignedManifest(envelope, publicKeyPem) {
|
|
372
|
+
try {
|
|
373
|
+
if (!envelope || !envelope.manifest || !envelope.signature || !envelope.signature.value) {
|
|
374
|
+
return { valid: false, reason: 'not a signed manifest envelope (missing manifest/signature)' };
|
|
375
|
+
}
|
|
376
|
+
const publicKey = crypto.createPublicKey(publicKeyPem);
|
|
377
|
+
const spec = signSpec(publicKey.asymmetricKeyType);
|
|
378
|
+
if (spec.alg !== envelope.signature.alg) {
|
|
379
|
+
return { valid: false, reason: `algorithm mismatch: envelope=${envelope.signature.alg}, key=${spec.alg}` };
|
|
380
|
+
}
|
|
381
|
+
const data = Buffer.from(canonicalJson(envelope.manifest), 'utf8');
|
|
382
|
+
const ok = crypto.verify(spec.digest, data, publicKey, Buffer.from(envelope.signature.value, 'base64'));
|
|
383
|
+
if (!ok) return { valid: false, reason: 'signature does not match (tampered manifest or wrong key)' };
|
|
384
|
+
const fp = publicKeyFingerprint(publicKey);
|
|
385
|
+
if (envelope.signature.publicKeyFingerprint && envelope.signature.publicKeyFingerprint !== fp) {
|
|
386
|
+
return { valid: false, reason: 'public-key fingerprint mismatch' };
|
|
387
|
+
}
|
|
388
|
+
return { valid: true, reason: 'signature valid', publicKeyFingerprint: fp };
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return { valid: false, reason: `verification error: ${e.message}` };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Generate an Ed25519 attestation keypair as PEM strings (`idx-index --gen-attestation-key`). */
|
|
395
|
+
export function generateAttestationKeyPair() {
|
|
396
|
+
return crypto.generateKeyPairSync('ed25519', {
|
|
397
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
398
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
399
|
+
});
|
|
400
|
+
}
|
package/search-core.mjs
CHANGED
|
@@ -295,6 +295,43 @@ export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
|
|
|
295
295
|
return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* B3 — score the learned-sparse expansion channel: a weighted-term retrieval over the EXISTING BM25
|
|
300
|
+
* postings, used identically by both backends so the channel is parity-by-construction.
|
|
301
|
+
*
|
|
302
|
+
* PARITY INVARIANT (do not break): each chunk receives EXACTLY ONE addend per associate term, so a
|
|
303
|
+
* chunk's final float sum is the cross-term accumulation in `termWeights`' iteration order — which is
|
|
304
|
+
* deterministic and identical across backends (expandSparseQuery rebuilds the Map sorted: weight desc,
|
|
305
|
+
* term asc). The INNER getPostings(term) order legitimately differs between backends (memory Map vs
|
|
306
|
+
* SQL rows) and is parity-IRRELEVANT precisely because the keys within one term are distinct (one
|
|
307
|
+
* scores.set per chunk per term). Never accumulate within a term, or emit a chunk twice for one term.
|
|
308
|
+
*
|
|
309
|
+
* The backends differ only in HOW postings are reached (in-memory Map vs SQL), injected as accessors:
|
|
310
|
+
* getDf(term) → document frequency (0 ⇒ skip the term)
|
|
311
|
+
* getPostings(term) → iterable of [chunkId, tf]
|
|
312
|
+
* getDocLen(chunkId) → that chunk's BM25 doc length (falls back to avgdl)
|
|
313
|
+
*
|
|
314
|
+
* @param {Iterable<[string, number]>} termWeights associate term → fusion weight (ordered)
|
|
315
|
+
* @param {{docCount:number, avgdl:number, getDf:Function, getPostings:Function, getDocLen:Function}} acc
|
|
316
|
+
* @returns {Array<{id:string, score:number, rank:number}>} ranked, capped, id-tie-broken
|
|
317
|
+
*/
|
|
318
|
+
export function scoreSparseExpanded(termWeights, { docCount, avgdl, getDf, getPostings, getDocLen }) {
|
|
319
|
+
const scores = new Map();
|
|
320
|
+
for (const [term, weight] of termWeights) {
|
|
321
|
+
const dfreq = getDf(term);
|
|
322
|
+
if (!dfreq) continue;
|
|
323
|
+
const idf = okapiIdf(docCount, dfreq);
|
|
324
|
+
for (const [chunkId, tf] of getPostings(term)) {
|
|
325
|
+
const dl = getDocLen(chunkId) ?? avgdl;
|
|
326
|
+
scores.set(chunkId, (scores.get(chunkId) || 0) + weight * bm25Score(idf, tf, dl, avgdl));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return Array.from(scores.entries())
|
|
330
|
+
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
|
|
331
|
+
.slice(0, LEXICAL_FUSION_CAP)
|
|
332
|
+
.map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
|
|
333
|
+
}
|
|
334
|
+
|
|
298
335
|
// ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
|
|
299
336
|
|
|
300
337
|
// Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
|
|
@@ -368,6 +405,20 @@ const NL_LEXICAL_WEIGHT = 1.5;
|
|
|
368
405
|
const NL_VECTOR_WEIGHT_PLAIN = 0.4;
|
|
369
406
|
const NL_VECTOR_WEIGHT_ENRICHED = 0.6;
|
|
370
407
|
|
|
408
|
+
// B3 learned-sparse channel weight. The sparse channel is the corpus-learned vocabulary EXPANSION
|
|
409
|
+
// view (associate terms the literal query never mentions); it only fires for NL queries on a
|
|
410
|
+
// --learned-sparse index, so it never touches symbolic lookups or the default path.
|
|
411
|
+
//
|
|
412
|
+
// HONEST MEASUREMENT (strict test:eval, 5 core suites, in-process A/B — see the weight sweep in
|
|
413
|
+
// IMPROVEMENT_LEARNED_SPARSE.md): the channel does NOT beat the lexical baseline at any weight.
|
|
414
|
+
// Higher weights (0.5/0.3) REGRESS semantic s@5 (the co-occurrence associates add noise that buries
|
|
415
|
+
// correct hits); the best case is ≈neutral at 0.2 (semantic + symbolic rank-1 both unchanged,
|
|
416
|
+
// overall s@5 −0.5). Like BM25F / AST / the code-embedding NL weight before it, this measured
|
|
417
|
+
// net-neutral-to-negative, so the DEFAULT stays lexical (B3 ships opt-in via --learned-sparse only).
|
|
418
|
+
// 0.2 is the measured least-harmful active setting for users who opt in / whose corpus has the
|
|
419
|
+
// vocabulary mismatch the benchmark fixtures lack.
|
|
420
|
+
const NL_SPARSE_WEIGHT = 0.2;
|
|
421
|
+
|
|
371
422
|
const QUERY_STOPWORDS = new Set([
|
|
372
423
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
|
|
373
424
|
'by', 'from', 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what',
|
|
@@ -424,7 +475,7 @@ export const EXAMPLE_DIR_RE = /^(?:[^/\\]+[/\\])?(?:examples?|samples?|demos?|tu
|
|
|
424
475
|
export function fuseAndRank({
|
|
425
476
|
lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
|
|
426
477
|
docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
|
|
427
|
-
corpusEnriched = false,
|
|
478
|
+
corpusEnriched = false, sparseResults = [],
|
|
428
479
|
}) {
|
|
429
480
|
const rrfScores = new Map();
|
|
430
481
|
const K = rrfK;
|
|
@@ -501,9 +552,12 @@ export function fuseAndRank({
|
|
|
501
552
|
const queryIsStyle = exactBoostName != null ||
|
|
502
553
|
/[.#]|css|scss|stylesheet|selector|\bstyle\b|\bclass\b|color|margin|padding|font|border|background|hover|keyframe|animation|mixin|breakpoint|responsive|\bwidth\b|\bheight\b/.test(queryLower);
|
|
503
554
|
|
|
555
|
+
// B3 learned-sparse channel (opt-in, presence + NL gated by the caller). When the caller passes
|
|
556
|
+
// no sparse candidates the spread contributes nothing → byte-identical to the default path.
|
|
504
557
|
const allResults = [
|
|
505
558
|
...vectorResults.map(r => ({ ...r, _w: wVec })),
|
|
506
559
|
...lexicalResults.map(r => ({ ...r, _w: wLex })),
|
|
560
|
+
...sparseResults.map(r => ({ ...r, _w: NL_SPARSE_WEIGHT })),
|
|
507
561
|
];
|
|
508
562
|
|
|
509
563
|
for (const { id, rank, _w } of allResults) {
|
|
@@ -593,6 +647,102 @@ export function fuseAndRank({
|
|
|
593
647
|
.filter(r => r.chunk !== undefined);
|
|
594
648
|
}
|
|
595
649
|
|
|
650
|
+
// ─── D3: learned re-ranker (opt-in, post-fusion) ─────────────────────────────────
|
|
651
|
+
//
|
|
652
|
+
// A learned linear model that RE-ORDERS the fused top-N using features the engine
|
|
653
|
+
// already computes — the RRF fused score plus PROGRAM-STRUCTURE signals A4/A5/A1 added
|
|
654
|
+
// (symbol centrality, resolved-edge in-degree, SCIP-resolved in-degree) and git recency.
|
|
655
|
+
// It is a *post-fusion* re-rank (like the LLM reranker / git boost — tool layer, never
|
|
656
|
+
// inside fuseAndRank), so the default RRF ordering is byte-identical: this runs ONLY when
|
|
657
|
+
// the caller opts in (config.ranker === 'learned'). Inference is a dot product — zero
|
|
658
|
+
// runtime dependency, no model server, deterministic (ties broken on id) → parity-safe.
|
|
659
|
+
//
|
|
660
|
+
// Honest scope: the discriminating features (centrality / in-degree / resolved-in) are
|
|
661
|
+
// ZERO without --symbol-graph, so on a default index the model degrades to the RRF score
|
|
662
|
+
// + git + node-type and is ≈neutral; its lift is on symbol-graph indexes. Trained offline
|
|
663
|
+
// (bench/train-ranker.mjs) on the benchmark's labelled query→chunk pairs; RRF stays the
|
|
664
|
+
// default. See docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the measured deltas.
|
|
665
|
+
|
|
666
|
+
/** Feature order — the model's weight vector is keyed by these names. */
|
|
667
|
+
export const RANKER_FEATURES = ['rrf', 'rank', 'centrality', 'in_degree', 'resolved_in', 'git', 'is_def', 'is_test'];
|
|
668
|
+
|
|
669
|
+
// Node types that are NOT a definition (a re-export / bare expression / import is a weaker hit).
|
|
670
|
+
const NON_DEF_NODE_RE = /expression|call_expression|re_export|_export_statement|import|comment/;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Default shipped model — CONSERVATIVE and RRF-DOMINANT by design. The offline trainer
|
|
674
|
+
* (bench/train-ranker.mjs) measured that the program-structure features (centrality / resolved
|
|
675
|
+
* in-degree) carry ~no signal for symbol retrieval on the benchmark — the correct symbol is usually
|
|
676
|
+
* NOT the most central one (the fit assigns them near-zero weight) — and a fully data-fit model only
|
|
677
|
+
* TIES RRF on the held-out split (rank-1 0.579 vs 0.579). So the shipped default keeps RRF in charge
|
|
678
|
+
* (it already
|
|
679
|
+
* encodes lexical+vector+boosts), adds only a whisper of structure as a tie-breaker, and an is_test
|
|
680
|
+
* penalty that aligns with the existing demotion. RRF remains the engine default (config.ranker);
|
|
681
|
+
* this model only runs under the opt-in `--ranker learned`, where it is ≈neutral on the generic
|
|
682
|
+
* benchmark and exists for users to RETRAIN on their own symbol-graph-rich repo. See
|
|
683
|
+
* docs/internals/IMPROVEMENT_LEARNED_RANKER.md for the full measured story.
|
|
684
|
+
*/
|
|
685
|
+
export const DEFAULT_RANKER_MODEL = Object.freeze({
|
|
686
|
+
bias: 0,
|
|
687
|
+
weights: Object.freeze({
|
|
688
|
+
rrf: 1.0, rank: 0.30, centrality: 0.04, in_degree: 0.04,
|
|
689
|
+
resolved_in: 0.04, git: 0.03, is_def: 0.0, is_test: -0.20,
|
|
690
|
+
}),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Extract the feature vector for one fused candidate. Pure; all backend data arrives through
|
|
695
|
+
* accessors so memory and SQLite produce identical features (parity).
|
|
696
|
+
*
|
|
697
|
+
* @param {{score:number, chunk:object}} result
|
|
698
|
+
* @param {number} idx 0-based position in the fused pool.
|
|
699
|
+
* @param {object} ctx { maxScore, getCentrality?, getInEdges?, gitScoreFor? }
|
|
700
|
+
*/
|
|
701
|
+
export function extractRankerFeatures(result, idx, ctx) {
|
|
702
|
+
const c = result.chunk || {};
|
|
703
|
+
const cen = ctx.getCentrality ? ctx.getCentrality(c.id) : null;
|
|
704
|
+
const inEdges = ctx.getInEdges ? (ctx.getInEdges(c.id) || []) : [];
|
|
705
|
+
let resolvedIn = 0;
|
|
706
|
+
for (const e of inEdges) if (e.confidence === 'resolved') resolvedIn++;
|
|
707
|
+
const nodeType = String(c.node_type || '');
|
|
708
|
+
return {
|
|
709
|
+
rrf: ctx.maxScore > 0 ? (result.score || 0) / ctx.maxScore : 0,
|
|
710
|
+
rank: 1 / (1 + idx),
|
|
711
|
+
centrality: cen ? cen.score : 0,
|
|
712
|
+
in_degree: Math.min(inEdges.length / 10, 1), // squashed into [0,1]
|
|
713
|
+
resolved_in: Math.min(resolvedIn / 5, 1),
|
|
714
|
+
git: ctx.gitScoreFor ? (ctx.gitScoreFor(c.file_path) || 0) : 0,
|
|
715
|
+
is_def: nodeType && !NON_DEF_NODE_RE.test(nodeType) ? 1 : 0,
|
|
716
|
+
is_test: c.file_path && TEST_FILE_RE.test(c.file_path) ? 1 : 0,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Linear score of a feature vector under a model. */
|
|
721
|
+
export function scoreLearned(features, model = DEFAULT_RANKER_MODEL) {
|
|
722
|
+
let s = model.bias || 0;
|
|
723
|
+
const w = model.weights || {};
|
|
724
|
+
for (const f of RANKER_FEATURES) s += (w[f] || 0) * (features[f] || 0);
|
|
725
|
+
return s;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Re-rank fused results with the learned model. Returns a NEW array (score replaced by the learned
|
|
730
|
+
* score), ordered by learned score desc with id tie-break. ≤1 result is returned unchanged.
|
|
731
|
+
* Deterministic → both backends re-rank identically (parity-safe).
|
|
732
|
+
*
|
|
733
|
+
* @param {Array<{score:number, chunk:object}>} results The fused (RRF) pool, over-fetched.
|
|
734
|
+
* @param {object} ctx Accessors: { getCentrality?, getInEdges?, gitScoreFor? }.
|
|
735
|
+
* @param {object} [model]
|
|
736
|
+
*/
|
|
737
|
+
export function learnedRerank(results, ctx = {}, model = DEFAULT_RANKER_MODEL) {
|
|
738
|
+
if (!Array.isArray(results) || results.length <= 1) return results;
|
|
739
|
+
const maxScore = results.reduce((m, r) => Math.max(m, r.score || 0), 0);
|
|
740
|
+
const fctx = { ...ctx, maxScore };
|
|
741
|
+
const scored = results.map((r, i) => ({ r, s: scoreLearned(extractRankerFeatures(r, i, fctx), model) }));
|
|
742
|
+
scored.sort((a, b) => (b.s - a.s) || ((a.r.chunk?.id ?? '') < (b.r.chunk?.id ?? '') ? -1 : 1));
|
|
743
|
+
return scored.map(({ r, s }) => ({ ...r, score: s }));
|
|
744
|
+
}
|
|
745
|
+
|
|
596
746
|
// ─── Graph centrality ──────────────────────────────────────────────────────────
|
|
597
747
|
|
|
598
748
|
/**
|