From e1843e7b813dfd73d4072011735151df0d41eac7 Mon Sep 17 00:00:00 2001 From: shahinyanm Date: Wed, 24 Jun 2026 09:28:39 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.47.0=20=E2=80=94=20`explore`=20t?= =?UTF-8?q?ool=20+=20ast-index=203.48.1=20+=20rebuild=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leverage ast-index 3.48 in token-pilot: - feat(explore): new MCP tool `explore` — one-shot ranked context. Wraps ast-index's `explore --rwr`: ranked symbols + top-file source heads + graph neighbours (callers/subclasses = blast radius via RWR) + related tests, in one compact block. Replaces find_usages → read_symbol → call_tree chaining. Graph on by default; degrades with a clear "requires ast-index >= 3.48" note on an older binary. Client method + types + handler + server registration + tests. - chore(deps): bump @ast-index/cli ^3.44 → ^3.48.1 (TS-indexing fix, rebuild swap-and-restore, memory caps, FUSE-safe canonicalize). npm audit still 0. - fix(buildIndex): trust the binary's swap-and-restore — on rebuild failure use the preserved index instead of throwing and falling back to raw reads. Lock and generic recovery paths unified. Deferred: --local / subtree scoping (multi-repo/worktree) needs a rooting rework. Full suite: +5 new tests; 23 failures unchanged (pre-existing env-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 6 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 28 +++++++ agents/tp-api-surface-tracker.md | 2 +- agents/tp-audit-scanner.md | 2 +- agents/tp-commit-writer.md | 2 +- agents/tp-context-engineer.md | 2 +- agents/tp-dead-code-finder.md | 2 +- agents/tp-debugger.md | 2 +- agents/tp-dep-health.md | 2 +- agents/tp-doc-writer.md | 2 +- agents/tp-history-explorer.md | 2 +- agents/tp-impact-analyzer.md | 2 +- agents/tp-incident-timeline.md | 2 +- agents/tp-incremental-builder.md | 2 +- agents/tp-migration-scout.md | 2 +- agents/tp-onboard.md | 2 +- agents/tp-performance-profiler.md | 2 +- agents/tp-pr-reviewer.md | 2 +- agents/tp-refactor-planner.md | 2 +- agents/tp-review-impact.md | 2 +- agents/tp-run.md | 2 +- agents/tp-session-restorer.md | 2 +- agents/tp-ship-coordinator.md | 2 +- agents/tp-spec-writer.md | 2 +- agents/tp-test-coverage-gapper.md | 2 +- agents/tp-test-triage.md | 2 +- agents/tp-test-writer.md | 2 +- package-lock.json | 58 ++++++++------ package.json | 4 +- server.json | 4 +- src/ast-index/client.ts | 111 ++++++++++++++++++++++--- src/ast-index/types.ts | 68 ++++++++++++++++ src/core/validation.ts | 29 +++++++ src/handlers/explore.ts | 124 ++++++++++++++++++++++++++++ src/server.ts | 44 ++++++++++ src/server/tool-definitions.ts | 26 ++++++ src/server/tool-profiles.ts | 1 + tests/ast-index/client.test.ts | 111 +++++++++++++++++++++++++ tests/handlers/explore.test.ts | 129 ++++++++++++++++++++++++++++++ 40 files changed, 723 insertions(+), 72 deletions(-) create mode 100644 src/handlers/explore.ts create mode 100644 tests/handlers/explore.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c03582f..8ce150d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot — save 60-90% tokens when AI reads code", - "version": "0.46.1" + "version": "0.47.0" }, "plugins": [ { "name": "token-pilot", "source": "./", - "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 23 MCP tools + 25 subagents + budget watchdog hooks.", - "version": "0.46.1", + "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 24 MCP tools + 25 subagents + budget watchdog hooks.", + "version": "0.47.0", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5725aa2..4cc464f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.46.1", + "version": "0.47.0", "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.", "author": { "name": "Digital-Threads", diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b57b3..ecb0400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to Token Pilot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.47.0] - 2026-06-24 + +### Added — `explore` tool: one-shot ranked context + graph blast-radius + +New MCP tool **`explore`** wraps ast-index 3.48's `explore` command: for a query +it returns ranked relevant symbols, the source heads of the top files, **graph +neighbours (callers + subclasses — the blast radius, via RWR over the +call/inheritance graph)**, and related test files — in a single compact block. +Replaces the common `find_usages` → `read_symbol` → `call_tree` chain with one +call. `graph` defaults on; `max_files` caps the source heads. Falls back to a +clear "requires ast-index >= 3.48" message when an older binary is resolved. + +### Changed — bump `@ast-index/cli` to 3.48.1 + +Picks up the upstream TypeScript-indexing fix, the rebuild **swap-and-restore** +guard (a failed rebuild no longer wipes the index), memory caps, and FUSE-safe +canonicalisation. `npm audit` stays at 0 vulnerabilities. + +### Changed — `buildIndex` trusts swap-and-restore + +When a rebuild fails, `buildIndex` now checks for the index the binary preserved +and uses it (instead of throwing and falling back to raw reads). The lock-case +and generic-failure recovery paths are unified. + +_Deferred:_ `--local` / subtree query scoping (to re-enable ast-index on +multi-repo / worktree parents instead of disabling it) needs a rooting-model +rework and ships separately. + ## [0.46.1] - 2026-06-18 ### Fixed — node:test (`node --test`) TAP output parsing diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index 312a046..29a2773 100644 --- a/agents/tp-api-surface-tracker.md +++ b/agents/tp-api-surface-tracker.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: haiku -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index 07bf8eb..54ce924 100644 --- a/agents/tp-audit-scanner.md +++ b/agents/tp-audit-scanner.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index 9360388..2e78b81 100644 --- a/agents/tp-commit-writer.md +++ b/agents/tp-commit-writer.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__test_summary - mcp__token-pilot__outline - Bash -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index c75cff0..9bbe0e7 100644 --- a/agents/tp-context-engineer.md +++ b/agents/tp-context-engineer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 68b32af2dacd82ebe52c4eec93edb903d452688274c3065218270627c564d8b0 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dead-code-finder.md b/agents/tp-dead-code-finder.md index 5ae18ec..acb4197 100644 --- a/agents/tp-dead-code-finder.md +++ b/agents/tp-dead-code-finder.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index d70bcad..7c9b1ec 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -12,7 +12,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index 987c54f..9d49f70 100644 --- a/agents/tp-dep-health.md +++ b/agents/tp-dep-health.md @@ -9,7 +9,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index 890bcad..f335c07 100644 --- a/agents/tp-doc-writer.md +++ b/agents/tp-doc-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: haiku -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index fad8266..9043636 100644 --- a/agents/tp-history-explorer.md +++ b/agents/tp-history-explorer.md @@ -10,7 +10,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index 6b50311..6122c30 100644 --- a/agents/tp-impact-analyzer.md +++ b/agents/tp-impact-analyzer.md @@ -12,7 +12,7 @@ tools: - mcp__token-pilot__read_symbols - Read model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index fe575ff..0d2088a 100644 --- a/agents/tp-incident-timeline.md +++ b/agents/tp-incident-timeline.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: inherit -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index f64896b..0e99b57 100644 --- a/agents/tp-incremental-builder.md +++ b/agents/tp-incremental-builder.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index f246a13..0d83da1 100644 --- a/agents/tp-migration-scout.md +++ b/agents/tp-migration-scout.md @@ -11,7 +11,7 @@ tools: - Grep - Glob model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index bbd8a34..b936904 100644 --- a/agents/tp-onboard.md +++ b/agents/tp-onboard.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__smart_read - mcp__token-pilot__smart_read_many - mcp__token-pilot__read_section -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index 8097512..bf0a9e6 100644 --- a/agents/tp-performance-profiler.md +++ b/agents/tp-performance-profiler.md @@ -11,7 +11,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index 7b094ba..138f531 100644 --- a/agents/tp-pr-reviewer.md +++ b/agents/tp-pr-reviewer.md @@ -11,7 +11,7 @@ tools: - mcp__token-pilot__read_for_edit - Read model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index 42bd267..00db012 100644 --- a/agents/tp-refactor-planner.md +++ b/agents/tp-refactor-planner.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__outline - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index 5c342b2..541f2f0 100644 --- a/agents/tp-review-impact.md +++ b/agents/tp-review-impact.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__module_info - Bash model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index 951d23c..292826a 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -16,7 +16,7 @@ tools: - Glob - Bash model: haiku -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index 7e9f500..c449fc0 100644 --- a/agents/tp-session-restorer.md +++ b/agents/tp-session-restorer.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__session_budget - Bash - Read -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index 599262e..5429215 100644 --- a/agents/tp-ship-coordinator.md +++ b/agents/tp-ship-coordinator.md @@ -11,7 +11,7 @@ tools: - Read - Grep model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index e6e00cb..97aeb3b 100644 --- a/agents/tp-spec-writer.md +++ b/agents/tp-spec-writer.md @@ -9,7 +9,7 @@ tools: - Read - Write model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: c7a4e8b39228fd5158528f389c924c5ff2d98c4b9b05ee0106d54a26c5dc1350 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-coverage-gapper.md b/agents/tp-test-coverage-gapper.md index a10a2c3..84b4325 100644 --- a/agents/tp-test-coverage-gapper.md +++ b/agents/tp-test-coverage-gapper.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__test_summary - Glob - Grep -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index d96fdf2..fddd0f4 100644 --- a/agents/tp-test-triage.md +++ b/agents/tp-test-triage.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__find_usages - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 6abce3c..da5d0eb 100644 --- a/agents/tp-test-writer.md +++ b/agents/tp-test-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.46.1" +token_pilot_version: "0.47.0" token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9 requiredMcpServers: - "token-pilot" diff --git a/package-lock.json b/package-lock.json index e53ee7e..37624a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "token-pilot", - "version": "0.46.1", + "version": "0.47.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "token-pilot", - "version": "0.46.1", + "version": "0.47.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@ast-index/cli": "^3.44.0", + "@ast-index/cli": "^3.48.1", "@modelcontextprotocol/sdk": "^1.12.0", "chokidar": "^4.0.3" }, @@ -162,9 +162,10 @@ } }, "node_modules/@ast-index/cli": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli/-/cli-3.47.0.tgz", - "integrity": "sha512-j/I6imReICRWB++eXGX9pUYdmDvGI/RIWZgxK2n1pM8NchGiaE/y+zxwKytGaHeDz8IpV8zKW4f/AjNiTKNKGg==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli/-/cli-3.48.1.tgz", + "integrity": "sha512-BpthBBYcjpQWl7eAr6XLZeLXHZ63a2P0GWld9S8VA8Fyk9MhD7TeV5fn+q3CdIvOgLBdqLyrRHA58lZaQ4pfJg==", + "license": "MIT", "bin": { "ast-index": "bin/ast-index" }, @@ -172,20 +173,21 @@ "node": ">=14" }, "optionalDependencies": { - "@ast-index/cli-darwin-arm64": "3.47.0", - "@ast-index/cli-darwin-x64": "3.47.0", - "@ast-index/cli-linux-arm64": "3.47.0", - "@ast-index/cli-linux-x64": "3.47.0", - "@ast-index/cli-win32-x64": "3.47.0" + "@ast-index/cli-darwin-arm64": "3.48.1", + "@ast-index/cli-darwin-x64": "3.48.1", + "@ast-index/cli-linux-arm64": "3.48.1", + "@ast-index/cli-linux-x64": "3.48.1", + "@ast-index/cli-win32-x64": "3.48.1" } }, "node_modules/@ast-index/cli-darwin-arm64": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli-darwin-arm64/-/cli-darwin-arm64-3.47.0.tgz", - "integrity": "sha512-QvRTZY2+CDadj1bdhLgdEYJ2F9agWf2C6i7eatf9FLGaZbtZqmPtpSEwkDc0tSXCIVGOXU7p9HV7x33DFMfW5g==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli-darwin-arm64/-/cli-darwin-arm64-3.48.1.tgz", + "integrity": "sha512-BCJjL0Ju/jO+f2nacUqPwSTKJGFK9FYo2KhBitb0RGskm3jiuIdzYC2jzjHWkjHs0xAq1PUQuI/MbazXrFx0zw==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -195,12 +197,13 @@ } }, "node_modules/@ast-index/cli-darwin-x64": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli-darwin-x64/-/cli-darwin-x64-3.47.0.tgz", - "integrity": "sha512-V6/Iwjmhs6TFUfL2xskAONfLI4AGEwpxMurIKNyVceWZRAT2hdRK0vEq+VMf3ebLahtNvZ/simRLgYJ36WQysw==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli-darwin-x64/-/cli-darwin-x64-3.48.1.tgz", + "integrity": "sha512-cEYdDis+pnK+dIhAayLTDU7ru3fJ1BHE1m1eY9MEsQv5+Uv9REEBK7A/XQDwCYhLXI2HD3ecHHniplE+KEBz+w==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -210,12 +213,13 @@ } }, "node_modules/@ast-index/cli-linux-arm64": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli-linux-arm64/-/cli-linux-arm64-3.47.0.tgz", - "integrity": "sha512-/Zlt/pBsLy9EQrjTNkjrX/edK0PVeyjAQoWqsksuM5/EHAMvqLkWDB+x4AJwWkAzNleKA5FDv+SbOxFq23lFQg==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli-linux-arm64/-/cli-linux-arm64-3.48.1.tgz", + "integrity": "sha512-HtY4fD3128amzA8Wd7rLqAiuPTbuElPd5LS2ZpL8+q3k4TUADriZ5MizLbYt/Dzbog99Or347GLcNej6b/rKKw==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -225,12 +229,13 @@ } }, "node_modules/@ast-index/cli-linux-x64": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli-linux-x64/-/cli-linux-x64-3.47.0.tgz", - "integrity": "sha512-V8Xl2MKBYmkbfKlCeRZTfak3Y8vRYP8NPdBaxQIO/EXJpnVc20ak/HTCHMxsmn2eEiTTobUoHyI2YQPp8gTAaQ==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli-linux-x64/-/cli-linux-x64-3.48.1.tgz", + "integrity": "sha512-eJofRx767jPlPMRJpI5Im2sCZenm4GU0PZxjTVvIuD4uxZ828/kMGunEHeSsSrvEW3v7EETUUsoAu1Bl7ZP/qQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -240,12 +245,13 @@ } }, "node_modules/@ast-index/cli-win32-x64": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/@ast-index/cli-win32-x64/-/cli-win32-x64-3.47.0.tgz", - "integrity": "sha512-THtaw7mw4RQsobrfAt7rqcsMZajKX8KVB0KPKnrgcSaxR3gRA52LPIFFbUHwfva8lb/APR36FFDaUFGkv+6T/w==", + "version": "3.48.1", + "resolved": "https://registry.npmjs.org/@ast-index/cli-win32-x64/-/cli-win32-x64-3.48.1.tgz", + "integrity": "sha512-MDrXAcMw4Ek08c+u0UINf1BmyV1uKv4hMFYc2cZhF1LM5V+Dq3Js7x6Ilh8KQwanahc+tsLX5wUrUAaej7/oGw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" diff --git a/package.json b/package.json index 0d58562..926675f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.46.1", + "version": "0.47.0", "description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", "type": "module", "main": "dist/index.js", @@ -67,7 +67,7 @@ "mcpName": "io.github.Digital-Threads/token-pilot", "license": "MIT", "dependencies": { - "@ast-index/cli": "^3.44.0", + "@ast-index/cli": "^3.48.1", "@modelcontextprotocol/sdk": "^1.12.0", "chokidar": "^4.0.3" }, diff --git a/server.json b/server.json index 795d15e..4dd59b6 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/Digital-Threads/token-pilot", "source": "github" }, - "version": "0.46.1", + "version": "0.47.0", "packages": [ { "registryType": "npm", "identifier": "token-pilot", - "version": "0.46.1", + "version": "0.47.0", "transport": { "type": "stdio" }, diff --git a/src/ast-index/client.ts b/src/ast-index/client.ts index ab46e0c..28d47db 100644 --- a/src/ast-index/client.ts +++ b/src/ast-index/client.ts @@ -25,6 +25,8 @@ import type { AstIndexModuleDep, AstIndexUnusedDep, AstIndexModuleApi, + AstIndexExploreResult, + AstIndexExploreRaw, } from "./types.js"; import { findBinary, installBinary } from "./binary-manager.js"; import { @@ -214,20 +216,27 @@ export class AstIndexClient { } catch (buildErr) { const errMsg = buildErr instanceof Error ? buildErr.message : String(buildErr); - if (errMsg.includes("lock") || errMsg.includes("already running")) { - const count = parseFileCount( - await this.exec(["--format", "json", "stats"]).catch(() => ""), + // ast-index 3.46+ preserves the previous index when a rebuild aborts + // (swap-and-restore guard). Before giving up, query stats once: if a + // usable index survived, use it instead of losing all indexed tools and + // falling back to raw reads. Covers both the lock / already-running case + // and any generic rebuild failure the binary recovered from. + const lockCase = + errMsg.includes("lock") || errMsg.includes("already running"); + const count = parseFileCount( + await this.exec(["--format", "json", "stats"]).catch(() => ""), + ); + if (count > 0 && count <= AstIndexClient.MAX_INDEX_FILES) { + this.indexed = true; + console.error( + lockCase + ? `[token-pilot] ast-index: using existing index (${count} files, rebuild skipped due to lock)` + : `[token-pilot] ast-index: rebuild failed but previous index preserved (${count} files) — using it`, ); - if (count > 0 && count <= AstIndexClient.MAX_INDEX_FILES) { - this.indexed = true; - console.error( - `[token-pilot] ast-index: using existing index (${count} files, rebuild skipped due to lock)`, - ); - return; - } - if (count > AstIndexClient.MAX_INDEX_FILES) { - return this.handleOversizedIndex(count); - } + return; + } + if (count > AstIndexClient.MAX_INDEX_FILES) { + return this.handleOversizedIndex(count); } console.error(`[token-pilot] ast-index: rebuild failed — ${errMsg}`); throw buildErr; @@ -452,6 +461,82 @@ export class AstIndexClient { } } + async explore( + query: string, + options?: { maxFiles?: number; graph?: boolean }, + ): Promise { + await this.ensureIndex(); + const empty: AstIndexExploreResult = { + query, + dominantLanguage: "", + symbols: [], + files: [], + neighbours: [], + tests: [], + }; + const args = ["explore", query, "--format", "json"]; + if (options?.maxFiles) args.push("-f", String(options.maxFiles)); + // Default graph ON — call/inheritance blast-radius is the value-add; + // callers opt out with graph: false. + if (options?.graph !== false) args.push("--rwr"); + try { + const result = await this.exec(args); + const parsed: AstIndexExploreRaw = JSON.parse(result); + return { + query: typeof parsed.query === "string" ? parsed.query : query, + dominantLanguage: + typeof parsed.dominant_language === "string" + ? parsed.dominant_language + : "", + symbols: Array.isArray(parsed.symbols) + ? parsed.symbols.map((s) => ({ + name: s.name, + kind: s.kind, + path: s.path, + line: s.line, + score: s.score, + vendor: s.vendor === true, + })) + : [], + files: Array.isArray(parsed.files) + ? parsed.files.map((f) => ({ + path: f.path, + line: f.line, + source: f.source, + })) + : [], + neighbours: Array.isArray(parsed.neighbours) + ? parsed.neighbours.map((n) => ({ + name: n.name, + kind: n.kind, + path: n.path, + line: n.line, + link: n.link, + })) + : [], + tests: Array.isArray(parsed.tests) + ? parsed.tests.map((t) => ({ + source: t.source, + tests: Array.isArray(t.tests) ? t.tests : [], + })) + : [], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[token-pilot] ast-index explore failed: ${msg}`); + // `explore` landed in ast-index 3.48. An older resolved binary reports + // "unrecognized subcommand 'explore'" — surface that instead of an + // indistinguishable empty result, so the caller knows to update. + if (/unrecognized subcommand|unexpected argument/.test(msg)) { + return { + ...empty, + error: "explore requires ast-index >= 3.48 — update the binary", + }; + } + return empty; + } + } + async implementations(name: string): Promise { await this.ensureIndex(); try { diff --git a/src/ast-index/types.ts b/src/ast-index/types.ts index f497cc5..64a65cb 100644 --- a/src/ast-index/types.ts +++ b/src/ast-index/types.ts @@ -234,3 +234,71 @@ export interface AstIndexModuleApi { file: string; line: number; } + +/** ast-index explore — one ranked symbol */ +export interface AstIndexExploreSymbol { + name: string; + kind: string; + path: string; + line: number; + score: number; + vendor: boolean; +} + +/** ast-index explore — one ranked file head (source is line-numbered) */ +export interface AstIndexExploreFile { + path: string; + line: number; + source: string; +} + +/** ast-index explore — one graph neighbour (blast radius, requires --rwr) */ +export interface AstIndexExploreNeighbour { + name: string; + kind: string; + path: string; + line: number; + /** "caller" | "subclass" | string */ + link: string; +} + +/** ast-index explore — tests grouped by source file */ +export interface AstIndexExploreTestGroup { + source: string; + tests: string[]; +} + +/** ast-index explore — mapped result */ +export interface AstIndexExploreResult { + query: string; + dominantLanguage: string; + symbols: AstIndexExploreSymbol[]; + files: AstIndexExploreFile[]; + neighbours: AstIndexExploreNeighbour[]; + tests: AstIndexExploreTestGroup[]; + /** Set when the run failed (e.g. binary too old for `explore`). */ + error?: string; +} + +/** ast-index explore — raw json shape from the binary (snake_case) */ +export interface AstIndexExploreRaw { + query?: string; + dominant_language?: string; + symbols?: Array<{ + name: string; + kind: string; + path: string; + line: number; + score: number; + vendor?: boolean; + }>; + files?: Array<{ path: string; line: number; source: string }>; + neighbours?: Array<{ + name: string; + kind: string; + path: string; + line: number; + link: string; + }>; + tests?: Array<{ source: string; tests?: string[] }>; +} diff --git a/src/core/validation.ts b/src/core/validation.ts index 10203d1..4ecff83 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -740,6 +740,35 @@ export function validateExploreAreaArgs(args: unknown): ExploreAreaArgs { return { path: a.path }; } +// ── explore ── + +export interface ExploreArgs { + query: string; + max_files?: number; + graph?: boolean; +} + +export function validateExploreArgs(args: unknown): ExploreArgs { + if (!args || typeof args !== "object") { + throw new Error('Arguments must be an object with a "query" parameter.'); + } + const a = args as Record; + if (typeof a.query !== "string" || a.query.length === 0) { + throw new Error('Required parameter "query" must be a non-empty string.'); + } + + const max_files = optionalNumber(a.max_files, "max_files"); + if (max_files !== undefined && max_files < 1) { + throw new Error('"max_files" must be at least 1.'); + } + + return { + query: a.query, + max_files, + graph: optionalBool(a.graph, "graph"), + }; +} + // ── smart_log ── export interface SmartLogArgs { diff --git a/src/handlers/explore.ts b/src/handlers/explore.ts new file mode 100644 index 0000000..39c406c --- /dev/null +++ b/src/handlers/explore.ts @@ -0,0 +1,124 @@ +import type { AstIndexClient } from "../ast-index/client.js"; +import type { ExploreArgs } from "../core/validation.js"; + +// ────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────── + +const MAX_RANKED_SYMBOLS = 12; + +export interface ExploreMeta { + query: string; + symbolCount: number; + fileCount: number; + neighbourCount: number; + testCount: number; +} + +// ────────────────────────────────────────────── +// Handler — one-shot ranked context + graph blast-radius. +// Mirrors the shape of handleExploreArea: build a compact, token-efficient +// text block and return it with lightweight meta. +// ────────────────────────────────────────────── + +export async function handleExplore( + args: ExploreArgs, + projectRoot: string, + astIndex: AstIndexClient, +): Promise<{ + content: Array<{ type: "text"; text: string }>; + meta: ExploreMeta; +}> { + void projectRoot; // explore runs against the index root, not a path + const result = await astIndex.explore(args.query, { + maxFiles: args.max_files, + graph: args.graph, + }); + + const lines: string[] = []; + lines.push( + `# explore: "${result.query}" (lang: ${result.dominantLanguage || "?"})`, + ); + + // Ranked symbols + if (result.symbols.length > 0) { + lines.push(""); + lines.push("## Ranked symbols"); + for (const s of result.symbols.slice(0, MAX_RANKED_SYMBOLS)) { + const vendorTag = s.vendor ? " [vendor]" : ""; + lines.push( + `${Math.round(s.score)} ${s.kind} ${s.name} ${s.path}:${s.line}${vendorTag}`, + ); + } + } + + // Source — file heads (source is already line-numbered) + if (result.files.length > 0) { + lines.push(""); + lines.push("## Source"); + for (const f of result.files) { + lines.push(`${f.path}:${f.line}`); + lines.push("```"); + lines.push(f.source.replace(/\n+$/, "")); + lines.push("```"); + } + } + + // Graph neighbours (blast radius) — only with --rwr + if (result.neighbours.length > 0) { + lines.push(""); + lines.push("## Graph neighbours (blast radius)"); + for (const n of result.neighbours) { + lines.push(`${n.link} ${n.kind} ${n.name} ${n.path}:${n.line}`); + } + } + + // Tests grouped by source + if (result.tests.length > 0) { + lines.push(""); + lines.push("## Tests"); + for (const t of result.tests) { + lines.push(`${t.source}:`); + for (const test of t.tests) { + lines.push(` ${test}`); + } + } + } + + const empty = + result.symbols.length === 0 && + result.files.length === 0 && + result.neighbours.length === 0 && + result.tests.length === 0; + + if (empty) { + const reason = + result.error ?? "No results — index unavailable or query matched nothing."; + return { + content: [ + { + type: "text", + text: `# explore: "${result.query}"\n\n${reason}`, + }, + ], + meta: { + query: result.query, + symbolCount: 0, + fileCount: 0, + neighbourCount: 0, + testCount: 0, + }, + }; + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + meta: { + query: result.query, + symbolCount: result.symbols.length, + fileCount: result.files.length, + neighbourCount: result.neighbours.length, + testCount: result.tests.reduce((n, t) => n + t.tests.length, 0), + }, + }; +} diff --git a/src/server.ts b/src/server.ts index e7679fb..aa06b2a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,6 +46,7 @@ import { handleModuleInfo } from "./handlers/module-info.js"; import { handleModuleRoute } from "./handlers/module-route.js"; import { handleSmartDiff } from "./handlers/smart-diff.js"; import { handleExploreArea } from "./handlers/explore-area.js"; +import { handleExplore } from "./handlers/explore.js"; import { handleSmartLog } from "./handlers/smart-log.js"; import { handleTestSummary } from "./handlers/test-summary.js"; import { handleSessionSnapshot } from "./handlers/session-snapshot.js"; @@ -90,6 +91,7 @@ import { validateModuleRouteArgs, validateSmartDiffArgs, validateExploreAreaArgs, + validateExploreArgs, validateSmartLogArgs, validateTestSummaryArgs, validateReadSectionArgs, @@ -1327,6 +1329,48 @@ export async function createServer( return eaResult; } + case "explore": { + const exArgs = validateExploreArgs(args); + const cachedEx = sessionCache?.get("explore", exArgs); + if (cachedEx) { + recordWithTrace({ + tool: "explore", + path: exArgs.query, + tokensReturned: cachedEx.tokenEstimate, + tokensWouldBe: cachedEx.tokensWouldBe ?? cachedEx.tokenEstimate, + timestamp: Date.now(), + sessionCacheHit: true, + savingsCategory: "cache", + args: exArgs, + }); + return cachedEx.result; + } + const exResult = await handleExplore(exArgs, projectRoot, astIndex); + const exText = exResult.content[0]?.text ?? ""; + const exTokens = estimateTokens(exText); + // explore replaces a find_usages + read_symbol + call_tree chain; + // approximate that baseline as ~3x the compacted output. + const exWouldBe = exTokens * 3; + sessionCache?.set( + "explore", + exArgs, + exResult, + { dependsOnAst: true }, + exTokens, + exWouldBe || exTokens, + ); + recordWithTrace({ + tool: "explore", + path: exArgs.query, + tokensReturned: exTokens, + tokensWouldBe: exWouldBe || exTokens, + timestamp: Date.now(), + savingsCategory: "compression", + args: exArgs, + }); + return exResult; + } + case "smart_log": { const slArgs = validateSmartLogArgs(args); // v0.30.0 strict mode: bound count to 20 when caller didn't set it. diff --git a/src/server/tool-definitions.ts b/src/server/tool-definitions.ts index 9461564..6795ad3 100644 --- a/src/server/tool-definitions.ts +++ b/src/server/tool-definitions.ts @@ -746,6 +746,32 @@ export const TOOL_DEFINITIONS = [ required: ["path"], }, }, + { + name: "explore", + description: + "One-shot ranked context + call/inheritance graph blast-radius for a query. Returns ranked symbols, the source heads of the top-ranked files, graph neighbours (callers + subclasses — the blast radius), and related test files in a single compact block. Use INSTEAD OF separate find_usages + read_symbol + call_tree when you need to understand an area fast — cheaper than chaining those three.", + inputSchema: { + type: "object" as const, + properties: { + query: { + type: "string", + description: + "Search terms (the binary splits the string into terms itself), e.g. \"AstIndexClient buildIndex\"", + }, + max_files: { + type: "number", + description: + "Cap on the number of source file heads returned (default: binary's own limit)", + }, + graph: { + type: "boolean", + description: + "Include call/inheritance graph neighbours (blast radius). Default: true. Set false to skip the graph walk.", + }, + }, + required: ["query"], + }, + }, { name: "smart_log", description: diff --git a/src/server/tool-profiles.ts b/src/server/tool-profiles.ts index 0ff9dbc..10fe60d 100644 --- a/src/server/tool-profiles.ts +++ b/src/server/tool-profiles.ts @@ -66,6 +66,7 @@ export const NAV_TOOLS: ReadonlySet = new Set([ "module_info", "related_files", "explore_area", + "explore", "smart_log", "smart_diff", "read_section", // v0.30.0: section reading is nav-class (read-only, no edit prep) diff --git a/tests/ast-index/client.test.ts b/tests/ast-index/client.test.ts index d123879..fba3d05 100644 --- a/tests/ast-index/client.test.ts +++ b/tests/ast-index/client.test.ts @@ -654,4 +654,115 @@ describe("AstIndexClient", () => { client.stopPeriodicUpdate(); expect(client.periodicTimer).toBeNull(); }); + + // ────────────────────────────────────────────────────────────────────── + // ast-index 3.46+ — swap-and-restore: a rebuild that aborts keeps the + // previous index. buildIndex must detect a usable preserved index in its + // failure path and use it instead of throwing (which would drop us to raw + // reads). Mirrors the existing lock-case recovery already in that catch. + // ────────────────────────────────────────────────────────────────────── + it("recovers a preserved index when rebuild fails (3.46+ swap-and-restore)", async () => { + const client = new AstIndexClient(tempDir) as any; + client.binaryPath = "/bin/ast-index"; + + // First stats (top of buildIndex) → no index yet, forcing the rebuild + // path. rebuild rejects, but the next stats reports a healthy index that + // the binary preserved. buildIndex should use it, not rethrow. + let statsCalls = 0; + client.exec = vi.fn(async (args: string[]) => { + if (args.includes("stats")) { + statsCalls += 1; + if (statsCalls === 1) return '{"stats":{"file_count":0}}'; + return '{"stats":{"file_count":123}}'; + } + if (args[0] === "rebuild") { + throw new Error("candidate scan aborted"); + } + return ""; + }); + + await expect(client.buildIndex()).resolves.toBeUndefined(); + expect(client.indexed).toBe(true); + }); + + // explore() maps the ast-index 3.48 JSON (snake_case) into the typed + // result, tolerating missing arrays and passing --rwr by default. + it("explore() maps json to the typed result and passes --rwr by default", async () => { + const client = new AstIndexClient(tempDir) as any; + client.binaryPath = "/bin/ast-index"; + client.ensureIndex = async () => {}; + + const execMock = vi.fn(async () => + JSON.stringify({ + dominant_language: "ts", + query: "AstIndexClient buildIndex", + files: [ + { line: 54, path: "src/ast-index/client.ts", source: " 54\tclass" }, + ], + symbols: [ + { + kind: "class", + line: 54, + name: "AstIndexClient", + path: "src/ast-index/client.ts", + score: 1000, + vendor: false, + }, + ], + neighbours: [ + { + kind: "function", + line: 69, + link: "caller", + name: "runSummaryPipeline", + path: "src/hooks/summary-pipeline.ts", + }, + ], + tests: [ + { + source: "src/ast-index/client.ts", + tests: ["tests/ast-index/client.test.ts"], + }, + ], + }), + ); + client.exec = execMock; + + const result = await client.explore("AstIndexClient buildIndex"); + expect(result.dominantLanguage).toBe("ts"); + expect(result.symbols[0].name).toBe("AstIndexClient"); + expect(result.symbols[0].vendor).toBe(false); + expect(result.files[0].path).toBe("src/ast-index/client.ts"); + expect(result.neighbours[0].link).toBe("caller"); + expect(result.tests[0].tests).toEqual(["tests/ast-index/client.test.ts"]); + + // Default graph ON → --rwr present; query passed as a single string. + const args = execMock.mock.calls[0][0] as string[]; + expect(args).toContain("--rwr"); + expect(args).toContain("AstIndexClient buildIndex"); + + // graph: false → no --rwr + execMock.mockClear(); + await client.explore("x", { graph: false }); + expect(execMock.mock.calls[0][0]).not.toContain("--rwr"); + }); + + it("explore() returns an empty result on exec failure", async () => { + const client = new AstIndexClient(tempDir) as any; + client.binaryPath = "/bin/ast-index"; + client.ensureIndex = async () => {}; + client.exec = vi.fn(async () => { + throw new Error("boom"); + }); + + const result = await client.explore("x"); + expect(result).toEqual({ + query: "x", + dominantLanguage: "", + symbols: [], + files: [], + neighbours: [], + tests: [], + }); + }); }); diff --git a/tests/handlers/explore.test.ts b/tests/handlers/explore.test.ts new file mode 100644 index 0000000..cfb7f2b --- /dev/null +++ b/tests/handlers/explore.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; +import { handleExplore } from "../../src/handlers/explore.js"; +import type { AstIndexExploreResult } from "../../src/ast-index/types.js"; + +function fakeAstIndex(result: AstIndexExploreResult) { + return { + explore: async () => result, + } as any; +} + +describe("handleExplore", () => { + it("formats ranked symbols, source, blast radius, and tests", async () => { + const result: AstIndexExploreResult = { + query: "AstIndexClient buildIndex", + dominantLanguage: "ts", + symbols: [ + { + name: "AstIndexClient", + kind: "class", + path: "src/ast-index/client.ts", + line: 54, + score: 1000, + vendor: false, + }, + ], + files: [ + { + path: "src/ast-index/client.ts", + line: 54, + source: " 54\texport class AstIndexClient {\n 55\t ...\n", + }, + ], + neighbours: [ + { + name: "runSummaryPipeline", + kind: "function", + path: "src/hooks/summary-pipeline.ts", + line: 69, + link: "caller", + }, + ], + tests: [ + { + source: "src/ast-index/client.ts", + tests: ["tests/ast-index/client.test.ts"], + }, + ], + }; + + const out = await handleExplore( + { query: "AstIndexClient buildIndex" }, + "/repo", + fakeAstIndex(result), + ); + const text = out.content[0].text; + + // Query in header + expect(text).toContain('# explore: "AstIndexClient buildIndex"'); + expect(text).toContain("(lang: ts)"); + + // Ranked symbol + expect(text).toContain("## Ranked symbols"); + expect(text).toContain("1000 class AstIndexClient src/ast-index/client.ts:54"); + + // Source block + expect(text).toContain("## Source"); + expect(text).toContain("export class AstIndexClient {"); + + // Blast-radius / graph neighbour line + expect(text).toContain("## Graph neighbours (blast radius)"); + expect(text).toContain( + "caller function runSummaryPipeline src/hooks/summary-pipeline.ts:69", + ); + + // Test path grouped by source + expect(text).toContain("## Tests"); + expect(text).toContain("tests/ast-index/client.test.ts"); + + expect(out.meta).toEqual({ + query: "AstIndexClient buildIndex", + symbolCount: 1, + fileCount: 1, + neighbourCount: 1, + testCount: 1, + }); + }); + + it("marks vendor symbols and returns a no-results message when empty", async () => { + const vendorResult: AstIndexExploreResult = { + query: "lodash", + dominantLanguage: "ts", + symbols: [ + { + name: "merge", + kind: "function", + path: "node_modules/lodash/merge.js", + line: 1, + score: 500, + vendor: true, + }, + ], + files: [], + neighbours: [], + tests: [], + }; + const vendorOut = await handleExplore( + { query: "lodash" }, + "/repo", + fakeAstIndex(vendorResult), + ); + expect(vendorOut.content[0].text).toContain("[vendor]"); + + const empty: AstIndexExploreResult = { + query: "nothingmatches", + dominantLanguage: "", + symbols: [], + files: [], + neighbours: [], + tests: [], + }; + const emptyOut = await handleExplore( + { query: "nothingmatches" }, + "/repo", + fakeAstIndex(empty), + ); + expect(emptyOut.content[0].text).toContain("No results"); + expect(emptyOut.meta.symbolCount).toBe(0); + }); +}); From 9629a9536f0bebf601f8cb2f8e26f801189ba671 Mon Sep 17 00:00:00 2001 From: shahinyanm Date: Wed, 24 Jun 2026 09:49:09 +0400 Subject: [PATCH 2/2] test: repair stale statusline assertions + harden install-test env isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing CI breakage surfaced while landing 0.47.0 (master CI has been red since the "label savings + efficiency %" badge change): - statusline.test.ts: badge now renders `· saved ` (labelled) plus an efficiency `%` for tool-call-backed totals. Updated the 8 stale regexes to match the current format; EFF-bearing cases assert ` \d+%`. - installer.test.ts / index.test.ts: installHook branches on CLAUDE_PLUGIN_ROOT. When another test sharing the vitest worker leaks that env var, the standalone-install assertions failed (sharding-dependent). beforeEach now neutralises it and afterEach restores — deterministic regardless of file-to-worker distribution. No production code change. The 2 git-root tests that flake under parallel load (real `git` subprocess, 3s timeout) are unrelated and pre-existing. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/hooks/installer.test.ts | 8 ++++++++ tests/hooks/statusline.test.ts | 16 ++++++++-------- tests/index.test.ts | 8 ++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/hooks/installer.test.ts b/tests/hooks/installer.test.ts index 5c4eab7..4585ff0 100644 --- a/tests/hooks/installer.test.ts +++ b/tests/hooks/installer.test.ts @@ -6,13 +6,21 @@ import { installHook, uninstallHook } from "../../src/hooks/installer.js"; describe("Hook Installer", () => { let tempDir: string; + let savedPluginRoot: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "token-pilot-test-")); + // installHook branches on CLAUDE_PLUGIN_ROOT (plugin vs standalone mode). + // These tests assert standalone behaviour, so neutralise any value leaked + // from another test sharing this worker. Plugin-mode tests set it locally. + savedPluginRoot = process.env.CLAUDE_PLUGIN_ROOT; + delete process.env.CLAUDE_PLUGIN_ROOT; }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); + if (savedPluginRoot === undefined) delete process.env.CLAUDE_PLUGIN_ROOT; + else process.env.CLAUDE_PLUGIN_ROOT = savedPluginRoot; }); it("installs hook in fresh project (no .claude dir)", async () => { diff --git a/tests/hooks/statusline.test.ts b/tests/hooks/statusline.test.ts index eba99aa..968b24e 100644 --- a/tests/hooks/statusline.test.ts +++ b/tests/hooks/statusline.test.ts @@ -105,7 +105,7 @@ describe("tp-statusline.sh", () => { const out = strip(runScript(TP_SCRIPT, payload)); // v0.43.0 — session (sess-abc: 5000+3500+2000 = 10500) + project // (all sessions: +9999 = 20499) → "s:10.5k · 20.4k". - expect(out).toMatch(/\[TP s:10\.5k · 20\.4k\]/); + expect(out).toMatch(/\[TP s:10\.5k · saved 20\.4k\]/); await rm(root, { recursive: true, force: true }); }); @@ -126,7 +126,7 @@ describe("tp-statusline.sh", () => { }); const out = strip(runScript(TP_SCRIPT, payload)); // v0.42.4 — one decimal place → 99999 → "99.9k". - expect(out).toMatch(/\[TP 99\.9k\]/); + expect(out).toMatch(/\[TP saved 99\.9k\]/); await rm(root, { recursive: true, force: true }); }); @@ -145,7 +145,7 @@ describe("tp-statusline.sh", () => { const out = strip(runScript(TP_SCRIPT, payload)); // v0.43.0 — the single event belongs to session "s", so session and // project totals are both 42000 → "s:42.0k · 42.0k". - expect(out).toMatch(/\[TP s:42\.0k · 42\.0k\]/); + expect(out).toMatch(/\[TP s:42\.0k · saved 42\.0k\]/); await rm(root, { recursive: true, force: true }); }); @@ -165,7 +165,7 @@ describe("tp-statusline.sh", () => { ); const payload = JSON.stringify({ session_id: "s", cwd: subdir }); const out = strip(runScript(TP_SCRIPT, payload)); - expect(out).toMatch(/\[TP s:150\.0k · 150\.0k\]/); + expect(out).toMatch(/\[TP s:150\.0k · saved 150\.0k\]/); await rm(root, { recursive: true, force: true }); }); @@ -188,7 +188,7 @@ describe("tp-statusline.sh", () => { const out = strip(runScript(TP_SCRIPT, payload)); // The one valid line belongs to session "sess-x" → session & project // both 500 (sub-1k → no decimal): "s:500 · 500". - expect(out).toMatch(/\[TP s:500 · 500\]/); + expect(out).toMatch(/\[TP s:500 · saved 500\]/); await rm(root, { recursive: true, force: true }); }); @@ -220,7 +220,7 @@ describe("tp-statusline.sh", () => { const payload = JSON.stringify({ session_id: "now", cwd: root }); const out = strip(runScript(TP_SCRIPT, payload)); // session now = 12300 → 12.3k; project = 172600 → 172.6k. - expect(out).toMatch(/\[TP s:12\.3k · 172\.6k\]/); + expect(out).toMatch(/\[TP s:12\.3k · saved 172\.6k\]/); await rm(root, { recursive: true, force: true }); }); @@ -246,7 +246,7 @@ describe("tp-statusline.sh", () => { const payload = JSON.stringify({ session_id: "now", cwd: root }); const out = strip(runScript(TP_SCRIPT, payload)); // session = 5000 + 34517 = 39517 → 39.5k; project = +10000 = 49517 → 49.5k - expect(out).toMatch(/\[TP s:39\.5k · 49\.5k\]/); + expect(out).toMatch(/\[TP s:39\.5k · saved 49\.5k \d+%\]/); await rm(root, { recursive: true, force: true }); }); @@ -265,7 +265,7 @@ describe("tp-statusline.sh", () => { const payload = JSON.stringify({ session_id: "s", cwd: root }); const out = strip(runScript(TP_SCRIPT, payload)); // only the second row saves: 3300 − 900 = 2400 → "2.4k" (session == project) - expect(out).toMatch(/\[TP s:2\.4k · 2\.4k\]/); + expect(out).toMatch(/\[TP s:2\.4k · saved 2\.4k \d+%\]/); await rm(root, { recursive: true, force: true }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 113de91..04089b3 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -71,6 +71,7 @@ import * as indexModule from "../src/index.ts"; describe("index CLI helpers", () => { let tempDir: string; + let savedPluginRoot: string | undefined; let logSpy: ReturnType; let errorSpy: ReturnType; let writeSpy: ReturnType; @@ -78,6 +79,11 @@ describe("index CLI helpers", () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "token-pilot-index-")); + // installHook branches on CLAUDE_PLUGIN_ROOT; neutralise any value leaked + // from another test sharing this worker so the install assertions are + // deterministic regardless of file sharding. + savedPluginRoot = process.env.CLAUDE_PLUGIN_ROOT; + delete process.env.CLAUDE_PLUGIN_ROOT; logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); @@ -133,6 +139,8 @@ describe("index CLI helpers", () => { afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); + if (savedPluginRoot === undefined) delete process.env.CLAUDE_PLUGIN_ROOT; + else process.env.CLAUDE_PLUGIN_ROOT = savedPluginRoot; vi.restoreAllMocks(); });