token-pilot 0.9.0 → 0.12.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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "token-pilot",
3
3
  "displayName": "Token Pilot",
4
- "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. 14 MCP tools for structural code reading, symbol navigation, and cross-file search.",
5
- "version": "0.9.0",
4
+ "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. 18 MCP tools for structural code reading, symbol navigation, and cross-file search.",
5
+ "version": "0.12.0",
6
6
  "author": "Digital-Threads",
7
7
  "repository": "https://github.com/Digital-Threads/token-pilot",
8
8
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.9.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. Returns structural overviews instead of full files.",
5
5
  "author": "token-pilot",
6
6
  "license": "MIT",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ 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.12.0] - 2026-03-14
9
+
10
+ ### Added
11
+ - **Version check for all components** — on startup, checks token-pilot (npm), ast-index (GitHub releases), and context-mode (npm) in parallel. Non-blocking, fire-and-forget. Shows update notifications in stderr.
12
+ - **`autoUpdate` config flag** — `updates.autoUpdate: true` in `.token-pilot.json` auto-downloads new ast-index binary on startup. Default: `false` (notify only). token-pilot and context-mode only notify (separate processes).
13
+ - **`checkBinaryUpdate()`** — compares installed ast-index version vs latest GitHub release.
14
+ - **`isNewerVersion()` utility** — semver comparison: strip `v` prefix, compare segments. Handles different lengths (`1.0` vs `1.0.1`).
15
+ - **Common Lisp extensions** — `.lisp`, `.lsp`, `.cl`, `.asd` added to `CODE_EXTENSIONS` for ast-index v3.28+ compatibility.
16
+ - **9 new tests** — `isNewerVersion()` covering major/minor/patch, same version, older, `v` prefix, different segment lengths, large numbers, real-world versions. Total: 217 tests.
17
+
18
+ ### Changed
19
+ - **`doctor` command** — now shows 3 sections: token-pilot (installed/latest), ast-index (installed/latest/auto-update status), context-mode (detected/latest npm). Previously only showed ast-index binary status.
20
+ - **`install-ast-index` command** — now also updates existing binary if newer version available on GitHub.
21
+ - **`printHelp()`** — fixed tool count: 18 (was incorrectly showing 12 since v0.8.0).
22
+ - **Startup update check** — replaced single `checkLatestVersion()` with `checkAllUpdates()` covering all 3 components via `Promise.allSettled`.
23
+
24
+ ### Fixed
25
+ - **`test_summary` PHPUnit parser** — now counts both `Failures:` and `Errors:` (was only counting failures).
26
+ - **`test_summary` cargo parser** — correctly identifies failure name-list section (no `----` markers) vs detail section.
27
+ - **`test_summary` token estimation** — uses shared `estimateTokens()` instead of local duplicate.
28
+ - **`smart_log` category detection** — `documentation` now matches docs pattern, `tests` (plural) matches test pattern, `optimize`/`optimization` match perf pattern.
29
+ - **`explore_area` path boundary** — `startsWith(path + '/')` prevents `src/auth` matching `src/authorize/`.
30
+ - **Validation consistency** — `validateSmartLogArgs` and `validateTestSummaryArgs` now use `optionalString`/`optionalNumber` helpers, reject empty strings, check integers.
31
+
32
+ ## [0.11.0] - 2026-03-14
33
+
34
+ ### Added
35
+ - **`smart_log` tool** — structured git log with commit category detection (feat/fix/refactor/docs/test/chore/style/perf). Shows author breakdown, file stats (+/-), per-commit file list. Filters by path and ref. Raw git log → compact summary.
36
+ - **`test_summary` tool** — runs test command and returns structured summary: total/passed/failed/skipped + failure details. Parsers for vitest, jest, pytest, phpunit, go test, cargo test, rspec, mocha + generic fallback. 200 lines of raw output → 10-15 lines.
37
+ - **38 new tests** — smart_log parser (5), categorizer (4), test_summary parsers (17), runner detection (8), validation (4). Total: 208 tests (was 170).
38
+
39
+ ### Changed
40
+ - **18 tools** (was 16) — added `smart_log`, `test_summary`
41
+ - **MCP instructions** — added smart_log and test_summary to workflow guidance
42
+
43
+ ## [0.10.0] - 2026-03-14
44
+
45
+ ### Added
46
+ - **`smart_diff` tool** — structural git diff with AST symbol mapping. Shows which functions/classes were modified/added/removed instead of raw patch output. Supports scopes: `unstaged`, `staged`, `commit` (ref required), `branch` (ref required). Small diffs (<=30 lines) include actual hunks, large diffs show summary. Returns `rawTokens` for precise savings analytics.
47
+ - **`explore_area` tool** — one-call directory exploration combining outline + imports + tests + git changes. Replaces 3-5 separate tool calls when starting work on an area. Sections: `outline` (recursive depth 2), `imports` (external deps + who imports this area), `tests` (matching test/spec files), `changes` (recent git log). All sections run in parallel via `Promise.allSettled`.
48
+ - **26 new tests** — smart_diff parser (10), symbol mapping (5), validation (11). Total: 170 tests (was 144).
49
+
50
+ ### Changed
51
+ - **16 tools** (was 14) — added `smart_diff`, `explore_area`
52
+ - **MCP instructions** — updated workflow: `project_overview → explore_area → smart_read → read_symbol → read_for_edit → edit → smart_diff`
53
+ - **`outlineDir` and `CODE_EXTENSIONS` exported** from outline.ts for reuse by explore_area
54
+
8
55
  ## [0.9.0] - 2026-03-08
