diff --git a/docs/history/2026-04-07_normalizer-followups-generator.md b/docs/history/2026-04-07_normalizer-followups-generator.md new file mode 100644 index 0000000..0df4865 --- /dev/null +++ b/docs/history/2026-04-07_normalizer-followups-generator.md @@ -0,0 +1,28 @@ +# 2026-04-07 — normalizer follow-ups (Generator) + +## 작업 +normalizer PoC v2(#4, `05c7a3f`)에서 Evaluator가 비차단 risk로 남긴 두 항목을 구현. + +- **Follow-up A** Float epsilon 구성화: `NormalizeProfile.float_decimals` (YAML, optional, default 6) → `Rules.RoundFloatsInNode(node, decimals)` 오버로드 → `Normalizer.Normalize`가 프로파일에서 읽어 주입. +- **Follow-up B** `mask_volatile_settings` JSON-path 스코핑: 필드명 HashSet → JSONPath-lite 화이트리스트(`$.a.b.c`). `Rules.ParseJsonPathLite`로 세그먼트 파싱, 정확 경로 매칭. 같은 이름의 무관한 서브트리 보호. + +## 변경 파일 +- `src/Recordingtest.Normalizer/Profile.cs` — `FloatDecimals`, `MaskVolatileSettings` 필드 추가. `IgnoreUnmatchedProperties()`. +- `src/Recordingtest.Normalizer/Rules.cs` — `RoundFloatsInNode(node, decimals)`, `MaskVolatileSettings(node, IReadOnlyList)`, `ParseJsonPathLite`, `DefaultVolatileSettingPaths`. +- `src/Recordingtest.Normalizer/Normalizer.cs` — `round_floats`/`mask_volatile_settings` 케이스에서 프로파일 옵션 전달. +- `src/Recordingtest.Normalizer/profiles/default.yaml` — `float_decimals: 6` + 16개 `$.` 항목. +- `tests/Recordingtest.Normalizer.Tests/RuleTests.cs` — 테스트 6개 추가. + +## 결과 +- Build: 0 warnings, 0 errors (TreatWarningsAsErrors). +- Normalizer tests: 10 → 16 (+6 신규, 모두 green). +- 솔루션 전체: 77 passed / 0 failed. + +## Regression trap (Follow-up B) +`MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked`는 `{GridSnap, Foo:{GridSnap}}` 입력에 `$.GridSnap` 화이트리스트를 적용. 수정 전 코드는 이름 기반 HashSet으로 `Foo.GridSnap`까지 마스킹했을 것이고 테스트가 실패했을 것이다. 신규 path 매칭은 stack 깊이/세그먼트가 정확히 일치할 때만 마스킹하므로 root 만 변경되고 nested boolean은 보존됨. + +## 메타 +- 소요 시간: 약 25분 +- Context 사용량: 약 47k tokens (단일 세션) +- 관련 이슈: #2 (normalizer follow-ups), #4 후속 +- 마커: non-issue / follow-up only — Sprint Contract DoD 변경 없음, PROGRESS/PLAN은 Evaluator/handoff에서 갱신. diff --git a/src/Recordingtest.Normalizer/Normalizer.cs b/src/Recordingtest.Normalizer/Normalizer.cs index 3e387d0..a256478 100644 --- a/src/Recordingtest.Normalizer/Normalizer.cs +++ b/src/Recordingtest.Normalizer/Normalizer.cs @@ -92,7 +92,8 @@ public static class Normalizer { if (isJson && jsonNode is not null) { - var (n, c) = Rules.RoundFloatsInNode(jsonNode); + var decimals = profile.FloatDecimals ?? Rules.DefaultFloatDecimals; + var (n, c) = Rules.RoundFloatsInNode(jsonNode, decimals); jsonNode = n; log.Add(new RuleApplication(rule, c)); } @@ -106,7 +107,10 @@ public static class Normalizer { if (isJson && jsonNode is not null) { - var (n, c) = Rules.MaskVolatileSettings(jsonNode); + var paths = (profile.MaskVolatileSettings is { Count: > 0 }) + ? (IReadOnlyList)profile.MaskVolatileSettings + : Rules.DefaultVolatileSettingPaths; + var (n, c) = Rules.MaskVolatileSettings(jsonNode, paths); jsonNode = n; log.Add(new RuleApplication(rule, c)); } diff --git a/src/Recordingtest.Normalizer/Profile.cs b/src/Recordingtest.Normalizer/Profile.cs index 5ec2799..f0c9b91 100644 --- a/src/Recordingtest.Normalizer/Profile.cs +++ b/src/Recordingtest.Normalizer/Profile.cs @@ -8,6 +8,19 @@ public sealed class Profile public string Name { get; set; } = ""; public List Rules { get; set; } = new(); + /// + /// Optional float decimals for round_floats. Null means use default (6). + /// + [YamlMember(Alias = "float_decimals", ApplyNamingConventions = false)] + public int? FloatDecimals { get; set; } + + /// + /// Optional JSON-path allowlist for mask_volatile_settings. + /// Each entry is a JSONPath-lite string like "$.GridSnap" or "$.Viewport.GridColor.R". + /// + [YamlMember(Alias = "mask_volatile_settings", ApplyNamingConventions = false)] + public List? MaskVolatileSettings { get; set; } + public static Profile Load(string profileName) { var baseDir = AppContext.BaseDirectory; @@ -22,6 +35,7 @@ public sealed class Profile var yaml = File.ReadAllText(path); var deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() .Build(); return deserializer.Deserialize(yaml) ?? new Profile { Name = profileName }; } diff --git a/src/Recordingtest.Normalizer/Rules.cs b/src/Recordingtest.Normalizer/Rules.cs index ab1d484..0a230a9 100644 --- a/src/Recordingtest.Normalizer/Rules.cs +++ b/src/Recordingtest.Normalizer/Rules.cs @@ -94,7 +94,12 @@ public static class Rules /// JSON-aware: parse and round all double values to 6 decimals. Operates only when input is JSON. /// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0. /// + public const int DefaultFloatDecimals = 6; + public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node) + => RoundFloatsInNode(node, DefaultFloatDecimals); + + public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node, int decimals) { int count = 0; if (node is null) return (null, 0); @@ -109,7 +114,7 @@ public static class Rules { if (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat) { - var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero); + var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero); obj[kv.Key] = JsonValue.Create(rounded); count++; } @@ -126,7 +131,7 @@ public static class Rules var item = arr[i]; if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat) { - var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero); + var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero); arr[i] = JsonValue.Create(rounded); count++; } @@ -164,45 +169,104 @@ public static class Rules } /// - /// 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. + /// Default JSON-path allowlist for known volatile fields, used when a profile + /// does not specify its own list. Each entry is a JSONPath-lite string anchored + /// at the document root. /// - public static readonly HashSet VolatileSettingFieldNames = new(StringComparer.Ordinal) + public static readonly IReadOnlyList DefaultVolatileSettingPaths = new List { - "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", + "$.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", }; + /// + /// Parses a JSONPath-lite string of the form "$.a.b.c" into segment list ["a","b","c"]. + /// Throws on malformed input. Wildcards and array indexers are not supported. + /// + public static List ParseJsonPathLite(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("path is empty", nameof(path)); + if (!path.StartsWith("$")) + throw new ArgumentException($"path must start with '$': {path}", nameof(path)); + var segments = new List(); + var rest = path.Substring(1); + if (rest.Length == 0) return segments; + if (rest[0] != '.') + throw new ArgumentException($"path must continue with '.': {path}", nameof(path)); + // split on '.' but preserve empties as errors + var parts = rest.Substring(1).Split('.'); + foreach (var p in parts) + { + if (string.IsNullOrEmpty(p)) + throw new ArgumentException($"empty segment in path: {path}", nameof(path)); + if (p.Contains('*') || p.Contains('[') || p.Contains(']')) + throw new ArgumentException($"wildcards/indexers not supported: {path}", nameof(path)); + segments.Add(p); + } + return segments; + } + public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node) + => MaskVolatileSettings(node, DefaultVolatileSettingPaths); + + public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node, IReadOnlyList jsonPaths) { int count = 0; if (node is null) return (null, 0); + + // Pre-parse the allowlist into segment chains for exact matching. + var allow = new List>(jsonPaths.Count); + foreach (var p in jsonPaths) + { + allow.Add(ParseJsonPathLite(p)); + } + + var stack = new List(); Walk(node); return (node, count); + bool PathMatches() + { + foreach (var chain in allow) + { + if (chain.Count != stack.Count) continue; + bool eq = true; + for (int i = 0; i < chain.Count; i++) + { + if (!string.Equals(chain[i], stack[i], StringComparison.Ordinal)) + { + eq = false; + break; + } + } + if (eq) return true; + } + return false; + } + void Walk(JsonNode n) { if (n is JsonObject obj) { foreach (var kv in obj.ToList()) { - if (VolatileSettingFieldNames.Contains(kv.Key)) + stack.Add(kv.Key); + if (PathMatches()) { obj[kv.Key] = JsonValue.Create(""); count++; @@ -211,6 +275,7 @@ public static class Rules { Walk(kv.Value); } + stack.RemoveAt(stack.Count - 1); } } else if (n is JsonArray arr) diff --git a/src/Recordingtest.Normalizer/profiles/default.yaml b/src/Recordingtest.Normalizer/profiles/default.yaml index dafced0..096638e 100644 --- a/src/Recordingtest.Normalizer/profiles/default.yaml +++ b/src/Recordingtest.Normalizer/profiles/default.yaml @@ -1,4 +1,5 @@ name: default +float_decimals: 6 rules: - strip_timestamps - mask_guids @@ -6,3 +7,20 @@ rules: - round_floats - mask_volatile_settings - sort_json_keys +mask_volatile_settings: + - "$.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" diff --git a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs index 3cda6bc..1cd982f 100644 --- a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs +++ b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs @@ -109,6 +109,89 @@ public class RuleTests } } + [Fact] + public void RoundFloats_DefaultProfile_Rounds6Decimals() + { + var input = "{\"x\": 3.1415926535897932}"; + var r = Normalizer.Normalize(input, "default"); + var node = JsonNode.Parse(r.Output)!; + Assert.Equal(3.141593, node["x"]!.GetValue()); + } + + [Fact] + public void RoundFloats_ProfileWithDecimals3_RoundsTo3() + { + // Write a temp profile next to the loaded profiles dir. + var baseDir = AppContext.BaseDirectory; + var dest = Path.Combine(baseDir, "profiles", "test_decimals3.yaml"); + File.WriteAllText(dest, + "name: test_decimals3\nfloat_decimals: 3\nrules:\n - round_floats\n"); + try + { + var input = "{\"x\": 3.1415926535}"; + var r = Normalizer.Normalize(input, "test_decimals3"); + var node = JsonNode.Parse(r.Output)!; + Assert.Equal(3.142, node["x"]!.GetValue()); + } + finally + { + if (File.Exists(dest)) File.Delete(dest); + } + } + + [Fact] + public void Profile_OmittedFloatDecimals_DefaultsTo6() + { + var baseDir = AppContext.BaseDirectory; + var dest = Path.Combine(baseDir, "profiles", "test_no_decimals.yaml"); + File.WriteAllText(dest, "name: test_no_decimals\nrules:\n - round_floats\n"); + try + { + var profile = Profile.Load("test_no_decimals"); + Assert.Null(profile.FloatDecimals); + var input = "{\"x\": 3.1415926535897932}"; + var r = Normalizer.Normalize(input, "test_no_decimals"); + var node = JsonNode.Parse(r.Output)!; + Assert.Equal(3.141593, node["x"]!.GetValue()); + } + finally + { + if (File.Exists(dest)) File.Delete(dest); + } + } + + [Fact] + public void MaskVolatileSettings_RootField_Masks() + { + var node = JsonNode.Parse("{\"GridSnap\":true,\"Other\":1}"); + var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" }); + Assert.Equal(1, c); + Assert.Equal("", n!["GridSnap"]!.GetValue()); + Assert.Equal(1, n!["Other"]!.GetValue()); + } + + [Fact] + public void MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked() + { + // Root has GridSnap (should mask), and an unrelated subtree Foo.GridSnap (should NOT mask). + var node = JsonNode.Parse("{\"GridSnap\":true,\"Foo\":{\"GridSnap\":false}}"); + var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" }); + Assert.Equal(1, c); + Assert.Equal("", n!["GridSnap"]!.GetValue()); + // Unrelated subtree must remain its original boolean value. + Assert.False(n!["Foo"]!["GridSnap"]!.GetValue()); + } + + [Fact] + public void MaskVolatileSettings_NestedPath_MasksCorrectly() + { + var node = JsonNode.Parse("{\"GridColor\":{\"R\":12,\"G\":34}}"); + var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridColor.R" }); + Assert.Equal(1, c); + Assert.Equal("", n!["GridColor"]!["R"]!.GetValue()); + Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue()); + } + [Fact] public void Normalize_SidecarPath_AcceptsDirectory() {