skannr 0.1.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.
Files changed (142) hide show
  1. package/.eslintrc.js +22 -0
  2. package/README.md +109 -0
  3. package/dist/agent-cli.d.ts +7 -0
  4. package/dist/agent-cli.d.ts.map +1 -0
  5. package/dist/agent-cli.js +364 -0
  6. package/dist/agent-cli.js.map +1 -0
  7. package/dist/agent.d.ts +115 -0
  8. package/dist/agent.d.ts.map +1 -0
  9. package/dist/agent.js +340 -0
  10. package/dist/agent.js.map +1 -0
  11. package/dist/benchmark.d.ts +53 -0
  12. package/dist/benchmark.d.ts.map +1 -0
  13. package/dist/benchmark.js +307 -0
  14. package/dist/benchmark.js.map +1 -0
  15. package/dist/cache.d.ts +97 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +284 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/cli.d.ts +6 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +238 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config.d.ts +8 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +52 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +176 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/languages/GenericAdapter.d.ts +10 -0
  32. package/dist/languages/GenericAdapter.d.ts.map +1 -0
  33. package/dist/languages/GenericAdapter.js +41 -0
  34. package/dist/languages/GenericAdapter.js.map +1 -0
  35. package/dist/languages/LanguageAdapter.d.ts +14 -0
  36. package/dist/languages/LanguageAdapter.d.ts.map +1 -0
  37. package/dist/languages/LanguageAdapter.js +3 -0
  38. package/dist/languages/LanguageAdapter.js.map +1 -0
  39. package/dist/languages/PythonAdapter.d.ts +10 -0
  40. package/dist/languages/PythonAdapter.d.ts.map +1 -0
  41. package/dist/languages/PythonAdapter.js +98 -0
  42. package/dist/languages/PythonAdapter.js.map +1 -0
  43. package/dist/languages/TypeScriptAdapter.d.ts +17 -0
  44. package/dist/languages/TypeScriptAdapter.d.ts.map +1 -0
  45. package/dist/languages/TypeScriptAdapter.js +321 -0
  46. package/dist/languages/TypeScriptAdapter.js.map +1 -0
  47. package/dist/languages/registry.d.ts +4 -0
  48. package/dist/languages/registry.d.ts.map +1 -0
  49. package/dist/languages/registry.js +86 -0
  50. package/dist/languages/registry.js.map +1 -0
  51. package/dist/mapper.d.ts +49 -0
  52. package/dist/mapper.d.ts.map +1 -0
  53. package/dist/mapper.js +386 -0
  54. package/dist/mapper.js.map +1 -0
  55. package/dist/ranker-enhanced.d.ts +37 -0
  56. package/dist/ranker-enhanced.d.ts.map +1 -0
  57. package/dist/ranker-enhanced.js +395 -0
  58. package/dist/ranker-enhanced.js.map +1 -0
  59. package/dist/ranker.d.ts +14 -0
  60. package/dist/ranker.d.ts.map +1 -0
  61. package/dist/ranker.js +105 -0
  62. package/dist/ranker.js.map +1 -0
  63. package/dist/rocket-chat-scope.d.ts +7 -0
  64. package/dist/rocket-chat-scope.d.ts.map +1 -0
  65. package/dist/rocket-chat-scope.js +95 -0
  66. package/dist/rocket-chat-scope.js.map +1 -0
  67. package/dist/scanner.d.ts +16 -0
  68. package/dist/scanner.d.ts.map +1 -0
  69. package/dist/scanner.js +228 -0
  70. package/dist/scanner.js.map +1 -0
  71. package/dist/skeletonizer.d.ts +5 -0
  72. package/dist/skeletonizer.d.ts.map +1 -0
  73. package/dist/skeletonizer.js +52 -0
  74. package/dist/skeletonizer.js.map +1 -0
  75. package/dist/tokenizer.d.ts +9 -0
  76. package/dist/tokenizer.d.ts.map +1 -0
  77. package/dist/tokenizer.js +21 -0
  78. package/dist/tokenizer.js.map +1 -0
  79. package/dist/types.d.ts +68 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +6 -0
  82. package/dist/types.js.map +1 -0
  83. package/gemini-extension/GEMINI.md +26 -0
  84. package/gemini-extension/gemini-extension.json +12 -0
  85. package/gemini-extension/package.json +14 -0
  86. package/gemini-extension/src/server.ts +63 -0
  87. package/gemini-extension/tsconfig.json +14 -0
  88. package/jest.config.js +5 -0
  89. package/package.json +46 -0
  90. package/src/agent-cli.ts +383 -0
  91. package/src/agent.ts +344 -0
  92. package/src/benchmark.ts +389 -0
  93. package/src/cache.ts +317 -0
  94. package/src/cli.ts +223 -0
  95. package/src/config.ts +22 -0
  96. package/src/index.ts +215 -0
  97. package/src/languages/GenericAdapter.ts +44 -0
  98. package/src/languages/LanguageAdapter.ts +14 -0
  99. package/src/languages/PythonAdapter.ts +74 -0
  100. package/src/languages/TypeScriptAdapter.ts +338 -0
  101. package/src/languages/registry.ts +49 -0
  102. package/src/mapper.ts +448 -0
  103. package/src/ranker-enhanced.ts +460 -0
  104. package/src/ranker.ts +92 -0
  105. package/src/scanner.ts +201 -0
  106. package/src/skeletonizer.ts +16 -0
  107. package/src/tokenizer.ts +20 -0
  108. package/src/types.ts +71 -0
  109. package/tests/agent.tools.test.ts +81 -0
  110. package/tests/benchmark.test.ts +31 -0
  111. package/tests/fixtures/sample.py +17 -0
  112. package/tests/fixtures/sample.ts +13 -0
  113. package/tests/fixtures/src/api/routes.ts +1 -0
  114. package/tests/fixtures/src/auth/permission.ts +3 -0
  115. package/tests/ranker-enhanced.test.ts +68 -0
  116. package/tests/ranker.test.ts +75 -0
  117. package/tests/scanner.scope.test.ts +41 -0
  118. package/tests/setup-fixtures.js +29 -0
  119. package/tests/skeletonizer.test.ts +142 -0
  120. package/tsconfig.json +21 -0
  121. package/uca-landing/index.html +17 -0
  122. package/uca-landing/package.json +23 -0
  123. package/uca-landing/postcss.config.js +6 -0
  124. package/uca-landing/src/App.jsx +43 -0
  125. package/uca-landing/src/components/AgentMode.jsx +45 -0
  126. package/uca-landing/src/components/CliReference.jsx +49 -0
  127. package/uca-landing/src/components/Features.jsx +63 -0
  128. package/uca-landing/src/components/Footer.jsx +35 -0
  129. package/uca-landing/src/components/Hero.jsx +124 -0
  130. package/uca-landing/src/components/HowItWorks.jsx +60 -0
  131. package/uca-landing/src/components/Install.jsx +90 -0
  132. package/uca-landing/src/components/LanguageSupport.jsx +63 -0
  133. package/uca-landing/src/components/Navbar.jsx +86 -0
  134. package/uca-landing/src/components/Problem.jsx +51 -0
  135. package/uca-landing/src/components/Reveal.jsx +40 -0
  136. package/uca-landing/src/components/WorksWith.jsx +59 -0
  137. package/uca-landing/src/hooks/useScrollNav.js +13 -0
  138. package/uca-landing/src/hooks/useTypewriter.js +41 -0
  139. package/uca-landing/src/index.css +13 -0
  140. package/uca-landing/src/main.jsx +10 -0
  141. package/uca-landing/tailwind.config.js +68 -0
  142. package/uca-landing/vite.config.js +6 -0
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Enhanced file ranking with dependency analysis and semantic scoring
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import { readFileContent } from './scanner';
7
+ import { RankedFile } from './types';
8
+ import { getAdapter } from './languages/registry';
9
+
10
+ export interface EnhancedRankedFile extends RankedFile {
11
+ reasons: string[];
12
+ dependencies?: string[];
13
+ symbolCount?: number;
14
+ complexity?: number;
15
+ }
16
+
17
+ interface FileMetadata {
18
+ imports: string[];
19
+ exports: string[];
20
+ symbolCount: number;
21
+ linesOfCode: number;
22
+ }
23
+
24
+ interface HybridScoreBreakdown {
25
+ lexical: number;
26
+ enhanced: number;
27
+ structural: number;
28
+ dependency: number;
29
+ rerank: number;
30
+ }
31
+
32
+ interface PreRankRecord {
33
+ file: EnhancedRankedFile;
34
+ metadata: FileMetadata | null;
35
+ content: string;
36
+ baseScore: number;
37
+ }
38
+
39
+ function splitIdentifierTokens(input: string): string[] {
40
+ return input
41
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
42
+ .split(/[^a-zA-Z0-9]+/)
43
+ .map(token => token.toLowerCase())
44
+ .filter(token => token.length > 2);
45
+ }
46
+
47
+ function expandQueryTerms(question: string): string[] {
48
+ const baseTerms = splitIdentifierTokens(question);
49
+ const expansions: Record<string, string[]> = {
50
+ auth: ['authentication', 'authorize', 'permission', 'access'],
51
+ authentication: ['auth', 'login', 'permission', 'session'],
52
+ permission: ['authorization', 'role', 'policy', 'access'],
53
+ message: ['messages', 'chat', 'room', 'send'],
54
+ upload: ['file', 'media', 'attachment', 'storage'],
55
+ endpoint: ['route', 'api', 'controller', 'handler'],
56
+ encryption: ['crypto', 'key', 'cipher', 'secure'],
57
+ };
58
+
59
+ const out = new Set<string>(baseTerms);
60
+ for (const term of baseTerms) {
61
+ const aliases = expansions[term];
62
+ if (!aliases) continue;
63
+ for (const alias of aliases) {
64
+ out.add(alias);
65
+ }
66
+ }
67
+ return Array.from(out);
68
+ }
69
+
70
+ function normalizeToUnit(values: number[]): number[] {
71
+ if (values.length === 0) return [];
72
+ const min = Math.min(...values);
73
+ const max = Math.max(...values);
74
+ if (max === min) {
75
+ return values.map(() => 0.5);
76
+ }
77
+ return values.map(value => (value - min) / (max - min));
78
+ }
79
+
80
+ function calculateLexicalScore(filePath: string, fileContent: string, terms: string[]): number {
81
+ if (terms.length === 0) return 0.5;
82
+
83
+ const pathTokens = splitIdentifierTokens(filePath);
84
+ const contentTokens = splitIdentifierTokens(fileContent);
85
+
86
+ let raw = 0;
87
+ for (const term of terms) {
88
+ const pathMatches = pathTokens.filter(token => token.includes(term)).length;
89
+ const contentMatches = contentTokens.filter(token => token === term).length;
90
+
91
+ if (pathMatches > 0) {
92
+ raw += Math.min(pathMatches * 2, 6);
93
+ }
94
+
95
+ if (contentMatches > 0) {
96
+ raw += Math.min(Math.sqrt(contentMatches), 4);
97
+ }
98
+ }
99
+
100
+ return Math.min(raw / (terms.length * 5), 1);
101
+ }
102
+
103
+ function calculateStructuralScore(
104
+ filePath: string,
105
+ question: string,
106
+ metadata: FileMetadata | null,
107
+ terms: string[]
108
+ ): number {
109
+ if (!metadata) return 0;
110
+
111
+ let score = 0;
112
+ const q = question.toLowerCase();
113
+
114
+ const exportHits = metadata.exports.filter(exp =>
115
+ terms.some(term => exp.toLowerCase().includes(term))
116
+ ).length;
117
+ score += Math.min(exportHits * 0.2, 0.5);
118
+
119
+ const importHits = metadata.imports.filter(imp =>
120
+ terms.some(term => imp.toLowerCase().includes(term))
121
+ ).length;
122
+ score += Math.min(importHits * 0.05, 0.2);
123
+
124
+ const density = metadata.linesOfCode > 0
125
+ ? metadata.symbolCount / Math.max(metadata.linesOfCode / 100, 1)
126
+ : 0;
127
+ score += Math.min(density * 0.05, 0.2);
128
+
129
+ if ((q.includes('where') || q.includes('entry') || q.includes('flow')) && filePath.includes('index')) {
130
+ score += 0.1;
131
+ }
132
+ if ((q.includes('permission') || q.includes('auth')) && filePath.includes('auth')) {
133
+ score += 0.15;
134
+ }
135
+ if ((q.includes('message') || q.includes('send')) && (filePath.includes('message') || filePath.includes('room'))) {
136
+ score += 0.15;
137
+ }
138
+
139
+ return Math.min(score, 1);
140
+ }
141
+
142
+ function calculateCrossRerankScore(filePath: string, content: string, question: string, terms: string[]): number {
143
+ const q = question.toLowerCase();
144
+ const contentLower = content.toLowerCase();
145
+ const pathLower = filePath.toLowerCase();
146
+
147
+ let score = 0;
148
+ for (const term of terms) {
149
+ if (pathLower.includes(term)) {
150
+ score += 0.1;
151
+ }
152
+ const termRegex = new RegExp(`\\b${term}\\b`, 'g');
153
+ const occurrences = (contentLower.match(termRegex) || []).length;
154
+ if (occurrences > 0) {
155
+ score += Math.min(occurrences * 0.03, 0.2);
156
+ }
157
+ }
158
+
159
+ // Phrase and intent matches get a strong rerank boost.
160
+ if (contentLower.includes(q) || pathLower.includes(q)) {
161
+ score += 0.25;
162
+ }
163
+
164
+ const hasVerbIntent = /(how|where|why|send|check|validate|authorize|upload)/.test(q);
165
+ if (hasVerbIntent && /(function|class|return|export)/.test(contentLower)) {
166
+ score += 0.1;
167
+ }
168
+
169
+ return Math.min(score, 1);
170
+ }
171
+
172
+ /**
173
+ * Enhanced relevance scoring with multiple factors
174
+ */
175
+ export function calculateEnhancedScore(
176
+ filePath: string,
177
+ fileContent: string | null,
178
+ question: string,
179
+ metadata?: FileMetadata
180
+ ): { score: number; reasons: string[] } {
181
+ if (!fileContent) {
182
+ return { score: 0, reasons: [] };
183
+ }
184
+
185
+ const reasons: string[] = [];
186
+ const questionLower = question.toLowerCase();
187
+ const questionWords = questionLower
188
+ .split(/\s+/)
189
+ .filter(word => word.length > 2);
190
+
191
+ if (questionWords.length === 0) {
192
+ return { score: 0.5, reasons: ['No meaningful keywords'] };
193
+ }
194
+
195
+ const fileName = path.basename(filePath, path.extname(filePath)).toLowerCase();
196
+ const pathComponents = filePath.toLowerCase().split(path.sep);
197
+ const contentLower = fileContent.toLowerCase();
198
+
199
+ let score = 0;
200
+ const weights = {
201
+ filenameExact: 10,
202
+ filenamePartial: 5,
203
+ pathComponent: 3,
204
+ contentFrequency: 2,
205
+ exportMatch: 4,
206
+ symbolDensity: 2,
207
+ fileSize: 1,
208
+ };
209
+
210
+ // 1. Filename matching (highest priority)
211
+ for (const word of questionWords) {
212
+ if (fileName === word) {
213
+ score += weights.filenameExact;
214
+ reasons.push(`Exact filename match: "${word}"`);
215
+ } else if (fileName.includes(word)) {
216
+ score += weights.filenamePartial;
217
+ reasons.push(`Filename contains: "${word}"`);
218
+ }
219
+ }
220
+
221
+ // 2. Path component matching
222
+ for (const word of questionWords) {
223
+ const matchingComponents = pathComponents.filter(comp => comp.includes(word));
224
+ if (matchingComponents.length > 0) {
225
+ score += weights.pathComponent * matchingComponents.length;
226
+ reasons.push(`Path contains: "${word}" (${matchingComponents.length} times)`);
227
+ }
228
+ }
229
+
230
+ // 3. Content frequency analysis
231
+ for (const word of questionWords) {
232
+ const regex = new RegExp(`\\b${word}\\b`, 'gi');
233
+ const matches = contentLower.match(regex) || [];
234
+ const frequency = matches.length;
235
+
236
+ if (frequency > 0) {
237
+ // Logarithmic scaling to prevent over-weighting very common words
238
+ const contentScore = Math.min(Math.log2(frequency + 1) * weights.contentFrequency, 10);
239
+ score += contentScore;
240
+
241
+ if (frequency > 5) {
242
+ reasons.push(`High frequency of "${word}": ${frequency} occurrences`);
243
+ } else if (frequency > 0) {
244
+ reasons.push(`Contains "${word}": ${frequency} times`);
245
+ }
246
+ }
247
+ }
248
+
249
+ // 4. Export matching (if file exports symbols matching question)
250
+ if (metadata?.exports) {
251
+ for (const word of questionWords) {
252
+ const matchingExports = metadata.exports.filter(exp =>
253
+ exp.toLowerCase().includes(word)
254
+ );
255
+ if (matchingExports.length > 0) {
256
+ score += weights.exportMatch * matchingExports.length;
257
+ reasons.push(`Exports symbols matching "${word}": ${matchingExports.join(', ')}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ // 5. Symbol density (files with more symbols are potentially more relevant)
263
+ if (metadata?.symbolCount && metadata?.linesOfCode) {
264
+ const density = metadata.symbolCount / (metadata.linesOfCode / 100);
265
+ if (density > 1) {
266
+ score += Math.min(density * weights.symbolDensity, 5);
267
+ reasons.push(`High symbol density: ${density.toFixed(1)} symbols per 100 LOC`);
268
+ }
269
+ }
270
+
271
+ // 6. File size penalty (very large or very small files are less likely to be relevant)
272
+ if (metadata?.linesOfCode) {
273
+ const loc = metadata.linesOfCode;
274
+ if (loc > 50 && loc < 500) {
275
+ score += weights.fileSize;
276
+ reasons.push('Optimal file size');
277
+ } else if (loc >= 500) {
278
+ score -= 1;
279
+ reasons.push('Large file - may need refinement');
280
+ }
281
+ }
282
+
283
+ // Normalize score to 0-1 range
284
+ const maxPossibleScore = questionWords.length *
285
+ (weights.filenameExact + weights.pathComponent + 10); // Approximate max
286
+ const normalizedScore = Math.min(score / maxPossibleScore, 1);
287
+
288
+ return { score: normalizedScore, reasons };
289
+ }
290
+
291
+ /**
292
+ * Extract file metadata using AST analysis
293
+ */
294
+ export function extractFileMetadata(filePath: string): FileMetadata | null {
295
+ try {
296
+ const content = readFileContent(filePath);
297
+ if (!content) return null;
298
+ const adapter = getAdapter(filePath);
299
+ const symbols = adapter.extractSymbols(content);
300
+ const imports = adapter.extractImports(content);
301
+ const exports = symbols.filter((symbol) => symbol.kind === 'export').map((symbol) => symbol.name);
302
+ const symbolCount = symbols.filter((symbol) => symbol.kind !== 'export').length;
303
+ const linesOfCode = content.split('\n').length;
304
+
305
+ return {
306
+ imports,
307
+ exports,
308
+ symbolCount,
309
+ linesOfCode,
310
+ };
311
+ } catch (error) {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Analyze file dependencies to boost scores of dependent files
318
+ */
319
+ export function analyzeDependencyGraph(
320
+ filePaths: string[],
321
+ rootPath: string
322
+ ): Map<string, string[]> {
323
+ const dependencyMap = new Map<string, string[]>();
324
+
325
+ for (const filePath of filePaths) {
326
+ try {
327
+ const content = readFileContent(filePath);
328
+ if (!content) continue;
329
+
330
+ const dependencies: string[] = [];
331
+ const importRegex = /(?:import\s+(?:{[^}]*}|[^;]+)\s+from\s+['"]([^'"]+)['"]|from\s+([A-Za-z0-9_./-]+)\s+import|import\s+([A-Za-z0-9_.,\s]+))/g;
332
+ let match;
333
+
334
+ while ((match = importRegex.exec(content)) !== null) {
335
+ const importPath = (match[1] || match[2] || match[3]?.split(',')[0]?.trim() || '').trim();
336
+ if (!importPath) {
337
+ continue;
338
+ }
339
+
340
+ // Resolve relative imports
341
+ if (importPath.startsWith('.')) {
342
+ const resolvedPath = path.resolve(path.dirname(filePath), importPath);
343
+ const relativePath = path.relative(rootPath, resolvedPath);
344
+ dependencies.push(relativePath);
345
+ } else {
346
+ dependencies.push(importPath);
347
+ }
348
+ }
349
+
350
+ const key = path.relative(rootPath, filePath);
351
+ dependencyMap.set(key, dependencies);
352
+ } catch (error) {
353
+ continue;
354
+ }
355
+ }
356
+
357
+ return dependencyMap;
358
+ }
359
+
360
+ /**
361
+ * Enhanced ranking with dependency analysis
362
+ */
363
+ export function rankFilesEnhanced(
364
+ filePaths: string[],
365
+ question: string,
366
+ limit: number = 10,
367
+ rootPath?: string
368
+ ): EnhancedRankedFile[] {
369
+ const resolvedRoot = rootPath || process.cwd();
370
+ const expandedTerms = expandQueryTerms(question);
371
+ const records: PreRankRecord[] = [];
372
+
373
+ // First pass: collect raw component scores.
374
+ for (const filePath of filePaths) {
375
+ const content = readFileContent(filePath) || '';
376
+ const metadata = extractFileMetadata(filePath);
377
+ const enhancedScore = calculateEnhancedScore(filePath, content, question, metadata || undefined);
378
+ const lexicalScore = calculateLexicalScore(filePath, content, expandedTerms);
379
+ const structuralScore = calculateStructuralScore(filePath, question, metadata, expandedTerms);
380
+ const reasons = [...enhancedScore.reasons];
381
+
382
+ const baseScore = (
383
+ lexicalScore * 0.5 +
384
+ enhancedScore.score * 0.3 +
385
+ structuralScore * 0.2
386
+ );
387
+
388
+ records.push({
389
+ file: {
390
+ path: filePath,
391
+ score: 0,
392
+ reasons,
393
+ symbolCount: metadata?.symbolCount,
394
+ dependencies: metadata?.imports,
395
+ },
396
+ metadata,
397
+ content,
398
+ baseScore,
399
+ });
400
+ }
401
+
402
+ if (records.length === 0) {
403
+ return [];
404
+ }
405
+
406
+ // Second pass: dependency-aware score contribution.
407
+ const dependencyMap = analyzeDependencyGraph(filePaths, resolvedRoot);
408
+ const dependencyScores: number[] = [];
409
+
410
+ for (const record of records) {
411
+ const relativePath = path.relative(resolvedRoot, record.file.path);
412
+ let dependencyBoost = 0;
413
+
414
+ for (const otherRecord of records) {
415
+ if (otherRecord.file.path === record.file.path) continue;
416
+ const otherRelPath = path.relative(resolvedRoot, otherRecord.file.path);
417
+ const otherDeps = dependencyMap.get(otherRelPath) || [];
418
+ if (otherDeps.some(dep => dep.includes(path.parse(relativePath).name))) {
419
+ dependencyBoost += Math.max(otherRecord.baseScore, 0) * 0.15;
420
+ }
421
+ }
422
+
423
+ dependencyScores.push(dependencyBoost);
424
+ }
425
+
426
+ const normalizedDependencyScores = normalizeToUnit(dependencyScores);
427
+
428
+ // Third pass: normalize and combine hybrid components.
429
+ const normalizedBaseScores = normalizeToUnit(records.map(record => record.baseScore));
430
+ records.forEach((record, index) => {
431
+ const combined = normalizedBaseScores[index] * 0.85 + normalizedDependencyScores[index] * 0.15;
432
+ record.file.score = combined;
433
+
434
+ if (normalizedDependencyScores[index] > 0.2) {
435
+ record.file.reasons.push(`Dependency centrality boost: ${(normalizedDependencyScores[index] * 100).toFixed(0)}%`);
436
+ }
437
+ });
438
+
439
+ // Fourth pass: cross-rerank on top candidates.
440
+ records.sort((a, b) => b.file.score - a.file.score || a.file.path.localeCompare(b.file.path));
441
+ const rerankWindow = Math.min(records.length, Math.max(limit * 4, 20));
442
+ const topRecords = records.slice(0, rerankWindow);
443
+ for (const record of topRecords) {
444
+ const rerank = calculateCrossRerankScore(record.file.path, record.content, question, expandedTerms);
445
+ record.file.score = Math.min(record.file.score * 0.75 + rerank * 0.25, 1);
446
+ if (rerank > 0.2) {
447
+ record.file.reasons.push(`Cross-rerank relevance: ${(rerank * 100).toFixed(0)}%`);
448
+ }
449
+ }
450
+
451
+ // Sort by score descending
452
+ records.sort((a, b) => {
453
+ if (b.file.score !== a.file.score) {
454
+ return b.file.score - a.file.score;
455
+ }
456
+ return a.file.path.localeCompare(b.file.path);
457
+ });
458
+
459
+ return records.slice(0, limit).map(record => record.file);
460
+ }
package/src/ranker.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * File ranking based on relevance to a question
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import { readFileContent } from './scanner';
7
+ import { RankedFile } from './types';
8
+
9
+ /**
10
+ * Simple keyword-based relevance scoring
11
+ * Returns a score between 0 and 1
12
+ */
13
+ export function calculateRelevanceScore(
14
+ filePath: string,
15
+ fileContent: string | null,
16
+ question: string
17
+ ): number {
18
+ if (!fileContent) {
19
+ return 0;
20
+ }
21
+
22
+ const questionLower = question.toLowerCase();
23
+ const questionWords = questionLower
24
+ .split(/\s+/)
25
+ .filter(word => word.length > 2); // Ignore very short words
26
+
27
+ if (questionWords.length === 0) {
28
+ return 0.5; // Neutral score if no meaningful words
29
+ }
30
+
31
+ // Extract filename and path components
32
+ const fileName = path.basename(filePath, path.extname(filePath)).toLowerCase();
33
+ const pathComponents = filePath.toLowerCase().split(path.sep);
34
+ const contentLower = fileContent.toLowerCase();
35
+
36
+ let score = 0;
37
+ let maxScore = 0;
38
+
39
+ for (const word of questionWords) {
40
+ maxScore += 10; // Max points per word
41
+
42
+ // Filename match (highest weight)
43
+ if (fileName.includes(word)) {
44
+ score += 5;
45
+ }
46
+
47
+ // Path component match (medium weight)
48
+ if (pathComponents.some(component => component.includes(word))) {
49
+ score += 3;
50
+ }
51
+
52
+ // Content match (lower weight, but counted)
53
+ const contentMatches = (contentLower.match(new RegExp(word, 'g')) || []).length;
54
+ // Cap content matches contribution
55
+ score += Math.min(contentMatches * 0.1, 2);
56
+ }
57
+
58
+ // Normalize to 0-1 range
59
+ return maxScore > 0 ? Math.min(score / maxScore, 1) : 0;
60
+ }
61
+
62
+ /**
63
+ * Rank files by relevance to the question
64
+ */
65
+ export function rankFiles(
66
+ filePaths: string[],
67
+ question: string,
68
+ limit: number = 10
69
+ ): RankedFile[] {
70
+ const rankedFiles: RankedFile[] = [];
71
+
72
+ for (const filePath of filePaths) {
73
+ const content = readFileContent(filePath);
74
+ const score = calculateRelevanceScore(filePath, content, question);
75
+
76
+ rankedFiles.push({
77
+ path: filePath,
78
+ score,
79
+ });
80
+ }
81
+
82
+ // Sort by score descending, then by path alphabetically
83
+ rankedFiles.sort((a, b) => {
84
+ if (b.score !== a.score) {
85
+ return b.score - a.score;
86
+ }
87
+ return a.path.localeCompare(b.path);
88
+ });
89
+
90
+ // Return top N files
91
+ return rankedFiles.slice(0, limit);
92
+ }