Finish the references implementation for the extraction module and update roadmap documentation
This commit is contained in:
@@ -135,6 +135,9 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
if len(selectedLaneIDs) == 0 {
|
||||
return ResolvedPipeline{}, fmt.Errorf("pipeline %q must select at least one artifact lane", pipelineID)
|
||||
}
|
||||
if err := validatePipelineReferenceDefaults(pipelineID, profile.References, lanesByID, catalog); err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
|
||||
resolved := ResolvedPipeline{
|
||||
ID: pipelineID,
|
||||
@@ -238,6 +241,44 @@ func resolveArtifactLane(
|
||||
return lane, capabilities, nil
|
||||
}
|
||||
|
||||
func validatePipelineReferenceDefaults(
|
||||
pipelineID string,
|
||||
pipelineReferences map[string]string,
|
||||
lanesByID map[string]ArtifactLaneProfile,
|
||||
catalog ModuleCatalog,
|
||||
) error {
|
||||
normalizedPipelineReferences, err := normalizedReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalizedPipelineReferences) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
declaredByAnyLane := make(map[string]struct{}, len(normalizedPipelineReferences))
|
||||
for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) {
|
||||
laneProfile := lanesByID[laneID]
|
||||
extract := resolveBinding(laneProfile.Extract, "")
|
||||
if extract.Module == "" {
|
||||
return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
|
||||
}
|
||||
extractSpec, err := extractorSpec(catalog, extract.Module)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err)
|
||||
}
|
||||
for _, slot := range extractSpec.ReferenceSlots {
|
||||
declaredByAnyLane[slot.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, slotName := range sortedStringMapKeys(normalizedPipelineReferences) {
|
||||
if _, ok := declaredByAnyLane[slotName]; !ok {
|
||||
return fmt.Errorf("pipeline %q reference slot %q is not declared by any artifact lane", pipelineID, slotName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveReferenceBindings(
|
||||
pipelineID string,
|
||||
laneID string,
|
||||
@@ -370,6 +411,18 @@ func normalizedReferenceMap(values map[string]string, keyName string) (map[strin
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortedArtifactLaneProfileKeys(values map[string]ArtifactLaneProfile) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedStringMapKeys(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -129,8 +129,7 @@ func TestResolvePipelineSelectsOnlyRequestedLanes(t *testing.T) {
|
||||
func TestResolvePipelineAppliesReferenceBindings(t *testing.T) {
|
||||
profile := multiLaneProfile()
|
||||
profile.References = map[string]string{
|
||||
" roster ": " ./shared-roster.yml ",
|
||||
"unclaimed": "./ignored.yml",
|
||||
" roster ": " ./shared-roster.yml ",
|
||||
}
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.References = map[string]string{
|
||||
@@ -168,6 +167,39 @@ func TestResolvePipelineAppliesReferenceBindings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineAllowsPipelineReferenceDeclaredOnlyByUnselectedLane(t *testing.T) {
|
||||
profile := multiLaneProfile()
|
||||
profile.References = map[string]string{"notes_context": "./notes.md"}
|
||||
catalog := newProfileCatalogWithOverride(t, ModuleSpec{
|
||||
Key: "note-extractor",
|
||||
Stage: StageExtract,
|
||||
Requires: []string{"chunk"},
|
||||
Provides: []string{"candidate"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "notes_context"},
|
||||
},
|
||||
})
|
||||
|
||||
resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, catalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
||||
}
|
||||
if refs := resolved.ArtifactLanes[0].References; len(refs) != 0 {
|
||||
t.Fatalf("selected lane references = %#v, want none", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsPipelineReferenceNotDeclaredByAnyLane(t *testing.T) {
|
||||
profile := multiLaneProfile()
|
||||
profile.References = map[string]string{"missing": "./missing.md"}
|
||||
|
||||
_, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, newProfileCatalog(t))
|
||||
if err == nil {
|
||||
t.Fatal("ResolvePipeline() error = nil, want error")
|
||||
}
|
||||
assertErrorContains(t, err, "multi", "reference slot", "missing", "not declared")
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsUndeclaredReferenceSlot(t *testing.T) {
|
||||
profile := baselineProfile()
|
||||
lane := profile.Artifacts["events"]
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,7 +18,8 @@ import (
|
||||
|
||||
const (
|
||||
referenceOriginFile = "file"
|
||||
referenceMediaType = "text/plain; charset=utf-8"
|
||||
referenceMediaType = "text/plain"
|
||||
unknownMediaType = "application/octet-stream"
|
||||
)
|
||||
|
||||
type ReferenceMaterializationOptions struct {
|
||||
@@ -88,6 +90,10 @@ func materializeLaneReferences(
|
||||
if !utf8.Valid(content) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("pipeline %q lane %q reference slot %q path %q must be UTF-8 text", pipelineID, lane.ID, slotName, path)
|
||||
}
|
||||
mediaType := referenceMediaTypeForPath(path)
|
||||
if !referenceMediaTypeAccepted(mediaType, slot.AcceptedMediaTypes) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("pipeline %q lane %q reference slot %q path %q media type %q is not accepted", pipelineID, lane.ID, slotName, path, mediaType)
|
||||
}
|
||||
if slot.MaxBytes > 0 && int64(len(content)) > slot.MaxBytes {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("pipeline %q lane %q reference slot %q path %q is %d bytes, limit %d", pipelineID, lane.ID, slotName, path, len(content), slot.MaxBytes)
|
||||
}
|
||||
@@ -101,7 +107,7 @@ func materializeLaneReferences(
|
||||
|
||||
item := contracts.ReferenceItem{
|
||||
SlotName: slotName,
|
||||
MediaType: referenceMediaType,
|
||||
MediaType: mediaType,
|
||||
Content: append([]byte(nil), content...),
|
||||
Digest: referenceDigest(content),
|
||||
Origin: contracts.ReferenceOrigin{Type: referenceOriginFile, URI: fileURI(path)},
|
||||
@@ -116,6 +122,43 @@ func materializeLaneReferences(
|
||||
return set, warnings, nil
|
||||
}
|
||||
|
||||
func referenceMediaTypeForPath(path string) string {
|
||||
extension := strings.ToLower(filepath.Ext(path))
|
||||
mediaType := mime.TypeByExtension(extension)
|
||||
if strings.TrimSpace(mediaType) == "" {
|
||||
if extension == ".md" || extension == ".markdown" {
|
||||
return "text/markdown"
|
||||
}
|
||||
return unknownMediaType
|
||||
}
|
||||
return canonicalMediaType(mediaType)
|
||||
}
|
||||
|
||||
func referenceMediaTypeAccepted(mediaType string, accepted []string) bool {
|
||||
if len(accepted) == 0 {
|
||||
return true
|
||||
}
|
||||
mediaType = canonicalMediaType(mediaType)
|
||||
for _, value := range accepted {
|
||||
if strings.EqualFold(mediaType, canonicalMediaType(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canonicalMediaType(mediaType string) string {
|
||||
trimmed := strings.TrimSpace(mediaType)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, _, err := mime.ParseMediaType(trimmed)
|
||||
if err != nil {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
return strings.ToLower(parsed)
|
||||
}
|
||||
|
||||
func referencePath(binding ReferenceBinding, options ReferenceMaterializationOptions) (string, error) {
|
||||
source := strings.TrimSpace(binding.Source)
|
||||
if source == "" {
|
||||
|
||||
@@ -117,6 +117,96 @@ func TestMaterializeReferencesRejectsNonUTF8Content(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesAllowsAnyMediaTypeWhenSlotDoesNotRestrictIt(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "roster.reference")
|
||||
writeReferenceFile(t, path, []byte("plain text"))
|
||||
|
||||
resolved := resolvedPipelineWithReference(t, "roster", "roster.reference", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "roster"})
|
||||
materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{
|
||||
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||
}
|
||||
item := materialized.ArtifactLanes[0].ReferenceSet.Slots["roster"].Items[0]
|
||||
if item.MediaType != unknownMediaType {
|
||||
t.Fatalf("MediaType = %q, want %q", item.MediaType, unknownMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesAcceptsDeclaredMarkdownMediaType(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "glossary.md")
|
||||
writeReferenceFile(t, path, []byte("# Terms\n"))
|
||||
|
||||
slot := contracts.ReferenceSlot{Name: "glossary", AcceptedMediaTypes: []string{"text/markdown"}}
|
||||
resolved := resolvedPipelineWithReference(t, "glossary", "glossary.md", contracts.ReferenceBindingSourceConfig, slot)
|
||||
materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
|
||||
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||
}
|
||||
item := materialized.ArtifactLanes[0].ReferenceSet.Slots["glossary"].Items[0]
|
||||
if item.MediaType != "text/markdown" {
|
||||
t.Fatalf("MediaType = %q, want text/markdown", item.MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesAcceptsDeclaredJSONMediaType(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "roster.json")
|
||||
writeReferenceFile(t, path, []byte(`{"aria":"cleric"}`))
|
||||
|
||||
slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"application/json"}}
|
||||
resolved := resolvedPipelineWithReference(t, "roster", "roster.json", contracts.ReferenceBindingSourceConfig, slot)
|
||||
materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
|
||||
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||
}
|
||||
item := materialized.ArtifactLanes[0].ReferenceSet.Slots["roster"].Items[0]
|
||||
if item.MediaType != "application/json" {
|
||||
t.Fatalf("MediaType = %q, want application/json", item.MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesRejectsUnacceptedMediaType(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "roster.json")
|
||||
writeReferenceFile(t, path, []byte(`{"aria":"cleric"}`))
|
||||
|
||||
slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"text/markdown"}}
|
||||
resolved := resolvedPipelineWithReference(t, "roster", "roster.json", contracts.ReferenceBindingSourceConfig, slot)
|
||||
_, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
|
||||
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "media type") || !strings.Contains(err.Error(), "application/json") || !strings.Contains(err.Error(), "roster") {
|
||||
t.Fatalf("error = %v, want media type rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesMatchesAcceptedMediaTypesIgnoringParameters(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "roster.txt")
|
||||
writeReferenceFile(t, path, []byte("Aria\n"))
|
||||
|
||||
slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"text/plain; charset=utf-8"}}
|
||||
resolved := resolvedPipelineWithReference(t, "roster", "roster.txt", contracts.ReferenceBindingSourceConfig, slot)
|
||||
materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
|
||||
ConfigPath: filepath.Join(configDir, "config.yml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||
}
|
||||
item := materialized.ArtifactLanes[0].ReferenceSet.Slots["roster"].Items[0]
|
||||
if item.MediaType != referenceMediaType {
|
||||
t.Fatalf("MediaType = %q, want %q", item.MediaType, referenceMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesWarnsForEmptyFiles(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
path := filepath.Join(configDir, "empty.txt")
|
||||
|
||||
Reference in New Issue
Block a user