Implement normalizer PoC (#4)
This commit is contained in:
39
docs/history/2026-04-07_이슈4-normalizer-generator.md
Normal file
39
docs/history/2026-04-07_이슈4-normalizer-generator.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 2026-04-07 이슈 #4 — Normalizer PoC (Generator)
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
`Recordingtest.Normalizer` 라이브러리와 xUnit 테스트 프로젝트를 신설하여 골든파일 정규화 PoC를 구현했다. `default.yaml` 프로파일에 5개 규칙(strip_timestamps, mask_guids, normalize_paths, round_floats, sort_json_keys) 등록.
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- `src/Recordingtest.Normalizer/` (라이브러리)
|
||||||
|
- `Recordingtest.Normalizer.csproj` (YamlDotNet, System.Text.Json)
|
||||||
|
- `Normalizer.cs`, `Rules.cs`, `Profile.cs`, `RuleApplication.cs`
|
||||||
|
- `profiles/default.yaml`
|
||||||
|
- `tests/Recordingtest.Normalizer.Tests/`
|
||||||
|
- `RuleTests.cs` (규칙별 단위 테스트 + idempotent + 5-rule 적용 확인)
|
||||||
|
- `CoverageTests.cs` (json-configs.json suspectedNondeterministicFields 매핑 검증)
|
||||||
|
- `recordingtest.sln` 두 프로젝트 추가
|
||||||
|
|
||||||
|
## 결과
|
||||||
|
- `dotnet build`: 경고 0, 오류 0
|
||||||
|
- `dotnet test`: 8/8 pass
|
||||||
|
|
||||||
|
## 매핑 (suspectedNondeterministicFields → 규칙)
|
||||||
|
- `*FilePath`, `*FileName` → `normalize_paths`
|
||||||
|
- 그 외 스칼라 설정값 → `sort_json_keys` (직렬화 시 키 순서 결정성 확보)
|
||||||
|
- 추가 값-수준 비결정성 → `strip_timestamps`, `mask_guids`, `round_floats`
|
||||||
|
|
||||||
|
자세한 근거는 `tests/Recordingtest.Normalizer.Tests/CoverageTests.cs` 헤더 주석 참조.
|
||||||
|
|
||||||
|
## 소요 시간
|
||||||
|
약 25분
|
||||||
|
|
||||||
|
## Context 사용량
|
||||||
|
약 55k tokens
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
#4
|
||||||
|
|
||||||
|
## 비고 / DoD 미충족 항목
|
||||||
|
- 사이드카 로그 파일 생성은 라이브러리 책임에서 제외하고 `NormalizeResult.Log`로 반환만 한다 (추후 CLI에서 영속화 예정 — 계약서의 sidecar 파일 작성 항목은 부분 충족).
|
||||||
|
- 정규화 epsilon은 6자리 고정. 향후 프로파일에서 구성 가능하도록 확장 필요.
|
||||||
|
- Evaluator가 위 두 항목을 어떻게 판정할지 별도 확인 필요.
|
||||||
@@ -1,16 +1,111 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.SutProber", "src\Recordingtest.SutProber\Recordingtest.SutProber.csproj", "{1A0B2C3D-0001-0000-0000-000000000001}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.SutProber", "src\Recordingtest.SutProber\Recordingtest.SutProber.csproj", "{1A0B2C3D-0001-0000-0000-000000000001}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Normalizer", "src\Recordingtest.Normalizer\Recordingtest.Normalizer.csproj", "{B8283D81-C9CC-45B9-A4C3-D79977756E55}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Normalizer.Tests", "tests\Recordingtest.Normalizer.Tests\Recordingtest.Normalizer.Tests.csproj", "{CB87B9E4-E5E4-4440-A555-7F5207E46C60}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter", "src\Recordingtest.DiffReporter\Recordingtest.DiffReporter.csproj", "{21A2E01D-FFC3-446D-B56E-775FF7E14C76}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Cli", "src\Recordingtest.DiffReporter.Cli\Recordingtest.DiffReporter.Cli.csproj", "{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Tests", "tests\Recordingtest.DiffReporter.Tests\Recordingtest.DiffReporter.Tests.csproj", "{65290E3F-D498-452B-9A76-FBC460E53A9F}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1A0B2C3D-0001-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{B8283D81-C9CC-45B9-A4C3-D79977756E55} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{CB87B9E4-E5E4-4440-A555-7F5207E46C60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{65290E3F-D498-452B-9A76-FBC460E53A9F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
133
src/Recordingtest.Normalizer/Normalizer.cs
Normal file
133
src/Recordingtest.Normalizer/Normalizer.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
public static class Normalizer
|
||||||
|
{
|
||||||
|
public static NormalizeResult Normalize(string input, string profileName)
|
||||||
|
{
|
||||||
|
var profile = Profile.Load(profileName);
|
||||||
|
var log = new List<RuleApplication>();
|
||||||
|
|
||||||
|
// Try to parse as JSON
|
||||||
|
JsonNode? jsonNode = null;
|
||||||
|
bool isJson = false;
|
||||||
|
var trimmed = input.TrimStart();
|
||||||
|
if (trimmed.StartsWith("{") || trimmed.StartsWith("["))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
jsonNode = JsonNode.Parse(input);
|
||||||
|
isJson = jsonNode is not null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
isJson = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string current = input;
|
||||||
|
|
||||||
|
foreach (var rule in profile.Rules)
|
||||||
|
{
|
||||||
|
switch (rule)
|
||||||
|
{
|
||||||
|
case "strip_timestamps":
|
||||||
|
{
|
||||||
|
if (isJson && jsonNode is not null)
|
||||||
|
{
|
||||||
|
// serialize, apply, reparse to keep pipeline consistent
|
||||||
|
var s = jsonNode.ToJsonString();
|
||||||
|
var (o, c) = Rules.StripTimestamps(s);
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
if (c > 0) jsonNode = JsonNode.Parse(o);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (o, c) = Rules.StripTimestamps(current);
|
||||||
|
current = o;
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mask_guids":
|
||||||
|
{
|
||||||
|
if (isJson && jsonNode is not null)
|
||||||
|
{
|
||||||
|
var s = jsonNode.ToJsonString();
|
||||||
|
var (o, c) = Rules.MaskGuids(s);
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
if (c > 0) jsonNode = JsonNode.Parse(o);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (o, c) = Rules.MaskGuids(current);
|
||||||
|
current = o;
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "normalize_paths":
|
||||||
|
{
|
||||||
|
if (isJson && jsonNode is not null)
|
||||||
|
{
|
||||||
|
var s = jsonNode.ToJsonString();
|
||||||
|
var (o, c) = Rules.NormalizePaths(s);
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
if (c > 0) jsonNode = JsonNode.Parse(o);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (o, c) = Rules.NormalizePaths(current);
|
||||||
|
current = o;
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "round_floats":
|
||||||
|
{
|
||||||
|
if (isJson && jsonNode is not null)
|
||||||
|
{
|
||||||
|
var (n, c) = Rules.RoundFloatsInNode(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)
|
||||||
|
{
|
||||||
|
var (n, c) = Rules.SortJsonKeys(jsonNode);
|
||||||
|
jsonNode = n;
|
||||||
|
log.Add(new RuleApplication(rule, c));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.Add(new RuleApplication(rule, 0));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unknown rule: {rule}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string output;
|
||||||
|
if (isJson && jsonNode is not null)
|
||||||
|
{
|
||||||
|
output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
output = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NormalizeResult(output, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Recordingtest.Normalizer/Profile.cs
Normal file
28
src/Recordingtest.Normalizer/Profile.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
public sealed class Profile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public List<string> Rules { get; set; } = new();
|
||||||
|
|
||||||
|
public static Profile Load(string profileName)
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var path = Path.Combine(baseDir, "profiles", profileName + ".yaml");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
// fallback: search up from cwd
|
||||||
|
var alt = Path.Combine(Directory.GetCurrentDirectory(), "profiles", profileName + ".yaml");
|
||||||
|
if (File.Exists(alt)) path = alt;
|
||||||
|
else throw new FileNotFoundException($"Profile not found: {profileName}", path);
|
||||||
|
}
|
||||||
|
var yaml = File.ReadAllText(path);
|
||||||
|
var deserializer = new DeserializerBuilder()
|
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.Build();
|
||||||
|
return deserializer.Deserialize<Profile>(yaml) ?? new Profile { Name = profileName };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Recordingtest.Normalizer/Recordingtest.Normalizer.csproj
Normal file
15
src/Recordingtest.Normalizer/Recordingtest.Normalizer.csproj
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>Recordingtest.Normalizer</AssemblyName>
|
||||||
|
<RootNamespace>Recordingtest.Normalizer</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="YamlDotNet" Version="15.1.6" />
|
||||||
|
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="profiles\*.yaml">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
15
src/Recordingtest.Normalizer/RuleApplication.cs
Normal file
15
src/Recordingtest.Normalizer/RuleApplication.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
public sealed record RuleApplication(string RuleId, int Count);
|
||||||
|
|
||||||
|
public sealed class NormalizeResult
|
||||||
|
{
|
||||||
|
public string Output { get; }
|
||||||
|
public IReadOnlyList<RuleApplication> Log { get; }
|
||||||
|
|
||||||
|
public NormalizeResult(string output, IReadOnlyList<RuleApplication> log)
|
||||||
|
{
|
||||||
|
Output = output;
|
||||||
|
Log = log;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/Recordingtest.Normalizer/Rules.cs
Normal file
204
src/Recordingtest.Normalizer/Rules.cs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
public static class Rules
|
||||||
|
{
|
||||||
|
// Matches ISO8601 (with optional fractional seconds and timezone) and common "yyyy-MM-dd HH:mm:ss"
|
||||||
|
public static readonly Regex TimestampRegex = new(
|
||||||
|
@"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static readonly Regex GuidRegex = new(
|
||||||
|
@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static (string output, int count) StripTimestamps(string input)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
var result = TimestampRegex.Replace(input, _ => { count++; return "<TS>"; });
|
||||||
|
return (result, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string output, int count) MaskGuids(string input)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
var result = GuidRegex.Replace(input, _ => { count++; return "<GUID>"; });
|
||||||
|
return (result, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string output, int count) NormalizePaths(string input)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
string result = input;
|
||||||
|
|
||||||
|
var repo = Environment.GetEnvironmentVariable("RECORDINGTEST_REPO");
|
||||||
|
if (string.IsNullOrEmpty(repo))
|
||||||
|
{
|
||||||
|
repo = Directory.GetCurrentDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try both raw and JSON-escaped (\\) forms
|
||||||
|
foreach (var candidate in EnumerateForms(repo))
|
||||||
|
{
|
||||||
|
result = ReplaceCounting(result, candidate, "<REPO>", ref count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
if (!string.IsNullOrEmpty(home))
|
||||||
|
{
|
||||||
|
foreach (var candidate in EnumerateForms(home))
|
||||||
|
{
|
||||||
|
result = ReplaceCounting(result, candidate, "<USER>", ref count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateForms(string path)
|
||||||
|
{
|
||||||
|
yield return path;
|
||||||
|
// JSON-escaped backslashes
|
||||||
|
if (path.Contains('\\'))
|
||||||
|
yield return path.Replace("\\", "\\\\");
|
||||||
|
// forward slashes
|
||||||
|
if (path.Contains('\\'))
|
||||||
|
yield return path.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceCounting(string input, string find, string replace, ref int count)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(find)) return input;
|
||||||
|
int idx = 0;
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int next = input.IndexOf(find, idx, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (next < 0)
|
||||||
|
{
|
||||||
|
sb.Append(input, idx, input.Length - idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sb.Append(input, idx, next - idx);
|
||||||
|
sb.Append(replace);
|
||||||
|
count++;
|
||||||
|
idx = next + find.Length;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static (JsonNode? node, int count) RoundFloatsInNode(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 (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
||||||
|
{
|
||||||
|
var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero);
|
||||||
|
obj[kv.Key] = JsonValue.Create(rounded);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
else if (kv.Value is JsonObject || kv.Value is JsonArray)
|
||||||
|
{
|
||||||
|
Walk(kv.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (n is JsonArray arr)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < arr.Count; i++)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
arr[i] = JsonValue.Create(rounded);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
else if (item is JsonObject || item is JsonArray)
|
||||||
|
{
|
||||||
|
Walk(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAsDouble(JsonValue v, out double d, out bool wasFloat)
|
||||||
|
{
|
||||||
|
d = 0;
|
||||||
|
wasFloat = false;
|
||||||
|
var el = v.GetValue<object?>();
|
||||||
|
// Use the underlying JsonElement when possible
|
||||||
|
if (v.TryGetValue<JsonElement>(out var je))
|
||||||
|
{
|
||||||
|
if (je.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
var raw = je.GetRawText();
|
||||||
|
if (raw.Contains('.') || raw.Contains('e') || raw.Contains('E'))
|
||||||
|
{
|
||||||
|
if (je.TryGetDouble(out d))
|
||||||
|
{
|
||||||
|
wasFloat = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted.
|
||||||
|
/// </summary>
|
||||||
|
public static (JsonNode? node, int count) SortJsonKeys(JsonNode? node)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
var result = SortInternal(node, ref count);
|
||||||
|
return (result, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode? SortInternal(JsonNode? node, ref int count)
|
||||||
|
{
|
||||||
|
if (node is JsonObject obj)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
var sorted = new JsonObject();
|
||||||
|
foreach (var kv in obj.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
sorted[kv.Key] = SortInternal(kv.Value, ref count);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
{
|
||||||
|
var newArr = new JsonArray();
|
||||||
|
foreach (var item in arr)
|
||||||
|
{
|
||||||
|
newArr.Add(SortInternal(item, ref count));
|
||||||
|
}
|
||||||
|
return newArr;
|
||||||
|
}
|
||||||
|
if (node is JsonValue v)
|
||||||
|
{
|
||||||
|
// Clone scalar
|
||||||
|
return JsonNode.Parse(v.ToJsonString());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Recordingtest.Normalizer/profiles/default.yaml
Normal file
7
src/Recordingtest.Normalizer/profiles/default.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name: default
|
||||||
|
rules:
|
||||||
|
- strip_timestamps
|
||||||
|
- mask_guids
|
||||||
|
- normalize_paths
|
||||||
|
- round_floats
|
||||||
|
- sort_json_keys
|
||||||
80
tests/Recordingtest.Normalizer.Tests/CoverageTests.cs
Normal file
80
tests/Recordingtest.Normalizer.Tests/CoverageTests.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
using Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
namespace Recordingtest.Normalizer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class CoverageTests
|
||||||
|
{
|
||||||
|
private static string FindCatalog()
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
for (int i = 0; i < 10 && dir is not null; i++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir, "docs", "sut-catalog", "json-configs.json");
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
throw new FileNotFoundException("json-configs.json not found by walking up from " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultProfile_CoversAllSuspectedFields()
|
||||||
|
{
|
||||||
|
var path = FindCatalog();
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
var allFields = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var entry in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (entry.TryGetProperty("SuspectedNondeterministicFields", out var arr))
|
||||||
|
{
|
||||||
|
foreach (var f in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
var s = f.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(s)) allFields.Add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 uncovered = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<RootNamespace>Recordingtest.Normalizer.Tests</RootNamespace>
|
||||||
|
<AssemblyName>Recordingtest.Normalizer.Tests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Recordingtest.Normalizer\Recordingtest.Normalizer.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
78
tests/Recordingtest.Normalizer.Tests/RuleTests.cs
Normal file
78
tests/Recordingtest.Normalizer.Tests/RuleTests.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Xunit;
|
||||||
|
using Recordingtest.Normalizer;
|
||||||
|
|
||||||
|
namespace Recordingtest.Normalizer.Tests;
|
||||||
|
|
||||||
|
public class RuleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StripTimestamps_ReplacesIso8601()
|
||||||
|
{
|
||||||
|
var input = "saved at 2026-04-07T12:34:56.789Z and 2025-01-02 03:04:05";
|
||||||
|
var (o, c) = Rules.StripTimestamps(input);
|
||||||
|
Assert.Equal(2, c);
|
||||||
|
Assert.Equal("saved at <TS> and <TS>", o);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaskGuids_ReplacesUuids()
|
||||||
|
{
|
||||||
|
var input = "id=550e8400-e29b-41d4-a716-446655440000 done";
|
||||||
|
var (o, c) = Rules.MaskGuids(input);
|
||||||
|
Assert.Equal(1, c);
|
||||||
|
Assert.Contains("<GUID>", o);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizePaths_ReplacesRepoAndUser()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", @"D:\proj\recordingtest");
|
||||||
|
var input = @"file: D:\proj\recordingtest\foo\bar.txt";
|
||||||
|
var (o, c) = Rules.NormalizePaths(input);
|
||||||
|
Assert.True(c >= 1);
|
||||||
|
Assert.Contains("<REPO>", o);
|
||||||
|
Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundFloats_RoundsToSixDecimals()
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse("{\"x\": 3.1415926535897932, \"n\": 1}");
|
||||||
|
var (n, c) = Rules.RoundFloatsInNode(node);
|
||||||
|
Assert.Equal(1, c);
|
||||||
|
Assert.Equal(3.141593, n!["x"]!.GetValue<double>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SortJsonKeys_RecursivelySortsObjects()
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse("{\"b\":1,\"a\":{\"y\":2,\"x\":1}}");
|
||||||
|
var (sorted, _) = Rules.SortJsonKeys(node);
|
||||||
|
var s = sorted!.ToJsonString();
|
||||||
|
Assert.Equal("{\"a\":{\"x\":1,\"y\":2},\"b\":1}", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_IsIdempotent()
|
||||||
|
{
|
||||||
|
var input = "{\"b\":2.0000001,\"a\":\"2026-04-07T00:00:00Z\",\"id\":\"550e8400-e29b-41d4-a716-446655440000\"}";
|
||||||
|
var first = Normalizer.Normalize(input, "default");
|
||||||
|
var second = Normalizer.Normalize(first.Output, "default");
|
||||||
|
Assert.Equal(first.Output, second.Output);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_AppliesAllDefaultRules()
|
||||||
|
{
|
||||||
|
var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}";
|
||||||
|
var r = Normalizer.Normalize(input, "default");
|
||||||
|
Assert.Equal(5, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user