Parse collection sequence declarations
This commit is contained in:
@@ -234,6 +234,9 @@ in Stages 13 and 14 respectively.
|
|||||||
Validation: focused declaration/parser tests, existing import-spec tests, and the
|
Validation: focused declaration/parser tests, existing import-spec tests, and the
|
||||||
everyday check. Existing import-only behavior remains intact during preparation.
|
everyday check. Existing import-only behavior remains intact during preparation.
|
||||||
|
|
||||||
|
**Completion:** Implemented and verified with focused declaration/parser and
|
||||||
|
import-spec tests plus `./scripts/check.sh`.
|
||||||
|
|
||||||
## Stage 4 — Reconcile Declarations And Select Collection Operations
|
## Stage 4 — Reconcile Declarations And Select Collection Operations
|
||||||
|
|
||||||
Scope: pure agreement and operation-selection helpers, independent of loading
|
Scope: pure agreement and operation-selection helpers, independent of loading
|
||||||
|
|||||||
@@ -9,29 +9,103 @@ import (
|
|||||||
|
|
||||||
const directiveKey = "configkit"
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
|
type rootDirectiveContext uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
rootDirectiveContextImportsOnly rootDirectiveContext = iota + 1
|
||||||
|
rootDirectiveContextSource
|
||||||
|
rootDirectiveContextOverlay
|
||||||
|
)
|
||||||
|
|
||||||
|
type rootDirectiveExtraction struct {
|
||||||
|
body *document.Node
|
||||||
|
imports []importDeclarationSpec
|
||||||
|
sequences []sequenceDeclarationSpec
|
||||||
|
importsPresent bool
|
||||||
|
sequencesPresent bool
|
||||||
|
}
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||||
|
extracted, diagnostics := extractRootDirectives(root, rootDirectiveContextImportsOnly)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, nil, diagnostics
|
||||||
|
}
|
||||||
|
return extracted.body, extracted.imports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRootDirectives(root *document.Node, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||||
|
if root == nil {
|
||||||
|
return rootDirectiveExtraction{}, nil
|
||||||
|
}
|
||||||
body := document.Clone(root)
|
body := document.Clone(root)
|
||||||
var declarations []importDeclarationSpec
|
|
||||||
directiveIndex := -1
|
directiveIndex := -1
|
||||||
for index, entry := range body.Mapping {
|
for index, entry := range body.Mapping {
|
||||||
if entry.Key != directiveKey {
|
if entry.Key != directiveKey {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
directiveIndex = index
|
directiveIndex = index
|
||||||
parsed, diagnostics := parseImportSpecificationDirective(entry.Value, Keys(directiveKey))
|
extracted, diagnostics := parseRootDirectiveBlock(entry.Value, Keys(directiveKey), context)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
}
|
}
|
||||||
declarations = parsed
|
extracted.body = body
|
||||||
break
|
if directiveIndex >= 0 {
|
||||||
}
|
extracted.body.Mapping = append(extracted.body.Mapping[:directiveIndex], extracted.body.Mapping[directiveIndex+1:]...)
|
||||||
if directiveIndex >= 0 {
|
}
|
||||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
if diagnostics := nestedDirectiveDiagnostics(extracted.body, Path{}); len(diagnostics) != 0 {
|
||||||
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
}
|
}
|
||||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
}
|
}
|
||||||
return body, declarations, nil
|
return rootDirectiveExtraction{body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRootDirectiveBlock(node *document.Node, directivePath Path, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||||
|
if node == nil || node.Kind != document.Mapping {
|
||||||
|
return rootDirectiveExtraction{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||||
|
}
|
||||||
|
var extracted rootDirectiveExtraction
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
fieldPath := directivePath.Key(entry.Key)
|
||||||
|
switch entry.Key {
|
||||||
|
case "imports":
|
||||||
|
extracted.importsPresent = true
|
||||||
|
if context == rootDirectiveContextOverlay {
|
||||||
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if context != rootDirectiveContextImportsOnly && context != rootDirectiveContextSource {
|
||||||
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "directive extraction context is invalid", entry.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
||||||
|
extracted.imports = append(extracted.imports, parsed...)
|
||||||
|
diagnostics = append(diagnostics, issues...)
|
||||||
|
case "sequences":
|
||||||
|
extracted.sequencesPresent = true
|
||||||
|
if context == rootDirectiveContextImportsOnly {
|
||||||
|
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if context != rootDirectiveContextSource && context != rootDirectiveContextOverlay {
|
||||||
|
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "directive extraction context is invalid", entry.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, issues := parseSequenceDeclarationDirective(entry.Value, fieldPath)
|
||||||
|
extracted.sequences = append(extracted.sequences, parsed...)
|
||||||
|
diagnostics = append(diagnostics, issues...)
|
||||||
|
default:
|
||||||
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return rootDirectiveExtraction{}, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
|
|||||||
@@ -193,18 +193,26 @@ func parseImportSpecificationDirective(node *document.Node, directivePath Path)
|
|||||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if entry.Value.Kind != document.Sequence {
|
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
||||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value))
|
specs = append(specs, parsed...)
|
||||||
|
diagnostics = append(diagnostics, issues...)
|
||||||
|
}
|
||||||
|
return specs, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
|
||||||
|
if node == nil || node.Kind != document.Sequence {
|
||||||
|
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
|
||||||
|
}
|
||||||
|
var specs []importDeclarationSpec
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for index, declarationNode := range node.Sequence {
|
||||||
|
spec, issues := parseImportDeclarationSpec(declarationNode, declarationPath.Index(index))
|
||||||
|
if len(issues) != 0 {
|
||||||
|
diagnostics = append(diagnostics, issues...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for index, declarationNode := range entry.Value.Sequence {
|
specs = append(specs, spec)
|
||||||
spec, issues := parseImportDeclarationSpec(declarationNode, fieldPath.Index(index))
|
|
||||||
if len(issues) != 0 {
|
|
||||||
diagnostics = append(diagnostics, issues...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
specs = append(specs, spec)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return specs, canonicalDiagnostics(diagnostics)
|
return specs, canonicalDiagnostics(diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ const (
|
|||||||
CodeImportRepeated Code = "import.repeated"
|
CodeImportRepeated Code = "import.repeated"
|
||||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
CodeImportVersion Code = "import.version"
|
CodeImportVersion Code = "import.version"
|
||||||
|
CodeSequenceInvalid Code = "sequence.invalid"
|
||||||
|
CodeSequenceConflict Code = "sequence.conflict"
|
||||||
CodeUnderlayVersion Code = "underlay.version"
|
CodeUnderlayVersion Code = "underlay.version"
|
||||||
|
|
||||||
CodeOverlayInvalid Code = "overlay.invalid"
|
CodeOverlayInvalid Code = "overlay.invalid"
|
||||||
|
|||||||
158
internal/engine/sequence_declaration.go
Normal file
158
internal/engine/sequence_declaration.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
type sequenceMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sequenceModeReplace sequenceMode = iota + 1
|
||||||
|
sequenceModeAppend
|
||||||
|
sequenceModeUpsert
|
||||||
|
)
|
||||||
|
|
||||||
|
type sequenceDeclarationSpec struct {
|
||||||
|
path Path
|
||||||
|
location Location
|
||||||
|
destination Path
|
||||||
|
destinationLocation Location
|
||||||
|
identityFields []string
|
||||||
|
identityPresent bool
|
||||||
|
identityLocation *Location
|
||||||
|
mode sequenceMode
|
||||||
|
modePresent bool
|
||||||
|
modeLocation *Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSequenceDeclarationDirective(node *document.Node, directivePath Path) ([]sequenceDeclarationSpec, []Diagnostic) {
|
||||||
|
if node == nil || node.Kind != document.Sequence {
|
||||||
|
return nil, []Diagnostic{sequenceDeclarationDiagnostic(directivePath, "sequences must be a sequence", node)}
|
||||||
|
}
|
||||||
|
declarations := make([]sequenceDeclarationSpec, 0, len(node.Sequence))
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for index, declarationNode := range node.Sequence {
|
||||||
|
declaration, issues := parseSequenceDeclarationSpec(declarationNode, directivePath.Index(index))
|
||||||
|
if len(issues) != 0 {
|
||||||
|
diagnostics = append(diagnostics, issues...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declarations = append(declarations, declaration)
|
||||||
|
}
|
||||||
|
return declarations, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSequenceDeclarationSpec(node *document.Node, declarationPath Path) (sequenceDeclarationSpec, []Diagnostic) {
|
||||||
|
if node == nil || node.Kind != document.Mapping {
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(declarationPath, "sequence declaration must be a mapping", node)}
|
||||||
|
}
|
||||||
|
declaration := sequenceDeclarationSpec{
|
||||||
|
path: declarationPath.clone(),
|
||||||
|
location: publicLocation(node.Location),
|
||||||
|
}
|
||||||
|
destinationPresent := false
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
fieldPath := declarationPath.Key(entry.Key)
|
||||||
|
switch entry.Key {
|
||||||
|
case "destination":
|
||||||
|
destinationPresent = true
|
||||||
|
destination, diagnostic := parseImportDestination(entry.Value, fieldPath)
|
||||||
|
if diagnostic != nil {
|
||||||
|
diagnostic.Code = CodeSequenceInvalid
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
declaration.destination = destination
|
||||||
|
declaration.destinationLocation = publicLocation(entry.Value.Location)
|
||||||
|
case "key":
|
||||||
|
fields, locations, errIndex, valid := parseSequenceIdentityFieldSet(entry.Value)
|
||||||
|
if !valid {
|
||||||
|
badPath := fieldPath
|
||||||
|
badNode := entry.Value
|
||||||
|
message := "key must be a string or nonempty list of strings"
|
||||||
|
if errIndex >= 0 && entry.Value != nil {
|
||||||
|
badPath = fieldPath.Index(errIndex)
|
||||||
|
if errIndex < len(entry.Value.Sequence) {
|
||||||
|
badNode = entry.Value.Sequence[errIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(badPath, message, badNode)}
|
||||||
|
}
|
||||||
|
declaration.identityFields = fields
|
||||||
|
declaration.identityPresent = true
|
||||||
|
location := publicLocation(entry.Value.Location)
|
||||||
|
if len(locations) != 0 {
|
||||||
|
location = publicLocation(locations[0])
|
||||||
|
}
|
||||||
|
declaration.identityLocation = &location
|
||||||
|
case "mode":
|
||||||
|
if entry.Value == nil || entry.Value.Kind != document.String {
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(fieldPath, "mode must be replace, append, or upsert", entry.Value)}
|
||||||
|
}
|
||||||
|
mode, ok := parseSequenceMode(entry.Value.Text)
|
||||||
|
if !ok {
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(fieldPath, "mode must be replace, append, or upsert", entry.Value)}
|
||||||
|
}
|
||||||
|
declaration.mode = mode
|
||||||
|
declaration.modePresent = true
|
||||||
|
location := publicLocation(entry.Value.Location)
|
||||||
|
declaration.modeLocation = &location
|
||||||
|
default:
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(fieldPath, "sequence declaration field is not recognized", entry.Value)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !destinationPresent {
|
||||||
|
return sequenceDeclarationSpec{}, []Diagnostic{sequenceDeclarationDiagnostic(declarationPath.Key("destination"), "sequence declaration requires a destination", node)}
|
||||||
|
}
|
||||||
|
return declaration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSequenceIdentityFieldSet(node *document.Node) ([]string, []document.Location, int, bool) {
|
||||||
|
if node == nil {
|
||||||
|
return nil, nil, -1, false
|
||||||
|
}
|
||||||
|
var fields []string
|
||||||
|
var locations []document.Location
|
||||||
|
switch node.Kind {
|
||||||
|
case document.String:
|
||||||
|
fields = []string{node.Text}
|
||||||
|
locations = []document.Location{node.Location}
|
||||||
|
case document.Sequence:
|
||||||
|
if len(node.Sequence) == 0 {
|
||||||
|
return nil, nil, -1, false
|
||||||
|
}
|
||||||
|
for index, member := range node.Sequence {
|
||||||
|
if member == nil || member.Kind != document.String {
|
||||||
|
return nil, nil, index, false
|
||||||
|
}
|
||||||
|
fields = append(fields, member.Text)
|
||||||
|
locations = append(locations, member.Location)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, nil, -1, false
|
||||||
|
}
|
||||||
|
normalized, err := normalizeSequenceIdentityFields(fields)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, -1, false
|
||||||
|
}
|
||||||
|
return normalized, locations, -1, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSequenceMode(value string) (sequenceMode, bool) {
|
||||||
|
switch value {
|
||||||
|
case "replace":
|
||||||
|
return sequenceModeReplace, true
|
||||||
|
case "append":
|
||||||
|
return sequenceModeAppend, true
|
||||||
|
case "upsert":
|
||||||
|
return sequenceModeUpsert, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceDeclarationDiagnostic(path Path, message string, node *document.Node) Diagnostic {
|
||||||
|
var primary *Location
|
||||||
|
if node != nil {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
primary = &location
|
||||||
|
}
|
||||||
|
return Diagnostic{Code: CodeSequenceInvalid, Severity: SeverityError, Path: path, Message: message, Primary: primary}
|
||||||
|
}
|
||||||
172
internal/engine/sequence_declaration_test.go
Normal file
172
internal/engine/sequence_declaration_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSequenceDeclarationPresenceAndRelativeDestination(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
declaration string
|
||||||
|
wantIdentity bool
|
||||||
|
wantMode bool
|
||||||
|
wantIdentityList []string
|
||||||
|
wantModeValue sequenceMode
|
||||||
|
}{
|
||||||
|
{name: "destination only is a no-op", declaration: "destination: [prompts]"},
|
||||||
|
{name: "key only", declaration: "destination: [prompts]\n key: id", wantIdentity: true, wantIdentityList: []string{"id"}},
|
||||||
|
{name: "mode only", declaration: "destination: [prompts]\n mode: append", wantMode: true, wantModeValue: sequenceModeAppend},
|
||||||
|
{name: "combined", declaration: "destination: [prompts]\n key: [namespace, id, id]\n mode: upsert", wantIdentity: true, wantMode: true, wantIdentityList: []string{"id", "namespace"}, wantModeValue: sequenceModeUpsert},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
extracted := extractSequenceDeclarationsYAML(t, "configkit:\n sequences:\n - "+test.declaration+"\n", rootDirectiveContextSource)
|
||||||
|
if !extracted.sequencesPresent || len(extracted.sequences) != 1 {
|
||||||
|
t.Fatalf("extraction = %#v", extracted)
|
||||||
|
}
|
||||||
|
declaration := extracted.sequences[0]
|
||||||
|
if declaration.destination.String() != "/prompts" || declaration.path.String() != "/configkit/sequences/0" {
|
||||||
|
t.Fatalf("relative destination or declaration path changed: %#v", declaration)
|
||||||
|
}
|
||||||
|
if declaration.location.SourceID != "sequence-declarations" || declaration.location.Line == 0 || declaration.destinationLocation.SourceID != "sequence-declarations" || declaration.destinationLocation.Line == 0 {
|
||||||
|
t.Fatalf("declaration source locations were not retained: %#v", declaration)
|
||||||
|
}
|
||||||
|
if declaration.identityPresent != test.wantIdentity || declaration.modePresent != test.wantMode {
|
||||||
|
t.Fatalf("presence = key:%t mode:%t, want key:%t mode:%t", declaration.identityPresent, declaration.modePresent, test.wantIdentity, test.wantMode)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(declaration.identityFields, test.wantIdentityList) || declaration.mode != test.wantModeValue {
|
||||||
|
t.Fatalf("parsed fields/mode = %#v/%d", declaration.identityFields, declaration.mode)
|
||||||
|
}
|
||||||
|
if declaration.identityPresent && (declaration.identityLocation == nil || declaration.identityLocation.SourceID != "sequence-declarations" || declaration.identityLocation.Line == 0) {
|
||||||
|
t.Fatalf("identity location missing: %#v", declaration)
|
||||||
|
}
|
||||||
|
if declaration.modePresent && (declaration.modeLocation == nil || declaration.modeLocation.SourceID != "sequence-declarations" || declaration.modeLocation.Line == 0) {
|
||||||
|
t.Fatalf("mode location missing: %#v", declaration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSequenceDeclarationNormalizesEquivalentFieldSets(t *testing.T) {
|
||||||
|
extracted := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [namespace, id, id]
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [id, namespace]
|
||||||
|
`, rootDirectiveContextSource)
|
||||||
|
if len(extracted.sequences) != 2 || !reflect.DeepEqual(extracted.sequences[0].identityFields, extracted.sequences[1].identityFields) {
|
||||||
|
t.Fatalf("equivalent declarations were not normalized: %#v", extracted.sequences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRootDirectivesCombinesImportsAndSequencesWithPresence(t *testing.T) {
|
||||||
|
extracted := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
imports: [{path: fragments/base.yaml}]
|
||||||
|
sequences: []
|
||||||
|
value: true
|
||||||
|
`, rootDirectiveContextSource)
|
||||||
|
if !extracted.importsPresent || len(extracted.imports) != 1 || !extracted.sequencesPresent || len(extracted.sequences) != 0 {
|
||||||
|
t.Fatalf("mixed extraction = %#v", extracted)
|
||||||
|
}
|
||||||
|
if extracted.body == nil || extracted.body.Kind != document.Mapping || len(extracted.body.Mapping) != 1 || extracted.body.Mapping[0].Key != "value" {
|
||||||
|
t.Fatalf("directive controls were not removed from payload: %#v", extracted.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRootDirectivesAllowsSequencesButRejectsOverlayImports(t *testing.T) {
|
||||||
|
_, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit:
|
||||||
|
imports: [{path: fragments/base.yaml}]
|
||||||
|
sequences: [{destination: [prompts], key: id}]
|
||||||
|
`), rootDirectiveContextOverlay)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayDirective || diagnostics[0].Path.String() != "/configkit/imports" {
|
||||||
|
t.Fatalf("overlay diagnostics = %#v, want imports-only rejection", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit:
|
||||||
|
sequences: [{destination: [prompts], key: id}]
|
||||||
|
`), rootDirectiveContextOverlay)
|
||||||
|
if len(diagnostics) != 0 || !extracted.sequencesPresent || len(extracted.sequences) != 1 {
|
||||||
|
t.Fatalf("overlay sequence extraction = %#v, %#v", extracted, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSequenceDeclarationRejectsMalformedForms(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "sequences wrong type", body: "configkit: {sequences: nope}\n", path: "/configkit/sequences"},
|
||||||
|
{name: "declaration wrong type", body: "configkit: {sequences: [nope]}\n", path: "/configkit/sequences/0"},
|
||||||
|
{name: "missing destination", body: "configkit: {sequences: [{key: id}]}\n", path: "/configkit/sequences/0/destination"},
|
||||||
|
{name: "destination wrong type", body: "configkit: {sequences: [{destination: nope}]}\n", path: "/configkit/sequences/0/destination"},
|
||||||
|
{name: "reserved destination", body: "configkit: {sequences: [{destination: [configkit]}]}\n", path: "/configkit/sequences/0/destination/0"},
|
||||||
|
{name: "key wrong type", body: "configkit: {sequences: [{destination: [prompts], key: true}]}\n", path: "/configkit/sequences/0/key"},
|
||||||
|
{name: "empty key set", body: "configkit: {sequences: [{destination: [prompts], key: []}]}\n", path: "/configkit/sequences/0/key"},
|
||||||
|
{name: "key member wrong type", body: "configkit: {sequences: [{destination: [prompts], key: [id, null]}]}\n", path: "/configkit/sequences/0/key/1"},
|
||||||
|
{name: "mode wrong type", body: "configkit: {sequences: [{destination: [prompts], mode: true}]}\n", path: "/configkit/sequences/0/mode"},
|
||||||
|
{name: "unknown mode", body: "configkit: {sequences: [{destination: [prompts], mode: merge}]}\n", path: "/configkit/sequences/0/mode"},
|
||||||
|
{name: "unknown declaration field", body: "configkit: {sequences: [{destination: [prompts], append: true}]}\n", path: "/configkit/sequences/0/append"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, test.body), rootDirectiveContextSource)
|
||||||
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != test.path {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %s at %s", diagnostics, CodeSequenceInvalid, test.path)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.Line == 0 {
|
||||||
|
t.Fatalf("diagnostic has no source location: %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceDeclarationsDoNotTriggerVersionOverlapWhenHeaderIsAbsent(t *testing.T) {
|
||||||
|
root := parseRootDirectiveDocument(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
version: v1
|
||||||
|
`)
|
||||||
|
presentPath, err := ParsePath("/configkit/sequences/0/destination")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
present := resolveSourceVersionPath(root, presentPath)
|
||||||
|
if !sourceVersionHeaderControlOverlap(root, present) {
|
||||||
|
t.Fatal("present version header beneath a sequence control did not overlap")
|
||||||
|
}
|
||||||
|
absentPath, err := ParsePath("/configkit/sequences/1/destination")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
absent := resolveSourceVersionPath(root, absentPath)
|
||||||
|
if absent.present {
|
||||||
|
t.Fatal("missing version header was reported present")
|
||||||
|
}
|
||||||
|
if sourceVersionHeaderControlOverlap(root, absent) {
|
||||||
|
t.Fatal("absent version header beneath a sequence control was treated as overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSequenceDeclarationsYAML(t *testing.T, input string, context rootDirectiveContext) rootDirectiveExtraction {
|
||||||
|
t.Helper()
|
||||||
|
extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, input), context)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("root directive diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRootDirectiveDocument(t *testing.T, input string) *document.Node {
|
||||||
|
t.Helper()
|
||||||
|
parsed, diagnostics := parseSource(Bytes("sequence-declarations.yml", []byte(input)), "sequence-declarations", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("source parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return parsed.document
|
||||||
|
}
|
||||||
@@ -231,7 +231,7 @@ func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVer
|
|||||||
|
|
||||||
interpreted := interpretVersion(rule, root)
|
interpreted := interpretVersion(rule, root)
|
||||||
evidence.version = interpreted.version
|
evidence.version = interpreted.version
|
||||||
if resolved.present && sourceVersionControlOverlap(root, evidence.resolvedPath) {
|
if sourceVersionHeaderControlOverlap(root, resolved) {
|
||||||
evidence.redactText()
|
evidence.redactText()
|
||||||
result.payload = nil
|
result.payload = nil
|
||||||
result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)}
|
result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)}
|
||||||
@@ -273,6 +273,10 @@ func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVer
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sourceVersionHeaderControlOverlap(root *document.Node, resolved sourceVersionPath) bool {
|
||||||
|
return resolved.present && sourceVersionControlOverlap(root, resolved.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
func sourceVersionOptionsDiagnostic(message string) Diagnostic {
|
func sourceVersionOptionsDiagnostic(message string) Diagnostic {
|
||||||
return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message}
|
return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ const (
|
|||||||
CodeImportRepeated Code = "import.repeated"
|
CodeImportRepeated Code = "import.repeated"
|
||||||
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
CodeImportVersion Code = "import.version"
|
CodeImportVersion Code = "import.version"
|
||||||
|
CodeSequenceInvalid Code = "sequence.invalid"
|
||||||
|
CodeSequenceConflict Code = "sequence.conflict"
|
||||||
CodeUnderlayVersion Code = "underlay.version"
|
CodeUnderlayVersion Code = "underlay.version"
|
||||||
|
|
||||||
CodeOverlayInvalid Code = "overlay.invalid"
|
CodeOverlayInvalid Code = "overlay.invalid"
|
||||||
|
|||||||
Reference in New Issue
Block a user