graph-indexer 3.0.0 → 3.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 +456 -0
- package/README.md +72 -15
- package/SECURITY.md +21 -4
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- package/integrations/claude-code/agents/code-structure.md +0 -3
- package/integrations/claude-code/skills/setup/SKILL.md +26 -0
- package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
- package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +10 -0
- package/integrations/claude-code-local/agents/code-structure.md +22 -0
- package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +140 -48
- package/src/cli/init.mjs +49 -17
- package/src/cli/resident.mjs +15 -5
- package/src/index/discover.mjs +3 -5
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +82 -7
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +371 -25
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +419 -26
- package/src/parse/arity.mjs +41 -3
- package/src/parse/extract.mjs +277 -46
- package/src/parse/lang/ecma.mjs +2 -0
- package/src/parse/lang/go.mjs +2 -0
- package/src/parse/lang/jvm.mjs +33 -0
- package/src/parse/lang/markdown.mjs +92 -0
- package/src/parse/lang/python.mjs +43 -0
- package/src/parse/lang/rust.mjs +215 -15
- package/src/parse/lang/sfc.mjs +125 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +55 -17
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +405 -0
- package/src/query/injection.mjs +173 -0
- package/src/query/intel.mjs +87 -22
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +286 -0
- package/src/query/read.mjs +6 -1
- package/src/query/report.mjs +53 -0
- package/src/query/routes.mjs +142 -0
- package/src/query/showcase.mjs +59 -0
- package/src/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- package/src/search/search.mjs +85 -17
- package/src/search/semantic.mjs +201 -0
- package/src/search/vectors.mjs +122 -0
- package/src/store/db.mjs +22 -4
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/README.md +47 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +4 -4
- /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
package/src/query/testcmd.mjs
CHANGED
|
@@ -6,16 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
9
10
|
|
|
10
|
-
function readJson(
|
|
11
|
+
function readJson(root, rel) { try { return JSON.parse(readRepoFile(root, rel)); } catch { return null; } }
|
|
11
12
|
const exists = (root, rel) => fs.existsSync(path.join(root, rel));
|
|
13
|
+
// commands are pasted into a shell as they are: a file named `t$(cmd).test.ts` (repository content)
|
|
14
|
+
// must reach the test runner as a name, never run as a command substitution
|
|
15
|
+
const plain = (s) => /^[\w./@+=:,-]+$/.test(s);
|
|
16
|
+
/** One shell word: as is when plain, else single-quoted. */
|
|
17
|
+
const q = (s) => (plain(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
18
|
+
// a name that would read as an option, or a test identifier that is not a plain word, gets no command
|
|
19
|
+
const shellSafe = (s) => !s.startsWith('-') && !/[\0\r\n]/.test(s);
|
|
12
20
|
|
|
13
21
|
/** Nearest package.json above a file (monorepos have one per package). */
|
|
14
22
|
function nearestPackage(root, rel) {
|
|
15
23
|
let dir = path.posix.dirname(rel);
|
|
16
24
|
for (;;) {
|
|
17
|
-
const p = path.join(
|
|
18
|
-
if (
|
|
25
|
+
const p = path.posix.join(dir === '.' ? '' : dir, 'package.json');
|
|
26
|
+
if (exists(root, p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(root, p) ?? {} };
|
|
19
27
|
if (dir === '.' || dir === '' || dir === '/') return null;
|
|
20
28
|
dir = path.posix.dirname(dir);
|
|
21
29
|
}
|
|
@@ -23,7 +31,7 @@ function nearestPackage(root, rel) {
|
|
|
23
31
|
|
|
24
32
|
function jsRunner(root, rel) {
|
|
25
33
|
const near = nearestPackage(root, rel);
|
|
26
|
-
const top = readJson(
|
|
34
|
+
const top = readJson(root, 'package.json') ?? {};
|
|
27
35
|
const pkgs = [near?.pkg, top].filter(Boolean);
|
|
28
36
|
const deps = Object.assign({}, ...pkgs.map(p => ({ ...p.dependencies, ...p.devDependencies })));
|
|
29
37
|
const scripts = pkgs.map(p => p.scripts?.test ?? '').join(' ');
|
|
@@ -38,7 +46,7 @@ function jsRunner(root, rel) {
|
|
|
38
46
|
|
|
39
47
|
function pyRunner(root) {
|
|
40
48
|
if (exists(root, 'tests/runtests.py')) return { kind: 'django' };
|
|
41
|
-
const pyproject = (
|
|
49
|
+
const pyproject = readRepoFile(root, 'pyproject.toml') ?? '';
|
|
42
50
|
if (exists(root, 'pytest.ini') || exists(root, 'conftest.py') || /\[tool\.pytest/.test(pyproject) || exists(root, 'tox.ini')) return { kind: 'pytest' };
|
|
43
51
|
return { kind: 'unittest' };
|
|
44
52
|
}
|
|
@@ -51,7 +59,7 @@ function pyRunner(root) {
|
|
|
51
59
|
export function testCommands(root, files) {
|
|
52
60
|
const groups = new Map();
|
|
53
61
|
const add = (key, f) => (groups.get(key) ?? groups.set(key, []).get(key)).push(f);
|
|
54
|
-
for (const f of files) {
|
|
62
|
+
for (const f of files.filter(shellSafe)) {
|
|
55
63
|
const ext = path.posix.extname(f);
|
|
56
64
|
if (/\.[cm]?[jt]sx?$/.test(ext)) add(`js:${jsRunner(root, f)}`, f);
|
|
57
65
|
else if (ext === '.py') add('py', f);
|
|
@@ -64,19 +72,20 @@ export function testCommands(root, files) {
|
|
|
64
72
|
}
|
|
65
73
|
const out = [];
|
|
66
74
|
for (const [key, fs_] of groups) {
|
|
67
|
-
if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.join(' ')}`);
|
|
75
|
+
if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.map(q).join(' ')}`);
|
|
68
76
|
else if (key === 'py') {
|
|
69
77
|
const r = pyRunner(root);
|
|
70
|
-
if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].join(' ')}`);
|
|
71
|
-
else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.join(' ')}`);
|
|
72
|
-
else out.push(`python -m unittest ${fs_.map(f => f.replace(/\.py$/, '').replace(/\//g, '.')).join(' ')}`);
|
|
73
|
-
} else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].join(' ')}`);
|
|
78
|
+
if (r.kind === 'django') out.push(`python tests/runtests.py ${[...new Set(fs_.map(f => f.replace(/^tests\//, '').replace(/\/[^/]*$/, '').replace(/\//g, '.')))].map(q).join(' ')}`);
|
|
79
|
+
else if (r.kind === 'pytest') out.push(`python -m pytest -q ${fs_.map(q).join(' ')}`);
|
|
80
|
+
else out.push(`python -m unittest ${fs_.map(f => q(f.replace(/\.py$/, '').replace(/\//g, '.'))).join(' ')}`);
|
|
81
|
+
} else if (key === 'go') out.push(`go test ${[...new Set(fs_.map(f => './' + path.posix.dirname(f)))].map(q).join(' ')}`);
|
|
74
82
|
else if (key === 'rs') out.push('cargo test');
|
|
75
83
|
else if (key === 'jvm') {
|
|
76
|
-
const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')))].join(',');
|
|
84
|
+
const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')).filter(c => /^\w+$/.test(c)))].join(',');
|
|
85
|
+
if (!classes) continue;
|
|
77
86
|
out.push(exists(root, 'pom.xml') ? `mvn -q test -Dtest=${classes}` : `./gradlew test ${classes.split(',').map(c => `--tests '*${c}'`).join(' ')}`);
|
|
78
|
-
} else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.join(' ')}` : `bundle exec ruby -Itest ${fs_[0]}`);
|
|
79
|
-
else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.join(' ')}`);
|
|
87
|
+
} else if (key === 'rb') out.push(exists(root, '.rspec') || fs_.some(f => f.endsWith('_spec.rb')) ? `bundle exec rspec ${fs_.map(q).join(' ')}` : `bundle exec ruby -Itest ${q(fs_[0])}`);
|
|
88
|
+
else if (key === 'php') out.push(`vendor/bin/phpunit ${fs_.map(q).join(' ')}`);
|
|
80
89
|
else if (key === 'cs') out.push('dotnet test');
|
|
81
90
|
}
|
|
82
91
|
return out;
|
|
@@ -90,6 +99,8 @@ export function testCommands(root, files) {
|
|
|
90
99
|
*/
|
|
91
100
|
export function testCaseCommands(root, cases) {
|
|
92
101
|
const out = [];
|
|
102
|
+
const word = (s) => /^\w+$/.test(s);
|
|
103
|
+
cases = cases.filter(c => shellSafe(c.path) && plain(c.path) && word(c.name) && (!c.cls || word(c.cls)));
|
|
93
104
|
const py = cases.filter(c => c.path.endsWith('.py'));
|
|
94
105
|
if (py.length) {
|
|
95
106
|
const r = pyRunner(root);
|
package/src/query/textsearch.mjs
CHANGED
|
@@ -7,15 +7,26 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import
|
|
10
|
+
import vm from 'node:vm';
|
|
11
|
+
import { withinRoot } from '../util/paths.mjs';
|
|
12
|
+
import { gitSync } from '../util/git.mjs';
|
|
11
13
|
|
|
12
14
|
const MAX_FILE_BYTES = 1_500_000;
|
|
15
|
+
// a caller's pattern gets this much matching time per search (reading files does not count): one
|
|
16
|
+
// with catastrophic backtracking (`(a+)+$`) is stopped instead of blocking the server
|
|
17
|
+
const REGEX_BUDGET_MS = 5000;
|
|
18
|
+
const SCAN = new vm.Script(`(() => {
|
|
19
|
+
if (!re.test(text)) return null;
|
|
20
|
+
const lines = text.split(/\\r?\\n/), hits = [];
|
|
21
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
|
|
22
|
+
return { lines, hits };
|
|
23
|
+
})()`);
|
|
13
24
|
const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
|
|
14
25
|
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
|
|
15
26
|
const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
|
|
16
27
|
|
|
17
28
|
function listFiles(root) {
|
|
18
|
-
const r =
|
|
29
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
|
|
19
30
|
if (r.status === 0) return r.stdout.split('\0').filter(Boolean).filter(p => !SKIP_DIRS.test(p));
|
|
20
31
|
const out = [];
|
|
21
32
|
const stack = [''];
|
|
@@ -98,25 +109,32 @@ export async function textSearch(intel, { pattern, path: prefix = null, literal
|
|
|
98
109
|
const indexed = new Map(intel.store.all('SELECT id, path, is_test FROM files').map(f => [f.path, f]));
|
|
99
110
|
const matches = [];
|
|
100
111
|
let total = 0, nfiles = 0;
|
|
112
|
+
const ctx = vm.createContext({ re, text: '' });
|
|
113
|
+
let budget = REGEX_BUDGET_MS;
|
|
101
114
|
for (const rel of files) {
|
|
102
115
|
let text;
|
|
103
116
|
try {
|
|
117
|
+
if (!withinRoot(root, rel)) continue;
|
|
104
118
|
const st = fs.statSync(path.join(root, rel));
|
|
105
119
|
if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
|
|
106
120
|
text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
107
121
|
} catch { continue; }
|
|
108
|
-
if (!re.test(text)) continue;
|
|
109
122
|
if (text.indexOf('\0') >= 0 && text.indexOf('\0') < 8000) continue; // binary
|
|
110
|
-
|
|
111
|
-
let
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
ctx.text = text;
|
|
124
|
+
let scan;
|
|
125
|
+
const t0 = performance.now();
|
|
126
|
+
try { scan = SCAN.runInContext(ctx, { timeout: Math.max(1, Math.ceil(budget)) }); } catch (e) {
|
|
127
|
+
if (e.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw e;
|
|
128
|
+
return { error: `The regular expression took more than ${REGEX_BUDGET_MS / 1000} s (catastrophic backtracking?). Simplify it, or pass literal: true to search for the text as is.` };
|
|
129
|
+
}
|
|
130
|
+
budget -= performance.now() - t0;
|
|
131
|
+
if (!scan?.hits.length) continue;
|
|
132
|
+
nfiles++;
|
|
133
|
+
const f = indexed.get(rel);
|
|
134
|
+
for (const i of scan.hits) {
|
|
115
135
|
total++;
|
|
116
|
-
|
|
117
|
-
matches.push({ path: rel, line: i + 1, text: lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
136
|
+
matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
118
137
|
}
|
|
119
|
-
if (any) nfiles++;
|
|
120
138
|
}
|
|
121
139
|
const ident = identifierOf(pattern, literal);
|
|
122
140
|
// annotate: enclosing definition, and what an identifier match refers to
|
package/src/search/search.mjs
CHANGED
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
* 1. name channel exact / case-insensitive / qualified / prefix matches on symbol names
|
|
5
5
|
* 2. lexical channel FTS5 BM25 over fields (name, qualified name, signature, doc, path, body)
|
|
6
6
|
* 3. concept channel the same index queried with thesaurus expansions (natural-language queries)
|
|
7
|
-
* 4.
|
|
7
|
+
* 4. semantic channel (optional) cosine similarity of a static code-embedding model (vectors.mjs),
|
|
8
|
+
* on when its model is installed
|
|
9
|
+
* 5. rerank a small linear model over interpretable features (name evidence, field
|
|
8
10
|
* coverage, kind/test/export priors, graph centrality), then per-file
|
|
9
11
|
* diversification so one file cannot flood the top results.
|
|
10
12
|
*/
|
|
11
13
|
import { analyzeQuery, codeTokens, splitIdentifier } from './tokenize.mjs';
|
|
14
|
+
import { VectorIndex } from './vectors.mjs';
|
|
12
15
|
|
|
13
16
|
const FTS_WEIGHTS = [10.0, 6.0, 3.0, 2.0, 2.5, 1.0]; // name, qname, sig, doc, path, body
|
|
14
17
|
const KIND_PRIOR = {
|
|
15
18
|
class: 1.0, interface: 0.95, struct: 1.0, trait: 0.95, enum: 0.85, type: 0.8, object: 0.95, module: 0.7, impl: 0.7,
|
|
16
19
|
function: 1.0, method: 1.0, constructor: 0.75, macro: 0.8, field: 0.55, property: 0.6, variable: 0.55, constant: 0.6,
|
|
17
|
-
selector: 0.35, keyframes: 0.3,
|
|
20
|
+
selector: 0.35, keyframes: 0.3, section: 0.5,
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
export const DEFAULT_WEIGHTS = {
|
|
@@ -39,11 +42,21 @@ export const DEFAULT_WEIGHTS = {
|
|
|
39
42
|
nlField: -0.93, // fields/properties/variables for natural-language (behavioural) queries
|
|
40
43
|
file: 1.0, // file-level BM25 of the symbol's file (hierarchical: file relevance first)
|
|
41
44
|
fileNL: 0.5, // extra file-level weight for natural-language (behavioural) queries
|
|
45
|
+
docs: 2.0, // documentation channel BM25 (only for queries about docs, design or rationale)
|
|
46
|
+
// semantic channel (opt-in): cosine, min-max normalised over its top candidates. Higher weights
|
|
47
|
+
// (0.5 / 2.0) score better on symbol search and worse on localizing real commits; these are
|
|
48
|
+
// the highest that leave localization within noise (docs/BENCHMARKS.md)
|
|
49
|
+
sem: 0.25,
|
|
50
|
+
semNL: 0.5, // extra semantic weight for natural-language queries
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
const EXAMPLE_PATH = /(^|\/)(examples?|samples?|demos?|sandbox|playground|docs?(_src|_source|_examples?)?|documentation|benchmarks?|bench|scripts?|fixtures?|testdata|tutorials?|website|\.github)\//i;
|
|
45
54
|
const DECLARATION_PATH = /\.d\.[cm]?ts$/;
|
|
55
|
+
export const WANTS_DOCS = /\b(docs?|documentation|documented|readme|guide|tutorial|design|rationale|architecture|adr|why|explained?|explanation|changelog|faq|how to)\b/i;
|
|
46
56
|
const BM25_FLOOR = 1.0;
|
|
57
|
+
const SEM_K = 200; // semantic candidates per query
|
|
58
|
+
const RRF_K = 60;
|
|
59
|
+
export const MAX_QUERIES = 6;
|
|
47
60
|
const FILE_GATE = 0.25; // own normalized BM25 at which the file boost applies in full (tuning split)
|
|
48
61
|
|
|
49
62
|
function stem(w) {
|
|
@@ -58,7 +71,8 @@ function ftsQuery(terms) {
|
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
export class SearchEngine {
|
|
61
|
-
constructor(indexer, { weights = DEFAULT_WEIGHTS, centrality = null, perFileCap = Number(process.env.GI_PER_FILE_CAP) || 3 } = {}) {
|
|
74
|
+
constructor(indexer, { weights = DEFAULT_WEIGHTS, centrality = null, perFileCap = Number(process.env.GI_PER_FILE_CAP) || 3, semantic = undefined } = {}) {
|
|
75
|
+
this.semantic = semantic === undefined ? VectorIndex.maybe(indexer) : semantic;
|
|
62
76
|
this.perFileCap = perFileCap;
|
|
63
77
|
this.ix = indexer;
|
|
64
78
|
this.store = indexer.store;
|
|
@@ -78,6 +92,7 @@ export class SearchEngine {
|
|
|
78
92
|
const kindFilter = opts.kinds ?? a.filters.kind ?? null;
|
|
79
93
|
const langFilter = opts.lang ?? a.filters.lang?.[0] ?? null;
|
|
80
94
|
const wantsTests = opts.includeTests ?? /\b(test|tests|spec|specs|mock|fixture)\b/i.test(query);
|
|
95
|
+
const wantsDocs = WANTS_DOCS.test(query);
|
|
81
96
|
const cands = new Map(); // id -> feature bag
|
|
82
97
|
const bag = (id) => { let f = cands.get(id); if (!f) cands.set(id, (f = { id })); return f; };
|
|
83
98
|
|
|
@@ -140,6 +155,23 @@ export class SearchEngine {
|
|
|
140
155
|
for (const r of rows) { const f = bag(r.id); if (f.bm25 == null) { f.bm25 = -r.s; if (-r.s > maxBm) maxBm = -r.s; } }
|
|
141
156
|
}
|
|
142
157
|
}
|
|
158
|
+
// documentation channel: Markdown sections, only when the query is about docs, design or rationale
|
|
159
|
+
let maxDoc = 0;
|
|
160
|
+
if (q1 && (wantsDocs || opts.docs)) {
|
|
161
|
+
for (const r of this.store.all('SELECT rowid AS id, bm25(doc_fts, 4.0, 1.0) AS s FROM doc_fts WHERE doc_fts MATCH ? ORDER BY s LIMIT 60', q1)) {
|
|
162
|
+
bag(r.id).doc = -r.s; if (-r.s > maxDoc) maxDoc = -r.s;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// semantic channel: definitions whose embedding is closest to the query's
|
|
166
|
+
let semLo = 0, semHi = 0;
|
|
167
|
+
if (this.semantic && (this.w.sem || this.w.semNL)) {
|
|
168
|
+
const hits = this.semantic.search(query, SEM_K);
|
|
169
|
+
if (hits.size) {
|
|
170
|
+
const vals = [...hits.values()];
|
|
171
|
+
semHi = Math.max(...vals); semLo = Math.min(...vals);
|
|
172
|
+
for (const [id, c] of hits) bag(id).sem = c;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
143
175
|
if (!cands.size) return { results: [], analysis: a };
|
|
144
176
|
|
|
145
177
|
// 4. features + rerank
|
|
@@ -161,6 +193,7 @@ export class SearchEngine {
|
|
|
161
193
|
if (pathFilter && !r.path.startsWith(pathFilter.replace(/^\.\//, ''))) continue;
|
|
162
194
|
if (kindFilter && !kindFilter.includes(r.kind)) continue;
|
|
163
195
|
if (langFilter && r.lang !== langFilter) continue;
|
|
196
|
+
if (r.kind === 'section' && !wantsDocs && !opts.docs) continue; // documentation answers only questions about it
|
|
164
197
|
const nameWords = splitIdentifier(r.name).map(stem);
|
|
165
198
|
const qnameWords = new Set(splitIdentifier(r.qname.replace(/\./g, '_')).map(stem));
|
|
166
199
|
const nameCover = nameWords.length ? nameWords.filter(x => qStems.has(x)).length / nameWords.length : 0;
|
|
@@ -171,7 +204,9 @@ export class SearchEngine {
|
|
|
171
204
|
// blown up by max-normalisation; when literal evidence is weak, the concept channel
|
|
172
205
|
// (thesaurus expansions) takes over — that is exactly the vocabulary-mismatch case.
|
|
173
206
|
if (f.bm25) s += w.bm25 * (f.bm25 / Math.max(maxBm, BM25_FLOOR));
|
|
207
|
+
if (f.doc) s += (w.docs ?? 0) * (f.doc / Math.max(maxDoc, BM25_FLOOR));
|
|
174
208
|
if (f.concept) s += (maxBm < BM25_FLOOR ? Math.max(w.concept, 1.0) : w.concept) * (f.concept / Math.max(maxCx, BM25_FLOOR));
|
|
209
|
+
if (f.sem != null && semHi > semLo) s += ((w.sem ?? 0) + (a.natural ? (w.semNL ?? 0) : 0)) * (f.sem - semLo) / (semHi - semLo);
|
|
175
210
|
if (f.exact) { s += w.exact * f.exact; reasons.push('name'); }
|
|
176
211
|
if (f.exactCI) { s += w.exactCI * f.exactCI; reasons.push('name'); }
|
|
177
212
|
if (f.qualified) { s += w.qualified; reasons.push('qualified name'); }
|
|
@@ -188,23 +223,56 @@ export class SearchEngine {
|
|
|
188
223
|
if (fs && f.bm25 && r.kind !== 'selector' && r.kind !== 'keyframes') s += (w.file + (a.natural ? w.fileNL : 0)) * fs * Math.min(1, (f.bm25 / Math.max(maxBm, BM25_FLOOR)) / FILE_GATE);
|
|
189
224
|
if (r.parent_kind === 'function' || r.parent_kind === 'method') s += w.nested;
|
|
190
225
|
if (DECLARATION_PATH.test(r.path)) s += w.declaration;
|
|
191
|
-
if (EXAMPLE_PATH.test(r.path) && !(pathFilter && EXAMPLE_PATH.test(pathFilter))) s += w.example;
|
|
226
|
+
if (r.kind !== 'section' && EXAMPLE_PATH.test(r.path) && !(pathFilter && EXAMPLE_PATH.test(pathFilter))) s += w.example;
|
|
192
227
|
if (a.natural && (r.kind === 'field' || r.kind === 'property' || r.kind === 'variable' || r.kind === 'constant')) s += w.nlField;
|
|
193
228
|
scored.push({ id: r.id, score: s, reasons, path: r.path, ...(opts.debug ? { features: { ...f, nameCover, queryCover, kind: r.kind } } : {}) });
|
|
194
229
|
}
|
|
195
230
|
scored.sort((x, y) => y.score - x.score || x.id - y.id);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
231
|
+
return { results: diversify(scored, limit, this.perFileCap), total: scored.length, analysis: a };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Several phrasings of one need ("where are tokens refreshed", "refreshToken", "session renewal")
|
|
236
|
+
* in one call: each is searched on its own and the rankings are fused by reciprocal rank
|
|
237
|
+
* (k = 60), so a definition several phrasings agree on rises and one phrasing's noise does not
|
|
238
|
+
* dominate. One query is the same as search().
|
|
239
|
+
*/
|
|
240
|
+
searchMany(queries, opts = {}) {
|
|
241
|
+
const qs = [...new Set((queries ?? []).map(q => String(q ?? '').trim()).filter(Boolean))].slice(0, MAX_QUERIES);
|
|
242
|
+
if (qs.length <= 1) return this.search(qs[0] ?? '', opts);
|
|
243
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 10, 100));
|
|
244
|
+
const per = Math.max(2 * limit, 20);
|
|
245
|
+
const fused = new Map();
|
|
246
|
+
let total = 0, analysis = null;
|
|
247
|
+
qs.forEach((q, qi) => {
|
|
248
|
+
const r = this.search(q, { ...opts, limit: per });
|
|
249
|
+
analysis ??= r.analysis;
|
|
250
|
+
total = Math.max(total, r.total ?? r.results.length);
|
|
251
|
+
r.results.forEach((x, rank) => {
|
|
252
|
+
let e = fused.get(x.id);
|
|
253
|
+
if (!e) fused.set(x.id, (e = { id: x.id, score: 0, reasons: [], path: x.path, queries: [] }));
|
|
254
|
+
e.score += 1 / (RRF_K + rank + 1);
|
|
255
|
+
e.queries.push(qi);
|
|
256
|
+
for (const why of x.reasons) if (!e.reasons.includes(why)) e.reasons.push(why);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
const scored = [...fused.values()].sort((x, y) => y.score - x.score || x.id - y.id);
|
|
260
|
+
return { results: diversify(scored, limit, this.perFileCap), total: Math.max(total, scored.length), analysis, queries: qs };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Per-file diversification: results past the cap from one file are pushed below the others. */
|
|
265
|
+
function diversify(scored, limit, cap) {
|
|
266
|
+
const perFile = new Map();
|
|
267
|
+
const out = [];
|
|
268
|
+
const deferred = [];
|
|
269
|
+
for (const r of scored) {
|
|
270
|
+
const n = perFile.get(r.path) ?? 0;
|
|
271
|
+
if (n >= cap) { deferred.push(r); continue; }
|
|
272
|
+
perFile.set(r.path, n + 1);
|
|
273
|
+
out.push(r);
|
|
274
|
+
if (out.length >= limit) break;
|
|
209
275
|
}
|
|
276
|
+
for (const r of deferred) { if (out.length >= limit) break; out.push(r); }
|
|
277
|
+
return out;
|
|
210
278
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional semantic channel: a static (Model2Vec) code embedding model, run in plain JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* A static model is a table of one vector per WordPiece token; a text's embedding is the mean of
|
|
5
|
+
* its tokens' vectors, normalised. There is no neural network to run, so it needs no native code,
|
|
6
|
+
* no ONNX runtime and no GPU: embedding a whole repository takes seconds on one core.
|
|
7
|
+
*
|
|
8
|
+
* The model is not part of the package. `graph-indexer semantic install` downloads it once, at a
|
|
9
|
+
* pinned revision with checked SHA-256 digests, into ~/.cache/graph-indexer/models (or
|
|
10
|
+
* GRAPH_INDEXER_MODEL_DIR). The channel is opt-in: it runs only with GRAPH_INDEXER_SEMANTIC=1 and
|
|
11
|
+
* the model installed; otherwise search is purely lexical + graph. On the benchmarks it helps
|
|
12
|
+
* symbol search for queries in words and does not help localizing real commits (docs/BENCHMARKS.md),
|
|
13
|
+
* so it is not the default.
|
|
14
|
+
*
|
|
15
|
+
* Vectors are computed lazily, file by file, the first time a search needs them, and kept in the
|
|
16
|
+
* index (`vectors`, `vec_files`), keyed by the file's content hash so edits re-embed only that file.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
export const MODEL = {
|
|
24
|
+
id: 'potion-code-16M-v2',
|
|
25
|
+
repo: 'minishlab/potion-code-16M-v2',
|
|
26
|
+
revision: 'e9d2a44ca6a05ac6685f3b23709ea57eb7352d5b',
|
|
27
|
+
license: 'MIT',
|
|
28
|
+
files: {
|
|
29
|
+
'model.safetensors': '75cf7a6c2171b230ad19b1e7d8e0b1aee86da5a02af8e7cacedd9921d227623c',
|
|
30
|
+
'tokenizer.json': '107bbdcbad4bff1d299b7a4c3a2fb17c52890688b7dd0e4c9deab79d3c4f3d45',
|
|
31
|
+
'config.json': '148e5691a6fcc553437156859701fba017a1ba5d340b170f17e0f3668fb861a7',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const MAX_TOKENS = 512; // Model2Vec's default truncation
|
|
36
|
+
const MAX_WORD_CHARS = 100; // WordPiece max_input_chars_per_word
|
|
37
|
+
|
|
38
|
+
export function modelDir() {
|
|
39
|
+
const base = process.env.GRAPH_INDEXER_MODEL_DIR || path.join(os.homedir(), '.cache', 'graph-indexer', 'models');
|
|
40
|
+
return path.join(base, MODEL.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function semanticEnabled() {
|
|
44
|
+
if (process.env.GRAPH_INDEXER_SEMANTIC !== '1') return false;
|
|
45
|
+
return fs.existsSync(path.join(modelDir(), 'model.safetensors')) && fs.existsSync(path.join(modelDir(), 'tokenizer.json'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── tokenizer: BertNormalizer(lowercase, strip accents) + BertPreTokenizer + WordPiece ──────────
|
|
49
|
+
|
|
50
|
+
function isControl(c) {
|
|
51
|
+
if (c === 9 || c === 10 || c === 13) return false;
|
|
52
|
+
return (c < 32) || (c >= 127 && c < 160);
|
|
53
|
+
}
|
|
54
|
+
function isWhitespace(c) { return c === 32 || c === 9 || c === 10 || c === 13 || c === 0xa0 || c === 0x3000 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x1680; }
|
|
55
|
+
function isCJK(c) {
|
|
56
|
+
return (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x20000 && c <= 0x2a6df) || (c >= 0x2a700 && c <= 0x2b73f) ||
|
|
57
|
+
(c >= 0x2b740 && c <= 0x2b81f) || (c >= 0x2b820 && c <= 0x2ceaf) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0x2f800 && c <= 0x2fa1f);
|
|
58
|
+
}
|
|
59
|
+
const PUNCT_RE = /\p{P}/u;
|
|
60
|
+
function isPunct(ch, c) {
|
|
61
|
+
return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) || (c > 127 && PUNCT_RE.test(ch));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Text → normalised pre-tokens (words and single punctuation characters). */
|
|
65
|
+
export function preTokenize(text) {
|
|
66
|
+
let s = '';
|
|
67
|
+
for (const ch of text) {
|
|
68
|
+
const c = ch.codePointAt(0);
|
|
69
|
+
if (c === 0 || c === 0xfffd || isControl(c)) continue;
|
|
70
|
+
if (isWhitespace(c)) { s += ' '; continue; }
|
|
71
|
+
if (isCJK(c)) { s += ' ' + ch + ' '; continue; }
|
|
72
|
+
s += ch;
|
|
73
|
+
}
|
|
74
|
+
s = s.toLowerCase().normalize('NFD').replace(/\p{Mn}/gu, '');
|
|
75
|
+
const out = [];
|
|
76
|
+
let cur = '';
|
|
77
|
+
for (const ch of s) {
|
|
78
|
+
const c = ch.codePointAt(0);
|
|
79
|
+
if (c === 32) { if (cur) { out.push(cur); cur = ''; } continue; }
|
|
80
|
+
if (isPunct(ch, c)) { if (cur) { out.push(cur); cur = ''; } out.push(ch); continue; }
|
|
81
|
+
cur += ch;
|
|
82
|
+
}
|
|
83
|
+
if (cur) out.push(cur);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class StaticModel {
|
|
88
|
+
constructor(dir = modelDir()) {
|
|
89
|
+
const tok = JSON.parse(fs.readFileSync(path.join(dir, 'tokenizer.json'), 'utf8'));
|
|
90
|
+
const m = tok.model;
|
|
91
|
+
if (m?.type !== 'WordPiece') throw new Error(`unsupported tokenizer model ${m?.type}`);
|
|
92
|
+
this.vocab = new Map(Object.entries(m.vocab));
|
|
93
|
+
this.prefix = m.continuing_subword_prefix ?? '##';
|
|
94
|
+
this.unk = this.vocab.get(m.unk_token ?? '[UNK]') ?? -1;
|
|
95
|
+
const buf = fs.readFileSync(path.join(dir, 'model.safetensors'));
|
|
96
|
+
const hlen = Number(buf.readBigUInt64LE(0));
|
|
97
|
+
const header = JSON.parse(buf.subarray(8, 8 + hlen).toString('utf8'));
|
|
98
|
+
const t = header.embeddings ?? Object.values(header).find(v => v && v.shape);
|
|
99
|
+
const [rows, dim] = t.shape;
|
|
100
|
+
const [a, b] = t.data_offsets;
|
|
101
|
+
const raw = buf.subarray(8 + hlen + a, 8 + hlen + b);
|
|
102
|
+
this.dim = dim;
|
|
103
|
+
this.rows = rows;
|
|
104
|
+
this.table = new Float32Array(rows * dim);
|
|
105
|
+
if (t.dtype === 'F32') for (let i = 0; i < rows * dim; i++) this.table[i] = raw.readFloatLE(i * 4);
|
|
106
|
+
else if (t.dtype === 'F16') for (let i = 0; i < rows * dim; i++) this.table[i] = f16(raw.readUInt16LE(i * 2));
|
|
107
|
+
else throw new Error(`unsupported embedding dtype ${t.dtype}`);
|
|
108
|
+
this.cache = new Map(); // word -> token ids
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** WordPiece ids of one pre-token (greedy longest match), [] when unknown. */
|
|
112
|
+
#word(w) {
|
|
113
|
+
let ids = this.cache.get(w);
|
|
114
|
+
if (ids) return ids;
|
|
115
|
+
ids = [];
|
|
116
|
+
if ([...w].length > MAX_WORD_CHARS) ids = [this.unk];
|
|
117
|
+
else {
|
|
118
|
+
let start = 0;
|
|
119
|
+
while (start < w.length) {
|
|
120
|
+
let end = w.length, found = -1;
|
|
121
|
+
while (start < end) {
|
|
122
|
+
const sub = (start > 0 ? this.prefix : '') + w.slice(start, end);
|
|
123
|
+
const id = this.vocab.get(sub);
|
|
124
|
+
if (id !== undefined) { found = id; break; }
|
|
125
|
+
end--;
|
|
126
|
+
}
|
|
127
|
+
if (found < 0) { ids = [this.unk]; break; }
|
|
128
|
+
ids.push(found);
|
|
129
|
+
start = end;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (this.cache.size > 200000) this.cache.clear();
|
|
133
|
+
this.cache.set(w, ids);
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
tokenize(text) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const w of preTokenize(text)) {
|
|
140
|
+
for (const id of this.#word(w)) { if (id !== this.unk) out.push(id); }
|
|
141
|
+
if (out.length >= MAX_TOKENS) break;
|
|
142
|
+
}
|
|
143
|
+
return out.length > MAX_TOKENS ? out.slice(0, MAX_TOKENS) : out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Mean of token vectors, L2-normalised; null for text without known tokens. */
|
|
147
|
+
embed(text) {
|
|
148
|
+
const ids = this.tokenize(text);
|
|
149
|
+
if (!ids.length) return null;
|
|
150
|
+
const d = this.dim, v = new Float32Array(d), T = this.table;
|
|
151
|
+
for (const id of ids) { const o = id * d; for (let j = 0; j < d; j++) v[j] += T[o + j]; }
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (let j = 0; j < d; j++) n += v[j] * v[j];
|
|
154
|
+
n = Math.sqrt(n);
|
|
155
|
+
if (!n) return null;
|
|
156
|
+
for (let j = 0; j < d; j++) v[j] /= n;
|
|
157
|
+
return v;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function f16(h) {
|
|
162
|
+
const s = (h & 0x8000) ? -1 : 1, e = (h >> 10) & 0x1f, f = h & 0x3ff;
|
|
163
|
+
if (e === 0) return s * Math.pow(2, -14) * (f / 1024);
|
|
164
|
+
if (e === 31) return f ? NaN : s * Infinity;
|
|
165
|
+
return s * Math.pow(2, e - 15) * (1 + f / 1024);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** int8 with one scale per vector: v ≈ q * scale. */
|
|
169
|
+
export function quantize(v) {
|
|
170
|
+
let m = 0;
|
|
171
|
+
for (let j = 0; j < v.length; j++) { const a = Math.abs(v[j]); if (a > m) m = a; }
|
|
172
|
+
const q = new Int8Array(v.length);
|
|
173
|
+
const scale = m / 127 || 1;
|
|
174
|
+
for (let j = 0; j < v.length; j++) q[j] = Math.round(v[j] / scale);
|
|
175
|
+
return { q, scale };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── model download (explicit, user-initiated) ──────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export async function installModel({ log = console.error } = {}) {
|
|
181
|
+
const dir = modelDir();
|
|
182
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
183
|
+
for (const [name, digest] of Object.entries(MODEL.files)) {
|
|
184
|
+
const dest = path.join(dir, name);
|
|
185
|
+
if (fs.existsSync(dest) && sha256File(dest) === digest) { log(`${name}: present`); continue; }
|
|
186
|
+
const url = `https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${name}`;
|
|
187
|
+
log(`downloading ${url}`);
|
|
188
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
189
|
+
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
|
190
|
+
const data = Buffer.from(await res.arrayBuffer());
|
|
191
|
+
const got = crypto.createHash('sha256').update(data).digest('hex');
|
|
192
|
+
if (got !== digest) throw new Error(`${name}: SHA-256 mismatch (expected ${digest}, got ${got}); nothing was installed`);
|
|
193
|
+
const tmp = dest + '.part';
|
|
194
|
+
fs.writeFileSync(tmp, data);
|
|
195
|
+
fs.renameSync(tmp, dest);
|
|
196
|
+
log(`${name}: ${(data.length / 1048576).toFixed(1)} MB, verified`);
|
|
197
|
+
}
|
|
198
|
+
return dir;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sha256File(p) { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); }
|