From e69ad0f32df409a16a8f4c83e3d3f7c57a1c37ce Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 15 Oct 2026 22:20:20 +0000 Subject: [PATCH] Keep complete paths in missing version evidence --- docs/config.md | 4 +- docs/roadmap/remediation.md | 10 ++++- facade_source_version_mode_test.go | 57 ++++++++++++++++++++++++++ internal/engine/source_version.go | 12 +++--- internal/engine/source_version_test.go | 29 +++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index 8a12a6d..9bba9ac 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index a165647..23c91aa 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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 diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index ed1ac30..b6c2f36 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -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) diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index ad6e71c..befad29 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -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 diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 7bfb733..1430bb3 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -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"` }