diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c9ea2da..e987568 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -234,6 +234,9 @@ in Stages 13 and 14 respectively. Validation: focused declaration/parser tests, existing import-spec tests, and the 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 Scope: pure agreement and operation-selection helpers, independent of loading diff --git a/internal/engine/directives.go b/internal/engine/directives.go index 76afbcb..44ae22b 100644 --- a/internal/engine/directives.go +++ b/internal/engine/directives.go @@ -9,29 +9,103 @@ import ( 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) { + 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) - var declarations []importDeclarationSpec directiveIndex := -1 for index, entry := range body.Mapping { if entry.Key != directiveKey { continue } directiveIndex = index - parsed, diagnostics := parseImportSpecificationDirective(entry.Value, Keys(directiveKey)) + extracted, diagnostics := parseRootDirectiveBlock(entry.Value, Keys(directiveKey), context) if len(diagnostics) != 0 { - return nil, nil, diagnostics + return rootDirectiveExtraction{}, diagnostics } - declarations = parsed - break - } - if directiveIndex >= 0 { - body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...) + extracted.body = body + if directiveIndex >= 0 { + extracted.body.Mapping = append(extracted.body.Mapping[:directiveIndex], extracted.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 { - 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 { diff --git a/internal/engine/import_spec.go b/internal/engine/import_spec.go index 608f9e5..9fb9005 100644 --- a/internal/engine/import_spec.go +++ b/internal/engine/import_spec.go @@ -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)) continue } - if entry.Value.Kind != document.Sequence { - diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)) + parsed, issues := parseImportDeclarations(entry.Value, fieldPath) + 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 } - for index, declarationNode := range entry.Value.Sequence { - spec, issues := parseImportDeclarationSpec(declarationNode, fieldPath.Index(index)) - if len(issues) != 0 { - diagnostics = append(diagnostics, issues...) - continue - } - specs = append(specs, spec) - } + specs = append(specs, spec) } return specs, canonicalDiagnostics(diagnostics) } diff --git a/internal/engine/records.go b/internal/engine/records.go index cc70b25..5863668 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -105,6 +105,8 @@ const ( CodeImportRepeated Code = "import.repeated" CodeImportLimitExceeded Code = "import.limit_exceeded" CodeImportVersion Code = "import.version" + CodeSequenceInvalid Code = "sequence.invalid" + CodeSequenceConflict Code = "sequence.conflict" CodeUnderlayVersion Code = "underlay.version" CodeOverlayInvalid Code = "overlay.invalid" diff --git a/internal/engine/sequence_declaration.go b/internal/engine/sequence_declaration.go new file mode 100644 index 0000000..b5a90f2 --- /dev/null +++ b/internal/engine/sequence_declaration.go @@ -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} +} diff --git a/internal/engine/sequence_declaration_test.go b/internal/engine/sequence_declaration_test.go new file mode 100644 index 0000000..7532e1d --- /dev/null +++ b/internal/engine/sequence_declaration_test.go @@ -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 +} diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index 0ae1378..e8aab88 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -231,7 +231,7 @@ func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVer interpreted := interpretVersion(rule, root) evidence.version = interpreted.version - if resolved.present && sourceVersionControlOverlap(root, evidence.resolvedPath) { + if sourceVersionHeaderControlOverlap(root, resolved) { evidence.redactText() result.payload = nil result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)} @@ -273,6 +273,10 @@ func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVer return result } +func sourceVersionHeaderControlOverlap(root *document.Node, resolved sourceVersionPath) bool { + return resolved.present && sourceVersionControlOverlap(root, resolved.resolvedPath) +} + func sourceVersionOptionsDiagnostic(message string) Diagnostic { return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message} } diff --git a/records.go b/records.go index 73d1744..faaa056 100644 --- a/records.go +++ b/records.go @@ -106,6 +106,8 @@ const ( CodeImportRepeated Code = "import.repeated" CodeImportLimitExceeded Code = "import.limit_exceeded" CodeImportVersion Code = "import.version" + CodeSequenceInvalid Code = "sequence.invalid" + CodeSequenceConflict Code = "sequence.conflict" CodeUnderlayVersion Code = "underlay.version" CodeOverlayInvalid Code = "overlay.invalid"