specvector 0.0.1 → 0.1.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.
- package/README.md +138 -12
- package/package.json +25 -7
- package/src/agent/.gitkeep +0 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/loop.ts +221 -0
- package/src/agent/tools/find-symbol.ts +224 -0
- package/src/agent/tools/grep.ts +149 -0
- package/src/agent/tools/index.ts +9 -0
- package/src/agent/tools/list-dir.ts +191 -0
- package/src/agent/tools/outline.ts +259 -0
- package/src/agent/tools/read-file.ts +140 -0
- package/src/agent/types.ts +145 -0
- package/src/config/.gitkeep +0 -0
- package/src/config/index.ts +285 -0
- package/src/context/index.ts +11 -0
- package/src/context/linear.ts +201 -0
- package/src/github/.gitkeep +0 -0
- package/src/github/comment.ts +102 -0
- package/src/github/diff.ts +90 -0
- package/src/index.ts +247 -0
- package/src/llm/factory.ts +146 -0
- package/src/llm/index.ts +50 -0
- package/src/llm/ollama.ts +313 -0
- package/src/llm/openrouter.ts +348 -0
- package/src/llm/provider.ts +133 -0
- package/src/mcp/.gitkeep +0 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/mcp-client.ts +382 -0
- package/src/mcp/types.ts +104 -0
- package/src/review/.gitkeep +0 -0
- package/src/review/diff-parser.ts +168 -0
- package/src/review/engine.ts +268 -0
- package/src/review/formatter.ts +168 -0
- package/src/tools/.gitkeep +0 -0
- package/src/types/diff.ts +65 -0
- package/src/types/llm.ts +126 -0
- package/src/types/result.ts +17 -0
- package/src/types/review.ts +111 -0
package/README.md
CHANGED
|
@@ -2,29 +2,155 @@
|
|
|
2
2
|
|
|
3
3
|
> Context-aware AI code review using Model Context Protocol (MCP)
|
|
4
4
|
|
|
5
|
-
🚧 **Coming Soon** — This package is under active development.
|
|
6
|
-
|
|
7
5
|
## What is SpecVector?
|
|
8
6
|
|
|
9
|
-
SpecVector is an open-source AI code review tool that
|
|
7
|
+
SpecVector is an open-source AI code review tool that explores your codebase to provide context-aware feedback on pull requests.
|
|
8
|
+
|
|
9
|
+
Unlike generic AI review tools that only see the diff, SpecVector:
|
|
10
|
+
|
|
11
|
+
- **Reads related files** to understand context
|
|
12
|
+
- **Searches for patterns** to find usages
|
|
13
|
+
- **Explores project structure** to understand architecture
|
|
14
|
+
- **Uses tool calling** for agentic code exploration
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### 1. Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Clone and install
|
|
22
|
+
git clone https://github.com/nedlink/specvector.git
|
|
23
|
+
cd specvector
|
|
24
|
+
bun install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Set up API Key
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Create .env file
|
|
31
|
+
cp .env.example .env
|
|
32
|
+
|
|
33
|
+
# Add your OpenRouter API key
|
|
34
|
+
echo "OPENROUTER_API_KEY=your-key-here" >> .env
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Get a key at [openrouter.ai](https://openrouter.ai)
|
|
38
|
+
|
|
39
|
+
### 3. Run a Review
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Review a PR (dry run - no posting)
|
|
43
|
+
cd /path/to/repo-with-pr
|
|
44
|
+
bun run /path/to/specvector/src/index.ts review 123 --dry-run
|
|
45
|
+
|
|
46
|
+
# Or with mock review (no LLM calls)
|
|
47
|
+
bun run /path/to/specvector/src/index.ts review 123 --mock --dry-run
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CLI Usage
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
SpecVector CLI v0.1.0
|
|
54
|
+
Context-aware AI code review
|
|
55
|
+
|
|
56
|
+
USAGE:
|
|
57
|
+
specvector review <pr-number> Review a pull request
|
|
58
|
+
specvector review <pr-number> --dry-run Preview review without posting
|
|
59
|
+
specvector review <pr-number> --mock Use mock review (no LLM)
|
|
60
|
+
specvector --help Show this help
|
|
61
|
+
specvector --version Show version
|
|
10
62
|
|
|
11
|
-
|
|
63
|
+
ENVIRONMENT:
|
|
64
|
+
OPENROUTER_API_KEY API key for OpenRouter
|
|
65
|
+
SPECVECTOR_PROVIDER LLM provider (openrouter or ollama)
|
|
66
|
+
SPECVECTOR_MODEL Model to use
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## GitHub Action
|
|
70
|
+
|
|
71
|
+
Add to your repository:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
name: SpecVector Code Review
|
|
75
|
+
|
|
76
|
+
on:
|
|
77
|
+
pull_request:
|
|
78
|
+
types: [opened, synchronize, reopened]
|
|
79
|
+
|
|
80
|
+
permissions:
|
|
81
|
+
contents: read
|
|
82
|
+
pull-requests: write
|
|
12
83
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
84
|
+
jobs:
|
|
85
|
+
review:
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- uses: actions/checkout@v4
|
|
89
|
+
with:
|
|
90
|
+
fetch-depth: 0
|
|
17
91
|
|
|
18
|
-
|
|
92
|
+
- uses: oven-sh/setup-bun@v2
|
|
93
|
+
|
|
94
|
+
- name: Install SpecVector
|
|
95
|
+
run: |
|
|
96
|
+
git clone https://github.com/nedlink/specvector.git /tmp/specvector
|
|
97
|
+
cd /tmp/specvector && bun install
|
|
98
|
+
|
|
99
|
+
- name: Review PR
|
|
100
|
+
env:
|
|
101
|
+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
102
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
103
|
+
run: |
|
|
104
|
+
cd ${{ github.workspace }}
|
|
105
|
+
bun run /tmp/specvector/src/index.ts review ${{ github.event.pull_request.number }}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Local Development
|
|
19
109
|
|
|
20
110
|
```bash
|
|
21
|
-
|
|
111
|
+
# Run tests
|
|
112
|
+
bun test
|
|
113
|
+
|
|
114
|
+
# Type check
|
|
115
|
+
bun run check
|
|
116
|
+
|
|
117
|
+
# Test the agent
|
|
118
|
+
bun run scripts/test-agent.ts
|
|
22
119
|
```
|
|
23
120
|
|
|
24
|
-
##
|
|
121
|
+
## LLM Providers
|
|
25
122
|
|
|
26
|
-
|
|
123
|
+
| Provider | Use Case | Setup |
|
|
124
|
+
| ---------- | -------------------------------------- | -------------------- |
|
|
125
|
+
| OpenRouter | Cloud access to Claude, GPT-4, Llama | `OPENROUTER_API_KEY` |
|
|
126
|
+
| Ollama | Self-hosted, air-gapped, privacy-first | `ollama serve` |
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Use Ollama instead of OpenRouter
|
|
130
|
+
SPECVECTOR_PROVIDER=ollama SPECVECTOR_MODEL=llama3.2 bun run src/index.ts review 123 --dry-run
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Architecture
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
PR Diff ───→ Agent Loop ───→ LLM ───→ Review Comment
|
|
137
|
+
↓ ↑
|
|
138
|
+
Tools
|
|
139
|
+
(read_file,
|
|
140
|
+
grep,
|
|
141
|
+
list_dir)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The agent can:
|
|
145
|
+
|
|
146
|
+
- **read_file** — Read source code files
|
|
147
|
+
- **grep** — Search for patterns in the codebase
|
|
148
|
+
- **list_dir** — Explore project structure
|
|
27
149
|
|
|
28
150
|
## License
|
|
29
151
|
|
|
30
152
|
MIT
|
|
153
|
+
|
|
154
|
+
## Contributing
|
|
155
|
+
|
|
156
|
+
PRs welcome! Run `bun test` before submitting.
|
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specvector",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Context-aware AI code review using Model Context Protocol (MCP)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
8
8
|
"specvector": "src/index.ts"
|
|
9
9
|
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
10
15
|
"scripts": {
|
|
11
16
|
"start": "bun src/index.ts",
|
|
12
|
-
"test": "bun test"
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"check": "bun x tsc --noEmit"
|
|
13
19
|
},
|
|
14
20
|
"keywords": [
|
|
15
21
|
"code-review",
|
|
@@ -19,17 +25,29 @@
|
|
|
19
25
|
"model-context-protocol",
|
|
20
26
|
"github-actions",
|
|
21
27
|
"linear",
|
|
22
|
-
"anthropic"
|
|
28
|
+
"anthropic",
|
|
29
|
+
"openrouter"
|
|
23
30
|
],
|
|
24
31
|
"author": "Dragos",
|
|
25
32
|
"license": "MIT",
|
|
26
33
|
"repository": {
|
|
27
34
|
"type": "git",
|
|
28
|
-
"url": "https://github.com/
|
|
35
|
+
"url": "git+https://github.com/Not-Diamond/specvector.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/Not-Diamond/specvector#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/Not-Diamond/specvector/issues"
|
|
29
40
|
},
|
|
30
41
|
"engines": {
|
|
31
|
-
"
|
|
42
|
+
"bun": ">=1.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@larryhudson/linear-mcp-server": "0.1.4"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "latest"
|
|
32
49
|
},
|
|
33
|
-
"
|
|
34
|
-
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
}
|
|
35
53
|
}
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent module - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Re-export types
|
|
6
|
+
export type {
|
|
7
|
+
Tool,
|
|
8
|
+
ToolResult,
|
|
9
|
+
ToolError,
|
|
10
|
+
AgentConfig,
|
|
11
|
+
AgentError,
|
|
12
|
+
AgentErrorCode,
|
|
13
|
+
AgentResult,
|
|
14
|
+
AgentState,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
DEFAULT_AGENT_CONFIG,
|
|
19
|
+
toolToLLMTool,
|
|
20
|
+
createAgentError,
|
|
21
|
+
AgentErrors,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
// Re-export loop
|
|
25
|
+
export { AgentLoop, createAgentLoop } from "./loop";
|
|
26
|
+
|
|
27
|
+
// Re-export tools
|
|
28
|
+
export * from "./tools";
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Loop - Core agentic execution engine.
|
|
3
|
+
*
|
|
4
|
+
* Implements a simple ReAct-style loop:
|
|
5
|
+
* 1. Send messages to LLM
|
|
6
|
+
* 2. If LLM returns tool_calls, execute them in parallel
|
|
7
|
+
* 3. Add tool results to messages and repeat
|
|
8
|
+
* 4. Stop when LLM returns content without tool_calls
|
|
9
|
+
*
|
|
10
|
+
* Fixes applied:
|
|
11
|
+
* - M1: Parallel tool execution with Promise.all
|
|
12
|
+
* - M2: Tool errors sent back to LLM instead of aborting
|
|
13
|
+
* - L2: Per-tool timeout
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
import { ok, err } from "../types/result";
|
|
18
|
+
import type { Message, ToolCall } from "../types/llm";
|
|
19
|
+
import type { LLMProvider, LLMError } from "../llm/provider";
|
|
20
|
+
import type {
|
|
21
|
+
Tool,
|
|
22
|
+
AgentConfig,
|
|
23
|
+
AgentResult,
|
|
24
|
+
AgentError,
|
|
25
|
+
} from "./types";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_AGENT_CONFIG,
|
|
28
|
+
toolToLLMTool,
|
|
29
|
+
AgentErrors,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
/** Default timeout for individual tool execution (30 seconds) */
|
|
33
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Agent loop - manages LLM ↔ Tool interaction.
|
|
37
|
+
*/
|
|
38
|
+
export class AgentLoop {
|
|
39
|
+
private readonly provider: LLMProvider;
|
|
40
|
+
private readonly tools: Map<string, Tool>;
|
|
41
|
+
private readonly config: Required<AgentConfig>;
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
provider: LLMProvider,
|
|
45
|
+
tools: Tool[],
|
|
46
|
+
config?: AgentConfig
|
|
47
|
+
) {
|
|
48
|
+
this.provider = provider;
|
|
49
|
+
this.tools = new Map(tools.map((t) => [t.name, t]));
|
|
50
|
+
this.config = { ...DEFAULT_AGENT_CONFIG, ...config };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run the agent loop with a task.
|
|
55
|
+
*/
|
|
56
|
+
async run(task: string): Promise<AgentResult> {
|
|
57
|
+
const messages: Message[] = [
|
|
58
|
+
{ role: "system", content: this.config.systemPrompt },
|
|
59
|
+
{ role: "user", content: task },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const startTime = Date.now();
|
|
63
|
+
let iteration = 0;
|
|
64
|
+
|
|
65
|
+
while (iteration < this.config.maxIterations) {
|
|
66
|
+
iteration++;
|
|
67
|
+
|
|
68
|
+
// Check timeout
|
|
69
|
+
if (Date.now() - startTime > this.config.timeoutMs) {
|
|
70
|
+
return err(AgentErrors.timeout());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get LLM response
|
|
74
|
+
const llmResult = await this.provider.chat(
|
|
75
|
+
messages,
|
|
76
|
+
{
|
|
77
|
+
tools: Array.from(this.tools.values()).map(toolToLLMTool),
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!llmResult.ok) {
|
|
82
|
+
return err(this.mapLLMError(llmResult.error));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const response = llmResult.value;
|
|
86
|
+
|
|
87
|
+
// Check for tool calls
|
|
88
|
+
if (response.tool_calls && response.tool_calls.length > 0) {
|
|
89
|
+
// Add assistant message with tool calls
|
|
90
|
+
messages.push({
|
|
91
|
+
role: "assistant",
|
|
92
|
+
content: response.content,
|
|
93
|
+
tool_calls: response.tool_calls,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Execute tools in parallel (M1 fix)
|
|
97
|
+
const toolMessages = await this.executeToolsParallel(response.tool_calls);
|
|
98
|
+
|
|
99
|
+
// Add all tool results to messages (M2 fix: errors as messages, not aborts)
|
|
100
|
+
for (const msg of toolMessages) {
|
|
101
|
+
messages.push(msg);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// No tool calls - we have the final answer
|
|
105
|
+
return ok(response.content ?? "");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Exceeded max iterations
|
|
110
|
+
return err(AgentErrors.maxIterationsExceeded(this.config.maxIterations));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Execute tool calls in parallel and return tool messages.
|
|
115
|
+
* Tool errors are returned as error messages to the LLM, not thrown.
|
|
116
|
+
*/
|
|
117
|
+
private async executeToolsParallel(toolCalls: ToolCall[]): Promise<Message[]> {
|
|
118
|
+
const results = await Promise.all(
|
|
119
|
+
toolCalls.map(call => this.executeSingleTool(call))
|
|
120
|
+
);
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a single tool call with timeout.
|
|
126
|
+
* Returns a tool message (success or error content).
|
|
127
|
+
*/
|
|
128
|
+
private async executeSingleTool(call: ToolCall): Promise<Message> {
|
|
129
|
+
const tool = this.tools.get(call.name);
|
|
130
|
+
|
|
131
|
+
if (!tool) {
|
|
132
|
+
// Tool not found - return error message to LLM
|
|
133
|
+
return {
|
|
134
|
+
role: "tool" as const,
|
|
135
|
+
content: `Error: Tool '${call.name}' not found. Available tools: ${Array.from(this.tools.keys()).join(", ")}`,
|
|
136
|
+
tool_call_id: call.id,
|
|
137
|
+
name: call.name,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Parse arguments
|
|
142
|
+
let args: Record<string, unknown>;
|
|
143
|
+
try {
|
|
144
|
+
args = JSON.parse(call.arguments);
|
|
145
|
+
} catch {
|
|
146
|
+
return {
|
|
147
|
+
role: "tool" as const,
|
|
148
|
+
content: `Error: Invalid JSON arguments for tool '${call.name}'`,
|
|
149
|
+
tool_call_id: call.id,
|
|
150
|
+
name: call.name,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Execute tool with timeout (L2 fix)
|
|
155
|
+
try {
|
|
156
|
+
const toolResult = await Promise.race([
|
|
157
|
+
tool.execute(args),
|
|
158
|
+
this.createToolTimeout(call.name),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
if (!toolResult.ok) {
|
|
162
|
+
// Tool returned an error - send it back to LLM (M2 fix)
|
|
163
|
+
return {
|
|
164
|
+
role: "tool" as const,
|
|
165
|
+
content: `Error: ${toolResult.error.message}`,
|
|
166
|
+
tool_call_id: call.id,
|
|
167
|
+
name: call.name,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Success
|
|
172
|
+
return {
|
|
173
|
+
role: "tool" as const,
|
|
174
|
+
content: toolResult.value,
|
|
175
|
+
tool_call_id: call.id,
|
|
176
|
+
name: call.name,
|
|
177
|
+
};
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Timeout or unexpected error
|
|
180
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
181
|
+
return {
|
|
182
|
+
role: "tool" as const,
|
|
183
|
+
content: `Error: ${message}`,
|
|
184
|
+
tool_call_id: call.id,
|
|
185
|
+
name: call.name,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a timeout promise for tool execution.
|
|
192
|
+
*/
|
|
193
|
+
private createToolTimeout(toolName: string): Promise<never> {
|
|
194
|
+
return new Promise((_, reject) => {
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
reject(new Error(`Tool '${toolName}' timed out after ${DEFAULT_TOOL_TIMEOUT_MS}ms`));
|
|
197
|
+
}, DEFAULT_TOOL_TIMEOUT_MS);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Map LLM error to agent error.
|
|
203
|
+
*/
|
|
204
|
+
private mapLLMError(error: LLMError): AgentError {
|
|
205
|
+
return AgentErrors.llmError(
|
|
206
|
+
`LLM error: ${error.message}`,
|
|
207
|
+
error.cause
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Create an agent loop.
|
|
214
|
+
*/
|
|
215
|
+
export function createAgentLoop(
|
|
216
|
+
provider: LLMProvider,
|
|
217
|
+
tools: Tool[],
|
|
218
|
+
config?: AgentConfig
|
|
219
|
+
): AgentLoop {
|
|
220
|
+
return new AgentLoop(provider, tools, config);
|
|
221
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find Symbol Tool - Find where a symbol is defined.
|
|
3
|
+
*
|
|
4
|
+
* Uses grep with smart patterns to find function, class, and variable definitions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "child_process";
|
|
8
|
+
import { promisify } from "util";
|
|
9
|
+
import { resolve } from "path";
|
|
10
|
+
import type { Tool, ToolResult } from "../types";
|
|
11
|
+
import { ok, err } from "../../types/result";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
/** Default timeout for symbol search (10 seconds) */
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/** Maximum results to return */
|
|
19
|
+
const MAX_RESULTS = 20;
|
|
20
|
+
|
|
21
|
+
/** Configuration for find_symbol tool */
|
|
22
|
+
export interface FindSymbolConfig {
|
|
23
|
+
/** Working directory for search */
|
|
24
|
+
workingDir: string;
|
|
25
|
+
/** Timeout in milliseconds */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create the find_symbol tool.
|
|
31
|
+
*/
|
|
32
|
+
export function createFindSymbolTool(config: FindSymbolConfig): Tool {
|
|
33
|
+
const normalizedWorkingDir = resolve(config.workingDir);
|
|
34
|
+
const timeout = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name: "find_symbol",
|
|
38
|
+
description: "Find where a function, class, or variable is defined. Use this to locate definitions when you see a symbol being used.",
|
|
39
|
+
parameters: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
symbol: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Name of the function, class, or variable to find",
|
|
45
|
+
},
|
|
46
|
+
type: {
|
|
47
|
+
type: "string",
|
|
48
|
+
enum: ["function", "class", "variable", "any"],
|
|
49
|
+
description: "Type of symbol to find (optional, defaults to 'any')",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["symbol"],
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
execute: async (args): Promise<ToolResult> => {
|
|
56
|
+
const symbolArg = args.symbol;
|
|
57
|
+
const typeArg = (args.type as string) ?? "any";
|
|
58
|
+
|
|
59
|
+
if (typeof symbolArg !== "string" || !symbolArg.trim()) {
|
|
60
|
+
return err({
|
|
61
|
+
code: "INVALID_SYMBOL",
|
|
62
|
+
message: "Symbol must be a non-empty string",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// SECURITY: Validate symbol name (alphanumeric + underscore only)
|
|
67
|
+
// This prevents: ReDoS (no regex metacharacters like +*?), command injection (no shell chars)
|
|
68
|
+
// Safe to embed in regex patterns after this check passes
|
|
69
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(symbolArg)) {
|
|
70
|
+
return err({
|
|
71
|
+
code: "INVALID_SYMBOL",
|
|
72
|
+
message: "Symbol must be a valid identifier",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build search patterns based on type
|
|
77
|
+
// SECURITY: symbolArg is validated above - only [a-zA-Z0-9_] chars allowed
|
|
78
|
+
const patterns = buildPatterns(symbolArg, typeArg);
|
|
79
|
+
|
|
80
|
+
// Pre-compile regex patterns for efficient labeling (used in result processing)
|
|
81
|
+
const compiledPatterns = patterns.map(p => ({
|
|
82
|
+
regex: new RegExp(p.pattern),
|
|
83
|
+
label: p.label,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const results: Array<{ file: string; line: number; content: string; type: string }> = [];
|
|
87
|
+
|
|
88
|
+
// Combine all patterns into single grep for performance
|
|
89
|
+
const combinedPattern = patterns.map(p => `(${p.pattern})`).join("|");
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync(
|
|
93
|
+
"grep",
|
|
94
|
+
[
|
|
95
|
+
"-rn", // Recursive with line numbers
|
|
96
|
+
"-E", // Extended regex
|
|
97
|
+
"--include=*.ts",
|
|
98
|
+
"--include=*.tsx",
|
|
99
|
+
"--include=*.js",
|
|
100
|
+
"--include=*.jsx",
|
|
101
|
+
"--include=*.py",
|
|
102
|
+
"--include=*.go",
|
|
103
|
+
"--include=*.rs",
|
|
104
|
+
combinedPattern,
|
|
105
|
+
".",
|
|
106
|
+
],
|
|
107
|
+
{
|
|
108
|
+
cwd: normalizedWorkingDir,
|
|
109
|
+
timeout,
|
|
110
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Parse grep output (handles both Unix and Windows paths)
|
|
115
|
+
for (const line of stdout.split("\n")) {
|
|
116
|
+
if (!line.trim() || results.length >= MAX_RESULTS) continue;
|
|
117
|
+
|
|
118
|
+
// Match both ./path and .\path (Windows)
|
|
119
|
+
const match = line.match(/^\.[\\/](.+?):(\d+):(.*)$/);
|
|
120
|
+
if (match) {
|
|
121
|
+
const file = match[1];
|
|
122
|
+
const lineNum = match[2];
|
|
123
|
+
const content = match[3];
|
|
124
|
+
|
|
125
|
+
if (!file || !lineNum) continue;
|
|
126
|
+
|
|
127
|
+
// Skip node_modules and test files for cleaner results
|
|
128
|
+
// Handle both forward and back slashes
|
|
129
|
+
if (file.includes("node_modules") || file.includes(".test.")) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Determine which pattern matched for the label (using pre-compiled regex)
|
|
134
|
+
// Limit line length to prevent ReDoS on malformed grep output
|
|
135
|
+
const safeContent = (content ?? "").slice(0, 500);
|
|
136
|
+
const matchedPattern = compiledPatterns.find(p => p.regex.test(safeContent));
|
|
137
|
+
const label = matchedPattern?.label ?? "definition";
|
|
138
|
+
|
|
139
|
+
results.push({
|
|
140
|
+
file: file.replace(/\\/g, "/"), // Normalize to forward slashes
|
|
141
|
+
line: parseInt(lineNum, 10),
|
|
142
|
+
content: (content ?? "").trim().slice(0, 100),
|
|
143
|
+
type: label,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// grep returns exit code 1 when no matches - that's expected
|
|
149
|
+
const isExecError = (err: unknown): err is { code: number; stderr?: string } =>
|
|
150
|
+
err !== null && typeof err === "object" && "code" in err;
|
|
151
|
+
|
|
152
|
+
if (isExecError(error) && error.code === 1 && !error.stderr) {
|
|
153
|
+
// No matches - continue to return empty results message
|
|
154
|
+
} else if (error instanceof Error) {
|
|
155
|
+
// Real error - log it for debugging
|
|
156
|
+
console.error(`find_symbol grep error: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (results.length === 0) {
|
|
161
|
+
return ok(`No definition found for symbol '${symbolArg}'`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Format output
|
|
165
|
+
const output = results
|
|
166
|
+
.map(r => `${r.file}:${r.line} (${r.type})\n ${r.content}`)
|
|
167
|
+
.join("\n\n");
|
|
168
|
+
|
|
169
|
+
return ok(`Found ${results.length} definition(s) for '${symbolArg}':\n\n${output}`);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface SearchPattern {
|
|
175
|
+
pattern: string;
|
|
176
|
+
label: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildPatterns(symbol: string, type: string): SearchPattern[] {
|
|
180
|
+
const patterns: SearchPattern[] = [];
|
|
181
|
+
|
|
182
|
+
// Function patterns
|
|
183
|
+
if (type === "function" || type === "any") {
|
|
184
|
+
patterns.push(
|
|
185
|
+
// TypeScript/JavaScript
|
|
186
|
+
{ pattern: `^(export\\s+)?(async\\s+)?function\\s+${symbol}\\s*\\(`, label: "function" },
|
|
187
|
+
{ pattern: `^(export\\s+)?const\\s+${symbol}\\s*=\\s*(async\\s+)?\\([^)]*\\)\\s*=>`, label: "arrow fn" },
|
|
188
|
+
{ pattern: `^(export\\s+)?const\\s+${symbol}\\s*=\\s*(async\\s+)?function`, label: "fn expr" },
|
|
189
|
+
// Python
|
|
190
|
+
{ pattern: `^(async\\s+)?def\\s+${symbol}\\s*\\(`, label: "function" },
|
|
191
|
+
// Go
|
|
192
|
+
{ pattern: `^func\\s+${symbol}\\s*\\(`, label: "function" },
|
|
193
|
+
// Rust
|
|
194
|
+
{ pattern: `^(pub\\s+)?fn\\s+${symbol}\\s*[(<]`, label: "function" },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Class patterns
|
|
199
|
+
if (type === "class" || type === "any") {
|
|
200
|
+
patterns.push(
|
|
201
|
+
// TypeScript/JavaScript
|
|
202
|
+
{ pattern: `^(export\\s+)?(abstract\\s+)?class\\s+${symbol}\\b`, label: "class" },
|
|
203
|
+
// Python
|
|
204
|
+
{ pattern: `^class\\s+${symbol}\\s*[:(]`, label: "class" },
|
|
205
|
+
// Go (struct)
|
|
206
|
+
{ pattern: `^type\\s+${symbol}\\s+struct`, label: "struct" },
|
|
207
|
+
// Rust
|
|
208
|
+
{ pattern: `^(pub\\s+)?struct\\s+${symbol}\\b`, label: "struct" },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Variable/const patterns
|
|
213
|
+
if (type === "variable" || type === "any") {
|
|
214
|
+
patterns.push(
|
|
215
|
+
// TypeScript/JavaScript
|
|
216
|
+
{ pattern: `^(export\\s+)?(const|let|var)\\s+${symbol}\\s*[=:]`, label: "variable" },
|
|
217
|
+
// Interface/Type
|
|
218
|
+
{ pattern: `^(export\\s+)?interface\\s+${symbol}\\b`, label: "interface" },
|
|
219
|
+
{ pattern: `^(export\\s+)?type\\s+${symbol}\\s*=`, label: "type" },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return patterns;
|
|
224
|
+
}
|