Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions internal/kernel/requirementsourcecodec/diagnostic_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
130 changes: 130 additions & 0 deletions internal/kernel/requirementsourcecodec/reference_diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -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/<entry>")
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
}
17 changes: 16 additions & 1 deletion internal/kernel/requirementsourcemodel/reference_closure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading