Handle companion imports without a filesystem base
This commit is contained in:
@@ -129,6 +129,8 @@ selection, and diagnostic tests for verification.
|
|||||||
|
|
||||||
## Stage 4 — Finish companion handling when no base exists
|
## 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
|
Handle the no-base outcome only for an active underlay binding with no
|
||||||
override, usable main context, or fallback. `RequireBase` fails with
|
override, usable main context, or fallback. `RequireBase` fails with
|
||||||
`CodeOptionsInvalid` regardless of import enablement or declaration count.
|
`CodeOptionsInvalid` regardless of import enablement or declaration count.
|
||||||
|
|||||||
@@ -68,3 +68,91 @@ func TestMainRelativeContextSelection(t *testing.T) {
|
|||||||
t.Fatalf("fallback value = %q", got)
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type tierAcquisitionState struct {
|
|||||||
type inputAcquisitionRequest struct {
|
type inputAcquisitionRequest struct {
|
||||||
input loadInput
|
input loadInput
|
||||||
resolved *resolvedFilesystemContext
|
resolved *resolvedFilesystemContext
|
||||||
|
withoutBase bool
|
||||||
role sourceVersionRole
|
role sourceVersionRole
|
||||||
directiveContext rootDirectiveContext
|
directiveContext rootDirectiveContext
|
||||||
protectionRule *VersionRule
|
protectionRule *VersionRule
|
||||||
@@ -34,6 +35,9 @@ type importAcquisitionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
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
|
source := request.input.source
|
||||||
destination := request.input.destination
|
destination := request.input.destination
|
||||||
accounting := state.accounting
|
accounting := state.accounting
|
||||||
@@ -102,7 +106,12 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
|||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
request.input.source = selectedSource
|
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}
|
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||||
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
|
|||||||
86
internal/engine/contextless_imports.go
Normal file
86
internal/engine/contextless_imports.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -292,6 +292,7 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
|||||||
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||||
input: contribution, role: sourceVersionContribution,
|
input: contribution, role: sourceVersionContribution,
|
||||||
resolved: selected[inputIndex],
|
resolved: selected[inputIndex],
|
||||||
|
withoutBase: contribution.mainRelative != nil && contribution.filesystem == nil && selected[inputIndex] == nil,
|
||||||
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
||||||
}, policy, state, tier)
|
}, policy, state, tier)
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
|
|||||||
Reference in New Issue
Block a user