token-pilot 0.13.0 → 0.14.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 (69) hide show
  1. package/.claude-plugin/hooks/hooks.json +9 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +29 -0
  5. package/README.md +28 -7
  6. package/dist/config/defaults.js +12 -0
  7. package/dist/core/architecture-fingerprint.d.ts +34 -0
  8. package/dist/core/architecture-fingerprint.js +127 -0
  9. package/dist/core/budget-planner.d.ts +21 -0
  10. package/dist/core/budget-planner.js +68 -0
  11. package/dist/core/confidence.d.ts +31 -0
  12. package/dist/core/confidence.js +99 -0
  13. package/dist/core/context-registry.d.ts +14 -0
  14. package/dist/core/context-registry.js +55 -0
  15. package/dist/core/decision-trace.d.ts +31 -0
  16. package/dist/core/decision-trace.js +45 -0
  17. package/dist/core/intent-classifier.d.ts +13 -0
  18. package/dist/core/intent-classifier.js +44 -0
  19. package/dist/core/policy-engine.d.ts +41 -0
  20. package/dist/core/policy-engine.js +76 -0
  21. package/dist/core/session-analytics.d.ts +8 -0
  22. package/dist/core/session-analytics.js +86 -7
  23. package/dist/core/session-cache.d.ts +74 -0
  24. package/dist/core/session-cache.js +162 -0
  25. package/dist/core/validation.d.ts +3 -0
  26. package/dist/core/validation.js +3 -0
  27. package/dist/git/file-watcher.d.ts +6 -0
  28. package/dist/git/file-watcher.js +18 -2
  29. package/dist/git/watcher.d.ts +3 -0
  30. package/dist/git/watcher.js +6 -0
  31. package/dist/handlers/code-audit.d.ts +7 -2
  32. package/dist/handlers/code-audit.js +19 -5
  33. package/dist/handlers/explore-area.d.ts +10 -0
  34. package/dist/handlers/explore-area.js +39 -13
  35. package/dist/handlers/find-unused.d.ts +3 -0
  36. package/dist/handlers/find-unused.js +3 -2
  37. package/dist/handlers/find-usages.d.ts +7 -0
  38. package/dist/handlers/find-usages.js +36 -5
  39. package/dist/handlers/module-info.d.ts +3 -0
  40. package/dist/handlers/module-info.js +22 -2
  41. package/dist/handlers/project-overview.d.ts +1 -1
  42. package/dist/handlers/project-overview.js +18 -2
  43. package/dist/handlers/read-for-edit.d.ts +3 -0
  44. package/dist/handlers/read-for-edit.js +185 -3
  45. package/dist/handlers/read-range.d.ts +1 -1
  46. package/dist/handlers/read-range.js +16 -1
  47. package/dist/handlers/read-symbol.d.ts +1 -1
  48. package/dist/handlers/read-symbol.js +26 -2
  49. package/dist/handlers/related-files.d.ts +11 -0
  50. package/dist/handlers/related-files.js +178 -42
  51. package/dist/handlers/smart-read-many.js +70 -16
  52. package/dist/handlers/smart-read.js +10 -1
  53. package/dist/handlers/test-summary.js +26 -3
  54. package/dist/hooks/installer.d.ts +12 -8
  55. package/dist/hooks/installer.js +24 -8
  56. package/dist/index.d.ts +16 -1
  57. package/dist/index.js +61 -55
  58. package/dist/server.js +395 -30
  59. package/dist/types.d.ts +12 -0
  60. package/package.json +5 -3
  61. package/start.sh +28 -27
  62. package/dist/handlers/class-hierarchy.d.ts +0 -11
  63. package/dist/handlers/class-hierarchy.js +0 -28
  64. package/dist/handlers/export-ast-index.d.ts +0 -22
  65. package/dist/handlers/export-ast-index.js +0 -175
  66. package/dist/handlers/find-implementations.d.ts +0 -11
  67. package/dist/handlers/find-implementations.js +0 -27
  68. package/dist/handlers/search-code.d.ts +0 -14
  69. package/dist/handlers/search-code.js +0 -32
