using System.IO.Hashing; using System.Text; namespace AxCopilot.Services.Agent; /// /// Hash-anchored edits 인프라. /// 파일 읽기 시 각 라인에 2글자 해시 앵커를 부여하고, /// 편집 시 해시 앵커로 대상 라인을 정확히 식별 + 스테일 감지. /// internal static class HashAnchor { // oh-my-openagent 호환 알파벳 (16자 → 2글자 조합 = 256가지) private const string Alphabet = "ZPMQVRWSNKTXJBYH"; /// /// 라인 내용 + 라인 번호(1-based)로부터 2글자 해시 앵커를 생성합니다. /// public static string ComputeAnchor(string lineContent, int lineNumber) { // 정규화: CR 제거 + 후행 공백 제거 var normalized = lineContent.TrimEnd('\r').TrimEnd(); // 빈 줄/공백만 있는 줄 → 라인번호를 시드로 사용 (충돌 감소) uint hash; if (IsBlankOrWhitespace(normalized)) { Span numBuf = stackalloc byte[4]; System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(numBuf, lineNumber); hash = XxHash32(numBuf); } else { var bytes = Encoding.UTF8.GetBytes(normalized); hash = XxHash32(bytes); } // 8비트로 축소 → 알파벳 2글자로 인코딩 var reduced = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24)); var hi = Alphabet[(reduced >> 4) & 0x0F]; var lo = Alphabet[reduced & 0x0F]; return $"{hi}{lo}"; } /// /// 파일 전체 라인에 대해 해시 앵커를 배열로 반환합니다. /// anchors[i]는 lines[i]의 앵커 (0-based). /// public static string[] ComputeAnchors(string[] lines) { var anchors = new string[lines.Length]; for (int i = 0; i < lines.Length; i++) anchors[i] = ComputeAnchor(lines[i], i + 1); return anchors; } /// /// "LINENUM#HASH" 형식의 위치 문자열을 파싱합니다. /// 예: "11#VK" → lineNumber=11, anchor="VK" /// public static bool TryParsePosition(string pos, out int lineNumber, out string anchor) { lineNumber = 0; anchor = ""; if (string.IsNullOrWhiteSpace(pos)) return false; var hashIdx = pos.IndexOf('#'); if (hashIdx < 1 || hashIdx >= pos.Length - 1) return false; if (!int.TryParse(pos.AsSpan(0, hashIdx), out lineNumber) || lineNumber < 1) return false; anchor = pos[(hashIdx + 1)..].Trim(); return anchor.Length == 2; } /// /// 앵커가 현재 파일 라인과 일치하는지 검증합니다. /// public static bool Validate(string lineContent, int lineNumber, string expectedAnchor) { var actual = ComputeAnchor(lineContent, lineNumber); return string.Equals(actual, expectedAnchor, StringComparison.Ordinal); } /// /// 해시 앵커가 포함된 파일 읽기 출력을 생성합니다. /// 형식: "LINENUM#HASH| content" /// public static string FormatLine(string lineContent, int lineNumber, string anchor) { return $"{lineNumber}#{anchor}| {lineContent.TrimEnd('\r')}"; } /// /// 해시 앵커가 포함된 파일 전체 출력을 생성합니다. /// public static string FormatLines(string[] lines, string[] anchors, int startIdx, int endIdx) { var sb = new StringBuilder((endIdx - startIdx) * 80); for (int i = startIdx; i < endIdx && i < lines.Length; i++) { var lineNum = i + 1; sb.Append(lineNum); sb.Append('#'); sb.Append(anchors[i]); sb.Append("| "); sb.AppendLine(lines[i].TrimEnd('\r')); } return sb.ToString(); } /// /// 여러 앵커 위치를 검증하고, 불일치가 있으면 상세 에러를 반환합니다. /// public static (bool AllValid, string? ErrorDetail) ValidatePositions( string[] lines, List<(int LineNumber, string Anchor)> positions) { var mismatches = new List(); foreach (var (lineNum, expectedAnchor) in positions) { if (lineNum < 1 || lineNum > lines.Length) { mismatches.Add($" Line {lineNum}: out of range (file has {lines.Length} lines)"); continue; } var actual = ComputeAnchor(lines[lineNum - 1], lineNum); if (!string.Equals(actual, expectedAnchor, StringComparison.Ordinal)) { var preview = lines[lineNum - 1].TrimEnd('\r'); if (preview.Length > 80) preview = preview[..80] + "..."; mismatches.Add($" Line {lineNum}: expected #{expectedAnchor}, got #{actual} — \"{preview}\""); } } if (mismatches.Count == 0) return (true, null); var detail = $"Hash anchor mismatch — file was modified since last read. Re-read the file to get fresh anchors.\n" + string.Join("\n", mismatches); return (false, detail); } // ════════════════════════════════════════════ // 내부 유틸 // ════════════════════════════════════════════ private static bool IsBlankOrWhitespace(string s) { foreach (var c in s) { if (c != ' ' && c != '\t') return false; } return true; } private static uint XxHash32(ReadOnlySpan data) { // System.IO.Hashing 사용 return System.IO.Hashing.XxHash32.HashToUInt32(data); } }