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.tsBlame385 lines · 2 contributors
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 { config } from "./config";
242097bccantynz-alt21import { getAnthropic, __resetAnthropicClientForTests } from "./ai-client";
23d1a81Claude22
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// ---------------------------------------------------------------------------
242097bccantynz-alt262// Anthropic client — shared with the rest of the ai-* modules, see ai-client.ts
23d1a81Claude263// ---------------------------------------------------------------------------
264
242097bccantynz-alt265/** @deprecated kept so existing test imports keep working; delegates to ai-client.ts's reset. */
266export const _resetClientForTests = __resetAnthropicClientForTests;
23d1a81Claude267
268// ---------------------------------------------------------------------------
269// Main entry point
270// ---------------------------------------------------------------------------
271
272/**
273 * Ask Claude to propose file edits that implement `spec`.
274 *
275 * Never throws — returns a discriminated union. On validation failure a
276 * proposed edit is silently dropped; if *every* proposed edit is rejected
277 * the result is still `{ok:true, edits:[], summary:"..."}` so the caller
278 * can distinguish "AI produced nothing usable" from "AI / transport error".
279 */
280export async function generateSpecEdits(
281 args: GenerateSpecEditsArgs
282): Promise<SpecAIResult> {
283 if (!config.anthropicApiKey) {
284 return { ok: false, error: "ANTHROPIC_API_KEY required" };
285 }
286
287 const model = args.model || DEFAULT_MODEL;
288
289 let systemPrompt: string;
290 let userPrompt: string;
291 try {
292 systemPrompt = buildSystemPrompt();
293 userPrompt = buildUserPrompt(args);
294 } catch (err) {
295 return {
296 ok: false,
297 error: `prompt construction failed: ${errMessage(err)}`,
298 };
299 }
300
301 let rawText: string;
302 try {
242097bccantynz-alt303 const client = getAnthropic();
23d1a81Claude304 const message = await client.messages.create({
305 model,
306 max_tokens: 4096,
307 temperature: 0.2,
308 system: systemPrompt,
309 messages: [{ role: "user", content: userPrompt }],
310 });
0c3eee5Claude311 try {
312 const { recordAiCost, extractUsage } = await import(
313 "./ai-cost-tracker"
314 );
315 const usage = extractUsage(message);
316 await recordAiCost({
317 model,
318 inputTokens: usage.input,
319 outputTokens: usage.output,
320 category: "spec_to_pr",
321 sourceKind: "spec",
322 });
323 } catch {
324 /* swallow — best-effort */
325 }
23d1a81Claude326 rawText = "";
327 for (const block of message.content) {
328 if (block.type === "text") {
329 rawText += block.text;
330 }
331 }
332 } catch (err) {
333 return { ok: false, error: `AI call failed: ${errMessage(err)}` };
334 }
335
336 const parsed = parseAiJsonResponse(rawText);
337 if (!parsed || typeof parsed !== "object") {
338 return { ok: false, error: "AI returned invalid JSON" };
339 }
340
341 const obj = parsed as Record<string, unknown>;
342 const summaryRaw = obj.summary;
343 const editsRaw = obj.edits;
344 const summary =
345 typeof summaryRaw === "string" && summaryRaw.trim()
346 ? summaryRaw.trim()
347 : "";
348
349 if (!Array.isArray(editsRaw)) {
350 return { ok: false, error: "AI returned invalid JSON" };
351 }
352
353 const edits: FileEdit[] = [];
354 for (const candidate of editsRaw) {
355 if (validateEdit(candidate)) {
356 edits.push(candidate);
357 }
358 // Forbidden / malformed edits are silently dropped. The caller can look
359 // at `edits.length` vs the original `editsRaw.length` if it cares.
360 }
361
362 if (edits.length === 0) {
363 return {
364 ok: true,
365 edits: [],
366 summary: summary || "AI proposed no changes",
367 };
368 }
369
370 return {
371 ok: true,
372 edits,
373 summary: summary || "AI proposed changes",
374 };
375}
376
377function errMessage(err: unknown): string {
378 if (err instanceof Error) return err.message;
379 if (typeof err === "string") return err;
380 try {
381 return JSON.stringify(err);
382 } catch {
383 return "unknown error";
384 }
385}