edhindex 1.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.
@@ -0,0 +1,181 @@
1
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { logger } from '../logging.js';
4
+ import { emitProgress } from '../progress.js';
5
+ import { Config, MODEL_CONFIGS } from '../config/index.js';
6
+ import { scanFiles, computeFileHash } from './file-utils.js';
7
+ import { createIgnoreRules } from './ignore.js';
8
+ import { initParser, loadLanguage, parseContentAsync, extractSymbols, extractImports, extractExports } from './parser.js';
9
+ import { Chunk, chunkFile } from './chunker.js';
10
+ import { computeFileDelta, computeChunkDelta } from './incremental.js';
11
+
12
+ export interface IndexFileResult {
13
+ chunks: Chunk[];
14
+ filePath: string;
15
+ relativePath: string;
16
+ hash: string;
17
+ }
18
+
19
+ export type IndexCallback = (result: IndexFileResult) => Promise<void>;
20
+ export type DeleteCallback = (chunkIds: string[]) => Promise<void>;
21
+
22
+ export interface IndexerOptions {
23
+ config: Config;
24
+ rootPath: string;
25
+ indexDir: string;
26
+ onIndex: IndexCallback;
27
+ onDelete?: DeleteCallback;
28
+ signal?: AbortSignal;
29
+ }
30
+
31
+ export class Indexer {
32
+ private config: Config;
33
+ private rootPath: string;
34
+ private indexDir: string;
35
+ private onIndex: IndexCallback;
36
+ private onDelete?: DeleteCallback;
37
+ private signal: AbortSignal;
38
+ private extToLang: Map<string, string>;
39
+
40
+ constructor(opts: IndexerOptions) {
41
+ this.config = opts.config;
42
+ this.rootPath = opts.rootPath;
43
+ this.indexDir = opts.indexDir;
44
+ this.onIndex = opts.onIndex;
45
+ this.onDelete = opts.onDelete;
46
+ this.signal = opts.signal || new AbortController().signal;
47
+
48
+ this.extToLang = new Map();
49
+ for (const lang of this.config.languages) {
50
+ const exts = LANG_EXTENSIONS[lang] || [];
51
+ for (const ext of exts) {
52
+ this.extToLang.set(ext, lang);
53
+ }
54
+ }
55
+ }
56
+
57
+ async initialize() {
58
+ await initParser();
59
+ for (const lang of this.config.languages) {
60
+ try {
61
+ await loadLanguage(lang);
62
+ logger.verbose(`Loaded tree-sitter grammar for ${lang}`);
63
+ } catch (e) {
64
+ logger.error(`Failed to load tree-sitter grammar for ${lang}:`, e);
65
+ }
66
+ }
67
+ }
68
+
69
+ async runFullIndex(): Promise<Chunk[]> {
70
+ emitProgress({ phase: 'scanning', current: 0, total: 0 });
71
+
72
+ const files = scanFiles(this.rootPath, this.config.languages);
73
+ const allChunks: Chunk[] = [];
74
+ let processed = 0;
75
+
76
+ emitProgress({ phase: 'scanning', current: files.length, total: files.length });
77
+
78
+ for (const file of files) {
79
+ if (this.signal.aborted) break;
80
+
81
+ emitProgress({ phase: 'embedding', percent: Math.round((processed / files.length) * 100) });
82
+
83
+ try {
84
+ const chunks = await this.processFile(file.path, file.relativePath, file.language);
85
+ allChunks.push(...chunks);
86
+ await this.onIndex({ chunks, filePath: file.path, relativePath: file.relativePath, hash: file.hash });
87
+ } catch (e) {
88
+ logger.debug(`Failed to process ${file.relativePath}:`, e);
89
+ }
90
+
91
+ processed++;
92
+ }
93
+
94
+ emitProgress({ phase: 'building_fts' });
95
+ emitProgress({ phase: 'ready' });
96
+
97
+ return allChunks;
98
+ }
99
+
100
+ async runIncremental(
101
+ existingFileHashes: Map<string, string>,
102
+ existingChunkHashes: Map<string, string>,
103
+ ): Promise<{ chunks: Chunk[]; removed: string[] }> {
104
+ emitProgress({ phase: 'scanning', current: 0, total: 0 });
105
+
106
+ const scannedFiles = scanFiles(this.rootPath, this.config.languages);
107
+ const scannedMap = new Map(scannedFiles.map(f => [f.relativePath, f]));
108
+ const delta = computeFileDelta(scannedMap, existingFileHashes);
109
+
110
+ emitProgress({ phase: 'scanning', current: scannedFiles.length, total: scannedFiles.length });
111
+
112
+ const changedFiles = [...delta.added, ...delta.modified];
113
+ const allChunks: Chunk[] = [];
114
+ let processed = 0;
115
+
116
+ for (const file of changedFiles) {
117
+ if (this.signal.aborted) break;
118
+
119
+ emitProgress({ phase: 'embedding', percent: Math.round((processed / changedFiles.length) * 100) });
120
+
121
+ try {
122
+ const chunks = await this.processFile(file.path, file.relativePath, file.language);
123
+ allChunks.push(...chunks);
124
+ await this.onIndex({ chunks, filePath: file.path, relativePath: file.relativePath, hash: file.hash });
125
+ } catch (e) {
126
+ logger.debug(`Failed to process ${file.relativePath}:`, e);
127
+ }
128
+
129
+ processed++;
130
+ }
131
+
132
+ if (this.onDelete && delta.removed.length > 0) {
133
+ const removedChunkIds: string[] = [];
134
+ for (const relPath of delta.removed) {
135
+ for (const [chunkId] of existingChunkHashes) {
136
+ if (chunkId.startsWith(`${relPath}:`)) {
137
+ removedChunkIds.push(chunkId);
138
+ }
139
+ }
140
+ }
141
+ await this.onDelete(removedChunkIds);
142
+ }
143
+
144
+ emitProgress({ phase: 'building_fts' });
145
+ emitProgress({ phase: 'ready' });
146
+
147
+ return { chunks: allChunks, removed: delta.removed };
148
+ }
149
+
150
+ async processFile(
151
+ filePath: string,
152
+ relativePath: string,
153
+ language: string,
154
+ ): Promise<Chunk[]> {
155
+ const content = readFileSync(filePath, 'utf-8');
156
+ const lines = content.split('\n');
157
+
158
+ const { tree } = await parseContentAsync(content, language);
159
+ const symbols = extractSymbols(tree, language);
160
+ const imports = extractImports(tree, language);
161
+ const exports = extractExports(tree, language);
162
+
163
+ return chunkFile(
164
+ filePath,
165
+ relativePath,
166
+ language,
167
+ symbols,
168
+ content,
169
+ lines,
170
+ imports,
171
+ exports,
172
+ );
173
+ }
174
+ }
175
+
176
+ const LANG_EXTENSIONS: Record<string, string[]> = {
177
+ ts: ['.ts', '.tsx', '.mts', '.cts'],
178
+ js: ['.js', '.jsx', '.mjs', '.cjs'],
179
+ py: ['.py', '.pyw'],
180
+ go: ['.go'],
181
+ };
@@ -0,0 +1,267 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { logger } from '../logging.js';
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ import { Parser, Language } from 'web-tree-sitter';
9
+
10
+ let initialized = false;
11
+ const loadedLanguages = new Map<string, any>();
12
+
13
+ export interface ASTNode {
14
+ type: string;
15
+ startLine: number;
16
+ endLine: number;
17
+ startCol: number;
18
+ endCol: number;
19
+ text: string;
20
+ children: ASTNode[];
21
+ }
22
+
23
+ export async function initParser() {
24
+ if (initialized) return;
25
+ await Parser.init();
26
+ initialized = true;
27
+ }
28
+
29
+ export async function loadLanguage(language: string): Promise<any> {
30
+ if (loadedLanguages.has(language)) {
31
+ return loadedLanguages.get(language)!;
32
+ }
33
+
34
+ let wasmPath: string;
35
+
36
+ switch (language) {
37
+ case 'ts':
38
+ case 'js':
39
+ wasmPath = require.resolve('tree-sitter-typescript/tree-sitter-typescript.wasm');
40
+ break;
41
+ case 'py':
42
+ wasmPath = require.resolve('tree-sitter-python/tree-sitter-python.wasm');
43
+ break;
44
+ case 'go':
45
+ wasmPath = require.resolve('tree-sitter-go/tree-sitter-go.wasm');
46
+ break;
47
+ default:
48
+ throw new Error(`Unsupported language: ${language}`);
49
+ }
50
+
51
+ const lang = await Language.load(wasmPath);
52
+ loadedLanguages.set(language, lang);
53
+
54
+ return lang;
55
+ }
56
+
57
+ function langKey(language: string): string {
58
+ return language === 'ts' || language === 'js' ? 'ts' : language;
59
+ }
60
+
61
+ export function parseFile(filePath: string, language: string): any {
62
+ const content = readFileSync(filePath, 'utf-8');
63
+ return parseContent(content, language);
64
+ }
65
+
66
+ function parseContent(content: string, _language?: string): never {
67
+ throw new Error('Use parseContentAsync for async parsing');
68
+ }
69
+
70
+ export async function parseContentAsync(content: string, language: string): Promise<{ tree: any; rootNode: ASTNode }> {
71
+ const lk = langKey(language);
72
+ const lang = loadedLanguages.get(language) || loadedLanguages.get(lk);
73
+ if (!lang) {
74
+ throw new Error(`Language not loaded: ${language}`);
75
+ }
76
+ const parser = new Parser();
77
+ parser.setLanguage(lang);
78
+ const tree = parser.parse(content);
79
+ if (!tree) {
80
+ throw new Error('Failed to parse content');
81
+ }
82
+ const rootNode = convertNode(tree.rootNode);
83
+ return { tree, rootNode };
84
+ }
85
+
86
+ export function getNodeText(node: any): string {
87
+ return node.text;
88
+ }
89
+
90
+ export function getChildCount(node: any): number {
91
+ return node.childCount;
92
+ }
93
+
94
+ export function getChild(node: any, index: number): any {
95
+ return node.child(index);
96
+ }
97
+
98
+ function convertNode(node: any): ASTNode {
99
+ return {
100
+ type: node.type,
101
+ startLine: node.startPosition.row + 1,
102
+ endLine: node.endPosition.row + 1,
103
+ startCol: node.startPosition.column,
104
+ endCol: node.endPosition.column,
105
+ text: node.text || '',
106
+ children: [],
107
+ };
108
+ }
109
+
110
+ export interface SymbolInfo {
111
+ name: string;
112
+ kind: 'function' | 'method' | 'class' | 'interface' | 'enum' | 'module';
113
+ startLine: number;
114
+ endLine: number;
115
+ text: string;
116
+ }
117
+
118
+ const QUERY_PATTERNS: Record<string, Record<string, string>> = {
119
+ ts: {
120
+ function: '(function_declaration name: (identifier) @name)',
121
+ method: '(method_definition name: (property_identifier) @name)',
122
+ class: '(class_declaration name: (type_identifier) @name)',
123
+ interface: '(interface_declaration name: (type_identifier) @name)',
124
+ enum: '(enum_declaration name: (type_identifier) @name)',
125
+ },
126
+ js: {
127
+ function: '(function_declaration name: (identifier) @name)',
128
+ method: '(method_definition name: (property_identifier) @name)',
129
+ class: '(class_declaration name: (identifier) @name)',
130
+ },
131
+ py: {
132
+ function: '(function_definition name: (identifier) @name)',
133
+ class: '(class_definition name: (identifier) @name)',
134
+ },
135
+ go: {
136
+ function: '(function_declaration name: (identifier) @name)',
137
+ method: '(method_declaration name: (field_identifier) @name)',
138
+ },
139
+ };
140
+
141
+ export function extractSymbols(tree: any, language: string): SymbolInfo[] {
142
+ const symbols: SymbolInfo[] = [];
143
+ const patterns = QUERY_PATTERNS[language];
144
+ if (!patterns) return symbols;
145
+
146
+ const rootNode = tree.rootNode;
147
+ walkTree(rootNode, symbols, language);
148
+ return symbols;
149
+ }
150
+
151
+ function walkTree(node: any, symbols: SymbolInfo[], language: string) {
152
+ const kind = nodeTypeToKind(node.type, language);
153
+ if (kind) {
154
+ const name = extractName(node, language);
155
+ symbols.push({
156
+ name: name || `anonymous_${kind}_${node.startPosition.row + 1}`,
157
+ kind,
158
+ startLine: node.startPosition.row + 1,
159
+ endLine: node.endPosition.row + 1,
160
+ text: node.text || '',
161
+ });
162
+ }
163
+
164
+ for (let i = 0; i < node.childCount; i++) {
165
+ walkTree(node.child(i), symbols, language);
166
+ }
167
+ }
168
+
169
+ function nodeTypeToKind(type: string, language: string): SymbolInfo['kind'] | null {
170
+ switch (language) {
171
+ case 'ts':
172
+ case 'js':
173
+ switch (type) {
174
+ case 'function_declaration': return 'function';
175
+ case 'arrow_function': return 'function';
176
+ case 'method_definition': return 'method';
177
+ case 'class_declaration': return 'class';
178
+ case 'interface_declaration': return 'interface';
179
+ case 'enum_declaration': return 'enum';
180
+ default: return null;
181
+ }
182
+ case 'py':
183
+ switch (type) {
184
+ case 'function_definition': return 'function';
185
+ case 'class_definition': return 'class';
186
+ default: return null;
187
+ }
188
+ case 'go':
189
+ switch (type) {
190
+ case 'function_declaration': return 'function';
191
+ case 'method_declaration': return 'method';
192
+ default: return null;
193
+ }
194
+ default:
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function extractName(node: any, language: string): string | null {
200
+ try {
201
+ for (let i = 0; i < node.childCount; i++) {
202
+ const child = node.child(i);
203
+ if (language === 'ts' || language === 'js') {
204
+ if (child.type === 'identifier' || child.type === 'property_identifier' || child.type === 'type_identifier') {
205
+ return child.text;
206
+ }
207
+ } else if (language === 'py') {
208
+ if (child.type === 'identifier') {
209
+ return child.text;
210
+ }
211
+ } else if (language === 'go') {
212
+ if (child.type === 'identifier' || child.type === 'field_identifier') {
213
+ return child.text;
214
+ }
215
+ }
216
+ }
217
+ } catch {
218
+ // ignore
219
+ }
220
+ return null;
221
+ }
222
+
223
+ export function extractImports(tree: any, language: string): string[] {
224
+ const imports: string[] = [];
225
+ const rootNode = tree.rootNode;
226
+ collectImports(rootNode, imports, language);
227
+ return imports;
228
+ }
229
+
230
+ function collectImports(node: any, imports: string[], language: string) {
231
+ if (node.type === 'import_statement' || node.type === 'import_declaration' ||
232
+ node.type === 'import_from_statement' || node.type === 'require_statement' ||
233
+ (language === 'py' && node.type === 'import_statement') ||
234
+ (language === 'py' && node.type === 'import_from_statement') ||
235
+ (language === 'go' && node.type === 'import_declaration') ||
236
+ (language === 'go' && node.type === 'import_spec')) {
237
+ imports.push(node.text || '');
238
+ }
239
+ for (let i = 0; i < node.childCount; i++) {
240
+ collectImports(node.child(i), imports, language);
241
+ }
242
+ }
243
+
244
+ export function extractExports(tree: any, language: string): string[] {
245
+ const exports: string[] = [];
246
+ const rootNode = tree.rootNode;
247
+ collectExports(rootNode, exports, language);
248
+ return exports;
249
+ }
250
+
251
+ function collectExports(node: any, exports: string[], language: string) {
252
+ if (language === 'ts' || language === 'js') {
253
+ if (node.type === 'export_statement') {
254
+ for (let i = 0; i < node.childCount; i++) {
255
+ const child = node.child(i);
256
+ if (child.type === 'function_declaration' || child.type === 'class_declaration' ||
257
+ child.type === 'variable_declaration' || child.type === 'interface_declaration' ||
258
+ child.type === 'type_alias_declaration') {
259
+ exports.push(child.text || '');
260
+ }
261
+ }
262
+ }
263
+ }
264
+ for (let i = 0; i < node.childCount; i++) {
265
+ collectExports(node.child(i), exports, language);
266
+ }
267
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,44 @@
1
+ export enum LogLevel {
2
+ Silent = 0,
3
+ Info = 1,
4
+ Verbose = 2,
5
+ Debug = 3,
6
+ }
7
+
8
+ const levelNames: Record<LogLevel, string> = {
9
+ [LogLevel.Silent]: 'SILENT',
10
+ [LogLevel.Info]: 'INFO',
11
+ [LogLevel.Verbose]: 'VERBOSE',
12
+ [LogLevel.Debug]: 'DEBUG',
13
+ };
14
+
15
+ let currentLevel = LogLevel.Info;
16
+
17
+ export function setLogLevel(level: LogLevel) {
18
+ currentLevel = level;
19
+ }
20
+
21
+ export function getLogLevel(): LogLevel {
22
+ return currentLevel;
23
+ }
24
+
25
+ function log(level: LogLevel, ...args: unknown[]) {
26
+ if (level <= currentLevel) {
27
+ const prefix = levelNames[level];
28
+ const timestamp = new Date().toISOString();
29
+ if (level === LogLevel.Debug) {
30
+ console.error(`[${timestamp}] [${prefix}]`, ...args);
31
+ } else if (level === LogLevel.Info) {
32
+ console.error(...args);
33
+ } else {
34
+ console.error(`[${prefix}]`, ...args);
35
+ }
36
+ }
37
+ }
38
+
39
+ export const logger = {
40
+ info: (...args: unknown[]) => log(LogLevel.Info, ...args),
41
+ verbose: (...args: unknown[]) => log(LogLevel.Verbose, ...args),
42
+ debug: (...args: unknown[]) => log(LogLevel.Debug, ...args),
43
+ error: (...args: unknown[]) => console.error(...args),
44
+ };
@@ -0,0 +1,126 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import {
3
+ ListToolsRequestSchema,
4
+ CallToolRequestSchema,
5
+ McpError,
6
+ ErrorCode,
7
+ } from '@modelcontextprotocol/sdk/types.js';
8
+ import { SearchEngine, SearchResult } from '../retrieval/search.js';
9
+ import { logger } from '../logging.js';
10
+
11
+ export function createMCPServer(searchEngine: SearchEngine, rootPath: string): Server {
12
+ const server = new Server(
13
+ {
14
+ name: 'edhindex',
15
+ version: '1.0.0',
16
+ },
17
+ {
18
+ capabilities: {
19
+ tools: {},
20
+ },
21
+ },
22
+ );
23
+
24
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
25
+ return {
26
+ tools: [
27
+ {
28
+ name: 'search_codebase',
29
+ description: `Search the indexed codebase using hybrid (keyword + semantic + reranked) search. Workspace: ${rootPath}. Searches file contents, symbols, and code structure.`,
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ query: {
34
+ type: 'string',
35
+ description: 'Search query - natural language or code terms',
36
+ },
37
+ maxResults: {
38
+ type: 'number',
39
+ description: 'Maximum results to return (default 10)',
40
+ default: 10,
41
+ },
42
+ fileFilter: {
43
+ type: 'string',
44
+ description: 'Optional file path filter (glob pattern)',
45
+ },
46
+ languageFilter: {
47
+ type: 'string',
48
+ description: 'Optional language filter (ts, js, py, go)',
49
+ },
50
+ },
51
+ required: ['query'],
52
+ },
53
+ },
54
+ ],
55
+ };
56
+ });
57
+
58
+ server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
59
+ if (request.params.name !== 'search_codebase') {
60
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
61
+ }
62
+
63
+ const args = request.params.arguments || {};
64
+ const query = String(args.query || '').trim();
65
+
66
+ if (!query) {
67
+ throw new McpError(ErrorCode.InvalidParams, 'query is required');
68
+ }
69
+
70
+ const maxResults = Math.min(Math.max(Number(args.maxResults) || 10, 1), 100);
71
+ const fileFilter = args.fileFilter ? String(args.fileFilter) : undefined;
72
+ const languageFilter = args.languageFilter ? String(args.languageFilter) : undefined;
73
+
74
+ // Security: sanitize and restrict to workspace
75
+ if (fileFilter) {
76
+ const { sanitizePath } = await import('../security.js');
77
+ const safe = sanitizePath(rootPath, fileFilter);
78
+ if (!safe) {
79
+ throw new McpError(ErrorCode.InvalidParams, 'File filter is outside the workspace');
80
+ }
81
+ }
82
+
83
+ try {
84
+ const results = await searchEngine.search({ query, maxResults, fileFilter, languageFilter });
85
+ return formatResults(results);
86
+ } catch (e: any) {
87
+ logger.error('Search failed:', e);
88
+ throw new McpError(ErrorCode.InternalError, `Search failed: ${e.message}`);
89
+ }
90
+ });
91
+
92
+ // Error handler
93
+ server.onerror = (error: any) => {
94
+ logger.error('MCP Server error:', error);
95
+ };
96
+
97
+ return server;
98
+ }
99
+
100
+ function formatResults(results: SearchResult[]) {
101
+ const textParts: string[] = [];
102
+
103
+ if (results.length === 0) {
104
+ textParts.push('No results found.');
105
+ } else {
106
+ textParts.push(`Found ${results.length} result(s):\n`);
107
+ for (let i = 0; i < results.length; i++) {
108
+ const r = results[i];
109
+ const lines: string[] = [];
110
+ lines.push(`[${i + 1}] ${r.file}:${r.startLine}-${r.endLine}`);
111
+ lines.push(` Language: ${r.language} | Type: ${r.matchType} | Score: ${r.score.toFixed(3)}`);
112
+ if (r.symbol) lines.push(` Symbol: ${r.symbol}${r.kind ? ` (${r.kind})` : ''}`);
113
+ lines.push(` ${'─'.repeat(60)}`);
114
+ textParts.push(lines.join('\n'));
115
+ }
116
+ }
117
+
118
+ return {
119
+ content: [
120
+ {
121
+ type: 'text',
122
+ text: textParts.join('\n'),
123
+ },
124
+ ],
125
+ };
126
+ }
@@ -0,0 +1,28 @@
1
+ export type ProgressPhase = 'scanning' | 'embedding' | 'building_fts' | 'ready';
2
+
3
+ export interface ProgressEvent {
4
+ phase: ProgressPhase;
5
+ current?: number;
6
+ total?: number;
7
+ percent?: number;
8
+ message?: string;
9
+ }
10
+
11
+ export type ProgressCallback = (event: ProgressEvent) => void;
12
+
13
+ const listeners: Set<ProgressCallback> = new Set();
14
+
15
+ export function onProgress(cb: ProgressCallback): () => void {
16
+ listeners.add(cb);
17
+ return () => listeners.delete(cb);
18
+ }
19
+
20
+ export function emitProgress(event: ProgressEvent) {
21
+ for (const cb of listeners) {
22
+ try {
23
+ cb(event);
24
+ } catch {
25
+ // swallow
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,22 @@
1
+ import { FTSStore, FTSResult } from '../storage/fts.js';
2
+
3
+ export function searchBM25(fts: FTSStore, query: string, topK: number): Array<{ id: string; score: number }> {
4
+ const results = fts.searchWithRank(query, topK * 2);
5
+ if (results.length === 0) return [];
6
+
7
+ if (results.length === 1) {
8
+ return [{ id: results[0].id, score: 1.0 }];
9
+ }
10
+
11
+ const negRanks = results.map(r => -r.rank);
12
+ const minNeg = Math.min(...negRanks);
13
+ const maxNeg = Math.max(...negRanks);
14
+ const range = maxNeg - minNeg;
15
+ if (range === 0) {
16
+ return results.map(r => ({ id: r.id, score: 1.0 }));
17
+ }
18
+ return results.map((r, i) => ({
19
+ id: r.id,
20
+ score: (negRanks[i] - minNeg) / range,
21
+ }));
22
+ }