/** * Lock file utilities for conflict detection */ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; export function isLockFile(filepath: string): boolean { return /(?:bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Gemfile\.lock|Pipfile\.lock|poetry\.lock|composer\.lock|cargo\.lock)$/i.test(filepath); } export function getLockFileInfo(filepath: string): { deletePattern: string; regenerateCmd: string } | null { const lower = filepath.toLowerCase(); if (lower.endsWith('bun.lock')) return { deletePattern: '**/bun.lock', regenerateCmd: 'bun install' }; if (lower.endsWith('bun.lockb')) return { deletePattern: '**/bun.lockb', regenerateCmd: 'bun install' }; if (lower.endsWith('package-lock.json')) return { deletePattern: '**/package-lock.json', regenerateCmd: 'npm install' }; if (lower.endsWith('yarn.lock')) return { deletePattern: '**/yarn.lock', regenerateCmd: 'yarn install' }; if (lower.endsWith('pnpm-lock.yaml')) return { deletePattern: '**/pnpm-lock.yaml', regenerateCmd: 'pnpm install' }; if (lower.endsWith('gemfile.lock')) return { deletePattern: '**/Gemfile.lock', regenerateCmd: 'bundle install' }; if (lower.endsWith('pipfile.lock')) return { deletePattern: '**/Pipfile.lock', regenerateCmd: 'pipenv lock' }; if (lower.endsWith('poetry.lock')) return { deletePattern: '**/poetry.lock', regenerateCmd: 'poetry lock' }; if (lower.endsWith('composer.lock')) return { deletePattern: '**/composer.lock', regenerateCmd: 'composer install' }; if (lower.endsWith('cargo.lock')) return { deletePattern: '**/Cargo.lock', regenerateCmd: 'cargo build' }; return null; } /** Trim leading whitespace so indented conflict markers still match (merge tools rarely indent, but be safe). */ function t(line: string): string { return line.trimStart(); } export function isGitConflictOpenLine(trimmed: string): boolean { return trimmed.startsWith('<<<<<<<'); } export function isGitConflictCloseLine(trimmed: string): boolean { return trimmed.startsWith('>>>>>>>'); } /** True when the line is Git's middle separator only: exactly seven `=` and optional trailing spaces. */ export function isGitConflictMiddleLine(trimmed: string): boolean { return /^={7}\s*$/.test(trimmed); } /** * One-line doc heading above a setext `=======` underline (narrow). * WHY: Orphan `=======` between normal sentences (failed merge) must still count as conflict. */ function looksLikeSetextHeadingLine(s: string): boolean { const line = s.trim(); if (!line || line.length > 120) return false; if (looksLikeCodeOrStructuredLine(line)) return false; if (isGitConflictOpenLine(line) || isGitConflictCloseLine(line) || isGitConflictMiddleLine(line)) return false; // Multi-word Title Case (e.g. "API Overview") if (/^[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\s*$/.test(line)) return true; // Single capitalized word / phrase token (e.g. "Roadmap", "v2") if (/^[A-Z][a-zA-Z0-9.-]{0,48}$/.test(line)) return true; // Short ALL CAPS heading if (/^[A-Z][A-Z0-9 ,&:+\-]{1,50}$/.test(line) && !/[a-z]/.test(line.slice(1))) return true; return false; } /** * Heuristic: line looks like source, config, or structured markup — not a one-line doc heading. */ function looksLikeCodeOrStructuredLine(s: string): boolean { if (/^(import|export|default|from)\b/.test(s)) return true; if (/^(const|let|var|function|class|interface|type|enum|namespace)\b/.test(s)) return true; if (/^(return|if|else|for|while|do|switch|case|break|continue|try|catch|finally|throw|new|await|async)\b/.test(s)) return true; if (/^(def|fn|pub|use|mod|impl|trait)\b/.test(s)) return true; if (/^#include\b|^package\b|^module\b/.test(s)) return true; if (/^(describe|it|test|expect|beforeEach|afterEach)\b/.test(s)) return true; if (/^\s*[`]{3,}/.test(s) || /^```/.test(s.trimStart())) return true; if (/^\s*[-*]\s+\[[ x]\]\s/i.test(s)) return true; if (/[{][$\w]|[};]\s*$|=>|::|<\/|^\s*[-+*]\s+`/.test(s)) return true; if (/^\s*[!%/<>&|[\]]/.test(s)) return true; return false; } /** * Single full line `=======` between a short non-code line and following body — typical setext / doc divider. * Only used when there is **no** `<<<<<<<` / `>>>>>>>` anywhere (those always imply conflict). */ function isPlausibleMarkdownSevenEqualsDivider(lines: string[], midIdx: number): boolean { let p = midIdx - 1; while (p >= 0 && lines[p]!.trim() === '') p--; if (p < 0) return false; const prev = lines[p]!.trim(); if (!prev || prev.length > 200) return false; if (!looksLikeSetextHeadingLine(prev)) return false; let n = midIdx + 1; while (n < lines.length && lines[n]!.trim() === '') n++; if (n >= lines.length) return false; const next = lines[n]!.trim(); if (!next) return false; if (isGitConflictOpenLine(next) || isGitConflictCloseLine(next)) return false; if (isGitConflictMiddleLine(next)) return false; return true; } /** * True when the file still contains git merge conflict markers or failed cleanup leftovers. * * Detects: * - **`<<<<<<<`** openers (standard regions) * - **`>>>>>>>`** closers — including orphan closers from partial cleanup * - **`=======`** middle lines: orphan middle, or **multiple** middle-only lines (broken merge); a **single** lone * middle line between a short non-code line and body is treated as markdown setext / divider (not conflict) * * Uses **trimStart** per line so slightly indented markers still count. */ export function hasConflictMarkers(content: string): boolean { const lines = content.split('\n'); let sawOpen = false; let sawClose = false; const middleIndices: number[] = []; for (let i = 0; i < lines.length; i++) { const line = t(lines[i]!); if (isGitConflictOpenLine(line)) sawOpen = true; else if (isGitConflictCloseLine(line)) sawClose = true; else if (isGitConflictMiddleLine(line)) middleIndices.push(i); } if (sawOpen || sawClose) return true; if (middleIndices.length === 0) return false; if (middleIndices.length >= 2) return true; const onlyMid = middleIndices[0]!; if (isPlausibleMarkdownSevenEqualsDivider(lines, onlyMid)) return false; return true; } /** * True if a `<<<<<<<` appears again before the first `=======` for that region (nested / botched merge). * WHY: Audits showed CHANGELOG prompts with back-to-back `<<<<<<< HEAD` — LLM resolution is unreliable; * deterministic keep-ours may also fail; warn the operator early. */ export function hasNestedConflictMarkers(content: string): boolean { const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const ti = t(lines[i]!); if (!isGitConflictOpenLine(ti)) continue; for (let j = i + 1; j < lines.length; j++) { const tj = t(lines[j]!); if (isGitConflictMiddleLine(tj) || isGitConflictCloseLine(tj)) break; if (isGitConflictOpenLine(tj)) return true; } } return false; } /** * True only when git **open or close** conflict markers appear (`<<<<<<<` / `>>>>>>>`). * WHY: `hasConflictMarkers` also flags standalone `=======` lines; markdown (ROADMAP, CHANGELOG) * often uses seven equals. Unmerged **index stage-2** blobs from Git should not contain open/close * markers — if they do, the blob is corrupt or not a normal ours side. Use this to validate * `git show :2:path` before writing deterministic keep-ours (see audit Cycle 66). */ export function hasGitConflictOpenOrCloseMarkers(content: string): boolean { for (const line of content.split('\n')) { const lineTrim = t(line); if (isGitConflictOpenLine(lineTrim) || isGitConflictCloseLine(lineTrim)) return true; } return false; } export function findFilesWithConflictMarkers(workdir: string, files: string[]): string[] { if (!workdir) return []; const conflicted: string[] = []; for (const file of files) { const fullPath = join(workdir, file); if (existsSync(fullPath)) { try { const content = readFileSync(fullPath, 'utf-8'); if (hasConflictMarkers(content)) { conflicted.push(file); } } catch { // Skip files that can't be read } } } return conflicted; }