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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Review Engine - Connects diff parsing with AI agent for code review.
3
+ *
4
+ * This is the core orchestration layer that:
5
+ * 1. Takes a PR diff
6
+ * 2. Creates an agent with codebase tools
7
+ * 3. Asks the LLM to review the changes
8
+ * 4. Parses the response into structured findings
9
+ */
10
+
11
+ import { createProvider } from "../llm";
12
+ import { createAgentLoop } from "../agent/loop";
13
+ import { createReadFileTool } from "../agent/tools/read-file";
14
+ import { createGrepTool } from "../agent/tools/grep";
15
+ import { createListDirTool } from "../agent/tools/list-dir";
16
+ import { createOutlineTool } from "../agent/tools/outline";
17
+ import { createFindSymbolTool } from "../agent/tools/find-symbol";
18
+ import { calculateStats, determineRecommendation } from "../types/review";
19
+ import type { ReviewResult, ReviewFinding, Severity } from "../types/review";
20
+ import type { Result } from "../types/result";
21
+ import { ok, err } from "../types/result";
22
+ import { loadConfig, getStrictnessModifier } from "../config";
23
+ import { getLinearContextForReview } from "../context";
24
+
25
+ /** Review engine configuration */
26
+ export interface ReviewConfig {
27
+ /** Provider to use (openrouter or ollama) */
28
+ provider?: "openrouter" | "ollama";
29
+ /** Model to use */
30
+ model?: string;
31
+ /** Working directory for tools */
32
+ workingDir: string;
33
+ /** Max iterations for agent */
34
+ maxIterations?: number;
35
+ /** Branch name for Linear ticket extraction */
36
+ branchName?: string;
37
+ /** PR title for Linear ticket extraction */
38
+ prTitle?: string;
39
+ /** PR body for Linear ticket extraction */
40
+ prBody?: string;
41
+ }
42
+
43
+ /** Error type for review engine */
44
+ export interface ReviewError {
45
+ code: "PROVIDER_ERROR" | "AGENT_ERROR" | "PARSE_ERROR";
46
+ message: string;
47
+ }
48
+
49
+ /**
50
+ * Run an AI-powered code review on a diff.
51
+ */
52
+ export async function runReview(
53
+ diff: string,
54
+ diffSummary: string,
55
+ config: ReviewConfig
56
+ ): Promise<Result<ReviewResult, ReviewError>> {
57
+ // Load config from file (falls back to defaults)
58
+ const fileConfig = await loadConfig(config.workingDir);
59
+
60
+ // Merge: explicit config > file config > env vars > defaults
61
+ const providerName = config.provider ||
62
+ fileConfig.provider ||
63
+ (process.env.SPECVECTOR_PROVIDER as "openrouter" | "ollama") ||
64
+ "openrouter";
65
+
66
+ const model = config.model ||
67
+ fileConfig.model ||
68
+ process.env.SPECVECTOR_MODEL ||
69
+ "anthropic/claude-sonnet-4.5";
70
+
71
+ const strictness = fileConfig.strictness || "normal";
72
+
73
+ // Log configuration
74
+ console.log(`🤖 Model: ${model}`);
75
+ console.log(`📏 Strictness: ${strictness}`);
76
+
77
+ const providerResult = createProvider({
78
+ provider: providerName,
79
+ model,
80
+ apiKey: process.env.OPENROUTER_API_KEY,
81
+ });
82
+
83
+ if (!providerResult.ok) {
84
+ return err({
85
+ code: "PROVIDER_ERROR",
86
+ message: providerResult.error.message,
87
+ });
88
+ }
89
+
90
+ // Create tools
91
+ const tools = [
92
+ createReadFileTool({ workingDir: config.workingDir, maxFileSize: 100 * 1024 }),
93
+ createGrepTool({ workingDir: config.workingDir, maxResults: 30 }),
94
+ createListDirTool({ workingDir: config.workingDir, maxDepth: 2, maxFiles: 50 }),
95
+ createOutlineTool({ workingDir: config.workingDir }),
96
+ createFindSymbolTool({ workingDir: config.workingDir }),
97
+ ];
98
+
99
+ // Build system prompt with strictness modifier
100
+ const strictnessGuidance = getStrictnessModifier(strictness);
101
+ let systemPrompt = REVIEW_SYSTEM_PROMPT + `\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
102
+
103
+ // Fetch Linear context if available
104
+ const linearResult = await getLinearContextForReview(
105
+ config.branchName,
106
+ config.prTitle,
107
+ config.prBody
108
+ );
109
+
110
+ if (linearResult.context) {
111
+ systemPrompt = linearResult.context + "\n\n" + systemPrompt;
112
+ // linearResult.ticketId available for future use (e.g., in review output)
113
+ console.log(`📎 Loaded Linear context for ticket: ${linearResult.ticketId}`);
114
+ } else if (linearResult.warning) {
115
+ console.warn(`⚠️ ${linearResult.warning}`);
116
+ }
117
+
118
+ // Create agent
119
+ const agent = createAgentLoop(providerResult.value, tools, {
120
+ maxIterations: config.maxIterations || fileConfig.maxIterations || 15,
121
+ timeoutMs: 3 * 60 * 1000, // 3 minutes
122
+ systemPrompt,
123
+ });
124
+
125
+ // Build the review task
126
+ const task = buildReviewTask(diff, diffSummary);
127
+
128
+ // Run the agent
129
+ const agentResult = await agent.run(task);
130
+
131
+ if (!agentResult.ok) {
132
+ return err({
133
+ code: "AGENT_ERROR",
134
+ message: agentResult.error.message,
135
+ });
136
+ }
137
+
138
+ // Parse the response into structured findings
139
+ const reviewResult = parseReviewResponse(agentResult.value, diffSummary);
140
+
141
+ return ok(reviewResult);
142
+ }
143
+
144
+ /**
145
+ * System prompt for the code review agent.
146
+ */
147
+ const REVIEW_SYSTEM_PROMPT = `You are a pragmatic code reviewer. Your job is to catch REAL problems, not nitpick.
148
+
149
+ ## Tools Available
150
+ - read_file: Read source files to understand context
151
+ - grep: Search for patterns to find related code
152
+ - list_dir: Explore project structure
153
+ - get_outline: Get functions/classes in a file (fast overview)
154
+ - find_symbol: Find where a function or class is defined
155
+
156
+ ## What to Look For (in priority order)
157
+ 1. **CRITICAL**: Security vulnerabilities, data loss, crashes
158
+ 2. **HIGH**: Bugs that WILL break functionality in production
159
+ 3. **MEDIUM**: Significant code quality issues (not style nits)
160
+
161
+ ## What NOT to Flag
162
+ - Style preferences or "I would do it differently"
163
+ - Theoretical performance issues without evidence
164
+ - Missing edge case tests for working code
165
+ - "Could be refactored" suggestions
166
+ - Code that works but isn't perfect
167
+
168
+ ## Key Principle
169
+ Most PRs should have 0-2 findings. If you're finding 5+ issues, you're being too picky.
170
+ Only flag issues you'd actually block a PR for in a real code review.
171
+
172
+ ## Response Format
173
+ SUMMARY: [1-2 sentences - is this code ready to merge?]
174
+
175
+ FINDINGS:
176
+ - [CRITICAL|HIGH|MEDIUM] [Category]: [Title]
177
+ [Brief description + how to fix]
178
+ File: [filename]
179
+
180
+ If the code is ready to merge, respond with:
181
+ SUMMARY: [Positive assessment]
182
+ FINDINGS: None
183
+
184
+ Maximum 3 findings. Focus on what matters.`;
185
+
186
+ /**
187
+ * Build the review task for the agent.
188
+ */
189
+ function buildReviewTask(diff: string, diffSummary: string): string {
190
+ return `Please review this pull request.
191
+
192
+ ## Changes Overview
193
+ ${diffSummary}
194
+
195
+ ## Diff
196
+ \`\`\`diff
197
+ ${diff.slice(0, 15000)}
198
+ \`\`\`
199
+ ${diff.length > 15000 ? "\n(diff truncated, use tools to read full files if needed)" : ""}
200
+
201
+ ## Instructions
202
+ 1. First, understand what the changes are doing
203
+ 2. Use tools to explore related code if needed (find usages, read implementations)
204
+ 3. Identify any issues with the changes
205
+ 4. Provide your review in the specified format`;
206
+ }
207
+
208
+ /**
209
+ * Parse the agent's response into structured findings.
210
+ */
211
+ function parseReviewResponse(response: string, diffSummary: string): ReviewResult {
212
+ const findings: ReviewFinding[] = [];
213
+
214
+ // Extract summary
215
+ const summaryMatch = response.match(/SUMMARY:\s*(.+?)(?=\n\nFINDINGS:|$)/s);
216
+ const summary = summaryMatch?.[1]?.trim() ?? response.slice(0, 200);
217
+
218
+ // Extract findings
219
+ const findingsSection = response.match(/FINDINGS:\s*([\s\S]*?)$/);
220
+ const findingsText = findingsSection?.[1] ?? "";
221
+
222
+ if (findingsText && !findingsText.toLowerCase().includes("none")) {
223
+ // Parse each finding
224
+ const findingPattern = /\[?(CRITICAL|HIGH|MEDIUM|LOW)\]?\s*\[?([^\]:\n]+)\]?:\s*([^\n]+)\n([\s\S]*?)(?=\n\s*-\s*\[?(?:CRITICAL|HIGH|MEDIUM|LOW)|$)/gi;
225
+
226
+ let match;
227
+ while ((match = findingPattern.exec(findingsText)) !== null) {
228
+ const severity = match[1];
229
+ const category = match[2];
230
+ const title = match[3];
231
+ const body = match[4] ?? "";
232
+
233
+ if (!severity || !category || !title) continue;
234
+
235
+ // Parse suggestion and file from body
236
+ const suggestionMatch = body.match(/Suggestion:\s*(.+?)(?=\n|File:|$)/i);
237
+ const fileMatch = body.match(/File:\s*(.+?)(?=\n|$)/i);
238
+ const description = body
239
+ .replace(/Suggestion:[\s\S]*?(?=\n|File:|$)/i, '')
240
+ .replace(/File:[\s\S]*?(?=\n|$)/i, '')
241
+ .trim();
242
+
243
+ findings.push({
244
+ severity: severity.toUpperCase() as Severity,
245
+ category: category.trim(),
246
+ title: title.trim(),
247
+ description: description || title.trim(),
248
+ suggestion: suggestionMatch?.[1]?.trim(),
249
+ file: fileMatch?.[1]?.trim(),
250
+ });
251
+ }
252
+ }
253
+
254
+ // Calculate stats
255
+ const filesReviewed = (diffSummary.match(/files? changed/i)?.[0] || "")
256
+ .match(/\d+/)?.[0] || "0";
257
+
258
+ const stats = calculateStats(findings);
259
+ const recommendation = determineRecommendation(stats);
260
+
261
+ return {
262
+ findings,
263
+ summary,
264
+ recommendation,
265
+ stats,
266
+ filesReviewed: parseInt(filesReviewed, 10) || 0,
267
+ };
268
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Formatter for review comments.
3
+ * Converts ReviewResult into markdown for PR comments.
4
+ */
5
+
6
+ import type { ReviewFinding, ReviewResult, Severity } from "../types/review";
7
+
8
+ /**
9
+ * Severity emoji indicators.
10
+ */
11
+ const SEVERITY_EMOJI: Record<Severity, string> = {
12
+ CRITICAL: "🔴",
13
+ HIGH: "🟠",
14
+ MEDIUM: "🟡",
15
+ LOW: "🟢",
16
+ };
17
+
18
+ /**
19
+ * Recommendation emoji indicators.
20
+ */
21
+ const RECOMMENDATION_EMOJI: Record<ReviewResult["recommendation"], string> = {
22
+ APPROVE: "✅",
23
+ REQUEST_CHANGES: "🔴",
24
+ COMMENT: "💬",
25
+ };
26
+
27
+ /**
28
+ * Format a review result as a GitHub PR comment.
29
+ */
30
+ export function formatReviewComment(result: ReviewResult): string {
31
+ const lines: string[] = [];
32
+
33
+ // Header
34
+ lines.push("## 🔍 SpecVector Code Review");
35
+ lines.push("");
36
+
37
+ // Recommendation badge
38
+ const recEmoji = RECOMMENDATION_EMOJI[result.recommendation];
39
+ const recText = formatRecommendation(result.recommendation);
40
+ lines.push(`**Recommendation:** ${recEmoji} ${recText}`);
41
+ lines.push("");
42
+
43
+ // Stats summary
44
+ lines.push(`**Files Reviewed:** ${result.filesReviewed}`);
45
+ if (result.stats.total > 0) {
46
+ const statParts: string[] = [];
47
+ if (result.stats.critical > 0) statParts.push(`${result.stats.critical} critical`);
48
+ if (result.stats.high > 0) statParts.push(`${result.stats.high} high`);
49
+ if (result.stats.medium > 0) statParts.push(`${result.stats.medium} medium`);
50
+ if (result.stats.low > 0) statParts.push(`${result.stats.low} low`);
51
+ lines.push(`**Issues Found:** ${statParts.join(", ")}`);
52
+ } else {
53
+ lines.push("**Issues Found:** None! 🎉");
54
+ }
55
+ lines.push("");
56
+
57
+ // Summary section
58
+ lines.push("### Summary");
59
+ lines.push("");
60
+ lines.push(result.summary);
61
+ lines.push("");
62
+
63
+ // Findings by severity (only include sections with findings)
64
+ if (result.stats.critical > 0) {
65
+ lines.push("---");
66
+ lines.push("");
67
+ lines.push(`### ${SEVERITY_EMOJI.CRITICAL} Critical Issues (${result.stats.critical})`);
68
+ lines.push("");
69
+ lines.push(...formatFindings(result.findings, "CRITICAL"));
70
+ }
71
+
72
+ if (result.stats.high > 0) {
73
+ lines.push("---");
74
+ lines.push("");
75
+ lines.push(`### ${SEVERITY_EMOJI.HIGH} High Issues (${result.stats.high})`);
76
+ lines.push("");
77
+ lines.push(...formatFindings(result.findings, "HIGH"));
78
+ }
79
+
80
+ if (result.stats.medium > 0) {
81
+ lines.push("---");
82
+ lines.push("");
83
+ lines.push(`### ${SEVERITY_EMOJI.MEDIUM} Medium Issues (${result.stats.medium})`);
84
+ lines.push("");
85
+ lines.push(...formatFindings(result.findings, "MEDIUM"));
86
+ }
87
+
88
+ if (result.stats.low > 0) {
89
+ lines.push("---");
90
+ lines.push("");
91
+ lines.push(`### ${SEVERITY_EMOJI.LOW} Low Issues (${result.stats.low})`);
92
+ lines.push("");
93
+ lines.push(...formatFindings(result.findings, "LOW"));
94
+ }
95
+
96
+ // Footer
97
+ lines.push("---");
98
+ lines.push("");
99
+ lines.push("*Powered by [SpecVector](https://github.com/Not-Diamond/specvector) — Context-aware AI code review*");
100
+
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /**
105
+ * Format findings for a specific severity level.
106
+ */
107
+ function formatFindings(findings: ReviewFinding[], severity: Severity): string[] {
108
+ const lines: string[] = [];
109
+ const filtered = findings.filter((f) => f.severity === severity);
110
+
111
+ for (const finding of filtered) {
112
+ // Title with optional category
113
+ const categoryPrefix = finding.category ? `${finding.category}: ` : "";
114
+ lines.push(`#### ${categoryPrefix}${finding.title}`);
115
+
116
+ // File reference
117
+ if (finding.file) {
118
+ const lineRef = finding.line ? `:${finding.line}` : "";
119
+ lines.push(`\`${finding.file}${lineRef}\``);
120
+ }
121
+
122
+ lines.push("");
123
+ lines.push(finding.description);
124
+
125
+ // Suggestion
126
+ if (finding.suggestion) {
127
+ lines.push("");
128
+ lines.push(`**Suggestion:** ${finding.suggestion}`);
129
+ }
130
+
131
+ lines.push("");
132
+ }
133
+
134
+ return lines;
135
+ }
136
+
137
+ /**
138
+ * Format recommendation as human-readable text.
139
+ */
140
+ function formatRecommendation(rec: ReviewResult["recommendation"]): string {
141
+ switch (rec) {
142
+ case "APPROVE":
143
+ return "Approve";
144
+ case "REQUEST_CHANGES":
145
+ return "Request Changes";
146
+ case "COMMENT":
147
+ return "Comment";
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Format a compact summary for CLI output.
153
+ */
154
+ export function formatReviewSummary(result: ReviewResult): string {
155
+ const recEmoji = RECOMMENDATION_EMOJI[result.recommendation];
156
+ const recText = formatRecommendation(result.recommendation);
157
+
158
+ const lines = [
159
+ `${recEmoji} ${recText}`,
160
+ "",
161
+ `Files Reviewed: ${result.filesReviewed}`,
162
+ `Issues: ${result.stats.critical} critical, ${result.stats.high} high, ${result.stats.medium} medium, ${result.stats.low} low`,
163
+ "",
164
+ result.summary,
165
+ ];
166
+
167
+ return lines.join("\n");
168
+ }
File without changes
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Diff parsing types for SpecVector code review.
3
+ */
4
+
5
+ /**
6
+ * A single hunk within a diff file, representing a contiguous change.
7
+ */
8
+ export interface DiffHunk {
9
+ /** Starting line in the old file */
10
+ oldStart: number;
11
+ /** Number of lines in the old file */
12
+ oldLines: number;
13
+ /** Starting line in the new file */
14
+ newStart: number;
15
+ /** Number of lines in the new file */
16
+ newLines: number;
17
+ /** The raw content of the hunk including +/- prefixes */
18
+ content: string;
19
+ /** Individual lines with their change type */
20
+ changes: DiffChange[];
21
+ }
22
+
23
+ /**
24
+ * A single line change within a hunk.
25
+ */
26
+ export interface DiffChange {
27
+ type: "add" | "delete" | "normal";
28
+ content: string;
29
+ oldLineNumber?: number;
30
+ newLineNumber?: number;
31
+ }
32
+
33
+ /**
34
+ * A single file within a diff.
35
+ */
36
+ export interface DiffFile {
37
+ /** Original file path (null if new file) */
38
+ oldPath: string | null;
39
+ /** New file path (null if deleted file) */
40
+ newPath: string | null;
41
+ /** Change status */
42
+ status: "added" | "deleted" | "modified" | "renamed";
43
+ /** Whether this is a binary file */
44
+ binary: boolean;
45
+ /** Hunks containing the actual changes */
46
+ hunks: DiffHunk[];
47
+ /** Number of added lines */
48
+ additions: number;
49
+ /** Number of deleted lines */
50
+ deletions: number;
51
+ }
52
+
53
+ /**
54
+ * Complete parsed diff from a PR.
55
+ */
56
+ export interface ParsedDiff {
57
+ /** All files in the diff */
58
+ files: DiffFile[];
59
+ /** Total lines added across all files */
60
+ totalAdditions: number;
61
+ /** Total lines deleted across all files */
62
+ totalDeletions: number;
63
+ /** Number of files changed */
64
+ filesChanged: number;
65
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * LLM types for SpecVector provider abstraction.
3
+ * Follows OpenAI/Anthropic message format conventions.
4
+ */
5
+
6
+ /**
7
+ * Message roles in a conversation.
8
+ */
9
+ export type MessageRole = "system" | "user" | "assistant" | "tool";
10
+
11
+ /**
12
+ * A single message in the conversation.
13
+ */
14
+ export interface Message {
15
+ /** Role of the message sender */
16
+ role: MessageRole;
17
+ /** Content of the message (null for tool-calling assistant messages) */
18
+ content: string | null;
19
+ /** Tool calls made by the assistant */
20
+ tool_calls?: ToolCall[];
21
+ /** ID of the tool call this message responds to (for tool role) */
22
+ tool_call_id?: string;
23
+ /** Name of the tool that generated this message (for tool role) */
24
+ name?: string;
25
+ }
26
+
27
+ /**
28
+ * A tool that can be called by the LLM.
29
+ */
30
+ export interface Tool {
31
+ /** Unique name of the tool */
32
+ name: string;
33
+ /** Human-readable description for the LLM */
34
+ description: string;
35
+ /** JSON Schema defining the tool's parameters */
36
+ parameters: JSONSchema;
37
+ }
38
+
39
+ /**
40
+ * JSON Schema type (simplified for tool parameters).
41
+ */
42
+ export interface JSONSchema {
43
+ type: "object";
44
+ properties: Record<string, JSONSchemaProperty>;
45
+ required?: string[];
46
+ additionalProperties?: boolean;
47
+ }
48
+
49
+ /**
50
+ * JSON Schema property definition.
51
+ */
52
+ export interface JSONSchemaProperty {
53
+ type: "string" | "number" | "boolean" | "array" | "object";
54
+ description?: string;
55
+ enum?: string[];
56
+ items?: JSONSchemaProperty;
57
+ properties?: Record<string, JSONSchemaProperty>;
58
+ required?: string[];
59
+ }
60
+
61
+ /**
62
+ * A tool call made by the assistant.
63
+ */
64
+ export interface ToolCall {
65
+ /** Unique identifier for this tool call */
66
+ id: string;
67
+ /** Name of the tool to call */
68
+ name: string;
69
+ /** JSON-encoded arguments for the tool */
70
+ arguments: string;
71
+ }
72
+
73
+ /**
74
+ * Token usage statistics.
75
+ */
76
+ export interface TokenUsage {
77
+ prompt_tokens: number;
78
+ completion_tokens: number;
79
+ total_tokens: number;
80
+ }
81
+
82
+ /**
83
+ * Response from a chat completion request.
84
+ */
85
+ export interface ChatResponse {
86
+ /** Text content of the response (null if only tool calls) */
87
+ content: string | null;
88
+ /** Tool calls made by the assistant */
89
+ tool_calls?: ToolCall[];
90
+ /** Token usage statistics */
91
+ usage: TokenUsage;
92
+ /** Model that generated the response */
93
+ model: string;
94
+ /** Reason the response ended */
95
+ finish_reason: "stop" | "tool_calls" | "length" | "content_filter";
96
+ }
97
+
98
+ /**
99
+ * Options for a chat completion request.
100
+ */
101
+ export interface ChatOptions {
102
+ /** Tools available for the model to call */
103
+ tools?: Tool[];
104
+ /** Sampling temperature (0-2, default 1) */
105
+ temperature?: number;
106
+ /** Maximum tokens to generate */
107
+ max_tokens?: number;
108
+ /** Stop sequences to end generation */
109
+ stop_sequences?: string[];
110
+ /** Force tool use */
111
+ tool_choice?: "auto" | "none" | { name: string };
112
+ }
113
+
114
+ /**
115
+ * Configuration for initializing a provider.
116
+ */
117
+ export interface ProviderConfig {
118
+ /** Provider type */
119
+ provider: "openrouter" | "ollama";
120
+ /** Model identifier */
121
+ model: string;
122
+ /** API key (for OpenRouter) */
123
+ apiKey?: string;
124
+ /** Base URL (for Ollama, default http://localhost:11434) */
125
+ baseUrl?: string;
126
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Result type for type-safe error handling.
3
+ * Use instead of throwing exceptions for expected errors.
4
+ */
5
+ export type Result<T, E = Error> =
6
+ | { ok: true; value: T }
7
+ | { ok: false; error: E };
8
+
9
+ /**
10
+ * Create a success result
11
+ */
12
+ export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
13
+
14
+ /**
15
+ * Create an error result
16
+ */
17
+ export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });