specvector 0.0.1 → 0.1.2
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 +132 -12
- package/package.json +28 -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 +321 -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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grep Search Tool - Search codebase for patterns.
|
|
3
|
+
*
|
|
4
|
+
* Uses execFile with array arguments to prevent command injection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "child_process";
|
|
8
|
+
import { promisify } from "util";
|
|
9
|
+
import type { Tool, ToolResult } from "../types";
|
|
10
|
+
import { ok, err } from "../../types/result";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
/** Maximum number of results to return */
|
|
15
|
+
const DEFAULT_MAX_RESULTS = 50;
|
|
16
|
+
|
|
17
|
+
/** Timeout for grep command in ms */
|
|
18
|
+
const GREP_TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/** Configuration for grep tool */
|
|
21
|
+
export interface GrepConfig {
|
|
22
|
+
/** Working directory for search */
|
|
23
|
+
workingDir: string;
|
|
24
|
+
/** Maximum results to return (default: 50) */
|
|
25
|
+
maxResults?: number;
|
|
26
|
+
/** Timeout in ms (default: 30s) */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the grep tool.
|
|
32
|
+
*/
|
|
33
|
+
export function createGrepTool(config: GrepConfig): Tool {
|
|
34
|
+
const maxResults = config.maxResults ?? DEFAULT_MAX_RESULTS;
|
|
35
|
+
const timeoutMs = config.timeoutMs ?? GREP_TIMEOUT_MS;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: "grep",
|
|
39
|
+
description: "Search for a pattern in files. Returns matching lines with file paths and line numbers. Use this to find usages of functions, imports, or specific code patterns.",
|
|
40
|
+
parameters: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
pattern: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Search pattern (supports regex)",
|
|
46
|
+
},
|
|
47
|
+
include: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "File glob pattern to include (e.g., '*.ts', '*.py'). Optional.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["pattern"],
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
execute: async (args): Promise<ToolResult> => {
|
|
56
|
+
const pattern = args.pattern;
|
|
57
|
+
const include = args.include;
|
|
58
|
+
|
|
59
|
+
// Validate pattern
|
|
60
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
61
|
+
return err({
|
|
62
|
+
code: "INVALID_PATTERN",
|
|
63
|
+
message: "Pattern must be a non-empty string",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Build grep arguments as array (prevents command injection)
|
|
69
|
+
const grepArgs: string[] = [
|
|
70
|
+
"-r", // Recursive
|
|
71
|
+
"-n", // Line numbers
|
|
72
|
+
"-I", // Ignore binary files
|
|
73
|
+
"--color=never",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
if (include && typeof include === "string") {
|
|
77
|
+
// Validate include pattern (only allow safe glob characters)
|
|
78
|
+
if (!/^[\w.*?\[\]/-]+$/.test(include)) {
|
|
79
|
+
return err({
|
|
80
|
+
code: "INVALID_INCLUDE",
|
|
81
|
+
message: "Include pattern contains invalid characters",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
grepArgs.push(`--include=${include}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pattern and search path
|
|
88
|
+
grepArgs.push(pattern, ".");
|
|
89
|
+
|
|
90
|
+
const { stdout, stderr } = await execFileAsync("grep", grepArgs, {
|
|
91
|
+
cwd: config.workingDir,
|
|
92
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
93
|
+
timeout: timeoutMs,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (stderr && !stdout) {
|
|
97
|
+
return err({
|
|
98
|
+
code: "GREP_ERROR",
|
|
99
|
+
message: stderr.trim(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parse and limit results
|
|
104
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
105
|
+
const limitedLines = lines.slice(0, maxResults);
|
|
106
|
+
|
|
107
|
+
if (lines.length === 0) {
|
|
108
|
+
return ok("No matches found.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let result = limitedLines.join("\n");
|
|
112
|
+
if (lines.length > maxResults) {
|
|
113
|
+
result += `\n\n... and ${lines.length - maxResults} more matches (limited to ${maxResults})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return ok(result);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// grep returns exit code 1 when no matches found
|
|
119
|
+
if (isExecError(error) && error.code === 1 && !error.stderr) {
|
|
120
|
+
return ok("No matches found.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle timeout
|
|
124
|
+
if (isExecError(error) && error.killed) {
|
|
125
|
+
return err({
|
|
126
|
+
code: "TIMEOUT",
|
|
127
|
+
message: `Search timed out after ${timeoutMs}ms`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return err({
|
|
132
|
+
code: "GREP_ERROR",
|
|
133
|
+
message: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Type guard for exec errors.
|
|
142
|
+
*/
|
|
143
|
+
function isExecError(error: unknown): error is { code: number; stderr: string; killed?: boolean } {
|
|
144
|
+
return (
|
|
145
|
+
error !== null &&
|
|
146
|
+
typeof error === "object" &&
|
|
147
|
+
"code" in error
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Tools - Barrel export for all tools.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { createReadFileTool, type ReadFileConfig } from "./read-file";
|
|
6
|
+
export { createGrepTool, type GrepConfig } from "./grep";
|
|
7
|
+
export { createListDirTool, type ListDirConfig } from "./list-dir";
|
|
8
|
+
export { createOutlineTool, type OutlineConfig } from "./outline";
|
|
9
|
+
export { createFindSymbolTool, type FindSymbolConfig } from "./find-symbol";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Directory Tool - List files in a directory.
|
|
3
|
+
*
|
|
4
|
+
* Security: Uses path.relative() check for path traversal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdir, stat } from "fs/promises";
|
|
8
|
+
import { resolve, isAbsolute, relative } from "path";
|
|
9
|
+
import type { Tool, ToolResult } from "../types";
|
|
10
|
+
import { ok, err } from "../../types/result";
|
|
11
|
+
|
|
12
|
+
/** Maximum depth for recursive listing */
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
14
|
+
|
|
15
|
+
/** Maximum files to return */
|
|
16
|
+
const DEFAULT_MAX_FILES = 100;
|
|
17
|
+
|
|
18
|
+
/** Configuration for list directory tool */
|
|
19
|
+
export interface ListDirConfig {
|
|
20
|
+
/** Working directory for relative paths */
|
|
21
|
+
workingDir: string;
|
|
22
|
+
/** Maximum depth for recursive listing (default: 3) */
|
|
23
|
+
maxDepth?: number;
|
|
24
|
+
/** Maximum files to return (default: 100) */
|
|
25
|
+
maxFiles?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FileInfo {
|
|
29
|
+
path: string;
|
|
30
|
+
type: "file" | "directory";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create the list_dir tool.
|
|
35
|
+
*/
|
|
36
|
+
export function createListDirTool(config: ListDirConfig): Tool {
|
|
37
|
+
const maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
38
|
+
const maxFiles = config.maxFiles ?? DEFAULT_MAX_FILES;
|
|
39
|
+
// Normalize working directory path
|
|
40
|
+
const normalizedWorkingDir = resolve(config.workingDir);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name: "list_dir",
|
|
44
|
+
description: "List files and directories. Use this to explore the project structure and find relevant files.",
|
|
45
|
+
parameters: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
path: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Directory path (relative to working directory or absolute). Use '.' for current directory.",
|
|
51
|
+
},
|
|
52
|
+
recursive: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
description: "If true, list files recursively (up to max depth). Default: false.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["path"],
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
execute: async (args): Promise<ToolResult> => {
|
|
61
|
+
const pathArg = args.path;
|
|
62
|
+
const recursive = args.recursive === true;
|
|
63
|
+
|
|
64
|
+
// Validate path argument
|
|
65
|
+
if (typeof pathArg !== "string") {
|
|
66
|
+
return err({
|
|
67
|
+
code: "INVALID_PATH",
|
|
68
|
+
message: "Path must be a string",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle empty path or "."
|
|
73
|
+
const targetPath = pathArg.trim() || ".";
|
|
74
|
+
|
|
75
|
+
// Resolve path
|
|
76
|
+
const dirPath = isAbsolute(targetPath)
|
|
77
|
+
? resolve(targetPath)
|
|
78
|
+
: resolve(normalizedWorkingDir, targetPath);
|
|
79
|
+
|
|
80
|
+
// Security: Check path traversal using relative path
|
|
81
|
+
const relativePath = relative(normalizedWorkingDir, dirPath);
|
|
82
|
+
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
83
|
+
return err({
|
|
84
|
+
code: "PATH_TRAVERSAL",
|
|
85
|
+
message: `Path must be within working directory`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const stats = await stat(dirPath);
|
|
91
|
+
|
|
92
|
+
if (!stats.isDirectory()) {
|
|
93
|
+
return err({
|
|
94
|
+
code: "NOT_A_DIRECTORY",
|
|
95
|
+
message: `Path is not a directory: ${pathArg}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const files: FileInfo[] = [];
|
|
100
|
+
await listDirectory(dirPath, normalizedWorkingDir, files, 0, recursive ? maxDepth : 0, maxFiles);
|
|
101
|
+
|
|
102
|
+
if (files.length === 0) {
|
|
103
|
+
return ok("Directory is empty.");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Format output
|
|
107
|
+
const output = files
|
|
108
|
+
.map((f) => `${f.type === "directory" ? "📁" : "📄"} ${f.path}`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
|
|
111
|
+
const result = files.length >= maxFiles
|
|
112
|
+
? `${output}\n\n... limited to ${maxFiles} items`
|
|
113
|
+
: output;
|
|
114
|
+
|
|
115
|
+
return ok(result);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (isNodeError(error)) {
|
|
118
|
+
if (error.code === "ENOENT") {
|
|
119
|
+
return err({
|
|
120
|
+
code: "NOT_FOUND",
|
|
121
|
+
message: `Directory not found: ${pathArg}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (error.code === "EACCES") {
|
|
125
|
+
return err({
|
|
126
|
+
code: "PERMISSION_DENIED",
|
|
127
|
+
message: `Permission denied: ${pathArg}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return err({
|
|
133
|
+
code: "LIST_ERROR",
|
|
134
|
+
message: `Failed to list directory: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recursively list directory contents.
|
|
143
|
+
*/
|
|
144
|
+
async function listDirectory(
|
|
145
|
+
dirPath: string,
|
|
146
|
+
workingDir: string,
|
|
147
|
+
files: FileInfo[],
|
|
148
|
+
depth: number,
|
|
149
|
+
maxDepth: number,
|
|
150
|
+
maxFiles: number
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (files.length >= maxFiles) return;
|
|
153
|
+
|
|
154
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
155
|
+
|
|
156
|
+
// Sort: directories first, then files, both alphabetically
|
|
157
|
+
entries.sort((a, b) => {
|
|
158
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
159
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
160
|
+
return a.name.localeCompare(b.name);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
if (files.length >= maxFiles) break;
|
|
165
|
+
|
|
166
|
+
// Skip hidden files and common non-essential directories
|
|
167
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fullPath = resolve(dirPath, entry.name);
|
|
172
|
+
const relativePath = relative(workingDir, fullPath);
|
|
173
|
+
|
|
174
|
+
files.push({
|
|
175
|
+
path: relativePath,
|
|
176
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Recurse into directories
|
|
180
|
+
if (entry.isDirectory() && depth < maxDepth) {
|
|
181
|
+
await listDirectory(fullPath, workingDir, files, depth + 1, maxDepth, maxFiles);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Type guard for Node.js errors with codes.
|
|
188
|
+
*/
|
|
189
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
190
|
+
return error instanceof Error && "code" in error;
|
|
191
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Outline Tool - Get structure of a file (functions, classes).
|
|
3
|
+
*
|
|
4
|
+
* Uses simple regex-based parsing for TypeScript/JavaScript files.
|
|
5
|
+
* This is fast and works without a full AST parser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, stat } from "fs/promises";
|
|
9
|
+
import { resolve, isAbsolute, relative, extname } from "path";
|
|
10
|
+
import type { Tool, ToolResult } from "../types";
|
|
11
|
+
import { ok, err } from "../../types/result";
|
|
12
|
+
|
|
13
|
+
/** Maximum file size for outline (200KB) */
|
|
14
|
+
const MAX_FILE_SIZE = 200 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Configuration for outline tool */
|
|
17
|
+
export interface OutlineConfig {
|
|
18
|
+
/** Working directory for relative paths */
|
|
19
|
+
workingDir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface OutlineItem {
|
|
23
|
+
type: "function" | "class" | "method" | "interface" | "type" | "const" | "export";
|
|
24
|
+
name: string;
|
|
25
|
+
line: number;
|
|
26
|
+
signature?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create the get_outline tool.
|
|
31
|
+
*/
|
|
32
|
+
export function createOutlineTool(config: OutlineConfig): Tool {
|
|
33
|
+
const normalizedWorkingDir = resolve(config.workingDir);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: "get_outline",
|
|
37
|
+
description: "Get the structure of a source file - functions, classes, interfaces. Use this to understand what's in a file without reading all the code.",
|
|
38
|
+
parameters: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
path: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Path to the file to analyze",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ["path"],
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
execute: async (args): Promise<ToolResult> => {
|
|
50
|
+
const pathArg = args.path;
|
|
51
|
+
|
|
52
|
+
if (typeof pathArg !== "string" || !pathArg.trim()) {
|
|
53
|
+
return err({
|
|
54
|
+
code: "INVALID_PATH",
|
|
55
|
+
message: "Path must be a non-empty string",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve and validate path
|
|
60
|
+
const filePath = isAbsolute(pathArg)
|
|
61
|
+
? resolve(pathArg)
|
|
62
|
+
: resolve(normalizedWorkingDir, pathArg);
|
|
63
|
+
|
|
64
|
+
const relativePath = relative(normalizedWorkingDir, filePath);
|
|
65
|
+
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
66
|
+
return err({
|
|
67
|
+
code: "PATH_TRAVERSAL",
|
|
68
|
+
message: "Path must be within working directory",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const stats = await stat(filePath);
|
|
74
|
+
|
|
75
|
+
if (!stats.isFile()) {
|
|
76
|
+
return err({
|
|
77
|
+
code: "NOT_A_FILE",
|
|
78
|
+
message: `Path is not a file: ${pathArg}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
83
|
+
return err({
|
|
84
|
+
code: "FILE_TOO_LARGE",
|
|
85
|
+
message: `File too large for outline (max 200KB)`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const content = await readFile(filePath, "utf-8");
|
|
90
|
+
const ext = extname(filePath).toLowerCase();
|
|
91
|
+
|
|
92
|
+
// Only support common code files
|
|
93
|
+
const supportedExts = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"];
|
|
94
|
+
if (!supportedExts.includes(ext)) {
|
|
95
|
+
return ok(`Outline not supported for ${ext} files. Use read_file instead.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const items = parseOutline(content, ext);
|
|
99
|
+
|
|
100
|
+
if (items.length === 0) {
|
|
101
|
+
return ok("No functions, classes, or interfaces found in this file.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Format output
|
|
105
|
+
const output = items
|
|
106
|
+
.map(item => {
|
|
107
|
+
const typeIcon = getTypeIcon(item.type);
|
|
108
|
+
const sig = item.signature ? `: ${item.signature}` : "";
|
|
109
|
+
return `${item.line.toString().padStart(4)} │ ${typeIcon} ${item.name}${sig}`;
|
|
110
|
+
})
|
|
111
|
+
.join("\n");
|
|
112
|
+
|
|
113
|
+
return ok(`File: ${pathArg}\n${"─".repeat(50)}\n${output}`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
116
|
+
return err({
|
|
117
|
+
code: "NOT_FOUND",
|
|
118
|
+
message: `File not found: ${pathArg}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (isNodeError(error) && error.code === "EISDIR") {
|
|
122
|
+
return err({
|
|
123
|
+
code: "NOT_A_FILE",
|
|
124
|
+
message: `Path is a directory, not a file: ${pathArg}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return err({
|
|
128
|
+
code: "OUTLINE_ERROR",
|
|
129
|
+
message: `Failed to get outline: ${error instanceof Error ? error.message : "Unknown"}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse file content to extract outline items.
|
|
138
|
+
*/
|
|
139
|
+
function parseOutline(content: string, ext: string): OutlineItem[] {
|
|
140
|
+
const items: OutlineItem[] = [];
|
|
141
|
+
const lines = content.split("\n");
|
|
142
|
+
|
|
143
|
+
// Extension-specific patterns
|
|
144
|
+
const patterns = getPatterns(ext);
|
|
145
|
+
|
|
146
|
+
// Max line length to prevent ReDoS attacks
|
|
147
|
+
const MAX_LINE_LENGTH = 500;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
const rawLine = lines[i];
|
|
151
|
+
const lineNum = i + 1;
|
|
152
|
+
|
|
153
|
+
// Skip long lines to prevent ReDoS (catastrophic backtracking)
|
|
154
|
+
if (!rawLine || rawLine.length > MAX_LINE_LENGTH) continue;
|
|
155
|
+
|
|
156
|
+
// Skip comment lines to avoid false positives
|
|
157
|
+
const trimmed = rawLine.trim();
|
|
158
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("#")) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const { regex, type, nameGroup, sigGroup } of patterns) {
|
|
163
|
+
const match = rawLine.match(regex);
|
|
164
|
+
if (match && match[nameGroup]) {
|
|
165
|
+
items.push({
|
|
166
|
+
type,
|
|
167
|
+
name: match[nameGroup],
|
|
168
|
+
line: lineNum,
|
|
169
|
+
signature: sigGroup && match[sigGroup] ? match[sigGroup].trim() : undefined,
|
|
170
|
+
});
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return items;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface Pattern {
|
|
180
|
+
regex: RegExp;
|
|
181
|
+
type: OutlineItem["type"];
|
|
182
|
+
nameGroup: number;
|
|
183
|
+
sigGroup?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getPatterns(ext: string): Pattern[] {
|
|
187
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
188
|
+
return [
|
|
189
|
+
// export function name(...)
|
|
190
|
+
{ regex: /^export\s+(?:async\s+)?function\s+(\w+)\s*(\([^)]*\))?/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
191
|
+
// function name(...)
|
|
192
|
+
{ regex: /^(?:async\s+)?function\s+(\w+)\s*(\([^)]*\))?/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
193
|
+
// const name = async (...) =>
|
|
194
|
+
{ regex: /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*(?::\s*\w+)?\s*=>/, type: "function", nameGroup: 1 },
|
|
195
|
+
// export class Name
|
|
196
|
+
{ regex: /^export\s+(?:abstract\s+)?class\s+(\w+)/, type: "class", nameGroup: 1 },
|
|
197
|
+
// class Name
|
|
198
|
+
{ regex: /^(?:abstract\s+)?class\s+(\w+)/, type: "class", nameGroup: 1 },
|
|
199
|
+
// export interface Name
|
|
200
|
+
{ regex: /^export\s+interface\s+(\w+)/, type: "interface", nameGroup: 1 },
|
|
201
|
+
// interface Name
|
|
202
|
+
{ regex: /^interface\s+(\w+)/, type: "interface", nameGroup: 1 },
|
|
203
|
+
// export type Name
|
|
204
|
+
{ regex: /^export\s+type\s+(\w+)/, type: "type", nameGroup: 1 },
|
|
205
|
+
// type Name
|
|
206
|
+
{ regex: /^type\s+(\w+)/, type: "type", nameGroup: 1 },
|
|
207
|
+
// method in class/object: name(...) { or async name(...)
|
|
208
|
+
{ regex: /^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/, type: "method", nameGroup: 1 },
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (ext === ".py") {
|
|
213
|
+
return [
|
|
214
|
+
{ regex: /^def\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
215
|
+
{ regex: /^async\s+def\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
216
|
+
{ regex: /^class\s+(\w+)/, type: "class", nameGroup: 1 },
|
|
217
|
+
{ regex: /^\s{4}def\s+(\w+)\s*\(([^)]*)\)/, type: "method", nameGroup: 1, sigGroup: 2 },
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (ext === ".go") {
|
|
222
|
+
return [
|
|
223
|
+
{ regex: /^func\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
224
|
+
{ regex: /^func\s+\(\w+\s+\*?\w+\)\s+(\w+)\s*\(([^)]*)\)/, type: "method", nameGroup: 1, sigGroup: 2 },
|
|
225
|
+
{ regex: /^type\s+(\w+)\s+struct/, type: "class", nameGroup: 1 },
|
|
226
|
+
{ regex: /^type\s+(\w+)\s+interface/, type: "interface", nameGroup: 1 },
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (ext === ".rs") {
|
|
231
|
+
return [
|
|
232
|
+
{ regex: /^pub\s+fn\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
233
|
+
{ regex: /^fn\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
|
|
234
|
+
{ regex: /^pub\s+struct\s+(\w+)/, type: "class", nameGroup: 1 },
|
|
235
|
+
{ regex: /^struct\s+(\w+)/, type: "class", nameGroup: 1 },
|
|
236
|
+
{ regex: /^pub\s+trait\s+(\w+)/, type: "interface", nameGroup: 1 },
|
|
237
|
+
{ regex: /^trait\s+(\w+)/, type: "interface", nameGroup: 1 },
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getTypeIcon(type: OutlineItem["type"]): string {
|
|
245
|
+
switch (type) {
|
|
246
|
+
case "function": return "ƒ";
|
|
247
|
+
case "class": return "◆";
|
|
248
|
+
case "method": return "○";
|
|
249
|
+
case "interface": return "◇";
|
|
250
|
+
case "type": return "τ";
|
|
251
|
+
case "const": return "●";
|
|
252
|
+
case "export": return "→";
|
|
253
|
+
default: return "•";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
258
|
+
return error instanceof Error && "code" in error;
|
|
259
|
+
}
|