specvector 0.1.9 → 0.3.3
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/package.json +2 -4
- package/src/agent/loop.ts +10 -6
- package/src/config/index.ts +5 -5
- package/src/context/linear.ts +77 -65
- package/src/index.ts +2 -2
- package/src/llm/index.ts +2 -2
- package/src/llm/provider.ts +67 -0
- package/src/mcp/mcp-client.ts +3 -2
- package/src/review/engine.ts +52 -7
- package/src/review/formatter.ts +38 -1
- package/src/types/review.ts +21 -0
- package/src/utils/redact.ts +125 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specvector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Context-aware AI code review using Model Context Protocol (MCP)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -44,9 +44,7 @@
|
|
|
44
44
|
"engines": {
|
|
45
45
|
"bun": ">=1.0.0"
|
|
46
46
|
},
|
|
47
|
-
"dependencies": {
|
|
48
|
-
"@larryhudson/linear-mcp-server": "0.1.4"
|
|
49
|
-
},
|
|
47
|
+
"dependencies": {},
|
|
50
48
|
"devDependencies": {
|
|
51
49
|
"@types/bun": "latest"
|
|
52
50
|
},
|
package/src/agent/loop.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { ok, err } from "../types/result";
|
|
18
18
|
import type { Message, ToolCall } from "../types/llm";
|
|
19
19
|
import type { LLMProvider, LLMError } from "../llm/provider";
|
|
20
|
+
import { withRetry } from "../llm/provider";
|
|
20
21
|
import type {
|
|
21
22
|
Tool,
|
|
22
23
|
AgentConfig,
|
|
@@ -70,12 +71,15 @@ export class AgentLoop {
|
|
|
70
71
|
return err(AgentErrors.timeout());
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
// Get LLM response
|
|
74
|
-
const llmResult = await
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// Get LLM response with retry on transient errors
|
|
75
|
+
const llmResult = await withRetry(
|
|
76
|
+
() => this.provider.chat(
|
|
77
|
+
messages,
|
|
78
|
+
{
|
|
79
|
+
tools: Array.from(this.tools.values()).map(toolToLLMTool),
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
{ maxRetries: 1, delayMs: 2000 }
|
|
79
83
|
);
|
|
80
84
|
|
|
81
85
|
if (!llmResult.ok) {
|
package/src/config/index.ts
CHANGED
|
@@ -280,19 +280,19 @@ export function getStrictnessModifier(strictness: SpecVectorConfig["strictness"]
|
|
|
280
280
|
switch (strictness) {
|
|
281
281
|
case "strict":
|
|
282
282
|
return `Be VERY strict. Flag any potential issue, no matter how minor.
|
|
283
|
-
Focus on: security vulnerabilities,
|
|
283
|
+
Focus on: security vulnerabilities, logic errors, boundary conditions, data flow issues, performance problems, error handling, edge cases.
|
|
284
284
|
Do not approve code that could be improved.`;
|
|
285
285
|
|
|
286
286
|
case "lenient":
|
|
287
287
|
return `Be lenient and focus only on critical issues.
|
|
288
|
-
Only flag: bugs that would cause runtime errors, security vulnerabilities, obvious mistakes.
|
|
289
|
-
Skip: style issues, minor improvements, suggestions.
|
|
288
|
+
Only flag: bugs that would cause runtime errors, logic errors causing crashes or data corruption, security vulnerabilities, obvious mistakes.
|
|
289
|
+
Skip: style issues, minor improvements, suggestions, theoretical problems.
|
|
290
290
|
Approve unless there are blocking issues.`;
|
|
291
291
|
|
|
292
292
|
case "normal":
|
|
293
293
|
default:
|
|
294
294
|
return `Use balanced judgement. Flag important issues but don't be nitpicky.
|
|
295
|
-
Focus on: bugs, security, performance, maintainability.
|
|
296
|
-
Skip: purely stylistic preferences.`;
|
|
295
|
+
Focus on: logic errors that would cause real bugs in production, security, performance, maintainability.
|
|
296
|
+
Skip: purely stylistic preferences, theoretical issues you haven't verified.`;
|
|
297
297
|
}
|
|
298
298
|
}
|
package/src/context/linear.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Linear Context Provider - Fetches ticket context for reviews.
|
|
3
3
|
*
|
|
4
|
-
* Extracts ticket ID from branch name/PR and fetches details via
|
|
4
|
+
* Extracts ticket ID from branch name/PR and fetches details via MCP.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createMCPClient } from "../mcp";
|
|
7
8
|
import type { Result } from "../types/result";
|
|
8
9
|
import { ok, err } from "../types/result";
|
|
9
10
|
|
|
@@ -12,12 +13,12 @@ import { ok, err } from "../types/result";
|
|
|
12
13
|
// ============================================================================
|
|
13
14
|
|
|
14
15
|
export interface LinearTicket {
|
|
15
|
-
|
|
16
|
-
title
|
|
17
|
-
description
|
|
18
|
-
state
|
|
19
|
-
priority
|
|
20
|
-
labels
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
state: string;
|
|
20
|
+
priority: number;
|
|
21
|
+
labels: string[];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface LinearContextError {
|
|
@@ -72,14 +73,21 @@ export function extractTicketId(
|
|
|
72
73
|
// Linear Context Fetching
|
|
73
74
|
// ============================================================================
|
|
74
75
|
|
|
76
|
+
/** Extended ticket data from Linear MCP */
|
|
77
|
+
export interface LinearTicketData {
|
|
78
|
+
context: string;
|
|
79
|
+
title?: string;
|
|
80
|
+
url?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
/**
|
|
76
84
|
* Fetch Linear ticket context for a review.
|
|
77
85
|
*
|
|
78
|
-
*
|
|
86
|
+
* Returns formatted context string and extracted metadata.
|
|
79
87
|
*/
|
|
80
88
|
export async function fetchLinearContext(
|
|
81
89
|
ticketId: string
|
|
82
|
-
): Promise<Result<
|
|
90
|
+
): Promise<Result<LinearTicketData, LinearContextError>> {
|
|
83
91
|
// Check for API token
|
|
84
92
|
const apiToken = process.env.LINEAR_API_TOKEN;
|
|
85
93
|
if (!apiToken) {
|
|
@@ -89,85 +97,83 @@ export async function fetchLinearContext(
|
|
|
89
97
|
});
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
state { name }
|
|
108
|
-
priority
|
|
109
|
-
labels { nodes { name } }
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
`,
|
|
113
|
-
variables: { id: ticketId },
|
|
114
|
-
}),
|
|
100
|
+
// Create MCP client
|
|
101
|
+
const clientResult = await createMCPClient({
|
|
102
|
+
name: "linear",
|
|
103
|
+
command: "npx",
|
|
104
|
+
args: ["linear-mcp-server"],
|
|
105
|
+
env: {
|
|
106
|
+
LINEAR_API_KEY: apiToken,
|
|
107
|
+
},
|
|
108
|
+
timeout: 30000,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!clientResult.ok) {
|
|
112
|
+
return err({
|
|
113
|
+
code: "CONNECTION_FAILED",
|
|
114
|
+
message: clientResult.error.message,
|
|
115
115
|
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const client = clientResult.value;
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
try {
|
|
121
|
+
// Fetch issue details using Linear MCP
|
|
122
|
+
const ticketResult = await client.callTool("get_issue", { issue_id: ticketId });
|
|
123
|
+
|
|
124
|
+
if (!ticketResult.ok) {
|
|
118
125
|
return err({
|
|
119
126
|
code: "FETCH_FAILED",
|
|
120
|
-
message:
|
|
127
|
+
message: ticketResult.error.message,
|
|
121
128
|
});
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
// Parse the response
|
|
132
|
+
const content = ticketResult.value.content;
|
|
133
|
+
const textContent = content.find(c => c.type === "text");
|
|
125
134
|
|
|
126
|
-
if (
|
|
135
|
+
if (!textContent?.text) {
|
|
127
136
|
return err({
|
|
128
137
|
code: "FETCH_FAILED",
|
|
129
|
-
message:
|
|
138
|
+
message: "No ticket data returned",
|
|
130
139
|
});
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
142
|
+
const ticketText = textContent.text;
|
|
143
|
+
|
|
144
|
+
// Extract URL from MCP response if present (Linear API includes url field)
|
|
145
|
+
// Pattern matches: "url": "https://linear.app/..." or URL: https://linear.app/...
|
|
146
|
+
const urlMatch = ticketText.match(/"?url"?:\s*"?(https:\/\/linear\.app\/[^"\s]+)"?/i);
|
|
147
|
+
const extractedUrl = urlMatch?.[1];
|
|
148
|
+
|
|
149
|
+
// Extract title from MCP response if present
|
|
150
|
+
const titleMatch = ticketText.match(/"?title"?:\s*"([^"]+)"/i);
|
|
151
|
+
const extractedTitle = titleMatch?.[1];
|
|
140
152
|
|
|
141
153
|
// Format the context for the review prompt
|
|
142
|
-
const contextBlock = formatLinearContext(ticketId,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
message: error instanceof Error ? error.message : "Unknown error",
|
|
154
|
+
const contextBlock = formatLinearContext(ticketId, ticketText);
|
|
155
|
+
|
|
156
|
+
return ok({
|
|
157
|
+
context: contextBlock,
|
|
158
|
+
title: extractedTitle,
|
|
159
|
+
url: extractedUrl,
|
|
149
160
|
});
|
|
161
|
+
|
|
162
|
+
} finally {
|
|
163
|
+
await client.close();
|
|
150
164
|
}
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
/**
|
|
154
|
-
* Format Linear
|
|
168
|
+
* Format Linear ticket data as context for review prompt.
|
|
155
169
|
*/
|
|
156
|
-
function formatLinearContext(ticketId: string,
|
|
157
|
-
const labels = issue.labels?.nodes?.map(l => l.name).filter(Boolean).join(", ") || "none";
|
|
158
|
-
const priority = issue.priority ?? 0;
|
|
159
|
-
const priorityLabels = ["", "Urgent", "High", "Medium", "Low", "No Priority"];
|
|
160
|
-
|
|
170
|
+
function formatLinearContext(ticketId: string, ticketData: string): string {
|
|
161
171
|
return `
|
|
162
172
|
## Linear Ticket Context: ${ticketId}
|
|
163
173
|
|
|
164
|
-
|
|
165
|
-
**Status:** ${issue.state?.name ?? "Unknown"}
|
|
166
|
-
**Priority:** ${priorityLabels[priority] ?? "Unknown"}
|
|
167
|
-
**Labels:** ${labels}
|
|
174
|
+
The following is the context from the linked Linear ticket. Use this to understand the business requirements and acceptance criteria for this PR.
|
|
168
175
|
|
|
169
|
-
|
|
170
|
-
${issue.description ?? "No description provided."}
|
|
176
|
+
${ticketData}
|
|
171
177
|
|
|
172
178
|
---
|
|
173
179
|
When reviewing, check that the PR changes align with the ticket requirements above.
|
|
@@ -184,7 +190,7 @@ export async function getLinearContextForReview(
|
|
|
184
190
|
branchName?: string,
|
|
185
191
|
prTitle?: string,
|
|
186
192
|
prBody?: string
|
|
187
|
-
): Promise<{ context: string | null; ticketId: string | null; warning?: string }> {
|
|
193
|
+
): Promise<{ context: string | null; ticketId: string | null; ticketTitle?: string; ticketUrl?: string; warning?: string }> {
|
|
188
194
|
// Extract ticket ID
|
|
189
195
|
const ticketId = extractTicketId(branchName, prTitle, prBody);
|
|
190
196
|
|
|
@@ -212,8 +218,14 @@ export async function getLinearContextForReview(
|
|
|
212
218
|
};
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
const { context, title, url } = result.value;
|
|
222
|
+
|
|
215
223
|
return {
|
|
216
|
-
context
|
|
224
|
+
context,
|
|
217
225
|
ticketId,
|
|
226
|
+
// Use extracted title from MCP if available, fallback to generic
|
|
227
|
+
ticketTitle: title ?? `Linear Ticket ${ticketId}`,
|
|
228
|
+
// Use extracted URL from MCP if available (no fallback - better to have no link than broken link)
|
|
229
|
+
ticketUrl: url,
|
|
218
230
|
};
|
|
219
231
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { postPRComment } from "./github/comment";
|
|
|
9
9
|
import { parseDiff, getDiffSummary } from "./review/diff-parser";
|
|
10
10
|
import { formatReviewComment, formatReviewSummary } from "./review/formatter";
|
|
11
11
|
import { runReview } from "./review/engine";
|
|
12
|
+
import { redactError } from "./utils/redact";
|
|
12
13
|
|
|
13
14
|
const VERSION = "0.1.0";
|
|
14
15
|
|
|
@@ -132,7 +133,6 @@ async function handleReview(args: string[]): Promise<void> {
|
|
|
132
133
|
displayAndPostReview(mockReview, prNumber, dryRun);
|
|
133
134
|
} else {
|
|
134
135
|
console.log("🤖 Running AI review...");
|
|
135
|
-
console.log(" (this may take 15-30 seconds)");
|
|
136
136
|
console.log("");
|
|
137
137
|
|
|
138
138
|
// Get branch name for Linear context
|
|
@@ -242,6 +242,6 @@ function generateMockReview(filesReviewed: number): import("./types/review").Rev
|
|
|
242
242
|
|
|
243
243
|
// Run the CLI
|
|
244
244
|
main().catch((error) => {
|
|
245
|
-
console.error("Fatal error:", error);
|
|
245
|
+
console.error("Fatal error:", redactError(error));
|
|
246
246
|
process.exit(1);
|
|
247
247
|
});
|
package/src/llm/index.ts
CHANGED
|
@@ -33,8 +33,8 @@ export type {
|
|
|
33
33
|
} from "../types/llm";
|
|
34
34
|
|
|
35
35
|
// Re-export provider interface and errors
|
|
36
|
-
export type { LLMProvider, LLMError, LLMErrorCode } from "./provider";
|
|
37
|
-
export { LLMErrors, isRetryableError, createLLMError } from "./provider";
|
|
36
|
+
export type { LLMProvider, LLMError, LLMErrorCode, RetryConfig } from "./provider";
|
|
37
|
+
export { LLMErrors, isRetryableError, createLLMError, withRetry } from "./provider";
|
|
38
38
|
|
|
39
39
|
// Re-export factory
|
|
40
40
|
export {
|
package/src/llm/provider.ts
CHANGED
|
@@ -131,3 +131,70 @@ export const LLMErrors = {
|
|
|
131
131
|
retryable: false,
|
|
132
132
|
}),
|
|
133
133
|
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Retry configuration for LLM calls.
|
|
137
|
+
*/
|
|
138
|
+
export interface RetryConfig {
|
|
139
|
+
/** Maximum number of retries (default: 1) */
|
|
140
|
+
maxRetries?: number;
|
|
141
|
+
/** Delay between retries in ms (default: 2000) */
|
|
142
|
+
delayMs?: number;
|
|
143
|
+
/** Whether to log retry attempts (default: true) */
|
|
144
|
+
logRetries?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wrap an async function with retry logic for transient errors.
|
|
149
|
+
*
|
|
150
|
+
* @param fn - Async function returning Result<T, LLMError>
|
|
151
|
+
* @param config - Retry configuration
|
|
152
|
+
* @returns Result with success value or final error
|
|
153
|
+
*/
|
|
154
|
+
export async function withRetry<T>(
|
|
155
|
+
fn: () => Promise<Result<T, LLMError>>,
|
|
156
|
+
config: RetryConfig = {}
|
|
157
|
+
): Promise<Result<T, LLMError>> {
|
|
158
|
+
const maxRetries = config.maxRetries ?? 1;
|
|
159
|
+
const delayMs = config.delayMs ?? 2000;
|
|
160
|
+
const logRetries = config.logRetries ?? true;
|
|
161
|
+
|
|
162
|
+
let lastError: LLMError | undefined;
|
|
163
|
+
|
|
164
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
165
|
+
const result = await fn();
|
|
166
|
+
|
|
167
|
+
if (result.ok) {
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lastError = result.error;
|
|
172
|
+
|
|
173
|
+
// Check if we should retry
|
|
174
|
+
if (attempt < maxRetries && isRetryableError(result.error)) {
|
|
175
|
+
const retryDelay = result.error.retryAfterMs ?? delayMs;
|
|
176
|
+
|
|
177
|
+
if (logRetries) {
|
|
178
|
+
console.warn(
|
|
179
|
+
`⚠️ LLM call failed (${result.error.code}), retrying in ${retryDelay}ms... (${attempt + 1}/${maxRetries})`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await sleep(retryDelay);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Non-retryable error or max retries reached
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Should not reach here, but return last error if we do
|
|
192
|
+
return { ok: false, error: lastError! };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Sleep for a given number of milliseconds.
|
|
197
|
+
*/
|
|
198
|
+
function sleep(ms: number): Promise<void> {
|
|
199
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
200
|
+
}
|
package/src/mcp/mcp-client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
MCPToolsListResult,
|
|
18
18
|
MCPToolCallResult,
|
|
19
19
|
} from "./types";
|
|
20
|
+
import { redactSecrets, buildSafeEnv } from "../utils/redact";
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
22
23
|
// Constants
|
|
@@ -73,7 +74,7 @@ export async function createMCPClient(
|
|
|
73
74
|
let proc: Subprocess;
|
|
74
75
|
try {
|
|
75
76
|
proc = spawn([config.command, ...config.args], {
|
|
76
|
-
env:
|
|
77
|
+
env: buildSafeEnv(config.env as Record<string, string> | undefined),
|
|
77
78
|
stdin: "pipe",
|
|
78
79
|
stdout: "pipe",
|
|
79
80
|
stderr: "pipe",
|
|
@@ -351,7 +352,7 @@ function startReadingStderr(state: MCPClientState): void {
|
|
|
351
352
|
const text = decoder.decode(value, { stream: true });
|
|
352
353
|
// Log stderr for debugging (could be made configurable)
|
|
353
354
|
if (text.trim()) {
|
|
354
|
-
console.error(`[MCP:${state.config.name}] ${text.trim()}`);
|
|
355
|
+
console.error(`[MCP:${state.config.name}] ${redactSecrets(text.trim())}`);
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
358
|
} catch {
|
package/src/review/engine.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { createListDirTool } from "../agent/tools/list-dir";
|
|
|
16
16
|
import { createOutlineTool } from "../agent/tools/outline";
|
|
17
17
|
import { createFindSymbolTool } from "../agent/tools/find-symbol";
|
|
18
18
|
import { calculateStats, determineRecommendation } from "../types/review";
|
|
19
|
-
import type { ReviewResult, ReviewFinding, Severity } from "../types/review";
|
|
19
|
+
import type { ReviewResult, ReviewFinding, Severity, ContextSource } from "../types/review";
|
|
20
20
|
import type { Result } from "../types/result";
|
|
21
21
|
import { ok, err } from "../types/result";
|
|
22
22
|
import { loadConfig, getStrictnessModifier } from "../config";
|
|
@@ -100,6 +100,9 @@ export async function runReview(
|
|
|
100
100
|
const strictnessGuidance = getStrictnessModifier(strictness);
|
|
101
101
|
let systemPrompt = REVIEW_SYSTEM_PROMPT + `\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
|
|
102
102
|
|
|
103
|
+
// Track context sources for citation
|
|
104
|
+
const contextSources: ContextSource[] = [];
|
|
105
|
+
|
|
103
106
|
// Fetch Linear context if available
|
|
104
107
|
const linearResult = await getLinearContextForReview(
|
|
105
108
|
config.branchName,
|
|
@@ -110,6 +113,16 @@ export async function runReview(
|
|
|
110
113
|
if (linearResult.context) {
|
|
111
114
|
systemPrompt = linearResult.context + "\n\n" + systemPrompt;
|
|
112
115
|
console.log(`📎 Loaded Linear context for ticket: ${linearResult.ticketId}`);
|
|
116
|
+
|
|
117
|
+
// Track Linear as a context source (only if we have a valid ticket ID)
|
|
118
|
+
if (linearResult.ticketId) {
|
|
119
|
+
contextSources.push({
|
|
120
|
+
type: "linear",
|
|
121
|
+
id: linearResult.ticketId,
|
|
122
|
+
title: linearResult.ticketTitle,
|
|
123
|
+
url: linearResult.ticketUrl,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
113
126
|
} else if (linearResult.warning) {
|
|
114
127
|
console.warn(`⚠️ ${linearResult.warning}`);
|
|
115
128
|
}
|
|
@@ -119,12 +132,21 @@ export async function runReview(
|
|
|
119
132
|
if (adrResult) {
|
|
120
133
|
systemPrompt = adrResult.formatted + "\n\n" + systemPrompt;
|
|
121
134
|
console.log(`📚 Loaded ${adrResult.context.count} ADR files from ${adrResult.context.path}`);
|
|
135
|
+
|
|
136
|
+
// Track each ADR file as a context source
|
|
137
|
+
for (const adrFile of adrResult.context.files) {
|
|
138
|
+
contextSources.push({
|
|
139
|
+
type: "adr",
|
|
140
|
+
id: adrFile.name,
|
|
141
|
+
title: adrFile.name.replace(".md", ""),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
// Create agent
|
|
125
147
|
const agent = createAgentLoop(providerResult.value, tools, {
|
|
126
148
|
maxIterations: config.maxIterations || fileConfig.maxIterations || 15,
|
|
127
|
-
timeoutMs:
|
|
149
|
+
timeoutMs: 15 * 60 * 1000, // 15 minutes for larger PRs with slower models
|
|
128
150
|
systemPrompt,
|
|
129
151
|
});
|
|
130
152
|
|
|
@@ -142,7 +164,7 @@ export async function runReview(
|
|
|
142
164
|
}
|
|
143
165
|
|
|
144
166
|
// Parse the response into structured findings
|
|
145
|
-
const reviewResult = parseReviewResponse(agentResult.value, diffSummary);
|
|
167
|
+
const reviewResult = parseReviewResponse(agentResult.value, diffSummary, contextSources);
|
|
146
168
|
|
|
147
169
|
return ok(reviewResult);
|
|
148
170
|
}
|
|
@@ -150,7 +172,7 @@ export async function runReview(
|
|
|
150
172
|
/**
|
|
151
173
|
* System prompt for the code review agent.
|
|
152
174
|
*/
|
|
153
|
-
const REVIEW_SYSTEM_PROMPT = `You are a pragmatic code reviewer. Your job is to catch REAL problems, not nitpick.
|
|
175
|
+
export const REVIEW_SYSTEM_PROMPT = `You are a pragmatic code reviewer. Your job is to catch REAL problems, not nitpick.
|
|
154
176
|
|
|
155
177
|
## Tools Available
|
|
156
178
|
- read_file: Read source files to understand context
|
|
@@ -159,21 +181,43 @@ const REVIEW_SYSTEM_PROMPT = `You are a pragmatic code reviewer. Your job is to
|
|
|
159
181
|
- get_outline: Get functions/classes in a file (fast overview)
|
|
160
182
|
- find_symbol: Find where a function or class is defined
|
|
161
183
|
|
|
184
|
+
## Tool Use Strategy
|
|
185
|
+
Before flagging any issue, you MUST verify your understanding:
|
|
186
|
+
1. **Read the full file** for any function being changed — don't judge from diff alone
|
|
187
|
+
2. **Use find_symbol** to trace how changed functions are called by other code
|
|
188
|
+
3. **Use grep** to find other usages of modified functions or variables
|
|
189
|
+
4. **Only flag an issue if you have verified it** by reading the surrounding context
|
|
190
|
+
|
|
162
191
|
## What to Look For (in priority order)
|
|
163
192
|
1. **CRITICAL**: Security vulnerabilities, data loss, crashes
|
|
164
193
|
2. **HIGH**: Bugs that WILL break functionality in production
|
|
165
194
|
3. **MEDIUM**: Significant code quality issues (not style nits)
|
|
166
195
|
|
|
196
|
+
## Business Logic Patterns to Detect
|
|
197
|
+
Focus on real logic errors that cause incorrect behavior:
|
|
198
|
+
- **Off-by-one errors**: Wrong boundary conditions, < vs <=, array index issues
|
|
199
|
+
- **Null/undefined handling**: Missing null checks on values that can be null
|
|
200
|
+
- **Race conditions**: Shared state without synchronization, async ordering bugs
|
|
201
|
+
- **Incorrect boolean logic**: Inverted conditions, wrong operator (AND vs OR)
|
|
202
|
+
- **Missing error paths**: Happy-path-only code that ignores failure cases in data flows
|
|
203
|
+
- **Wrong operator**: Using = instead of ==, + instead of -, incorrect comparisons
|
|
204
|
+
- **State management bugs**: Mutating shared state, stale closures, incorrect resets
|
|
205
|
+
- **Type coercion issues**: Implicit conversions causing unexpected behavior
|
|
206
|
+
|
|
167
207
|
## What NOT to Flag
|
|
168
208
|
- Style preferences or "I would do it differently"
|
|
169
209
|
- Theoretical performance issues without evidence
|
|
170
210
|
- Missing edge case tests for working code
|
|
171
211
|
- "Could be refactored" suggestions
|
|
172
212
|
- Code that works but isn't perfect
|
|
213
|
+
- Naming convention preferences
|
|
214
|
+
- Comment formatting or missing comments
|
|
215
|
+
- Import ordering or grouping
|
|
173
216
|
|
|
174
217
|
## Key Principle
|
|
175
218
|
Most PRs should have 0-2 findings. If you're finding 5+ issues, you're being too picky.
|
|
176
219
|
Only flag issues you'd actually block a PR for in a real code review.
|
|
220
|
+
Verify every finding with tool use before reporting it.
|
|
177
221
|
|
|
178
222
|
## Response Format
|
|
179
223
|
SUMMARY: [1-2 sentences - is this code ready to merge?]
|
|
@@ -206,15 +250,15 @@ ${diff.length > 15000 ? "\n(diff truncated, use tools to read full files if need
|
|
|
206
250
|
|
|
207
251
|
## Instructions
|
|
208
252
|
1. First, understand what the changes are doing
|
|
209
|
-
2. Use tools to explore related code
|
|
210
|
-
3.
|
|
253
|
+
2. Use tools to explore related code — read full files, trace call chains, check usages
|
|
254
|
+
3. For each potential issue, verify it by reading surrounding context before flagging
|
|
211
255
|
4. Provide your review in the specified format`;
|
|
212
256
|
}
|
|
213
257
|
|
|
214
258
|
/**
|
|
215
259
|
* Parse the agent's response into structured findings.
|
|
216
260
|
*/
|
|
217
|
-
function parseReviewResponse(response: string, diffSummary: string): ReviewResult {
|
|
261
|
+
export function parseReviewResponse(response: string, diffSummary: string, contextSources: ContextSource[] = []): ReviewResult {
|
|
218
262
|
const findings: ReviewFinding[] = [];
|
|
219
263
|
|
|
220
264
|
// Extract summary
|
|
@@ -270,5 +314,6 @@ function parseReviewResponse(response: string, diffSummary: string): ReviewResul
|
|
|
270
314
|
recommendation,
|
|
271
315
|
stats,
|
|
272
316
|
filesReviewed: parseInt(filesReviewed, 10) || 0,
|
|
317
|
+
contextSources: contextSources.length > 0 ? contextSources : undefined,
|
|
273
318
|
};
|
|
274
319
|
}
|
package/src/review/formatter.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Converts ReviewResult into markdown for PR comments.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { ReviewFinding, ReviewResult, Severity } from "../types/review";
|
|
6
|
+
import type { ReviewFinding, ReviewResult, Severity, ContextSource } from "../types/review";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Severity emoji indicators.
|
|
@@ -60,6 +60,14 @@ export function formatReviewComment(result: ReviewResult): string {
|
|
|
60
60
|
lines.push(result.summary);
|
|
61
61
|
lines.push("");
|
|
62
62
|
|
|
63
|
+
// Context Used section (if any context was used)
|
|
64
|
+
if (result.contextSources && result.contextSources.length > 0) {
|
|
65
|
+
lines.push("### Context Used");
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(...formatContextSources(result.contextSources));
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
// Findings by severity (only include sections with findings)
|
|
64
72
|
if (result.stats.critical > 0) {
|
|
65
73
|
lines.push("---");
|
|
@@ -134,6 +142,35 @@ function formatFindings(findings: ReviewFinding[], severity: Severity): string[]
|
|
|
134
142
|
return lines;
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Format context sources for the Context Used section.
|
|
147
|
+
* Renders Linear tickets with clickable links and ADR files as a list.
|
|
148
|
+
*/
|
|
149
|
+
function formatContextSources(sources: ContextSource[]): string[] {
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
|
|
152
|
+
// Group sources by type
|
|
153
|
+
const linearSources = sources.filter(s => s.type === "linear");
|
|
154
|
+
const adrSources = sources.filter(s => s.type === "adr");
|
|
155
|
+
|
|
156
|
+
// Format Linear tickets
|
|
157
|
+
for (const source of linearSources) {
|
|
158
|
+
if (source.url) {
|
|
159
|
+
lines.push(`- **Linear ticket:** [${source.id}](${source.url})`);
|
|
160
|
+
} else {
|
|
161
|
+
lines.push(`- **Linear ticket:** ${source.id}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Format ADR files
|
|
166
|
+
if (adrSources.length > 0) {
|
|
167
|
+
const adrList = adrSources.map(s => `\`${s.id}\``).join(", ");
|
|
168
|
+
lines.push(`- **ADRs referenced:** ${adrList}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
/**
|
|
138
175
|
* Format recommendation as human-readable text.
|
|
139
176
|
*/
|
package/src/types/review.ts
CHANGED
|
@@ -32,6 +32,25 @@ export interface ReviewFinding {
|
|
|
32
32
|
*/
|
|
33
33
|
export type Recommendation = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Type of external context source used in the review.
|
|
37
|
+
*/
|
|
38
|
+
export type ContextSourceType = "linear" | "adr";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* External context source that informed the review.
|
|
42
|
+
*/
|
|
43
|
+
export interface ContextSource {
|
|
44
|
+
/** Type of context source */
|
|
45
|
+
type: ContextSourceType;
|
|
46
|
+
/** Identifier (ticket ID or file path) */
|
|
47
|
+
id: string;
|
|
48
|
+
/** Display title */
|
|
49
|
+
title?: string;
|
|
50
|
+
/** URL for clickable link (Linear tickets) */
|
|
51
|
+
url?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/**
|
|
36
55
|
* Statistics about the review findings.
|
|
37
56
|
*/
|
|
@@ -57,6 +76,8 @@ export interface ReviewResult {
|
|
|
57
76
|
stats: ReviewStats;
|
|
58
77
|
/** Files that were reviewed */
|
|
59
78
|
filesReviewed: number;
|
|
79
|
+
/** External context sources used in the review */
|
|
80
|
+
contextSources?: ContextSource[];
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
/**
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction utilities.
|
|
3
|
+
*
|
|
4
|
+
* Prevents API keys, tokens, and other sensitive values from appearing
|
|
5
|
+
* in logs, error messages, or PR comments.
|
|
6
|
+
*
|
|
7
|
+
* Story 7.4: Security Enforcement (NFR4, NFR5)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Environment variable names that contain secrets.
|
|
12
|
+
* Values from these variables will be redacted from any output.
|
|
13
|
+
*/
|
|
14
|
+
const SECRET_ENV_VARS = [
|
|
15
|
+
"OPENROUTER_API_KEY",
|
|
16
|
+
"LINEAR_API_TOKEN",
|
|
17
|
+
"LINEAR_API_KEY",
|
|
18
|
+
"GITHUB_TOKEN",
|
|
19
|
+
"GH_TOKEN",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Environment variable names that are safe to pass to MCP subprocesses.
|
|
24
|
+
* Only these variables (plus explicitly provided config.env) are forwarded.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_SAFE_ENV_VARS = [
|
|
27
|
+
"PATH",
|
|
28
|
+
"HOME",
|
|
29
|
+
"USER",
|
|
30
|
+
"SHELL",
|
|
31
|
+
"TERM",
|
|
32
|
+
"LANG",
|
|
33
|
+
"LC_ALL",
|
|
34
|
+
"NODE_ENV",
|
|
35
|
+
"TMPDIR",
|
|
36
|
+
"XDG_RUNTIME_DIR",
|
|
37
|
+
// Node/Bun runtime
|
|
38
|
+
"NODE_PATH",
|
|
39
|
+
"BUN_INSTALL",
|
|
40
|
+
// npm/npx needs these to find packages
|
|
41
|
+
"npm_config_prefix",
|
|
42
|
+
"npm_config_cache",
|
|
43
|
+
"NVM_DIR",
|
|
44
|
+
"NVM_BIN",
|
|
45
|
+
"VOLTA_HOME",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Redact known secret values from a string.
|
|
50
|
+
*
|
|
51
|
+
* Scans the string for any values that match current environment variable
|
|
52
|
+
* secrets and replaces them with "[REDACTED]".
|
|
53
|
+
*
|
|
54
|
+
* @param text - The string to redact secrets from
|
|
55
|
+
* @returns The redacted string
|
|
56
|
+
*/
|
|
57
|
+
export function redactSecrets(text: string): string {
|
|
58
|
+
let redacted = text;
|
|
59
|
+
|
|
60
|
+
for (const envVar of SECRET_ENV_VARS) {
|
|
61
|
+
const value = process.env[envVar];
|
|
62
|
+
if (value && value.length >= 4) {
|
|
63
|
+
// Replace all occurrences of the secret value
|
|
64
|
+
redacted = redacted.replaceAll(value, "[REDACTED]");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return redacted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Safely stringify an error, redacting any secrets.
|
|
73
|
+
*
|
|
74
|
+
* Handles unknown error types (Error, string, object, etc.)
|
|
75
|
+
* and ensures no secret values leak through error messages.
|
|
76
|
+
*
|
|
77
|
+
* @param error - The error to stringify
|
|
78
|
+
* @returns A safe, redacted error string
|
|
79
|
+
*/
|
|
80
|
+
export function redactError(error: unknown): string {
|
|
81
|
+
let message: string;
|
|
82
|
+
|
|
83
|
+
if (error instanceof Error) {
|
|
84
|
+
// Only use message, not stack (stack can contain env var values)
|
|
85
|
+
message = error.message;
|
|
86
|
+
} else if (typeof error === "string") {
|
|
87
|
+
message = error;
|
|
88
|
+
} else {
|
|
89
|
+
try {
|
|
90
|
+
message = JSON.stringify(error) ?? String(error);
|
|
91
|
+
} catch {
|
|
92
|
+
message = String(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return redactSecrets(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a filtered environment for MCP subprocesses.
|
|
101
|
+
*
|
|
102
|
+
* Only includes safe, non-secret environment variables from process.env,
|
|
103
|
+
* merged with any explicitly provided config env vars.
|
|
104
|
+
*
|
|
105
|
+
* @param configEnv - Additional env vars from MCP config (may include tokens intentionally)
|
|
106
|
+
* @returns A filtered environment object
|
|
107
|
+
*/
|
|
108
|
+
export function buildSafeEnv(configEnv?: Record<string, string>): Record<string, string> {
|
|
109
|
+
const safeEnv: Record<string, string> = {};
|
|
110
|
+
|
|
111
|
+
// Only copy allowed env vars from process.env
|
|
112
|
+
for (const key of MCP_SAFE_ENV_VARS) {
|
|
113
|
+
const value = process.env[key];
|
|
114
|
+
if (value !== undefined) {
|
|
115
|
+
safeEnv[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Merge explicitly provided config env (these are intentional, e.g., LINEAR_API_KEY)
|
|
120
|
+
if (configEnv) {
|
|
121
|
+
Object.assign(safeEnv, configEnv);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return safeEnv;
|
|
125
|
+
}
|