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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specvector",
3
- "version": "0.1.9",
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 this.provider.chat(
75
- messages,
76
- {
77
- tools: Array.from(this.tools.values()).map(toolToLLMTool),
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) {
@@ -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, performance issues, error handling, edge cases.
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
  }
@@ -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 Linear GraphQL API.
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
- identifier?: string;
16
- title?: string;
17
- description?: string;
18
- state?: { name?: string };
19
- priority?: number;
20
- labels?: { nodes?: Array<{ name?: string }> };
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
- * Uses Linear's GraphQL API directly - simpler and more reliable than MCP.
86
+ * Returns formatted context string and extracted metadata.
79
87
  */
80
88
  export async function fetchLinearContext(
81
89
  ticketId: string
82
- ): Promise<Result<string, LinearContextError>> {
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
- try {
93
- // Use Linear GraphQL API directly
94
- const response = await fetch("https://api.linear.app/graphql", {
95
- method: "POST",
96
- headers: {
97
- "Content-Type": "application/json",
98
- Authorization: apiToken,
99
- },
100
- body: JSON.stringify({
101
- query: `
102
- query GetIssue($id: String!) {
103
- issue(id: $id) {
104
- identifier
105
- title
106
- description
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
- if (!response.ok) {
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: `Linear API error: ${response.status}`,
127
+ message: ticketResult.error.message,
121
128
  });
122
129
  }
123
130
 
124
- const data = await response.json() as { data?: { issue?: LinearTicket | null }; errors?: Array<{ message: string }> };
131
+ // Parse the response
132
+ const content = ticketResult.value.content;
133
+ const textContent = content.find(c => c.type === "text");
125
134
 
126
- if (data.errors?.length) {
135
+ if (!textContent?.text) {
127
136
  return err({
128
137
  code: "FETCH_FAILED",
129
- message: data.errors[0]?.message ?? "GraphQL error",
138
+ message: "No ticket data returned",
130
139
  });
131
140
  }
132
141
 
133
- const issue = data.data?.issue;
134
- if (!issue) {
135
- return err({
136
- code: "FETCH_FAILED",
137
- message: `Issue ${ticketId} not found`,
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, issue);
143
- return ok(contextBlock);
144
-
145
- } catch (error) {
146
- return err({
147
- code: "FETCH_FAILED",
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 issue data as context for review prompt.
168
+ * Format Linear ticket data as context for review prompt.
155
169
  */
156
- function formatLinearContext(ticketId: string, issue: { title?: string; description?: string; state?: { name?: string }; priority?: number; labels?: { nodes?: Array<{ name?: string }> } }): 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
- **Title:** ${issue.title ?? "Untitled"}
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
- ### Description
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: result.value,
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 {
@@ -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
+ }
@@ -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: { ...process.env, ...config.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 {
@@ -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: 3 * 60 * 1000, // 3 minutes
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 if needed (find usages, read implementations)
210
- 3. Identify any issues with the changes
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
  }
@@ -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
  */
@@ -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
+ }