From ffc179c8220b67711e8a81b943f5be1fec439b1a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 01:27:28 +0000 Subject: [PATCH] Centralize builder request cloning --- docs/internal/pipeline.md | 11 +- .../framework/pipeline/preparation_test.go | 168 ++++++++++++++++++ internal/framework/pipeline/prepare.go | 14 +- .../framework/pipeline/validator_registry.go | 16 +- .../modules/dnd/spells/catalog/effective.go | 25 +-- 5 files changed, 198 insertions(+), 36 deletions(-) diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 6e0ab95..2a3dda5 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -51,11 +51,12 @@ until its producing step completes. Preparation is the construction boundary. It validates the resolved shape and registry set, clones the resolved data, then constructs the input adapter, -chunker, stage-local validators, every typed lane, and output encoder with -cloned options, references, and shared dependencies. It also collects stable -checkpoint fingerprints. Missing registrations, incompatible typed entries, -nil implementations, and constructor failures are reported before source -parsing or any stage operation begins. +chunker, stage-local validators, every typed lane, and output encoder. Each +registered builder receives its own cloned build request immediately before its +module-owned code runs. Preparation also collects stable checkpoint +fingerprints. Missing registrations, incompatible typed entries, nil +implementations, and constructor failures are reported before source parsing +or any stage operation begins. An output encoder can opt into source-evidence publication through its output policy. Preparation keeps the configured lane allowlist and active lanes diff --git a/internal/framework/pipeline/preparation_test.go b/internal/framework/pipeline/preparation_test.go index 4ffeaa3..2b33222 100644 --- a/internal/framework/pipeline/preparation_test.go +++ b/internal/framework/pipeline/preparation_test.go @@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) { if name != "extract" { return } + request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder" slot := request.References.Slots["extract"] slot.Items[0].Content = []byte("mutated by extractor builder") request.References.Slots["extract"] = slot @@ -318,6 +319,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) { resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference") resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference") resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference") + resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options prepared, err := Prepare(resolved, registries, ModuleDependencies{}) if err != nil { @@ -339,6 +341,15 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) { if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" { t.Fatalf("resolved extract references = %q, want original content", got) } + if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" { + t.Fatalf("prepared extract references = %q, want original content", got) + } + if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" { + t.Fatalf("resolved extract options = %#v, want original nested value", got) + } + if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" { + t.Fatalf("prepared extract options = %#v, want original nested value", got) + } _, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{ References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet), @@ -354,6 +365,163 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) { } } +func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) { + request := constructionBuildRequest() + probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)} + + inputs := NewInputAdapterRegistry() + if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) { + probe.observe(request) + return &constructionInput{key: "input"}, nil + }); err != nil { + t.Fatal(err) + } + chunkers := NewChunkerRegistry() + if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) { + probe.observe(request) + return &typedTestChunker{key: "chunk"}, nil + }); err != nil { + t.Fatal(err) + } + outputs := NewOutputEncoderRegistry() + if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) { + probe.observe(request) + return &typedTestOutput{key: "output"}, nil + }); err != nil { + t.Fatal(err) + } + extractors := NewExtractorRegistry() + extractSpec := testModuleSpec("extract", StageExtract) + extractSpec.ArtifactKind = "test/notes" + if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) { + probe.observe(request) + return typedTestExtractor[codecNotes]{key: "extract"}, nil + }); err != nil { + t.Fatal(err) + } + mergers := NewMergerRegistry() + mergeSpec := testModuleSpec("merge", StageMerge) + mergeSpec.ArtifactKind = "test/notes" + if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) { + probe.observe(request) + return typedTestMerger[codecNotes]{key: "merge"}, nil + }); err != nil { + t.Fatal(err) + } + normalizers := NewNormalizerRegistry() + normalizeSpec := testModuleSpec("normalize", StageNormalize) + normalizeSpec.ArtifactKind = "test/notes" + if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) { + probe.observe(request) + return typedTestNormalizer[codecNotes]{key: "normalize"}, nil + }); err != nil { + t.Fatal(err) + } + validators := NewValidatorRegistry() + if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) { + probe.observe(request) + return typedTestChunkValidator{key: "chunk-validator"}, nil + }); err != nil { + t.Fatal(err) + } + if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) { + probe.observe(request) + return typedTestValidator[codecNotes]{key: "typed-validator"}, nil + }); err != nil { + t.Fatal(err) + } + if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) { + probe.observe(request) + return typedTestSerializedValidator{key: "serialized-validator"}, nil + }); err != nil { + t.Fatal(err) + } + + builders := []struct { + name string + call func() error + }{ + {name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }}, + {name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }}, + {name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }}, + {name: "extract", call: func() error { + entry, _ := extractors.typedEntry("extract") + _, err := entry.builder(request) + return err + }}, + {name: "merge", call: func() error { + entry, _ := mergers.typedEntry("merge", "test/notes") + _, err := entry.builder(request) + return err + }}, + {name: "normalize", call: func() error { + entry, _ := normalizers.typedEntry("normalize", "test/notes") + _, err := entry.builder(request) + return err + }}, + {name: "chunk validator", call: func() error { + entry, _ := validators.chunkEntry("chunk-validator") + _, err := entry.builder(request) + return err + }}, + {name: "typed validator", call: func() error { + entry, _ := validators.typedEntry("typed-validator", "test/notes") + _, err := entry.builder(request) + return err + }}, + {name: "serialized validator", call: func() error { + entry, _ := validators.serializedEntry("serialized-validator") + _, err := entry.builder(request) + return err + }}, + } + for _, builder := range builders { + t.Run(builder.name, func(t *testing.T) { + if err := builder.call(); err != nil { + t.Fatal(err) + } + }) + } + if !reflect.DeepEqual(request, probe.want) { + t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want) + } +} + +type buildRequestMutationProbe struct { + t *testing.T + want BuildRequest +} + +func (probe buildRequestMutationProbe) observe(request BuildRequest) { + probe.t.Helper() + if !reflect.DeepEqual(request, probe.want) { + probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want) + } + options := request.Options["nested"].(map[string]any) + options["value"] = "mutated" + request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated" + request.Options["bytes"].([]byte)[0] = 'x' + slot := request.References.Slots["reference"] + slot.Items[0].Content[0] = 'x' + slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")}) + request.References.Slots["reference"] = slot + delete(request.References.Slots, "unused") +} + +func constructionBuildRequest() BuildRequest { + return BuildRequest{ + Options: map[string]any{ + "nested": map[string]any{"value": "original"}, + "items": []any{map[string]any{"value": "original"}}, + "bytes": []byte("original"), + }, + References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}}, + "unused": {Slot: contracts.ReferenceSlot{Name: "unused"}}, + }}, + } +} + func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) { tests := []struct { name string diff --git a/internal/framework/pipeline/prepare.go b/internal/framework/pipeline/prepare.go index e8d4ce6..a98ac78 100644 --- a/internal/framework/pipeline/prepare.go +++ b/internal/framework/pipeline/prepare.go @@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend artifactCodecs: registries.ArtifactCodecs, } request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest { - return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references} + return BuildRequest{Dependencies: deps, Options: binding.Options, References: references} } input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input, contracts.ReferenceSet{})) @@ -202,7 +202,7 @@ func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, outpu func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) { executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)} request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest { - return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references} + return BuildRequest{Dependencies: deps, Options: binding.Options, References: references} } extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module) if !ok { @@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains) prepared := preparedValidatorChain{resolved: resolved} for _, validator := range resolved.Validators { - request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options), References: references} + request := BuildRequest{Dependencies: deps, Options: validator.Binding.Options, References: references} built, err := buildPreparedValidator(registries.Validators, validator, request) if err != nil { return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err) @@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida if !ok { return preparedValidator{}, fmt.Errorf("typed construction entry is not registered") } - implementation, err = entry.builder(cloneBuildRequest(request)) + implementation, err = entry.builder(request) prepared.typed = implementation prepared.typedValidate = entry.validate case ValidatorTargetChunk: @@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida if !ok { return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered") } - prepared.chunk, err = entry.builder(cloneBuildRequest(request)) + prepared.chunk, err = entry.builder(request) implementation = prepared.chunk case ValidatorTargetSerialized: entry, ok := registry.serializedEntry(key) if !ok { return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered") } - prepared.serialized, err = entry.builder(cloneBuildRequest(request)) + prepared.serialized, err = entry.builder(request) implementation = prepared.serialized default: return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target) @@ -323,7 +323,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida } func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) { - implementation, err := builder(cloneBuildRequest(request)) + implementation, err := builder(request) if err != nil { return nil, err } diff --git a/internal/framework/pipeline/validator_registry.go b/internal/framework/pipeline/validator_registry.go index 52d1b0e..74eccf5 100644 --- a/internal/framework/pipeline/validator_registry.go +++ b/internal/framework/pipeline/validator_registry.go @@ -150,7 +150,13 @@ func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSp if registry.chunkEntries == nil { registry.chunkEntries = make(map[string]chunkValidatorEntry) } - registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, validateOptions: validateOptions, builder: builder} + registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{ + spec: normalizedSpec, + validateOptions: validateOptions, + builder: func(request BuildRequest) (contracts.ChunkValidator, error) { + return builder(cloneBuildRequest(request)) + }, + } return nil } @@ -187,7 +193,13 @@ func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec Serial if registry.serializedEntries == nil { registry.serializedEntries = make(map[string]serializedValidatorEntry) } - registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, validateOptions: validateOptions, builder: builder} + registry.serializedEntries[spec.Key] = serializedValidatorEntry{ + spec: spec, + validateOptions: validateOptions, + builder: func(request BuildRequest) (contracts.SerializedValidator, error) { + return builder(cloneBuildRequest(request)) + }, + } return nil } diff --git a/internal/modules/dnd/spells/catalog/effective.go b/internal/modules/dnd/spells/catalog/effective.go index 07a0058..7e9ac90 100644 --- a/internal/modules/dnd/spells/catalog/effective.go +++ b/internal/modules/dnd/spells/catalog/effective.go @@ -50,16 +50,15 @@ func (c EffectiveCatalog) Lookup(name string) (string, bool) { } // ResolveEffectiveCatalog loads the embedded SRD catalog and applies the -// optional spell-catalog overlay found in the cloned reference set. It does -// not resolve paths or perform filesystem access. +// optional spell-catalog overlay found in its spell_catalog reference slot. It +// does not resolve paths or perform filesystem access. func ResolveEffectiveCatalog(references contracts.ReferenceSet) (EffectiveCatalog, error) { base, err := LoadSRD5E2014() if err != nil { return EffectiveCatalog{}, err } - cloned := cloneReferenceSet(references) - slot, ok := cloned.Slots[SpellCatalogReferenceSlot] + slot, ok := references.Slots[SpellCatalogReferenceSlot] if !ok || len(slot.Items) == 0 { return composeEffectiveCatalog(base, nil) } @@ -407,21 +406,3 @@ func cloneStringMap(values map[string]string) map[string]string { } return out } - -func cloneReferenceSet(in contracts.ReferenceSet) contracts.ReferenceSet { - if len(in.Slots) == 0 { - return contracts.ReferenceSet{} - } - out := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(in.Slots))} - for name, slot := range in.Slots { - slot.Slot.AcceptedMediaTypes = append([]string(nil), slot.Slot.AcceptedMediaTypes...) - items := make([]contracts.ReferenceItem, len(slot.Items)) - for index, item := range slot.Items { - item.Content = append([]byte(nil), item.Content...) - items[index] = item - } - slot.Items = items - out.Slots[name] = slot - } - return out -}