token-pilot 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/config/defaults.js +5 -0
  3. package/dist/core/context-registry.d.ts +4 -0
  4. package/dist/core/context-registry.js +13 -0
  5. package/dist/core/validation.d.ts +6 -0
  6. package/dist/core/validation.js +25 -2
  7. package/dist/formatters/structure.d.ts +1 -0
  8. package/dist/formatters/structure.js +48 -2
  9. package/dist/handlers/csv-sections.d.ts +35 -0
  10. package/dist/handlers/csv-sections.js +129 -0
  11. package/dist/handlers/find-usages.js +65 -13
  12. package/dist/handlers/json-sections.d.ts +17 -0
  13. package/dist/handlers/json-sections.js +66 -0
  14. package/dist/handlers/markdown-sections.d.ts +15 -0
  15. package/dist/handlers/markdown-sections.js +49 -0
  16. package/dist/handlers/non-code.js +48 -11
  17. package/dist/handlers/read-for-edit.d.ts +4 -1
  18. package/dist/handlers/read-for-edit.js +113 -2
  19. package/dist/handlers/read-section.d.ts +12 -0
  20. package/dist/handlers/read-section.js +96 -0
  21. package/dist/handlers/read-symbol.d.ts +1 -0
  22. package/dist/handlers/read-symbol.js +20 -4
  23. package/dist/handlers/smart-read-many.js +9 -0
  24. package/dist/handlers/smart-read.d.ts +1 -0
  25. package/dist/handlers/smart-read.js +57 -0
  26. package/dist/handlers/symbol-display-constants.d.ts +10 -0
  27. package/dist/handlers/symbol-display-constants.js +10 -0
  28. package/dist/handlers/yaml-sections.d.ts +17 -0
  29. package/dist/handlers/yaml-sections.js +46 -0
  30. package/dist/server/tool-definitions.d.ts +161 -16
  31. package/dist/server/tool-definitions.js +53 -16
  32. package/dist/server.js +19 -2
  33. package/dist/types.d.ts +6 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to Token Pilot will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.18.0] - 2026-04-05
9
+
10
+ ### Added
11
+ - **`read_section` tool** — read a specific section from Markdown, YAML, JSON, or CSV files. Markdown: by heading name. YAML/JSON: by top-level key. CSV: by row range (`rows:1-50`). Much cheaper than reading the whole file.
12
+ - **`read_for_edit` section parameter** — prepare edit context for non-code file sections. Works with all 4 formats.
13
+ - **Markdown outline with line ranges** — `smart_read` on `.md` files now shows `[L5-20]` ranges and hints for `read_section`.
14
+ - **YAML/JSON section ranges** — `smart_read` on `.yaml`/`.json` shows top-level key ranges.
15
+ - **CSV smart_read** — shows columns, row count, sample rows, and hints for row-range reading.
16
+ - **4 section parsers** — `markdown-sections.ts`, `yaml-sections.ts`, `json-sections.ts`, `csv-sections.ts`.
17
+
18
+ ### Changed
19
+ - **20 tools** (was 19) — added `read_section`.
20
+ - **492 tests** (was 441).
21
+
22
+ ### Fixed
23
+ - `npm audit` — resolved brace-expansion, path-to-regexp, picomatch vulnerabilities.
24
+
25
+ ## [0.17.0] - 2026-04-02
26
+
27
+ ### Added
28
+ - **`smart_read` scope parameter** — `scope="nav"` returns names + line ranges only (2-3x smaller), `scope="exports"` shows only public API. Default `scope="full"` unchanged.
29
+ - **`smart_read` auto-delta** — when a file changed since last load (within 120s), shows ADDED/REMOVED/UNCHANGED symbols instead of full re-read. Config: `smartRead.autoDelta.enabled`.
30
+ - **`read_symbol` include_edit_context** — optional `include_edit_context=true` appends raw code block (max 60 lines) to save a separate `read_for_edit` call. Large symbols fall back to `read_for_edit`.
31
+ - **`find_usages` mode=list** — compact `file:line` output for initial discovery, 5-10x smaller than full mode.
32
+ - **`smart_read_many` per-file dedup** — skips files already in context and unchanged, returns compact reminder instead.
33
+ - **Actionable hints** — `read_for_edit` suggests `read_diff` after editing. Config: `display.actionableHints`.
34
+ - **`symbol-display-constants.ts`** — shared display constants for symbol rendering.
35
+
36
+ ### Changed
37
+ - **441 tests** (was 427) — new tests for scope, list mode, include_edit_context, dedup.
38
+ - **MCP instructions** updated with scope/mode/include_edit_context guidance.
39
+ - **find_usages context rendering** — sequential instead of concurrent to prevent shared cache race condition.
40
+
8
41
  ## [0.16.1] - 2026-03-21
