Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

spec-ai.tsBlame391 lines · 1 contributor
23d1a81Claude1/**
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
20import Anthropic from "@anthropic-ai/sdk";
21import { config } from "./config";
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27export type FileEdit =
28 | { action: "create"; path: string; content: string }
29 | { action: "edit"; path: string; content: string }
30 | { action: "delete"; path: string };
31
32export type SpecAIResult =
33 | { ok: true; edits: FileEdit[]; summary: string }
34 | { ok: false; error: string };
35
36export 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. */
50const MAX_PROMPT_BYTES = 50_000;
51/** Hard cap on file list lines. */
52const MAX_FILE_LIST_LINES = 500;
53/** Default model — spec says `claude-sonnet-4-6`. */
54const 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 */
60const 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 */
78export 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 */
94export 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 */
112export 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 */
135export 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 */
165export 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 */
200export 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
252function 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
265let _client: Anthropic | null = null;
266
267function getClient(): Anthropic {
268 if (!_client) {
eb3b81aClaude269 if (!config.anthropicApiKey) {
270 throw new Error("ANTHROPIC_API_KEY is not set");
271 }
23d1a81Claude272 _client = new Anthropic({ apiKey: config.anthropicApiKey });
273 }
274 return _client;
275}
276
277/**
278 * Drop the cached Anthropic client. Only intended for tests that need to
279 * swap `globalThis.fetch` between calls — the SDK captures `fetch` at
280 * client construction time, so reusing a client would pin the stubbed
281 * fetch from an earlier test.
282 *
283 * @internal
284 */
285export function _resetClientForTests(): void {
286 _client = null;
287}
288
289// ---------------------------------------------------------------------------
290// Main entry point
291// ---------------------------------------------------------------------------
292
293/**
294 * Ask Claude to propose file edits that implement `spec`.
295 *
296 * Never throws — returns a discriminated union. On validation failure a
297 * proposed edit is silently dropped; if *every* proposed edit is rejected
298 * the result is still `{ok:true, edits:[], summary:"..."}` so the caller
299 * can distinguish "AI produced nothing usable" from "AI / transport error".
300 */
301export async function generateSpecEdits(
302 args: GenerateSpecEditsArgs
303): Promise<SpecAIResult> {
304 if (!config.anthropicApiKey) {
305 return { ok: false, error: "ANTHROPIC_API_KEY required" };
306 }
307
308 const model = args.model || DEFAULT_MODEL;
309
310 let systemPrompt: string;
311 let userPrompt: string;
312 try {
313 systemPrompt = buildSystemPrompt();
314 userPrompt = buildUserPrompt(args);
315 } catch (err) {
316 return {
317 ok: false,
318 error: `prompt construction failed: ${errMessage(err)}`,
319 };
320 }
321
322 let rawText: string;
323 try {
324 const client = getClient();
325 const message = await client.messages.create({
326 model,
327 max_tokens: 4096,
328 temperature: 0.2,
329 system: systemPrompt,
330 messages: [{ role: "user", content: userPrompt }],
331 });
332 rawText = "";
333 for (const block of message.content) {
334 if (block.type === "text") {
335 rawText += block.text;
336 }
337 }
338 } catch (err) {
339 return { ok: false, error: `AI call failed: ${errMessage(err)}` };
340 }
341
342 const parsed = parseAiJsonResponse(rawText);
343 if (!parsed || typeof parsed !== "object") {
344 return { ok: false, error: "AI returned invalid JSON" };
345 }
346
347 const obj = parsed as Record<string, unknown>;
348 const summaryRaw = obj.summary;
349 const editsRaw = obj.edits;
350 const summary =
351 typeof summaryRaw === "string" && summaryRaw.trim()
352 ? summaryRaw.trim()
353 : "";
354
355 if (!Array.isArray(editsRaw)) {
356 return { ok: false, error: "AI returned invalid JSON" };
357 }
358
359 const edits: FileEdit[] = [];
360 for (const candidate of editsRaw) {
361 if (validateEdit(candidate)) {
362 edits.push(candidate);
363 }
364 // Forbidden / malformed edits are silently dropped. The caller can look
365 // at `edits.length` vs the original `editsRaw.length` if it cares.
366 }
367
368 if (edits.length === 0) {
369 return {
370 ok: true,
371 edits: [],
372 summary: summary || "AI proposed no changes",
373 };
374 }
375
376 return {
377 ok: true,
378 edits,
379 summary: summary || "AI proposed changes",
380 };
381}
382
383function errMessage(err: unknown): string {
384 if (err instanceof Error) return err.message;
385 if (typeof err === "string") return err;
386 try {
387 return JSON.stringify(err);
388 } catch {
389 return "unknown error";
390 }
391}