diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index def4031..6c4acbf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -66,6 +66,8 @@ development-guide navigation with the final documentation stage. ## Stage 2 — Implement source-root control extraction and guards +**Status:** Complete. Focused document-root control and extraction tests pass. + **Owners:** [directives.go](../../internal/engine/directives.go), its callers in [acquisition.go](../../internal/engine/acquisition.go) and [selected_overlay.go](../../internal/engine/selected_overlay.go), and focused diff --git a/internal/engine/directive_scope_test.go b/internal/engine/directive_scope_test.go new file mode 100644 index 0000000..b474f13 --- /dev/null +++ b/internal/engine/directive_scope_test.go @@ -0,0 +1,93 @@ +package engine + +import ( + "reflect" + "testing" + + "go.maximumdirect.net/configkit/internal/document" +) + +func TestDocumentRootControlScope(t *testing.T) { + root := parseRootDirectiveDocument(t, `config: + imports: [{path: fragment.yaml}] + sequences: [] +service: + config: {imports: ordinary, sequences: ordinary} +records: + - config: {imports: ordinary} +configkit: {imports: [{path: absent.yaml}]} +_configkit: ordinary +`) + extracted, diagnostics := extractRootDirectives(root, rootDirectiveContextSource) + if len(diagnostics) != 0 || len(extracted.imports) != 1 { + t.Fatalf("extraction = %#v, diagnostics = %#v", extracted, diagnostics) + } + if len(root.Mapping) != 5 || len(extracted.body.Mapping) != 4 { + t.Fatalf("source mutated or payload lost: root=%#v body=%#v", root.Mapping, extracted.body.Mapping) + } + for _, key := range []string{"service", "records", "configkit", "_configkit"} { + got := documentEntryIndex(extracted.body.Mapping, key) + if got < 0 { + t.Errorf("payload missing %q", key) + continue + } + original := documentEntryIndex(root.Mapping, key) + if !reflect.DeepEqual(extracted.body.Mapping[got].Value, root.Mapping[original].Value) { + t.Errorf("payload for %q changed", key) + } + } + for _, guard := range []func(*document.Node) []Diagnostic{ + func(node *document.Node) []Diagnostic { return disabledDirectiveDiagnostics(node, Path{}) }, + disabledRootImportDiagnostics, + func(node *document.Node) []Diagnostic { return overlayDirectiveDiagnostics(node, Path{}, true) }, + } { + if got := guard(extracted.body); len(got) != 0 { + t.Errorf("nested application data rejected: %#v", got) + } + } +} + +func TestDocumentRootControlValidation(t *testing.T) { + for _, test := range []struct { + name string + input string + code Code + path string + }{ + {"not a mapping", "config: wrong\n", CodeImportInvalid, "/config"}, + {"unknown field", "config: {unknown: true}\n", CodeImportInvalid, "/config/unknown"}, + {"bad imports", "config: {imports: wrong}\n", CodeImportInvalid, "/config/imports"}, + } { + t.Run(test.name, func(t *testing.T) { + _, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, test.input), rootDirectiveContextSource) + if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path { + t.Fatalf("diagnostics = %#v", diagnostics) + } + }) + } +} + +func TestDocumentRootControlGuards(t *testing.T) { + imports := parseRootDirectiveDocument(t, "config: {imports: [{path: fragment.yaml}]}\n") + for _, got := range [][]Diagnostic{disabledDirectiveDiagnostics(imports, Path{}), disabledRootImportDiagnostics(imports)} { + if len(got) != 1 || got[0].Code != CodeImportDisabled || got[0].Path.String() != "/config" { + t.Fatalf("disabled diagnostics = %#v", got) + } + } + if got := overlayDirectiveDiagnostics(imports, Path{}, true); len(got) != 1 || got[0].Code != CodeOverlayDirective { + t.Fatalf("overlay imports diagnostics = %#v", got) + } + sequences := parseRootDirectiveDocument(t, "config: {sequences: []}\n") + if got := disabledRootImportDiagnostics(sequences); len(got) != 0 { + t.Fatalf("sequences with imports disabled = %#v", got) + } + if got := disabledDirectiveDiagnostics(sequences, Path{}); len(got) != 1 || got[0].Code != CodeImportDisabled { + t.Fatalf("imports-only guard = %#v", got) + } + if got := overlayDirectiveDiagnostics(sequences, Path{}, true); len(got) != 0 { + t.Fatalf("overlay sequences diagnostics = %#v", got) + } + if _, got := extractRootDirectives(sequences, rootDirectiveContextImportsOnly); len(got) == 0 || got[0].Code != CodeSequenceInvalid { + t.Fatalf("imports-only extraction diagnostics = %#v", got) + } +} diff --git a/internal/engine/directives.go b/internal/engine/directives.go index 52d73df..cdb0a30 100644 --- a/internal/engine/directives.go +++ b/internal/engine/directives.go @@ -7,7 +7,7 @@ import ( "go.maximumdirect.net/configkit/internal/document" ) -const directiveKey = "configkit" +const directiveKey = "config" type rootDirectiveContext uint8 @@ -51,14 +51,8 @@ func extractRootDirectives(root *document.Node, context rootDirectiveContext) (r 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 rootDirectiveExtraction{}, diagnostics - } return rootDirectiveExtraction{body: body}, nil } @@ -105,28 +99,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro return extracted, nil } -func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { - if node.Kind == document.Mapping { - for _, entry := range node.Mapping { - childPath := current.Key(entry.Key) - if entry.Key == directiveKey { - location := publicLocation(entry.KeyLocation) - return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}} - } - if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { - return diagnostics - } - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { - return diagnostics - } - } - } - return nil -} - func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node { return fragmentVersionClaimProjected(root, rule, -1, nil) } @@ -230,15 +202,6 @@ func overlayDirectiveDiagnostics(node *document.Node, current Path, allowSequenc Message: "overlays may contain only root collection sequence declarations", Primary: &location, }} } - if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath, sequencesAllowed); len(diagnostics) != 0 { - return diagnostics - } - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index), sequencesAllowed); len(diagnostics) != 0 { - return diagnostics - } } } return nil @@ -256,11 +219,6 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { Message: "configuration directives require imports to be enabled", Primary: &location, }) } - diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) } } return diagnostics @@ -291,39 +249,6 @@ func disabledRootImportDiagnostics(node *document.Node) []Diagnostic { } } } - for _, entry := range node.Mapping { - if entry.Key == directiveKey { - continue - } - if diagnostic := disabledNestedDirectiveDiagnostics(entry.Value, Path{}.Key(entry.Key)); len(diagnostic) != 0 { - return diagnostic - } - } - return nil -} - -func disabledNestedDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { - if node.Kind == document.Mapping { - for _, entry := range node.Mapping { - childPath := path.Key(entry.Key) - if entry.Key == directiveKey { - location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} - return []Diagnostic{{ - Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, - Message: "configuration directives require imports to be enabled", Primary: &location, - }} - } - if diagnostics := disabledNestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { - return diagnostics - } - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - if diagnostics := disabledNestedDirectiveDiagnostics(child, path.Index(index)); len(diagnostics) != 0 { - return diagnostics - } - } - } return nil } diff --git a/internal/engine/sequence_declaration_test.go b/internal/engine/sequence_declaration_test.go index 5d34596..8daf7b1 100644 --- a/internal/engine/sequence_declaration_test.go +++ b/internal/engine/sequence_declaration_test.go @@ -64,7 +64,7 @@ func TestParseSequenceDeclarationNormalizesEquivalentFieldSets(t *testing.T) { } func TestExtractRootDirectivesCombinesAndRemovesImportsAndSequences(t *testing.T) { - extracted := extractSequenceDeclarationsYAML(t, `configkit: + extracted := extractSequenceDeclarationsYAML(t, `config: imports: [{path: fragments/base.yaml}] sequences: [] value: true @@ -78,15 +78,15 @@ value: true } func TestExtractRootDirectivesAllowsSequencesButRejectsOverlayImports(t *testing.T) { - _, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit: + _, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `config: 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" { + if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayDirective || diagnostics[0].Path.String() != "/config/imports" { t.Fatalf("overlay diagnostics = %#v, want imports-only rejection", diagnostics) } - extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit: + extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `config: sequences: [{destination: [prompts], key: id}] `), rootDirectiveContextOverlay) if len(diagnostics) != 0 || len(extracted.sequences) != 1 {