9
42
 
10
43
  ### Added
@@ -12,6 +12,10 @@ export const DEFAULT_CONFIG = {
12
12
  smallFileThreshold: 200,
13
13
  showDependencyHints: true,
14
14
  advisoryReminders: true,
15
+ autoDelta: {
16
+ enabled: true,
17
+ maxAgeSec: 120,
18
+ },
15
19
  },
16
20
  git: {
17
21
  watchHead: true,
@@ -33,6 +37,7 @@ export const DEFAULT_CONFIG = {
33
37
  showReferences: false,
34
38
  maxDepth: 2,
35
39
  showTokenSavings: true,
40
+ actionableHints: true,
36
41
  },
37
42
  contextMode: {
38
43
  enabled: 'auto',
@@ -40,6 +40,10 @@ export declare class ContextRegistry {
40
40
  entries: ContextEntry[];
41
41
  };
42
42
  estimateTokens(): number;
43
+ trackStructureSymbols(path: string, symbolNames: string[]): void;
44
+ getSymbolNames(path: string): string[] | undefined;
45
+ /** Get the timestamp when a file was last loaded into context. */
46
+ getLoadedAt(path: string): number | undefined;
43
47
  invalidateByGitDiff(changedFiles: string[]): void;
44
48
  }
45
49
  //# sourceMappingURL=context-registry.d.ts.map
@@ -182,6 +182,19 @@ export class ContextRegistry {
182
182
  }
183
183
  return total;
184
184
  }
