Add configuration source reporting

This commit is contained in:
2026-08-30 14:56:22 +00:00
parent a102db36af
commit dde7f76ecb
11 changed files with 654 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"io"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -21,12 +22,15 @@ type inspectionFlags struct {
type inspectionConfig struct {
PipelinePath string
Pipeline *config.PipelineConfig
CampaignPath string
Campaign *config.CampaignConfig
Party config.ResolvedParty
}
// Config dispatches read-only pipeline configuration commands.
func Config(ctx context.Context, args []string, out io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("config: expected subcommand: validate|show")
return fmt.Errorf("config: expected subcommand: validate|show|sources")
}
if args[0] == "--help" || args[0] == "-h" {
writeConfigCommandUsage(out)
@@ -37,6 +41,8 @@ func Config(ctx context.Context, args []string, out io.Writer) error {
return ConfigValidate(ctx, args[1:], out)
case "show":
return ConfigShow(ctx, args[1:], out)
case "sources":
return ConfigSources(ctx, args[1:], out)
default:
return fmt.Errorf("config: unknown subcommand %q", args[0])
}
@@ -83,6 +89,37 @@ func ConfigShow(_ context.Context, args []string, out io.Writer) error {
return err
}
// ConfigSources validates and reports deterministic effective configuration
// ownership without loading session state or runtime collaborators.
func ConfigSources(_ context.Context, args []string, out io.Writer) error {
flags, err := parseInspectionFlags("config sources", args, out)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
resolved, err := resolveInspectionConfig(flags)
if err != nil {
return fmt.Errorf("config sources: %w", err)
}
records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party)
if err != nil {
return fmt.Errorf("config sources: %w", err)
}
records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...)
sortInspectionSourceRecords(records)
if err := writeInspectionSourceHeader(out, resolved); err != nil {
return err
}
for _, record := range records {
if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil {
return err
}
}
return nil
}
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(out)
@@ -155,6 +192,9 @@ func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
return &inspectionConfig{
PipelinePath: loaded.PipelinePath,
Pipeline: loaded.Pipeline,
CampaignPath: loaded.CampaignPath,
Campaign: loaded.Campaign,
Party: loaded.Party,
}, nil
}
@@ -194,7 +234,63 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
}
func writeConfigCommandUsage(out io.Writer) {
fmt.Fprintln(out, "Usage: narratio config <validate|show>")
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources>")
fmt.Fprintln(out)
fmt.Fprintln(out, "Use config validate to check an effective pipeline or config show to print normalized effective YAML.")
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, or config sources to report ownership.")
}
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil {
return err
}
imports := config.EffectivePipelineImports(resolved.Pipeline)
if len(imports) == 0 {
if _, err := fmt.Fprintln(out, "imports: none"); err != nil {
return err
}
}
for _, imported := range imports {
if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil {
return err
}
}
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok {
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil {
return err
}
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
return err
}
if resolved.Campaign == nil {
if _, err := fmt.Fprintln(out, "campaign: none"); err != nil {
return err
}
if _, err := fmt.Fprintln(out, "party: none"); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil {
return err
}
_, err := fmt.Fprintln(out, "path\trole\tsource")
return err
}
func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) {
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
})
}

View File

@@ -21,11 +21,12 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
want string
code int
}{
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show>"},
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources>"},
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
{name: "unknown", args: []string{"config", "sources"}, want: `config: unknown subcommand "sources"`, code: 1},
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show", code: 1},
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
{name: "unknown", args: []string{"config", "diff"}, want: `config: unknown subcommand "diff"`, code: 1},
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources", code: 1},
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
@@ -86,6 +87,13 @@ whisperx:
if strings.Contains(show, secret) {
t.Fatalf("show output leaked a raw secret: %q", show)
}
var sourcesOut bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath}, &sourcesOut); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
if strings.Contains(sourcesOut.String(), secret) {
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
}
if calls != 0 {
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
}
@@ -189,6 +197,133 @@ whisperx:
}
}
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
var first, second bytes.Buffer
if err := ConfigSources(context.Background(), args, &first); err != nil {
t.Fatalf("first ConfigSources() error = %v", err)
}
if err := ConfigSources(context.Background(), args, &second); err != nil {
t.Fatalf("second ConfigSources() error = %v", err)
}
if first.String() != second.String() {
t.Fatalf("sources output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
}
output := first.String()
for _, want := range []string{
"root: ",
"imports: none",
"profile: name=testing selection=cli overlay=",
"campaign: id=sample-campaign source=",
"party: mode=legacy source=",
"digest: ",
"path\trole\tsource\n",
"workspace.root\troot\t",
"whisperx.language\tprofile\t",
"campaign.inputs.players_file\tlegacy_player\t",
"trim.enabled\tdefault\tdefault",
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
output := out.String()
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
for _, want := range []string{
"party: mode=canonical source=" + partyPath,
"derived.players\tparty\t" + partyPath,
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,
"scriptorium.artifacts.character_note_arannis.depends_on\tfamily\t" + pipelinePath,
"scriptorium.artifacts.character_note_arannis.inputs.prior.source\tparty\t" + partyPath,
"publish.outputs[",
"\tfamily\t" + pipelinePath,
"\tparty\t" + partyPath,
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
data, err := os.ReadFile(pipelinePath)
if err != nil {
t.Fatal(err)
}
profilePath := filepath.Join(filepath.Dir(pipelinePath), "testing.yml")
composition := "composition:\n default_profile: testing\n profiles:\n testing:\n overlay: testing.yml\n"
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, profilePath, `scriptorium:
artifact_families:
character_note:
enabled: true
prompt_id: dnd.character_note
`)
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath
if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String())
}
}
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) {
dir := t.TempDir()
rootPath := filepath.Join(dir, "pipeline.yml")
importPath := filepath.Join(dir, "base.yml")
profilePath := filepath.Join(dir, "testing.yml")
mustWriteTestFile(t, rootPath, `composition:
imports: [base.yml]
default_profile: testing
profiles:
testing:
overlay: testing.yml
workspace:
root: /srv/narratio
whisperx:
transcribe_url: https://transcription.example.com/transcribe
`)
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n")
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", rootPath}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
output := out.String()
for _, want := range []string{
"import: " + importPath,
"profile: name=testing selection=default overlay=" + profilePath,
"workspace.root\troot\t" + rootPath,
"storage.backend\timport\t" + importPath,
"whisperx.language\tprofile\t" + profilePath,
"trim.enabled\tdefault\tdefault",
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
@@ -204,10 +339,25 @@ whisperx:
transcribe_url: https://transcription.example.com/transcribe
scriptorium:
artifact_families:
character_meta:
enabled: false
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/meta.md
character_note:
enabled: false
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/note.md
member_dependencies: [character_meta]
inputs:
prior: {source: narratio.member_artifact.character_meta, required: true}
member_vars:
character_name: character.name
publish:
enabled: true
required: true
publish:
enabled: true
upload_run: false
`)
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
inputs: