diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7312e38..0088fc6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -302,7 +302,7 @@ overlay/CLI accounting. No limitations. ## Stage 6 — Preserve diagnostics, source inspection, and resource contexts -Status: planned. Scope: coordinates, detached records, and resource ownership. +Status: complete. Scope: coordinates, detached records, and resource ownership. 1. Check entry/child/grandchild source records' individual destinations, kinds, IDs, physical paths, and ImportedFrom locations. Include public failure @@ -329,6 +329,19 @@ diagnostic/import tests, `go test -race ./internal/engine`, and everyday check. Done: logical placement is inspectable without changing physical identity, filesystem lifetime, or the public facade boundary. +Evidence (2026-10-14): based on `43e55dfa85963d050d5899aec9d96f4ef637ebf6`, +using `go1.26.8 linux/amd64`. Focused public facade import/destination/resource +tests, the engine import/destination/provenance/diagnostic/resource suite, +`go test -race ./internal/engine`, and `./scripts/check.sh` all passed. New +coverage verifies exact host and native physical source paths, IDs, kinds, +effective destinations, declaration locations, native resource opens from +their declaring directories, detached destination copies across repeated and +independent loads, and public source/diagnostic records for imported parse, +source-local directive, effective-path composition/version, and typed validation +failures. Existing layered inheritance/context and source-acquisition tests +cover inherited lower values, explicit filesystem contexts, and cleanup. No +limitations. + ## Stage 7 — Verify inheritance, history, sensitivity, and diffs Status: planned. Scope: retained placed data and one final application lifecycle. diff --git a/facade_destination_test.go b/facade_destination_test.go index 010314a..6ddb75b 100644 --- a/facade_destination_test.go +++ b/facade_destination_test.go @@ -247,6 +247,16 @@ name: profile wantPaths := []string{ "main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml", } + wantPhysicalPaths := []string{ + mainPath, + filepath.Join(directory, "metadata.yml"), + filepath.Join(directory, "prompts", "a.yml"), + filepath.Join(directory, "prompts", "b.yml"), + filepath.Join(directory, "entries", "main.yml"), + filepath.Join(directory, "entries", "nested", "details.yml"), + filepath.Join(directory, "sibling.yml"), + } + wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"} wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)} for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} { destination := append([]string(nil), test.prefix...) @@ -260,11 +270,18 @@ name: profile t.Fatalf("sources = %#v, want %d", sources, len(wantPaths)) } for index, source := range sources { - if filepath.Base(source.Path) != wantPaths[index] || source.Destination.String() != wantDestinations[index].String() { - t.Errorf("source %d path/destination = (%q, %q), want base path %q and destination %q", index, source.Path, source.Destination.String(), wantPaths[index], wantDestinations[index].String()) + if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() { + t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String()) } - if index > 0 && source.ImportedFrom == nil { - t.Errorf("imported source %d has no physical declaration location", index) + if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile { + t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind) + } + if wantImportParents[index] == "" { + if source.ImportedFrom != nil { + t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom) + } + } else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 { + t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index]) } } }) @@ -473,6 +490,135 @@ func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) { } } +func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) { + directory := t.TempDir() + write := func(name, contents string) string { + t.Helper() + filename := filepath.Join(directory, name) + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return filename + } + + t.Run("imported parse failure retains effective source destination", func(t *testing.T) { + mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n") + childPath := write("parse/child.yml", "count: [unterminated\n") + _, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"), + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("load error = %T %v", err, err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" { + t.Fatalf("imported parse diagnostic = %#v", diagnostics) + } + sources := loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath { + t.Fatalf("imported parse failure sources = %#v", sources) + } + }) + + t.Run("directive failure stays source local", func(t *testing.T) { + mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n") + parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n") + _, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"), + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("load error = %T %v", err, err) + } + diagnostics := loadError.Diagnostics() + wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0) + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" { + t.Fatalf("import directive diagnostic = %#v", diagnostics) + } + sources := loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath { + t.Fatalf("directive failure sources = %#v", sources) + } + }) + + t.Run("composition failure reports effective path and all sources", func(t *testing.T) { + mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n") + write("compose/left.yml", "value: same\n") + write("compose/right.yml", "value: same\n") + _, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"), + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("load error = %T %v", err, err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" { + t.Fatalf("composition diagnostic = %#v", diagnostics) + } + sources := loadError.Sources() + if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" { + t.Fatalf("composition failure sources = %#v", sources) + } + }) + + t.Run("version failure reports effective path and imported source", func(t *testing.T) { + mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n") + childPath := write("version/child.yml", "version: forbidden\n") + _, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"), + }, configkit.LayeredOptions[map[string]any]{ + Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("mount", "tenant", "version"), + Check: func(configkit.Version, *configkit.Validation) error { return nil }, + }}, + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("load error = %T %v", err, err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" { + t.Fatalf("version diagnostic = %#v", diagnostics) + } + sources := loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath { + t.Fatalf("version failure sources = %#v", sources) + } + }) + + t.Run("typed validation reports effective path and authored location", func(t *testing.T) { + type configuration struct { + Mount struct { + Tenant struct { + Count int `yaml:"count"` + } `yaml:"tenant"` + } `yaml:"mount"` + } + mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n") + childPath := write("typed/child.yml", "count: invalid\n") + _, err := configkit.LoadLayered[configuration](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"), + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("load error = %T %v", err, err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 { + t.Fatalf("typed validation diagnostic = %#v", diagnostics) + } + sources := loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath { + t.Fatalf("typed validation sources = %#v", sources) + } + }) +} + func TestFacadeDestinationMappingFailurePaths(t *testing.T) { t.Parallel() diff --git a/facade_resource_context_test.go b/facade_resource_context_test.go index 2ee6017..f2e51d2 100644 --- a/facade_resource_context_test.go +++ b/facade_resource_context_test.go @@ -2,10 +2,12 @@ package configkit_test import ( "errors" + "fmt" "io/fs" "os" "path" "path/filepath" + "reflect" "strings" "testing" "testing/fstest" @@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) { t.Fatal("CLI source returned a context") } } + +func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) { + borrowed := &observedResourceFS{files: fstest.MapFS{ + "bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")}, + "bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")}, + "bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")}, + "bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")}, + "bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")}, + }} + namespace := configkit.NewFilesystem("bundle", borrowed) + input := configkit.Input{ + Source: namespace.File("bundle/config/main.yml"), Imports: true, + Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace}, + } + load := func() *configkit.Result[map[string]any] { + t.Helper() + result, err := configkit.LoadLayered[map[string]any](input) + if err != nil { + t.Fatalf("load relative native imports: %v", err) + } + return result + } + result := load() + wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"} + wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"} + sources := result.Sources() + if len(sources) != len(wantPaths) { + t.Fatalf("sources = %#v", sources) + } + for index, source := range sources { + if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] { + t.Errorf("source %d = %#v", index, source) + } + if index == 0 { + if source.ImportedFrom != nil { + t.Errorf("entry source has import location %#v", source.ImportedFrom) + } + } else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 { + t.Errorf("source %d import location = %#v", index, source.ImportedFrom) + } + } + + for _, test := range []struct { + field, effectivePath, wantBase, wantResource, wantContents string + }{ + {"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"}, + {"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"}, + } { + info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...)) + if !ok || len(info.Origin.Locations) != 1 { + t.Fatalf("%s origin = %#v, %v", test.field, info, ok) + } + context, ok := result.SourceContext(info.Origin.Locations[0].SourceID) + if !ok || context.BaseDir != test.wantBase || context.FS == nil { + t.Fatalf("%s context = %#v, %v", test.field, context, ok) + } + contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource)) + if err != nil || string(contents) != test.wantContents { + t.Fatalf("%s opened resource = %q, %v", test.field, contents, err) + } + } + + sources[1].Destination = configkit.Keys("caller", "mutation") + for query := 0; query < 2; query++ { + fresh := result.Sources() + if fresh[1].Destination.String() != "/logical" { + t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String()) + } + } + second := load() + if !reflect.DeepEqual(second.Sources(), result.Sources()) { + t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources()) + } +}