diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 2b1f2a7..0e8ba1c 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -308,7 +308,13 @@ coarsen semantic identity. ## 6. Sensitivity Candidate Deduplication Cannot Match Parsed Paths -Priority: P3. Status: open. +Priority: P3. Status: complete. + +Completion: candidate and dependency deduplication uses structural segment +equality, including parsed tokens, without conflating explicit keys and indexes. +`TestSensitivityCandidatesMergeRepeatedParsedPaths` failed before the fix and +now verifies unique semantic/dependency candidates and merged sensitivity facts. +The full ordinary suite and `git diff --check` passed. Relevant code: `sensitivityCandidates` and `appendDistinctHistoryPath` in [history_select.go](../../internal/engine/history_select.go), especially diff --git a/internal/engine/history_select.go b/internal/engine/history_select.go index 7affcff..dee621c 100644 --- a/internal/engine/history_select.go +++ b/internal/engine/history_select.go @@ -210,7 +210,7 @@ func (model *historyModel) sensitivityCandidates() []historySensitivityCandidate } pointer := path.String() for _, index := range indices[pointer] { - if sameConcreteHistoryPath(candidates[index].path, path) { + if slices.Equal(candidates[index].path.segments, path.segments) { candidates[index].sensitive = candidates[index].sensitive || sensitive candidates[index].classify = candidates[index].classify || classify return index @@ -254,7 +254,7 @@ func (model *historyModel) sensitivityCandidates() []historySensitivityCandidate func appendDistinctHistoryPath(paths *[]Path, path Path) { for _, existing := range *paths { - if sameConcreteHistoryPath(existing, path) { + if slices.Equal(existing.segments, path.segments) { return } } diff --git a/internal/engine/history_select_test.go b/internal/engine/history_select_test.go index 989691e..9ef2ba0 100644 --- a/internal/engine/history_select_test.go +++ b/internal/engine/history_select_test.go @@ -2,11 +2,48 @@ package engine import ( "reflect" + "slices" "testing" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) +func TestSensitivityCandidatesMergeRepeatedParsedPaths(t *testing.T) { + t.Parallel() + builder := newHistoryBuilder() + key := Keys("shape", "0") + index := Keys("shape").Index(0) + parsed := parseOverrideTestPath(t, "/shape/0") + dependency := parseOverrideTestPath(t, "/dependency") + attribution := builder.addAttribution(Origin{Layer: LayerResolution}, nil, []Path{dependency}) + payload := builder.nextPayload(historyPhaseResolution) + for repeat := 0; repeat < 3; repeat++ { + for _, path := range []Path{key, index} { + builder.addObservation(historyObservation{ + path: path, phase: historyPhaseResolution, payload: payload, + reportedPath: Keys("shape"), role: historyRoleOperation, + afterPresent: true, sensitive: repeat == 2, + attributionRef: attribution, + }) + } + } + candidates := builder.freeze().sensitivityCandidates() + for _, path := range []Path{key, index, parsed, dependency} { + count := 0 + for _, candidate := range candidates { + if slices.Equal(candidate.path.segments, path.segments) { + count++ + if path.String() == "/shape/0" && !candidate.sensitive { + t.Fatal("merged candidate lost a later sensitivity fact") + } + } + } + if count != 1 { + t.Fatalf("semantic candidate %#v occurs %d times; want one", path, count) + } + } +} + func TestHistorySelectionSeparatesNumericShapesAndGroupsParsedSides(t *testing.T) { t.Parallel()