package config import ( "fmt" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" ) // EffectivePipelineRootPath reports the absolute root pipeline path retained // while resolving cfg. It is empty for a pipeline not loaded through the // production loader. func EffectivePipelineRootPath(cfg *PipelineConfig) string { if cfg == nil || cfg.resolution == nil { return "" } return cfg.resolution.rootPath } // EffectivePipelineImports reports the ordered, absolute import paths that // contributed to cfg. func EffectivePipelineImports(cfg *PipelineConfig) []string { if cfg == nil || cfg.resolution == nil { return nil } return append([]string(nil), cfg.resolution.imports...) } // NormalizedConfigurationPath returns a clean absolute path when possible for // display-only configuration provenance. func NormalizedConfigurationPath(path string) string { return normalizedProvenancePath(path) } // EffectivePipelineSourceRecord identifies one effective logical field and a // safe source that contributed to it. Generated values intentionally have two // records: their family declaration and the canonical party that supplied the // member-specific value. type EffectivePipelineSourceRecord struct { Path string Role string Source string } // EffectivePipelineSources projects pipeline ownership after defaults and // optional family expansion. It never includes effective values or raw secret // material, only logical paths and source identifiers. func EffectivePipelineSources(cfg *PipelineConfig, party ResolvedParty) ([]EffectivePipelineSourceRecord, error) { if cfg == nil { return nil, fmt.Errorf("pipeline config is required") } data, err := MarshalEffectivePipeline(cfg) if err != nil { return nil, err } document, err := parseCompositionBytes("effective pipeline", data) if err != nil { return nil, err } paths := effectivePipelineFieldPaths(document.root) owners := effectivePipelineOwners(cfg) families := ArtifactFamilies(cfg) publishCount := 0 if cfg.Publish != nil { publishCount = len(cfg.Publish.Outputs) } records := make([]EffectivePipelineSourceRecord, 0, len(paths)+publishCount) for _, path := range paths { if path == "publish.outputs" { continue } if familyKey, ok := generatedArtifactFamilyForPath(path, families); ok { records = appendGeneratedArtifactSources(records, path, familyKey, families, owners, party) continue } records = appendPipelineOwners(records, path, owners[path], cfg) } records = appendPublishOutputSources(records, cfg, families, party, owners) sort.Slice(records, func(left, right int) bool { if records[left].Path != records[right].Path { return records[left].Path < records[right].Path } if records[left].Role != records[right].Role { return records[left].Role < records[right].Role } return records[left].Source < records[right].Source }) return records, nil } // EffectiveCampaignSources projects selected campaign and party ownership // without retaining or rendering campaign values. Canonical players are a // derived party value, while a legacy players file remains explicitly marked // as legacy input provenance. func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord { if campaign == nil { return nil } campaignSource := normalizedProvenancePath(campaignPath) records := []EffectivePipelineSourceRecord{ {Path: "campaign.campaign_id", Role: "campaign", Source: campaignSource}, {Path: "campaign.inputs.autocorrect_file", Role: "campaign", Source: campaignSource}, {Path: "campaign.inputs.glossary_file", Role: "campaign", Source: campaignSource}, {Path: "campaign.inputs.speakers_file", Role: "campaign", Source: campaignSource}, } if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" { records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource}) } if party.Mode == PartyModeCanonical { partySource := normalizedProvenancePath(party.Source.Path) records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}, EffectivePipelineSourceRecord{Path: "derived.players", Role: "party", Source: partySource}, ) } else { partySource := normalizedProvenancePath(party.Source.Path) records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}) if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" { records = append(records, EffectivePipelineSourceRecord{ Path: "campaign.inputs.players_file", Role: "legacy_player", Source: campaignInputSourcePath(campaignPath, campaign.Inputs.PlayersFile), }) } } sort.Slice(records, func(left, right int) bool { if records[left].Path != records[right].Path { return records[left].Path < records[right].Path } if records[left].Role != records[right].Role { return records[left].Role < records[right].Role } return records[left].Source < records[right].Source }) return records } // MarshalEffectivePipeline renders the validated, normalized pipeline as one // deterministic YAML document. Composition declarations and runtime-only // resolution data are excluded. Artifact family declarations are also omitted // because a resolved pipeline exposes their concrete artifacts instead. func MarshalEffectivePipeline(cfg *PipelineConfig) ([]byte, error) { if cfg == nil { return nil, fmt.Errorf("pipeline config is required") } data, err := yaml.Marshal(cfg) if err != nil { return nil, fmt.Errorf("serialize effective pipeline: %w", err) } document, err := parseCompositionBytes("effective pipeline", data) if err != nil { return nil, err } removeResolutionOnlyPipelineFields(document) return document.canonicalYAML() } func removeResolutionOnlyPipelineFields(document *compositionDocument) { if document == nil || document.root == nil { return } scriptoriumIndex := compositionFieldIndex(document.root.fields, "scriptorium") if scriptoriumIndex < 0 { return } scriptorium := document.root.fields[scriptoriumIndex].value if scriptorium == nil || scriptorium.kind != yaml.MappingNode { return } familyIndex := compositionFieldIndex(scriptorium.fields, "artifact_families") if familyIndex < 0 { return } scriptorium.fields = append(scriptorium.fields[:familyIndex], scriptorium.fields[familyIndex+1:]...) for index := range scriptorium.fields { scriptorium.fields[index].order = index } } func effectivePipelineFieldPaths(node *compositionNode) []string { var paths []string var visit func(*compositionNode) visit = func(current *compositionNode) { if current == nil { return } if current.kind == yaml.MappingNode && len(current.fields) > 0 { for _, field := range current.fields { visit(field.value) } return } paths = append(paths, current.path) } visit(node) return paths } func effectivePipelineOwners(cfg *PipelineConfig) map[string][]string { owners := make(map[string][]string) if cfg == nil || cfg.resolution == nil { return owners } for _, ownership := range cfg.resolution.ownership { owners[ownership.path] = append([]string(nil), ownership.sources...) } return owners } func generatedArtifactFamilyForPath(path string, families ArtifactFamilyCatalog) (string, bool) { const prefix = "scriptorium.artifacts." if !strings.HasPrefix(path, prefix) { return "", false } memberPath := strings.TrimPrefix(path, prefix) memberKey, _, _ := strings.Cut(memberPath, ".") member, exists := families.Members[memberKey] if !exists { return "", false } _, exists = families.Families[member.Family] return member.Family, exists } func appendGeneratedArtifactSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord { for _, source := range generatedArtifactSources(path, familyKey, families, owners) { role := "family" if source == pipelineDefaultOwnershipSource { role = "default" } records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)}) } if source := normalizedProvenancePath(party.Source.Path); source != "" { records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source}) } return records } func generatedArtifactSources(path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string) []string { const artifactPrefix = "scriptorium.artifacts." memberPath := strings.TrimPrefix(path, artifactPrefix) _, suffix, _ := strings.Cut(memberPath, ".") familyPrefix := "scriptorium.artifact_families." + familyKey + "." candidates := []string{familyPrefix + suffix} switch { case suffix == "output_path": candidates = []string{familyPrefix + "output_path_pattern"} case suffix == "depends_on": candidates = []string{familyPrefix + "depends_on", familyPrefix + "member_dependencies"} case strings.HasPrefix(suffix, "vars."): variable := strings.TrimPrefix(suffix, "vars.") candidates = []string{familyPrefix + "vars." + variable, familyPrefix + "member_vars." + variable} } sources := sourcesForPipelinePaths(owners, candidates) if len(sources) != 0 { return sources } if family, ok := families.Families[familyKey]; ok && family.Source != "" { return []string{family.Source} } return []string{pipelineDefaultOwnershipSource} } func sourcesForPipelinePaths(owners map[string][]string, paths []string) []string { seen := make(map[string]struct{}) var sources []string for _, path := range paths { for _, source := range owners[path] { if _, exists := seen[source]; exists { continue } seen[source] = struct{}{} sources = append(sources, source) } } return sources } func appendPipelineOwners(records []EffectivePipelineSourceRecord, path string, sources []string, cfg *PipelineConfig) []EffectivePipelineSourceRecord { if len(sources) == 0 { sources = []string{pipelineDefaultOwnershipSource} } for _, source := range sources { records = append(records, EffectivePipelineSourceRecord{ Path: path, Role: pipelineSourceRole(cfg, source), Source: normalizedProvenanceSource(source), }) } return records } func appendPublishOutputSources(records []EffectivePipelineSourceRecord, cfg *PipelineConfig, families ArtifactFamilyCatalog, party ResolvedParty, owners map[string][]string) []EffectivePipelineSourceRecord { if cfg == nil || cfg.Publish == nil { return records } generated := make(map[string]string) for familyKey, family := range families.Families { if family.Publish == nil || !family.Publish.Enabled { continue } for _, key := range family.Members { generated["narratio.artifact."+key] = familyKey } } for index, output := range cfg.Publish.Outputs { path := fmt.Sprintf("publish.outputs[%d]", index) if familyKey, ok := generated[strings.TrimSpace(output.Source)]; ok { records = appendGeneratedPublishSources(records, path, familyKey, families, owners, party) continue } records = appendPipelineOwners(records, path, owners["publish.outputs"], cfg) } return records } func appendGeneratedPublishSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord { familyPrefix := "scriptorium.artifact_families." + familyKey + ".publish." sources := sourcesForPipelinePaths(owners, []string{familyPrefix + "enabled", familyPrefix + "required", familyPrefix + "dest_pattern"}) if len(sources) == 0 { if family, ok := families.Families[familyKey]; ok && family.Source != "" { sources = []string{family.Source} } else { sources = []string{pipelineDefaultOwnershipSource} } } for _, source := range sources { role := "family" if source == pipelineDefaultOwnershipSource { role = "default" } records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)}) } if source := normalizedProvenancePath(party.Source.Path); source != "" { records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source}) } return records } func pipelineSourceRole(cfg *PipelineConfig, source string) string { if source == pipelineDefaultOwnershipSource { return "default" } if cfg == nil || cfg.resolution == nil { return "pipeline" } if source == cfg.resolution.rootPath { return "root" } if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath { return "profile" } for _, imported := range cfg.resolution.imports { if source == imported { return "import" } } return "pipeline" } func normalizedProvenanceSource(source string) string { if source == pipelineDefaultOwnershipSource { return source } return normalizedProvenancePath(source) } func normalizedProvenancePath(path string) string { if strings.TrimSpace(path) == "" { return "" } abs, err := filepath.Abs(path) if err != nil { return filepath.Clean(path) } return filepath.Clean(abs) } func campaignInputSourcePath(campaignPath, configuredPath string) string { if filepath.IsAbs(configuredPath) { return normalizedProvenancePath(configuredPath) } return normalizedProvenancePath(filepath.Join(filepath.Dir(campaignPath), configuredPath)) }