Files
notarius/internal/core/config/inheritance_test.go

620 lines
21 KiB
Go

package config
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestPipelineInheritancePreservesCompositionAndReplacesSteps(t *testing.T) {
t.Parallel()
tests := []struct {
name string
childSteps string
wantStepIDs []string
}{
{
name: "inherits ordered steps",
wantStepIDs: []string{"prepare", "summarize"},
},
{
name: "replaces ordered steps",
childSteps: `
steps:
- id: replacement
artifacts:
summary:
extract: {module: extract}`,
wantStepIDs: []string{"replacement"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := `version: 5
pipelines:
parent:
input: {module: input}
structured_output_repair_attempts: 2
steps:
- id: prepare
artifacts:
score:
extract: {module: extract, retries: 2}
- id: summarize
artifacts:
notes:
extract: {module: extract}
child:
extends: parent
llm_profile: local
structured_output_repair_attempts: 0` + test.childSteps + "\n"
loaded, err := loadConfigSource(configkit.Bytes("inheritance.yml", []byte(source)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
parent := loaded.Config.Pipelines["parent"]
child := loaded.Config.Pipelines["child"]
if parent.Input.Module != "input" || child.Input.Module != "input" {
t.Fatalf("parent/child inputs = %#v / %#v", parent.Input, child.Input)
}
if child.LLMProfile != "local" ||
child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 {
t.Fatalf("child overrides = %#v", child)
}
if parent.StructuredOutputRepairAttempts == nil || *parent.StructuredOutputRepairAttempts != 2 {
t.Fatalf("parent settings changed: %#v", parent)
}
if len(child.Steps) != len(test.wantStepIDs) {
t.Fatalf("child steps = %#v, want %v", child.Steps, test.wantStepIDs)
}
for i, want := range test.wantStepIDs {
if child.Steps[i].ID != want {
t.Fatalf("child step %d = %q, want %q", i, child.Steps[i].ID, want)
}
}
if test.childSteps == "" && child.Steps[0].Artifacts["score"].Extract.Module != "extract" {
t.Fatalf("inherited first artifact = %#v", child.Steps[0].Artifacts["score"])
}
for _, id := range []string{"parent", "child"} {
resolved, err := loaded.Config.Resolve(ResolveInput{PipelineID: id, Catalog: effectiveCatalog(t)})
if err != nil {
t.Fatalf("Resolve(%q) error = %v", id, err)
}
if resolved.ResolvedPipeline.ID != id {
t.Fatalf("resolved pipeline ID = %q, want %q", resolved.ResolvedPipeline.ID, id)
}
wantResolvedSteps := len(test.wantStepIDs)
if id == "parent" {
wantResolvedSteps = 2
}
if len(resolved.ResolvedPipeline.Steps) != wantResolvedSteps {
t.Fatalf("resolved %q steps = %#v", id, resolved.ResolvedPipeline.Steps)
}
if id == "child" && resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].Extract.LLMProfile != "local" {
t.Fatalf("resolved child profile = %q", resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].Extract.LLMProfile)
}
}
})
}
}
func TestArtifactLaneInheritanceUsesSiblingMappingComposition(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("lanes.yml", []byte(`version: 5
pipelines:
session:
input: {module: input}
artifacts:
base:
extract: {module: extract, retries: 2}
derived:
extends: base
extract: {retries: 4}
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
lanes := loaded.Config.Pipelines["session"].Artifacts
if lanes["base"].Extract.Module != "extract" || lanes["base"].Extract.Retries != 2 {
t.Fatalf("base lane = %#v", lanes["base"])
}
if lanes["derived"].Extract.Module != "extract" || lanes["derived"].Extract.Retries != 4 {
t.Fatalf("derived lane = %#v", lanes["derived"])
}
if _, err := loaded.Config.Resolve(ResolveInput{PipelineID: "session", Catalog: effectiveCatalog(t)}); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
}
func TestInheritedPresenceOverridesRemainExplicit(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("presence.yml", []byte(`version: 5
pipelines:
parent:
input: {module: input}
structured_output_repair_attempts: 3
chunk:
module: generic
validators: []
artifacts:
score:
extract:
module: extract
validators: [{module: configured-a}]
child:
extends: parent
structured_output_repair_attempts: 0
artifacts:
score:
extract:
validators: []
`)), LoadOptions{})
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("loadConfigSource() diagnostics = %#v", loadError.Diagnostics())
}
t.Fatalf("loadConfigSource() error = %v", err)
}
parent := loaded.Config.Pipelines["parent"]
child := loaded.Config.Pipelines["child"]
if parent.Chunk.Module != "generic" || !parent.Chunk.Validators.Set || parent.Chunk.Validators.Validators == nil || len(parent.Chunk.Validators.Validators) != 0 {
t.Fatalf("parent empty chunk validators = %#v", parent.Chunk)
}
if !parent.Artifacts["score"].Extract.Validators.Set || len(parent.Artifacts["score"].Extract.Validators.Validators) != 1 ||
parent.Artifacts["score"].Extract.Validators.Validators[0].Module != "configured-a" {
t.Fatalf("parent extraction validators = %#v", parent.Artifacts["score"].Extract.Validators)
}
if parent.StructuredOutputRepairAttempts == nil || *parent.StructuredOutputRepairAttempts != 3 {
t.Fatalf("parent repair budget = %#v", parent.StructuredOutputRepairAttempts)
}
if child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 {
t.Fatalf("child zero repair budget = %#v", child.StructuredOutputRepairAttempts)
}
extract := child.Artifacts["score"].Extract
if !extract.Validators.Set || extract.Validators.Validators == nil || len(extract.Validators.Validators) != 0 {
t.Fatalf("child empty extraction validators = %#v", extract.Validators)
}
if !child.Chunk.Validators.Set || child.Chunk.Validators.Validators == nil || len(child.Chunk.Validators.Validators) != 0 {
t.Fatalf("inherited empty chunk validators = %#v", child.Chunk.Validators)
}
resolved, err := loaded.Config.Resolve(ResolveInput{PipelineID: "child", Catalog: effectiveCatalog(t)})
if err != nil {
t.Fatalf("Resolve(child) error = %v", err)
}
lanes := resolved.ResolvedPipeline.AllArtifactLanes()
if len(lanes) != 1 || lanes[0].Extract.StructuredOutputRepairAttempts == nil || *lanes[0].Extract.StructuredOutputRepairAttempts != 0 {
t.Fatalf("resolved child repair budget = %#v", lanes)
}
if !resolved.ResolvedPipeline.Chunk.Validators.Set || len(resolved.ResolvedPipeline.Chunk.Validators.Validators) != 0 ||
!lanes[0].Extract.Validators.Set || len(lanes[0].Extract.Validators.Validators) != 0 {
t.Fatalf("resolved empty validator overrides = chunk %#v, extraction %#v", resolved.ResolvedPipeline.Chunk.Validators, lanes[0].Extract.Validators)
}
}
func TestInheritedFalseOptionRetainsExplicitMetadata(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("false-option.yml", []byte(`version: 5
pipelines:
parent:
input: {module: input}
output:
module: json
options:
evidence_context: {enabled: false}
child:
extends: parent
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
options := loaded.Config.Pipelines["child"].Output.Options["evidence_context"].(map[string]any)
if enabled, ok := options["enabled"].(bool); !ok || enabled {
t.Fatalf("inherited evidence_context.enabled = %#v", options["enabled"])
}
path := configkit.Keys("pipelines", "child", "output", "options", "evidence_context", "enabled")
info, ok := loaded.result.Metadata().Lookup(path)
if !ok || !info.Explicit || info.Null || len(info.Origin.Inheritance) == 0 {
t.Fatalf("inherited false metadata = %#v, found=%v", info, ok)
}
}
func TestInheritedAndDirectNullRepairBudgetsRemainInvalid(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
source string
path string
}{
{
name: "child null",
source: `version: 5
pipelines:
parent: {input: {module: input}, structured_output_repair_attempts: 2}
child:
extends: parent
structured_output_repair_attempts: null
`,
path: "/pipelines/child/structured_output_repair_attempts",
},
{
name: "inherited null",
source: `version: 5
pipelines:
parent: {input: {module: input}, structured_output_repair_attempts: null}
child: {extends: parent}
`,
path: "/pipelines/parent/structured_output_repair_attempts",
},
} {
t.Run(test.name, func(t *testing.T) {
_, err := loadConfigSource(configkit.Bytes("null.yml", []byte(test.source)), LoadOptions{})
assertDocumentDiagnosticAt(t, err, test.path)
})
}
}
func TestOverlayAndOverridesComposeBeforeInheritance(t *testing.T) {
t.Parallel()
root := t.TempDir()
overlays := filepath.Join(root, "overlays")
if err := os.MkdirAll(overlays, 0o700); err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "config.yml")
if err := os.WriteFile(main, []byte(`version: 5
pipelines:
parent:
input: {module: input}
structured_output_repair_attempts: 1
inherited:
extends: parent
overridden:
extends: parent
structured_output_repair_attempts: 0
cancelled:
extends: parent
input: {module: input}
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlays, "development.yml"), []byte(`pipelines:
parent:
structured_output_repair_attempts: 2
cancelled:
extends: null
`), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := Load(main, LoadOptions{Overlay: "development"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got := *loaded.Config.Pipelines["inherited"].StructuredOutputRepairAttempts; got != 2 {
t.Fatalf("overlay-inherited repair budget = %d, want 2", got)
}
if got := *loaded.Config.Pipelines["overridden"].StructuredOutputRepairAttempts; got != 0 {
t.Fatalf("child replacement repair budget = %d, want 0", got)
}
if loaded.Config.Pipelines["cancelled"].StructuredOutputRepairAttempts != nil {
t.Fatalf("cancelled child inherited parent settings: %#v", loaded.Config.Pipelines["cancelled"])
}
withOverride, err := Load(main, LoadOptions{
Overlay: "development",
Overrides: []configkit.Override{{
Path: configkit.Keys("pipelines", "parent", "structured_output_repair_attempts"),
Value: 3,
Flag: "--repair-budget",
}},
})
if err != nil {
t.Fatalf("Load() with explicit override error = %v", err)
}
if got := *withOverride.Config.Pipelines["inherited"].StructuredOutputRepairAttempts; got != 3 {
t.Fatalf("override-inherited repair budget = %d, want 3", got)
}
}
func TestInheritedReferencePathsKeepDeclaringSource(t *testing.T) {
t.Parallel()
root := t.TempDir()
parentDirectory := filepath.Join(root, "parents")
childDirectory := filepath.Join(root, "children")
overlayDirectory := filepath.Join(root, "overlays")
fixtureDirectory := filepath.Join(root, "fixtures")
for _, directory := range []string{parentDirectory, childDirectory, overlayDirectory, fixtureDirectory} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
for _, fixture := range []struct{ directory, name string }{
{parentDirectory, "shared.txt"},
{parentDirectory, "parent.txt"},
{parentDirectory, "step.txt"},
{childDirectory, "child.txt"},
{overlayDirectory, "overlay.txt"},
{fixtureDirectory, "outside.txt"},
} {
if err := os.WriteFile(filepath.Join(fixture.directory, fixture.name), []byte("fixture\n"), 0o600); err != nil {
t.Fatal(err)
}
}
parent := filepath.Join(parentDirectory, "pipelines.yml")
if err := os.WriteFile(parent, []byte(`pipelines:
" parent ":
input: {module: input}
references:
" shared ": {path: ./shared.txt}
nested:
extends: " shared "
source: {path: ./parent.txt}
outside: {path: ../fixtures/outside.txt}
steps:
- id: first
references:
step-source: {path: ./step.txt}
artifacts:
summary:
extract: {module: extract}
`), 0o600); err != nil {
t.Fatal(err)
}
child := filepath.Join(childDirectory, "pipelines.yml")
if err := os.WriteFile(child, []byte(`pipelines:
child:
extends: " parent "
references:
source: {path: ./child.txt}
`), 0o600); err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "notarius.yml")
if err := os.WriteFile(main, []byte(`version: 5
configkit:
imports:
- path: parents/pipelines.yml
- path: children/pipelines.yml
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlayDirectory, "development.yml"), []byte(`pipelines:
" parent ":
references:
" shared ": {path: ./overlay.txt}
`), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := loadConfig(main, LoadOptions{})
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
assertReferencePath := func(pipelineID, referenceID, want string) {
t.Helper()
if got := loaded.Config.Pipelines[pipelineID].References[referenceID].Path; got != want {
t.Errorf("%s reference %s path = %q, want %q", pipelineID, referenceID, got, want)
}
}
assertReferencePath("parent", "shared", filepath.Join(parentDirectory, "shared.txt"))
assertReferencePath("child", "shared", filepath.Join(parentDirectory, "shared.txt"))
assertReferencePath("child", "nested", filepath.Join(parentDirectory, "shared.txt"))
assertReferencePath("parent", "source", filepath.Join(parentDirectory, "parent.txt"))
assertReferencePath("child", "source", filepath.Join(childDirectory, "child.txt"))
// Keep ../ coverage separate from paths that distinguish the declaring file.
assertReferencePath("child", "outside", filepath.Join(fixtureDirectory, "outside.txt"))
if got := loaded.Config.Pipelines["child"].Steps[0].References["step-source"].Path; got != filepath.Join(parentDirectory, "step.txt") {
t.Errorf("inherited step reference path = %q", got)
}
overlaid, err := loadConfig(main, LoadOptions{Overlay: "development"})
if err != nil {
t.Fatalf("loadConfig() with overlay error = %v", err)
}
for _, pipelineID := range []string{"parent", "child"} {
for _, referenceID := range []string{"shared", "nested"} {
if got, want := overlaid.Config.Pipelines[pipelineID].References[referenceID].Path, filepath.Join(overlayDirectory, "overlay.txt"); got != want {
t.Errorf("overlaid %s reference %s path = %q, want %q", pipelineID, referenceID, got, want)
}
}
}
if got, want := overlaid.Config.Pipelines["child"].References["source"].Path, filepath.Join(childDirectory, "child.txt"); got != want {
t.Errorf("child replacement path = %q, want %q", got, want)
}
if got, want := overlaid.Config.Pipelines["child"].Steps[0].References["step-source"].Path, filepath.Join(parentDirectory, "step.txt"); got != want {
t.Errorf("overlaid inherited step reference path = %q, want %q", got, want)
}
}
func TestInheritedInspectionRetainsOriginsRedactionAndOwnership(t *testing.T) {
t.Parallel()
root := t.TempDir()
parentDirectory := filepath.Join(root, "parents")
childDirectory := filepath.Join(root, "children")
overlayDirectory := filepath.Join(root, "overlays")
for _, directory := range []string{parentDirectory, childDirectory, overlayDirectory} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
parentFile := filepath.Join(parentDirectory, "pipelines.yml")
if err := os.WriteFile(parentFile, []byte(`pipelines:
parent:
input: {module: input}
structured_output_repair_attempts: 2
references:
source: {path: ./shared.txt}
artifacts:
summary:
extract:
module: extract
options:
nested: {value: original}
credentials: {api_key: fake-parent-secret}
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(parentDirectory, "shared.txt"), []byte("source\n"), 0o600); err != nil {
t.Fatal(err)
}
childFile := filepath.Join(childDirectory, "pipelines.yml")
if err := os.WriteFile(childFile, []byte(`pipelines:
child:
extends: parent
structured_output_repair_attempts: 0
`), 0o600); err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "notarius.yml")
if err := os.WriteFile(main, []byte(`version: 5
configkit:
imports:
- path: parents/pipelines.yml
- path: children/pipelines.yml
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlayDirectory, "development.yml"), []byte(`pipelines:
parent:
artifacts:
summary:
extract:
options:
credentials: {api_key: fake-overlay-secret}
`), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := Load(main, LoadOptions{Overlay: "development"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
child := loaded.Config.Pipelines["child"]
wantPath := filepath.Join(parentDirectory, "shared.txt")
if got := child.References["source"].Path; got != wantPath {
t.Fatalf("runtime inherited path = %q, want %q", got, wantPath)
}
if child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 {
t.Fatalf("runtime inherited repair setting = %#v", child.StructuredOutputRepairAttempts)
}
var effective bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatalf("WriteEffective() error = %v", err)
}
if !strings.Contains(effective.String(), wantPath) || strings.Contains(effective.String(), "extends:") ||
!strings.Contains(effective.String(), "structured_output_repair_attempts: 0") {
t.Fatalf("effective configuration is inconsistent with runtime conversion: %s", effective.String())
}
for _, secret := range []string{"fake-parent-secret", "fake-overlay-secret"} {
if strings.Contains(effective.String(), secret) {
t.Fatalf("effective configuration exposed %q", secret)
}
}
for _, test := range []struct {
name string
path configkit.Path
}{
{name: "repair setting", path: configkit.Keys("pipelines", "child", "structured_output_repair_attempts")},
{name: "normalized reference", path: configkit.Keys("pipelines", "child", "references", "source", "path")},
} {
t.Run(test.name, func(t *testing.T) {
explanation, ok := loaded.result.Explain(test.path)
if !ok || len(explanation.History) == 0 {
t.Fatalf("inherited explanation = %#v, found=%v", explanation, ok)
}
inheritanceFromParent := false
for _, change := range explanation.History {
if len(change.Origin.Inheritance) == 0 {
continue
}
for _, location := range change.Origin.Locations {
for _, source := range loaded.result.Sources() {
if source.ID == location.SourceID && source.Path == parentFile {
inheritanceFromParent = true
}
}
}
}
if !inheritanceFromParent {
t.Fatalf("inheritance history lacks parent source evidence: %#v", explanation.History)
}
var rendered bytes.Buffer
if err := loaded.WriteExplanation(&rendered, test.path.String()); err != nil {
t.Fatalf("WriteExplanation() error = %v", err)
}
if !strings.Contains(rendered.String(), filepath.ToSlash(filepath.Join("parents", "pipelines.yml"))) {
t.Fatalf("explanation lacks parent source evidence: %s", rendered.String())
}
if test.name == "normalized reference" {
foundNormalization := false
for _, change := range explanation.History {
if change.Origin.Operation == configkit.ChangeNormalization {
foundNormalization = true
}
}
if !foundNormalization || !strings.Contains(rendered.String(), wantPath) {
t.Fatalf("normalized path evidence = %#v; rendered %s", explanation.History, rendered.String())
}
}
})
}
secretPath := configkit.Keys("pipelines", "child", "artifacts", "summary", "extract", "options", "credentials", "api_key")
secretExplanation, ok := loaded.result.Explain(secretPath)
if !ok || !secretExplanation.Effective.Redacted {
t.Fatalf("inherited secret explanation = %#v, found=%v", secretExplanation, ok)
}
redactedHistory := false
for _, change := range secretExplanation.History {
redactedHistory = redactedHistory || change.Before.Redacted || change.After.Redacted
}
if !redactedHistory {
t.Fatalf("secret history was not redacted: %#v", secretExplanation.History)
}
var secretOutput bytes.Buffer
if err := loaded.WriteExplanation(&secretOutput, secretPath.String()); err != nil {
t.Fatalf("WriteExplanation(secret) error = %v", err)
}
if strings.Contains(secretOutput.String(), "fake-parent-secret") || strings.Contains(secretOutput.String(), "fake-overlay-secret") ||
!strings.Contains(secretOutput.String(), "[REDACTED]") {
t.Fatalf("secret explanation did not redact values and history: %s", secretOutput.String())
}
childOptions := loaded.Config.Pipelines["child"].Artifacts["summary"].Extract.Options
childOptions["nested"].(map[string]any)["value"] = "changed"
parentValue := loaded.Config.Pipelines["parent"].Artifacts["summary"].Extract.Options["nested"].(map[string]any)["value"]
if parentValue != "original" {
t.Fatalf("child runtime mutation changed parent value: %#v", parentValue)
}
var frozen bytes.Buffer
if err := loaded.WriteEffective(&frozen); err != nil {
t.Fatalf("WriteEffective() after runtime mutation error = %v", err)
}
if !strings.Contains(frozen.String(), "original") || strings.Contains(frozen.String(), "changed") {
t.Fatalf("runtime mutation changed frozen inspection: %s", frozen.String())
}
}