graph-indexer 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +31 -105
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. 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,119 @@
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
+
10
+ function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
11
+ const exists = (root, rel) => fs.existsSync(path.join(root, rel));
12
+
13
+ /** Nearest package.json above a file (monorepos have one per package). */
14
+ function nearestPackage(root, rel) {
15
+ let dir = path.posix.dirname(rel);
16
+ for (;;) {
17
+ const p = path.join(root, dir === '.' ? '' : dir, 'package.json');
18
+ if (fs.existsSync(p)) return { dir: dir === '.' ? '' : dir, pkg: readJson(p) ?? {} };
19
+ if (dir === '.' || dir === '' || dir === '/') return null;
20
+ dir = path.posix.dirname(dir);
21
+ }
22
+ }
23
+
24
+ function jsRunner(root, rel) {
25
+ const near = nearestPackage(root, rel);
26
+ const top = readJson(path.join(root, 'package.json')) ?? {};
27
+ const pkgs = [near?.pkg, top].filter(Boolean);
28
+ const deps = Object.assign({}, ...pkgs.map(p => ({ ...p.dependencies, ...p.devDependencies })));
29
+ const scripts = pkgs.map(p => p.scripts?.test ?? '').join(' ');
30
+ if (deps.vitest || /\bvitest\b/.test(scripts)) return 'npx vitest run';
31
+ if (deps.jest || /\bjest\b/.test(scripts)) return 'npx jest';
32
+ if (deps.mocha || /\bmocha\b/.test(scripts) || exists(root, '.mocharc.js') || exists(root, '.mocharc.json') || exists(root, '.mocharc.yml')) return 'npx mocha';
33
+ if (deps.ava || /\bava\b/.test(scripts)) return 'npx ava';
34
+ if (deps.tap || /\btap\b/.test(scripts)) return 'npx tap';
35
+ if (/node --test/.test(scripts)) return 'node --test';
36
+ return 'npm test --';
37
+ }
38
+
39
+ function pyRunner(root) {
40
+ if (exists(root, 'tests/runtests.py')) return { kind: 'django' };
41
+ const pyproject = (() => { try { return fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8'); } catch { return ''; } })();
42
+ if (exists(root, 'pytest.ini') || exists(root, 'conftest.py') || /\[tool\.pytest/.test(pyproject) || exists(root, 'tox.ini')) return { kind: 'pytest' };
43
+ return { kind: 'unittest' };
44
+ }
45
+
46
+ /**
47
+ * @param {string} root repository root
48
+ * @param {string[]} files test files (repo-relative)
49
+ * @returns {string[]} shell commands
50
+ */
51
+ export function testCommands(root, files) {
52
+ const groups = new Map();
53
+ const add = (key, f) => (groups.get(key) ?? groups.set(key, []).get(key)).push(f);
54
+ for (const f of files) {
55
+ const ext = path.posix.extname(f);
56
+ if (/\.[cm]?[jt]sx?$/.test(ext)) add(`js:${jsRunner(root, f)}`, f);
57
+ else if (ext === '.py') add('py', f);
58
+ else if (ext === '.go') add('go', f);
59
+ else if (ext === '.rs') add('rs', f);
60
+ else if (ext === '.java' || ext === '.kt' || ext === '.scala') add('jvm', f);
61
+ else if (ext === '.rb') add('rb', f);
62
+ else if (ext === '.php') add('php', f);
63
+ else if (ext === '.cs') add('cs', f);
64
+ }
65
+ const out = [];
66
+ for (const [key, fs_] of groups) {
67
+ if (key.startsWith('js:')) out.push(`${key.slice(3)} ${fs_.join(' ')}`);
68
+ else if (key === 'py') {
69
+ 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(' ')}`);
74
+ else if (key === 'rs') out.push('cargo test');
75
+ else if (key === 'jvm') {
76
+ const classes = [...new Set(fs_.map(f => path.posix.basename(f).replace(/\.\w+$/, '')))].join(',');
77
+ 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(' ')}`);
80
+ else if (key === 'cs') out.push('dotnet test');
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Commands that run only the given test functions: [{ path, name, cls }] (cls: the enclosing test
87
+ * class, if any). pytest, unittest and Django (Python), `go test -run`, Maven and Gradle; other
88
+ * ecosystems name tests by strings the index does not see, so they get no command here.
89
+ * @returns {string[]} shell commands
90
+ */
91
+ export function testCaseCommands(root, cases) {
92
+ const out = [];
93
+ const py = cases.filter(c => c.path.endsWith('.py'));
94
+ if (py.length) {
95
+ const r = pyRunner(root);
96
+ const mod = (p) => p.replace(/\.py$/, '').replace(/\//g, '.');
97
+ if (r.kind === 'pytest') out.push(`python -m pytest -q ${py.map(c => `${c.path}::${c.cls ? `${c.cls}::` : ''}${c.name}`).join(' ')}`);
98
+ else if (r.kind === 'django') {
99
+ const withCls = py.filter(c => c.cls);
100
+ if (withCls.length) out.push(`python tests/runtests.py ${withCls.map(c => `${mod(c.path.replace(/^tests\//, ''))}.${c.cls}.${c.name}`).join(' ')}`);
101
+ } else {
102
+ const withCls = py.filter(c => c.cls);
103
+ if (withCls.length) out.push(`python -m unittest ${withCls.map(c => `${mod(c.path)}.${c.cls}.${c.name}`).join(' ')}`);
104
+ }
105
+ }
106
+ const byDir = new Map();
107
+ 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);
108
+ for (const [dir, names] of byDir) out.push(`go test ./${dir} -run '^(${[...new Set(names)].join('|')})$'`);
109
+ const jvm = cases.filter(c => /\.(java|kt|scala)$/.test(c.path));
110
+ if (jvm.length) {
111
+ const cls = (c) => c.cls ?? path.posix.basename(c.path).replace(/\.\w+$/, '');
112
+ if (exists(root, 'pom.xml')) {
113
+ const by = new Map();
114
+ for (const c of jvm) (by.get(cls(c)) ?? by.set(cls(c), []).get(cls(c))).push(c.name);
115
+ out.push(`mvn -q test -Dtest=${[...by].map(([k, v]) => `${k}#${[...new Set(v)].join('+')}`).join(',')}`);
116
+ } else out.push(`./gradlew test ${jvm.map(c => `--tests '*${cls(c)}.${c.name}'`).join(' ')}`);
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,160 @@
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 { spawnSync } from 'node:child_process';
11
+
12
+ const MAX_FILE_BYTES = 1_500_000;
13
+ const SKIP_DIRS = /(^|\/)(node_modules|\.git|\.graph-indexer|dist|build|out|target|coverage|__pycache__|\.venv|venv|\.next|\.nuxt|\.cache|vendor)\//;
14
+ const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
15
+ const COMMENT_LINE = /^\s*(\/\/|#(?![!\[])|\*|\/\*|<!--|--\s|;;|%)/;
16
+
17
+ function listFiles(root) {
18
+ const r = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 30 });
19
+ if (r.status === 0) return r.stdout.split('\0').filter(Boolean).filter(p => !SKIP_DIRS.test(p));
20
+ const out = [];
21
+ const stack = [''];
22
+ while (stack.length) {
23
+ const rel = stack.pop();
24
+ let entries;
25
+ try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
26
+ for (const e of entries) {
27
+ const p = rel ? `${rel}/${e.name}` : e.name;
28
+ if (e.isDirectory()) { if (!e.name.startsWith('.') && !SKIP_DIRS.test(p + '/')) stack.push(p); } else if (e.isFile()) out.push(p);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
35
+
36
+ /** Regular expression source for a glob: `*` within a directory, `**` across directories, `?`, `[…]`, `{a,b}`. */
37
+ function globSource(glob) {
38
+ let re = '';
39
+ for (let i = 0; i < glob.length; i++) {
40
+ const c = glob[i];
41
+ if (c === '*') {
42
+ if (glob[i + 1] !== '*') { re += '[^/]*'; continue; }
43
+ i++;
44
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
45
+ } else if (c === '?') re += '[^/]';
46
+ else if (c === '[' && glob.indexOf(']', i + 2) > i) { const j = glob.indexOf(']', i + 2); re += glob.slice(i, j + 1).replace(/^\[!/, '[^'); i = j; }
47
+ else if (c === '{' && glob.indexOf('}', i) > i) { const j = glob.indexOf('}', i); re += `(?:${glob.slice(i + 1, j).split(',').map(escapeRe).join('|')})`; i = j; }
48
+ else re += escapeRe(c);
49
+ }
50
+ return re;
51
+ }
52
+
53
+ /**
54
+ * Files whose path matches a glob (`*.toml`, `tests/test_*.py`, `**` for any depth; without a slash
55
+ * it is matched against file names) or, without wildcards, contains the text (case-insensitive).
56
+ */
57
+ export function findFiles(root, pattern, { path: prefix = null } = {}) {
58
+ const pre = prefix ? String(prefix).replace(/^\.\//, '').replace(/\/+$/, '') : null;
59
+ let test;
60
+ if (/[*?[{]/.test(pattern)) {
61
+ const onName = !pattern.includes('/');
62
+ const re = new RegExp(onName ? `^${globSource(pattern)}$` : `(^|/)${globSource(pattern.replace(/^\.?\//, ''))}$`);
63
+ test = (p) => re.test(onName ? p.slice(p.lastIndexOf('/') + 1) : p);
64
+ } else {
65
+ const t = pattern.toLowerCase();
66
+ test = (p) => p.toLowerCase().includes(t);
67
+ }
68
+ return listFiles(root).filter(p => (!pre || p === pre || p.startsWith(pre + '/')) && test(p)).sort();
69
+ }
70
+
71
+ /** The identifier a pattern is about (`\bget\(` → get, `Reflector.get` → get), if any. */
72
+ export function identifierOf(pattern, literal) {
73
+ const src = literal ? pattern : pattern.replace(/\\[bBsSdDwW]/g, ' ').replace(/\\(.)/g, '$1').replace(/[()[\]{}^$*+?|]/g, ' ');
74
+ const words = src.split(/[^A-Za-z0-9_$]+/).filter(w => /^[A-Za-z_$][\w$]*$/.test(w));
75
+ return words.length ? words[words.length - 1] : null;
76
+ }
77
+
78
+ function rank(p, isTest) {
79
+ if (isTest) return 2;
80
+ if (EXAMPLE_PATH.test(p)) return 3;
81
+ return 0;
82
+ }
83
+
84
+ /**
85
+ * @returns {{ error?: string, total: number, files: number, shown: object[], summary: object|null, truncated: boolean }}
86
+ */
87
+ export async function textSearch(intel, { pattern, path: prefix = null, literal = false, ignoreCase = false, limit = 60 }) {
88
+ let re;
89
+ try {
90
+ const src = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : pattern;
91
+ re = new RegExp(src, ignoreCase ? 'i' : '');
92
+ } catch (e) {
93
+ return { error: `Invalid regular expression: ${e.message}. Pass literal: true to search for the text as is.` };
94
+ }
95
+ const root = intel.root;
96
+ const pre = prefix ? String(prefix).replace(/^\.\//, '').replace(/^\/+/, '') : null;
97
+ const files = listFiles(root).filter(p => !pre || p === pre || p.startsWith(pre.endsWith('/') ? pre : pre + '/') || p.startsWith(pre));
98
+ const indexed = new Map(intel.store.all('SELECT id, path, is_test FROM files').map(f => [f.path, f]));
99
+ const matches = [];
100
+ let total = 0, nfiles = 0;
101
+ for (const rel of files) {
102
+ let text;
103
+ try {
104
+ const st = fs.statSync(path.join(root, rel));
105
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
106
+ text = fs.readFileSync(path.join(root, rel), 'utf8');
107
+ } catch { continue; }
108
+ if (!re.test(text)) continue;
109
+ if (text.indexOf('\0') >= 0 && text.indexOf('\0') < 8000) continue; // binary
110
+ const lines = text.split(/\r?\n/);
111
+ let any = false;
112
+ for (let i = 0; i < lines.length; i++) {
113
+ if (!re.test(lines[i])) continue;
114
+ any = true;
115
+ total++;
116
+ const f = indexed.get(rel);
117
+ matches.push({ path: rel, line: i + 1, text: lines[i], fileId: f?.id ?? null, isTest: !!f?.is_test, code: !!f });
118
+ }
119
+ if (any) nfiles++;
120
+ }
121
+ const ident = identifierOf(pattern, literal);
122
+ // annotate: enclosing definition, and what an identifier match refers to
123
+ const byFile = new Map();
124
+ for (const m of matches) (byFile.get(m.path) ?? byFile.set(m.path, []).get(m.path)).push(m);
125
+ const summary = ident ? { ident, targets: new Map(), defs: 0, unbound: 0, text: 0, nonCode: 0 } : null;
126
+ for (const [rel, ms] of byFile) {
127
+ const f = indexed.get(rel);
128
+ if (!f) { for (const m of ms) { m.what = 'file'; if (summary) summary.nonCode++; } continue; }
129
+ 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);
130
+ 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
131
+ WHERE r.file_id = ? AND r.name = ?`, f.id, ident) : [];
132
+ const refsAt = new Map();
133
+ for (const r of refs) (refsAt.get(r.line) ?? refsAt.set(r.line, []).get(r.line)).push(r);
134
+ for (const m of ms) {
135
+ // innermost definition around the line, ignoring ones that start on it (inline classes, the def itself)
136
+ let encl = null;
137
+ 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;
138
+ m.encl = encl?.qname ?? null;
139
+ if (!summary) continue;
140
+ const def = syms.find(s => s.name === ident && s.name_line === m.line);
141
+ const rs = refsAt.get(m.line) ?? [];
142
+ if (def) { m.what = 'def'; m.target = def.qname; summary.defs++; continue; }
143
+ const bound = rs.filter(r => r.dst_id != null);
144
+ if (bound.length) {
145
+ m.what = 'ref';
146
+ m.target = [...new Set(bound.map(r => r.dst_qname))].join(', ');
147
+ for (const r of bound) {
148
+ const k = `${r.dst_qname}\u0000${r.dst_path}`;
149
+ summary.targets.set(k, (summary.targets.get(k) ?? 0) + 1);
150
+ }
151
+ continue;
152
+ }
153
+ if (rs.length) { m.what = 'unbound'; summary.unbound++; continue; }
154
+ m.what = COMMENT_LINE.test(m.text) ? 'comment' : 'text';
155
+ summary.text++;
156
+ }
157
+ }
158
+ 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);
159
+ return { total, files: nfiles, shown: matches.slice(0, limit), all: matches, summary, truncated: matches.length > limit };
160
+ }
@@ -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
+ }