From f0a4bf9bb24d71859a8d6d0c360e4d2d955ff86c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 9 Nov 2026 18:45:00 +0000 Subject: [PATCH] Bind companion inputs to the main context --- docs/roadmap/implementation.md | 2 + facade_main_relative_test.go | 70 +++++++++++++++++++++++++++ internal/engine/acquisition.go | 7 +++ internal/engine/import_acquisition.go | 7 ++- internal/engine/load_request.go | 28 +++++++++-- internal/engine/load_request_test.go | 6 +-- 6 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 facade_main_relative_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5ee9b00..b730398 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -64,6 +64,8 @@ option diagnostic codes; avoid promising order among unrelated errors. ## Stage 2 — Bind companion inputs with a usable context +**Status:** Complete. Focused host, native, symlink, fallback, and grouped-load checks passed. + In `assembleLayeredInput`, pass the main's `assembledInput.resolvedFilesystem` to `acquireUnderlayGroupScopes`. Reuse the acquisition handoff in `acquisition.go` and resolution helpers in `filesystem_context.go`; do not diff --git a/facade_main_relative_test.go b/facade_main_relative_test.go new file mode 100644 index 0000000..3d92f9b --- /dev/null +++ b/facade_main_relative_test.go @@ -0,0 +1,70 @@ +package configkit_test + +import ( + "os" + "path/filepath" + "testing" + "testing/fstest" + + configkit "go.maximumdirect.net/configkit" +) + +func TestMainRelativeCompanion(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 3\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "extra.yml"), []byte("settings: {companion: host}\n"), 0600); err != nil { + t.Fatal(err) + } + binding := configkit.MainRelative() + companion := configkit.Input{Source: configkit.Bytes("companions", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: binding} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(dir, "main.yml"))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 3 || result.Config.Settings["companion"] != "host" { + t.Fatalf("configuration = %#v", result.Config) + } + native := configkit.NewFilesystem("bundle", fstest.MapFS{"dir/main.yml": &fstest.MapFile{Data: []byte("count: 4\n")}, "dir/extra.yml": &fstest.MapFile{Data: []byte("settings: {companion: native}\n")}}) + result, err = configkit.LoadLayered(configkit.Input{Source: native.File("dir/main.yml")}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 4 || result.Config.Settings["companion"] != "native" { + t.Fatalf("configuration = %#v", result.Config) + } +} + +func TestMainRelativeContextSelection(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "main") + if err := os.Mkdir(base, 0700); err != nil { + t.Fatal(err) + } + for path, value := range map[string]string{"main/main.yml": "count: 1\n", "main/extra.yml": "settings: {value: derived}\n", "extra.yml": "settings: {value: fallback}\n"} { + if err := os.WriteFile(filepath.Join(root, path), []byte(value), 0600); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(root, "link.yml") + if err := os.Symlink(filepath.Join(base, "main.yml"), link); err != nil { + t.Fatal(err) + } + companion := configkit.Input{Source: configkit.Bytes("companion", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}} + opts := configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(link), Filesystem: &configkit.FilesystemContext{Root: root}}, opts) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if got := result.Config.Settings["value"]; got != "derived" { + t.Fatalf("derived value = %q", got) + } + result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, opts) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if got := result.Config.Settings["value"]; got != "fallback" { + t.Fatalf("fallback value = %q", got) + } +} diff --git a/internal/engine/acquisition.go b/internal/engine/acquisition.go index 6fb0771..811d855 100644 --- a/internal/engine/acquisition.go +++ b/internal/engine/acquisition.go @@ -20,6 +20,7 @@ type tierAcquisitionState struct { type inputAcquisitionRequest struct { input loadInput + resolved *resolvedFilesystemContext role sourceVersionRole directiveContext rootDirectiveContext protectionRule *VersionRule @@ -126,6 +127,12 @@ func acquireStandaloneInput(input assembledInput, request inputAcquisitionReques if processing != nil { input.document = processing.payload } + if request.resolved != nil { + if diagnostic := validateResolvedFilesystemDirectories(*request.resolved); diagnostic != nil { + return input, []Diagnostic{*diagnostic} + } + input.resolvedFilesystem = request.resolved + } if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) { resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken) if diagnostic != nil { diff --git a/internal/engine/import_acquisition.go b/internal/engine/import_acquisition.go index 988576d..e71580f 100644 --- a/internal/engine/import_acquisition.go +++ b/internal/engine/import_acquisition.go @@ -38,7 +38,12 @@ func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionP source := request.input.input.source context := request.input.input.filesystem main := request.main - resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken) + resolved, diagnostic := resolvedFilesystemContext{}, (*Diagnostic)(nil) + if request.input.resolved != nil { + resolved = *request.input.resolved + } else { + resolved, diagnostic = resolveFilesystemContext(source, context, main, tier.hostToken) + } if diagnostic != nil { return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic} } diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index beb48e0..ad59186 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -102,7 +102,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p projectVersion := sequenceVersionPath(request.protectionVersion()) for index, group := range request.underlays { evidenceStart := len(input.versionState.evidenceSince(0)) - acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy) + acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy, input.resolvedFilesystem) mergeResourceContexts(&input.contexts, acquired.contexts) if len(diagnostics) != 0 { input.sources = append(input.sources, acquired.sources...) @@ -260,12 +260,27 @@ func applyCompletedTier( return operations, nil } -func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) { +func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy, mainContext *resolvedFilesystemContext) (*acquiredUnderlayGroup, []Diagnostic) { initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}} + selected := make([]*resolvedFilesystemContext, len(group.inputs)) + for i, contribution := range group.inputs { + if contribution.filesystem == nil && contribution.mainRelative != nil && mainContext != nil { + derived := *mainContext + derived.mainPath = "" + derived.hostToken = tier.hostToken + selected[i] = &derived + } else if contribution.filesystem == nil && contribution.mainRelative != nil { + group.inputs[i].filesystem = contribution.mainRelative.Fallback + } + } nativeAnchors := make(map[*namespaceToken]string) for inputIndex, contribution := range group.inputs { - if token := nativeTokenForLoadInput(contribution); token != nil { + token := nativeTokenForLoadInput(contribution) + if selected[inputIndex] != nil && selected[inputIndex].native != nil { + token = selected[inputIndex].native.token + } + if token != nil { if _, exists := nativeAnchors[token]; !exists { nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1) } @@ -276,6 +291,7 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s for inputIndex, contribution := range group.inputs { acquired, diagnostics := acquireInput(inputAcquisitionRequest{ input: contribution, role: sourceVersionContribution, + resolved: selected[inputIndex], directiveContext: rootDirectiveContextLayered, protectionRule: rule, }, policy, state, tier) for _, source := range acquired.sources { @@ -294,7 +310,11 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s kind: "host", host: explicitNamespaceAnchor(index, 0), input: explicitNamespaceAnchor(index, inputIndex+1), } - if token := nativeTokenForLoadInput(contribution); token != nil { + token := nativeTokenForLoadInput(contribution) + if selected[inputIndex] != nil && selected[inputIndex].native != nil { + token = selected[inputIndex].native.token + } + if token != nil { anchors.kind = "native" anchors.native = nativeAnchors[token] } diff --git a/internal/engine/load_request_test.go b/internal/engine/load_request_test.go index f42a150..c06ca40 100644 --- a/internal/engine/load_request_test.go +++ b/internal/engine/load_request_test.go @@ -81,7 +81,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) { accounting := &importAccounting{} group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}} state := &acquisitionState{accounting: accounting} - acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}) + acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil) if len(diagnostics) != 0 { t.Fatalf("acquire underlay group: %#v", diagnostics) } @@ -109,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) { t.Fatalf("composed group handoff = %#v", composition) } - empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}) + empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil) if len(diagnostics) != 0 || len(empty.scopes) != 0 { t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics) } @@ -471,7 +471,7 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) { func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) { state := &acquisitionState{accounting: accounting} - acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}) + acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil) return acquired, diagnostics }