Blame · Line-by-line history
autorepair.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.
| 2c34075 | 1 | /** |
| 2 | * Auto-Repair Engine | |
| 3 | * | |
| 4 | * When code hits gluecron, it gets scanned and automatically repaired. | |
| 5 | * No human intervention needed. The developer pushes broken code, | |
| 6 | * gluecron fixes it and commits the repair in seconds. | |
| 7 | * | |
| 8 | * Repairs: | |
| 9 | * 1. Trailing whitespace / inconsistent line endings | |
| 10 | * 2. Missing newline at end of file | |
| 11 | * 3. Hardcoded secrets → environment variable references | |
| 12 | * 4. Known vulnerable dependency versions → safe versions | |
| 13 | * 5. Missing .gitignore entries (node_modules, .env, etc.) | |
| 14 | * 6. JSON syntax errors (trailing commas, etc.) | |
| 15 | * 7. Package.json missing fields | |
| 16 | * 8. Insecure defaults (eval, innerHTML) | |
| 17 | * 9. Import sorting | |
| 18 | * 10. Dead code detection markers | |
| 19 | */ | |
| 20 | ||
| 91b054e | 21 | import { mkdtemp, rm } from "fs/promises"; |
| 22 | import { join } from "path"; | |
| 23 | import { tmpdir } from "os"; | |
| 2c34075 | 24 | import { getRepoPath, getDefaultBranch } from "../git/repository"; |
| 25 | ||
| 26 | export interface RepairResult { | |
| 27 | repaired: boolean; | |
| 28 | repairs: Repair[]; | |
| 29 | commitSha: string | null; | |
| 30 | } | |
| 31 | ||
| 32 | export interface Repair { | |
| 33 | file: string; | |
| 34 | type: string; | |
| 35 | description: string; | |
| 36 | linesChanged: number; | |
| 37 | } | |
| 38 | ||
| 39 | async function exec( | |
| 40 | cmd: string[], | |
| 41 | cwd: string, | |
| 91b054e | 42 | stdin?: string, |
| 43 | extraEnv?: Record<string, string> | |
| 44 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { | |
| 2c34075 | 45 | const proc = Bun.spawn(cmd, { |
| 46 | cwd, | |
| 47 | stdout: "pipe", | |
| 48 | stderr: "pipe", | |
| 49 | stdin: stdin !== undefined ? "pipe" : undefined, | |
| 50 | env: { | |
| 51 | ...process.env, | |
| 52 | GIT_AUTHOR_NAME: "gluecron[bot]", | |
| 53 | GIT_AUTHOR_EMAIL: "bot@gluecron.com", | |
| 54 | GIT_COMMITTER_NAME: "gluecron[bot]", | |
| 55 | GIT_COMMITTER_EMAIL: "bot@gluecron.com", | |
| 91b054e | 56 | ...extraEnv, |
| 2c34075 | 57 | }, |
| 58 | }); | |
| 59 | if (stdin !== undefined && proc.stdin) { | |
| 60 | proc.stdin.write(new TextEncoder().encode(stdin)); | |
| 61 | proc.stdin.end(); | |
| 62 | } | |
| 63 | const stdout = await new Response(proc.stdout).text(); | |
| 91b054e | 64 | const stderr = await new Response(proc.stderr).text(); |
| 2c34075 | 65 | const exitCode = await proc.exited; |
| 91b054e | 66 | return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; |
| 67 | } | |
| 68 | ||
| 69 | /** 40 lowercase hex chars — the shape of a real git object SHA. */ | |
| 70 | function looksLikeSha(s: string): boolean { | |
| 71 | return /^[0-9a-f]{40}$/.test(s); | |
| 2c34075 | 72 | } |
| 73 | ||
| 74 | export async function autoRepair( | |
| 75 | owner: string, | |
| 76 | repo: string, | |
| 77 | ref: string | |
| 78 | ): Promise<RepairResult> { | |
| 79 | const repoDir = getRepoPath(owner, repo); | |
| 80 | const repairs: Repair[] = []; | |
| 81 | ||
| 82 | // Get all files in the tree | |
| 83 | const { stdout: fileList } = await exec( | |
| 84 | ["git", "ls-tree", "-r", "--name-only", ref], | |
| 85 | repoDir | |
| 86 | ); | |
| 87 | const files = fileList.split("\n").filter(Boolean); | |
| 88 | ||
| 89 | // Track modified blobs: path -> new content | |
| 90 | const modifications: Map<string, string> = new Map(); | |
| 91 | ||
| 92 | // ─── REPAIR 1: Ensure .gitignore has essential entries ───── | |
| 93 | ||
| 94 | const gitignoreFile = files.find((f) => f === ".gitignore"); | |
| 95 | if (gitignoreFile) { | |
| 96 | const { stdout: content } = await exec( | |
| 97 | ["git", "show", `${ref}:.gitignore`], | |
| 98 | repoDir | |
| 99 | ); | |
| 100 | const essentialEntries = [ | |
| 101 | "node_modules/", | |
| 102 | ".env", | |
| 103 | ".env.local", | |
| 104 | ".DS_Store", | |
| 105 | "dist/", | |
| 106 | ]; | |
| 107 | const lines = content.split("\n"); | |
| 108 | const missing = essentialEntries.filter( | |
| 109 | (entry) => !lines.some((l) => l.trim() === entry) | |
| 110 | ); | |
| 111 | if (missing.length > 0) { | |
| 112 | const newContent = content.trimEnd() + "\n\n# Auto-added by gluecron\n" + missing.join("\n") + "\n"; | |
| 113 | modifications.set(".gitignore", newContent); | |
| 114 | repairs.push({ | |
| 115 | file: ".gitignore", | |
| 116 | type: "gitignore", | |
| 117 | description: `Added missing entries: ${missing.join(", ")}`, | |
| 118 | linesChanged: missing.length, | |
| 119 | }); | |
| 120 | } | |
| 121 | } else if (files.includes("package.json")) { | |
| 122 | // No .gitignore at all in a JS project | |
| 123 | const newContent = `# Auto-generated by gluecron | |
| 124 | node_modules/ | |
| 125 | dist/ | |
| 126 | .env | |
| 127 | .env.local | |
| 128 | .env.*.local | |
| 129 | *.log | |
| 130 | .DS_Store | |
| 131 | coverage/ | |
| 132 | .cache/ | |
| 133 | `; | |
| 134 | modifications.set(".gitignore", newContent); | |
| 135 | repairs.push({ | |
| 136 | file: ".gitignore", | |
| 137 | type: "gitignore", | |
| 138 | description: "Created .gitignore with standard entries", | |
| 139 | linesChanged: 10, | |
| 140 | }); | |
| 141 | } | |
| 142 | ||
| 143 | // ─── REPAIR 2: Fix trailing whitespace and missing EOF newlines ─ | |
| 144 | ||
| 145 | const textExtensions = /\.(ts|tsx|js|jsx|json|md|txt|yaml|yml|toml|css|html|py|rb|go|rs|java|sh|sql)$/; | |
| 146 | const textFiles = files.filter((f) => textExtensions.test(f)).slice(0, 50); // Limit to 50 files | |
| 147 | ||
| 148 | for (const filePath of textFiles) { | |
| 149 | const { stdout: content, exitCode } = await exec( | |
| 150 | ["git", "show", `${ref}:${filePath}`], | |
| 151 | repoDir | |
| 152 | ); | |
| 153 | if (exitCode !== 0) continue; | |
| 154 | ||
| 155 | let modified = content; | |
| 156 | let changes = 0; | |
| 157 | ||
| 158 | // Remove trailing whitespace | |
| 159 | const lines = modified.split("\n"); | |
| 160 | const trimmed = lines.map((line) => { | |
| 161 | const trimmedLine = line.replace(/[\t ]+$/, ""); | |
| 162 | if (trimmedLine !== line) changes++; | |
| 163 | return trimmedLine; | |
| 164 | }); | |
| 165 | modified = trimmed.join("\n"); | |
| 166 | ||
| 167 | // Ensure file ends with newline | |
| 168 | if (modified.length > 0 && !modified.endsWith("\n")) { | |
| 169 | modified += "\n"; | |
| 170 | changes++; | |
| 171 | } | |
| 172 | ||
| 173 | if (modified !== content) { | |
| 174 | modifications.set(filePath, modified); | |
| 175 | repairs.push({ | |
| 176 | file: filePath, | |
| 177 | type: "whitespace", | |
| 178 | description: `Fixed ${changes} whitespace issue${changes > 1 ? "s" : ""}`, | |
| 179 | linesChanged: changes, | |
| 180 | }); | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | // ─── REPAIR 3: Detect and mask hardcoded secrets ─────────── | |
| 185 | ||
| 186 | for (const filePath of textFiles) { | |
| 187 | if (filePath.includes("test") || filePath.includes("spec")) continue; | |
| 188 | if (filePath.endsWith(".md") || filePath.endsWith(".txt")) continue; | |
| 189 | ||
| 190 | const existing = modifications.get(filePath); | |
| 191 | const { stdout: rawContent } = await exec( | |
| 192 | ["git", "show", `${ref}:${filePath}`], | |
| 193 | repoDir | |
| 194 | ); | |
| 195 | const content = existing || rawContent; | |
| 196 | ||
| 197 | let modified = content; | |
| 198 | let secretsFound = 0; | |
| 199 | ||
| 200 | // AWS keys | |
| 201 | modified = modified.replace( | |
| 202 | /((?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16})/g, | |
| 203 | (match) => { | |
| 204 | secretsFound++; | |
| 205 | return "process.env.AWS_ACCESS_KEY_ID"; | |
| 206 | } | |
| 207 | ); | |
| 208 | ||
| 209 | if (secretsFound > 0 && modified !== content) { | |
| 210 | modifications.set(filePath, modified); | |
| 211 | repairs.push({ | |
| 212 | file: filePath, | |
| 213 | type: "secret-masking", | |
| 214 | description: `Masked ${secretsFound} hardcoded secret${secretsFound > 1 ? "s" : ""}`, | |
| 215 | linesChanged: secretsFound, | |
| 216 | }); | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | // ─── REPAIR 4: Fix JSON files ───────────────────────────── | |
| 221 | ||
| 222 | const jsonFiles = files.filter((f) => f.endsWith(".json")).slice(0, 20); | |
| 223 | for (const filePath of jsonFiles) { | |
| 224 | const existing = modifications.get(filePath); | |
| 225 | const { stdout: rawContent } = await exec( | |
| 226 | ["git", "show", `${ref}:${filePath}`], | |
| 227 | repoDir | |
| 228 | ); | |
| 229 | const content = existing || rawContent; | |
| 230 | ||
| 231 | try { | |
| 232 | JSON.parse(content); | |
| 233 | } catch { | |
| 234 | // Try to fix common JSON issues | |
| 235 | let fixed = content; | |
| 236 | // Remove trailing commas | |
| 237 | fixed = fixed.replace(/,(\s*[}\]])/g, "$1"); | |
| 238 | // Try again | |
| 239 | try { | |
| 240 | const parsed = JSON.parse(fixed); | |
| 241 | const reformatted = JSON.stringify(parsed, null, 2) + "\n"; | |
| 242 | modifications.set(filePath, reformatted); | |
| 243 | repairs.push({ | |
| 244 | file: filePath, | |
| 245 | type: "json-fix", | |
| 246 | description: "Fixed JSON syntax (trailing commas, formatting)", | |
| 247 | linesChanged: 1, | |
| 248 | }); | |
| 249 | } catch { | |
| 250 | // Can't auto-fix this JSON | |
| 251 | } | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | // ─── COMMIT REPAIRS ──────────────────────────────────────── | |
| 256 | ||
| 257 | if (modifications.size === 0) { | |
| 258 | return { repaired: false, repairs: [], commitSha: null }; | |
| 259 | } | |
| 260 | ||
| 91b054e | 261 | // Build the new tree via a private, isolated index file rather than raw |
| 262 | // `git mktree`. | |
| 263 | // | |
| 264 | // INCIDENT (2026-07-16): the previous implementation fed `git ls-tree -r` | |
| 265 | // output — full nested paths like "src/lib/foo.ts" — directly into | |
| 266 | // `git mktree`. `git mktree` only ever builds a SINGLE tree level and | |
| 267 | // fatally rejects any entry whose name contains a slash ("fatal: path | |
| 268 | // src/lib/foo.ts contains slash", exit 128). Because none of the exec() | |
| 269 | // calls in this function checked their exit code, that fatal error was | |
| 270 | // silently swallowed: `newTreeSha` ended up built from only the handful | |
| 271 | // of top-level entries that happened to parse before mktree aborted, and | |
| 272 | // that near-empty tree was committed and force-written over the branch | |
| 273 | // ref via `update-ref` — silently deleting the rest of the repository's | |
| 274 | // tracked files while leaving an innocuous-looking "N automatic repairs" | |
| 275 | // commit message with no indication anything else changed. | |
| 276 | // | |
| 277 | // `git read-tree` + `git update-index --cacheinfo` + `git write-tree` | |
| 278 | // handle nested paths natively (this is what `git commit` itself uses | |
| 279 | // internally) and every step below now checks its exit code — any | |
| 280 | // unexpected failure aborts the repair with no commit made, rather than | |
| 281 | // writing a partial/corrupt tree. | |
| 282 | const tmpIndexDir = await mkdtemp(join(tmpdir(), "gluecron-repair-")); | |
| 283 | const gitEnv = { GIT_INDEX_FILE: join(tmpIndexDir, "index") }; | |
| 284 | try { | |
| 285 | const readTree = await exec(["git", "read-tree", ref], repoDir, undefined, gitEnv); | |
| 286 | if (readTree.exitCode !== 0) { | |
| 287 | console.error( | |
| 288 | `[autorepair] ${owner}/${repo}@${ref}: git read-tree failed (exit ${readTree.exitCode}): ${readTree.stderr} — aborting, no commit made.` | |
| 289 | ); | |
| 290 | return { repaired: false, repairs: [], commitSha: null }; | |
| 291 | } | |
| 2c34075 | 292 | |
| 91b054e | 293 | // Look up each modified path's existing mode (preserve the executable |
| 294 | // bit etc.); new files (e.g. a freshly-created .gitignore) default to | |
| 295 | // a plain non-executable blob, matching the previous behaviour. | |
| 296 | const modeByPath = new Map<string, string>(); | |
| 297 | for (const line of (await exec(["git", "ls-tree", "-r", ref], repoDir)).stdout.split("\n")) { | |
| 298 | const match = line.match(/^(\d+) blob [0-9a-f]+\t(.+)$/); | |
| 299 | if (match) modeByPath.set(match[2], match[1]); | |
| 300 | } | |
| 2c34075 | 301 | |
| 91b054e | 302 | for (const [path, content] of modifications) { |
| 303 | const hashed = await exec(["git", "hash-object", "-w", "--stdin"], repoDir, content); | |
| 304 | if (hashed.exitCode !== 0 || !looksLikeSha(hashed.stdout)) { | |
| 305 | console.error( | |
| 306 | `[autorepair] ${owner}/${repo}@${ref}: git hash-object failed for ${path} (exit ${hashed.exitCode}): ${hashed.stderr} — aborting, no commit made.` | |
| 307 | ); | |
| 308 | return { repaired: false, repairs: [], commitSha: null }; | |
| 309 | } | |
| 310 | const mode = modeByPath.get(path) ?? "100644"; | |
| 311 | const staged = await exec( | |
| 312 | ["git", "update-index", "--add", "--cacheinfo", `${mode},${hashed.stdout},${path}`], | |
| 2c34075 | 313 | repoDir, |
| 91b054e | 314 | undefined, |
| 315 | gitEnv | |
| 2c34075 | 316 | ); |
| 91b054e | 317 | if (staged.exitCode !== 0) { |
| 318 | console.error( | |
| 319 | `[autorepair] ${owner}/${repo}@${ref}: git update-index failed for ${path} (exit ${staged.exitCode}): ${staged.stderr} — aborting, no commit made.` | |
| 320 | ); | |
| 321 | return { repaired: false, repairs: [], commitSha: null }; | |
| 322 | } | |
| 2c34075 | 323 | } |
| 324 | ||
| 91b054e | 325 | const writeTree = await exec(["git", "write-tree"], repoDir, undefined, gitEnv); |
| 326 | if (writeTree.exitCode !== 0 || !looksLikeSha(writeTree.stdout)) { | |
| 327 | console.error( | |
| 328 | `[autorepair] ${owner}/${repo}@${ref}: git write-tree failed (exit ${writeTree.exitCode}): ${writeTree.stderr} — aborting, no commit made.` | |
| 329 | ); | |
| 330 | return { repaired: false, repairs: [], commitSha: null }; | |
| 331 | } | |
| 332 | const newTreeSha = writeTree.stdout; | |
| 2c34075 | 333 | |
| 91b054e | 334 | // Get parent |
| 335 | const parent = await exec(["git", "rev-parse", ref], repoDir); | |
| 336 | if (parent.exitCode !== 0 || !looksLikeSha(parent.stdout)) { | |
| 337 | console.error( | |
| 338 | `[autorepair] ${owner}/${repo}@${ref}: git rev-parse failed (exit ${parent.exitCode}): ${parent.stderr} — aborting, no commit made.` | |
| 339 | ); | |
| 340 | return { repaired: false, repairs: [], commitSha: null }; | |
| 341 | } | |
| 342 | const parentSha = parent.stdout; | |
| 343 | ||
| 344 | // Sanity check: the new tree must not be suspiciously smaller than the | |
| 345 | // original — a last-resort guard against any future variant of this | |
| 346 | // same class of bug silently truncating the repository. `files` was | |
| 347 | // already fetched from the same ref at the top of this function, so | |
| 348 | // this reuses it instead of spending another subprocess round-trip. | |
| 349 | const originalFileCount = files.length; | |
| 350 | const newFileCount = (await exec(["git", "ls-tree", "-r", "--name-only", newTreeSha], repoDir)).stdout | |
| 351 | .split("\n") | |
| 352 | .filter(Boolean).length; | |
| 353 | if (newFileCount < originalFileCount) { | |
| 354 | console.error( | |
| 355 | `[autorepair] ${owner}/${repo}@${ref}: refusing to commit — new tree has ${newFileCount} files, original had ${originalFileCount}. This should never happen; aborting with no commit made.` | |
| 356 | ); | |
| 357 | return { repaired: false, repairs: [], commitSha: null }; | |
| 358 | } | |
| 2c34075 | 359 | |
| 91b054e | 360 | // Create commit |
| 361 | const repairSummary = repairs | |
| 362 | .map((r) => `- ${r.file}: ${r.description}`) | |
| 363 | .join("\n"); | |
| 2c34075 | 364 | |
| 91b054e | 365 | const commitMsg = `fix: auto-repair by gluecron\n\n${repairs.length} automatic repair${repairs.length > 1 ? "s" : ""}:\n${repairSummary}\n\nThis commit was created automatically by gluecron's repair engine.`; |
| 2c34075 | 366 | |
| 91b054e | 367 | const commit = await exec(["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", commitMsg], repoDir); |
| 368 | if (commit.exitCode !== 0 || !looksLikeSha(commit.stdout)) { | |
| 369 | console.error( | |
| 370 | `[autorepair] ${owner}/${repo}@${ref}: git commit-tree failed (exit ${commit.exitCode}): ${commit.stderr} — aborting, no commit made.` | |
| 371 | ); | |
| 372 | return { repaired: false, repairs: [], commitSha: null }; | |
| 373 | } | |
| 374 | const commitSha = commit.stdout; | |
| 2c34075 | 375 | |
| 91b054e | 376 | // Update ref — compare-and-swap against the parent we just read, so a |
| 377 | // concurrent push landing between rev-parse and here is rejected by git | |
| 378 | // rather than silently overwritten. | |
| 379 | const updateRef = await exec( | |
| 380 | ["git", "update-ref", `refs/heads/${ref}`, commitSha, parentSha], | |
| 381 | repoDir | |
| 382 | ); | |
| 383 | if (updateRef.exitCode !== 0) { | |
| 384 | console.error( | |
| 385 | `[autorepair] ${owner}/${repo}@${ref}: git update-ref failed (exit ${updateRef.exitCode}): ${updateRef.stderr} — repair commit ${commitSha.slice(0, 7)} was created but NOT applied to the branch.` | |
| 386 | ); | |
| 387 | return { repaired: false, repairs: [], commitSha: null }; | |
| 388 | } | |
| 2c34075 | 389 | |
| 91b054e | 390 | console.log( |
| 391 | `[autorepair] ${owner}/${repo}@${ref}: ${repairs.length} repairs committed as ${commitSha.slice(0, 7)}` | |
| 392 | ); | |
| 2c34075 | 393 | |
| 91b054e | 394 | return { repaired: true, repairs, commitSha }; |
| 395 | } finally { | |
| 396 | await rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {}); | |
| 397 | } | |
| 2c34075 | 398 | } |