9
56
 
10
57
  ### Added
package/README.md CHANGED
@@ -173,6 +173,10 @@ For more control, you can add rules to your project:
173
173
  | `find_unused` | manual | Detect dead code — unused functions, classes, variables. |
174
174
  | `code_audit` | multiple `Grep` | Code quality issues in one call: TODO/FIXME comments, deprecated symbols, structural code patterns (via ast-grep), decorator search. |
175
175
  | `module_info` | manual analysis | Module dependency analysis: dependencies, dependents, public API, unused deps. Use for architecture understanding and dependency cleanup. |
176
+ | `smart_diff` | raw `git diff` | Structural diff with AST symbol mapping — shows which functions/classes changed instead of raw patch. Scopes: unstaged, staged, commit, branch. |
177
+ | `explore_area` | outline + related_files + git log | One-call directory exploration: structure, imports, tests, recent changes. Replaces 3-5 separate calls. |
178
+ | `smart_log` | raw `git log` | Structured commit history with category detection (feat/fix/refactor/docs), file stats, author breakdown. Filters by path and ref. |
179
+ | `test_summary` | raw test output | Run tests and get structured summary: total/passed/failed + failure details. Supports vitest, jest, pytest, phpunit, go, cargo, rspec, mocha. |
176
180
 
177
181
  ### Analytics
178
182
 
@@ -351,6 +355,10 @@ src/
351
355
  code-audit.ts — code_audit handler (TODOs, deprecated, patterns)
352
356
  project-overview.ts — project_overview (dual-detection + confidence)
353
357
  module-info.ts — module_info handler (deps, dependents, API, unused)
358
+ smart-diff.ts — smart_diff handler (structural git diff + symbol mapping)
359
+ explore-area.ts — explore_area handler (outline + imports + tests + changes)
360
+ smart-log.ts — smart_log handler (structured git log + category detection)
361
+ test-summary.ts — test_summary handler (run tests + parse output)
354
362
  non-code.ts — JSON/YAML/MD/TOML structural summaries
355
363
  export-ast-index.ts — AST export for context-mode BM25
356
364
  git/
@@ -12,4 +12,17 @@ export declare function findBinary(configPath?: string | null): Promise<BinarySt
12
12
  * Download and install ast-index binary from GitHub releases.
13
13
  */
14
14
  export declare function installBinary(onProgress?: (msg: string) => void): Promise<BinaryStatus>;
