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

spec-ai.tsBlame403 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) {
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 */
282export 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 */
298export 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 });
0c3eee5Claude329 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 }
23d1a81Claude344 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
395function 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}