From 92a23ad2c7a59233dc40d1f215f3147050cb86b4 Mon Sep 17 00:00:00 2001 From: iperev Date: Sat, 19 Sep 2026 14:02:51 +0200 Subject: [PATCH] fix: preserve lexical owners in reference diagnostics --- .../requirementsourcecodec/diagnostic_path.go | 13 +- .../reference_diagnostics_test.go | 130 ++++++++++++++++++ .../reference_closure.go | 17 ++- .../reference_diagnostics_test.go | 32 +++++ 4 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 internal/kernel/requirementsourcecodec/reference_diagnostics_test.go create mode 100644 internal/kernel/requirementsourcemodel/reference_diagnostics_test.go diff --git a/internal/kernel/requirementsourcecodec/diagnostic_path.go b/internal/kernel/requirementsourcecodec/diagnostic_path.go index 332ae7f7..174155e9 100644 --- a/internal/kernel/requirementsourcecodec/diagnostic_path.go +++ b/internal/kernel/requirementsourcecodec/diagnostic_path.go @@ -24,6 +24,10 @@ func resolveModelPath(wire document, path string) diagnosticPath { return resolveIdentifiedPath(segments, "nonClaimDefinitions", len(wire.NonClaimDefinitions), func(index int) string { return wire.NonClaimDefinitions[index].NonClaimID }) case "vocabulary": return resolveIdentifiedPath(segments, "vocabulary", len(wire.Vocabulary), func(index int) string { return wire.Vocabulary[index].TermID }) + case "scenarios": + return resolveIdentifiedPath(segments, "scenarios", len(wire.Scenarios), func(index int) string { return wire.Scenarios[index].ScenarioID }) + case "derivations": + return resolveIdentifiedPath(segments, "derivations", len(wire.Derivations), func(index int) string { return wire.Derivations[index].DerivationID }) default: return conventionalModelPath(segments) } @@ -129,13 +133,8 @@ func conventionalModelPath(segments []string) diagnosticPath { } func appendSafeSegments(base string, segments []string) diagnosticPath { - lookup := base - reported := base - for _, segment := range segments { - lookup = joinPointer(lookup, segment) - reported = joinPointer(reported, segment) - } - return diagnosticPath{lookup: lookup, reported: reported} + tail := conventionalModelPath(segments) + return diagnosticPath{lookup: base + tail.lookup, reported: base + tail.reported} } func sameDiagnosticPath(path string) diagnosticPath { diff --git a/internal/kernel/requirementsourcecodec/reference_diagnostics_test.go b/internal/kernel/requirementsourcecodec/reference_diagnostics_test.go new file mode 100644 index 00000000..c3824080 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/reference_diagnostics_test.go @@ -0,0 +1,130 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" + "testing" +) + +func TestScenarioValueDiagnosticPreservesDynamicKeyRedaction(t *testing.T) { + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + scenario := root["scenarios"].([]any)[0].(map[string]any) + example := scenario["examples"].([]any)[0].(map[string]any) + example["values"].(map[string]any)["surface"] = "" + }) + _, err := Parse(payload) + assertDiagnostic(t, err, "invalid_text", "/scenarios/0/examples/0/values/") + span := err.(*Error).Diagnostic().Span + if !bytes.Equal(payload[span.Start:span.End], []byte(`""`)) || strings.Contains(err.Error(), "surface") { + t.Fatal("scenario diagnostic lost its lexical value or exposed the dynamic key") + } +} + +func TestDanglingNonClaimDiagnosticsResolveLexicalOwner(t *testing.T) { + for _, owner := range []string{"source", "member", "profile", "scenario", "derivation"} { + for _, reversed := range []bool{false, true} { + for _, multiline := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/reversed=%t/multiline=%t", owner, reversed, multiline), func(t *testing.T) { + payload, path := danglingNonClaimPayload(t, owner, reversed) + if multiline { + var indented bytes.Buffer + if err := json.Indent(&indented, payload, "", " "); err != nil { + t.Fatal(err) + } + payload = bytes.ReplaceAll(indented.Bytes(), []byte("\n"), []byte("\r\n")) + } + _, err := Parse(payload) + assertDiagnostic(t, err, "dangling_nonclaim_ref", path) + diagnostic := err.(*Error).Diagnostic() + marker := bytes.Index(payload, []byte(`"NCL-ZZ.missing"`)) + if marker < 0 { + t.Fatal("missing independent reference marker") + } + start := bytes.LastIndexByte(payload[:marker], '[') + end := marker + bytes.IndexByte(payload[marker:], ']') + 1 + if diagnostic.Span != (ByteSpan{Start: int64(start), End: int64(end)}) { + t.Fatalf("span = %#v, want original reference array [%d,%d)", diagnostic.Span, start, end) + } + positions := replayPositions(payload, SourceMap{entries: map[string]Location{path: {ValueSpan: diagnostic.Span}}}) + if diagnostic.CoordinateState != "scalar" || diagnostic.Start == nil || diagnostic.End == nil || + *diagnostic.Start != positions[int64(start)] || *diagnostic.End != positions[int64(end)] { + t.Fatal("diagnostic scalar coordinates do not match original source") + } + if strings.Contains(err.Error(), "NCL-") || strings.Contains(diagnostic.Path, "SCN-") || strings.Contains(diagnostic.Path, "DRV-") { + t.Fatal("diagnostic disclosed caller identity") + } + repaired := mutateRoot(t, payload, func(root map[string]any) { + root["nonClaimDefinitions"] = append(root["nonClaimDefinitions"].([]any), map[string]any{ + "nonClaimId": "NCL-ZZ.missing", "statement": "This added declaration closes the reference only.", + }) + }) + if _, err := Parse(repaired); err != nil { + t.Fatalf("resolved reference control: %v", err) + } + }) + } + } + } +} + +func danglingNonClaimPayload(t *testing.T, owner string, reversed bool) ([]byte, string) { + t.Helper() + path := "/sourceNonClaimRefs" + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + selected, key := root, "sourceNonClaimRefs" + groups := root["groups"].([]any) + members := groups[1].(map[string]any)["members"].([]any) + switch owner { + case "member", "profile": + selected = members[0].(map[string]any)["fields"].(map[string]any) + key = "nonClaimRefs" + groupIndex, memberIndex := 1, 0 + if reversed { + groupIndex = 0 + memberIndex = len(members) - 1 + groups[0], groups[1] = groups[1], groups[0] + slices.Reverse(members) + } + path = fmt.Sprintf("/groups/%d/members/%d/fields/nonClaimRefs", groupIndex, memberIndex) + if owner == "profile" { + profile := root["profiles"].([]any)[0].(map[string]any)["fields"].(map[string]any) + profile[key] = selected[key] + for _, member := range members { + delete(member.(map[string]any)["fields"].(map[string]any), key) + } + selected, path = profile, "/profiles/0/fields/nonClaimRefs" + } + case "scenario", "derivation": + collection, idKey, otherID := "scenarios", "scenarioId", "SCN-AA.other" + if owner == "derivation" { + collection, idKey, otherID = "derivations", "derivationId", "DRV-AA.other" + } + values := root[collection].([]any) + selected, key = values[0].(map[string]any), "nonClaimRefs" + selected[idKey] = selected[idKey].(string) + ".owner" + other := make(map[string]any, len(selected)) + for field, value := range selected { + other[field] = value + } + other[idKey] = otherID + values = append(values, other) + index := 0 + if reversed { + index = 1 + slices.Reverse(values) + } + root[collection] = values + path = fmt.Sprintf("/%s/%d/nonClaimRefs", collection, index) + } + refs := append([]any{}, selected[key].([]any)...) + refs = append(refs, "NCL-ZZ.missing") + if reversed { + slices.Reverse(refs) + } + selected[key] = refs + }) + return payload, path +} diff --git a/internal/kernel/requirementsourcemodel/reference_closure.go b/internal/kernel/requirementsourcemodel/reference_closure.go index 6ee3c552..a10a24e2 100644 --- a/internal/kernel/requirementsourcemodel/reference_closure.go +++ b/internal/kernel/requirementsourcemodel/reference_closure.go @@ -79,7 +79,7 @@ func validateReferenceClosure(definitions map[string]struct{}, vocabulary map[st return invalid("invalid_reference_role", "references") } if _, exists := definitions[edge.To.ID]; !exists { - return invalid("dangling_nonclaim_ref", "references") + return invalid("dangling_nonclaim_ref", nonClaimReferencePath(edge)) } usedDefinitions[edge.To.ID] = struct{}{} case ReferenceScenarioVocabulary: @@ -105,6 +105,21 @@ func validateReferenceClosure(definitions map[string]struct{}, vocabulary map[st return nil } +func nonClaimReferencePath(edge ReferenceEdge) string { + switch edge.Kind { + case ReferenceSourceNonClaim: + return "sourceNonClaimRefs" + case ReferenceRequirementNonClaim: + return identified("requirements", edge.From.ID) + ".nonClaimRefs" + case ReferenceScenarioNonClaim: + return identified("scenarios", edge.From.ID) + ".nonClaimRefs" + case ReferenceDerivationNonClaim: + return identified("derivations", edge.From.ID) + ".nonClaimRefs" + default: + return "references" + } +} + func sortedSetKeys(values map[string]struct{}) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/kernel/requirementsourcemodel/reference_diagnostics_test.go b/internal/kernel/requirementsourcemodel/reference_diagnostics_test.go new file mode 100644 index 00000000..a6e60e8a --- /dev/null +++ b/internal/kernel/requirementsourcemodel/reference_diagnostics_test.go @@ -0,0 +1,32 @@ +package requirementsourcemodel + +import "testing" + +func TestDanglingNonClaimDiagnosticsRetainOrigin(t *testing.T) { + for _, item := range []struct { + name string + path string + mutate func(*Draft) + }{ + {"source", "sourceNonClaimRefs", func(d *Draft) { d.SourceNonClaimRefs = append(d.SourceNonClaimRefs, "NCL-MISSING") }}, + {"requirement", `requirements["REQ-MODEL-001"].nonClaimRefs`, func(d *Draft) { + d.Groups[0].Members[0].Fields.NonClaimRefs.Value = append(d.Groups[0].Members[0].Fields.NonClaimRefs.Value, "NCL-MISSING") + }}, + {"scenario", `scenarios["SCN-MODEL-REQUEST"].nonClaimRefs`, func(d *Draft) { d.Scenarios[0].NonClaimRefs = append(d.Scenarios[0].NonClaimRefs, "NCL-MISSING") }}, + {"derivation", `derivations["DRV-MODEL-001"].nonClaimRefs`, func(d *Draft) { d.Derivations[0].NonClaimRefs = append(d.Derivations[0].NonClaimRefs, "NCL-MISSING") }}, + } { + t.Run(item.name, func(t *testing.T) { + draft := validDraft() + item.mutate(&draft) + _, err := Normalize(draft) + validation, ok := err.(*ValidationError) + if !ok || validation.Code != "dangling_nonclaim_ref" || validation.Path != item.path { + t.Fatalf("Normalize() = %v, want dangling reference at %s", err, item.path) + } + draft.NonClaimDefinitions = append(draft.NonClaimDefinitions, NonClaimDefinition{NonClaimID: "NCL-MISSING", Statement: "A reference does not prove behavior."}) + if _, err := Normalize(draft); err != nil { + t.Fatalf("resolved reference control: %v", err) + } + }) + } +}