diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c62e842..52290ff 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -461,6 +461,28 @@ checker and full race suite. ## Stage 9: Validate cross-feature consistency and scaling +**Completed 2026-09-29.** A compact public matrix now checks equivalent host, +native, byte, reader, and mapping inputs; tier-local repetition versus cross-tier +reuse; reader consumption; and safe diagnostics/effective/history output after +secret replacement. Engine tests verify exact occurrence accounting across +imports, shared glob/source limits, and the mixed-source CLI ceiling. Rendering +now indexes source labels once per explanation instead of scanning all records +for every location. Loading and passive context ownership were reviewed against +ADR 0008; no handles or background work are retained by queries. + +On Linux amd64 with `-benchmem` and 100 ms samples, the 512-entry assembly-only +composition measured about 3.5 ms/op (the earlier post-index baseline was about +3.3 ms), and recursive overlay about 2.2 ms/op (about 12 ms at the first-stage +handoff before subsequent provenance work). The 128-application owned assembly +remained about 0.30 ms/op versus 6.35 ms/op for copy-preserving application. +End-to-end layered measurements were about 7.15 ms/op for 512 broad catalog +entries, 2.59 ms/op for 128 small tiers, 3.27 ms/op for 128 alternating subtree +replacements, 1.86 ms/op for 64 imported native defaults, and 1.36 ms/op for +64 retained-history applications. Allocations grew roughly with these fixture +sizes. I/O, sorting, decoding, callbacks, and retained output contribute to +full-path cost; these observations are not timing gates or an unconditional +complexity guarantee. The everyday checker and full race suite passed. + 1. Consolidate a small semantic equivalence matrix across host, native, bytes, readers, and mappings, using equivalent logical inputs and contexts. Cover strict conflict versus intentional precedence and version-path preservation; diff --git a/facade_equivalence_test.go b/facade_equivalence_test.go new file mode 100644 index 0000000..7c83578 --- /dev/null +++ b/facade_equivalence_test.go @@ -0,0 +1,164 @@ +package configkit_test + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "testing/fstest" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type equivalentConfig struct { + Version uint64 `yaml:"version"` + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Count int `yaml:"count"` + Items []string `yaml:"items"` + Optional *string `yaml:"optional"` +} + +func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) { + const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n" + hostPath := filepath.Join(t.TempDir(), "main.yml") + if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + namespace := configkit.NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte(yaml)}}) + context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace} + for _, test := range []struct { + name string + source configkit.Source + context *configkit.FilesystemContext + kind configkit.SourceKind + }{ + {"host", configkit.File(hostPath), nil, configkit.SourceKindFile}, + {"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem}, + {"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes}, + {"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader}, + {"mapping", configkit.Mapping("programmatic", map[string]any{ + "version": uint64(1), "name": "retained", "enabled": false, "count": 0, + "items": []any{}, "optional": nil, + }), context, configkit.SourceKindMapping}, + } { + t.Run(test.name, func(t *testing.T) { + checks := 0 + result, err := configkit.LoadLayered(configkit.Input{Source: test.source, Filesystem: test.context}, configkit.LayeredOptions[equivalentConfig]{ + Definition: configkit.Definition[equivalentConfig]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error { + checks++ + if !version.Present || version.Text != "1" { + t.Errorf("version = %#v", version) + } + return nil + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + want := equivalentConfig{Version: 1, Name: "retained", Items: []string{}} + if !reflect.DeepEqual(result.Config, want) || checks != 1 { + t.Fatalf("config = %#v, checks %d", result.Config, checks) + } + for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} { + info, ok := result.Metadata().Lookup(configkit.Keys(field)) + if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") { + t.Errorf("%s input info = %#v, %v", field, info, ok) + } + } + sources := result.Sources() + if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].ID != "source-1" || sources[0].UnderlayIndex != 0 { + t.Fatalf("sources = %#v", sources) + } + resource, ok := result.SourceContext("source-1") + if !ok || resource.BaseDir != "." || resource.FS == nil { + t.Fatalf("source context = %#v, %v", resource, ok) + } + }) + } +} + +func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) { + namespace := configkit.NewFilesystem("bundle", fstest.MapFS{ + "left.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")}, + "right.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")}, + "shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")}, + }) + type config struct { + Name string `yaml:"name"` + } + left := configkit.Input{Source: namespace.File("left.yml"), Imports: true} + right := configkit.Input{Source: namespace.File("right.yml"), Imports: true} + _, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left, right}}}, + }) + if got := layeredErrorCode(t, err); got != configkit.CodeImportRepeated { + t.Fatalf("same-tier repeated import = %s", got) + } + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left}}, {Inputs: []configkit.Input{right}}}, + }) + if err != nil || result.Config.Name != "retained" || len(result.Sources()) != 4 { + t.Fatalf("cross-tier repeated import = %#v, %v", result, err) + } + reader := configkit.Reader("single-use", strings.NewReader("name: retained\n")) + _, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: reader}}}, {Inputs: []configkit.Input{{Source: reader}}}}, + }) + if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed { + t.Fatalf("reader re-consumption = %s", got) + } +} + +func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) { + type config struct { + Secret string `yaml:"secret"` + } + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}}, + Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}, + }) + if err != nil || result.Config.Secret != "new-secret-canary" { + t.Fatalf("secret load = %#v, %v", result, err) + } + var effective, history bytes.Buffer + if err := result.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + if err := result.WriteExplanation(&history, configkit.Keys("secret")); err != nil { + t.Fatal(err) + } + if strings.Contains(effective.String()+history.String(), "old-secret-canary") || strings.Contains(effective.String()+history.String(), "new-secret-canary") { + t.Fatalf("secret output leaked: %q %q", effective.String(), history.String()) + } + _, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}}, + Definition: configkit.Definition[config]{Validate: func(config *config, validation *configkit.Validation) { + validation.Error(configkit.Keys("secret"), "invalid secret") + }}, + }) + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("validation error = %#v", err) + } + var diagnostics bytes.Buffer + if err := configkit.WriteDiagnostics(&diagnostics, loadError.Diagnostics()); err != nil { + t.Fatal(err) + } + if strings.Contains(diagnostics.String(), "secret-canary") { + t.Fatalf("diagnostic leaked secret: %q", diagnostics.String()) + } +} + +func layeredErrorCode(t *testing.T, err error) configkit.Code { + t.Helper() + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 { + t.Fatalf("load error = %#v", err) + } + return loadError.Diagnostics()[0].Code +} diff --git a/internal/engine/explain_render.go b/internal/engine/explain_render.go index 4724aae..52f231f 100644 --- a/internal/engine/explain_render.go +++ b/internal/engine/explain_render.go @@ -23,10 +23,14 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { return errExplanationUnknownPath } var rendered strings.Builder + labels := make(map[string]string, len(r.sources)) + for _, source := range r.sources { + labels[source.ID] = source.Label + } rendered.WriteString("path: ") rendered.WriteString(strconv.Quote(explanation.Path.String())) rendered.WriteByte('\n') - writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources) + writeExplanationOrigin(&rendered, "effective source", explanation.Origin, labels) writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0) if explanation.SecretReference != nil { rendered.WriteString("secret reference: ") @@ -43,7 +47,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { } else { rendered.WriteString("canonical typed\n") } - writeExplanationOrigin(&rendered, " source", change.Origin, r.sources) + writeExplanationOrigin(&rendered, " source", change.Origin, labels) writeExplanationSnapshot(&rendered, " before", change.Before, 4) writeExplanationSnapshot(&rendered, " after", change.After, 4) for _, reason := range change.Reasons { @@ -65,7 +69,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { return err } -func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) { +func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, labels map[string]string) { output.WriteString(label) output.WriteString(": layer=") output.WriteString(strconv.Quote(string(origin.Layer))) @@ -77,7 +81,7 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin for _, location := range origin.Locations { output.WriteString(" location: source=") output.WriteString(strconv.Quote(location.SourceID)) - if label := sourceLabel(sources, location.SourceID); label != "" { + if label := labels[location.SourceID]; label != "" { output.WriteString(" label=") output.WriteString(strconv.Quote(label)) } @@ -89,15 +93,6 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin } } -func sourceLabel(sources []SourceInfo, sourceID string) string { - for _, source := range sources { - if source.ID == sourceID { - return source.Label - } - } - return "" -} - func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) { if !snapshot.Present { output.WriteString(label) diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 17bd82a..d0703d6 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -126,6 +126,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a } func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) { + initialBytes, initialNodes := accounting.bytes, accounting.nodes visited := make(map[resourceIdentity]importRoute) hostToken := &namespaceToken{} var documents []*document.Node @@ -168,6 +169,8 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account if len(diagnostics) != 0 { return nil, sources, contexts, diagnostics } + composition.inputBytes = accounting.bytes - initialBytes + composition.inputNodes = accounting.nodes - initialNodes for pointer, origin := range composition.origins { origin.Layer = LayerUnderlay composition.origins[pointer] = origin diff --git a/internal/engine/load_request_test.go b/internal/engine/load_request_test.go index ae47a81..01ae505 100644 --- a/internal/engine/load_request_test.go +++ b/internal/engine/load_request_test.go @@ -208,3 +208,73 @@ func groupedErrorCode(t *testing.T, err error) Code { } return loadError.Diagnostics()[0].Code } + +func TestGroupedAccountingChargesMixedOccurrencesOnce(t *testing.T) { + const ( + one = "configkit: {imports: [{path: child-one.yml}]}\nleft: first\n" + child = "extra: imported\n" + two = "configkit: {imports: [{path: child-two.yml}]}\nright: second\n" + ) + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "one.yml": &fstest.MapFile{Data: []byte(one)}, + "child-one.yml": &fstest.MapFile{Data: []byte(child)}, + "two.yml": &fstest.MapFile{Data: []byte(two)}, + "child-two.yml": &fstest.MapFile{Data: []byte(child)}, + }) + accounting := &importAccounting{} + first, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("one.yml"), imports: true}}}, 1, nil, accounting) + if len(diagnostics) != 0 || first == nil || len(sources) != 2 || accounting.sources != 2 || accounting.bytes != int64(len(one)+len(child)) { + t.Fatalf("first acquisition = %#v, %#v, %#v, accounting %#v", first, sources, diagnostics, accounting) + } + second, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("two.yml"), imports: true}}}, 2, nil, accounting) + if len(diagnostics) != 0 || second == nil || len(sources) != 2 || sources[0].ID != "source-3" || sources[1].ID != "source-4" || accounting.sources != 4 || accounting.bytes != int64(len(one)+len(two)+2*len(child)) { + t.Fatalf("second acquisition = %#v, %#v, %#v, accounting %#v", second, sources, diagnostics, accounting) + } + var wantNodes int + for _, text := range []string{one, child, two, child} { + parsed, diagnostics := parseSource(Bytes("fixture", []byte(text)), "source-1", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + wantNodes += countInputNodes(parsed.document) + } + if accounting.nodes != wantNodes { + t.Fatalf("charged nodes = %d, want %d", accounting.nodes, wantNodes) + } + if first.inputBytes != int64(len(one)+len(child)) || second.inputBytes != int64(len(two)+len(child)) { + t.Fatalf("composition costs = %d, %d, accounting %d", first.inputBytes, second.inputBytes, accounting.bytes) + } +} + +func TestGroupedGlobEntryBudgetIsShared(t *testing.T) { + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\n")}, + "parts/a.yml": &fstest.MapFile{Data: []byte("left: one\n")}, + "parts/b.yml": &fstest.MapFile{Data: []byte("right: two\n")}, + }) + accounting := &importAccounting{entries: maxGlobEntries - 1} + _, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}, 1, nil, accounting) + if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded || accounting.entries <= maxGlobEntries { + t.Fatalf("shared glob budget = %#v, accounting %#v", diagnostics, accounting) + } +} + +func TestGroupedMixedSourcesShareCLIRecordLimit(t *testing.T) { + filesystem := NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("value: base\n")}}) + inputs := make([]Input, maxImportSources-1) + inputs[0] = Input{Source: Bytes("empty-bytes", []byte("{}\n"))} + for index := 1; index < len(inputs); index++ { + inputs[index] = Input{Source: Mapping("empty-map", map[string]any{})} + } + type config struct { + Value string `yaml:"value"` + } + _, err := LoadLayered(Input{Source: filesystem.File("main.yml")}, testSecretBinding(), LayeredOptions[config]{ + Underlays: []Underlay{{Inputs: inputs}}, + Overrides: []Override{{Path: Keys("value"), Value: "cli", Flag: "--value"}}, + }) + loadError, ok := err.(*LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeOverrideLimitExceeded || len(loadError.Sources()) != maxImportSources { + t.Fatalf("mixed source/CLI limit = %#v", err) + } +} diff --git a/internal/engine/underlay_bench_test.go b/internal/engine/underlay_bench_test.go new file mode 100644 index 0000000..d1a17d1 --- /dev/null +++ b/internal/engine/underlay_bench_test.go @@ -0,0 +1,156 @@ +package engine + +import ( + "io" + "strconv" + "testing" + "testing/fstest" +) + +type benchmarkCatalogConfig struct { + Catalog map[string]string `yaml:"catalog"` +} + +type benchmarkNodeConfig struct { + Node string `yaml:"node"` +} + +func BenchmarkUnderlayBroadCatalog(b *testing.B) { + for _, width := range []int{128, 256, 512} { + b.Run(strconv.Itoa(width), func(b *testing.B) { + inputs := make([]Input, 2) + for half := range inputs { + catalog := make(map[string]any, width/2) + for index := range width / 2 { + catalog["item-"+strconv.Itoa(half*width/2+index)] = "value" + } + inputs[half] = Input{Source: Mapping("catalog-"+strconv.Itoa(half), map[string]any{"catalog": catalog})} + } + options := LayeredOptions[benchmarkCatalogConfig]{Underlays: []Underlay{{Inputs: inputs}}} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), options); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkUnderlayManySmallTiers(b *testing.B) { + for _, count := range []int{32, 64, 128} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + groups := make([]Underlay, count) + for index := range groups { + groups[index].Inputs = []Input{{Source: Mapping("item", map[string]any{ + "catalog": map[string]any{"item-" + strconv.Itoa(index): "value"}, + })}} + } + options := LayeredOptions[benchmarkCatalogConfig]{Underlays: groups} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), options); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkUnderlaySubtreeReplacement(b *testing.B) { + for _, count := range []int{32, 64, 128} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + groups := make([]Underlay, count) + for index := range groups { + var node any = "temporary" + if index%2 == 0 { + node = map[string]any{"left": "one", "right": "two", "nested": map[string]any{"enabled": false}} + } + groups[index].Inputs = []Input{{Source: Mapping("replacement", map[string]any{"node": node})}} + } + options := LayeredOptions[benchmarkNodeConfig]{Underlays: groups} + main := Input{Source: Mapping("main", map[string]any{"node": "final"})} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := LoadLayered(main, testSecretBinding(), options); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkUnderlayNativeImports(b *testing.B) { + for _, count := range []int{16, 32, 64} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + files := fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'defaults/*.yml'}]}\n")}, + } + for index := range count { + name := "item-" + strconv.Itoa(index) + files["defaults/"+name+".yml"] = &fstest.MapFile{Data: []byte("catalog: {" + name + ": value}\n")} + } + filesystem := NewFilesystem("bundle", files) + main := Input{Source: filesystem.File("main.yml"), Imports: true} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := LoadLayered[benchmarkCatalogConfig](main, testSecretBinding()); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkUnderlayRetainedHistory(b *testing.B) { + for _, count := range []int{16, 32, 64} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + groups := make([]Underlay, count) + for index := range groups { + groups[index].Inputs = []Input{{Source: Mapping("value", map[string]any{ + "catalog": map[string]any{"shared": strconv.Itoa(index)}, + })}} + } + options := LayeredOptions[benchmarkCatalogConfig]{Underlays: groups} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + result, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), options) + if err != nil { + b.Fatal(err) + } + if _, ok := result.Inspection.Explain(Keys("catalog", "shared")); !ok { + b.Fatal("retained path was not explained") + } + } + }) + } +} + +func BenchmarkUnderlayRenderedHistory(b *testing.B) { + for _, count := range []int{32, 64, 128} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + groups := make([]Underlay, count) + for index := range groups { + groups[index].Inputs = []Input{{Source: Mapping("value", map[string]any{ + "catalog": map[string]any{"shared": strconv.Itoa(index)}, + })}} + } + result, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[benchmarkCatalogConfig]{Underlays: groups}) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if err := result.Inspection.WriteExplanation(io.Discard, Keys("catalog", "shared")); err != nil { + b.Fatal(err) + } + } + }) + } +}