Complete artifact provenance reporting

This commit is contained in:
2026-09-07 12:08:19 +00:00
parent 3ec54b8c72
commit 0ac8072a9a
5 changed files with 144 additions and 6 deletions

View File

@@ -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:

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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}