185
+ trackStructureSymbols(path, symbolNames) {
186
+ const entry = this.entries.get(path);
187
+ if (entry) {
188
+ entry.symbolNames = symbolNames;
189
+ }
190
+ }
191
+ getSymbolNames(path) {
192
+ return this.entries.get(path)?.symbolNames;
193
+ }
194
+ /** Get the timestamp when a file was last loaded into context. */
195
+ getLoadedAt(path) {
196
+ return this.entries.get(path)?.loadedAt;
197
+ }
185
198
  invalidateByGitDiff(changedFiles) {
186
199
  for (const file of changedFiles) {
187
200
  this.entries.delete(file);
@@ -59,6 +59,7 @@ export interface FindUsagesArgs {
59
59
  limit?: number;
60
60
  lang?: string;
61
61
  context_lines?: number;
62
+ mode?: 'full' | 'list';
62
63
  }
63
64
  export declare function validateFindUsagesArgs(args: unknown): FindUsagesArgs;
64
65
  /**
@@ -79,6 +80,7 @@ export declare function validateReadForEditArgs(args: unknown): {
79
80
  include_callers?: boolean;
80
81
  include_tests?: boolean;
81
82
  include_changes?: boolean;
83
+ section?: string;
82
84
  };
83
85
  /**
84
86
  * Validate related_files arguments.
@@ -154,6 +156,10 @@ export interface TestSummaryArgs {
154
156
  timeout?: number;
155
157
  }
156
158
  export declare function validateTestSummaryArgs(args: unknown): TestSummaryArgs;
159
+ export declare function validateReadSectionArgs(args: unknown): {
160
+ path: string;
161
+ heading: string;
162
+ };
157
163
  /** Detect roots that would cause ast-index to scan the entire filesystem */
158
164
  export declare function isDangerousRoot(root: string): boolean;
159
165
  //# sourceMappingURL=validation.d.ts.map
@@ -160,6 +160,14 @@ export function validateFindUsagesArgs(args) {
160
160
  if (context_lines !== undefined && (context_lines < 0 || context_lines > 10)) {
161
161
  throw new Error('"context_lines" must be between 0 and 10.');
162
162
  }
163
+ let mode;
164
+ if (a.mode !== undefined && a.mode !== null) {
165
+ const validModes = ['full', 'list'];
166
+ if (typeof a.mode !== 'string' || !validModes.includes(a.mode)) {
167
+ throw new Error(`"mode" must be one of: ${validModes.join(', ')}`);
168
+ }
169
+ mode = a.mode;
170
+ }
163
171
  return {
164
172
  symbol: a.symbol,
165
173
  scope: optionalString(a.scope, 'scope'),
@@ -167,6 +175,7 @@ export function validateFindUsagesArgs(args) {
167
175
  limit,
168
176
  lang: optionalString(a.lang, 'lang'),
169
177
  context_lines,
178
+ mode,
170
179
  };
171
180
  }
172
181
  /**
@@ -219,8 +228,8 @@ export function validateReadForEditArgs(args) {
219
228
  if (typeof a.path !== 'string' || a.path.length === 0) {
220
229
  throw new Error('Required parameter "path" must be a non-empty string.');
221
230
  }
222
- if (!a.symbol && !a.line && (!Array.isArray(a.symbols) || a.symbols.length === 0)) {
223
- throw new Error('Either "symbol", "symbols", or "line" must be provided.');
231
+ if (!a.symbol && !a.line && (!Array.isArray(a.symbols) || a.symbols.length === 0) && !a.section) {
232
+ throw new Error('Either "symbol", "symbols", "line", or "section" must be provided.');
224
233
  }
225
234
  // Validate symbols array (batch mode)
226
235
  let symbols;
@@ -247,6 +256,7 @@ export function validateReadForEditArgs(args) {
247
256
  include_callers: optionalBool(a.include_callers, 'include_callers'),
248
257
  include_tests: optionalBool(a.include_tests, 'include_tests'),
249
258
  include_changes: optionalBool(a.include_changes, 'include_changes'),
259
+ section: optionalString(a.section, 'section'),
250
260
  };
251
261
  }
252
262
  /**
@@ -444,6 +454,19 @@ export function validateTestSummaryArgs(args) {
444
454
  }
445
455
  return { command: a.command, runner, timeout };
446
456
  }
457
+ export function validateReadSectionArgs(args) {
458
+ if (!args || typeof args !== 'object') {
459
+ throw new Error('Arguments must be an object.');
460
+ }
461
+ const a = args;
462
+ if (typeof a.path !== 'string' || a.path.length === 0) {
463
+ throw new Error('Required parameter "path" must be a non-empty string.');
464
+ }
465
+ if (typeof a.heading !== 'string' || a.heading.length === 0) {
466
+ throw new Error('Required parameter "heading" must be a non-empty string.');
467
+ }
468
+ return { path: a.path, heading: a.heading };
469
+ }
447
470
  /** Detect roots that would cause ast-index to scan the entire filesystem */
448
471
  export function isDangerousRoot(root) {
449
472
  const normalized = root.replace(/\/+$/, '') || '/';
@@ -5,6 +5,7 @@ export interface FormatOptions {
5
5
  showDependencyHints?: boolean;
6
6
  maxDepth?: number;
7
7
  showTokenSavings?: boolean;
8
+ scope?: 'full' | 'nav' | 'exports';
8
9
  }
9
10
  /**
10
11
  * Format a FileStructure as token-optimized text for LLMs.
@@ -2,13 +2,48 @@
2
2
  * Format a FileStructure as token-optimized text for LLMs.
3
3
  */
4
4
  export function formatOutline(structure, options = {}) {
5
- const { showImports = true, showDocs = true, showDependencyHints = true, maxDepth = 2, } = options;
5
+ const { showImports = true, showDocs = true, showDependencyHints = true, maxDepth = 2, scope = 'full', } = options;
6
6
  const lines = [];
7
7
  // Header
8
8
  const sizeKB = (structure.meta.bytes / 1024).toFixed(1);
9
- lines.push(`FILE: ${structure.path} (${structure.meta.lines} lines, ${sizeKB}KB)`);
9
+ const scopeLabel = scope !== 'full' ? `, ${scope} mode` : '';
10
+ lines.push(`FILE: ${structure.path} (${structure.meta.lines} lines, ${sizeKB}KB${scopeLabel})`);
10
11
  lines.push(`LANGUAGE: ${structure.language}`);
11
12
  lines.push('');
13
+ // NAV scope: compact names + line ranges only
14
+ if (scope === 'nav') {
15
+ lines.push('SYMBOLS:');
16
+ for (const sym of structure.symbols) {
17
+ formatSymbolNav(sym, lines, 1, maxDepth);
18
+ }
19
+ lines.push('');
20
+ lines.push('HINT: Use scope="full" for type signatures and imports. Use read_symbol(path, symbol) to load code.');
21
+ return lines.join('\n');
22
+ }
23
+ // EXPORTS scope: only exported symbols
24
+ if (scope === 'exports') {
25
+ const exportNames = new Set(structure.exports.map(e => e.name));
26
+ const exportedSymbols = structure.symbols.filter(s => exportNames.has(s.name));
27
+ const hiddenCount = structure.symbols.length - exportedSymbols.length;
28
+ lines.push('EXPORTS:');
29
+ for (const exp of structure.exports) {
30
+ const defaultLabel = exp.isDefault ? ' (default)' : '';
31
+ lines.push(` ${exp.kind} ${exp.name}${defaultLabel}`);
32
+ }
33
+ lines.push('');
34
+ lines.push('STRUCTURE:');
35
+ for (const sym of exportedSymbols) {
36
+ formatSymbolTree(sym, lines, 1, maxDepth, showDocs, showDependencyHints);
37
+ }
38
+ lines.push('');
39
+ if (hiddenCount > 0) {
40
+ lines.push(`HINT: ${hiddenCount} non-exported symbol(s) hidden. Use scope="full" to see all symbols.`);
41
+ }
42
+ else {
43
+ lines.push('HINT: Use read_symbol(path, symbol) to load a specific symbol\'s code.');
44
+ }
45
+ return lines.join('\n');
46
+ }
12
47
  // Imports
13
48
  if (showImports && structure.imports.length > 0) {
14
49
  lines.push('IMPORTS:');
@@ -111,6 +146,17 @@ function formatSymbolTree(sym, lines, depth, maxDepth, showDocs, showDeps, paren
111
146
  lines.push(`${indent} (${sym.children.length} members — increase depth to see)`);
112
147
  }
113
148
  }
149
+ function formatSymbolNav(sym, lines, depth, maxDepth) {
150
+ const indent = ' '.repeat(depth);
151
+ const loc = `[L${sym.location.startLine}-${sym.location.endLine}]`;
152
+ const callable = sym.kind === 'function' || sym.kind === 'method' ? '()' : '';
153
+ lines.push(`${indent}${sym.name}${callable} ${loc}`);
154
+ if (depth < maxDepth && sym.children.length > 0) {
155
+ for (const child of sym.children) {
156
+ formatSymbolNav(child, lines, depth + 1, maxDepth);
157
+ }
158
+ }
159
+ }
114
160
  /**
115
161
  * Detect HTTP route decorators and format as "METHOD /path".
116
162
  * Uses standard HTTP verbs — not framework-specific.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CSV parser — column-aware reading with row/column subsetting.
3
+ */
4
+ export interface CsvOutline {
5
+ columns: string[];
6
+ rowCount: number;
7
+ sampleRows: string[][];
8
+ }
9
+ export interface CsvSection {
10
+ heading: string;
11
+ startLine: number;
12
+ endLine: number;
13
+ lineCount: number;
14
+ }
15
+ /**
16
+ * Parse CSV into an outline: columns, row count, sample.
17
+ * Simple parser — handles quoted fields with commas.
18
+ */
19
+ export declare function parseCsvOutline(content: string): CsvOutline;
20
+ /**
21
+ * Parse a row range specification into a CsvSection.
22
+ * Supported formats:
23
+ * "rows:1-50" — row range (1-indexed, refers to data rows, not header)
24
+ * "rows:1-50" with column filter isn't supported at section level
25
+ */
26
+ export declare function parseCsvSectionSpec(heading: string, totalDataRows: number): CsvSection | null;
27
+ /**
28
+ * Extract CSV rows for a section. Returns header + requested rows.
29
+ */
30
+ export declare function extractCsvSectionContent(lines: string[], section: CsvSection): string;
31
+ /**
32
+ * Format CSV outline for smart_read output.
33
+ */
34
+ export declare function formatCsvOutline(filePath: string, outline: CsvOutline, lineCount: number): string;
35
+ //# sourceMappingURL=csv-sections.d.ts.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CSV parser — column-aware reading with row/column subsetting.
3
+ */
4
+ /**
5
+ * Parse CSV into an outline: columns, row count, sample.
6
+ * Simple parser — handles quoted fields with commas.
7
+ */
8
+ export function parseCsvOutline(content) {
9
+ const lines = content.split('\n').filter(l => l.trim());
10
+ if (lines.length === 0)
11
+ return { columns: [], rowCount: 0, sampleRows: [] };
12
+ const columns = parseCsvRow(lines[0]);
13
+ const dataLines = lines.slice(1);
14
+ const sampleRows = dataLines.slice(0, 5).map(parseCsvRow);
15
+ return {
16
+ columns,
17
+ rowCount: dataLines.length,
18
+ sampleRows,
19
+ };
20
+ }
21
+ /**
22
+ * Parse a row range specification into a CsvSection.
23
+ * Supported formats:
24
+ * "rows:1-50" — row range (1-indexed, refers to data rows, not header)
25
+ * "rows:1-50" with column filter isn't supported at section level
26
+ */
27
+ export function parseCsvSectionSpec(heading, totalDataRows) {
28
+ // rows:N-M format
29
+ const rowMatch = heading.match(/^rows?:\s*(\d+)\s*-\s*(\d+)$/i);
30
+ if (rowMatch) {
31
+ const start = Math.max(1, parseInt(rowMatch[1], 10));
32
+ const end = Math.min(totalDataRows, parseInt(rowMatch[2], 10));
33
+ if (start > end || start > totalDataRows)
34
+ return null;
35
+ // +1 for header line offset
36
+ return {
37
+ heading: `rows ${start}-${end}`,
38
+ startLine: start + 1, // +1 because line 1 is header
39
+ endLine: end + 1,
40
+ lineCount: end - start + 1,
41
+ };
42
+ }
43
+ // Single row number
44
+ const singleMatch = heading.match(/^rows?:\s*(\d+)$/i);
45
+ if (singleMatch) {
46
+ const row = parseInt(singleMatch[1], 10);
47
+ if (row < 1 || row > totalDataRows)
48
+ return null;
49
+ return {
50
+ heading: `row ${row}`,
51
+ startLine: row + 1,
52
+ endLine: row + 1,
53
+ lineCount: 1,
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Extract CSV rows for a section. Returns header + requested rows.
60
+ */
61
+ export function extractCsvSectionContent(lines, section) {
62
+ const header = lines[0]; // always include header
63
+ const dataRows = lines.slice(section.startLine - 1, section.endLine);
64
+ return [header, ...dataRows].join('\n');
65
+ }
66
+ /**
67
+ * Parse a single CSV row handling quoted fields.
68
+ */
69
+ function parseCsvRow(line) {
70
+ const fields = [];
71
+ let current = '';
72
+ let inQuote = false;
73
+ for (let i = 0; i < line.length; i++) {
74
+ const ch = line[i];
75
+ if (inQuote) {
76
+ if (ch === '"') {
77
+ if (i + 1 < line.length && line[i + 1] === '"') {
78
+ current += '"';
79
+ i++; // skip escaped quote
80
+ }
81
+ else {
82
+ inQuote = false;
83
+ }
84
+ }
85
+ else {
86
+ current += ch;
87
+ }
88
+ }
89
+ else {
90
+ if (ch === '"') {
91
+ inQuote = true;
92
+ }
93
+ else if (ch === ',') {
94
+ fields.push(current.trim());
95
+ current = '';
96
+ }
97
+ else {
98
+ current += ch;
99
+ }
100
+ }
101
+ }
102
+ fields.push(current.trim());
103
+ return fields;
104
+ }
105
+ /**
106
+ * Format CSV outline for smart_read output.
107
+ */
108
+ export function formatCsvOutline(filePath, outline, lineCount) {
109
+ const lines = [
110
+ `FILE: ${filePath} (${lineCount} lines, CSV)`,
111
+ '',
112
+ `COLUMNS (${outline.columns.length}): ${outline.columns.join(', ')}`,
113
+ `ROWS: ${outline.rowCount}`,
114
+ '',
115
+ ];
116
+ if (outline.sampleRows.length > 0) {
117
+ lines.push(`SAMPLE (first ${outline.sampleRows.length} rows):`);
118
+ for (const row of outline.sampleRows) {
119
+ // Format as: col1=val1, col2=val2, ...
120
+ const pairs = outline.columns.map((col, i) => `${col}=${row[i] ?? ''}`);
121
+ lines.push(` ${pairs.join(', ')}`);
122
+ }
123
+ }
124
+ lines.push('');
125
+ lines.push(`HINT: Use read_section("${filePath}", heading="rows:1-50") to load specific rows.`);
126
+ lines.push(` Use read_section("${filePath}", heading="rows:${outline.rowCount}") for last row.`);
127
+ return lines.join('\n');
128
+ }
129
+ //# sourceMappingURL=csv-sections.js.map
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { assessConfidence, formatConfidence } from '../core/confidence.js';
4
4
  /**
@@ -53,10 +53,15 @@ function renderSection(title, items) {
53
53
  lines.push('');
54
54
  return lines;
55
55
  }
56
+ /** Max unique files to read for context (prevents unbounded I/O). */
57
+ const MAX_CONTEXT_FILES = 30;
58
+ /** Max file size (bytes) to read for context lines. */
59
+ const MAX_CONTEXT_FILE_SIZE = 500_000;
56
60
  /**
57
61
  * Render a section with surrounding source context lines.
62
+ * Uses shared fileCache to avoid re-reading the same file across sections.
58
63
  */
59
- async function renderSectionWithContext(title, items, contextLines, projectRoot) {
64
+ async function renderSectionWithContext(title, items, contextLines, projectRoot, fileCache) {
60
65
  if (items.length === 0)
61
66
  return [];
62
67
  const lines = [`${title}:`];
@@ -66,17 +71,30 @@ async function renderSectionWithContext(title, items, contextLines, projectRoot)
66
71
  arr.push({ line: item.line, text: item.text });
67
72
  byFile.set(item.file, arr);
68
73
  }
74
+ let filesRead = 0;
69
75
  for (const [file, matches] of byFile) {
70
76
  matches.sort((a, b) => a.line - b.line);
71
77
  lines.push(` ${file}:`);
72
- // Read file for context
78
+ // Read file for context (with shared cache and limits)
73
79
  let fileLines = null;
74
- try {
75
- const content = await readFile(resolve(projectRoot, file), 'utf-8');
76
- fileLines = content.split('\n');
80
+ if (fileCache.has(file)) {
81
+ fileLines = fileCache.get(file);
77
82
  }
78
- catch {
79
- // File unreadable — fall back to text-only
83
+ else if (filesRead < MAX_CONTEXT_FILES) {
84
+ try {
85
+ const fileStat = await stat(resolve(projectRoot, file));
86
+ if (fileStat.size <= MAX_CONTEXT_FILE_SIZE) {
87
+ const content = await readFile(resolve(projectRoot, file), 'utf-8');
88
+ fileLines = content.split('\n');
89
+ }
90
+ }
91
+ catch {
92
+ // File unreadable
93
+ }
94
+ fileCache.set(file, fileLines);
95
+ filesRead++;
96
+ }
97
+ if (!fileLines) {
80
98
  for (const m of matches) {
81
99
  lines.push(` :${m.line} ${m.text}`);
82
100
  }
@@ -218,6 +236,36 @@ export async function handleFindUsages(args, astIndex, projectRoot) {
218
236
  meta: { files: [], definitions: 0, imports: 0, usages: 0, total: 0 },
219
237
  };
220
238
  }
239
+ // ─── List mode — compact file:line output ───
240
+ if (args.mode === 'list') {
241
+ const allItems = [...definitions, ...allImports, ...allUsages];
242
+ const byFile = new Map();
243
+ for (const item of allItems) {
244
+ const arr = byFile.get(item.file) ?? [];
245
+ arr.push(item.line);
246
+ byFile.set(item.file, arr);
247
+ }
248
+ const listLines = [
249
+ `USAGES OF "${args.symbol}" (${allItems.length} matches in ${byFile.size} files):`,
250
+ '',
251
+ ];
252
+ for (const [file, fileLines] of byFile) {
253
+ const sorted = [...new Set(fileLines)].sort((a, b) => a - b);
254
+ listLines.push(` ${file}: L${sorted.join(', L')}`);
255
+ }
256
+ listLines.push('');
257
+ listLines.push(`HINT: Use find_usages("${args.symbol}", path="specific_dir/") to narrow, or read_symbol() on specific matches.`);
258
+ return {
259
+ content: [{ type: 'text', text: listLines.join('\n') }],
260
+ meta: {
261
+ files: Array.from(byFile.keys()),
262
+ definitions: definitions.length,
263
+ imports: allImports.length,
264
+ usages: allUsages.length,
265
+ total: allItems.length,
266
+ },
267
+ };
268
+ }
221
269
  // Build header with active filters
222
270
  const filterHints = [];
223
271
  if (args.scope)
@@ -232,11 +280,11 @@ export async function handleFindUsages(args, astIndex, projectRoot) {
232
280
  '',
233
281
  ];
234
282
  if (args.context_lines !== undefined && args.context_lines > 0 && projectRoot) {
235
- const [defSection, impSection, useSection] = await Promise.all([
236
- renderSectionWithContext('DEFINITIONS', definitions, args.context_lines, projectRoot),
237
- renderSectionWithContext('IMPORTS', allImports, args.context_lines, projectRoot),
238
- renderSectionWithContext('USAGES', allUsages, args.context_lines, projectRoot),
239
- ]);
283
+ // Shared file cache across sections — sequential to avoid concurrent Map writes
284
+ const contextFileCache = new Map();
285
+ const defSection = await renderSectionWithContext('DEFINITIONS', definitions, args.context_lines, projectRoot, contextFileCache);
286
+ const impSection = await renderSectionWithContext('IMPORTS', allImports, args.context_lines, projectRoot, contextFileCache);
287
+ const useSection = await renderSectionWithContext('USAGES', allUsages, args.context_lines, projectRoot, contextFileCache);
240
288
  lines.push(...defSection);
241
289
  lines.push(...impSection);
242
290
  lines.push(...useSection);
@@ -247,6 +295,10 @@ export async function handleFindUsages(args, astIndex, projectRoot) {
247
295
  lines.push(...renderSection('USAGES', allUsages));
248
296
  }
249
297
  lines.push('HINT: Use read_symbol() or read_range() to load specific results.');
298
+ if (totalCount > 20) {
299
+ lines.push('');
300
+ lines.push(`NARROW: ${totalCount} matches found. Use find_usages("${args.symbol}", path="specific_dir/") to filter by location.`);
301
+ }
250
302
  // Confidence metadata
251
303
  const confidenceMeta = assessConfidence({
252
304
  refsFound: totalCount > 0,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * JSON section parser — parses top-level keys with line ranges.
3
+ */
4
+ export interface JsonSection {
5
+ heading: string;
6
+ startLine: number;
7
+ endLine: number;
8
+ lineCount: number;
9
+ }
10
+ /**
11
+ * Parse JSON into sections based on top-level keys.
12
+ * Works with formatted JSON (pretty-printed). For minified JSON, returns empty.
13
+ */
14
+ export declare function parseJsonSections(content: string): JsonSection[];
15
+ export declare function findJsonSection(sections: JsonSection[], heading: string): JsonSection | undefined;
16
+ export declare function extractJsonSectionContent(lines: string[], section: JsonSection): string;
17
+ //# sourceMappingURL=json-sections.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * JSON section parser — parses top-level keys with line ranges.
3
+ */
4
+ /**
5
+ * Parse JSON into sections based on top-level keys.
6
+ * Works with formatted JSON (pretty-printed). For minified JSON, returns empty.
7
+ */
8
+ export function parseJsonSections(content) {
9
+ if (!content.trim())
10
+ return [];
11
+ const lines = content.split('\n');
12
+ if (lines.length < 3)
13
+ return []; // minified or trivial
14
+ // Find top-level keys: lines matching /^\s{0,2}"key":/ (0-2 spaces indent = top level)
15
+ const topKeys = [];
16
+ // Track brace depth to identify top-level
17
+ let depth = 0;
18
+ let inString = false;
19
+ let lineIdx = 0;
20
+ for (lineIdx = 0; lineIdx < lines.length; lineIdx++) {
21
+ const line = lines[lineIdx];
22
+ // Simple top-level key detection: at depth 1 (inside root object)
23
+ // Match: "key": or "key" : at the beginning of a line (with indent)
24
+ if (depth === 1) {
25
+ const keyMatch = line.match(/^\s*"([^"]+)"\s*:/);
26
+ if (keyMatch) {
27
+ topKeys.push({ key: keyMatch[1], line: lineIdx + 1 });
28
+ }
29
+ }
30
+ // Track depth (simplified — doesn't handle strings perfectly but good enough for formatted JSON)
31
+ for (let ci = 0; ci < line.length; ci++) {
32
+ const ch = line[ci];
33
+ if (ch === '"' && (ci === 0 || line[ci - 1] !== '\\')) {
34
+ inString = !inString;
35
+ }
36
+ if (!inString) {
37
+ if (ch === '{' || ch === '[')
38
+ depth++;
39
+ if (ch === '}' || ch === ']')
40
+ depth--;
41
+ }
42
+ }
43
+ }
44
+ if (topKeys.length === 0)
45
+ return [];
46
+ const sections = [];
47
+ for (let i = 0; i < topKeys.length; i++) {
48
+ const start = topKeys[i].line;
49
+ const end = i + 1 < topKeys.length ? topKeys[i + 1].line - 1 : lines.length - 1; // -1 to exclude closing }
50
+ sections.push({
51
+ heading: topKeys[i].key,
52
+ startLine: start,
53
+ endLine: end,
54
+ lineCount: end - start + 1,
55
+ });
56
+ }
57
+ return sections;
58
+ }
59
+ export function findJsonSection(sections, heading) {
60
+ const normalized = heading.trim().toLowerCase();
61
+ return sections.find(s => s.heading.toLowerCase() === normalized);
62
+ }
63
+ export function extractJsonSectionContent(lines, section) {
64
+ return lines.slice(section.startLine - 1, section.endLine).join('\n');
65
+ }
66
+ //# sourceMappingURL=json-sections.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Markdown section parser — shared helper for section-aware tools.
3
+ * Parses heading structure with line ranges for targeted reading.
4
+ */
5
+ export interface MarkdownSection {
6
+ heading: string;
7
+ level: number;
8
+ startLine: number;
9
+ endLine: number;
10
+ lineCount: number;
11
+ }
12
+ export declare function parseMarkdownSections(content: string): MarkdownSection[];
13
+ export declare function findSection(sections: MarkdownSection[], heading: string): MarkdownSection | undefined;
14
+ export declare function extractSectionContent(lines: string[], section: MarkdownSection): string;
15
+ //# sourceMappingURL=markdown-sections.d.ts.map