Keep complete paths in missing version evidence

This commit is contained in:
2026-10-15 22:20:20 +00:00
parent 0a2246bdbe
commit e69ad0f32d
5 changed files with 105 additions and 7 deletions

View File

@@ -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
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
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
available. Successful final source records reflect completed sensitivity
classification at the declaration's mounted path and ancestors, including

View File

@@ -265,7 +265,15 @@ Required remediation:
### 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
[source_version.go](../../internal/engine/source_version.go), especially lines

View File

@@ -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) {
reader.reads++
return reader.Reader.Read(data)

View File

@@ -265,21 +265,23 @@ func (evidence *sourceVersionEvidence) redactText() {
}
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() {
return resolved
}
current := root
resolved.nearest = current.Location
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
for _, segment := range path.segments {
for position, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
resolved.absence = sourceVersionPathObstructed
return resolved
}
resolved.resolvedPath = resolved.resolvedPath.Key(segment.key)
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentKey, key: segment.key}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
@@ -298,7 +300,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
resolved.absence = sourceVersionIndexUnavailable
return resolved
}
resolved.resolvedPath = resolved.resolvedPath.Index(index)
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentIndex, index: index}
if index >= len(current.Sequence) {
resolved.absence = sourceVersionIndexUnavailable
return resolved
@@ -310,7 +312,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
}
resolved.nearest = current.Location
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

View File

@@ -3,11 +3,40 @@ package engine
import (
"errors"
"path/filepath"
"reflect"
"testing"
"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 {
Version string `yaml:"version"`
}