diff --git a/internal/cli/reference_contract_test.go b/internal/cli/reference_contract_test.go new file mode 100644 index 0000000..a852093 --- /dev/null +++ b/internal/cli/reference_contract_test.go @@ -0,0 +1,454 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +func TestReferenceSelectorsParseAndApplyAllDocumentedForms(t *testing.T) { + tests := []struct { + name string + selector string + only []string + wantStage pipeline.ModuleStage + wantLane string + wantSlot string + }{ + {name: "flat", selector: "alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"}, + {name: "chunk", selector: "chunk.chunk-slot", wantStage: pipeline.StageChunk, wantSlot: "chunk-slot"}, + {name: "merge", selector: "merge.alpha-merge", only: []string{"alpha"}, wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"}, + {name: "lane", selector: "alpha.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"}, + {name: "lane extract", selector: "alpha.extract.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"}, + {name: "lane merge", selector: "alpha.merge.alpha-merge", wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"}, + {name: "lane normalize", selector: "alpha.normalize.alpha-normalize", wantStage: pipeline.StageNormalize, wantLane: "alpha", wantSlot: "alpha-normalize"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := referenceContractConfig() + catalog := referenceContractCatalog(t, true, true) + selector, err := parseReferenceSelector(tt.selector, "--reference") + if err != nil { + t.Fatal(err) + } + overrides, _, err := resolveCLIReferenceRequests(cfg, "demo", tt.only, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil) + if err != nil { + t.Fatalf("resolve selector: %v", err) + } + if len(overrides) != 1 { + t.Fatalf("overrides = %#v, want one binding", overrides) + } + got := overrides[0] + if got.Stage != tt.wantStage || got.LaneID != tt.wantLane || got.SlotName != tt.wantSlot || got.BindingSource != contracts.ReferenceBindingSourceCLI { + t.Fatalf("binding = %#v, want %s/%s/%s from CLI", got, tt.wantStage, tt.wantLane, tt.wantSlot) + } + }) + } +} + +func TestReferenceSelectorsRejectAmbiguityWithSpecificSuggestions(t *testing.T) { + cfg := referenceContractConfig() + catalog := referenceContractCatalog(t, true, true) + for _, tt := range []struct { + name string + selector string + want []string + }{ + {name: "flat shared slot", selector: "shared", want: []string{"alpha.extract.shared", "beta.extract.shared"}}, + {name: "lane shared slot", selector: "alpha.shared", want: []string{"alpha.extract.shared", "alpha.merge.shared", "alpha.normalize.shared"}}, + {name: "all mergers", selector: "merge.shared", want: []string{"alpha.merge.shared", "beta.merge.shared"}}, + } { + t.Run(tt.name, func(t *testing.T) { + selector, err := parseReferenceSelector(tt.selector, "--reference") + if err != nil { + t.Fatal(err) + } + _, _, err = resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil) + if err == nil { + t.Fatal("resolve selector succeeded, want ambiguity error") + } + for _, fragment := range tt.want { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error = %q, want suggestion %q", err, fragment) + } + } + }) + } +} + +func TestReferenceSelectorsRespectSelectedLanesBeforeMaterialization(t *testing.T) { + cfg := referenceContractConfig() + catalog := referenceContractCatalog(t, true, true) + for _, tt := range []struct { + name string + selector string + want string + }{ + {name: "unselected lane", selector: "beta.extract.beta-slot", want: `reference lane "beta" is not selected`}, + {name: "unknown lane", selector: "missing.extract.beta-slot", want: `reference lane "missing" is not selected`}, + } { + t.Run(tt.name, func(t *testing.T) { + selector, err := parseReferenceSelector(tt.selector, "--reference") + if err != nil { + t.Fatal(err) + } + _, _, err = resolveCLIReferenceRequests(cfg, "demo", []string{"alpha"}, catalog, []cliReferenceRequest{{Selector: selector, Source: filepath.Join(t.TempDir(), "missing.txt")}}, nil) + if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), "missing.txt") { + t.Fatalf("error = %v, want selection failure before file access", err) + } + }) + } +} + +func TestReferenceSyntaxErrorsReturnTwo(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "reference missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference"}}, + {name: "reference missing selector", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "=path.txt"}}, + {name: "reference missing separator", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot"}}, + {name: "reference missing path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot="}}, + {name: "reference excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "a.b.c.d=path.txt"}}, + {name: "unbind with path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "slot=path.txt"}}, + {name: "unbind excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "a.b.c.d"}}, + {name: "unbind missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunWithOptions(tt.args, &stdout, &stderr, Options{LookupEnv: emptyLookup}) + if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestReferenceOverridesUseFinalExactTargetBinding(t *testing.T) { + cfg := referenceContractConfig() + catalog := referenceContractCatalog(t, true, true) + alphaShared, err := parseReferenceSelector("alpha.extract.shared", "--reference") + if err != nil { + t.Fatal(err) + } + betaShared, err := parseReferenceSelector("beta.extract.shared", "--reference") + if err != nil { + t.Fatal(err) + } + overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{ + {Selector: alphaShared, Source: "alpha-first.txt"}, + {Selector: alphaShared, Source: "alpha-final.txt"}, + {Selector: betaShared, Source: "beta-only.txt"}, + }, nil) + if err != nil { + t.Fatal(err) + } + if len(unbinds) != 0 { + t.Fatalf("unbinds = %#v, want none", unbinds) + } + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides}) + if err != nil { + t.Fatalf("resolve pipeline: %v", err) + } + alpha := referenceContractLane(t, effective.ResolvedPipeline, "alpha") + beta := referenceContractLane(t, effective.ResolvedPipeline, "beta") + if source := referenceContractBindingSource(alpha.ExtractReferences.Bindings, "shared"); source != "alpha-final.txt" { + t.Fatalf("alpha shared source = %q, want final exact-target override", source) + } + if source := referenceContractBindingSource(beta.ExtractReferences.Bindings, "shared"); source != "beta-only.txt" { + t.Fatalf("beta shared source = %q, want target-specific override", source) + } +} + +func TestReferenceUnbindsRemoveOptionalAndProtectRequiredSlots(t *testing.T) { + cfg := referenceContractConfig() + catalog := referenceContractCatalog(t, true, true) + optional, err := parseReferenceSelector("alpha.extract.alpha-slot", "--without-reference") + if err != nil { + t.Fatal(err) + } + _, without, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: optional}}) + if err != nil { + t.Fatal(err) + } + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: without}) + if err != nil { + t.Fatalf("optional unbind: %v", err) + } + if binding := referenceContractFindBinding(referenceContractLane(t, effective.ResolvedPipeline, "alpha").ExtractReferences.Bindings, "alpha-slot"); binding != nil { + t.Fatalf("optional binding after unbind = %#v, want absent", binding) + } + + for _, tt := range []struct { + name string + selector string + }{ + {name: "chunk", selector: "chunk.required-chunk"}, + {name: "extract", selector: "alpha.extract.required-extract"}, + {name: "merge", selector: "alpha.merge.required-merge"}, + {name: "normalize", selector: "alpha.normalize.required-normalize"}, + } { + t.Run(tt.name, func(t *testing.T) { + selector, err := parseReferenceSelector(tt.selector, "--without-reference") + if err != nil { + t.Fatal(err) + } + _, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: selector}}) + if err != nil { + t.Fatal(err) + } + _, err = cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: unbinds}) + if err == nil || !strings.Contains(err.Error(), "required reference slot") { + t.Fatalf("resolve error = %v, want required-slot failure", err) + } + }) + } +} + +func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) { + configDir := t.TempDir() + workingDir := t.TempDir() + cfg := referenceContractConfig() + configPath := filepath.Join(configDir, "config.yml") + if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "optional.txt"), []byte("optional reference"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workingDir, "cli-reference.txt"), []byte("CLI reference"), 0o600); err != nil { + t.Fatal(err) + } + catalog := referenceContractCatalog(t, true, true) + selector, err := parseReferenceSelector("alpha.extract.alpha-slot", "--reference") + if err != nil { + t.Fatal(err) + } + overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "cli-reference.txt"}}, nil) + if err != nil { + t.Fatal(err) + } + effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides, ReferenceUnbinds: unbinds}) + if err != nil { + t.Fatalf("resolve pipeline: %v", err) + } + materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: workingDir}) + if err != nil { + t.Fatalf("materialize references: %v", err) + } + alpha := referenceContractLane(t, materialized, "alpha") + cliItem := alpha.ExtractReferences.ReferenceSet.Slots["alpha-slot"].Items[0] + if string(cliItem.Content) != "CLI reference" || cliItem.BindingSource != contracts.ReferenceBindingSourceCLI || cliItem.Origin.URI != referenceContractFileURI(filepath.Join(workingDir, "cli-reference.txt")) { + t.Fatalf("CLI materialization = %#v, want working-directory provenance", cliItem) + } + configItem := alpha.ExtractReferences.ReferenceSet.Slots["required-extract"].Items[0] + if string(configItem.Content) != "config reference" || configItem.BindingSource != contracts.ReferenceBindingSourceConfig || configItem.Origin.URI != referenceContractFileURI(filepath.Join(configDir, "required.txt")) { + t.Fatalf("config materialization = %#v, want config-directory provenance", configItem) + } +} + +func TestReferenceTargetLookupUsesArtifactVariantsAndReportsMissingContext(t *testing.T) { + cfg := referenceContractConfig() + full := referenceContractCatalog(t, true, true) + targets, err := selectedReferenceTargets(cfg, "demo", nil, full) + if err != nil { + t.Fatalf("select reference targets: %v", err) + } + var alphaMerge, betaMerge selectedReferenceTarget + for _, target := range targets { + if target.stage == pipeline.StageMerge && target.laneID == "alpha" { + alphaMerge = target + } + if target.stage == pipeline.StageMerge && target.laneID == "beta" { + betaMerge = target + } + } + if _, ok := alphaMerge.slots["alpha-merge"]; !ok { + t.Fatalf("alpha merger slots = %#v, want alpha artifact variant", alphaMerge.slots) + } + if _, ok := betaMerge.slots["beta-merge"]; !ok { + t.Fatalf("beta merger slots = %#v, want beta artifact variant", betaMerge.slots) + } + if _, ok := betaMerge.slots["alpha-merge"]; ok { + t.Fatalf("beta merger slots = %#v, must not use alpha variant", betaMerge.slots) + } + + missingMerger := referenceContractCatalog(t, false, true) + _, err = selectedReferenceTargets(cfg, "demo", nil, missingMerger) + if err == nil || !strings.Contains(err.Error(), "merger") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) { + t.Fatalf("missing merger error = %v, want artifact variant context", err) + } + missingNormalizer := referenceContractCatalog(t, true, false) + _, err = selectedReferenceTargets(cfg, "demo", nil, missingNormalizer) + if err == nil || !strings.Contains(err.Error(), "normalizer") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) { + t.Fatalf("missing normalizer error = %v, want artifact variant context", err) + } + missingExtractor := referenceContractCatalog(t, true, true) + missingExtractor.Extractors = pipeline.NewExtractorRegistry() + _, err = selectedReferenceTargets(cfg, "demo", nil, missingExtractor) + if err == nil || !strings.Contains(err.Error(), `lane "alpha" extract module`) || !strings.Contains(err.Error(), "not registered") { + t.Fatalf("missing extractor error = %v, want lane/module context", err) + } +} + +const ( + referenceContractKindAlpha contracts.ArtifactKind = "reference/alpha" + referenceContractKindBeta contracts.ArtifactKind = "reference/beta" +) + +func referenceContractConfig() config.Config { + cfg := config.Default() + cfg.Pipelines = map[string]pipeline.PipelineProfile{ + "demo": { + ID: "demo", + Input: pipeline.Binding("reference/input"), + Chunk: pipeline.Binding("reference/chunk"), + Output: pipeline.Binding("reference/output"), + Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "alpha": { + Extract: pipeline.Binding("reference/extract-alpha"), + Merge: pipeline.Binding("reference/shared-merge"), + Normalize: pipeline.Binding("reference/shared-normalize"), + References: map[string]string{"required-extract": "required.txt"}, + }, + "beta": { + Extract: pipeline.Binding("reference/extract-beta"), + Merge: pipeline.Binding("reference/shared-merge"), + Normalize: pipeline.Binding("reference/shared-normalize"), + References: map[string]string{"required-extract": "required.txt"}, + }, + }, + }, + } + profile := cfg.Pipelines["demo"] + profile.Chunk.References = map[string]string{"required-chunk": "required.txt"} + alpha := profile.Artifacts["alpha"] + alpha.Extract.References = map[string]string{"required-extract": "required.txt", "alpha-slot": "optional.txt"} + alpha.Merge.References = map[string]string{"required-merge": "required.txt"} + alpha.Normalize.References = map[string]string{"required-normalize": "required.txt"} + profile.Artifacts["alpha"] = alpha + beta := profile.Artifacts["beta"] + beta.Extract.References = map[string]string{"required-extract": "required.txt"} + beta.Merge.References = map[string]string{"required-merge": "required.txt"} + beta.Normalize.References = map[string]string{"required-normalize": "required.txt"} + profile.Artifacts["beta"] = beta + cfg.Pipelines["demo"] = profile + return cfg +} + +func referenceContractCatalog(t *testing.T, includeBetaMerger, includeBetaNormalizer bool) pipeline.ModuleCatalog { + t.Helper() + registries := pipeline.Registries{ + Inputs: pipeline.NewInputAdapterRegistry(), + Chunkers: pipeline.NewChunkerRegistry(), + ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + Extractors: pipeline.NewExtractorRegistry(), + Mergers: pipeline.NewMergerRegistry(), + Normalizers: pipeline.NewNormalizerRegistry(), + Validators: pipeline.NewValidatorRegistry(), + ValidatorChains: pipeline.NewValidatorChainRegistry(), + Outputs: pipeline.NewOutputEncoderRegistry(), + } + register := func(err error) { + if err != nil { + t.Fatal(err) + } + } + register(registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "reference/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil })) + register(registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-slot"}, {Name: "required-chunk", Required: true}}}, func() (contracts.Chunker, error) { return stateTestChunker{}, nil })) + register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecA{})) + register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecB{})) + register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-alpha", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil })) + register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-beta", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil })) + register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil })) + if includeBetaMerger { + register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil })) + } + register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil })) + if includeBetaNormalizer { + register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil })) + } + register(registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil })) + return catalogFromRegistries(registries) +} + +type referenceContractCodecB struct{} + +type referenceContractCodecA struct{} + +func (referenceContractCodecA) Kind() contracts.ArtifactKind { return referenceContractKindAlpha } +func (referenceContractCodecA) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "reference.alpha", Name: "reference_alpha", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} +} +func (referenceContractCodecA) MediaType() string { return "application/json" } +func (referenceContractCodecA) EncodeCandidate(stateTestArtifact) ([]byte, error) { + return []byte(`{"value":"ok"}`), nil +} +func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) { + return []byte(`{"value":"ok"}`), nil +} +func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) { + return stateTestArtifact{Value: "ok"}, nil +} + +func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta } +func (referenceContractCodecB) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "reference.beta", Name: "reference_beta", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} +} +func (referenceContractCodecB) MediaType() string { return "application/json" } +func (referenceContractCodecB) EncodeCandidate(stateTestArtifact) ([]byte, error) { + return []byte(`{"value":"ok"}`), nil +} +func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) { + return []byte(`{"value":"ok"}`), nil +} +func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) { + return stateTestArtifact{Value: "ok"}, nil +} + +func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane { + t.Helper() + for _, lane := range resolved.ArtifactLanes { + if lane.ID == id { + return lane + } + } + t.Fatalf("lane %q not found", id) + return pipeline.ResolvedArtifactLane{} +} + +func referenceContractBindingSource(bindings []pipeline.ReferenceBinding, slot string) string { + for _, binding := range bindings { + if binding.SlotName == slot { + return binding.Source + } + } + return "" +} + +func referenceContractFindBinding(bindings []pipeline.ReferenceBinding, slot string) *pipeline.ReferenceBinding { + for i := range bindings { + if bindings[i].SlotName == slot { + return &bindings[i] + } + } + return nil +} + +func referenceContractFileURI(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + absolute = path + } + return "file://" + filepath.ToSlash(absolute) +}