15
+ /**
16
+ * Check if a newer version of ast-index is available on GitHub.
17
+ * Non-blocking, returns null values on any error.
18
+ */
19
+ export declare function checkBinaryUpdate(currentPath: string | null): Promise<{
20
+ current: string | null;
21
+ latest: string | null;
22
+ updateAvailable: boolean;
23
+ }>;
24
+ /**
25
+ * Compare two semver strings. Returns true if `latest` is newer than `current`.
26
+ */
27
+ export declare function isNewerVersion(current: string, latest: string): boolean;
15
28
  //# sourceMappingURL=binary-manager.d.ts.map
@@ -86,6 +86,49 @@ export async function installBinary(onProgress) {
86
86
  throw err;
87
87
  }
88
88
  }
89
+ /**
90
+ * Check if a newer version of ast-index is available on GitHub.
91
+ * Non-blocking, returns null values on any error.
92
+ */
93
+ export async function checkBinaryUpdate(currentPath) {
94
+ if (!currentPath) {
95
+ return { current: null, latest: null, updateAvailable: false };
96
+ }
97
+ try {
98
+ const [current, release] = await Promise.all([
99
+ getBinaryVersion(currentPath),
100
+ fetchLatestRelease(),
101
+ ]);
102
+ const latest = release.tag.replace(/^v/, '');
103
+ if (!current) {
104
+ return { current: null, latest, updateAvailable: false };
105
+ }
106
+ return {
107
+ current,
108
+ latest,
109
+ updateAvailable: isNewerVersion(current, latest),
110
+ };
111
+ }
112
+ catch {
113
+ return { current: null, latest: null, updateAvailable: false };
114
+ }
115
+ }
116
+ /**
117
+ * Compare two semver strings. Returns true if `latest` is newer than `current`.
118
+ */
119
+ export function isNewerVersion(current, latest) {
120
+ const c = current.replace(/^v/, '').split('.').map(Number);
121
+ const l = latest.replace(/^v/, '').split('.').map(Number);
122
+ for (let i = 0; i < Math.max(c.length, l.length); i++) {
123
+ const cv = c[i] ?? 0;
124
+ const lv = l[i] ?? 0;
125
+ if (lv > cv)
126
+ return true;
127
+ if (lv < cv)
128
+ return false;
129
+ }
130
+ return false;
131
+ }
89
132
  // --- Internal helpers ---
