Compare commits

..

3 Commits

Author SHA1 Message Date
minsung
2428827df6 Orchestrate normalizer follow-ups evaluation + update PROGRESS
- Float epsilon configurable (default 6) pass
- JSON-path mask scoping pass with regression trap verified
- 77/77 tests, 3 follow-ups marked done

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:45:04 +09:00
minsung
eeee3c2a03 normalizer: configurable float epsilon + JSON-path volatile mask scoping
Follow-ups to #4 normalizer PoC v2:
- Profile.float_decimals (default 6) flows into Rules.RoundFloatsInNode.
- mask_volatile_settings switches from name-only HashSet to a JSONPath-lite
  allowlist ($.a.b.c) so same-named fields in unrelated subtrees stay intact.
- default.yaml migrated; 6 new tests including a regression trap for the
  unrelated-subtree case. 16/16 normalizer tests, 77/77 solution tests.

Refs #2
2026-04-07 20:42:27 +09:00
minsung
0f0324efb5 sut-prober snake_case JSON + scaffolding review 1회차
- SutProber JsonNamingPolicy.SnakeCaseLower (strict contract compliance)
- Regenerated docs/sut-catalog/*.json
- CoverageTests: accept both snake_case and PascalCase (resilience)
- docs/history: scaffolding review 1회차 (no removals, audit only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:41:51 +09:00
16 changed files with 1254 additions and 878 deletions

View File

@@ -39,6 +39,8 @@
| 2026-04-07 | Smoke gap fix + Evaluator pass (#11) — STAThread, KeyTranslator, 60 tests, regression trap 검증 | commit `139fbbc` |
| 2026-04-07 | Smoke test 1회차 — recorder PID attach + UIA target 정상 (box-v4), player 재생 부분 실패 | `docs/history/2026-04-07_smoke-1회차-결과.md`, scenarios/box-v4*.yaml |
| 2026-04-07 | Smoke 2차 gap fix + Evaluator pass (#12) — full-path resolver, type target inheritance, window filter, UTF-8 BOM-less, 71 tests | commit `8784fec` |
| 2026-04-07 | sut-prober snake_case + scaffolding review 1회차 | commit `0f0324e` |
| 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` |
## In progress
@@ -50,10 +52,10 @@ _(없음)_
## Follow-ups
- [ ] sut-prober JSON naming`JsonNamingPolicy.SnakeCaseLower`로 변경 (contract 엄격 준수). non-blocking.
- [ ] diff-reporter: 실제 `diff-triager` 에이전트 통합 테스트 (현재 schema 단위 테스트로 대체, DoD #8 partial). non-blocking.
- [ ] normalizer: `mask_volatile_settings` 규칙을 JSON-path 스코핑으로 제한 (현재는 필드명 전역 매칭). non-blocking risk.
- [ ] normalizer: float epsilon 구성화 (현재 6 decimals 하드코딩). contract risks 섹션.
- [x] ~~sut-prober JSON naming snake_case~~ — commit `0f0324e`
- [x] ~~normalizer: mask_volatile_settings JSON-path 스코핑~~ — commit `eeee3c2`
- [x] ~~normalizer: float epsilon 구성화~~ — commit `eeee3c2`
- [ ] diff-reporter: 실제 `diff-triager` 에이전트 통합 테스트. non-blocking.
- [ ] recorder/player: **라이브 SUT 수동 smoke test** — 60 FPS / 10회 중 9회 reliability DoD는 샌드박스 unit test 불가, 실제 환경에서 검증 필요.
- [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough).
- [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요.

View File

@@ -0,0 +1,38 @@
# normalizer-followups — Evaluation
**Verdict: PASS**
**Generator commit:** eeee3c2
**Evaluator date:** 2026-04-07
## Verdict table
| # | Criterion | Evidence | Result |
|---|-----------|----------|--------|
| 1 | `dotnet build recordingtest.sln` — 0 warn / 0 err | Build succeeded, 0 Warning(s), 0 Error(s) | pass |
| 2 | `dotnet test` total 77 pass | 16+17+16+5+5+6+6+6 = 77 passed, 0 failed | pass |
| A1 | `Profile.FloatDecimals` int? with YAML alias `float_decimals` | `Profile.cs:14-15` `[YamlMember(Alias="float_decimals")] public int? FloatDecimals` | pass |
| A2 | `RoundFloatsInNode` accepts decimals parameter | `Rules.cs:102` `RoundFloatsInNode(JsonNode?, int decimals)` + default overload using `DefaultFloatDecimals` | pass |
| A3 | `DefaultFloatDecimals = 6` | `Rules.cs:97` `public const int DefaultFloatDecimals = 6` | pass |
| A4 | Profile decimals flows via `Normalizer` | `Normalizer.cs:95` `profile.FloatDecimals ?? Rules.DefaultFloatDecimals` | pass |
| A5 | Omitted `float_decimals` defaults to 6 | Test `Profile_OmittedFloatDecimals_DefaultsTo6` asserts `profile.FloatDecimals == null` and output rounds to 3.141593 | pass |
| A6 | Configurable decimals actually applied | Test `RoundFloats_ProfileWithDecimals3_RoundsTo3` writes temp profile, expects 3.142 | pass |
| B1 | `ParseJsonPathLite` exists, rejects `*` and `[...]` | `Rules.cs:200-222` throws on wildcards/indexers, requires leading `$.` | pass |
| B2 | `MaskVolatileSettings(node, paths)` walks with path stack | `Rules.cs:227-289` pre-parses allowlist, maintains `stack` list, exact-chain compare in `PathMatches()` | pass |
| B3 | `DefaultVolatileSettingPaths` has 16 entries | `Rules.cs:176-194` — counted 16 paths | pass |
| B4 | `default.yaml` migrated to list form | `profiles/default.yaml:10-26` — YAML sequence of 16 `$.<path>` strings; `float_decimals: 6` present | pass |
| B5 | Regression trap: `SameNameInUnrelatedSubtree_NotMasked` | `RuleTests.cs:174-183` — input `{"GridSnap":true,"Foo":{"GridSnap":false}}` with `["$.GridSnap"]`; asserts root masked and `n["Foo"]["GridSnap"].GetValue<bool>() == false`. Pre-fix name-based fallback would have masked both, causing `GetValue<bool>()` to throw InvalidOperationException on `<VOLATILE>` string → test is load-bearing | pass |
| B6 | Nested path mask works | `MaskVolatileSettings_NestedPath_MasksCorrectly``$.GridColor.R` masks only R, leaves G | pass |
| B7 | Root mask works | `MaskVolatileSettings_RootField_Masks` | pass |
| B8 | No leftover `VolatileSettingFieldNames` fallback | Grep in `src/` — no matches anywhere | pass |
| 9 | CoverageTests still green | Normalizer.Tests dll 16 passed (includes coverage tests) | pass |
## Notes
- Regression trap load-bearing: confirmed. The old `VolatileSettingFieldNames.Contains(kv.Key)` approach would mask both `GridSnap` occurrences → nested `.GetValue<bool>()` on a `"<VOLATILE>"` JsonValue would throw. The test would fail loudly.
- Test count for Normalizer.Tests went from 10 → 16 as claimed (6 new tests present and accounted for).
- Default-on behavior preserved: `default.yaml` both specifies `float_decimals: 6` explicitly AND the omitted-profile test proves the `?? DefaultFloatDecimals` fallback path.
- Count of `DefaultVolatileSettingPaths`: 16 entries confirmed (CanOverrideWireColorWithFace, IsSidePanelVisible, OverrideFaceColor, Solar_IsLocalTime, VisibleGrid, GridSnap, MidpointOsnap, GridSpacing, GridColor.{ALPHA,BLUE,GREEN,RED}, MajorGridColor.{ALPHA,BLUE,GREEN,RED}).
## Partial / gaps
None. Both follow-ups are complete with no residual fallback code.

View File

@@ -0,0 +1,25 @@
# 2026-04-07 normalizer-followups — Evaluator
- **작업**: Evaluator 채점 — Follow-up A (float epsilon 구성화) + Follow-up B (JSON-path mask scoping)
- **Generator commit**: eeee3c2
- **Verdict**: PASS
- **관련 이슈**: normalizer follow-ups (see PLAN.md)
- **소요 시간**: ~6분
- **Context 사용량**: ~35k 토큰 (single session, no compaction)
## 검증 결과
- `dotnet build recordingtest.sln`: 0 warn / 0 err
- `dotnet test recordingtest.sln --no-build`: 77 passed / 0 failed (Player 16, Recorder 17, Normalizer 16, EgPlugin 5, DiffReporter 5, Runner 6, EngineBridge.Integration 6, EngineBridge 6)
- Normalizer.Tests 10 → 16 확인 (6 신규)
- `VolatileSettingFieldNames` 잔존 없음 (grep src/ empty)
- Regression trap `SameNameInUnrelatedSubtree_NotMasked` load-bearing 확인 — 구버전 name-based 매칭이면 nested bool GetValue에서 throw
## 산출물
- `docs/contracts/normalizer-followups.evaluation.md` (verdict table)
- 본 히스토리 파일
## 참고
PROGRESS.md 갱신은 호출자가 수행 (evaluator는 touch 금지). Generator 코드 수정 없음.

View File

@@ -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<string>)`, `ParseJsonPathLite`, `DefaultVolatileSettingPaths`.
- `src/Recordingtest.Normalizer/Normalizer.cs``round_floats`/`mask_volatile_settings` 케이스에서 프로파일 옵션 전달.
- `src/Recordingtest.Normalizer/profiles/default.yaml``float_decimals: 6` + 16개 `$.<path>` 항목.
- `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에서 갱신.

View File

@@ -0,0 +1,69 @@
# 2026-04-07 Scaffolding Review 1회차
- **작업**: `.claude/` 비계 주기 감사 (harness design 원칙)
- **소요 시간**: ~10분
- **Context 사용량**: ~420k tokens (orchestrator 누적)
## 범위
PoC 8개 완료 + smoke 2차 fix 완료 시점에서 `.claude/` 하위 agents/commands/skills/hooks 인벤토리 감사.
## 인벤토리
### Agents (5) — 모두 유효
| Agent | 사용 이력 | 평가 |
|-------|----------|------|
| planner | 간접 사용 (orchestrator가 contract 직접 작성) | 유지 — `/contract` 커맨드 경유 가치 있음 |
| evaluator | 10+ Evaluator 사이클에서 사용 | 핵심 |
| sut-explorer | 미사용 (sut-prober 코드로 대체) | **검토 대상**: 실행 이력 없음. 향후 dynamic 분석 필요 시 부활 |
| diff-triager | 미사용 (아직 실제 실패 triage 미발생) | 유지 — 첫 회귀 실패 시 필요 |
| scenario-author | 미사용 | 유지 — 향후 자연어 → yaml 변환 시 필요 |
### Commands (7) — 대부분 미사용
| Command | 사용 이력 | 평가 |
|---------|----------|------|
| contract | 미사용 (orchestrator 직접 작성) | **검토**: 실질 가치 재검토 |
| evaluate | 미사용 (Agent tool 직접 호출로 대체) | **검토**: 실질 가치 재검토 |
| sut-probe | 미사용 (sut-prober exe 직접 실행) | **검토** |
| regress | 미사용 (test-runner 미배포) | 유지 — 러너 배포 후 사용 예정 |
| approve | 미사용 | 유지 — baseline 승격 워크플로에 필요 |
| handoff | 미사용 (직접 PROGRESS 편집) | **검토** |
| progress | 미사용 (orchestrator가 직접 read) | **검토** |
**관찰**: 커맨드는 명시적 슬래시 호출이 필요한데 orchestrator 세션에서는 일반 도구 호출이 더 빠름. 커맨드는 **사용자 직접 호출 용도**로 한정 가치.
### Skills (3) — 적절
| Skill | 평가 |
|-------|------|
| flaui-cookbook | 유지 — recorder/player 작업 시 참조 |
| golden-file-normalizer | 유지 — normalizer 규칙 저작 시 참조 |
| aptabase | 별도 플러그인 (외부 서비스), 유지 |
### Hooks (7 active) — 핵심
| Hook | 사용 이력 | 평가 |
|------|----------|------|
| session-start-progress.sh | 세션마다 PROGRESS/PLAN 주입 | 핵심 |
| stop-handoff-reminder.sh | 세션 종료 시 경고 | 유지 |
| guard-sut-folder.sh | SUT 폴더 보호 | 핵심 |
| guard-sut-launch.sh | SUT 실행 경고 | 유지 |
| aptabase-* | 외부 텔레메트리 | 별도 관리 |
| install-git-hook.sh | 설치 스크립트 | 유지 |
## 권고 (실행 보류, 검토만)
1. **미사용 커맨드 4개** (`contract`, `evaluate`, `handoff`, `progress`): orchestrator 세션 관점에선 redundant. 단 **사용자가 직접 `/handoff` 같은 걸 치는 시나리오**는 유효. 삭제 대신 문서화로 보완 권장.
2. **`sut-explorer` 에이전트**: 미사용. 정적 분석은 `sut-prober` 코드가 담당. 제거 후보.
3. **`diff-triager` 에이전트**: 아직 트리거된 적 없지만 첫 실 regression 실패 시 필요 → 유지.
4. **`scenario-author` 에이전트**: 향후 자연어 시나리오 변환 시 가치. 유지.
## 이번 감사에서 정리한 것
- 없음 (감사만, 삭제 보류)
## 다음 감사 권장 시점
- engine-bridge v3 완료 후
- smoke 2회차 pass 후
- 또는 PoC 모듈이 3개 더 추가됐을 때
## 추가 follow-up
`.claude/settings.json`의 hooks 섹션에 `aptabase-*` 관련 등록 상태와 현재 운영 일치 여부는 aptabase skill 담당 범위라 감사 제외.

View File

@@ -0,0 +1,27 @@
# 2026-04-07 sut-prober snake_case JSON naming
- **작업**: follow-up — contract 엄격 준수
- **소요 시간**: ~5분
- **Context 사용량**: ~420k (orchestrator 누적)
## 변경
`src/Recordingtest.SutProber/Program.cs``JsonSerializerOptions``PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower` 추가.
## 검증
- `dotnet build src/Recordingtest.SutProber`: green (0/0)
- `dotnet run`: exit 0, 카탈로그 재생성
- 출력 확인: `"name"`, `"path"`, `"dlls"`, `"size_bytes"` (이전 `"SizeBytes"` 에서 변경)
- `size_bytes`, `has_pdb` 스네이크 케이스 확인됨
## 영향
- sut-prober contract DoD 엄격 준수 (이전 Evaluator "or equivalent" 완화 조항 제거)
- `docs/sut-catalog/*.json` 재생성 필요 (이번 실행에서 덮어씀)
- 다른 모듈은 sut-catalog JSON을 **필드 이름으로 참조** 하는 곳이 있다면 영향 — normalizer의 coverage test (`CoverageTests.cs`)가 `suspectedNondeterministicFields` 또는 `suspected_nondeterministic_fields` 중 어느 쪽을 파싱하는지 확인 필요
## Follow-up
- normalizer Coverage test에 영향이 있는지 다음 dotnet test run에서 검증 필요
- PROGRESS.md Follow-ups 섹션에서 이 항목 제거 예정 (orchestrator 마무리 시)

View File

@@ -1,87 +1,87 @@
[
{
"Name": "Editor.AI01.HttpConnector.dll",
"SizeBytes": 15872,
"HasPdb": true
"name": "Editor.AI01.HttpConnector.dll",
"size_bytes": 15872,
"has_pdb": true
},
{
"Name": "Editor01.Localization.dll",
"SizeBytes": 357888,
"HasPdb": true
"name": "Editor01.Localization.dll",
"size_bytes": 357888,
"has_pdb": true
},
{
"Name": "Editor02.HmEGAppManager.dll",
"SizeBytes": 529408,
"HasPdb": true
"name": "Editor02.HmEGAppManager.dll",
"size_bytes": 529408,
"has_pdb": true
},
{
"Name": "Editor03.PluginInterface.dll",
"SizeBytes": 347136,
"HasPdb": true
"name": "Editor03.PluginInterface.dll",
"size_bytes": 347136,
"has_pdb": true
},
{
"Name": "Editor04.CommandControl.dll",
"SizeBytes": 60416,
"HasPdb": true
"name": "Editor04.CommandControl.dll",
"size_bytes": 60416,
"has_pdb": true
},
{
"Name": "Editor05.CommandCore.dll",
"SizeBytes": 99840,
"HasPdb": true
"name": "Editor05.CommandCore.dll",
"size_bytes": 99840,
"has_pdb": true
},
{
"Name": "Editor06.CommandCustom.dll",
"SizeBytes": 29184,
"HasPdb": true
"name": "Editor06.CommandCustom.dll",
"size_bytes": 29184,
"has_pdb": true
},
{
"Name": "Editor07.WidgetPluginInterface.dll",
"SizeBytes": 8704,
"HasPdb": true
"name": "Editor07.WidgetPluginInterface.dll",
"size_bytes": 8704,
"has_pdb": true
},
{
"Name": "EditorCore.dll",
"SizeBytes": 57636352,
"HasPdb": true
"name": "EditorCore.dll",
"size_bytes": 57636352,
"has_pdb": true
},
{
"Name": "HmCommonBridge.dll",
"SizeBytes": 72192,
"HasPdb": false
"name": "HmCommonBridge.dll",
"size_bytes": 72192,
"has_pdb": false
},
{
"Name": "HmCommonUI.dll",
"SizeBytes": 1710592,
"HasPdb": false
"name": "HmCommonUI.dll",
"size_bytes": 1710592,
"has_pdb": false
},
{
"Name": "HmEG.dll",
"SizeBytes": 242715136,
"HasPdb": true
"name": "HmEG.dll",
"size_bytes": 242715136,
"has_pdb": true
},
{
"Name": "HmEG3DMouse.dll",
"SizeBytes": 40448,
"HasPdb": false
"name": "HmEG3DMouse.dll",
"size_bytes": 40448,
"has_pdb": false
},
{
"Name": "HmGeometry.V2.dll",
"SizeBytes": 2985472,
"HasPdb": true
"name": "HmGeometry.V2.dll",
"size_bytes": 2985472,
"has_pdb": true
},
{
"Name": "HmGeometry.dll",
"SizeBytes": 1863168,
"HasPdb": true
"name": "HmGeometry.dll",
"size_bytes": 1863168,
"has_pdb": true
},
{
"Name": "HmPG.dll",
"SizeBytes": 34816,
"HasPdb": false
"name": "HmPG.dll",
"size_bytes": 34816,
"has_pdb": false
},
{
"Name": "HmTriangle.dll",
"SizeBytes": 195584,
"HasPdb": true
"name": "HmTriangle.dll",
"size_bytes": 195584,
"has_pdb": true
}
]

View File

@@ -1,7 +1,7 @@
[
{
"Name": "CategoryCommands.json",
"TopLevelKeys": [
"name": "CategoryCommands.json",
"top_level_keys": [
"CategoryCommands[0]",
"CategoryCommands[10]",
"CategoryCommands[11]",
@@ -75,11 +75,11 @@
"ViewerCategoryCommands[7]",
"ViewerCategoryCommands[8]"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "CommandAlias.json",
"TopLevelKeys": [
"name": "CommandAlias.json",
"top_level_keys": [
"3F",
"A",
"C",
@@ -101,11 +101,11 @@
"UNITE",
"Z"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "DefaultCategoryCommands.json",
"TopLevelKeys": [
"name": "DefaultCategoryCommands.json",
"top_level_keys": [
"CategoryCommands[0]",
"CategoryCommands[10]",
"CategoryCommands[11]",
@@ -179,11 +179,11 @@
"ViewerCategoryCommands[7]",
"ViewerCategoryCommands[8]"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "DefaultCommandAlias.json",
"TopLevelKeys": [
"name": "DefaultCommandAlias.json",
"top_level_keys": [
"3F",
"A",
"C",
@@ -205,11 +205,11 @@
"UNITE",
"Z"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "DefaultKeyShortCut.json",
"TopLevelKeys": [
"name": "DefaultKeyShortCut.json",
"top_level_keys": [
"Ctrl_0",
"Ctrl_1",
"Ctrl_2",
@@ -374,11 +374,11 @@
"PageUp",
"Tab"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "DefaultMouseSnap.json",
"TopLevelKeys": [
"name": "DefaultMouseSnap.json",
"top_level_keys": [
"AdditionalAngleStr",
"ApplyIncrementAngle",
"CenterOsnap",
@@ -400,14 +400,14 @@
"TangentOsnap",
"VertexOsnap"
],
"SuspectedNondeterministicFields": [
"suspected_nondeterministic_fields": [
"GridSnap",
"MidpointOsnap"
]
},
{
"Name": "DefaultSettings.json",
"TopLevelKeys": [
"name": "DefaultSettings.json",
"top_level_keys": [
"AmbientLightColor.ALPHA",
"AmbientLightColor.BLUE",
"AmbientLightColor.GREEN",
@@ -549,7 +549,7 @@
"WireframeColor.RED",
"ZoomSensitivity"
],
"SuspectedNondeterministicFields": [
"suspected_nondeterministic_fields": [
"AutoSaveFilePath",
"AutoSave_RecentFileName",
"CanOverrideWireColorWithFace",
@@ -569,8 +569,8 @@
]
},
{
"Name": "DefaultStartupCommand.json",
"TopLevelKeys": [
"name": "DefaultStartupCommand.json",
"top_level_keys": [
"NeverRepeatCommands[0]",
"NeverRepeatCommands[10]",
"NeverRepeatCommands[11]",
@@ -585,11 +585,11 @@
"NeverRepeatCommands[8]",
"NeverRepeatCommands[9]"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "DefaultUnits.json",
"TopLevelKeys": [
"name": "DefaultUnits.json",
"top_level_keys": [
"BaseUnit",
"CurrentUnit",
"Denominator",
@@ -597,11 +597,11 @@
"ImperialDenominator",
"SignificantDigit"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "KeyShortCut.json",
"TopLevelKeys": [
"name": "KeyShortCut.json",
"top_level_keys": [
"Ctrl_0",
"Ctrl_1",
"Ctrl_2",
@@ -766,16 +766,16 @@
"PageUp",
"Tab"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "Materials.json",
"TopLevelKeys": [],
"SuspectedNondeterministicFields": []
"name": "Materials.json",
"top_level_keys": [],
"suspected_nondeterministic_fields": []
},
{
"Name": "MouseSnap.json",
"TopLevelKeys": [
"name": "MouseSnap.json",
"top_level_keys": [
"AdditionalAngleStr",
"ApplyIncrementAngle",
"CenterOsnap",
@@ -797,19 +797,19 @@
"TangentOsnap",
"VertexOsnap"
],
"SuspectedNondeterministicFields": [
"suspected_nondeterministic_fields": [
"GridSnap",
"MidpointOsnap"
]
},
{
"Name": "RecentFiles.json",
"TopLevelKeys": [],
"SuspectedNondeterministicFields": []
"name": "RecentFiles.json",
"top_level_keys": [],
"suspected_nondeterministic_fields": []
},
{
"Name": "Settings.json",
"TopLevelKeys": [
"name": "Settings.json",
"top_level_keys": [
"AmbientLightColor.ALPHA",
"AmbientLightColor.BLUE",
"AmbientLightColor.GREEN",
@@ -951,7 +951,7 @@
"WireframeColor.RED",
"ZoomSensitivity"
],
"SuspectedNondeterministicFields": [
"suspected_nondeterministic_fields": [
"AutoSaveFilePath",
"AutoSave_RecentFileName",
"CanOverrideWireColorWithFace",
@@ -971,8 +971,8 @@
]
},
{
"Name": "StartupCommand.json",
"TopLevelKeys": [
"name": "StartupCommand.json",
"top_level_keys": [
"NeverRepeatCommands[0]",
"NeverRepeatCommands[10]",
"NeverRepeatCommands[11]",
@@ -987,11 +987,11 @@
"NeverRepeatCommands[8]",
"NeverRepeatCommands[9]"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
},
{
"Name": "Units.json",
"TopLevelKeys": [
"name": "Units.json",
"top_level_keys": [
"BaseUnit",
"CurrentUnit",
"Denominator",
@@ -999,6 +999,6 @@
"ImperialDenominator",
"SignificantDigit"
],
"SuspectedNondeterministicFields": []
"suspected_nondeterministic_fields": []
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>)profile.MaskVolatileSettings
: Rules.DefaultVolatileSettingPaths;
var (n, c) = Rules.MaskVolatileSettings(jsonNode, paths);
jsonNode = n;
log.Add(new RuleApplication(rule, c));
}

View File

@@ -8,6 +8,19 @@ public sealed class Profile
public string Name { get; set; } = "";
public List<string> Rules { get; set; } = new();
/// <summary>
/// Optional float decimals for round_floats. Null means use default (6).
/// </summary>
[YamlMember(Alias = "float_decimals", ApplyNamingConventions = false)]
public int? FloatDecimals { get; set; }
/// <summary>
/// Optional JSON-path allowlist for mask_volatile_settings.
/// Each entry is a JSONPath-lite string like "$.GridSnap" or "$.Viewport.GridColor.R".
/// </summary>
[YamlMember(Alias = "mask_volatile_settings", ApplyNamingConventions = false)]
public List<string>? 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<Profile>(yaml) ?? new Profile { Name = profileName };
}

View File

@@ -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.
/// </summary>
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
}
/// <summary>
/// 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.
/// </summary>
public static readonly HashSet<string> VolatileSettingFieldNames = new(StringComparer.Ordinal)
public static readonly IReadOnlyList<string> DefaultVolatileSettingPaths = new List<string>
{
"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",
};
/// <summary>
/// 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.
/// </summary>
public static List<string> 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<string>();
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<string> 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<List<string>>(jsonPaths.Count);
foreach (var p in jsonPaths)
{
allow.Add(ParseJsonPathLite(p));
}
var stack = new List<string>();
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("<VOLATILE>");
count++;
@@ -211,6 +275,7 @@ public static class Rules
{
Walk(kv.Value);
}
stack.RemoveAt(stack.Count - 1);
}
}
else if (n is JsonArray arr)

View File

@@ -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"

View File

@@ -28,6 +28,7 @@ var assemblies = AssemblyScanner.Scan(sutPath);
var opts = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
// deterministic property order follows POCO definition
};

View File

@@ -63,7 +63,9 @@ public class CoverageTests
var allFields = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in doc.RootElement.EnumerateArray())
{
if (entry.TryGetProperty("SuspectedNondeterministicFields", out var arr))
// Accept both snake_case (current sut-prober) and PascalCase (legacy) for resilience.
if (entry.TryGetProperty("suspected_nondeterministic_fields", out var arr)
|| entry.TryGetProperty("SuspectedNondeterministicFields", out arr))
{
foreach (var f in arr.EnumerateArray())
{

View File

@@ -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<double>());
}
[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<double>());
}
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<double>());
}
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("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
Assert.Equal(1, n!["Other"]!.GetValue<int>());
}
[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("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
// Unrelated subtree must remain its original boolean value.
Assert.False(n!["Foo"]!["GridSnap"]!.GetValue<bool>());
}
[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("<VOLATILE>", n!["GridColor"]!["R"]!.GetValue<string>());
Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue<int>());
}
[Fact]
public void Normalize_SidecarPath_AcceptsDirectory()
{