diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 902e25f..54a05ed 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -129,6 +129,8 @@ selection, and diagnostic tests for verification. ## Stage 4 — Finish companion handling when no base exists +**Status:** Complete. Focused no-base, import, placement, and grouped-load checks passed. + Handle the no-base outcome only for an active underlay binding with no override, usable main context, or fallback. `RequireBase` fails with `CodeOptionsInvalid` regardless of import enablement or declaration count. diff --git a/facade_main_relative_test.go b/facade_main_relative_test.go index 3d92f9b..4ee823a 100644 --- a/facade_main_relative_test.go +++ b/facade_main_relative_test.go @@ -68,3 +68,91 @@ func TestMainRelativeContextSelection(t *testing.T) { t.Fatalf("fallback value = %q", got) } } + +func TestMainRelativeWithoutBase(t *testing.T) { + tests := []struct { + name, source string + requireBase bool + wantCode configkit.Code + }{ + {"optional", "config: {imports: [{path: missing.yml}]}\ncount: 7\n", false, ""}, + {"required", "config: {imports: [{path: missing.yml, required: true}]}\n", false, configkit.CodeImportMissing}, + {"required directory", "config: {imports: [{dir: missing, required: true}]}\n", false, configkit.CodeImportMissing}, + {"required glob", "config: {imports: [{glob: '*.yml', required: true}]}\n", false, configkit.CodeImportMissing}, + {"empty required", "config: {imports: [{path: [], required: true}]}\ncount: 7\n", false, ""}, + {"base required", "count: 7\n", true, configkit.CodeOptionsInvalid}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: true, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}}) + if test.wantCode != "" { + if err == nil { + t.Fatal("expected failure") + } + for _, d := range err.(*configkit.LoadError).Diagnostics() { + if d.Code == test.wantCode { + return + } + } + t.Fatalf("diagnostics: %#v", err.(*configkit.LoadError).Diagnostics()) + } + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 7 { + t.Fatalf("config = %#v", result.Config) + } + if sources := result.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 { + t.Fatalf("sources = %#v", sources) + } + }) + } +} + +func TestMainRelativeWithoutBaseSequenceItem(t *testing.T) { + type item struct { + Name string `yaml:"name"` + } + type config struct { + Items []item `yaml:"items"` + } + input := configkit.Input{Source: configkit.Bytes("item", []byte("config: {imports: [{path: missing.yml}]}\nname: example\n")), Imports: true, MainRelative: configkit.MainRelative(), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if len(result.Config.Items) != 1 || result.Config.Items[0].Name != "example" { + t.Fatalf("config = %#v", result.Config) + } +} + +func TestMainRelativeWithoutBaseImportDefaults(t *testing.T) { + required := true + tests := []struct { + name, source string + imports bool + requireBase bool + code configkit.Code + }{ + {"default required", "config: {imports: [{path: missing.yml}]}\n", true, false, configkit.CodeImportMissing}, + {"malformed", "config: {imports: [{path: 42}]}\n", true, false, configkit.CodeImportInvalid}, + {"imports disabled", "config: {imports: [{path: missing.yml}]}\n", false, false, configkit.CodeImportDisabled}, + {"base required without imports", "count: 1\n", false, true, configkit.CodeOptionsInvalid}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: test.imports, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}} + _, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{ImportDefaults: configkit.ImportDefaults{Required: &required}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}}) + if err == nil { + t.Fatal("expected failure") + } + for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() { + if diagnostic.Code == test.code { + return + } + } + t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics()) + }) + } +} diff --git a/internal/engine/acquisition.go b/internal/engine/acquisition.go index 811d855..f99f8c0 100644 --- a/internal/engine/acquisition.go +++ b/internal/engine/acquisition.go @@ -21,6 +21,7 @@ type tierAcquisitionState struct { type inputAcquisitionRequest struct { input loadInput resolved *resolvedFilesystemContext + withoutBase bool role sourceVersionRole directiveContext rootDirectiveContext protectionRule *VersionRule @@ -34,6 +35,9 @@ type importAcquisitionRequest struct { } func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) { + if request.withoutBase && request.input.mainRelative.RequireBase { + return assembledInput{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "main-relative input requires a filesystem base")} + } source := request.input.source destination := request.input.destination accounting := state.accounting @@ -102,7 +106,12 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta return input, diagnostics } request.input.source = selectedSource - if request.input.imports && selectedSource.kind != sourceNone { + if request.input.imports && selectedSource.kind != sourceNone && request.withoutBase { + input, diagnostics = acquireContextlessInput(input, request, versionProcessing, policy) + if len(diagnostics) != 0 { + return input, diagnostics + } + } else if request.input.imports && selectedSource.kind != sourceNone { importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing} input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier) if len(diagnostics) != 0 { diff --git a/internal/engine/contextless_imports.go b/internal/engine/contextless_imports.go new file mode 100644 index 0000000..142a516 --- /dev/null +++ b/internal/engine/contextless_imports.go @@ -0,0 +1,86 @@ +package engine + +import "go.maximumdirect.net/configkit/internal/document" + +func acquireContextlessInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy) (assembledInput, []Diagnostic) { + parsed := input.parsed + var body *document.Node + var declarations []importDeclarationSpec + var sequences []sequenceDeclarationSpec + var diagnostics []Diagnostic + if processing != nil { + body, declarations = processing.payload, processing.imports + sequences = cloneSequenceDeclarations(processing.sequences) + } else { + body, declarations, diagnostics = splitImportDirectives(parsed.document) + } + destination := request.input.destination + localDestination := destination + if request.input.placementMode == PlacementModeSequenceItem { + localDestination = Path{} + } + if len(diagnostics) == 0 { + var policies []resolvedImportPolicy + policies, diagnostics = prepareImportDeclarations(declarations, policy.importDefaults, localDestination) + if len(diagnostics) == 0 { + for i, spec := range declarations { + if !policies[i].required || spec.selector == importSelectorPath && len(spec.paths) == 0 { + continue + } + site := spec.fieldSites["path"] + if spec.selector == importSelectorPath && len(spec.selectorSites) != 0 { + site = spec.selectorSites[0] + } + if spec.selector == importSelectorDirectory { + site = spec.fieldSites["dir"] + } + if spec.selector == importSelectorGlob { + site = spec.fieldSites["glob"] + } + diagnostics = []Diagnostic{{Code: CodeImportMissing, Severity: SeverityError, Path: site.path.clone(), Message: "import cannot be selected because no filesystem base is available", Primary: cloneLocation(&site.location)}} + break + } + } + } + if len(diagnostics) != 0 { + if request.input.placementMode == PlacementModeSequenceItem { + diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0)) + } + return input, diagnostics + } + key := importKeyForMainSource(parsed.source) + info := cloneSourceInfo(*parsed.source) + accounting := input.accounting + accounting.sources++ + accounting.bytes += parsed.bytes + accounting.nodes += countInputNodes(parsed.document) + limits := policy.importLimits + if accounting.sources > limits.sources || accounting.bytes > limits.bytes || accounting.nodes > limits.nodes { + return input, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate input limit", nil)} + } + contribution := acquiredContribution{source: info, payload: body, identity: key, role: importRoleMain, scopeOwner: key, processing: processing, sequenceDeclarations: sequences} + var scope acquisitionScope + if request.input.placementMode == PlacementModeSequenceItem { + contribution.document = body + local := acquisitionScope{owner: key, contributions: []acquiredContribution{contribution}} + scope = acquisitionScope{owner: key, destination: destination.clone(), contributions: []acquiredContribution{{source: info, payload: body, identity: key, role: importRoleMain, scopeOwner: key, destination: destination.clone(), processing: processing, sequenceDeclarations: sequences, pendingItem: &local}}} + if diagnostic := reservePendingItemPlacements(&scope, limits, accounting); diagnostic != nil { + return input, []Diagnostic{*diagnostic} + } + } else { + _, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes) + if err != nil { + return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, info.ID, "configuration exceeds the source depth or node limit after destination placement")} + } + accounting.nodes += destinationWrapperNodeCount(destination) + if accounting.nodes > limits.nodes { + return input, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)} + } + contribution.document, contribution.destination = placed, destination.clone() + scope = acquisitionScope{owner: key, destination: destination.clone(), contributions: []acquiredContribution{contribution}} + } + input.document = body + input.composition = &composedDocument{acquisition: []acquisitionScope{scope}, importGraph: newImportInclusionGraph(), inputBytes: accounting.bytes, inputNodes: accounting.nodes} + input.acquisition = cloneAcquisitionScopes(input.composition.acquisition) + return input, nil +} diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index ad59186..ab1681a 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -292,6 +292,7 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s acquired, diagnostics := acquireInput(inputAcquisitionRequest{ input: contribution, role: sourceVersionContribution, resolved: selected[inputIndex], + withoutBase: contribution.mainRelative != nil && contribution.filesystem == nil && selected[inputIndex] == nil, directiveContext: rootDirectiveContextLayered, protectionRule: rule, }, policy, state, tier) for _, source := range acquired.sources {