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-context.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-context.tsBlame262 lines · 1 contributor
23d1a81Claude1/**
2 * Spec context reader for spec-to-PR.
3 *
4 * Given a bare repo on disk and a natural-language spec, produce a bounded
5 * "prompt context" package: a capped file list plus the highest-scoring
6 * source files (by keyword overlap with the spec), each content-truncated.
7 *
8 * Design:
9 * - All git access is via `Bun.spawn(["git", "-C", repoDiskPath, ...])` —
10 * argv form, no shell.
11 * - We never throw: any git/IO failure is returned as `{ok:false, error}`.
12 * - Scoring is deliberately cheap (token overlap + a couple of small boosts)
13 * because this runs synchronously before an LLM call on every request and
14 * must stay predictable/cheap.
15 * - Binary files are skipped by the classic NUL-byte heuristic so we don't
16 * waste token budget on images/archives.
17 */
18export type SpecContext = {
19 fileList: string[];
20 relevantFiles: Array<{ path: string; content: string }>;
21 defaultBranch: string;
22 totalSizeBytes: number;
23};
24
25export type BuildSpecContextArgs = {
26 repoDiskPath: string;
27 spec: string;
28 defaultBranch?: string;
29 maxRelevantFiles?: number;
30 maxFileBytes?: number;
31};
32
33export type BuildSpecContextResult =
34 | { ok: true; context: SpecContext }
35 | { ok: false; error: string };
36
37const STOP_WORDS = new Set([
38 "add",
39 "the",
40 "and",
41 "for",
42 "from",
43 "with",
44 "this",
45 "that",
46]);
47
48const CODE_EXTENSIONS = new Set([
49 "ts",
50 "tsx",
51 "js",
52 "jsx",
53 "py",
54 "go",
55 "rs",
56 "rb",
57 "java",
58 "php",
59]);
60
61const BOOST_NAMES = new Set(["readme", "index", "main", "app"]);
62
63const MAX_FILE_LIST = 500;
64const DEFAULT_MAX_RELEVANT = 20;
65const DEFAULT_MAX_BYTES = 3000;
66const BINARY_SNIFF_BYTES = 8000;
67
68/** Tokenize a spec into lowercase alphanumeric words of length ≥3, stop-words removed. */
69function tokenize(spec: string): string[] {
70 const out: string[] = [];
71 const seen = new Set<string>();
72 for (const raw of spec.toLowerCase().split(/[^a-z0-9]+/)) {
73 if (raw.length < 3) continue;
74 if (STOP_WORDS.has(raw)) continue;
75 if (seen.has(raw)) continue;
76 seen.add(raw);
77 out.push(raw);
78 }
79 return out;
80}
81
82/**
83 * Score a path against a set of spec tokens. Higher = more relevant.
84 *
85 * Exported so the unit tests can exercise scoring without building a real
86 * git repo on disk.
87 */
88export function scoreFile(path: string, tokens: string[]): number {
89 const lower = path.toLowerCase();
90 const parts = lower.split(/[\/\\._-]+/).filter(Boolean);
91 let score = 0;
92 for (const tok of tokens) {
93 for (const part of parts) {
94 if (part === tok) score += 2;
95 else if (part.includes(tok)) score += 1;
96 }
97 }
98 // Boost well-known "entry-point"-ish filenames.
99 for (const part of parts) {
100 if (BOOST_NAMES.has(part)) {
101 score += 1;
102 break;
103 }
104 }
105 // Boost common code-file extensions.
106 const dot = lower.lastIndexOf(".");
107 if (dot !== -1) {
108 const ext = lower.slice(dot + 1);
109 if (CODE_EXTENSIONS.has(ext)) score += 0.5;
110 }
111 return score;
112}
113
114async function runGit(
115 repoDiskPath: string,
116 args: string[]
117): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
118 try {
119 const proc = Bun.spawn(["git", "-C", repoDiskPath, ...args], {
120 stdout: "pipe",
121 stderr: "pipe",
122 });
123 const [stdout, stderr] = await Promise.all([
124 new Response(proc.stdout).text(),
125 new Response(proc.stderr).text(),
126 ]);
127 const exitCode = await proc.exited;
128 if (exitCode !== 0) {
129 return {
130 ok: false,
131 error: (stderr || `git ${args[0]} exited ${exitCode}`).trim(),
132 };
133 }
134 return { ok: true, stdout };
135 } catch (err) {
136 return {
137 ok: false,
138 error: err instanceof Error ? err.message : String(err),
139 };
140 }
141}
142
143async function runGitBytes(
144 repoDiskPath: string,
145 args: string[]
146): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; error: string }> {
147 try {
148 const proc = Bun.spawn(["git", "-C", repoDiskPath, ...args], {
149 stdout: "pipe",
150 stderr: "pipe",
151 });
152 const [bytes, stderr] = await Promise.all([
153 new Response(proc.stdout).arrayBuffer(),
154 new Response(proc.stderr).text(),
155 ]);
156 const exitCode = await proc.exited;
157 if (exitCode !== 0) {
158 return {
159 ok: false,
160 error: (stderr || `git ${args[0]} exited ${exitCode}`).trim(),
161 };
162 }
163 return { ok: true, bytes: new Uint8Array(bytes) };
164 } catch (err) {
165 return {
166 ok: false,
167 error: err instanceof Error ? err.message : String(err),
168 };
169 }
170}
171
172function looksBinary(bytes: Uint8Array): boolean {
173 const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
174 for (let i = 0; i < n; i++) {
175 if (bytes[i] === 0) return true;
176 }
177 return false;
178}
179
180export async function buildSpecContext(
181 args: BuildSpecContextArgs
182): Promise<BuildSpecContextResult> {
183 const {
184 repoDiskPath,
185 spec,
186 maxRelevantFiles = DEFAULT_MAX_RELEVANT,
187 maxFileBytes = DEFAULT_MAX_BYTES,
188 } = args;
189
190 // 1. Resolve default branch. We trust `defaultBranch` if passed, otherwise
191 // ask the repo for its symbolic HEAD.
192 let defaultBranch = args.defaultBranch;
193 if (!defaultBranch) {
194 const head = await runGit(repoDiskPath, [
195 "symbolic-ref",
196 "--short",
197 "HEAD",
198 ]);
199 if (!head.ok) return { ok: false, error: head.error };
200 defaultBranch = head.stdout.trim();
201 if (!defaultBranch) {
202 return { ok: false, error: "could not resolve default branch" };
203 }
204 }
205
206 // 2. Full file list on the branch. Cap before scoring to keep work bounded
207 // on monorepos.
208 const tree = await runGit(repoDiskPath, [
209 "ls-tree",
210 "-r",
211 defaultBranch,
212 "--name-only",
213 ]);
214 if (!tree.ok) return { ok: false, error: tree.error };
215
216 const allPaths = tree.stdout
217 .split("\n")
218 .map((s) => s.trim())
219 .filter((s) => s.length > 0);
220 const fileList = allPaths.slice(0, MAX_FILE_LIST);
221
222 // 3. Score & rank. Ties broken by shorter path (likely more central).
223 const tokens = tokenize(spec);
224 const scored = fileList
225 .map((path) => ({ path, score: scoreFile(path, tokens) }))
226 .sort((a, b) => {
227 if (b.score !== a.score) return b.score - a.score;
228 return a.path.length - b.path.length;
229 });
230
231 // 4. Fetch content for the top N. Skip binaries, truncate oversized files.
232 const relevantFiles: Array<{ path: string; content: string }> = [];
233 let totalSizeBytes = 0;
234 const TRUNC_MARKER = "\n...[truncated]";
235
236 for (const { path } of scored) {
237 if (relevantFiles.length >= maxRelevantFiles) break;
238 const blob = await runGitBytes(repoDiskPath, [
239 "show",
240 `${defaultBranch}:${path}`,
241 ]);
242 if (!blob.ok) continue; // submodule, missing, etc. — just skip
243 if (looksBinary(blob.bytes)) continue;
244
245 let content = new TextDecoder("utf-8", { fatal: false }).decode(blob.bytes);
246 if (content.length > maxFileBytes) {
247 content = content.slice(0, maxFileBytes) + TRUNC_MARKER;
248 }
249 relevantFiles.push({ path, content });
250 totalSizeBytes += content.length;
251 }
252
253 return {
254 ok: true,
255 context: {
256 fileList,
257 relevantFiles,
258 defaultBranch,
259 totalSizeBytes,
260 },
261 };
262}