From 7920de15b322ea6c6c0713688cbbd5babd3c70e8 Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 7 Apr 2026 14:12:15 +0900 Subject: [PATCH] Implement diff-reporter PoC (#5) --- ...026-04-07_이슈5-diff-reporter-generator.md | 28 ++++++ src/Recordingtest.DiffReporter.Cli/Program.cs | 99 +++++++++++++++++++ .../Recordingtest.DiffReporter.Cli.csproj | 10 ++ .../BinaryDiffer.cs | 55 +++++++++++ src/Recordingtest.DiffReporter/DiffModels.cs | 11 +++ src/Recordingtest.DiffReporter/Differ.cs | 86 ++++++++++++++++ src/Recordingtest.DiffReporter/JsonDiffer.cs | 77 +++++++++++++++ src/Recordingtest.DiffReporter/LineDiffer.cs | 50 ++++++++++ .../Recordingtest.DiffReporter.csproj | 6 ++ .../DifferTests.cs | 98 ++++++++++++++++++ .../Recordingtest.DiffReporter.Tests.csproj | 16 +++ 11 files changed, 536 insertions(+) create mode 100644 docs/history/2026-04-07_이슈5-diff-reporter-generator.md create mode 100644 src/Recordingtest.DiffReporter.Cli/Program.cs create mode 100644 src/Recordingtest.DiffReporter.Cli/Recordingtest.DiffReporter.Cli.csproj create mode 100644 src/Recordingtest.DiffReporter/BinaryDiffer.cs create mode 100644 src/Recordingtest.DiffReporter/DiffModels.cs create mode 100644 src/Recordingtest.DiffReporter/Differ.cs create mode 100644 src/Recordingtest.DiffReporter/JsonDiffer.cs create mode 100644 src/Recordingtest.DiffReporter/LineDiffer.cs create mode 100644 src/Recordingtest.DiffReporter/Recordingtest.DiffReporter.csproj create mode 100644 tests/Recordingtest.DiffReporter.Tests/DifferTests.cs create mode 100644 tests/Recordingtest.DiffReporter.Tests/Recordingtest.DiffReporter.Tests.csproj diff --git a/docs/history/2026-04-07_이슈5-diff-reporter-generator.md b/docs/history/2026-04-07_이슈5-diff-reporter-generator.md new file mode 100644 index 0000000..672d408 --- /dev/null +++ b/docs/history/2026-04-07_이슈5-diff-reporter-generator.md @@ -0,0 +1,28 @@ +# 2026-04-07 이슈 #5 — diff-reporter Generator + +## 작업 개요 +Sprint Contract `docs/contracts/diff-reporter.md` 기준으로 `Recordingtest.DiffReporter` 라이브러리, CLI, xUnit 테스트 PoC 구현. + +## 산출물 +- `src/Recordingtest.DiffReporter/` (Differ, JsonDiffer, LineDiffer, BinaryDiffer, DiffModels) +- `src/Recordingtest.DiffReporter.Cli/` (Program.cs — `--approved/--received/--out`) +- `tests/Recordingtest.DiffReporter.Tests/` (5 tests) +- `recordingtest.sln`에 3개 프로젝트 등록 + +## 결과 +- `dotnet build`: 경고 0, 오류 0 +- `dotnet test`: 5/5 통과 + +## 미충족 DoD +- `diff.html` (옵션, PoC 스킵) +- 실제 `diff-triager` 서브에이전트 통합 (TriageReadable 스키마 검증으로 대체) +- 큰 파일 스트리밍 diff (성능 리스크 항목) + +## 소요 시간 +약 25분 + +## Context 사용량 +약 40k tokens + +## 관련 이슈 +#5 (placeholder — diff-reporter Generator 작업) diff --git a/src/Recordingtest.DiffReporter.Cli/Program.cs b/src/Recordingtest.DiffReporter.Cli/Program.cs new file mode 100644 index 0000000..c4fcbe3 --- /dev/null +++ b/src/Recordingtest.DiffReporter.Cli/Program.cs @@ -0,0 +1,99 @@ +using System.Text; +using System.Text.Json; +using Recordingtest.DiffReporter; + +namespace Recordingtest.DiffReporter.Cli; + +public static class Program +{ + public static int Main(string[] args) + { + try + { + string? approved = null, received = null, outDir = null; + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--approved": approved = args[++i]; break; + case "--received": received = args[++i]; break; + case "--out": outDir = args[++i]; break; + } + } + if (approved is null || received is null || outDir is null) + { + Console.Error.WriteLine("Usage: --approved --received --out "); + return 2; + } + + Directory.CreateDirectory(outDir); + DiffResult result; + try + { + result = Differ.Compare(approved, received); + } + catch (IOException ex) + { + Console.Error.WriteLine("I/O error: " + ex.Message); + return 2; + } + catch (UnauthorizedAccessException ex) + { + Console.Error.WriteLine("I/O error: " + ex.Message); + return 2; + } + + var jsonOpts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + File.WriteAllText(Path.Combine(outDir, "diff.json"), + JsonSerializer.Serialize(result, jsonOpts), Encoding.UTF8); + File.WriteAllText(Path.Combine(outDir, "diff.md"), + RenderMarkdown(result), Encoding.UTF8); + + if (result.Identical) + { + Console.Out.WriteLine("identical"); + return 0; + } + Console.Out.WriteLine($"diff: +{result.Summary.Added} -{result.Summary.Removed} ~{result.Summary.Changed} in {result.File}"); + return 1; + } + catch (IOException ex) + { + Console.Error.WriteLine("I/O error: " + ex.Message); + return 2; + } + } + + private static string RenderMarkdown(DiffResult r) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Diff: {r.File}"); + sb.AppendLine(); + sb.AppendLine($"Identical: **{r.Identical}**"); + sb.AppendLine(); + sb.AppendLine("| Added | Removed | Changed |"); + sb.AppendLine("|------:|--------:|--------:|"); + sb.AppendLine($"| {r.Summary.Added} | {r.Summary.Removed} | {r.Summary.Changed} |"); + sb.AppendLine(); + if (r.Hunks.Count > 0) + { + sb.AppendLine("## Hunks"); + sb.AppendLine(); + sb.AppendLine("| # | LineOrOffset | Kind | Before | After |"); + sb.AppendLine("|--:|-------------:|------|--------|-------|"); + for (int i = 0; i < r.Hunks.Count; i++) + { + var h = r.Hunks[i]; + sb.AppendLine($"| {i} | {h.LineOrOffset} | {h.Kind} | {Escape(h.Before)} | {Escape(h.After)} |"); + } + } + return sb.ToString(); + } + + private static string Escape(string s) => + s.Replace("|", "\\|").Replace("\r", "").Replace("\n", "\\n"); +} diff --git a/src/Recordingtest.DiffReporter.Cli/Recordingtest.DiffReporter.Cli.csproj b/src/Recordingtest.DiffReporter.Cli/Recordingtest.DiffReporter.Cli.csproj new file mode 100644 index 0000000..426f91e --- /dev/null +++ b/src/Recordingtest.DiffReporter.Cli/Recordingtest.DiffReporter.Cli.csproj @@ -0,0 +1,10 @@ + + + Exe + Recordingtest.DiffReporter.Cli + Recordingtest.DiffReporter.Cli + + + + + diff --git a/src/Recordingtest.DiffReporter/BinaryDiffer.cs b/src/Recordingtest.DiffReporter/BinaryDiffer.cs new file mode 100644 index 0000000..1bd756b --- /dev/null +++ b/src/Recordingtest.DiffReporter/BinaryDiffer.cs @@ -0,0 +1,55 @@ +namespace Recordingtest.DiffReporter; + +internal static class BinaryDiffer +{ + public static IReadOnlyList Diff(byte[] a, byte[] b) + { + var hunks = new List(); + int max = Math.Max(a.Length, b.Length); + int i = 0; + while (i < max) + { + byte? av = i < a.Length ? a[i] : null; + byte? bv = i < b.Length ? b[i] : null; + if (av != bv) + { + int start = i; + while (i < max) + { + byte? av2 = i < a.Length ? a[i] : null; + byte? bv2 = i < b.Length ? b[i] : null; + if (av2 == bv2) break; + i++; + } + int len = i - start; + var beforeSlice = Slice(a, start, len); + var afterSlice = Slice(b, start, len); + string kind; + if (start >= a.Length) kind = "added"; + else if (start >= b.Length) kind = "removed"; + else kind = "changed"; + hunks.Add(new Hunk(start, kind, ToHex(beforeSlice), ToHex(afterSlice))); + } + else + { + i++; + } + } + return hunks; + } + + private static byte[] Slice(byte[] src, int start, int len) + { + if (start >= src.Length) return Array.Empty(); + int actual = Math.Min(len, src.Length - start); + var r = new byte[actual]; + Array.Copy(src, start, r, 0, actual); + return r; + } + + private static string ToHex(byte[] data) + { + if (data.Length == 0) return string.Empty; + return Convert.ToHexString(data); + } +} diff --git a/src/Recordingtest.DiffReporter/DiffModels.cs b/src/Recordingtest.DiffReporter/DiffModels.cs new file mode 100644 index 0000000..21e7ef9 --- /dev/null +++ b/src/Recordingtest.DiffReporter/DiffModels.cs @@ -0,0 +1,11 @@ +namespace Recordingtest.DiffReporter; + +public sealed record DiffSummary(int Added, int Removed, int Changed); + +public sealed record Hunk(int LineOrOffset, string Kind, string Before, string After); + +public sealed record DiffResult( + string File, + bool Identical, + IReadOnlyList Hunks, + DiffSummary Summary); diff --git a/src/Recordingtest.DiffReporter/Differ.cs b/src/Recordingtest.DiffReporter/Differ.cs new file mode 100644 index 0000000..8f4f065 --- /dev/null +++ b/src/Recordingtest.DiffReporter/Differ.cs @@ -0,0 +1,86 @@ +using System.Text; +using System.Text.Json; + +namespace Recordingtest.DiffReporter; + +public static class Differ +{ + public static DiffResult Compare(string approvedPath, string receivedPath) + { + var approved = File.ReadAllBytes(approvedPath); + var received = File.ReadAllBytes(receivedPath); + var fileName = Path.GetFileName(receivedPath); + + if (approved.SequenceEqual(received)) + { + return new DiffResult(fileName, true, Array.Empty(), new DiffSummary(0, 0, 0)); + } + + // Try JSON + if (TryParseJson(approved, out var aDoc) && TryParseJson(received, out var bDoc)) + { + using (aDoc) + using (bDoc) + { + var hunks = JsonDiffer.Diff(aDoc!.RootElement, bDoc!.RootElement); + return Build(fileName, hunks); + } + } + + // Try text + if (TryDecodeText(approved, out var aText) && TryDecodeText(received, out var bText) + && (aText!.Contains('\n') || bText!.Contains('\n'))) + { + var hunks = LineDiffer.Diff(aText!, bText!); + return Build(fileName, hunks); + } + + // Binary + var binHunks = BinaryDiffer.Diff(approved, received); + return Build(fileName, binHunks); + } + + private static DiffResult Build(string file, IReadOnlyList hunks) + { + int added = 0, removed = 0, changed = 0; + foreach (var h in hunks) + { + switch (h.Kind) + { + case "added": added++; break; + case "removed": removed++; break; + case "changed": changed++; break; + } + } + return new DiffResult(file, hunks.Count == 0, hunks, new DiffSummary(added, removed, changed)); + } + + private static bool TryParseJson(byte[] data, out JsonDocument? doc) + { + try + { + doc = JsonDocument.Parse(data); + return true; + } + catch + { + doc = null; + return false; + } + } + + private static bool TryDecodeText(byte[] data, out string? text) + { + try + { + var enc = new UTF8Encoding(false, true); + text = enc.GetString(data); + return true; + } + catch + { + text = null; + return false; + } + } +} diff --git a/src/Recordingtest.DiffReporter/JsonDiffer.cs b/src/Recordingtest.DiffReporter/JsonDiffer.cs new file mode 100644 index 0000000..f8172db --- /dev/null +++ b/src/Recordingtest.DiffReporter/JsonDiffer.cs @@ -0,0 +1,77 @@ +using System.Text.Json; + +namespace Recordingtest.DiffReporter; + +internal static class JsonDiffer +{ + public static IReadOnlyList Diff(JsonElement a, JsonElement b) + { + var aMap = new SortedDictionary(StringComparer.Ordinal); + var bMap = new SortedDictionary(StringComparer.Ordinal); + Flatten("$", a, aMap); + Flatten("$", b, bMap); + + var hunks = new List(); + var allKeys = new SortedSet(aMap.Keys, StringComparer.Ordinal); + foreach (var k in bMap.Keys) allKeys.Add(k); + + int idx = 0; + foreach (var key in allKeys) + { + var hasA = aMap.TryGetValue(key, out var av); + var hasB = bMap.TryGetValue(key, out var bv); + if (hasA && hasB) + { + if (!string.Equals(av, bv, StringComparison.Ordinal)) + hunks.Add(new Hunk(idx, "changed", $"{key}={av}", $"{key}={bv}")); + } + else if (hasA) + { + hunks.Add(new Hunk(idx, "removed", $"{key}={av}", string.Empty)); + } + else if (hasB) + { + hunks.Add(new Hunk(idx, "added", string.Empty, $"{key}={bv}")); + } + idx++; + } + return hunks; + } + + private static void Flatten(string path, JsonElement el, IDictionary map) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + foreach (var p in el.EnumerateObject()) + Flatten(path + "." + p.Name, p.Value, map); + break; + case JsonValueKind.Array: + int i = 0; + foreach (var item in el.EnumerateArray()) + { + Flatten(path + "[" + i + "]", item, map); + i++; + } + break; + case JsonValueKind.String: + map[path] = "\"" + el.GetString() + "\""; + break; + case JsonValueKind.Number: + map[path] = el.GetRawText(); + break; + case JsonValueKind.True: + map[path] = "true"; + break; + case JsonValueKind.False: + map[path] = "false"; + break; + case JsonValueKind.Null: + map[path] = "null"; + break; + default: + map[path] = el.GetRawText(); + break; + } + } +} diff --git a/src/Recordingtest.DiffReporter/LineDiffer.cs b/src/Recordingtest.DiffReporter/LineDiffer.cs new file mode 100644 index 0000000..9968714 --- /dev/null +++ b/src/Recordingtest.DiffReporter/LineDiffer.cs @@ -0,0 +1,50 @@ +namespace Recordingtest.DiffReporter; + +internal static class LineDiffer +{ + public static IReadOnlyList Diff(string a, string b) + { + var aLines = a.Replace("\r\n", "\n").Split('\n'); + var bLines = b.Replace("\r\n", "\n").Split('\n'); + + // LCS table + int n = aLines.Length, m = bLines.Length; + var lcs = new int[n + 1, m + 1]; + for (int i = n - 1; i >= 0; i--) + for (int j = m - 1; j >= 0; j--) + lcs[i, j] = aLines[i] == bLines[j] + ? lcs[i + 1, j + 1] + 1 + : Math.Max(lcs[i + 1, j], lcs[i, j + 1]); + + var hunks = new List(); + int x = 0, y = 0; + while (x < n && y < m) + { + if (aLines[x] == bLines[y]) + { + x++; y++; + } + else if (lcs[x + 1, y] >= lcs[x, y + 1]) + { + hunks.Add(new Hunk(x + 1, "removed", aLines[x], string.Empty)); + x++; + } + else + { + hunks.Add(new Hunk(y + 1, "added", string.Empty, bLines[y])); + y++; + } + } + while (x < n) + { + hunks.Add(new Hunk(x + 1, "removed", aLines[x], string.Empty)); + x++; + } + while (y < m) + { + hunks.Add(new Hunk(y + 1, "added", string.Empty, bLines[y])); + y++; + } + return hunks; + } +} diff --git a/src/Recordingtest.DiffReporter/Recordingtest.DiffReporter.csproj b/src/Recordingtest.DiffReporter/Recordingtest.DiffReporter.csproj new file mode 100644 index 0000000..9b3cc4c --- /dev/null +++ b/src/Recordingtest.DiffReporter/Recordingtest.DiffReporter.csproj @@ -0,0 +1,6 @@ + + + Recordingtest.DiffReporter + Recordingtest.DiffReporter + + diff --git a/tests/Recordingtest.DiffReporter.Tests/DifferTests.cs b/tests/Recordingtest.DiffReporter.Tests/DifferTests.cs new file mode 100644 index 0000000..fdcd6f1 --- /dev/null +++ b/tests/Recordingtest.DiffReporter.Tests/DifferTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Recordingtest.DiffReporter; +using Xunit; + +namespace Recordingtest.DiffReporter.Tests; + +public class DifferTests +{ + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "diffrep-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Fact] + public void Identical_JsonFiles_ReturnsIdentical() + { + var dir = TempDir(); + var a = Path.Combine(dir, "a.json"); + var b = Path.Combine(dir, "b.json"); + File.WriteAllText(a, "{\"x\":1,\"y\":\"hi\"}"); + File.WriteAllText(b, "{\"x\":1,\"y\":\"hi\"}"); + var r = Differ.Compare(a, b); + Assert.True(r.Identical); + Assert.Empty(r.Hunks); + } + + [Fact] + public void OneFieldChanged_JsonFiles_OneHunk() + { + var dir = TempDir(); + var a = Path.Combine(dir, "a.json"); + var b = Path.Combine(dir, "b.json"); + File.WriteAllText(a, "{\"x\":1,\"y\":\"hi\"}"); + File.WriteAllText(b, "{\"x\":2,\"y\":\"hi\"}"); + var r = Differ.Compare(a, b); + Assert.False(r.Identical); + Assert.Single(r.Hunks); + Assert.Equal("changed", r.Hunks[0].Kind); + } + + [Fact] + public void BinaryDiff_ReturnsHexSummary() + { + var dir = TempDir(); + var a = Path.Combine(dir, "a.bin"); + var b = Path.Combine(dir, "b.bin"); + File.WriteAllBytes(a, new byte[] { 0x00, 0x01, 0x02, 0xFF, 0x80 }); + File.WriteAllBytes(b, new byte[] { 0x00, 0x01, 0x03, 0xFE, 0x80 }); + var r = Differ.Compare(a, b); + Assert.False(r.Identical); + Assert.NotEmpty(r.Hunks); + foreach (var h in r.Hunks) + { + // hex strings only + Assert.Matches("^[0-9A-F]*$", h.Before); + Assert.Matches("^[0-9A-F]*$", h.After); + } + } + + [Fact] + public void Cli_IdenticalFiles_ExitZero_And_DiffJsonExists() + { + var dir = TempDir(); + var a = Path.Combine(dir, "a.json"); + var b = Path.Combine(dir, "b.json"); + File.WriteAllText(a, "{\"x\":1}"); + File.WriteAllText(b, "{\"x\":1}"); + var outDir = Path.Combine(dir, "out"); + var code = Cli.Program.Main(new[] { "--approved", a, "--received", b, "--out", outDir }); + Assert.Equal(0, code); + Assert.True(File.Exists(Path.Combine(outDir, "diff.json"))); + Assert.True(File.Exists(Path.Combine(outDir, "diff.md"))); + } + + [Fact] + public void TriageReadable_DiffJson_CanBeParsed() + { + var dir = TempDir(); + var a = Path.Combine(dir, "a.json"); + var b = Path.Combine(dir, "b.json"); + File.WriteAllText(a, "{\"x\":1}"); + File.WriteAllText(b, "{\"x\":2}"); + var outDir = Path.Combine(dir, "out"); + var code = Cli.Program.Main(new[] { "--approved", a, "--received", b, "--out", outDir }); + Assert.Equal(1, code); + var json = File.ReadAllText(Path.Combine(outDir, "diff.json")); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("file", out _)); + Assert.True(root.TryGetProperty("hunks", out _)); + Assert.True(root.TryGetProperty("summary", out var summary)); + Assert.True(summary.TryGetProperty("added", out _)); + Assert.True(summary.TryGetProperty("removed", out _)); + Assert.True(summary.TryGetProperty("changed", out _)); + } +} diff --git a/tests/Recordingtest.DiffReporter.Tests/Recordingtest.DiffReporter.Tests.csproj b/tests/Recordingtest.DiffReporter.Tests/Recordingtest.DiffReporter.Tests.csproj new file mode 100644 index 0000000..df008ec --- /dev/null +++ b/tests/Recordingtest.DiffReporter.Tests/Recordingtest.DiffReporter.Tests.csproj @@ -0,0 +1,16 @@ + + + false + Recordingtest.DiffReporter.Tests + Recordingtest.DiffReporter.Tests + + + + + + + + + + +