diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0c84586..675b93d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -139,6 +139,8 @@ defaults and resolution must not gain a new schema/key rejection. ## Stage 4 — Verify source-version and acquisition integration +**Status:** Complete. Focused source-version and declaration-overlap tests pass. + **Owners:** [source_version.go](../../internal/engine/source_version.go), `processAcquiredInputVersion` in acquisition, import processing, and existing source-version/item-policy tests. diff --git a/internal/engine/acquisition.go b/internal/engine/acquisition.go index 1b3e4c6..6fb0771 100644 --- a/internal/engine/acquisition.go +++ b/internal/engine/acquisition.go @@ -205,7 +205,7 @@ func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRe hasResult := false updateMainProcessing := false switch request.directiveContext { - case rootDirectiveContextSource: + case rootDirectiveContextSource, rootDirectiveContextLayered: if state != nil { processed, diagnostics := state.processWithDirectiveContext( parsed.document, request.role, request.input.destination, sourceID, @@ -226,30 +226,6 @@ func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRe } } hasResult = true - case rootDirectiveContextLayered: - extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext) - if len(diagnostics) != 0 { - return nil, diagnostics - } - result = sourceVersionProcessingResult{ - payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences, - directiveContext: request.directiveContext, - } - hasResult = true - if state != nil && state.mode != sourceVersionModeDefault { - processed, diagnostics := state.process( - extracted.body, request.role, request.input.destination, - sourceID, request.input.placementMode, - ) - if len(diagnostics) != 0 { - return nil, diagnostics - } - processed.imports = extracted.imports - processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...) - processed.directiveContext = request.directiveContext - result = processed - updateMainProcessing = request.role == sourceVersionMain - } default: if state != nil && state.mode != sourceVersionModeDefault { processed, diagnostics := state.process( diff --git a/internal/engine/item_policy_test.go b/internal/engine/item_policy_test.go index 32dab81..4792933 100644 --- a/internal/engine/item_policy_test.go +++ b/internal/engine/item_policy_test.go @@ -458,7 +458,7 @@ func TestItemSourceVersionProcessingConsumesDeclarationsBeforeComposition(t *tes t.Parallel() root := parseItemPolicyDocument(t, "item", `version: v1 -configkit: +config: sequences: - destination: [prompts] key: id @@ -483,7 +483,7 @@ prompts: if _, ok := documentAtPath(processed.payload, Keys("version")); ok { t.Fatalf("consumed version header remains in item payload: %#v", processed.payload) } - if _, ok := documentAtPath(processed.payload, Keys("configkit")); ok { + if _, ok := documentAtPath(processed.payload, Keys("config")); ok { t.Fatalf("consumed controls remain in item payload: %#v", processed.payload) } } diff --git a/internal/engine/sequence_declaration_test.go b/internal/engine/sequence_declaration_test.go index 8daf7b1..35be78c 100644 --- a/internal/engine/sequence_declaration_test.go +++ b/internal/engine/sequence_declaration_test.go @@ -126,13 +126,13 @@ func TestParseSequenceDeclarationRejectsMalformedForms(t *testing.T) { } func TestSequenceDeclarationsDoNotTriggerVersionOverlapWhenHeaderIsAbsent(t *testing.T) { - root := parseRootDirectiveDocument(t, `configkit: + root := parseRootDirectiveDocument(t, `config: sequences: - destination: [prompts] key: id version: v1 `) - presentPath, err := ParsePath("/configkit/sequences/0/destination") + presentPath, err := ParsePath("/config/sequences/0/destination") if err != nil { t.Fatal(err) } @@ -140,7 +140,7 @@ version: v1 if !sourceVersionHeaderControlOverlap(root, present) { t.Fatal("present version header beneath a sequence control did not overlap") } - absentPath, err := ParsePath("/configkit/sequences/1/destination") + absentPath, err := ParsePath("/config/sequences/1/destination") if err != nil { t.Fatal(err) } diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 47fd6d9..8ee27b2 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -24,7 +24,7 @@ func TestDefaultMainVersionWarningSurvivesLayeredLoading(t *testing.T) { }{ {name: "ordinary", ordinary: true}, {name: "layered", lower: "{}"}, - {name: "later structural failure", body: "configkit:\n sequences: [{destination: [items], key: id}]\nitems: []\n", lower: "items: null", failure: true}, + {name: "later structural failure", body: "config:\n sequences: [{destination: [items], key: id}]\nitems: []\n", lower: "items: null", failure: true}, {name: "later decoding failure", body: "count: invalid\n", lower: "{}", failure: true}, } { t.Run(test.name, func(t *testing.T) { @@ -433,8 +433,8 @@ func TestSourceVersionExtractionRejectsStructuralOverlap(t *testing.T) { wantCode Code }{ { - name: "inside import declaration", input: "configkit:\n imports:\n - path: child.yml\n", - path: Keys("configkit", "imports").Index(0).Key("path"), wantCode: CodeVersionError, + name: "inside import declaration", input: "config:\n imports:\n - path: child.yml\n", + path: Keys("config", "imports").Index(0).Key("path"), wantCode: CodeVersionError, }, { name: "extends control", input: "settings:\n extends: parent\n", @@ -445,8 +445,8 @@ func TestSourceVersionExtractionRejectsStructuralOverlap(t *testing.T) { path: Keys("settings", "extends", "version"), wantCode: CodeVersionError, }, { - name: "malformed directive cannot be consumed", input: "configkit:\n imports: true\n", - path: Keys("configkit", "imports"), wantCode: CodeImportInvalid, + name: "malformed directive cannot be consumed", input: "config:\n imports: true\n", + path: Keys("config", "imports"), wantCode: CodeImportInvalid, }, } for _, test := range tests { @@ -471,7 +471,7 @@ func TestSourceVersionExtractionRejectsStructuralOverlap(t *testing.T) { }) } - unrelatedRoot := sourceVersionTestDocument(t, "configkit:\n imports:\n - path: child.yml\nmetadata:\n version: 4\n") + unrelatedRoot := sourceVersionTestDocument(t, "config:\n imports:\n - path: child.yml\nmetadata:\n version: 4\n") unrelatedResult := processSourceVersion(unrelatedRoot, &VersionRule{ Path: Keys("metadata", "version"), Check: func(Version, *Validation) error { return nil }, }, sourceVersionContribution, Path{}, sourceVersionModeIfPresent) @@ -489,6 +489,84 @@ func TestSourceVersionExtractionRejectsStructuralOverlap(t *testing.T) { } } +func TestSourceVersionAllowsNestedConfigData(t *testing.T) { + root := sourceVersionTestDocument(t, "service: {config: {version: 4, imports: ordinary}}\n") + called := false + result := processSourceVersion(root, &VersionRule{ + Path: Keys("service", "config", "version"), + Check: func(version Version, _ *Validation) error { + called = version.Present && version.Text == "4" + return nil + }, + }, sourceVersionContribution, Path{}, sourceVersionModeIfPresent) + if len(result.diagnostics) != 0 || !called || result.payload == nil { + t.Fatalf("nested version result = %#v, checked = %t", result, called) + } + if _, found := documentAtPath(result.payload, Keys("service", "config", "version")); found { + t.Fatal("accepted contribution header was not consumed") + } +} + +func TestSourceVersionMountedConfigKeepsLocalAndEffectivePaths(t *testing.T) { + type config struct { + Config struct { + Version string `yaml:"version"` + } `yaml:"config"` + } + checks := 0 + definition := Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error { + checks++ + if !version.Present || version.Text != "4" { + return errors.New("unexpected version") + } + return nil + }}} + main := Input{Source: Bytes("main", []byte("version: 4\n")), Destination: Keys("config")} + loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[config]{Definition: definition, SourceVersionMode: sourceVersionModeRequired}) + if err != nil || loaded == nil || loaded.Config.Config.Version != "4" || checks != 1 { + t.Fatalf("mounted version = %#v, checks %d, error %v", loaded, checks, err) + } + _, err = LoadLayered(main, testSecretBinding(), LayeredOptions[config]{Definition: definition, SourceVersionMode: sourceVersionModeRequired, + Overrides: []Override{{Path: Keys("config", "version"), Value: "5", Flag: "--version"}}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("protected override error = %v", err) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOverrideVersion, "/config/version") +} + +func TestSourceVersionLayeredRejectsControlHeaderBeforeChecking(t *testing.T) { + called := false + definition := Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("config", "imports").Index(0).Key("path"), + Check: func(Version, *Validation) error { called = true; return nil }, + }} + mainPath := writeImportFile(t, t.TempDir(), "main.yml", "config: {imports: [{path: child.yml}]}\n") + _, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, + testSecretBinding(), LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent}) + var loadErr *LoadError + if !errors.As(err, &loadErr) || called { + t.Fatalf("overlap error = %v, checker called = %t", err, called) + } + requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionError, "/config/imports/0/path") +} + +func TestSourceVersionDefaultModeChecksAssembledConfigPath(t *testing.T) { + checked := false + definition := Definition[map[string]any]{Version: &VersionRule{ + Path: Keys("config", "version"), Check: func(version Version, _ *Validation) error { + checked = version.Present && version.Text == "4" + return nil + }, + }} + loaded, err := LoadLayered(Input{Source: Bytes("main", []byte("version: 4\n")), Destination: Keys("config")}, + testSecretBinding(), LayeredOptions[map[string]any]{Definition: definition}) + if err != nil || loaded == nil || !checked { + t.Fatalf("assembled version = %#v, checked = %t, error = %v", loaded, checked, err) + } +} + func TestSourceVersionDefaultModeDoesNotCheckOrConsume(t *testing.T) { t.Parallel() @@ -858,13 +936,13 @@ func TestSourceVersionImportsCheckNestedAndGlobSourcesBeforeComposition(t *testi directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", `version: main -configkit: +config: imports: - glob: fragments/*.yml destination: [groups] `) firstPath := writeImportFile(t, directory, "fragments/a.yml", `version: first -configkit: +config: imports: - path: nested/child.yml destination: [nested] @@ -935,7 +1013,7 @@ func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) { t.Run("rejected child is not hidden by the main declaration", func(t *testing.T) { t.Parallel() directory := t.TempDir() - mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n") + mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfig: {imports: [{path: child.yml}]}\n") writeImportFile(t, directory, "child.yml", "version: rejected\n") checks := 0 _, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{ @@ -963,7 +1041,7 @@ func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) { t.Run("required child omission reports its nearest source location", func(t *testing.T) { t.Parallel() directory := t.TempDir() - mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n") + mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfig: {imports: [{path: child.yml}]}\n") writeImportFile(t, directory, "child.yml", "name: child\n") checks := 0 _, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{ @@ -986,7 +1064,7 @@ func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) { t.Parallel() directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", `version: main -configkit: +config: imports: - path: missing.yml `) @@ -1144,7 +1222,7 @@ func TestSourceVersionUnderlayImportsKeepLocalHeadersAndMountedDestinations(t *t directory := t.TempDir() underlayPath := writeImportFile(t, directory, "underlay.yml", `meta: {version: peer-root} -configkit: +config: imports: - path: nested.yml destination: [nested] @@ -1296,7 +1374,7 @@ func TestSourceVersionSelectedOverlayConsumesHeaderAndRetainsWarning(t *testing. directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\nsettings: {kept: base}\nitems: [{id: base}]\n") overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `schema: {version: overlay-v} -configkit: +config: sequences: - destination: [items] key: id @@ -1327,7 +1405,7 @@ items: [{id: overlay}] 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) } - if _, found := loaded.Config["configkit"]; found || len(loaded.Config["items"].([]any)) != 2 { + if _, found := loaded.Config["config"]; found || len(loaded.Config["items"].([]any)) != 2 { t.Fatalf("overlay declarations or collection values = %#v", loaded.Config) } diagnostics := loaded.Inspection.Diagnostics() @@ -1395,7 +1473,7 @@ func TestSourceVersionSelectedOverlayRequiresAndChecksItsDeclaration(t *testing. 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") + writeImportFile(t, directory, "overlays/testing.yml", "config: {imports: true}\nschema: {version: overlay-v}\n") checks := 0 loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ Overlay: "testing", SourceVersionMode: sourceVersionModeRequired, @@ -1513,7 +1591,7 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T) t.Parallel() directory := t.TempDir() - mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n") + mainPath := writeImportFile(t, directory, "main.yml", "config: {imports: [{path: child.yml}]}\n") childPath := writeImportFile(t, directory, "child.yml", "version: accepted\nname: child\n") main := parseImportMain(t, mainPath) child, diagnostics := parseSource(File(childPath), "source-child", defaultParseLimits())