graph-indexer 2.1.1 → 3.0.1
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 +312 -1
- package/README.md +236 -312
- package/SECURITY.md +53 -58
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +129 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +366 -0
- package/src/cli/init.mjs +272 -0
- package/src/cli/resident.mjs +185 -0
- package/src/index/discover.mjs +128 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +332 -0
- package/src/index/resolver.mjs +961 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +974 -0
- package/src/parse/arity.mjs +153 -0
- package/src/parse/extract.mjs +1038 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +345 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +683 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +130 -0
- package/src/query/textsearch.mjs +178 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +63 -0
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a piece of code depends on, for reading it without searching: the definitions its lines use
|
|
3
|
+
* that live outside those lines, with where each one is and its signature.
|
|
4
|
+
*
|
|
5
|
+
* An agent exploring code reads a function, meets a name, searches for its definition, reads that,
|
|
6
|
+
* meets another name… one hop per call. The index already resolved those names (scope, imports,
|
|
7
|
+
* receiver types), so a read can carry the answers: this module ranks them and the tools render them
|
|
8
|
+
* next to the code (a "defined elsewhere" card) and in hooks after the agent's own reads.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// how much a use says about what the reader needs to know
|
|
12
|
+
const KIND_WEIGHT = { call: 1, new: 1, inherit: 1, type: 0.8, decorator: 0.6, value: 0.6, read: 0.5 };
|
|
13
|
+
const NAMED_KINDS = ['call', 'new', 'inherit', 'type', 'decorator'];
|
|
14
|
+
// what the reader most often needs next: code it calls and types it uses; data (fields, constants,
|
|
15
|
+
// enum members) comes after, its one-line definition is usually all there is to it
|
|
16
|
+
const TARGET_WEIGHT = (kind) => (['field', 'property', 'variable', 'constant'].includes(kind) ? 0.35 : 1);
|
|
17
|
+
const DEFINITION_KINDS = ['function', 'method', 'constructor', 'class', 'interface', 'struct', 'trait', 'enum', 'type', 'constant', 'macro', 'object', 'module'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Definitions used by lines [from, to] of `file` that are defined outside that range.
|
|
21
|
+
*
|
|
22
|
+
* Bound references come from the resolver; a name that stayed unbound is included when exactly one
|
|
23
|
+
* definition in the repository's non-test code carries it (marked `byName`). Ranked by how the code
|
|
24
|
+
* uses them (calls, instantiations and base types first; more uses first), then by first use.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} opts
|
|
27
|
+
* @param {number} [opts.max] rows returned in `rows`; the rest go to `more`
|
|
28
|
+
* @param {Set<number>} [opts.skipIds] definitions not to list (already shown to the reader)
|
|
29
|
+
* @param {Set<number>} [opts.skipAncestorsOf] symbol id whose enclosing definitions are not listed
|
|
30
|
+
* @returns {{ rows: object[], more: object[] }}
|
|
31
|
+
*/
|
|
32
|
+
export function definitionsUsed(intel, file, from, to, { max = 10, skipIds = new Set(), skipAncestorsOf = null } = {}) {
|
|
33
|
+
const f = intel.store.get('SELECT id FROM files WHERE path = ?', file);
|
|
34
|
+
if (!f) return { rows: [], more: [] };
|
|
35
|
+
const skip = new Set(skipIds);
|
|
36
|
+
for (let p = skipAncestorsOf != null ? intel.store.get('SELECT parent_id FROM symbols WHERE id = ?', skipAncestorsOf)?.parent_id : null; p != null;
|
|
37
|
+
p = intel.store.get('SELECT parent_id FROM symbols WHERE id = ?', p)?.parent_id) skip.add(p);
|
|
38
|
+
const refs = intel.store.all('SELECT name, kind, line, dst_id, conf FROM refs WHERE file_id = ? AND line BETWEEN ? AND ? ORDER BY line', f.id, from, to);
|
|
39
|
+
const bound = new Map(), unbound = new Map();
|
|
40
|
+
for (const r of refs) {
|
|
41
|
+
const w = KIND_WEIGHT[r.kind] ?? 0.5;
|
|
42
|
+
if (r.dst_id != null) {
|
|
43
|
+
// weak name-only bindings of plain values are mostly locals that share a global's name
|
|
44
|
+
if ((r.kind === 'value' || r.kind === 'read') && (r.conf ?? 1) < 0.5) continue;
|
|
45
|
+
const e = bound.get(r.dst_id) ?? bound.set(r.dst_id, { n: 0, score: 0, first: r.line }).get(r.dst_id);
|
|
46
|
+
e.n++; e.score += w * Math.max(0.3, r.conf ?? 1);
|
|
47
|
+
} else if (NAMED_KINDS.includes(r.kind)) {
|
|
48
|
+
const e = unbound.get(r.name) ?? unbound.set(r.name, { n: 0, score: 0, first: r.line }).get(r.name);
|
|
49
|
+
e.n++; e.score += w * 0.5;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const inView = (s) => s.path === file && s.start_line >= from && s.end_line <= to;
|
|
53
|
+
const rows = [];
|
|
54
|
+
const get = intel.store.q(`SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.sig, s.doc, s.type, f.path, f.is_test
|
|
55
|
+
FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?`);
|
|
56
|
+
for (const [id, e] of bound) {
|
|
57
|
+
if (skip.has(id)) continue;
|
|
58
|
+
const s = get.get(id);
|
|
59
|
+
if (!s || inView(s)) continue;
|
|
60
|
+
rows.push({ ...s, ...e, score: e.score * TARGET_WEIGHT(s.kind), byName: false });
|
|
61
|
+
}
|
|
62
|
+
const seen = new Set(rows.map(r => r.id));
|
|
63
|
+
for (const [name, e] of unbound) {
|
|
64
|
+
const cands = intel.store.all(`SELECT s.id FROM symbols s JOIN files f ON f.id = s.file_id
|
|
65
|
+
WHERE s.name = ? AND f.is_test = 0 AND s.kind IN (${DEFINITION_KINDS.map(() => '?').join(',')}) LIMIT 2`, name, ...DEFINITION_KINDS);
|
|
66
|
+
if (cands.length !== 1 || seen.has(cands[0].id) || skip.has(cands[0].id)) continue;
|
|
67
|
+
const s = get.get(cands[0].id);
|
|
68
|
+
if (!s || inView(s)) continue;
|
|
69
|
+
seen.add(s.id);
|
|
70
|
+
rows.push({ ...s, ...e, score: e.score * TARGET_WEIGHT(s.kind), byName: true });
|
|
71
|
+
}
|
|
72
|
+
rows.sort((a, b) => b.score - a.score || a.first - b.first);
|
|
73
|
+
return { rows: rows.slice(0, max), more: rows.slice(max) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Test functions that reference a definition (directly), for "which tests exercise this". */
|
|
77
|
+
export function testsUsing(intel, id, { max = 3 } = {}) {
|
|
78
|
+
return intel.store.all(`SELECT f.path, COALESCE(s.qname, '(module)') AS qname, MIN(r.line) AS line FROM refs r
|
|
79
|
+
JOIN files f ON f.id = r.file_id LEFT JOIN symbols s ON s.id = r.src_id
|
|
80
|
+
WHERE r.dst_id = ? AND f.is_test = 1 GROUP BY f.path, s.qname ORDER BY f.path, line LIMIT ?`, id, max + 1);
|
|
81
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to run a set of test files in this repository: one command per test ecosystem, detected from
|
|
3
|
+
* the project's own configuration (package.json scripts and dependencies, pytest/unittest layout,
|
|
4
|
+
* go.mod, Cargo.toml, Maven/Gradle builds). Commands target only the given files, so an agent can
|
|
5
|
+
* verify a change in seconds instead of running the whole suite.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readRepoFile } from '../util/paths.mjs';
|
|
10
|
+
|
|
11
|
+
function readJson(root, rel) { try { return JSON.parse(readRepoFile(root, rel)); } catch { return null; } }
|
|
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);
|
|
20
|
+
|
|
21
|
+
/** Nearest package.json above a file (monorepos have one per package). */
|
|
22
|
+
function nearestPackage(root, rel) {
|
|
23
|
+
let dir = path.posix.dirname(rel);
|
|
24
|
+
for (;;) {
|
|
25
|
+
const p = path.posix.join(dir === '.' ? '' : dir, 'package.json');
|
|
26
|
+
if (exists(root, p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(root, p) ?? {} };
|
|
27
|
+
if (dir === '.' || dir === '' || dir === '/') return null;
|
|
28
|
+
dir = path.posix.dirname(dir);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function jsRunner(root, rel) {
|
|
33
|
+
const near = nearestPackage(root, rel);
|
|
34
|
+
const top = readJson(root, 'package.json') ?? {};
|
|
35
|
+
const pkgs = [near?.pkg, top].filter(Boolean);
|
|
36
|
+
const deps = Object.assign({}, ...pkgs.map(p => ({ ...p.dependencies, ...p.devDependencies })));
|
|
37
|
+
const scripts = pkgs.map(p => p.scripts?.test ?? '').join(' ');
|
|
38
|
+
if (deps.vitest || /\bvitest\b/.test(scripts)) return 'npx vitest run';
|
|
39
|
+
if (deps.jest || /\bjest\b/.test(scripts)) return 'npx jest';
|
|
40
|
+
if (deps.mocha || /\bmocha\b/.test(scripts) || exists(root, '.mocharc.js') || exists(root, '.mocharc.json') || exists(root, '.mocharc.yml')) return 'npx mocha';
|
|
41
|
+
if (deps.ava || /\bava\b/.test(scripts)) return 'npx ava';
|
|
42
|
+
if (deps.tap || /\btap\b/.test(scripts)) return 'npx tap';
|
|
43
|
+
if (/node --test/.test(scripts)) return 'node --test';
|
|
44
|
+
return 'npm test --';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pyRunner(root) {
|
|
48
|
+
if (exists(root, 'tests/runtests.py')) return { kind: 'django' };
|
|
49
|
+
const pyproject = readRepoFile(root, 'pyproject.toml') ?? '';
|
|
50
|
+
if (exists(root, 'pytest.ini') || exists(root, 'conftest.py') || /\[tool\.pytest/.test(pyproject) || exists(root, 'tox.ini')) return { kind: 'pytest' };
|
|
51
|
+
return { kind: 'unittest' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} root repository root
|
|
56
|
+
* @param {string[]} files test files (repo-relative)
|
|
57
|
+
* @returns {string[]} shell commands
|
|
58
|
+
*/
|
|
59
|
+
export function testCommands(root, files) {
|
|
60
|
+
const groups = new Map();
|
|
61
|
+
const add = (key, f) => (groups.get(key) ?? groups.set(key, []).get(key)).push(f);
|
|
62
|
+
for (const f of files.filter(shellSafe)) {
|
|
63
|
+
const ext = path.posix.extname(f);
|
|
64
|
+
if (/\.[cm]?[jt]sx?$/.test(ext)) add(`js:${jsRunner(root, f)}`, f);
|
|
65
|
+
else if (ext === '.py') add('py', f);
|
|
66
|
+
else if (ext === '.go') add('go', f);
|
|
67
|
+
else if (ext === '.rs') add('rs', f);
|
|
68
|
+
else if (ext === '.java' || ext === '.kt' || ext === '.scala') add('jvm', f);
|
|
69
|
+
else if (ext === '.rb') add('rb', f);
|
|
70
|
+
else if (ext === '.php') add('php', f);
|
|
71
|
+
else if (ext === '.cs') add('cs', f);
|
|
72
|
+
}
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [key, fs_] of groups) {
|
|
75
|
+
if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.map(q).join(' ')}`);
|
|
76
|
+
else if (key === 'py') {
|
|
77
|
+
const r = pyRunner(root);
|
|
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(' ')}`);
|
|
82
|
+
else if (key === 'rs') out.push('cargo test');
|
|
83
|
+
else if (key === 'jvm') {
|
|
84
|
+
const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')).filter(c => /^\w+$/.test(c)))].join(',');
|
|
85
|
+
if (!classes) continue;
|
|
86
|
+
out.push(exists(root, 'pom.xml') ? `mvn -q test -Dtest=${classes}` : `./gradlew test ${classes.split(',').map(c => `--tests '*${c}'`).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(' ')}`);
|
|
89
|
+
else if (key === 'cs') out.push('dotnet test');
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Commands that run only the given test functions: [{ path, name, cls }] (cls: the enclosing test
|
|
96
|
+
* class, if any). pytest, unittest and Django (Python), `go test -run`, Maven and Gradle; other
|
|
97
|
+
* ecosystems name tests by strings the index does not see, so they get no command here.
|
|
98
|
+
* @returns {string[]} shell commands
|
|
99
|
+
*/
|
|
100
|
+
export function testCaseCommands(root, cases) {
|
|
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)));
|
|
104
|
+
const py = cases.filter(c => c.path.endsWith('.py'));
|
|
105
|
+
if (py.length) {
|
|
106
|
+
const r = pyRunner(root);
|
|
107
|
+
const mod = (p) => p.replace(/\.py$/, '').replace(/\//g, '.');
|
|
108
|
+
if (r.kind === 'pytest') out.push(`python -m pytest -q ${py.map(c => `${c.path}::${c.cls ? `${c.cls}::` : ''}${c.name}`).join(' ')}`);
|
|
109
|
+
else if (r.kind === 'django') {
|
|
110
|
+
const withCls = py.filter(c => c.cls);
|
|
111
|
+
if (withCls.length) out.push(`python tests/runtests.py ${withCls.map(c => `${mod(c.path.replace(/^tests\//, ''))}.${c.cls}.${c.name}`).join(' ')}`);
|
|
112
|
+
} else {
|
|
113
|
+
const withCls = py.filter(c => c.cls);
|
|
114
|
+
if (withCls.length) out.push(`python -m unittest ${withCls.map(c => `${mod(c.path)}.${c.cls}.${c.name}`).join(' ')}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const byDir = new Map();
|
|
118
|
+
for (const c of cases.filter(c => c.path.endsWith('.go'))) (byDir.get(path.posix.dirname(c.path)) ?? byDir.set(path.posix.dirname(c.path), []).get(path.posix.dirname(c.path))).push(c.name);
|
|
119
|
+
for (const [dir, names] of byDir) out.push(`go test ./${dir} -run '^(${[...new Set(names)].join('|')})$'`);
|
|
120
|
+
const jvm = cases.filter(c => /\.(java|kt|scala)$/.test(c.path));
|
|
121
|
+
if (jvm.length) {
|
|
122
|
+
const cls = (c) => c.cls ?? path.posix.basename(c.path).replace(/\.\w+$/, '');
|
|
123
|
+
if (exists(root, 'pom.xml')) {
|
|
124
|
+
const by = new Map();
|
|
125
|
+
for (const c of jvm) (by.get(cls(c)) ?? by.set(cls(c), []).get(cls(c))).push(c.name);
|
|
126
|
+
out.push(`mvn -q test -Dtest=${[...by].map(([k, v]) => `${k}#${[...new Set(v)].join('+')}`).join(',')}`);
|
|
127
|
+
} else out.push(`./gradlew test ${jvm.map(c => `--tests '*${cls(c)}.${c.name}'`).join(' ')}`);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text search over every file in the repository (code, config, docs), with the structure grep
|
|
3
|
+
* cannot give: each code match is attributed to its enclosing definition, and when the pattern is
|
|
4
|
+
* an identifier each match says what it is — the definition itself, a reference bound to a
|
|
5
|
+
* specific symbol (so same-named methods of different classes are told apart), a reference the
|
|
6
|
+
* index could not bind, or text in a comment or string.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import vm from 'node:vm';
|
|
11
|
+
import { withinRoot } from '../util/paths.mjs';
|
|
12
|
+
import { gitSync } from '../util/git.mjs';
|
|
13
|
+
|
|
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
|
+
})()`);
|
|
24
|
+
const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
|
|
25
|
+
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
|
|
26
|
+
const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
|
|
27
|
+
|
|
28
|
+
function listFiles(root) {
|
|
29
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
|
|
30
|
+
if (r.status === 0) return r.stdout.split('\0').filter(Boolean).filter(p => !SKIP_DIRS.test(p));
|
|
31
|
+
const out = [];
|
|
32
|
+
const stack = [''];
|
|
33
|
+
while (stack.length) {
|
|
34
|
+
const rel = stack.pop();
|
|
35
|
+
let entries;
|
|
36
|
+
try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
39
|
+
if (e.isDirectory()) { if (!e.name.startsWith('.') && !SKIP_DIRS.test(p + '/')) stack.push(p); } else if (e.isFile()) out.push(p);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
46
|
+
|
|
47
|
+
/** Regular expression source for a glob: `*` within a directory, `**` across directories, `?`, `[…]`, `{a,b}`. */
|
|
48
|
+
function globSource(glob) {
|
|
49
|
+
let re = '';
|
|
50
|
+
for (let i = 0; i < glob.length; i++) {
|
|
51
|
+
const c = glob[i];
|
|
52
|
+
if (c === '*') {
|
|
53
|
+
if (glob[i + 1] !== '*') { re += '[^/]*'; continue; }
|
|
54
|
+
i++;
|
|
55
|
+
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
|
|
56
|
+
} else if (c === '?') re += '[^/]';
|
|
57
|
+
else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += glob.slice(i, j + 1).replace(/^\[!/, '[^'); i = j; }
|
|
58
|
+
else if (c === '{' && glob.indexOf('}', i) > i) { const j = glob.indexOf('}', i); re += `(?:${glob.slice(i + 1, j).split(',').map(escapeRe).join('|')})`; i = j; }
|
|
59
|
+
else re += escapeRe(c);
|
|
60
|
+
}
|
|
61
|
+
return re;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Files whose path matches a glob (`*.toml`, `tests/test_*.py`, `**` for any depth; without a slash
|
|
66
|
+
* it is matched against file names) or, without wildcards, contains the text (case-insensitive).
|
|
67
|
+
*/
|
|
68
|
+
export function findFiles(root, pattern, { path: prefix = null } = {}) {
|
|
69
|
+
const pre = prefix ? String(prefix).replace(/^\.\//, '').replace(/\/+$/, '') : null;
|
|
70
|
+
let test;
|
|
71
|
+
if (/[*?[{]/.test(pattern)) {
|
|
72
|
+
const onName = !pattern.includes('/');
|
|
73
|
+
const re = new RegExp(onName ? `^${globSource(pattern)}$` : `(^|/)${globSource(pattern.replace(/^\.?\//, ''))}$`);
|
|
74
|
+
test = (p) => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p);
|
|
75
|
+
} else {
|
|
76
|
+
const t = pattern.toLowerCase();
|
|
77
|
+
test = (p) => p.toLowerCase().includes(t);
|
|
78
|
+
}
|
|
79
|
+
return listFiles(root).filter(p => (!pre || p === pre || p.startsWith(pre + '/')) && test(p)).sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The identifier a pattern is about (`\bget\(` → get, `Reflector.get` → get), if any. */
|
|
83
|
+
export function identifierOf(pattern, literal) {
|
|
84
|
+
const src = literal ? pattern : pattern.replace(/\\[bBsSdDwW]/g, ' ').replace(/\\(.)/g, '$1').replace(/[()[\]{}^$*+?|]/g, ' ');
|
|
85
|
+
const words = src.split(/[^A-Za-z0-9_$]+/).filter(w => /^[A-Za-z_$][\w$]*$/.test(w));
|
|
86
|
+
return words.length ? words[words.length - 1] : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rank(p, isTest) {
|
|
90
|
+
if (isTest) return 2;
|
|
91
|
+
if (EXAMPLE_PATH.test(p)) return 3;
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @returns {{ error?: string, total: number, files: number, shown: object[], summary: object|null, truncated: boolean }}
|
|
97
|
+
*/
|
|
98
|
+
export async function textSearch(intel, { pattern, path: prefix = null, literal = false, ignoreCase = false, limit = 60 }) {
|
|
99
|
+
let re;
|
|
100
|
+
try {
|
|
101
|
+
const src = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : pattern;
|
|
102
|
+
re = new RegExp(src, ignoreCase ? 'i' : '');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { error: `Invalid regular expression: ${e.message}. Pass literal: true to search for the text as is.` };
|
|
105
|
+
}
|
|
106
|
+
const root = intel.root;
|
|
107
|
+
const pre = prefix ? String(prefix).replace(/^\.\//, '').replace(/^\/+/, '') : null;
|
|
108
|
+
const files = listFiles(root).filter(p => !pre || p === pre || p.startsWith(pre.endsWith('/') ? pre : pre + '/') || p.startsWith(pre));
|
|
109
|
+
const indexed = new Map(intel.store.all('SELECT id, path, is_test FROM files').map(f => [f.path, f]));
|
|
110
|
+
const matches = [];
|
|
111
|
+
let total = 0, nfiles = 0;
|
|
112
|
+
const ctx = vm.createContext({ re, text: '' });
|
|
113
|
+
let budget = REGEX_BUDGET_MS;
|
|
114
|
+
for (const rel of files) {
|
|
115
|
+
let text;
|
|
116
|
+
try {
|
|
117
|
+
if (!withinRoot(root, rel)) continue;
|
|
118
|
+
const st = fs.statSync(path.join(root, rel));
|
|
119
|
+
if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
|
|
120
|
+
text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
121
|
+
} catch { continue; }
|
|
122
|
+
if (text.indexOf('\0') >= 0 && text.indexOf('\0') < 8000) continue; // binary
|
|
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) {
|
|
135
|
+
total++;
|
|
136
|
+
matches.push({ path: rel, line: i + 1, text: scan.lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const ident = identifierOf(pattern, literal);
|
|
140
|
+
// annotate: enclosing definition, and what an identifier match refers to
|
|
141
|
+
const byFile = new Map();
|
|
142
|
+
for (const m of matches) (byFile.get(m.path) ?? byFile.set(m.path, []).get(m.path)).push(m);
|
|
143
|
+
const summary = ident ? { ident, targets: new Map(), defs: 0, unbound: 0, text: 0, nonCode: 0 } : null;
|
|
144
|
+
for (const [rel, ms] of byFile) {
|
|
145
|
+
const f = indexed.get(rel);
|
|
146
|
+
if (!f) { for (const m of ms) { m.what = 'file'; if (summary) summary.nonCode++; } continue; }
|
|
147
|
+
const syms = intel.store.all('SELECT id, qname, kind, name, start_line, end_line, name_line FROM symbols WHERE file_id = ? ORDER BY start_line', f.id);
|
|
148
|
+
const refs = ident ? intel.store.all(`SELECT r.line, r.kind, r.dst_id, d.qname AS dst_qname, df.path AS dst_path FROM refs r LEFT JOIN symbols d ON d.id = r.dst_id LEFT JOIN files df ON df.id = d.file_id
|
|
149
|
+
WHERE r.file_id = ? AND r.name = ?`, f.id, ident) : [];
|
|
150
|
+
const refsAt = new Map();
|
|
151
|
+
for (const r of refs) (refsAt.get(r.line) ?? refsAt.set(r.line, []).get(r.line)).push(r);
|
|
152
|
+
for (const m of ms) {
|
|
153
|
+
// innermost definition around the line, ignoring ones that start on it (inline classes, the def itself)
|
|
154
|
+
let encl = null;
|
|
155
|
+
for (const s of syms) if (s.start_line < m.line && s.end_line >= m.line && (!encl || s.start_line >= encl.start_line)) encl = s;
|
|
156
|
+
m.encl = encl?.qname ?? null;
|
|
157
|
+
if (!summary) continue;
|
|
158
|
+
const def = syms.find(s => s.name === ident && s.name_line === m.line);
|
|
159
|
+
const rs = refsAt.get(m.line) ?? [];
|
|
160
|
+
if (def) { m.what = 'def'; m.target = def.qname; summary.defs++; continue; }
|
|
161
|
+
const bound = rs.filter(r => r.dst_id != null);
|
|
162
|
+
if (bound.length) {
|
|
163
|
+
m.what = 'ref';
|
|
164
|
+
m.target = [...new Set(bound.map(r => r.dst_qname))].join(', ');
|
|
165
|
+
for (const r of bound) {
|
|
166
|
+
const k = `${r.dst_qname}\u0000${r.dst_path}`;
|
|
167
|
+
summary.targets.set(k, (summary.targets.get(k) ?? 0) + 1);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (rs.length) { m.what = 'unbound'; summary.unbound++; continue; }
|
|
172
|
+
m.what = COMMENT_LINE.test(m.text) ? 'comment' : 'text';
|
|
173
|
+
summary.text++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
matches.sort((a, b) => rank(a.path, a.isTest) + (a.code ? 0 : 1) - (rank(b.path, b.isTest) + (b.code ? 0 : 1)) || a.path.localeCompare(b.path) || a.line - b.line);
|
|
177
|
+
return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: matches.length > limit };
|
|
178
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid symbol search.
|
|
3
|
+
*
|
|
4
|
+
* 1. name channel exact / case-insensitive / qualified / prefix matches on symbol names
|
|
5
|
+
* 2. lexical channel FTS5 BM25 over fields (name, qualified name, signature, doc, path, body)
|
|
6
|
+
* 3. concept channel the same index queried with thesaurus expansions (natural-language queries)
|
|
7
|
+
* 4. rerank a small linear model over interpretable features (name evidence, field
|
|
8
|
+
* coverage, kind/test/export priors, graph centrality), then per-file
|
|
9
|
+
* diversification so one file cannot flood the top results.
|
|
10
|
+
*/
|
|
11
|
+
import { analyzeQuery, codeTokens, splitIdentifier } from './tokenize.mjs';
|
|
12
|
+
|
|
13
|
+
const FTS_WEIGHTS = [10.0, 6.0, 3.0, 2.0, 2.5, 1.0]; // name, qname, sig, doc, path, body
|
|
14
|
+
const KIND_PRIOR = {
|
|
15
|
+
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
|
+
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,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_WEIGHTS = {
|
|
21
|
+
// Tuned by coordinate ascent on the TUNING split of bench/suites (bench/tune-weights.mjs);
|
|
22
|
+
// the held-out split is reported, never used for selection.
|
|
23
|
+
bm25: 2.66, // normalized lexical score
|
|
24
|
+
concept: 0.056, // normalized concept-expansion score
|
|
25
|
+
exact: 1.6, // query identifier == symbol name (case-sensitive)
|
|
26
|
+
exactCI: 0.8, // case-insensitive (×0.75 for singular/plural)
|
|
27
|
+
qualified: 1.8, // query `A.b` matches qualified name suffix
|
|
28
|
+
prefix: 0.27, // symbol name starts with the query identifier
|
|
29
|
+
nameCover: 0.45, // fraction of symbol-name words present in the query
|
|
30
|
+
queryCover: 0.8, // fraction of query terms present in name/qname
|
|
31
|
+
kind: 0.35, // kind prior
|
|
32
|
+
test: -1.6, // test file (unless the query is about tests)
|
|
33
|
+
exported: 0.06,
|
|
34
|
+
doc: 0.033,
|
|
35
|
+
central: 0.25, // graph centrality (PageRank percentile)
|
|
36
|
+
nested: -0.3, // local/nested function inside another function
|
|
37
|
+
declaration: -1.4, // .d.ts declarations (implementations are what agents want)
|
|
38
|
+
example: -1.2, // examples/, samples/, sandbox/, docs/, benchmarks/ trees
|
|
39
|
+
nlField: -0.93, // fields/properties/variables for natural-language (behavioural) queries
|
|
40
|
+
file: 1.0, // file-level BM25 of the symbol's file (hierarchical: file relevance first)
|
|
41
|
+
fileNL: 0.5, // extra file-level weight for natural-language (behavioural) queries
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const EXAMPLE_PATH = /(^|\/)(examples?|samples?|demos?|sandbox|playground|docs?(_src|_source|_examples?)?|documentation|benchmarks?|bench|scripts?|fixtures?|testdata|tutorials?|website|\.github)\//i;
|
|
45
|
+
const DECLARATION_PATH = /\.d\.[cm]?ts$/;
|
|
46
|
+
const BM25_FLOOR = 1.0;
|
|
47
|
+
const FILE_GATE = 0.25; // own normalized BM25 at which the file boost applies in full (tuning split)
|
|
48
|
+
|
|
49
|
+
function stem(w) {
|
|
50
|
+
// light stemmer for coverage features (FTS5 does the real stemming for retrieval)
|
|
51
|
+
return w.replace(/(ies)$/, 'y').replace(/(sses|ches|shes|xes)$/, (m) => m.slice(0, -2)).replace(/([^s])s$/, '$1')
|
|
52
|
+
.replace(/(ing|ed|er|or|ion|ions|ation|ations)$/, '').replace(/e$/, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ftsQuery(terms) {
|
|
56
|
+
const uniq = [...new Set(terms.filter(t => /^[a-z0-9]+$/i.test(t)))].slice(0, 32);
|
|
57
|
+
return uniq.length ? uniq.map(t => `"${t}"`).join(' OR ') : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class SearchEngine {
|
|
61
|
+
constructor(indexer, { weights = DEFAULT_WEIGHTS, centrality = null, perFileCap = Number(process.env.GI_PER_FILE_CAP) || 3 } = {}) {
|
|
62
|
+
this.perFileCap = perFileCap;
|
|
63
|
+
this.ix = indexer;
|
|
64
|
+
this.store = indexer.store;
|
|
65
|
+
this.w = weights;
|
|
66
|
+
this.centrality = centrality; // () => Map(id -> percentile 0..1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} query
|
|
71
|
+
* @param {{ limit?: number, path?: string, kinds?: string[], includeTests?: boolean, lang?: string }} opts
|
|
72
|
+
* @returns {{ results: Array<{ id:number, score:number, reasons:string[] }>, analysis: object }}
|
|
73
|
+
*/
|
|
74
|
+
search(query, opts = {}) {
|
|
75
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 10, 100));
|
|
76
|
+
const a = analyzeQuery(query);
|
|
77
|
+
const pathFilter = opts.path ?? a.filters.path?.[0] ?? null;
|
|
78
|
+
const kindFilter = opts.kinds ?? a.filters.kind ?? null;
|
|
79
|
+
const langFilter = opts.lang ?? a.filters.lang?.[0] ?? null;
|
|
80
|
+
const wantsTests = opts.includeTests ?? /\b(test|tests|spec|specs|mock|fixture)\b/i.test(query);
|
|
81
|
+
const cands = new Map(); // id -> feature bag
|
|
82
|
+
const bag = (id) => { let f = cands.get(id); if (!f) cands.set(id, (f = { id })); return f; };
|
|
83
|
+
|
|
84
|
+
// 1. name channel
|
|
85
|
+
const idents = new Set(a.identifiers);
|
|
86
|
+
for (const id of idents) {
|
|
87
|
+
const qualified = /[.#:]|->/.test(id);
|
|
88
|
+
if (qualified) {
|
|
89
|
+
const parts = id.split(/::|\.|#|->/).filter(Boolean);
|
|
90
|
+
const name = parts[parts.length - 1];
|
|
91
|
+
const suffix = parts.join('.');
|
|
92
|
+
for (const r of this.store.all('SELECT id, qname FROM symbols WHERE name = ? LIMIT 200', name)) {
|
|
93
|
+
if (r.qname === suffix || r.qname.endsWith('.' + suffix)) bag(r.id).qualified = 1;
|
|
94
|
+
else bag(r.id).exact = Math.max(bag(r.id).exact ?? 0, 0.5);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const lc = id.toLowerCase();
|
|
99
|
+
const variants = [lc, lc.endsWith('s') ? lc.slice(0, -1) : lc + 's'];
|
|
100
|
+
for (const r of this.store.all('SELECT id, name, name_lc FROM symbols WHERE name_lc IN (?, ?) LIMIT 300', variants[0], variants[1])) {
|
|
101
|
+
const f = bag(r.id);
|
|
102
|
+
if (r.name === id) f.exact = 1;
|
|
103
|
+
else if (r.name_lc === lc) f.exactCI = 1;
|
|
104
|
+
else f.exactCI = Math.max(f.exactCI ?? 0, 0.75); // singular/plural (BackgroundTask ↔ BackgroundTasks)
|
|
105
|
+
}
|
|
106
|
+
if (id.length >= 4) {
|
|
107
|
+
for (const r of this.store.all("SELECT id FROM symbols WHERE name_lc >= ? AND name_lc < ? LIMIT 60", id.toLowerCase(), id.toLowerCase() + '')) {
|
|
108
|
+
const f = bag(r.id);
|
|
109
|
+
if (!f.exact && !f.exactCI) f.prefix = 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. lexical channel
|
|
115
|
+
const lexTerms = a.terms.length ? a.terms : codeTokens(query);
|
|
116
|
+
const q1 = ftsQuery(lexTerms);
|
|
117
|
+
let maxBm = 0;
|
|
118
|
+
if (q1) {
|
|
119
|
+
const rows = this.store.all(`SELECT rowid AS id, bm25(fts, ${FTS_WEIGHTS.join(', ')}) AS s FROM fts WHERE fts MATCH ? ORDER BY s LIMIT 400`, q1);
|
|
120
|
+
for (const r of rows) { const v = -r.s; bag(r.id).bm25 = v; if (v > maxBm) maxBm = v; }
|
|
121
|
+
}
|
|
122
|
+
// 3. concept channel
|
|
123
|
+
let maxCx = 0;
|
|
124
|
+
const q2 = a.expansions.length ? ftsQuery(a.expansions) : null;
|
|
125
|
+
if (q2) {
|
|
126
|
+
const rows = this.store.all(`SELECT rowid AS id, bm25(fts, ${FTS_WEIGHTS.join(', ')}) AS s FROM fts WHERE fts MATCH ? ORDER BY s LIMIT 150`, q2);
|
|
127
|
+
for (const r of rows) { const v = -r.s; bag(r.id).concept = v; if (v > maxCx) maxCx = v; }
|
|
128
|
+
}
|
|
129
|
+
// 4. file channel: files whose whole text matches the query best; their best-matching
|
|
130
|
+
// symbols become candidates even when long-body length normalisation buried them
|
|
131
|
+
const fileScore = new Map();
|
|
132
|
+
if (q1 && (this.w.file || this.w.fileNL)) {
|
|
133
|
+
const frows = this.store.all('SELECT rowid AS fid, bm25(file_fts, 2.0, 1.0) AS s FROM file_fts WHERE file_fts MATCH ? ORDER BY s LIMIT 40', q1);
|
|
134
|
+
const maxF = frows.length ? -frows[0].s : 0;
|
|
135
|
+
for (const r of frows) fileScore.set(r.fid, -r.s / Math.max(maxF, BM25_FLOOR));
|
|
136
|
+
const top = frows.slice(0, 8).map(r => r.fid);
|
|
137
|
+
if (top.length) {
|
|
138
|
+
const rows = this.store.all(`SELECT rowid AS id, bm25(fts, ${FTS_WEIGHTS.join(', ')}) AS s FROM fts WHERE fts MATCH ?
|
|
139
|
+
AND rowid IN (SELECT id FROM symbols WHERE file_id IN (${top.map(() => '?').join(',')})) ORDER BY s LIMIT 200`, q1, ...top);
|
|
140
|
+
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!cands.size) return { results: [], analysis: a };
|
|
144
|
+
|
|
145
|
+
// 4. features + rerank
|
|
146
|
+
const ids = [...cands.keys()];
|
|
147
|
+
const rows = new Map();
|
|
148
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
149
|
+
const chunk = ids.slice(i, i + 500);
|
|
150
|
+
for (const r of this.store.all(`SELECT s.id, s.name, s.qname, s.kind, s.exported, s.doc IS NOT NULL AS has_doc, s.parent_id, s.file_id, f.path, f.lang, f.is_test,
|
|
151
|
+
(SELECT kind FROM symbols p WHERE p.id = s.parent_id) AS parent_kind
|
|
152
|
+
FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id IN (${chunk.map(() => '?').join(',')})`, ...chunk)) rows.set(r.id, r);
|
|
153
|
+
}
|
|
154
|
+
const qStems = new Set(lexTerms.map(stem));
|
|
155
|
+
const central = this.centrality?.() ?? null;
|
|
156
|
+
const w = this.w;
|
|
157
|
+
const scored = [];
|
|
158
|
+
for (const f of cands.values()) {
|
|
159
|
+
const r = rows.get(f.id);
|
|
160
|
+
if (!r) continue;
|
|
161
|
+
if (pathFilter && !r.path.startsWith(pathFilter.replace(/^\.\//, ''))) continue;
|
|
162
|
+
if (kindFilter && !kindFilter.includes(r.kind)) continue;
|
|
163
|
+
if (langFilter && r.lang !== langFilter) continue;
|
|
164
|
+
const nameWords = splitIdentifier(r.name).map(stem);
|
|
165
|
+
const qnameWords = new Set(splitIdentifier(r.qname.replace(/\./g, '_')).map(stem));
|
|
166
|
+
const nameCover = nameWords.length ? nameWords.filter(x => qStems.has(x)).length / nameWords.length : 0;
|
|
167
|
+
const queryCover = qStems.size ? [...qStems].filter(x => qnameWords.has(x)).length / qStems.size : 0;
|
|
168
|
+
let s = 0;
|
|
169
|
+
const reasons = [];
|
|
170
|
+
// floors keep near-zero-IDF matches (a word present in most documents) from being
|
|
171
|
+
// blown up by max-normalisation; when literal evidence is weak, the concept channel
|
|
172
|
+
// (thesaurus expansions) takes over — that is exactly the vocabulary-mismatch case.
|
|
173
|
+
if (f.bm25) s += w.bm25 * (f.bm25 / Math.max(maxBm, BM25_FLOOR));
|
|
174
|
+
if (f.concept) s += (maxBm < BM25_FLOOR ? Math.max(w.concept, 1.0) : w.concept) * (f.concept / Math.max(maxCx, BM25_FLOOR));
|
|
175
|
+
if (f.exact) { s += w.exact * f.exact; reasons.push('name'); }
|
|
176
|
+
if (f.exactCI) { s += w.exactCI * f.exactCI; reasons.push('name'); }
|
|
177
|
+
if (f.qualified) { s += w.qualified; reasons.push('qualified name'); }
|
|
178
|
+
if (f.prefix) s += w.prefix;
|
|
179
|
+
s += w.nameCover * nameCover + w.queryCover * queryCover;
|
|
180
|
+
s += w.kind * (KIND_PRIOR[r.kind] ?? 0.5);
|
|
181
|
+
if (r.is_test && !wantsTests) s += w.test;
|
|
182
|
+
if (r.exported) s += w.exported;
|
|
183
|
+
if (r.has_doc) s += w.doc;
|
|
184
|
+
if (central) s += w.central * (central.get(r.id) ?? 0);
|
|
185
|
+
// hierarchical evidence: a relevant file lifts its symbols in proportion to their own
|
|
186
|
+
// lexical match (P(file) × P(symbol | file)), never symbols that merely live there
|
|
187
|
+
const fs = fileScore.get(r.file_id);
|
|
188
|
+
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
|
+
if (r.parent_kind === 'function' || r.parent_kind === 'method') s += w.nested;
|
|
190
|
+
if (DECLARATION_PATH.test(r.path)) s += w.declaration;
|
|
191
|
+
if (EXAMPLE_PATH.test(r.path) && !(pathFilter && EXAMPLE_PATH.test(pathFilter))) s += w.example;
|
|
192
|
+
if (a.natural && (r.kind === 'field' || r.kind === 'property' || r.kind === 'variable' || r.kind === 'constant')) s += w.nlField;
|
|
193
|
+
scored.push({ id: r.id, score: s, reasons, path: r.path, ...(opts.debug ? { features: { ...f, nameCover, queryCover, kind: r.kind } } : {}) });
|
|
194
|
+
}
|
|
195
|
+
scored.sort((x, y) => y.score - x.score || x.id - y.id);
|
|
196
|
+
// per-file diversification: 3rd+ result from the same file is pushed down
|
|
197
|
+
const perFile = new Map();
|
|
198
|
+
const out = [];
|
|
199
|
+
const deferred = [];
|
|
200
|
+
for (const r of scored) {
|
|
201
|
+
const n = perFile.get(r.path) ?? 0;
|
|
202
|
+
if (n >= this.perFileCap) { deferred.push(r); continue; }
|
|
203
|
+
perFile.set(r.path, n + 1);
|
|
204
|
+
out.push(r);
|
|
205
|
+
if (out.length >= limit) break;
|
|
206
|
+
}
|
|
207
|
+
for (const r of deferred) { if (out.length >= limit) break; out.push(r); }
|
|
208
|
+
return { results: out, total: scored.length, analysis: a };
|
|
209
|
+
}
|
|
210
|
+
}
|