@@ -1,7 +1,12 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { readFile, stat, access } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
2
4
  import { createHash } from 'node:crypto';
5
+ import { relative, join } from 'node:path';
3
6
  import { estimateTokens } from '../core/token-estimator.js';
4
7
  import { resolveSafePath } from '../core/validation.js';
8
+ import { assessConfidence, formatConfidence } from '../core/confidence.js';
9
+ const execFileAsync = promisify(execFile);
5
10
  const DEFAULT_CONTEXT = 5;
6
11
  export async function handleReadForEdit(args, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex) {
7
12
  const absPath = resolveSafePath(projectRoot, args.path);
@@ -91,7 +96,7 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
91
96
  const rangeCount = rangeEnd - rangeStart + 1;
92
97
  // Extract RAW code (no line number prefixes — ready for Edit old_string)
93
98
  const rawCode = lines.slice(rangeStart - 1, rangeEnd).join('\n');
94
- const output = [
99
+ const outputLines = [
95
100
  `--- EDIT CONTEXT ---`,
96
101
  `FILE: ${args.path}`,
97
102
  `TARGET: ${targetLabel}`,
@@ -103,7 +108,54 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
103
108
  '',
104
109
  `To edit: use exact text above as old_string in Edit tool.`,
105
110
  `For Read requirement: Read("${args.path}", offset=${rangeStart}, limit=${rangeCount})`,
106
- ].join('\n');
111
+ ];
112
+ // --- Optional enrichment sections ---
113
+ // include_callers: compact caller list via ast-index refs
114
+ if (args.include_callers && args.symbol && !astIndex.isDisabled()) {
115
+ try {
116
+ const refs = await astIndex.refs(args.symbol, 10);
117
+ const callers = refs.usages.slice(0, 5);
118
+ if (callers.length > 0) {
119
+ outputLines.push('');
120
+ outputLines.push(`CALLERS (${callers.length}):`);
121
+ for (const c of callers) {
122
+ const relPath = relative(projectRoot, c.path);
123
+ const ctx = c.context ? ` — ${c.context.trim().slice(0, 80)}` : '';
124
+ outputLines.push(` ${relPath}:${c.line}${ctx}`);
125
+ }
126
+ }
127
+ else {
128
+ outputLines.push('');
129
+ outputLines.push('CALLERS: none found');
130
+ }
131
+ }
132
+ catch {
133
+ // ast-index unavailable — skip silently
134
+ }
135
+ }
136
+ // include_tests: find related test file and list test names
137
+ if (args.include_tests) {
138
+ const testSection = await findTestSection(absPath, args.path, projectRoot, astIndex);
139
+ outputLines.push('');
140
+ outputLines.push(...testSection);
141
+ }
142
+ // include_changes: git diff filtered to target region
143
+ if (args.include_changes) {
144
+ const diffSection = await findChangesSection(absPath, projectRoot, rangeStart, rangeEnd);
145
+ outputLines.push('');
146
+ outputLines.push(...diffSection);
147
+ }
148
+ // Confidence metadata
149
+ const confidenceMeta = assessConfidence({
150
+ symbolResolved: !!args.symbol && startLine > 0,
151
+ fullFile: false,
152
+ truncated: false,
153
+ hasCallers: args.include_callers ?? false,
154
+ hasTests: args.include_tests ?? false,
155
+ astAvailable: true,
156
+ });
157
+ outputLines.push(formatConfidence(confidenceMeta));
158
+ const output = outputLines.join('\n');
107
159
  const tokens = estimateTokens(output);
108
160
  // Track in context
109
161
  contextRegistry.trackLoad(absPath, {
@@ -115,4 +167,134 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
115
167
  });
116
168
  return { content: [{ type: 'text', text: output }] };
117
169
  }