90
133
  function getPlatform() {
91
134
  switch (platform()) {
@@ -38,6 +38,10 @@ export const DEFAULT_CONFIG = {
38
38
  adviseDelegation: true,
39
39
  largeNonCodeThreshold: 200,
40
40
  },
41
+ updates: {
42
+ checkOnStartup: true,
43
+ autoUpdate: false,
44
+ },
41
45
  ignore: [
42
46
  'node_modules/**',
43
47
  'dist/**',
@@ -110,6 +110,35 @@ export interface ModuleInfoArgs {
110
110
  check?: 'deps' | 'dependents' | 'api' | 'unused-deps' | 'all';
111
111
  }
112
112
  export declare function validateModuleInfoArgs(args: unknown): ModuleInfoArgs;
113
+ /**
114
+ * Validate smart_diff arguments.
115
+ */
116
+ export interface SmartDiffArgs {
117
+ scope?: 'unstaged' | 'staged' | 'commit' | 'branch';
118
+ path?: string;
119
+ ref?: string;
120
+ }
121
+ export declare function validateSmartDiffArgs(args: unknown): SmartDiffArgs;
122
+ /**
123
+ * Validate explore_area arguments.
124
+ */
125
+ export interface ExploreAreaArgs {
126
+ path: string;
127
+ include?: Array<'outline' | 'imports' | 'tests' | 'changes'>;
128
+ }
129
+ export declare function validateExploreAreaArgs(args: unknown): ExploreAreaArgs;
130
+ export interface SmartLogArgs {
131
+ path?: string;
132
+ count?: number;
133
+ ref?: string;
134
+ }
135
+ export declare function validateSmartLogArgs(args: unknown): SmartLogArgs;
136
+ export interface TestSummaryArgs {
137
+ command: string;
138
+ runner?: string;
139
+ timeout?: number;
140
+ }
141
+ export declare function validateTestSummaryArgs(args: unknown): TestSummaryArgs;
113
142
  /** Detect roots that would cause ast-index to scan the entire filesystem */
114
143
  export declare function isDangerousRoot(root: string): boolean;
115
144
  //# sourceMappingURL=validation.d.ts.map
@@ -293,6 +293,94 @@ export function validateModuleInfoArgs(args) {
293
293
  check: check ?? 'all',
294
294
  };
295
295
  }
296
+ export function validateSmartDiffArgs(args) {
297
+ if (!args || typeof args !== 'object')
298
+ return { scope: 'unstaged' };
299
+ const a = args;
300
+ let scope;
301
+ if (a.scope !== undefined && a.scope !== null) {
302
+ const validScopes = ['unstaged', 'staged', 'commit', 'branch'];
303
+ if (typeof a.scope !== 'string' || !validScopes.includes(a.scope)) {
304
+ throw new Error(`"scope" must be one of: ${validScopes.join(', ')}`);
305
+ }
306
+ scope = a.scope;
307
+ }
308
+ const ref = optionalString(a.ref, 'ref');
309
+ if ((scope === 'commit' || scope === 'branch') && !ref) {
310
+ throw new Error(`"ref" is required when scope="${scope}".`);
311
+ }
312
+ return {
313
+ scope: scope ?? 'unstaged',
314
+ path: optionalString(a.path, 'path'),
315
+ ref,
316
+ };
317
+ }
318
+ const VALID_EXPLORE_SECTIONS = ['outline', 'imports', 'tests', 'changes'];
319
+ export function validateExploreAreaArgs(args) {
320
+ if (!args || typeof args !== 'object') {
321
+ throw new Error('Arguments must be an object with a "path" parameter.');
322
+ }
323
+ const a = args;
324
+ if (typeof a.path !== 'string' || a.path.length === 0) {
325
+ throw new Error('Required parameter "path" must be a non-empty string.');
326
+ }
327
+ if (a.include !== undefined && a.include !== null) {
328
+ if (!Array.isArray(a.include)) {
329
+ throw new Error('"include" must be an array of section names.');
330
+ }
331
+ for (const item of a.include) {
332
+ if (typeof item !== 'string' || !VALID_EXPLORE_SECTIONS.includes(item)) {
333
+ throw new Error(`Each element of "include" must be one of: ${VALID_EXPLORE_SECTIONS.join(', ')}. Got: "${item}"`);
334
+ }
335
+ }
336
+ return { path: a.path, include: a.include };
337
+ }
338
+ return { path: a.path };
339
+ }
340
+ export function validateSmartLogArgs(args) {
341
+ if (!args || typeof args !== 'object')
342
+ return {};
343
+ const a = args;
344
+ const path = optionalString(a.path, 'path');
345
+ if (path !== undefined && path.length === 0) {
346
+ throw new Error('"path" must be a non-empty string.');
347
+ }
348
+ const count = optionalNumber(a.count, 'count');
349
+ if (count !== undefined) {
350
+ if (!Number.isInteger(count) || count < 1 || count > 50) {
351
+ throw new Error('"count" must be an integer between 1 and 50.');
352
+ }
353
+ }
354
+ const ref = optionalString(a.ref, 'ref');
355
+ if (ref !== undefined && ref.length === 0) {
356
+ throw new Error('"ref" must be a non-empty string.');
357
+ }
358
+ return { path, count, ref };
359
+ }
360
+ const VALID_RUNNERS = ['vitest', 'jest', 'pytest', 'phpunit', 'go', 'cargo', 'rspec', 'mocha'];
361
+ export function validateTestSummaryArgs(args) {
362
+ if (!args || typeof args !== 'object') {
363
+ throw new Error('Arguments must be an object with a "command" parameter.');
364
+ }
365
+ const a = args;
366
+ if (typeof a.command !== 'string' || a.command.length === 0) {
367
+ throw new Error('Required parameter "command" must be a non-empty string.');
368
+ }
369
+ let runner;
370
+ if (a.runner !== undefined && a.runner !== null) {
371
+ if (typeof a.runner !== 'string' || !VALID_RUNNERS.includes(a.runner)) {
372
+ throw new Error(`"runner" must be one of: ${VALID_RUNNERS.join(', ')}`);
373
+ }
374
+ runner = a.runner;
375
+ }
376
+ const timeout = optionalNumber(a.timeout, 'timeout');
377
+ if (timeout !== undefined) {
378
+ if (!Number.isInteger(timeout) || timeout < 1000 || timeout > 300000) {
379
+ throw new Error('"timeout" must be an integer between 1000 and 300000 (ms).');
380
+ }
381
+ }
382
+ return { command: a.command, runner, timeout };
383
+ }
296
384
  /** Detect roots that would cause ast-index to scan the entire filesystem */
297
385
  export function isDangerousRoot(root) {
298
386
  const normalized = root.replace(/\/+$/, '') || '/';
@@ -0,0 +1,9 @@
1
+ import type { AstIndexClient } from '../ast-index/client.js';
2
+ import type { ExploreAreaArgs } from '../core/validation.js';
3
+ export declare function handleExploreArea(args: ExploreAreaArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
4
+ content: Array<{
5
+ type: 'text';
6
+ text: string;
7
+ }>;
8
+ }>;
9
+ //# sourceMappingURL=explore-area.d.ts.map
@@ -0,0 +1,280 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { resolve, relative, basename, dirname } from 'node:path';
5
+ import { resolveSafePath } from '../core/validation.js';
6
+ import { outlineDir, CODE_EXTENSIONS } from './outline.js';
7
+ const execFileAsync = promisify(execFile);
8
+ // ──────────────────────────────────────────────
9
+ // Constants
10
+ // ──────────────────────────────────────────────
11
+ const MAX_IMPORT_FILES = 20;
12
+ const MAX_OUTPUT_LINES = 500;
13
+ // ──────────────────────────────────────────────
14
+ // Handler
15
+ // ──────────────────────────────────────────────
16
+ export async function handleExploreArea(args, projectRoot, astIndex) {
17
+ // Resolve path — if it points to a file, use its parent directory
18
+ let absPath = resolveSafePath(projectRoot, args.path);
19
+ const pathStat = await stat(absPath).catch(() => null);
20
+ if (!pathStat) {
21
+ return {
22
+ content: [{ type: 'text', text: `Path "${args.path}" not found.` }],
23
+ };
24
+ }
25
+ if (!pathStat.isDirectory()) {
26
+ absPath = dirname(absPath);
27
+ }
28
+ const relDir = relative(projectRoot, absPath) || '.';
29
+ const include = args.include ?? ['outline', 'imports', 'tests', 'changes'];
30
+ // Collect code files for import/test analysis
31
+ const codeFiles = await listCodeFiles(absPath);
32
+ // Run all sections in parallel
33
+ const [outlineSection, importsSection, testsSection, changesSection] = await Promise.allSettled([
34
+ include.includes('outline') ? buildOutlineSection(absPath, projectRoot, astIndex) : Promise.resolve(null),
35
+ include.includes('imports') ? buildImportsSection(codeFiles, absPath, projectRoot, astIndex) : Promise.resolve(null),
36
+ include.includes('tests') ? buildTestsSection(codeFiles, absPath, projectRoot) : Promise.resolve(null),
37
+ include.includes('changes') ? buildChangesSection(relDir, projectRoot) : Promise.resolve(null),
38
+ ]);
39
+ // Assemble output
40
+ const lines = [];
41
+ const subdirCount = await countSubdirs(absPath);
42
+ lines.push(`AREA: ${relDir}/ (${codeFiles.length} code files${subdirCount > 0 ? `, ${subdirCount} subdirs` : ''})`);
43
+ lines.push('');
44
+ // Outline
45
+ const outlineLines = extractResult(outlineSection);
46
+ if (outlineLines) {
47
+ lines.push('STRUCTURE:');
48
+ lines.push(...outlineLines);
49
+ lines.push('');
50
+ }
51
+ // Imports
52
+ const importLines = extractResult(importsSection);
53
+ if (importLines) {
54
+ lines.push(...importLines);
55
+ }
56
+ // Tests
57
+ const testLines = extractResult(testsSection);
58
+ if (testLines) {
59
+ lines.push(...testLines);
60
+ }
61
+ // Changes
62
+ const changeLines = extractResult(changesSection);
63
+ if (changeLines) {
64
+ lines.push(...changeLines);
65
+ }
66
+ // Truncate if needed
67
+ if (lines.length > MAX_OUTPUT_LINES) {
68
+ lines.length = MAX_OUTPUT_LINES;
69
+ lines.push('... truncated. Use outline() on specific subdirectories for details.');
70
+ }
71
+ lines.push('HINT: Use smart_read(file) for details, read_symbol(path, symbol) for source code, find_usages(symbol) for references.');
72
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
73
+ }
74
+ // ──────────────────────────────────────────────
75
+ // Outline section — reuses outlineDir from outline.ts
76
+ // ──────────────────────────────────────────────
77
+ async function buildOutlineSection(absPath, projectRoot, astIndex) {
78
+ const sections = [];
79
+ await outlineDir(absPath, sections, 0, 2, projectRoot, astIndex);
80
+ return sections;
81
+ }
82
+ // ──────────────────────────────────────────────
83
+ // Imports section — aggregate external deps + who imports this area
84
+ // ──────────────────────────────────────────────
85
+ async function buildImportsSection(codeFiles, absPath, projectRoot, astIndex) {
86
+ if (!astIndex.isAvailable() || astIndex.isDisabled() || astIndex.isOversized()) {
87
+ return [];
88
+ }
89
+ const filesToAnalyze = codeFiles.slice(0, MAX_IMPORT_FILES);
90
+ const externalDeps = new Set();
91
+ const internalDeps = new Set();
92
+ const relDir = relative(projectRoot, absPath) || '.';
93
+ // Get imports for each file
94
+ const importResults = await Promise.allSettled(filesToAnalyze.map(f => astIndex.fileImports(f)));
95
+ for (const result of importResults) {
96
+ if (result.status !== 'fulfilled' || !result.value)
97
+ continue;
98
+ for (const imp of result.value) {
99
+ const source = imp.source;
100
+ if (!source)
101
+ continue;
102
+ if (source.startsWith('.') || source.startsWith('/')) {
103
+ // Internal import — track if it's outside this area
104
+ const resolved = resolve(absPath, source);
105
+ if (!resolved.startsWith(absPath + '/') && resolved !== absPath) {
106
+ const relImport = relative(projectRoot, resolved).replace(/\.[^.]+$/, '');
107
+ internalDeps.add(relImport);
108
+ }
109
+ }
110
+ else {
111
+ // External package
112
+ const pkg = source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0];
113
+ externalDeps.add(pkg);
114
+ }
115
+ }
116
+ }
117
+ // Find who imports files from this area (reverse dependencies)
118
+ const importedBy = new Set();
119
+ const fileBasenames = filesToAnalyze.map(f => basename(f).replace(/\.[^.]+$/, ''));
120
+ const refResults = await Promise.allSettled(fileBasenames.slice(0, 10).map(name => astIndex.refs(name, 10)));
121
+ for (const result of refResults) {
122
+ if (result.status !== 'fulfilled' || !result.value)
123
+ continue;
124
+ const refs = result.value;
125
+ if (refs.imports) {
126
+ for (const imp of refs.imports) {
127
+ const impFile = imp.path;
128
+ if (!impFile)
129
+ continue;
130
+ const relFile = relative(projectRoot, impFile);
131
+ // Only include files outside this area
132
+ if (!relFile.startsWith(relDir + '/') && relFile !== relDir) {
133
+ importedBy.add(relFile.replace(/\.[^.]+$/, ''));
134
+ }
135
+ }
136
+ }
137
+ }
138
+ const lines = [];
139
+ if (externalDeps.size > 0) {
140
+ const deps = Array.from(externalDeps).sort().slice(0, 20);
141
+ lines.push(`IMPORTS: ${deps.join(', ')}${externalDeps.size > 20 ? ` ... (${externalDeps.size} total)` : ''}`);
142
+ }
143
+ if (internalDeps.size > 0) {
144
+ const deps = Array.from(internalDeps).sort().slice(0, 10);
145
+ lines.push(`INTERNAL DEPS: ${deps.join(', ')}${internalDeps.size > 10 ? ` ... (${internalDeps.size} total)` : ''}`);
146
+ }
147
+ if (importedBy.size > 0) {
148
+ const importers = Array.from(importedBy).sort().slice(0, 10);
149
+ lines.push(`IMPORTED BY: ${importers.join(', ')}${importedBy.size > 10 ? ` ... (${importedBy.size} total)` : ''}`);
150
+ }
151
+ if (lines.length > 0)
152
+ lines.push('');
153
+ return lines;
154
+ }
155
+ // ──────────────────────────────────────────────
156
+ // Tests section — find test/spec files matching area files
157
+ // ──────────────────────────────────────────────
158
+ async function buildTestsSection(codeFiles, absPath, projectRoot) {
159
+ const testFiles = [];
160
+ const areaFileNames = new Set(codeFiles.map(f => basename(f).replace(/\.[^.]+$/, '')));
161
+ // Scan for test files: check area dir + common test dirs
162
+ const dirsToScan = [absPath];
163
+ // Check for sibling __tests__ or tests directory
164
+ const parent = dirname(absPath);
165
+ const areaName = basename(absPath);
166
+ const testDirCandidates = [
167
+ resolve(absPath, '__tests__'),
168
+ resolve(absPath, 'tests'),
169
+ resolve(absPath, 'test'),
170
+ resolve(parent, '__tests__', areaName),
171
+ resolve(parent, 'tests', areaName),
172
+ ];
173
+ for (const testDir of testDirCandidates) {
174
+ const testDirStat = await stat(testDir).catch(() => null);
175
+ if (testDirStat?.isDirectory()) {
176
+ dirsToScan.push(testDir);
177
+ }
178
+ }
179
+ // Also check project-level test directories
180
+ const projectTestDirs = [
181
+ resolve(projectRoot, 'tests'),
182
+ resolve(projectRoot, 'test'),
183
+ resolve(projectRoot, '__tests__'),
184
+ ];
185
+ for (const testDir of projectTestDirs) {
186
+ if (dirsToScan.includes(testDir))
187
+ continue;
188
+ const testDirStat = await stat(testDir).catch(() => null);
189
+ if (testDirStat?.isDirectory()) {
190
+ dirsToScan.push(testDir);
191
+ }
192
+ }
193
+ for (const dir of dirsToScan) {
194
+ try {
195
+ const entries = await readdir(dir, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ if (!entry.isFile())
198
+ continue;
199
+ const name = entry.name;
200
+ if (name.includes('.test.') || name.includes('.spec.') || name.includes('_test.') || name.includes('_spec.')) {
201
+ // Check if this test corresponds to an area file
202
+ const testBase = name
203
+ .replace(/\.(test|spec)\./, '.')
204
+ .replace(/_(test|spec)\./, '.')
205
+ .replace(/\.[^.]+$/, '');
206
+ if (areaFileNames.has(testBase) || dir !== absPath) {
207
+ const relPath = relative(projectRoot, resolve(dir, name));
208
+ if (!testFiles.includes(relPath)) {
209
+ testFiles.push(relPath);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ catch { /* skip unreadable dirs */ }
216
+ }
217
+ if (testFiles.length === 0)
218
+ return [];
219
+ const lines = [];
220
+ lines.push(`TESTS: ${testFiles.join(', ')}`);
221
+ lines.push('');
222
+ return lines;
223
+ }
224
+ // ──────────────────────────────────────────────
225
+ // Changes section — recent git log for this area
226
+ // ──────────────────────────────────────────────
227
+ async function buildChangesSection(relDir, projectRoot) {
228
+ try {
229
+ const { stdout } = await execFileAsync('git', ['log', '--oneline', '-5', '--', relDir], { cwd: projectRoot, timeout: 5000 });
230
+ if (!stdout.trim())
231
+ return [];
232
+ const lines = [];
233
+ lines.push('RECENT CHANGES:');
234
+ for (const line of stdout.trim().split('\n')) {
235
+ lines.push(` ${line}`);
236
+ }
237
+ lines.push('');
238
+ return lines;
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ }
244
+ // ──────────────────────────────────────────────
245
+ // Helpers
246
+ // ──────────────────────────────────────────────
247
+ function extractResult(settled) {
248
+ if (settled.status === 'fulfilled' && settled.value && settled.value.length > 0) {
249
+ return settled.value;
250
+ }
251
+ return null;
252
+ }
253
+ async function listCodeFiles(dirPath) {
254
+ try {
255
+ const entries = await readdir(dirPath, { withFileTypes: true });
256
+ const files = [];
257
+ for (const entry of entries) {
258
+ if (entry.isFile()) {
259
+ const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
260
+ if (CODE_EXTENSIONS.has(ext)) {
261
+ files.push(resolve(dirPath, entry.name));
262
+ }
263
+ }
264
+ }
265
+ return files.sort();
266
+ }
267
+ catch {
268
+ return [];
269
+ }
270
+ }
271
+ async function countSubdirs(dirPath) {
272
+ try {
273
+ const entries = await readdir(dirPath, { withFileTypes: true });
274
+ return entries.filter(e => e.isDirectory()).length;
275
+ }
276
+ catch {
277
+ return 0;
278
+ }
279
+ }
280
+ //# sourceMappingURL=explore-area.js.map
@@ -1,9 +1,15 @@
1
1
  import type { AstIndexClient } from '../ast-index/client.js';
2
2
  import type { OutlineArgs } from '../core/validation.js';
3
+ export declare const CODE_EXTENSIONS: Set<string>;
3
4
  export declare function handleOutline(args: OutlineArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
4
5
  content: Array<{
5
6
  type: 'text';
6
7
  text: string;
7
8
  }>;
8
9
  }>;
10
+ /**
11
+ * Outline a single directory. When depth < maxDepth and recursive,
12
+ * recurse into subdirectories. Otherwise show file counts only.
13
+ */
14
+ export declare function outlineDir(absPath: string, sections: string[], depth: number, maxDepth: number, projectRoot: string, astIndex: AstIndexClient): Promise<void>;
9
15
  //# sourceMappingURL=outline.d.ts.map
@@ -1,11 +1,12 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { resolve, basename, relative } from 'node:path';
3
3
  import { resolveSafePath } from '../core/validation.js';
4
- const CODE_EXTENSIONS = new Set([
4
+ export const CODE_EXTENSIONS = new Set([
5
5
  'ts', 'tsx', 'js', 'jsx', 'mjs', 'py', 'go', 'rs', 'java', 'kt', 'kts',
6
6
  'swift', 'cs', 'cpp', 'cc', 'cxx', 'hpp', 'c', 'h', 'php', 'rb', 'scala',
7
7
  'dart', 'lua', 'sh', 'bash', 'sql', 'r', 'vue', 'svelte', 'pl', 'pm',
8
8
  'ex', 'exs', 'groovy', 'm', 'proto', 'bsl',
9
+ 'lisp', 'lsp', 'cl', 'asd',
9
10
  ]);
10
11
  // Standard HTTP verbs — protocol-level, not framework-specific
11
12
  const HTTP_VERBS = ['Get', 'Post', 'Put', 'Delete', 'Patch', 'Head', 'Options', 'All'];
@@ -35,7 +36,7 @@ const MAX_OUTLINE_LINES = 500;
35
36
  * Outline a single directory. When depth < maxDepth and recursive,
36
37
  * recurse into subdirectories. Otherwise show file counts only.
37
38
  */
38
- async function outlineDir(absPath, sections, depth, maxDepth, projectRoot, astIndex) {
39
+ export async function outlineDir(absPath, sections, depth, maxDepth, projectRoot, astIndex) {
39
40
  // Guard: stop if output is already too large
40
41
  if (sections.length >= MAX_OUTLINE_LINES) {
41
42
  if (!sections[sections.length - 1]?.startsWith('⚠')) {