diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 09c6d66..1a48246 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -164,7 +164,14 @@ and exact-integer regression coverage. ## 5. Complete provenance for generated artifacts and inferred dependencies -Status: Open — P3 +Status: Resolved — P3 + +Resolution: Generated catalog records now collect the complete base/profile family +definition set from ConfigKit history. Effective source ownership for materialized +dependency lists is rebuilt narrowly from retained explicit-list and input-edge +origins, including mixed and generated-family dependencies. CLI tests verify a +profiled family catalog agrees with generated source attribution and that inferred +ordinary edges name their input contributor instead of the central default. Two new reporting paths lose available declaration information: diff --git a/internal/app/config_artifacts_test.go b/internal/app/config_artifacts_test.go index 95ecf80..b78abcc 100644 --- a/internal/app/config_artifacts_test.go +++ b/internal/app/config_artifacts_test.go @@ -80,6 +80,35 @@ func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) { } } +func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) { + pipelinePath, campaignPath := writeInspectionFamilyConfig(t) + profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") + mustWriteTestFile(t, profilePath, `scriptorium: + artifact_families: + character_note: + prompt_id: dnd.character_note.testing +`) + + args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"} + var catalog bytes.Buffer + if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil { + t.Fatal(err) + } + records := catalogRecordsByKey(t, catalog.String()) + if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { + t.Fatalf("generated family definitions = %#v", got) + } + + var sources bytes.Buffer + if err := ConfigSources(context.Background(), args, &sources); err != nil { + t.Fatal(err) + } + want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + profilePath + if !strings.Contains(sources.String(), want) { + t.Fatalf("sources output missing profiled family contributor %q:\n%s", want, sources.String()) + } +} + func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) { dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index ba879be..83e5e53 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -620,6 +620,66 @@ func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing } } +func TestConfigSourcesAttributesInferredArtifactDependencies(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + inputPath := filepath.Join(dir, "input-only.yml") + profilePath := filepath.Join(dir, "profiles", "testing.yml") + mustWriteTestFile(t, pipelinePath, `configkit: + imports: + - path: input-only.yml +workspace: + root: /srv/narratio +whisperx: + transcribe_url: https://transcription.example.test/transcribe +scriptorium: + artifacts: + first: + enabled: false + output_path: artifacts/first.md + second: + enabled: false + output_path: artifacts/second.md + mixed: + enabled: false + output_path: artifacts/mixed.md + depends_on: [first] +`) + mustWriteTestFile(t, inputPath, `scriptorium: + artifacts: + input_only: + enabled: false + output_path: artifacts/input-only.md + inputs: + prior: + source: narratio.artifact.first +`) + mustWriteTestFile(t, profilePath, `scriptorium: + artifacts: + mixed: + inputs: + prior: + source: narratio.artifact.second +`) + + var out bytes.Buffer + if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath, + "scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath, + "scriptorium.artifacts.mixed.depends_on\tprofile\t" + profilePath, + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "scriptorium.artifacts.input_only.depends_on\tdefault\tdefault") { + t.Fatalf("inferred dependency retained default attribution:\n%s", out.String()) + } +} + func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) { dir := t.TempDir() rootPath := filepath.Join(dir, "pipeline.yml") diff --git a/internal/config/artifact_catalog.go b/internal/config/artifact_catalog.go index c4c9f80..f701fc4 100644 --- a/internal/config/artifact_catalog.go +++ b/internal/config/artifact_catalog.go @@ -50,7 +50,7 @@ func EffectiveArtifactCatalog(cfg *PipelineConfig, party ResolvedParty) ([]Effec Dependencies: append([]string(nil), artifact.DependsOn...), } if member, ok := cfg.familyCatalog.Members[key]; ok { - record.DefinitionSources = nonEmptySortedStrings([]string{member.Source}) + record.DefinitionSources = artifactFamilyDefinitionSources(cfg, member.Family) record.FamilyKey = member.Family record.CharacterID = member.CharacterID record.FamilySource = member.Source @@ -65,7 +65,21 @@ func EffectiveArtifactCatalog(cfg *PipelineConfig, party ResolvedParty) ([]Effec } func artifactDefinitionSources(cfg *PipelineConfig, key string) []string { - if cfg.resolution == nil { + return declarationDefinitionSources(cfg, + configkit.Keys("scriptorium", "artifacts", key), + "scriptorium.artifacts."+key, + ) +} + +func artifactFamilyDefinitionSources(cfg *PipelineConfig, key string) []string { + return declarationDefinitionSources(cfg, + configkit.Keys("scriptorium", "artifact_families", key), + "scriptorium.artifact_families."+key, + ) +} + +func declarationDefinitionSources(cfg *PipelineConfig, declaration configkit.Path, prefix string) []string { + if cfg == nil || cfg.resolution == nil { return nil } pathsByID := make(map[string]string, len(cfg.resolution.configkitSources)) @@ -78,7 +92,6 @@ func artifactDefinitionSources(cfg *PipelineConfig, key string) []string { } seen := map[string]struct{}{} - declaration := configkit.Keys("scriptorium", "artifacts", key) for _, change := range cfg.resolution.configkitMetadata.History(declaration) { if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerProfile { continue @@ -92,7 +105,6 @@ func artifactDefinitionSources(cfg *PipelineConfig, key string) []string { } } if len(seen) == 0 { - prefix := "scriptorium.artifacts." + key for _, ownership := range cfg.resolution.ownership { if ownership.path != prefix && !strings.HasPrefix(ownership.path, prefix+".") { continue diff --git a/internal/config/effective_pipeline.go b/internal/config/effective_pipeline.go index 2b31a24..9f54224 100644 --- a/internal/config/effective_pipeline.go +++ b/internal/config/effective_pipeline.go @@ -227,9 +227,39 @@ func effectivePipelineOwners(cfg *PipelineConfig) map[string][]string { for _, ownership := range cfg.resolution.ownership { owners[ownership.path] = append([]string(nil), ownership.sources...) } + if cfg.Scriptorium != nil { + for key, artifact := range cfg.Scriptorium.Artifacts { + paths := artifactDependencyOwnershipPaths(key, artifact) + if sources := sourcesForPipelinePaths(owners, paths); len(sources) != 0 { + sort.Strings(sources) + owners["scriptorium.artifacts."+key+".depends_on"] = sources + } + } + } return owners } +func artifactDependencyOwnershipPaths(artifactKey string, artifact ScriptoriumArtifactConfig) []string { + var paths []string + for _, dependency := range artifact.DependsOn { + for _, origin := range artifact.dependencyOrigins[dependency] { + prefix := "scriptorium.artifacts." + artifactKey + if origin.family != "" { + prefix = "scriptorium.artifact_families." + origin.family + } + switch { + case origin.inputName != "": + paths = append(paths, prefix+".inputs."+origin.inputName+".source") + case origin.family != "": + paths = append(paths, prefix+".member_dependencies") + default: + paths = append(paths, prefix+".depends_on") + } + } + } + return paths +} + func generatedArtifactFamilyForPath(path string, families ArtifactFamilyCatalog) (string, bool) { const prefix = "scriptorium.artifacts." if !strings.HasPrefix(path, prefix) { @@ -269,7 +299,7 @@ func generatedArtifactSources(path, familyKey string, families ArtifactFamilyCat case suffix == "output_path": candidates = []string{familyPrefix + "output_path_pattern"} case suffix == "depends_on": - candidates = []string{familyPrefix + "depends_on", familyPrefix + "member_dependencies"} + candidates = []string{path, 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}