170
+ // --- Helper: find related test file and extract test names ---
171
+ async function findTestSection(absPath, relPath, projectRoot, astIndex) {
172
+ // Derive test file path from source path using common conventions
173
+ // src/handlers/foo.ts → tests/handlers/foo.test.ts
174
+ // src/core/bar.ts → tests/core/bar.test.ts
175
+ const srcPrefix = 'src/';
176
+ let testRelPath;
177
+ if (relPath.startsWith(srcPrefix)) {
178
+ const rest = relPath.slice(srcPrefix.length);
179
+ const ext = rest.match(/\.[^.]+$/)?.[0] ?? '.ts';
180
+ const base = rest.replace(/\.[^.]+$/, '');
181
+ testRelPath = `tests/${base}.test${ext}`;
182
+ }
183
+ else {
184
+ const ext = relPath.match(/\.[^.]+$/)?.[0] ?? '.ts';
185
+ const base = relPath.replace(/\.[^.]+$/, '');
186
+ testRelPath = `${base}.test${ext}`;
187
+ }
188
+ const testAbsPath = join(projectRoot, testRelPath);
189
+ try {
190
+ await access(testAbsPath);
191
+ }
192
+ catch {
193
+ return [`TESTS: none found (expected at ${testRelPath})`];
194
+ }
195
+ // Test file exists — try to get outline for test names
196
+ const lines = [`TESTS: ${testRelPath}`];
197
+ if (!astIndex.isDisabled()) {
198
+ try {
199
+ const outline = await astIndex.outline(testAbsPath);
200
+ if (outline?.symbols && outline.symbols.length > 0) {
201
+ for (const sym of outline.symbols) {
202
+ lines.push(` ${sym.kind} ${sym.name}`);
203
+ if (sym.children) {
204
+ for (const child of sym.children) {
205
+ lines.push(` ${child.kind} ${child.name}`);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ // outline failed — just show file path
213
+ }
214
+ }
215
+ return lines;
216
+ }
217
+ // --- Helper: git diff filtered to target region ---
218
+ async function findChangesSection(absPath, projectRoot, rangeStart, rangeEnd) {
219
+ const MAX_DIFF_LINES = 30;
220
+ try {
221
+ // Try unstaged changes first
222
+ let diffOutput = '';
223
+ let diffLabel = 'unstaged';
224
+ try {
225
+ const { stdout } = await execFileAsync('git', ['diff', 'HEAD', '--', absPath], {
226
+ cwd: projectRoot,
227
+ timeout: 5000,
228
+ });
229
+ diffOutput = stdout;
230
+ }
231
+ catch {
232
+ // git not available or not a repo
233
+ return ['RECENT CHANGES: unavailable (not a git repo)'];
234
+ }
235
+ // If no unstaged changes, try last commit
236
+ if (!diffOutput.trim()) {
237
+ try {
238
+ const { stdout } = await execFileAsync('git', ['diff', 'HEAD~1', '--', absPath], {
239
+ cwd: projectRoot,
240
+ timeout: 5000,
241
+ });
242
+ diffOutput = stdout;
243
+ diffLabel = 'last commit';
244
+ }
245
+ catch {
246
+ // no previous commit
247
+ }
248
+ }
249
+ if (!diffOutput.trim()) {
250
+ return ['RECENT CHANGES: none (file unchanged)'];
251
+ }
252
+ // Filter hunks to those overlapping with target range
253
+ const relevantLines = filterDiffHunks(diffOutput, rangeStart, rangeEnd);
254
+ if (relevantLines.length === 0) {
255
+ return ['RECENT CHANGES: none in target region'];
256
+ }
257
+ const lines = [`RECENT CHANGES (${diffLabel}):`];
258
+ const trimmed = relevantLines.slice(0, MAX_DIFF_LINES);
259
+ for (const line of trimmed) {
260
+ lines.push(` ${line}`);
261
+ }
262
+ if (relevantLines.length > MAX_DIFF_LINES) {
263
+ lines.push(` ... ${relevantLines.length - MAX_DIFF_LINES} more lines`);
264
+ }
265
+ return lines;
266
+ }
267
+ catch {
268
+ return ['RECENT CHANGES: unavailable'];
269
+ }
270
+ }
271
+ /** Filter diff output to only hunks overlapping [rangeStart, rangeEnd]. */
272
+ function filterDiffHunks(diff, rangeStart, rangeEnd) {
273
+ const allLines = diff.split('\n');
274
+ const result = [];
275
+ let inRelevantHunk = false;
276
+ for (const line of allLines) {
277
+ // Hunk header: @@ -a,b +c,d @@
278
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
279
+ if (hunkMatch) {
280
+ const hunkStart = parseInt(hunkMatch[1], 10);
281
+ const hunkLen = parseInt(hunkMatch[2] ?? '1', 10);
282
+ const hunkEnd = hunkStart + hunkLen - 1;
283
+ // Check overlap with target range
284
+ inRelevantHunk = hunkStart <= rangeEnd && hunkEnd >= rangeStart;
285
+ if (inRelevantHunk) {
286
+ result.push(line);
287
+ }
288
+ continue;
289
+ }
290
+ // Skip diff metadata lines (diff --git, index, ---, +++)
291
+ if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
292
+ continue;
293
+ }
294
+ if (inRelevantHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
295
+ result.push(line);
296
+ }
297
+ }
298
+ return result;
299
+ }
118
300
  //# sourceMappingURL=read-for-edit.js.map
@@ -5,7 +5,7 @@ export interface ReadRangeArgs {
5
5
  start_line: number;
6
6
  end_line: number;
7
7
  }
8
- export declare function handleReadRange(args: ReadRangeArgs, projectRoot: string, fileCache: FileCache, contextRegistry: ContextRegistry): Promise<{
8
+ export declare function handleReadRange(args: ReadRangeArgs, projectRoot: string, fileCache: FileCache, contextRegistry: ContextRegistry, advisoryReminders?: boolean): Promise<{
9
9
  content: Array<{
10
10
  type: 'text';
11
11
  text: string;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { estimateTokens } from '../core/token-estimator.js';
3
3
  import { resolveSafePath } from '../core/validation.js';
4
- export async function handleReadRange(args, projectRoot, fileCache, contextRegistry) {
4
+ export async function handleReadRange(args, projectRoot, fileCache, contextRegistry, advisoryReminders = true) {
5
5
  const absPath = resolveSafePath(projectRoot, args.path);
6
6
  // Get lines
7
7
  const cached = fileCache.get(absPath);
@@ -13,6 +13,18 @@ export async function handleReadRange(args, projectRoot, fileCache, contextRegis
13
13
  const content = await readFile(absPath, 'utf-8');
14
14
  lines = content.split('\n');
15
15
  }
16
+ // Dedup: check if full file is already in context and unchanged
17
+ if (advisoryReminders) {
18
+ const hash = cached?.hash;
19
+ if (hash && !contextRegistry.isStale(absPath, hash)) {
20
+ if (contextRegistry.isFullyLoaded(absPath)) {
21
+ const reminder = contextRegistry.rangeReminder(absPath, args.start_line, args.end_line);
22
+ if (reminder) {
23
+ return { content: [{ type: 'text', text: reminder }] };
24
+ }
25
+ }
26
+ }
27
+ }
16
28
  const start = Math.max(0, args.start_line - 1);
17
29
  const end = Math.min(lines.length, args.end_line);
18
30
  if (start >= lines.length || start >= end) {
@@ -39,6 +51,9 @@ export async function handleReadRange(args, projectRoot, fileCache, contextRegis
39
51
  endLine: args.end_line,
40
52
  tokens,
41
53
  });
54
+ if (cached?.hash) {
55
+ contextRegistry.setContentHash(absPath, cached.hash);
56
+ }
42
57
  return { content: [{ type: 'text', text: output }] };
43
58
  }
44
59
  //# sourceMappingURL=read-range.js.map
@@ -9,7 +9,7 @@ export interface ReadSymbolArgs {
9
9
  context_after?: number;
10
10
  show?: 'full' | 'head' | 'tail' | 'outline';
11
11
  }
12
- export declare function handleReadSymbol(args: ReadSymbolArgs, projectRoot: string, symbolResolver: SymbolResolver, fileCache: FileCache, contextRegistry: ContextRegistry, astIndex?: AstIndexClient): Promise<{
12
+ export declare function handleReadSymbol(args: ReadSymbolArgs, projectRoot: string, symbolResolver: SymbolResolver, fileCache: FileCache, contextRegistry: ContextRegistry, astIndex?: AstIndexClient, advisoryReminders?: boolean): Promise<{
13
13
  content: Array<{
14
14
  type: 'text';
15
15
  text: string;
@@ -1,7 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { estimateTokens } from '../core/token-estimator.js';
3
3
  import { resolveSafePath } from '../core/validation.js';
4
- export async function handleReadSymbol(args, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex) {
4
+ import { assessConfidence, formatConfidence } from '../core/confidence.js';
5
+ export async function handleReadSymbol(args, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex, advisoryReminders = true) {
5
6
  const absPath = resolveSafePath(projectRoot, args.path);
6
7
  // Get file content
7
8
  const cached = fileCache.get(absPath);
@@ -13,6 +14,18 @@ export async function handleReadSymbol(args, projectRoot, symbolResolver, fileCa
13
14
  const content = await readFile(absPath, 'utf-8');
14
15
  lines = content.split('\n');
15
16
  }
17
+ // Dedup: check if content already in context and unchanged
18
+ if (advisoryReminders) {
19
+ const hash = cached?.hash;
20
+ if (hash && !contextRegistry.isStale(absPath, hash)) {
21
+ if (contextRegistry.isFullyLoaded(absPath) || contextRegistry.isSymbolLoaded(absPath, args.symbol)) {
22
+ const reminder = contextRegistry.symbolReminder(absPath, args.symbol);
23
+ if (reminder) {
24
+ return { content: [{ type: 'text', text: reminder }] };
25
+ }
26
+ }
27
+ }
28
+ }
16
29
  // Resolve symbol — auto-fetch structure if not cached
17
30
  let structure = cached?.structure;
18
31
  if (!structure && astIndex) {
@@ -116,6 +129,17 @@ export async function handleReadSymbol(args, projectRoot, symbolResolver, fileCa
116
129
  endLine: resolved.endLine,
117
130
  tokens,
118
131
  });
119
- return { content: [{ type: 'text', text: output }] };
132
+ if (cached?.hash) {
133
+ contextRegistry.setContentHash(absPath, cached.hash);
134
+ }
135
+ // Confidence metadata
136
+ const confidenceMeta = assessConfidence({
137
+ symbolResolved: true,
138
+ truncated,
139
+ fullFile: false,
140
+ hasCallers: resolved.symbol.references.length > 0,
141
+ astAvailable: !!structure,
142
+ });
143
+ return { content: [{ type: 'text', text: output + formatConfidence(confidenceMeta) }] };
120
144
  }
121
145
  //# sourceMappingURL=read-symbol.js.map
@@ -2,10 +2,21 @@ import type { AstIndexClient } from '../ast-index/client.js';
2
2
  export interface RelatedFilesArgs {
3
3
  path: string;
4
4
  }
5
+ export interface RelatedFilesMeta {
6
+ imports: string[];
7
+ importedBy: string[];
8
+ tests: string[];
9
+ ranked: {
10
+ high: string[];
11
+ medium: string[];
12
+ low: string[];
13
+ };
14
+ }
5
15
  export declare function handleRelatedFiles(args: RelatedFilesArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
6
16
  content: Array<{
7
17
  type: 'text';
8
18
  text: string;
9
19
  }>;
20
+ meta: RelatedFilesMeta;
10
21
  }>;
11
22
  //# sourceMappingURL=related-files.d.ts.map
@@ -1,5 +1,9 @@
1
- import { basename, extname } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { basename, dirname, extname, relative, resolve } from 'node:path';
2
5
  import { resolveSafePath } from '../core/validation.js';
6
+ const execFileAsync = promisify(execFile);
3
7
  /**
4
8
  * Language families — files with extensions in the same family are considered related.
5
9
  * This prevents cross-language false positives (e.g. Python files showing as importers of TS).
@@ -32,36 +36,64 @@ const TEST_PATTERNS = [
32
36
  /\/tests?\//,
33
37
  ];
34
38
  export async function handleRelatedFiles(args, projectRoot, astIndex) {
39
+ const emptyMeta = { imports: [], importedBy: [], tests: [], ranked: { high: [], medium: [], low: [] } };
35
40
  if (astIndex.isDisabled() || astIndex.isOversized()) {
36
- return { content: [{ type: 'text', text: 'related_files is disabled: ' + (astIndex.isDisabled()
41
+ return {
42
+ content: [{
43
+ type: 'text',
44
+ text: 'related_files is disabled: ' + (astIndex.isDisabled()
37
45
  ? 'project root not detected. Call smart_read() on any project file first — this auto-detects the project root and enables ast-index tools.'
38
- : 'ast-index built >50k files (likely includes node_modules). Ensure node_modules is in .gitignore.') +
39
- '\nAlternative: use smart_read() to see file imports in the outline.' }] };
46
+ : 'ast-index built >50k files (likely includes node_modules). Ensure node_modules is in .gitignore.')
47
+ + '\nAlternative: use smart_read() to see file imports in the outline.',
48
+ }],
49
+ meta: emptyMeta,
50
+ };
40
51
  }
41
52
  const absPath = resolveSafePath(projectRoot, args.path);
42
53
  const fileName = basename(absPath);
43
54
  const fileBase = fileName.replace(/\.\w+$/, '');
44
- const sections = [`RELATED FILES: ${args.path}`, ''];
45
- // 1. Forward imports (what this file imports)
55
+ const fileDir = dirname(absPath);
56
+ // Scoring map: relPath RankedFile
57
+ const fileScores = new Map();
58
+ function addScore(relPath, points, tag) {
59
+ const existing = fileScores.get(relPath);
60
+ if (existing) {
61
+ existing.score += points;
62
+ if (!existing.tags.includes(tag))
63
+ existing.tags.push(tag);
64
+ }
65
+ else {
66
+ fileScores.set(relPath, { relPath, score: points, tags: [tag] });
67
+ }
68
+ }
69
+ // Track original categories for backwards-compatible meta
70
+ const importPaths = new Set();
71
+ const importedByPaths = [];
72
+ const testPaths = [];
73
+ // 1. Forward imports (what this file imports) → +4 per file
46
74
  try {
47
75
  const imports = await astIndex.fileImports(absPath);
48
76
  if (imports && imports.length > 0) {
49
- sections.push('IMPORTS (this file uses):');
50
77
  for (const imp of imports) {
51
- const specStr = imp.specifiers?.length ? imp.specifiers.join(', ') : '*';
52
- sections.push(` → ${imp.source} (${specStr})`);
78
+ const resolvedImport = resolveImportPath(absPath, imp.source, projectRoot);
79
+ if (resolvedImport) {
80
+ const relPath = relative(projectRoot, resolvedImport);
81
+ importPaths.add(relPath);
82
+ addScore(relPath, 4, 'import');
83
+ // Same directory bonus
84
+ if (dirname(resolvedImport) === fileDir) {
85
+ addScore(relPath, 2, 'same-dir');
86
+ }
87
+ }
53
88
  }
54
- sections.push('');
55
89
  }
56
90
  }
57
91
  catch {
58
92
  // fileImports not available — skip silently
59
93
  }
60
- // 2. Reverse imports (what imports this file)
61
- const importedBy = [];
94
+ // 2. Reverse imports (what imports this file) → +3 per file, +1 per extra ref
62
95
  const sourceLang = getLangFamily(absPath);
63
96
  try {
64
- // Get structure to find exported symbol names
65
97
  const structure = await astIndex.outline(absPath);
66
98
  const exportNames = [];
67
99
  if (structure) {
@@ -71,85 +103,189 @@ export async function handleRelatedFiles(args, projectRoot, astIndex) {
71
103
  break;
72
104
  }
73
105
  }
74
- // Also try the file base name as a symbol
75
106
  if (!exportNames.includes(fileBase)) {
76
107
  exportNames.push(fileBase);
77
108
  }
78
- // Search refs for each exported symbol (check imports + usages)
79
109
  const seenFiles = new Set();
80
110
  seenFiles.add(absPath);
111
+ // Track ref count per file for multi-ref bonus
112
+ const refCounts = new Map();
81
113
  for (const name of exportNames) {
82
114
  try {
83
115
  const refs = await astIndex.refs(name, 30);
84
- // Check both imports and usages — imports catch direct `import X from`,
85
- // usages catch re-exports, function calls, type references from other files
86
116
  const refEntries = [
87
117
  ...(refs?.imports ?? []),
88
118
  ...(refs?.usages ?? []),
89
119
  ];
90
120
  for (const ref of refEntries) {
91
121
  const refPath = ref.path;
92
- if (!refPath || seenFiles.has(refPath))
122
+ if (!refPath || seenFiles.has(refPath)) {
123
+ // Still count extra refs for already-seen files
124
+ if (refPath && refPath !== absPath) {
125
+ const rp = relative(projectRoot, refPath);
126
+ refCounts.set(rp, (refCounts.get(rp) ?? 0) + 1);
127
+ }
93
128
  continue;
94
- // Filter cross-language false positives:
95
- // only include files from the same language family
129
+ }
96
130
  if (sourceLang) {
97
131
  const refLang = getLangFamily(refPath);
98
132
  if (refLang && refLang !== sourceLang)
99
133
  continue;
100
134
  }
101
135
  seenFiles.add(refPath);
102
- importedBy.push(refPath);
136
+ const relPath = relative(projectRoot, refPath);
137
+ importedByPaths.push(relPath);
138
+ refCounts.set(relPath, (refCounts.get(relPath) ?? 0) + 1);
139
+ addScore(relPath, 3, 'importer');
140
+ // Same directory bonus
141
+ if (dirname(refPath) === fileDir) {
142
+ addScore(relPath, 2, 'same-dir');
143
+ }
103
144
  }
104
145
  }
105
146
  catch {
106
147
  // skip symbol
107
148
  }
108
149
  }
109
- if (importedBy.length > 0) {
110
- sections.push('IMPORTED BY (uses this file):');
111
- for (const p of importedBy) {
112
- sections.push(` → ${p}`);
150
+ // Apply multi-ref bonus: +1 per extra ref beyond the first
151
+ for (const [relPath, count] of refCounts) {
152
+ if (count > 1) {
153
+ addScore(relPath, count - 1, 'multi-ref');
113
154
  }
114
- sections.push('');
115
155
  }
116
156
  }
117
157
  catch {
118
158
  // refs not available — skip silently
119
159
  }
120
- // 3. Test files
160
+ // 3. Test files → +5 per file
121
161
  try {
122
162
  const allFiles = await astIndex.listFiles();
123
- const testFiles = [];
124
163
  if (allFiles && allFiles.length > 0) {
125
164
  for (const f of allFiles) {
126
- // Match test files for this module
127
165
  const fBase = basename(f);
128
166
  if (fBase.includes(fileBase) && TEST_PATTERNS.some(p => p.test(f))) {
129
- testFiles.push(f);
167
+ const relPath = relative(projectRoot, f);
168
+ testPaths.push(relPath);
169
+ addScore(relPath, 5, 'test');
130
170
  }
131
171
  }
132
172
  }
133
- if (testFiles.length > 0) {
134
- sections.push('TESTS:');
135
- for (const t of testFiles) {
136
- sections.push(` → ${t}`);
137
- }
138
- sections.push('');
139
- }
140
173
  }
141
174
  catch {
142
175
  // listFiles not available — skip silently
143
176
  }
144
- // 4. Check if we found anything useful
145
- if (sections.length <= 2) {
177
+ // 4. Recently changed files +2 boost
178
+ const changedFiles = await getRecentlyChangedFiles(projectRoot);
179
+ for (const [, ranked] of fileScores) {
180
+ if (changedFiles.has(ranked.relPath)) {
181
+ addScore(ranked.relPath, 2, 'changed');
182
+ }
183
+ }
184
+ // 5. Sort by score and bucket into high/medium/low
185
+ const allRanked = Array.from(fileScores.values()).sort((a, b) => b.score - a.score);
186
+ const high = [];
187
+ const medium = [];
188
+ const low = [];
189
+ for (const r of allRanked) {
190
+ if (r.score >= 5)
191
+ high.push(r);
192
+ else if (r.score >= 3)
193
+ medium.push(r);
194
+ else
195
+ low.push(r);
196
+ }
197
+ // 6. Build output
198
+ const sections = [`RELATED FILES: ${args.path}`, ''];
199
+ if (high.length > 0) {
200
+ sections.push(`HIGH VALUE (${high.length} file${high.length > 1 ? 's' : ''} — read these first):`);
201
+ for (const r of high) {
202
+ sections.push(` ★ ${r.relPath} [${r.tags.join(', ')}]`);
203
+ }
204
+ sections.push('');
205
+ }
206
+ if (medium.length > 0) {
207
+ sections.push(`MEDIUM (${medium.length} file${medium.length > 1 ? 's' : ''}):`);
208
+ for (const r of medium) {
209
+ sections.push(` · ${r.relPath} [${r.tags.join(', ')}]`);
210
+ }
211
+ sections.push('');
212
+ }
213
+ if (low.length > 0) {
214
+ sections.push(`LOW (${low.length} file${low.length > 1 ? 's' : ''} — read only if needed):`);
215
+ for (const r of low) {
216
+ sections.push(` · ${r.relPath} [${r.tags.join(', ')}]`);
217
+ }
218
+ sections.push('');
219
+ }
220
+ if (allRanked.length === 0) {
146
221
  sections.push('No related files found. AST index may not cover this file.');
147
222
  sections.push('HINT: Use smart_read() to explore the file structure.');
148
223
  }
149
224
  else {
150
- // Suggested reading order
151
- sections.push('HINT: Use smart_read_many(paths=[...]) to read related files at once.');
225
+ const highPaths = high.map(r => `"${r.relPath}"`).join(', ');
226
+ if (high.length > 0) {
227
+ sections.push(`HINT: Use smart_read_many(paths=[${highPaths}]) to read the most relevant files.`);
228
+ }
229
+ else {
230
+ sections.push('HINT: Use smart_read_many(paths=[...]) to read related files at once.');
231
+ }
232
+ }
233
+ return {
234
+ content: [{ type: 'text', text: sections.join('\n') }],
235
+ meta: {
236
+ imports: Array.from(importPaths).sort(),
237
+ importedBy: Array.from(new Set(importedByPaths)).sort(),
238
+ tests: Array.from(new Set(testPaths)).sort(),
239
+ ranked: {
240
+ high: high.map(r => r.relPath),
241
+ medium: medium.map(r => r.relPath),
242
+ low: low.map(r => r.relPath),
243
+ },
244
+ },
245
+ };
246
+ }
247
+ /** Get files changed in the last 5 commits (single git call). */
248
+ async function getRecentlyChangedFiles(projectRoot) {
249
+ try {
250
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD~5'], {
251
+ cwd: projectRoot,
252
+ timeout: 5000,
253
+ });
254
+ const files = stdout.trim().split('\n').filter(Boolean);
255
+ return new Set(files);
256
+ }
257
+ catch {
258
+ // git not available, not a repo, or <5 commits — try smaller range
259
+ try {
260
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD~1'], {
261
+ cwd: projectRoot,
262
+ timeout: 5000,
263
+ });
264
+ const files = stdout.trim().split('\n').filter(Boolean);
265
+ return new Set(files);
266
+ }
267
+ catch {
268
+ return new Set();
269
+ }
270
+ }
271
+ }
272
+ function resolveImportPath(sourceFile, importSource, projectRoot) {
273
+ if (!importSource.startsWith('.') && !importSource.startsWith('/')) {
274
+ return null;
275
+ }
276
+ const basePath = importSource.startsWith('/')
277
+ ? resolve(projectRoot, '.' + importSource)
278
+ : resolve(dirname(sourceFile), importSource);
279
+ const candidates = [
280
+ basePath,
281
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.php', '.go', '.rs', '.java', '.kt', '.swift']
282
+ .flatMap((ext) => [`${basePath}${ext}`, resolve(basePath, `index${ext}`)]),
283
+ ];
284
+ for (const candidate of candidates) {
285
+ if (candidate.startsWith(projectRoot) && existsSync(candidate)) {
286
+ return candidate;
287
+ }
152
288
  }
153
- return { content: [{ type: 'text', text: sections.join('\n') }] };
289
+ return null;
154
290
  }
155
291
  //# sourceMappingURL=related-files.js.map