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.
- package/.claude-plugin/hooks/hooks.json +9 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +28 -7
- package/dist/config/defaults.js +12 -0
- package/dist/core/architecture-fingerprint.d.ts +34 -0
- package/dist/core/architecture-fingerprint.js +127 -0
- package/dist/core/budget-planner.d.ts +21 -0
- package/dist/core/budget-planner.js +68 -0
- package/dist/core/confidence.d.ts +31 -0
- package/dist/core/confidence.js +99 -0
- package/dist/core/context-registry.d.ts +14 -0
- package/dist/core/context-registry.js +55 -0
- package/dist/core/decision-trace.d.ts +31 -0
- package/dist/core/decision-trace.js +45 -0
- package/dist/core/intent-classifier.d.ts +13 -0
- package/dist/core/intent-classifier.js +44 -0
- package/dist/core/policy-engine.d.ts +41 -0
- package/dist/core/policy-engine.js +76 -0
- package/dist/core/session-analytics.d.ts +8 -0
- package/dist/core/session-analytics.js +86 -7
- package/dist/core/session-cache.d.ts +74 -0
- package/dist/core/session-cache.js +162 -0
- package/dist/core/validation.d.ts +3 -0
- package/dist/core/validation.js +3 -0
- package/dist/git/file-watcher.d.ts +6 -0
- package/dist/git/file-watcher.js +18 -2
- package/dist/git/watcher.d.ts +3 -0
- package/dist/git/watcher.js +6 -0
- package/dist/handlers/code-audit.d.ts +7 -2
- package/dist/handlers/code-audit.js +19 -5
- package/dist/handlers/explore-area.d.ts +10 -0
- package/dist/handlers/explore-area.js +39 -13
- package/dist/handlers/find-unused.d.ts +3 -0
- package/dist/handlers/find-unused.js +3 -2
- package/dist/handlers/find-usages.d.ts +7 -0
- package/dist/handlers/find-usages.js +36 -5
- package/dist/handlers/module-info.d.ts +3 -0
- package/dist/handlers/module-info.js +22 -2
- package/dist/handlers/project-overview.d.ts +1 -1
- package/dist/handlers/project-overview.js +18 -2
- package/dist/handlers/read-for-edit.d.ts +3 -0
- package/dist/handlers/read-for-edit.js +185 -3
- package/dist/handlers/read-range.d.ts +1 -1
- package/dist/handlers/read-range.js +16 -1
- package/dist/handlers/read-symbol.d.ts +1 -1
- package/dist/handlers/read-symbol.js +26 -2
- package/dist/handlers/related-files.d.ts +11 -0
- package/dist/handlers/related-files.js +178 -42
- package/dist/handlers/smart-read-many.js +70 -16
- package/dist/handlers/smart-read.js +10 -1
- package/dist/handlers/test-summary.js +26 -3
- package/dist/hooks/installer.d.ts +12 -8
- package/dist/hooks/installer.js +24 -8
- package/dist/index.d.ts +16 -1
- package/dist/index.js +61 -55
- package/dist/server.js +395 -30
- package/dist/types.d.ts +12 -0
- package/package.json +5 -3
- package/start.sh +28 -27
- package/dist/handlers/class-hierarchy.d.ts +0 -11
- package/dist/handlers/class-hierarchy.js +0 -28
- package/dist/handlers/export-ast-index.d.ts +0 -22
- package/dist/handlers/export-ast-index.js +0 -175
- package/dist/handlers/find-implementations.d.ts +0 -11
- package/dist/handlers/find-implementations.js +0 -27
- package/dist/handlers/search-code.d.ts +0 -14
- 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
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
45
|
-
//
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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.
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
289
|
+
return null;
|
|
154
290
|
}
|
|
155
291
|
//# sourceMappingURL=related-files.js.map
|