diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 70748ed..4dc0e20 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement shared source version checks -Status: Stages 1–7 complete; public load-wide mode integration has not started. +Status: Stages 1–8 complete; evidence sensitivity classification has not started. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. The [feature roadmap](source-version-checks.md) owns target behavior and @@ -451,6 +451,24 @@ No environmental limitation affected validation; checks ran on Linux/amd64. Validate: facade version/layered/source and boundary tests; everyday check. Done: both supported public load APIs expose the implemented modes. +Completion evidence: added the root-owned `SourceVersionMode` enum and constants +to ordinary and layered options, with Go documentation for zero-value behavior, +source-local declaration paths, destination-prefixed main protection, and the +two enabled policies. The facade converts explicitly through the internal +engine boundary, which keeps its mode type private and preserves unknown values +for option validation before acquisition. Added external-package coverage for +enabled ordinary and layered loads using byte, file, and mapping sources; zero +mode compatibility; invalid modes and missing rules before reader access; and +reusing one Definition with different modes. Source evidence remains private. +These checks passed with Go `go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test . -run 'SourceVersionMode|Version|Layered|SourceInfo|Destination|PublicFacadeBoundary|DependencyImportBoundary' -count=1` +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|Version|Layered|Source|Destination|Import' -count=1` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 9 — Classify evidence at original paths and ancestors 1. Include declaration paths/ancestors in final classification candidates, diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go new file mode 100644 index 0000000..9a7bce0 --- /dev/null +++ b/facade_source_version_mode_test.go @@ -0,0 +1,175 @@ +package configkit_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type sourceVersionModeReader struct { + *strings.Reader + reads int +} + +func (reader *sourceVersionModeReader) Read(data []byte) (int, error) { + reader.reads++ + return reader.Reader.Read(data) +} + +func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) { + t.Parallel() + + var checked []configkit.Version + definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("meta", "version"), + Check: func(version configkit.Version, _ *configkit.Validation) error { + checked = append(checked, version) + return nil + }, + }} + ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + if err != nil || ordinary == nil { + t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err) + } + if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" { + t.Fatalf("main declaration was not retained: %#v", ordinary.Config) + } + + directory := t.TempDir() + mainPath := filepath.Join(directory, "main.yml") + if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil { + t.Fatal(err) + } + layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{ + Source: configkit.Mapping("underlay", map[string]any{ + "meta": map[string]any{"version": "underlay-v"}, "fallback": "present", + }), + Destination: configkit.Keys("defaults"), + }}}}, + }) + if err != nil || layered == nil { + t.Fatalf("layered enabled load = %#v, %v", layered, err) + } + defaults, ok := layered.Config["defaults"].(map[string]any) + if !ok || defaults["fallback"] != "present" { + t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"]) + } + if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 { + t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"]) + } + if len(checked) != 3 { + t.Fatalf("checker calls across separate mode uses = %#v", checked) + } + for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} { + found := false + for _, version := range checked { + found = found || version.Present && version.Text == want + } + if !found { + t.Errorf("checked versions %#v do not include %q", checked, want) + } + } +} + +func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) { + t.Parallel() + + checks := 0 + result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{ + Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: func(version configkit.Version, _ *configkit.Validation) error { + checks++ + if version != (configkit.Version{Present: true, Text: "v1"}) { + t.Errorf("default version = %#v", version) + } + return nil + }, + }}, + }) + if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 { + t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks) + } +} + +func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) { + t.Parallel() + + validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }, + }} + for _, test := range []struct { + name string + mode configkit.SourceVersionMode + def configkit.Definition[map[string]any] + layered bool + }{ + {name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition}, + {name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent}, + {name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true}, + {name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true}, + } { + t.Run(test.name, func(t *testing.T) { + reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")} + source := configkit.Reader("version.yml", reader) + var err error + if test.layered { + _, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{ + Definition: test.def, SourceVersionMode: test.mode, + }) + } else { + _, err = configkit.Load(source, configkit.Options[map[string]any]{ + Definition: test.def, SourceVersionMode: test.mode, + }) + } + var loadError *configkit.LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid { + t.Fatalf("invalid mode error = %#v", err) + } + if reader.reads != 0 { + t.Fatalf("invalid options read source %d times", reader.reads) + } + }) + } +} + +func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) { + t.Parallel() + + checks := 0 + definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: func(version configkit.Version, _ *configkit.Validation) error { + checks++ + if version.Present { + t.Errorf("missing declaration reached checker as %#v", version) + } + return nil + }, + }} + source := configkit.Bytes("reused", []byte("value: retained\n")) + ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + if err != nil || ifPresent == nil || checks != 1 { + t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks) + } + required, err := configkit.Load(source, configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, + }) + var loadError *configkit.LoadError + if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError { + t.Fatalf("required load = %#v, %v", required, err) + } + if checks != 1 { + t.Fatalf("required absence reached checker: calls = %d", checks) + } +} diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index faa6ca8..792aefd 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -10,6 +10,13 @@ const ( sourceVersionModeRequired ) +// SourceVersionModeFromFacade converts the root-owned public mode while +// keeping the engine's policy type private. Unknown values remain intact for +// pre-acquisition validation. +func SourceVersionModeFromFacade(value uint8) sourceVersionMode { + return sourceVersionMode(value) +} + type sourceVersionRole uint8 const ( diff --git a/layered.go b/layered.go index 3d262d5..1cf945d 100644 --- a/layered.go +++ b/layered.go @@ -23,8 +23,12 @@ type Underlay struct{ Inputs []Input } // LayeredOptions selects lower-priority tiers and final application rules. // An active Overlay requires an explicit OverlayFilesystem; an inactive overlay // ignores it. Overrides remain explicit CLI records, not general mappings. +// SourceVersionMode applies one declaration policy to main, underlay, import, +// and selected overlay documents. Enabled modes interpret the version path +// source-locally while protecting main's destination-prefixed path. type LayeredOptions[T any] struct { Definition Definition[T] + SourceVersionMode SourceVersionMode Underlays []Underlay Overlay string OverlayFilesystem *FilesystemContext @@ -57,7 +61,9 @@ func toEngineInput(input Input) engine.Input { func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] { result := engine.LayeredOptions[T]{ - Definition: toEngineDefinition(options.Definition), Overlay: options.Overlay, + Definition: toEngineDefinition(options.Definition), + SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), + Overlay: options.Overlay, OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), } if options.Underlays != nil { diff --git a/lifecycle.go b/lifecycle.go index 3dd4004..41e3926 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -17,15 +17,39 @@ type Definition[T any] struct { Secret func(Path) bool } +// SourceVersionMode controls schema-version declarations across one complete +// load. The zero value preserves the existing effective-path version check. +type SourceVersionMode uint8 + +const ( + // SourceVersionModeDefault preserves the existing main-version check and + // rejects version-path contributions from imported, underlay, and overlay + // payloads. + SourceVersionModeDefault SourceVersionMode = iota + // SourceVersionModeIfPresent checks each participating document that has a + // declaration. A missing main declaration still reaches the checker; an + // absent non-main declaration skips it. Accepted non-main declarations are + // consumed before composition. + SourceVersionModeIfPresent + // SourceVersionModeRequired requires a declaration in each participating + // document. NoSource retains the existing absent-main checker behavior. + // Accepted non-main declarations are consumed before composition. + SourceVersionModeRequired +) + // Options contains the application definition and per-load input selections. // Imports enables explicit path and glob imports; Overlay selects one fixed overlay; // Overrides contains only values explicitly supplied by an application CLI. +// Enabled source-version modes interpret Definition.Version.Path in each +// document's local coordinates. Main protection remains at that path prefixed +// by the main input's Destination. type Options[T any] struct { - Definition Definition[T] - Imports bool - Overlay string - Filesystem *FilesystemContext - Overrides []Override + Definition Definition[T] + Imports bool + Overlay string + Filesystem *FilesystemContext + Overrides []Override + SourceVersionMode SourceVersionMode } // FilesystemContext supplies roots for imports and overlays. With FS nil, @@ -226,9 +250,10 @@ func (v *Validation) engineContext() *engine.Validation { func toEngineOptions[T any](options Options[T]) engine.Options[T] { result := engine.Options[T]{ - Definition: toEngineDefinition(options.Definition), - Imports: options.Imports, - Overlay: options.Overlay, + Definition: toEngineDefinition(options.Definition), + Imports: options.Imports, + Overlay: options.Overlay, + SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), } if options.Filesystem != nil { result.Filesystem = toEngineFilesystemContext(options.Filesystem)