258 lines · 9.0 KB · TypeScript
RawDownload
/** * Git diff and change queries */import type { SimpleGit } from 'simple-git';import { debug } from '../logger.js'; /** * Map (path -> (oldLine -> newLine)) from git diff base..head. * WHY: After push, review comment line numbers refer to pre-push code; re-mapping * reduces WRONG_LOCATION when the fixer looks up code at the comment's line. * Only context lines (unchanged) are mapped; deleted/added lines are skipped. */export async function computeLineMapFromDiff(  git: SimpleGit,  baseRef: string,  headRef: string): Promise<Map<string, Map<number, number>>> {  const out = new Map<string, Map<number, number>>();  let raw: string;  try {    raw = await git.raw(['diff', `${baseRef}..${headRef}`]);  } catch (err) {    debug('Line map diff failed', { baseRef, headRef, error: err instanceof Error ? err.message : String(err) });    return out;  }  const lines = raw.split('\n');  let path: string | null = null;  let oldLine = 0;  let newLine = 0;  for (let i = 0; i < lines.length; i++) {    const line = lines[i];    const diffFile = line.match(/^diff --git a\/.+? b\/(.+)/);    if (diffFile) {      path = diffFile[1];      continue;    }    const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);    // Note: supports hunk parsing only if a valid path is established from the diff output.    if (hunk && path) {      oldLine = parseInt(hunk[1], 10);      newLine = parseInt(hunk[3], 10);      continue;    }    if (path && (line.startsWith(' ') || line.startsWith('-') || line.startsWith('+'))) {      if (line.startsWith(' ') || line.startsWith('-')) {        if (line.startsWith(' ')) {          let fileMap = out.get(path);          if (!fileMap) {            fileMap = new Map();            out.set(path, fileMap);          }          fileMap.set(oldLine, newLine);        }        oldLine++;      }      if (line.startsWith(' ') || line.startsWith('+')) newLine++;    }  }  return out;}  export async function getChangedFiles(git: SimpleGit): Promise<string[]> {  const status = await git.status();  return [...new Set([          ...status.modified,          ...status.created,          ...status.not_added,          ...status.deleted,          ...status.renamed.map((r) => r.to),      ])];} export async function getDiff(git: SimpleGit, file?: string): Promise<string> {  if (file) {    return git.diff(['--', file]);  }  return git.diff();} export async function getDiffForFile(git: SimpleGit, file: string): Promise<string> {  try {    return await git.diff(['HEAD', '--', file]);  } catch {    // File might be new (untracked), try --no-index diff    try {      return await git.diff(['--no-index', '/dev/null', file]);    } catch (err) {      // Log error but return empty - file may not exist or have permission issues      debug('Failed to get diff for file', { file, error: err instanceof Error ? err.message : String(err) });      return '';    }  // Note: handles known exit code 1 as a valid case for file differences without failing  }} /** Margin (lines) when filtering a diff by line range so the verifier sees enough context. */const DIFF_FILTER_LINE_MARGIN = 40; /** * Filter a single-file unified diff to only hunks that overlap the given line range. * WHY: When multiple fixes target the same file, the batch verifier gets the same full diff for each; * filtering per issue by line avoids attributing one fix's hunks to another (prompts.log audit). */export function filterUnifiedDiffByLineRange(  diff: string,  issueLine: number,  margin: number = DIFF_FILTER_LINE_MARGIN): string {  const minLine = Math.max(1, issueLine - margin);  const maxLine = issueLine + margin;  const lines = diff.split('\n');  const out: string[] = [];  let inHeader = true;  let keepHunk = false;  let hunkLines: string[] = [];   for (let i = 0; i < lines.length; i++) {    const line = lines[i];    const diffFile = line.match(/^diff --git a\/.+? b\/(.+)/);    if (diffFile) {      if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines);      hunkLines = [];      inHeader = true;      out.push(line);      continue;    }    if (line.startsWith('---') || line.startsWith('+++')) {      out.push(line);      continue;    }    const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);    if (hunk) {      if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines);      const oldStart = parseInt(hunk[1], 10);      const oldCount = parseInt(hunk[2] || '1', 10);      const oldEnd = oldStart + oldCount;      keepHunk = oldEnd >= minLine && oldStart <= maxLine;      hunkLines = [line];      inHeader = false;      continue;    }    if (!inHeader) hunkLines.push(line);  }  if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines);  return out.join('\n');} export async function hasChanges(git: SimpleGit): Promise<boolean> {  const status = await git.status();  return !status.isClean();} /** * Detect if a file has been corrupted by automated fix attempts. * * Compares the current working tree version against the base branch version. * Returns { corrupted: true, reason, baseContent } if the file shows signs of corruption: * - Duplicate method/function definitions (≥3 occurrences) * - Brace imbalance growth compared to the base version * - Tool-artifact remnants (search/replace XML markup) * - Diff from base has grown significantly (>2x) * * @param baseBranch - The base branch to compare against (e.g. "origin/dev") * @returns { corrupted: false } if file is healthy, or { corrupted: true, reason?: string, baseContent?: string } if corrupted */export async function detectFileCorruption(  git: SimpleGit,  filePath: string,  baseBranch: string): Promise<{ corrupted: boolean; reason?: string; baseContent?: string }> {  try {    // Get the base branch version of the file    let baseContent: string;    try {      baseContent = await git.show([`${baseBranch}:${filePath}`]);    } catch {      // File doesn't exist in base branch — can't be corrupted by us      return { corrupted: false };    }     // Get current working tree content    let currentContent: string;    try {      const { readFileSync } = await import('fs');      const { resolve } = await import('path');      const cwd = await git.revparse(['--show-toplevel']);      currentContent = readFileSync(resolve(cwd.trim(), filePath), 'utf-8');    } catch {      return { corrupted: false };    }     const baseLines = baseContent.split('\n').length;    const currentLines = currentContent.split('\n').length;    const currentLower = currentContent.toLowerCase();     // Heuristic 1: Duplicate method/function definitions    // A sign of layered failed search/replace attempts    // Note: Only match `(` not `<` to avoid false positives on generic type exports like `export type Foo<T>`    const methodPattern = /(?:public|private|protected|async|export)\s+(?:function\s+)?(\w+)\s*\(/g;    const methodCounts = new Map<string, number>();    let match;    while ((match = methodPattern.exec(currentContent)) !== null) {      const name = match[1];      methodCounts.set(name, (methodCounts.get(name) || 0) + 1);    }    const duplicateMethods = [...methodCounts.entries()].filter(([, count]) => count >= 3);    if (duplicateMethods.length > 0) {      const names = duplicateMethods.map(([name, count]) => `${name}(×${count})`).join(', ');      return {        corrupted: true,        reason: `Duplicate method definitions detected: ${names}`,        baseContent,      };    }     // Heuristic 2: Orphaned code indicators    // Unbalanced braces suggest structural damage    const openBraces = (currentContent.match(/\{/g) || []).length;    const closeBraces = (currentContent.match(/\}/g) || []).length;    const braceImbalance = Math.abs(openBraces - closeBraces);    const baseBraceOpen = (baseContent.match(/\{/g) || []).length;    const baseBraceClose = (baseContent.match(/\}/g) || []).length;    const baseBraceImbalance = Math.abs(baseBraceOpen - baseBraceClose);    if (braceImbalance > baseBraceImbalance + 3) {      return {        corrupted: true,        reason: `Structural damage: brace imbalance grew from ${baseBraceImbalance} to ${braceImbalance}`,        baseContent,      };    }     // Heuristic 3: Tool artifact remnants inside the code    if (currentLower.includes('<search>') || currentLower.includes('<replace>') ||        currentLower.includes('</change>') || currentLower.includes('<change path=')) {      return {        corrupted: true,        reason: 'Tool markup (search/replace XML) found inside source file',        baseContent,      };    }     // Heuristic 4: File has grown significantly    if (currentLines > baseLines * 2) {      return {        corrupted: true,        reason: `File length has more than doubled (${baseLines} → ${currentLines} lines)`,        baseContent,      };    }     return { corrupted: false };  } catch (err) {    debug('Corruption detection failed', { filePath, error: err instanceof Error ? err.message : String(err) });    return { corrupted: false };  }}