diff --git a/docs/history/2026-04-07_이슈4-normalizer-iteration2.md b/docs/history/2026-04-07_이슈4-normalizer-iteration2.md
new file mode 100644
index 0000000..185edc9
--- /dev/null
+++ b/docs/history/2026-04-07_이슈4-normalizer-iteration2.md
@@ -0,0 +1,41 @@
+# 2026-04-07 이슈 #4 — Normalizer Iteration 2 (Generator)
+
+## 요약
+Evaluator가 fail 처리한 두 항목(DoD #6 sidecar 로그, DoD #7 suspected-field 커버리지)을 수정.
+
+## 변경 사항
+- `src/Recordingtest.Normalizer/Normalizer.cs`
+ - `Normalize(string input, string profileName, string? sidecarPath = null)` 오버로드 추가.
+ - `sidecarPath`가 디렉터리면 `
/normalization.log`, 아니면 해당 경로에 작성.
+ - 포맷: `{RuleId}\tcount={Count}` 라인을 RuleId 사전순 정렬, 마지막 `total=` 라인.
+- `src/Recordingtest.Normalizer/Rules.cs`
+ - `mask_volatile_settings` 규칙 추가. catalog에 등재된 휘발성 boolean/scalar 필드 (GridSnap, IsSidePanelVisible, GridColor.* 등)의 값을 ``로 마스킹.
+- `src/Recordingtest.Normalizer/profiles/default.yaml`
+ - `mask_volatile_settings` 규칙을 sort_json_keys 직전에 추가.
+- `tests/Recordingtest.Normalizer.Tests/RuleTests.cs`
+ - sidecar 파일/디렉터리 두 케이스 테스트 추가.
+ - default 적용 규칙 수 5 → 6 갱신, `mask_volatile_settings` 포함 검증.
+- `tests/Recordingtest.Normalizer.Tests/CoverageTests.cs`
+ - `|| true` 단락 제거. 명시적 `Dictionary` 필드→규칙 매핑 도입.
+ - 매핑된 규칙이 default 프로파일에 실제로 존재하는지 검증.
+ - 매핑 없는 필드는 explicit 메시지로 fail.
+
+## 매핑 (suspectedNondeterministicFields → 규칙)
+- `AutoSaveFilePath`, `AutoSave_RecentFileName` → `normalize_paths`
+- `CanOverrideWireColorWithFace`, `IsSidePanelVisible`, `OverrideFaceColor`, `Solar_IsLocalTime`, `VisibleGrid`, `GridSnap`, `MidpointOsnap`, `GridSpacing` → `mask_volatile_settings`
+- `GridColor.{ALPHA,BLUE,GREEN,RED}`, `MajorGridColor.{ALPHA,BLUE,GREEN,RED}` → `mask_volatile_settings`
+
+## 결과
+- `dotnet build recordingtest.sln`: 경고 0, 오류 0
+- `dotnet test tests/Recordingtest.Normalizer.Tests`: 10/10 pass (기존 8 + sidecar 2)
+- 강화된 coverage 테스트가 명시적 매핑으로 실제 통과함 (단락 없음).
+- sidecar 파일 작성 verified (파일 존재 + 라인 포맷 + 정렬 + total 합계).
+
+## 소요 시간
+약 15분
+
+## Context 사용량
+약 35k tokens
+
+## 관련 이슈
+#4
diff --git a/src/Recordingtest.Normalizer/Normalizer.cs b/src/Recordingtest.Normalizer/Normalizer.cs
index 63b8338..3e387d0 100644
--- a/src/Recordingtest.Normalizer/Normalizer.cs
+++ b/src/Recordingtest.Normalizer/Normalizer.cs
@@ -6,6 +6,9 @@ namespace Recordingtest.Normalizer;
public static class Normalizer
{
public static NormalizeResult Normalize(string input, string profileName)
+ => Normalize(input, profileName, null);
+
+ public static NormalizeResult Normalize(string input, string profileName, string? sidecarPath)
{
var profile = Profile.Load(profileName);
var log = new List();
@@ -99,6 +102,20 @@ public static class Normalizer
}
break;
}
+ case "mask_volatile_settings":
+ {
+ if (isJson && jsonNode is not null)
+ {
+ var (n, c) = Rules.MaskVolatileSettings(jsonNode);
+ jsonNode = n;
+ log.Add(new RuleApplication(rule, c));
+ }
+ else
+ {
+ log.Add(new RuleApplication(rule, 0));
+ }
+ break;
+ }
case "sort_json_keys":
{
if (isJson && jsonNode is not null)
@@ -128,6 +145,36 @@ public static class Normalizer
output = current;
}
- return new NormalizeResult(output, log);
+ var result = new NormalizeResult(output, log);
+
+ if (sidecarPath is not null)
+ {
+ string filePath;
+ if (Directory.Exists(sidecarPath))
+ {
+ filePath = Path.Combine(sidecarPath, "normalization.log");
+ }
+ else
+ {
+ var parent = Path.GetDirectoryName(sidecarPath);
+ if (!string.IsNullOrEmpty(parent) && !Directory.Exists(parent))
+ {
+ Directory.CreateDirectory(parent);
+ }
+ filePath = sidecarPath;
+ }
+
+ var sb = new System.Text.StringBuilder();
+ int total = 0;
+ foreach (var entry in log.OrderBy(l => l.RuleId, StringComparer.Ordinal))
+ {
+ sb.Append(entry.RuleId).Append("\tcount=").Append(entry.Count).Append('\n');
+ total += entry.Count;
+ }
+ sb.Append("total=").Append(total).Append('\n');
+ File.WriteAllText(filePath, sb.ToString());
+ }
+
+ return result;
}
}
diff --git a/src/Recordingtest.Normalizer/Rules.cs b/src/Recordingtest.Normalizer/Rules.cs
index ed78112..ab1d484 100644
--- a/src/Recordingtest.Normalizer/Rules.cs
+++ b/src/Recordingtest.Normalizer/Rules.cs
@@ -163,6 +163,66 @@ public static class Rules
return false;
}
+ ///
+ /// Allowlist of field names whose values are known to be volatile boolean/scalar
+ /// settings (per docs/sut-catalog/json-configs.json). The values are replaced with
+ /// a deterministic placeholder so golden-file comparisons stay stable while still
+ /// preserving the field's presence and key order.
+ ///
+ public static readonly HashSet VolatileSettingFieldNames = new(StringComparer.Ordinal)
+ {
+ "CanOverrideWireColorWithFace",
+ "IsSidePanelVisible",
+ "OverrideFaceColor",
+ "Solar_IsLocalTime",
+ "VisibleGrid",
+ "GridSnap",
+ "MidpointOsnap",
+ "GridSpacing",
+ "GridColor.ALPHA",
+ "GridColor.BLUE",
+ "GridColor.GREEN",
+ "GridColor.RED",
+ "MajorGridColor.ALPHA",
+ "MajorGridColor.BLUE",
+ "MajorGridColor.GREEN",
+ "MajorGridColor.RED",
+ };
+
+ public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node)
+ {
+ int count = 0;
+ if (node is null) return (null, 0);
+ Walk(node);
+ return (node, count);
+
+ void Walk(JsonNode n)
+ {
+ if (n is JsonObject obj)
+ {
+ foreach (var kv in obj.ToList())
+ {
+ if (VolatileSettingFieldNames.Contains(kv.Key))
+ {
+ obj[kv.Key] = JsonValue.Create("");
+ count++;
+ }
+ else if (kv.Value is JsonObject || kv.Value is JsonArray)
+ {
+ Walk(kv.Value);
+ }
+ }
+ }
+ else if (n is JsonArray arr)
+ {
+ foreach (var item in arr)
+ {
+ if (item is JsonObject || item is JsonArray) Walk(item);
+ }
+ }
+ }
+ }
+
///
/// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted.
///
diff --git a/src/Recordingtest.Normalizer/profiles/default.yaml b/src/Recordingtest.Normalizer/profiles/default.yaml
index 85e3e3e..dafced0 100644
--- a/src/Recordingtest.Normalizer/profiles/default.yaml
+++ b/src/Recordingtest.Normalizer/profiles/default.yaml
@@ -4,4 +4,5 @@ rules:
- mask_guids
- normalize_paths
- round_floats
+ - mask_volatile_settings
- sort_json_keys
diff --git a/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs
index c2ad204..f0f2233 100644
--- a/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs
+++ b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs
@@ -6,21 +6,43 @@ namespace Recordingtest.Normalizer.Tests;
///
/// Verifies that every "SuspectedNondeterministicFields" entry in
-/// docs/sut-catalog/json-configs.json is covered by a default-profile rule.
-///
-/// Mapping rationale:
-/// - Field names ending in "Path" / "FileName" / "FilePath" -> normalize_paths
-/// (their VALUES are absolute filesystem paths)
-/// - All other suspected fields are simple scalar settings whose order in the
-/// serialized JSON varies between runs. These are covered by sort_json_keys,
-/// which produces a canonical key ordering so the resulting bytes are
-/// deterministic regardless of which suspected scalar fields exist.
-/// - The default profile additionally provides strip_timestamps, mask_guids,
-/// and round_floats for the value-level non-determinism not catalogued in
-/// json-configs.json yet.
+/// docs/sut-catalog/json-configs.json is covered by a semantically appropriate
+/// rule that is actually present in the default profile.
///
public class CoverageTests
{
+ // Explicit field -> rule mapping. Each entry must be a rule that semantically
+ // covers the kind of value the field holds.
+ // *Path / *FileName / *RecentFile* -> normalize_paths
+ // Known volatile boolean / color / scalar settings -> mask_volatile_settings
+ // No catch-all to sort_json_keys for arbitrary scalars.
+ private static readonly Dictionary FieldRuleMap = new(StringComparer.Ordinal)
+ {
+ // path-bearing
+ ["AutoSaveFilePath"] = "normalize_paths",
+ ["AutoSave_RecentFileName"] = "normalize_paths",
+
+ // volatile boolean / scalar UI settings
+ ["CanOverrideWireColorWithFace"] = "mask_volatile_settings",
+ ["IsSidePanelVisible"] = "mask_volatile_settings",
+ ["OverrideFaceColor"] = "mask_volatile_settings",
+ ["Solar_IsLocalTime"] = "mask_volatile_settings",
+ ["VisibleGrid"] = "mask_volatile_settings",
+ ["GridSnap"] = "mask_volatile_settings",
+ ["MidpointOsnap"] = "mask_volatile_settings",
+ ["GridSpacing"] = "mask_volatile_settings",
+
+ // volatile color channels
+ ["GridColor.ALPHA"] = "mask_volatile_settings",
+ ["GridColor.BLUE"] = "mask_volatile_settings",
+ ["GridColor.GREEN"] = "mask_volatile_settings",
+ ["GridColor.RED"] = "mask_volatile_settings",
+ ["MajorGridColor.ALPHA"] = "mask_volatile_settings",
+ ["MajorGridColor.BLUE"] = "mask_volatile_settings",
+ ["MajorGridColor.GREEN"] = "mask_volatile_settings",
+ ["MajorGridColor.RED"] = "mask_volatile_settings",
+ };
+
private static string FindCatalog()
{
var dir = AppContext.BaseDirectory;
@@ -51,30 +73,29 @@ public class CoverageTests
}
}
- // Sanity: catalog must have produced at least one field, otherwise the
- // assertion below is vacuous.
Assert.NotEmpty(allFields);
var profile = Profile.Load("default");
- Assert.Contains("normalize_paths", profile.Rules);
- Assert.Contains("sort_json_keys", profile.Rules);
+ var profileRules = new HashSet(profile.Rules, StringComparer.Ordinal);
- var uncovered = new List();
+ var unmapped = new List();
+ var notInProfile = new List();
foreach (var field in allFields)
{
- bool covered =
- IsPathField(field) // -> normalize_paths
- || true; // -> sort_json_keys covers any scalar by canonicalising order
- if (!covered) uncovered.Add(field);
+ if (!FieldRuleMap.TryGetValue(field, out var rule))
+ {
+ unmapped.Add(field);
+ continue;
+ }
+ if (!profileRules.Contains(rule))
+ {
+ notInProfile.Add($"{field} -> {rule}");
+ }
}
- Assert.Empty(uncovered);
- }
-
- private static bool IsPathField(string name)
- {
- return name.EndsWith("Path", StringComparison.Ordinal)
- || name.EndsWith("FileName", StringComparison.Ordinal)
- || name.EndsWith("FilePath", StringComparison.Ordinal);
+ Assert.True(unmapped.Count == 0,
+ "Suspected fields without an explicit semantic rule mapping: " + string.Join(", ", unmapped));
+ Assert.True(notInProfile.Count == 0,
+ "Mapped rules missing from default profile: " + string.Join(", ", notInProfile));
}
}
diff --git a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
index 839dd44..3cda6bc 100644
--- a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
+++ b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
@@ -67,12 +67,62 @@ public class RuleTests
{
var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}";
var r = Normalizer.Normalize(input, "default");
- Assert.Equal(5, r.Log.Count);
+ Assert.Equal(6, r.Log.Count);
var ids = r.Log.Select(l => l.RuleId).ToList();
Assert.Contains("strip_timestamps", ids);
Assert.Contains("mask_guids", ids);
Assert.Contains("normalize_paths", ids);
Assert.Contains("round_floats", ids);
Assert.Contains("sort_json_keys", ids);
+ Assert.Contains("mask_volatile_settings", ids);
+ }
+
+ [Fact]
+ public void Normalize_WritesSidecarLogFile()
+ {
+ var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"GridSnap\":true,\"x\":1.234567}";
+ var tmp = Path.Combine(Path.GetTempPath(), "norm-sidecar-" + Guid.NewGuid().ToString("N") + ".log");
+ try
+ {
+ var r = Normalizer.Normalize(input, "default", tmp);
+ Assert.True(File.Exists(tmp), "sidecar file should exist at " + tmp);
+ var text = File.ReadAllText(tmp);
+
+ // every rule from result.Log should appear with matching count
+ int total = 0;
+ foreach (var entry in r.Log)
+ {
+ Assert.Contains($"{entry.RuleId}\tcount={entry.Count}", text);
+ total += entry.Count;
+ }
+ Assert.Contains($"total={total}", text);
+
+ // sorted by RuleId
+ var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ var ruleLines = lines.Where(l => !l.StartsWith("total=")).ToList();
+ var sorted = ruleLines.OrderBy(l => l, StringComparer.Ordinal).ToList();
+ Assert.Equal(sorted, ruleLines);
+ }
+ finally
+ {
+ if (File.Exists(tmp)) File.Delete(tmp);
+ }
+ }
+
+ [Fact]
+ public void Normalize_SidecarPath_AcceptsDirectory()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "norm-sidecar-dir-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ try
+ {
+ Normalizer.Normalize("{\"a\":1}", "default", dir);
+ var expected = Path.Combine(dir, "normalization.log");
+ Assert.True(File.Exists(expected));
+ }
+ finally
+ {
+ if (Directory.Exists(dir)) Directory.Delete(dir, true);
+ }
}
}