Keep complete paths in missing version evidence
This commit is contained in:
@@ -351,7 +351,9 @@ occur. Otherwise it records a source-local `Path`, the observed `Version` state
|
|||||||
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
||||||
available `Location`, and whether text was redacted. A non-nil record with
|
available `Location`, and whether text was redacted. A non-nil record with
|
||||||
`Present == false` records observed absence; it does not say the checker ran or
|
`Present == false` records observed absence; it does not say the checker ran or
|
||||||
accepted a declaration. During callbacks and in failed-load source records,
|
accepted a declaration. Absent evidence still identifies the full requested
|
||||||
|
header path, while its location identifies the nearest available source node.
|
||||||
|
During callbacks and in failed-load source records,
|
||||||
text is conservatively withheld because final classification may not be
|
text is conservatively withheld because final classification may not be
|
||||||
available. Successful final source records reflect completed sensitivity
|
available. Successful final source records reflect completed sensitivity
|
||||||
classification at the declaration's mounted path and ancestors, including
|
classification at the declaration's mounted path and ancestors, including
|
||||||
|
|||||||
@@ -265,7 +265,15 @@ Required remediation:
|
|||||||
|
|
||||||
### 5. Retain the complete requested path for absent declaration evidence (P2)
|
### 5. Retain the complete requested path for absent declaration evidence (P2)
|
||||||
|
|
||||||
Status: open.
|
Status: complete. Lookup now clones the full requested path and refines only
|
||||||
|
segments whose container shape was actually observed. Unresolved suffixes remain
|
||||||
|
intact, and nearest source locations remain independent of the header path.
|
||||||
|
Engine regressions protect segment intent and caller ownership; public
|
||||||
|
regressions cover missing early members, scalar obstruction, and unavailable
|
||||||
|
intermediate indices through optional final/callback evidence and required-load
|
||||||
|
failure evidence. Both regressions failed before the fix and pass afterward.
|
||||||
|
Focused source-version/path/source-record tests and the everyday check passed
|
||||||
|
on `go1.26.9 linux/amd64`. The configuration contract clarifies absence evidence.
|
||||||
|
|
||||||
Affected: `resolveSourceVersionPath` and `sourceVersionCheckForEvidence` in
|
Affected: `resolveSourceVersionPath` and `sourceVersionCheckForEvidence` in
|
||||||
[source_version.go](../../internal/engine/source_version.go), especially lines
|
[source_version.go](../../internal/engine/source_version.go), especially lines
|
||||||
|
|||||||
@@ -113,6 +113,63 @@ func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, main, underlay string
|
||||||
|
path configkit.Path
|
||||||
|
}{
|
||||||
|
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
|
||||||
|
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
|
||||||
|
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||||
|
} {
|
||||||
|
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||||
|
var callbackEvidence *configkit.SourceVersionCheck
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
SourceVersionMode: mode,
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
source, found := resolution.Source("source-2")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing callback source")
|
||||||
|
}
|
||||||
|
callbackEvidence = source.VersionCheck
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
var evidence *configkit.SourceVersionCheck
|
||||||
|
if mode == configkit.SourceVersionModeRequired {
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if loaded != nil || !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("%s: required omission did not fail", test.name)
|
||||||
|
}
|
||||||
|
diagnostics := failure.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||||
|
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||||
|
}
|
||||||
|
evidence = failure.Sources()[1].VersionCheck
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", test.name, err)
|
||||||
|
}
|
||||||
|
evidence = loaded.Sources()[1].VersionCheck
|
||||||
|
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||||
|
t.Errorf("%s: callback path is incomplete", test.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
|
||||||
|
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
|
||||||
|
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
||||||
reader.reads++
|
reader.reads++
|
||||||
return reader.Reader.Read(data)
|
return reader.Reader.Read(data)
|
||||||
|
|||||||
@@ -265,21 +265,23 @@ func (evidence *sourceVersionEvidence) redactText() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath {
|
func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath {
|
||||||
resolved := sourceVersionPath{absence: sourceVersionPathInvalid}
|
// Refine only segments whose container shape is known, keeping the full
|
||||||
|
// requested suffix when lookup stops before the declaration.
|
||||||
|
resolved := sourceVersionPath{absence: sourceVersionPathInvalid, resolvedPath: path.clone()}
|
||||||
if root == nil || !path.valid() {
|
if root == nil || !path.valid() {
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
current := root
|
current := root
|
||||||
resolved.nearest = current.Location
|
resolved.nearest = current.Location
|
||||||
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
|
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
|
||||||
for _, segment := range path.segments {
|
for position, segment := range path.segments {
|
||||||
switch current.Kind {
|
switch current.Kind {
|
||||||
case document.Mapping:
|
case document.Mapping:
|
||||||
if segment.kind == pathSegmentIndex {
|
if segment.kind == pathSegmentIndex {
|
||||||
resolved.absence = sourceVersionPathObstructed
|
resolved.absence = sourceVersionPathObstructed
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
resolved.resolvedPath = resolved.resolvedPath.Key(segment.key)
|
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentKey, key: segment.key}
|
||||||
found := false
|
found := false
|
||||||
for _, entry := range current.Mapping {
|
for _, entry := range current.Mapping {
|
||||||
if entry.Key == segment.key {
|
if entry.Key == segment.key {
|
||||||
@@ -298,7 +300,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
|
|||||||
resolved.absence = sourceVersionIndexUnavailable
|
resolved.absence = sourceVersionIndexUnavailable
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
resolved.resolvedPath = resolved.resolvedPath.Index(index)
|
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentIndex, index: index}
|
||||||
if index >= len(current.Sequence) {
|
if index >= len(current.Sequence) {
|
||||||
resolved.absence = sourceVersionIndexUnavailable
|
resolved.absence = sourceVersionIndexUnavailable
|
||||||
return resolved
|
return resolved
|
||||||
@@ -310,7 +312,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
|
|||||||
}
|
}
|
||||||
resolved.nearest = current.Location
|
resolved.nearest = current.Location
|
||||||
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
|
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
|
||||||
path: resolved.resolvedPath.clone(), kind: current.Kind,
|
path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)}, kind: current.Kind,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resolved.node = current
|
resolved.node = current
|
||||||
|
|||||||
@@ -3,11 +3,40 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSourceVersionAbsentPathKeepsUnresolvedSegmentIntent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
path string
|
||||||
|
want []pathSegment
|
||||||
|
}{
|
||||||
|
{"missing member", "{}\n", "/meta/version", []pathSegment{{kind: pathSegmentKey, key: "meta"}, {kind: pathSegmentParsed, key: "version"}}},
|
||||||
|
{"scalar obstruction", "meta: false\n", "/meta/version", []pathSegment{{kind: pathSegmentKey, key: "meta"}, {kind: pathSegmentParsed, key: "version"}}},
|
||||||
|
{"unavailable index", "meta: []\n", "/meta/3/version", []pathSegment{{kind: pathSegmentKey, key: "meta"}, {kind: pathSegmentIndex, index: 3}, {kind: pathSegmentParsed, key: "version"}}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path, err := ParsePath(test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolved := resolveSourceVersionPath(sourceVersionTestDocument(t, test.body), path)
|
||||||
|
if resolved.present || !reflect.DeepEqual(resolved.resolvedPath.segments, test.want) {
|
||||||
|
t.Fatalf("absent path = %#v, want %#v", resolved.resolvedPath, test.want)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(path.segments, mustParseSourceVersionPath(t, test.path).segments) {
|
||||||
|
t.Fatal("lookup mutated the requested path")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type sourceVersionMountedService struct {
|
type sourceVersionMountedService struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user