From 15f5b591b9032bebd2ba3dc1749b7b3cf7279fb4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 15 Oct 2026 15:44:04 +0000 Subject: [PATCH] Check source versions in selected overlays --- docs/roadmap/implementation.md | 23 ++- internal/engine/load_input.go | 4 +- internal/engine/load_request.go | 2 +- internal/engine/selected_overlay.go | 24 ++- internal/engine/source_version_test.go | 195 +++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 189a879..70748ed 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement shared source version checks -Status: Stages 1–6 complete; selected overlay integration has not started. +Status: Stages 1–7 complete; public load-wide mode integration 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 @@ -416,6 +416,27 @@ Validate: overlay/override/inheritance/version tests and everyday check. Done: all document roles are integrated privately; synthetic operations are exempt from header checks, not from existing protection. +Completion evidence: threaded the shared version state through ordinary and +layered selected-overlay loading. After parse and overlay-directive validation, +the selected overlay is checked as a non-main source; accepted headers are +removed from its owned payload before effective-path protection and application. +The original parsed node still supplies raw byte/node charges, and source +identity, filesystem namespace, closure, overlay-control checks, and override +protection remain on their existing paths. Overlay warnings are accumulated with +the other per-load diagnostics. Added coverage for accepted, required-missing, +and rejected overlay declarations; structural validation before callbacks; +warning retention; CLI overrides without document-header checks; mounted-main +protection after header consumption; and sequence deletion retaining original +evidence coordinates while later payload indices shift. Existing default overlay +version/directive/accounting coverage remains passing. These checks passed with +Go `go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|Version|Import|Underlay|Overlay|Override|Inheritance|Destination' -count=1` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 8 — Expose the public load-wide mode 1. Add root mode/constants/option fields and both conversions from decision 1. diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index ba6ddba..94d1c56 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -142,9 +142,9 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI } assembly := newAssemblyAccumulator(composition) if activeOverlay(options.Overlay) { - input.sources, diagnostics = loadSelectedOverlayInto( + input.sources, diagnostics = loadSelectedOverlayIntoWithVersion( source, options.Filesystem, input.parsed, assembly, input.sources, - options.Overlay, options.Definition.Version, defaultImportLimits(), + options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, ) if len(diagnostics) != 0 { return input, diagnostics diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 1fd20e4..0561643 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -126,7 +126,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a selection := Source{kind: sourceBytes} selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}} var diagnostics []Diagnostic - input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits()) + input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState) if len(diagnostics) != 0 { return input, diagnostics } diff --git a/internal/engine/selected_overlay.go b/internal/engine/selected_overlay.go index c5a68ed..33dd28e 100644 --- a/internal/engine/selected_overlay.go +++ b/internal/engine/selected_overlay.go @@ -38,6 +38,10 @@ func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main } func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) { + return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil) +} + +func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) { if nativeContext(source, context) { access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) if diagnostic != nil { @@ -46,7 +50,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil { return cloneSourceInfos(sources), []Diagnostic{*diagnostic} } - return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits) + return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState) } rootPath, _, _, diagnostic := importFilesystem(source, context, main) if diagnostic != nil { @@ -58,10 +62,14 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par value.Primary = nil return cloneSourceInfos(sources), []Diagnostic{value} } - return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits) + return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState) } func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) { + return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil) +} + +func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) { closed := false defer func() { if !closed { @@ -126,7 +134,15 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 { return sources, diagnostics } - if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil { + overlayPayload := overlayRoot + if versionState != nil && versionState.mode != sourceVersionModeDefault { + processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID) + if len(failures) != 0 { + return sources, failures + } + overlayPayload = processed.payload + } + if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil { location := publicLocation(versionNode.Location) return sources, []Diagnostic{{ Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(), @@ -139,7 +155,7 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)} } - if diagnostics := base.applyOverlay(overlayRoot, nil, nil, LayerOverlay); len(diagnostics) != 0 { + if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 { return sources, diagnostics } base.current.inputBytes += int64(len(data)) diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 4aa05fd..3208dbd 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -974,6 +974,201 @@ func TestSourceVersionUnderlaysProtectRemainingMountedPayload(t *testing.T) { } } +func TestSourceVersionSelectedOverlayConsumesHeaderAndRetainsWarning(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\nsettings: {kept: base}\n") + overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "schema: {version: overlay-v}\nsettings: {changed: overlay}\n") + var checked []string + loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ + Overlay: "testing", SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("schema", "version"), + Check: func(version Version, validation *Validation) error { + checked = append(checked, version.Text) + if version.Text == "overlay-v" { + validation.Warning(Path{}, "overlay accepted with advisory") + } + return nil + }, + }}, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(checked) != 2 || checked[0] != "main-v" || checked[1] != "overlay-v" { + t.Fatalf("checked source declarations = %#v", checked) + } + if loaded.Config["schema"].(map[string]any)["version"] != "main-v" || loaded.Config["settings"].(map[string]any)["changed"] != "overlay" { + t.Fatalf("overlay payload = %#v", loaded.Config) + } + diagnostics := loaded.Inspection.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning { + t.Fatalf("overlay warning was not retained: %#v", diagnostics) + } + sources := loaded.Inspection.Sources() + if len(sources) != 2 || sources[1].Path != filepath.Clean(overlayPath) || sources[1].Layer != LayerOverlay { + t.Fatalf("overlay source records = %#v", sources) + } +} + +func TestSourceVersionSelectedOverlayRequiresAndChecksItsDeclaration(t *testing.T) { + t.Parallel() + + t.Run("required absence", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\n") + writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: overlay}\n") + loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ + Overlay: "testing", SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }, + }}, + }) + if err == nil || loaded != nil { + t.Fatalf("Load = %#v, %v, want required overlay declaration error", loaded, err) + } + loadErr, ok := err.(*LoadError) + if !ok || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeVersionError { + t.Fatalf("missing overlay diagnostics = %#v", err) + } + }) + + t.Run("checker rejection", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\n") + writeImportFile(t, directory, "overlays/testing.yml", "schema: {version: rejected-overlay}\nsettings: {changed: hidden}\n") + var checked []string + loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ + Overlay: "testing", SourceVersionMode: sourceVersionModeIfPresent, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("schema", "version"), + Check: func(version Version, _ *Validation) error { + checked = append(checked, version.Text) + if version.Text == "rejected-overlay" { + return errors.New("overlay rejected") + } + return nil + }, + }}, + }) + if err == nil || loaded != nil { + t.Fatalf("Load = %#v, %v, want overlay rejection", loaded, err) + } + loadErr, ok := err.(*LoadError) + if !ok || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeVersionHookFailed { + t.Fatalf("overlay rejection diagnostics = %#v", err) + } + if len(checked) != 2 || checked[0] != "main-v" || checked[1] != "rejected-overlay" { + t.Fatalf("checked source declarations = %#v", checked) + } + }) + + t.Run("structural validation precedes declaration processing", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\n") + writeImportFile(t, directory, "overlays/testing.yml", "configkit: {imports: true}\nschema: {version: overlay-v}\n") + checks := 0 + loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ + Overlay: "testing", SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("schema", "version"), Check: func(Version, *Validation) error { checks++; return nil }, + }}, + }) + if err == nil || loaded != nil { + t.Fatalf("Load = %#v, %v, want structural overlay rejection", loaded, err) + } + loadErr, ok := err.(*LoadError) + if !ok || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeOverlayDirective { + t.Fatalf("structural overlay diagnostics = %#v", err) + } + if checks != 1 { + t.Fatalf("version checker calls = %d, want main only", checks) + } + }) +} + +func TestSourceVersionOverlayLeavesCLIOverridesHeaderFree(t *testing.T) { + t.Parallel() + + checks := 0 + loaded, err := Load(Bytes("main", []byte("schema: {version: main-v}\nsettings: {base: value}\n")), testSecretBinding(), Options[map[string]any]{ + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("schema", "version"), + Check: func(Version, *Validation) error { + checks++ + return nil + }, + }}, + Overrides: []Override{{Path: Keys("settings", "cli"), Value: "supplied", Flag: "--settings.cli"}}, + }) + if err != nil { + t.Fatalf("Load with CLI override: %v", err) + } + if checks != 1 || loaded.Config["settings"].(map[string]any)["cli"] != "supplied" { + t.Fatalf("checker count/configuration = %d, %#v", checks, loaded.Config) + } +} + +func TestSourceVersionOverlayProtectsMountedMainAndPreservesSequenceCoordinates(t *testing.T) { + t.Parallel() + + t.Run("mounted main ownership", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "meta: {version: main-v}\n") + writeImportFile(t, directory, "overlays/testing.yml", "meta: {version: overlay-v}\nservice: {meta: {version: payload}}\n") + _, err := LoadLayered(Input{Source: File(mainPath), Destination: Keys("service")}, testSecretBinding(), LayeredOptions[map[string]any]{ + Overlay: "testing", OverlayFilesystem: &FilesystemContext{BaseDir: directory, Root: directory}, + SourceVersionMode: sourceVersionModeRequired, + Definition: Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("meta", "version"), Check: func(Version, *Validation) error { return nil }, + }}, + }) + loadErr, ok := err.(*LoadError) + if !ok || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeOverlayVersion { + t.Fatalf("mounted overlay ownership error = %#v", err) + } + }) + + t.Run("sequence location remains source-local after deletion", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "base: true\n") + writeImportFile(t, directory, "overlays/testing.yml", "versions:\n - before\n - overlay-version\n - after\n") + main, diagnostics := parseSource(File(mainPath), "source-main", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatalf("parse main: %#v", diagnostics) + } + localRule := &VersionRule{Path: Keys("versions").Index(1), Check: func(version Version, _ *Validation) error { + if version.Text != "overlay-version" { + t.Errorf("checked version = %#v", version) + } + return nil + }} + effectiveRule := effectiveSourceVersionRule(localRule, Keys("service"), sourceVersionModeRequired) + state := &sourceVersionState{localRule: localRule, effectiveRule: effectiveRule, mode: sourceVersionModeRequired} + base, compositionDiagnostics := composeDocuments(&document.Node{Kind: document.Mapping}) + if len(compositionDiagnostics) != 0 { + t.Fatalf("compose base: %#v", compositionDiagnostics) + } + assembly := newAssemblyAccumulator(base) + _, diagnostics = loadSelectedOverlayIntoWithVersion( + File(mainPath), nil, main, assembly, sourceInfos(main.source), "testing", effectiveRule, defaultImportLimits(), state, + ) + if len(diagnostics) != 0 { + t.Fatalf("load selected overlay: %#v", diagnostics) + } + if len(state.evidence) != 1 || state.evidence[0].resolvedPath.String() != "/versions/1" || state.evidence[0].location == nil || state.evidence[0].location.Line != 3 { + t.Fatalf("sequence evidence lost original coordinates: %#v", state.evidence) + } + versions, found := documentAtPath(assembly.current.root, Keys("versions")) + if !found || versions.Kind != document.Sequence || len(versions.Sequence) != 2 || versions.Sequence[0].Text != "before" || versions.Sequence[1].Text != "after" { + t.Fatalf("remaining overlay sequence = %#v", versions) + } + }) +} + func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T) { t.Parallel()