CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
spec-ai.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 23d1a81 | 1 | /** |
| 2 | * spec-to-PR v2, part 2 — Claude API call + response parser. | |
| 3 | * | |
| 4 | * Given a user spec plus a compact view of the repository (file list + | |
| 5 | * relevant file contents), asks Claude for a minimal set of file edits that | |
| 6 | * implement the spec. The response is parsed, validated, and returned as a | |
| 7 | * discriminated union so the caller (spec-to-PR pipeline) can decide what | |
| 8 | * to do next. | |
| 9 | * | |
| 10 | * Contract: | |
| 11 | * - Never throws. Every failure path returns `{ok:false, error:string}`. | |
| 12 | * - Never invents or installs dependencies. Never edits forbidden paths. | |
| 13 | * - "no edits" is a valid successful result — caller decides policy. | |
| 14 | * | |
| 15 | * Client pattern cribbed from `src/lib/ai-review.ts` (direct `@anthropic-ai/sdk` | |
| 16 | * + per-call `client.messages.create`), kept intentionally consistent with the | |
| 17 | * rest of the `ai-*` modules. | |
| 18 | */ | |
| 19 | ||
| 20 | import Anthropic from "@anthropic-ai/sdk"; | |
| 21 | import { config } from "./config"; | |
| 22 | ||
| 23 | // --------------------------------------------------------------------------- | |
| 24 | // Public types | |
| 25 | // --------------------------------------------------------------------------- | |
| 26 | ||
| 27 | export type FileEdit = | |
| 28 | | { action: "create"; path: string; content: string } | |
| 29 | | { action: "edit"; path: string; content: string } | |
| 30 | | { action: "delete"; path: string }; | |
| 31 | ||
| 32 | export type SpecAIResult = | |
| 33 | | { ok: true; edits: FileEdit[]; summary: string } | |
| 34 | | { ok: false; error: string }; | |
| 35 | ||
| 36 | export interface GenerateSpecEditsArgs { | |
| 37 | spec: string; | |
| 38 | fileList: string[]; | |
| 39 | relevantFiles: Array<{ path: string; content: string }>; | |
| 40 | defaultBranch: string; | |
| 41 | /** Model override. Default: `claude-sonnet-4-6` as specified by the caller. */ | |
| 42 | model?: string; | |
| 43 | } | |
| 44 | ||
| 45 | // --------------------------------------------------------------------------- | |
| 46 | // Tunables | |
| 47 | // --------------------------------------------------------------------------- | |
| 48 | ||
| 49 | /** Total prompt size cap, in bytes. Matches the v2 spec. */ | |
| 50 | const MAX_PROMPT_BYTES = 50_000; | |
| 51 | /** Hard cap on file list lines. */ | |
| 52 | const MAX_FILE_LIST_LINES = 500; | |
| 53 | /** Default model — spec says `claude-sonnet-4-6`. */ | |
| 54 | const DEFAULT_MODEL = "claude-sonnet-4-6"; | |
| 55 | ||
| 56 | /** | |
| 57 | * Paths we will never let Claude edit, regardless of what it returns. | |
| 58 | * Matches substrings/prefixes — see `isForbiddenPath`. | |
| 59 | */ | |
| 60 | const FORBIDDEN_PATTERNS: Array<string | RegExp> = [ | |
| 61 | "BUILD_BIBLE.md", | |
| 62 | "src/views/layout.tsx", | |
| 63 | /^drizzle\//, | |
| 64 | /^legal\//, | |
| 65 | "LICENSE", | |
| 66 | /^\.github\//, | |
| 67 | ]; | |
| 68 | ||
| 69 | // --------------------------------------------------------------------------- | |
| 70 | // Public helpers (exported so tests can poke at the pure bits) | |
| 71 | // --------------------------------------------------------------------------- | |
| 72 | ||
| 73 | /** | |
| 74 | * True if `path` targets a protected area of the tree that Claude must not | |
| 75 | * touch. Rejects edits as a defence-in-depth check in addition to the | |
| 76 | * instruction in the system prompt. | |
| 77 | */ | |
| 78 | export function isForbiddenPath(path: string): boolean { | |
| 79 | if (!path) return true; | |
| 80 | for (const pat of FORBIDDEN_PATTERNS) { | |
| 81 | if (typeof pat === "string") { | |
| 82 | if (path === pat) return true; | |
| 83 | } else if (pat.test(path)) { | |
| 84 | return true; | |
| 85 | } | |
| 86 | } | |
| 87 | return false; | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * True if `path` is a safe, relative, non-traversing filesystem path. | |
| 92 | * Rejects absolute paths, `..` traversal, backslashes, and empty strings. | |
| 93 | */ | |
| 94 | export function isSafeRelativePath(path: string): boolean { | |
| 95 | if (typeof path !== "string") return false; | |
| 96 | if (!path) return false; | |
| 97 | if (path.startsWith("/")) return false; | |
| 98 | if (path.includes("\\")) return false; | |
| 99 | const parts = path.split("/"); | |
| 100 | for (const part of parts) { | |
| 101 | if (part === "" || part === "." || part === "..") return false; | |
| 102 | } | |
| 103 | return true; | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Structural + policy validation for a single edit. Returns true only if: | |
| 108 | * - `action` is one of create / edit / delete | |
| 109 | * - `path` is a safe relative path and not forbidden | |
| 110 | * - `content` is a string for create / edit | |
| 111 | */ | |
| 112 | export function validateEdit(edit: unknown): edit is FileEdit { | |
| 113 | if (!edit || typeof edit !== "object") return false; | |
| 114 | const e = edit as Record<string, unknown>; | |
| 115 | const action = e.action; | |
| 116 | const path = e.path; | |
| 117 | if (typeof path !== "string") return false; | |
| 118 | if (!isSafeRelativePath(path)) return false; | |
| 119 | if (isForbiddenPath(path)) return false; | |
| 120 | if (action === "create" || action === "edit") { | |
| 121 | return typeof e.content === "string"; | |
| 122 | } | |
| 123 | if (action === "delete") { | |
| 124 | return true; | |
| 125 | } | |
| 126 | return false; | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Parse a Claude response body (which may be wrapped in ```json / ``` fences or | |
| 131 | * contain surrounding prose) into a JSON object. | |
| 132 | * | |
| 133 | * Returns `null` on any parse failure. | |
| 134 | */ | |
| 135 | export function parseAiJsonResponse(text: string): unknown | null { | |
| 136 | if (typeof text !== "string" || !text) return null; | |
| 137 | let trimmed = text.trim(); | |
| 138 | ||
| 139 | // Strip leading / trailing triple-backtick fences, optionally tagged "json". | |
| 140 | const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); | |
| 141 | if (fenceMatch) { | |
| 142 | trimmed = fenceMatch[1].trim(); | |
| 143 | } | |
| 144 | ||
| 145 | try { | |
| 146 | return JSON.parse(trimmed); | |
| 147 | } catch { | |
| 148 | // Fall back to first balanced {...} block if any. | |
| 149 | const braceMatch = trimmed.match(/\{[\s\S]*\}/); | |
| 150 | if (braceMatch) { | |
| 151 | try { | |
| 152 | return JSON.parse(braceMatch[0]); | |
| 153 | } catch { | |
| 154 | return null; | |
| 155 | } | |
| 156 | } | |
| 157 | return null; | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Build the system prompt. Kept as an exported helper so it can be inspected | |
| 163 | * from tests without calling Claude. | |
| 164 | */ | |
| 165 | export function buildSystemPrompt(): string { | |
| 166 | return [ | |
| 167 | "You are an AI coding assistant working inside a repo.", | |
| 168 | "Given a user spec and the repo's file tree + relevant files, produce file edits that implement the spec.", | |
| 169 | "", | |
| 170 | "Rules:", | |
| 171 | "- Minimal scope — implement one feature at a time.", | |
| 172 | "- Never edit tests, CI configs, BUILD_BIBLE.md, locked files, or license files.", | |
| 173 | "- Never invent new dependencies.", | |
| 174 | "- Keep edits surgical and focused on the spec.", | |
| 175 | "", | |
| 176 | "Respond with ONLY a JSON object matching this TypeScript type, with no prose, no markdown fences, no commentary:", | |
| 177 | "", | |
| 178 | "{", | |
| 179 | ' summary: string,', | |
| 180 | " edits: Array<{ action: 'create' | 'edit' | 'delete', path: string, content?: string }>", | |
| 181 | "}", | |
| 182 | "", | |
| 183 | "For create and edit actions, `content` is required and must be the full file contents.", | |
| 184 | "For delete actions, omit `content`.", | |
| 185 | "Paths must be relative (no leading /, no ..).", | |
| 186 | ].join("\n"); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Build the user prompt, fitting inside `MAX_PROMPT_BYTES`. | |
| 191 | * | |
| 192 | * Truncation strategy, in order: | |
| 193 | * 1. Cap file list at `MAX_FILE_LIST_LINES`. | |
| 194 | * 2. If still over budget, drop trailing file-list entries. | |
| 195 | * 3. If still over, drop lowest-ranked (last) relevant files. | |
| 196 | * | |
| 197 | * `relevantFiles` is assumed to be pre-sorted by the caller in descending | |
| 198 | * score order (most relevant first). We only ever drop from the tail. | |
| 199 | */ | |
| 200 | export function buildUserPrompt(args: GenerateSpecEditsArgs): string { | |
| 201 | const { spec, defaultBranch } = args; | |
| 202 | let fileList = args.fileList.slice(0, MAX_FILE_LIST_LINES); | |
| 203 | const relevant = args.relevantFiles.slice(); | |
| 204 | ||
| 205 | const header = () => | |
| 206 | [ | |
| 207 | `Default branch: ${defaultBranch}`, | |
| 208 | "", | |
| 209 | "User spec:", | |
| 210 | spec, | |
| 211 | "", | |
| 212 | ].join("\n"); | |
| 213 | ||
| 214 | const render = (): string => { | |
| 215 | const parts: string[] = [header()]; | |
| 216 | parts.push("Repository file list:"); | |
| 217 | parts.push("```"); | |
| 218 | parts.push(fileList.join("\n")); | |
| 219 | parts.push("```"); | |
| 220 | parts.push(""); | |
| 221 | if (relevant.length > 0) { | |
| 222 | parts.push("Relevant files:"); | |
| 223 | parts.push(""); | |
| 224 | for (const f of relevant) { | |
| 225 | parts.push("```" + (f.path || "")); | |
| 226 | parts.push(f.content || ""); | |
| 227 | parts.push("```"); | |
| 228 | parts.push(""); | |
| 229 | } | |
| 230 | } | |
| 231 | return parts.join("\n"); | |
| 232 | }; | |
| 233 | ||
| 234 | let out = render(); | |
| 235 | if (byteLen(out) <= MAX_PROMPT_BYTES) return out; | |
| 236 | ||
| 237 | // 1. Trim file list. | |
| 238 | while (fileList.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) { | |
| 239 | fileList = fileList.slice(0, Math.max(0, fileList.length - 10)); | |
| 240 | } | |
| 241 | out = render(); | |
| 242 | if (byteLen(out) <= MAX_PROMPT_BYTES) return out; | |
| 243 | ||
| 244 | // 2. Drop lowest-scoring (tail) relevant files one at a time. | |
| 245 | while (relevant.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) { | |
| 246 | relevant.pop(); | |
| 247 | } | |
| 248 | ||
| 249 | return render(); | |
| 250 | } | |
| 251 | ||
| 252 | function byteLen(s: string): number { | |
| 253 | // Bun / Node both expose Buffer; fall back to a UTF-8 estimate otherwise. | |
| 254 | try { | |
| 255 | return Buffer.byteLength(s, "utf8"); | |
| 256 | } catch { | |
| 257 | return s.length; | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | // --------------------------------------------------------------------------- | |
| 262 | // Anthropic client (local to this module — matches ai-review.ts pattern) | |
| 263 | // --------------------------------------------------------------------------- | |
| 264 | ||
| 265 | let _client: Anthropic | null = null; | |
| 266 | ||
| 267 | function getClient(): Anthropic { | |
| 268 | if (!_client) { | |
| 269 | _client = new Anthropic({ apiKey: config.anthropicApiKey }); | |
| 270 | } | |
| 271 | return _client; | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Drop the cached Anthropic client. Only intended for tests that need to | |
| 276 | * swap `globalThis.fetch` between calls — the SDK captures `fetch` at | |
| 277 | * client construction time, so reusing a client would pin the stubbed | |
| 278 | * fetch from an earlier test. | |
| 279 | * | |
| 280 | * @internal | |
| 281 | */ | |
| 282 | export function _resetClientForTests(): void { | |
| 283 | _client = null; | |
| 284 | } | |
| 285 | ||
| 286 | // --------------------------------------------------------------------------- | |
| 287 | // Main entry point | |
| 288 | // --------------------------------------------------------------------------- | |
| 289 | ||
| 290 | /** | |
| 291 | * Ask Claude to propose file edits that implement `spec`. | |
| 292 | * | |
| 293 | * Never throws — returns a discriminated union. On validation failure a | |
| 294 | * proposed edit is silently dropped; if *every* proposed edit is rejected | |
| 295 | * the result is still `{ok:true, edits:[], summary:"..."}` so the caller | |
| 296 | * can distinguish "AI produced nothing usable" from "AI / transport error". | |
| 297 | */ | |
| 298 | export async function generateSpecEdits( | |
| 299 | args: GenerateSpecEditsArgs | |
| 300 | ): Promise<SpecAIResult> { | |
| 301 | if (!config.anthropicApiKey) { | |
| 302 | return { ok: false, error: "ANTHROPIC_API_KEY required" }; | |
| 303 | } | |
| 304 | ||
| 305 | const model = args.model || DEFAULT_MODEL; | |
| 306 | ||
| 307 | let systemPrompt: string; | |
| 308 | let userPrompt: string; | |
| 309 | try { | |
| 310 | systemPrompt = buildSystemPrompt(); | |
| 311 | userPrompt = buildUserPrompt(args); | |
| 312 | } catch (err) { | |
| 313 | return { | |
| 314 | ok: false, | |
| 315 | error: `prompt construction failed: ${errMessage(err)}`, | |
| 316 | }; | |
| 317 | } | |
| 318 | ||
| 319 | let rawText: string; | |
| 320 | try { | |
| 321 | const client = getClient(); | |
| 322 | const message = await client.messages.create({ | |
| 323 | model, | |
| 324 | max_tokens: 4096, | |
| 325 | temperature: 0.2, | |
| 326 | system: systemPrompt, | |
| 327 | messages: [{ role: "user", content: userPrompt }], | |
| 328 | }); | |
| 0c3eee5 | 329 | try { |
| 330 | const { recordAiCost, extractUsage } = await import( | |
| 331 | "./ai-cost-tracker" | |
| 332 | ); | |
| 333 | const usage = extractUsage(message); | |
| 334 | await recordAiCost({ | |
| 335 | model, | |
| 336 | inputTokens: usage.input, | |
| 337 | outputTokens: usage.output, | |
| 338 | category: "spec_to_pr", | |
| 339 | sourceKind: "spec", | |
| 340 | }); | |
| 341 | } catch { | |
| 342 | /* swallow — best-effort */ | |
| 343 | } | |
| 23d1a81 | 344 | rawText = ""; |
| 345 | for (const block of message.content) { | |
| 346 | if (block.type === "text") { | |
| 347 | rawText += block.text; | |
| 348 | } | |
| 349 | } | |
| 350 | } catch (err) { | |
| 351 | return { ok: false, error: `AI call failed: ${errMessage(err)}` }; | |
| 352 | } | |
| 353 | ||
| 354 | const parsed = parseAiJsonResponse(rawText); | |
| 355 | if (!parsed || typeof parsed !== "object") { | |
| 356 | return { ok: false, error: "AI returned invalid JSON" }; | |
| 357 | } | |
| 358 | ||
| 359 | const obj = parsed as Record<string, unknown>; | |
| 360 | const summaryRaw = obj.summary; | |
| 361 | const editsRaw = obj.edits; | |
| 362 | const summary = | |
| 363 | typeof summaryRaw === "string" && summaryRaw.trim() | |
| 364 | ? summaryRaw.trim() | |
| 365 | : ""; | |
| 366 | ||
| 367 | if (!Array.isArray(editsRaw)) { | |
| 368 | return { ok: false, error: "AI returned invalid JSON" }; | |
| 369 | } | |
| 370 | ||
| 371 | const edits: FileEdit[] = []; | |
| 372 | for (const candidate of editsRaw) { | |
| 373 | if (validateEdit(candidate)) { | |
| 374 | edits.push(candidate); | |
| 375 | } | |
| 376 | // Forbidden / malformed edits are silently dropped. The caller can look | |
| 377 | // at `edits.length` vs the original `editsRaw.length` if it cares. | |
| 378 | } | |
| 379 | ||
| 380 | if (edits.length === 0) { | |
| 381 | return { | |
| 382 | ok: true, | |
| 383 | edits: [], | |
| 384 | summary: summary || "AI proposed no changes", | |
| 385 | }; | |
| 386 | } | |
| 387 | ||
| 388 | return { | |
| 389 | ok: true, | |
| 390 | edits, | |
| 391 | summary: summary || "AI proposed changes", | |
| 392 | }; | |
| 393 | } | |
| 394 | ||
| 395 | function errMessage(err: unknown): string { | |
| 396 | if (err instanceof Error) return err.message; | |
| 397 | if (typeof err === "string") return err; | |
| 398 | try { | |
| 399 | return JSON.stringify(err); | |
| 400 | } catch { | |
| 401 | return "unknown error"; | |
| 402 | } | |
| 403 | } |