diff --git a/docs/cli.md b/docs/cli.md index 61efcfb..1149fa0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,7 @@ Top-level commands: - `publish `: force-run publish. - `clean ` or `clean --all`: remove local work/spool state. - `session `: session helper commands. +- `config `: validate or display resolved pipeline configuration. Session subcommands: @@ -77,6 +78,30 @@ Commands with additional positionals keep their command-specific order: ## Command Reference +### `config validate` and `config show` + +```bash +narratio config validate [--config ] [--campaign | --campaign-file ] [--profile ] +narratio config show [--config ] [--campaign | --campaign-file ] [--profile ] +``` + +These commands resolve the selected profile, defaults, ordinary paths, and—if +a campaign is selected—the campaign-owned party. They neither discover or load +a session nor create a workspace, manifest, run, lock, adapter, remote +connection, or credential environment. + +Campaign selection is optional for a pipeline without party-driven artifact +families. A pipeline with `scriptorium.artifact_families` needs a selected or +configured default campaign so Narratio can expand its concrete artifacts and +publish rules. `--campaign` and `--campaign-file` remain mutually exclusive. +Session, range, force, and artifact-execution flags are not accepted. + +`config validate` writes a concise root-path, selected-profile (or `none`), and +effective-digest summary after successful complete validation. `config show` +writes one deterministic, secret-free YAML document containing defaulted and +expanded concrete configuration. It omits composition declarations, artifact +family declarations, and runtime provenance. + ### `version` ```bash diff --git a/docs/config.md b/docs/config.md index ee9e7ef..a3b951b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -42,6 +42,23 @@ The downloaded remote session file is command-scoped: Narratio removes it after the command finishes and records only the remote object provenance alongside the durable copied session input. +### Read-only effective pipeline inspection + +`narratio config validate` and `narratio config show` use the same `--config`, +`--campaign`, `--campaign-file`, and `--profile` selection rules as pipeline +commands, but do not select, discover, or load a session. They do not read +credential values or create runtime state. + +Campaign selection is optional only when the resolved pipeline has no +`scriptorium.artifact_families`. When families are declared, Narratio selects a +campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`, +then parses the campaign-owned party and expands concrete artifacts and any +family publish rules before validation. `config validate` prints the resulting +root, profile, and effective digest. `config show` emits the normalized +effective pipeline YAML, with defaults and concrete expansion included but +composition and family declarations omitted. The [CLI reference](cli.md#config-validate-and-config-show) +owns command syntax and output conventions. + ### Identity segments Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 65b129f..577087d 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -52,6 +52,22 @@ download retain that exact pipeline object and its private provenance. Removing a temporary downloaded session file therefore cannot invalidate the resolved pipeline or campaign context. +The application also has a separate read-only inspection resolver for `config +validate` and `config show`. It uses the same production root/profile and +campaign selection functions, but never routes through session discovery, +remote-session download, secret loading, adapter composition, workspace +initialization, manifest access, or cleanup. A pipeline with retained artifact +family declarations must resolve its selected campaign before ordinary pipeline +validation, which expands its canonical-party members and generated publish +rules. A pipeline without those declarations may be validated by itself. + +`MarshalEffectivePipeline` is the configuration-owned projection for `config +show`. It serializes the typed, defaulted effective mapping through the +deterministic composition renderer, then removes resolution-only artifact +family declarations. The result contains no composition envelope or private +provenance fields and has one trailing newline; commands do not marshal runtime +objects directly. + Campaign context construction also reads and classifies the campaign-owned party source through `ParseParty`. A canonical party retains its raw bytes and normalized roster in runtime-only `ResolvedParty` provenance, while a legacy diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index abb44f5..b2307ae 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -952,7 +952,7 @@ rules before ordinary validation and publication. ## Stage 17 — Read-Only `config validate` And `config show` -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/app/commands.go b/internal/app/commands.go index 6d4fab0..dd524fd 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -7,7 +7,7 @@ import ( "strings" ) -var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"} +var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session", "config"} var runCommandFn = Run @@ -40,6 +40,8 @@ func Execute(args []string, stdout, stderr io.Writer) int { err = Session(ctx, cmdArgs, stdout) case "clean": err = Clean(ctx, cmdArgs, stdout) + case "config": + err = Config(ctx, cmdArgs, stdout) default: fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd) printUsage(stderr) diff --git a/internal/app/config_commands.go b/internal/app/config_commands.go new file mode 100644 index 0000000..6e81791 --- /dev/null +++ b/internal/app/config_commands.go @@ -0,0 +1,200 @@ +package app + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +type inspectionFlags struct { + pipelinePath string + campaignPath string + campaignFilePath string + profile singletonStringFlag +} + +type inspectionConfig struct { + PipelinePath string + Pipeline *config.PipelineConfig +} + +// 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") + } + if args[0] == "--help" || args[0] == "-h" { + writeConfigCommandUsage(out) + return nil + } + switch args[0] { + case "validate": + return ConfigValidate(ctx, args[1:], out) + case "show": + return ConfigShow(ctx, args[1:], out) + default: + return fmt.Errorf("config: unknown subcommand %q", args[0]) + } +} + +// ConfigValidate validates one fully resolved effective pipeline without +// loading session state or constructing runtime collaborators. +func ConfigValidate(_ context.Context, args []string, out io.Writer) error { + flags, err := parseInspectionFlags("config validate", 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 validate: %w", err) + } + _, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n", + inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline)) + return err +} + +// ConfigShow validates and renders one fully resolved effective pipeline +// without loading session state or constructing runtime collaborators. +func ConfigShow(_ context.Context, args []string, out io.Writer) error { + flags, err := parseInspectionFlags("config show", 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 show: %w", err) + } + data, err := config.MarshalEffectivePipeline(resolved.Pipeline) + if err != nil { + return fmt.Errorf("config show: %w", err) + } + _, err = out.Write(data) + return err +} + +func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) { + fs := flag.NewFlagSet(command, flag.ContinueOnError) + fs.SetOutput(out) + var flags inspectionFlags + fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") + fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") + flags.profile.name = "profile" + fs.Var(&flags.profile, "profile", "named pipeline profile") + fs.Usage = func() { + fmt.Fprintf(out, "Usage: narratio %s [--config ] [--campaign | --campaign-file ] [--profile ]\n\n", command) + fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Flags:") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err) + } + if fs.NArg() != 0 { + return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command) + } + if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" { + return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command) + } + return flags, nil +} + +func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) { + pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}) + if err != nil { + return nil, err + } + + needsCampaign := pipelineHasArtifactFamilies(pipeline) + hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != "" + if !needsCampaign && !hasCampaignSelection { + if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil { + return nil, err + } + return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil + } + + campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath) + if err != nil { + if needsCampaign { + return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err) + } + return nil, err + } + campaign, err := config.LoadCampaign(campaignPath) + if err != nil { + return nil, err + } + if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" { + if got := config.CampaignID(campaign); got != selectedID { + return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID) + } + } + loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign) + if err != nil { + return nil, err + } + if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil { + return nil, err + } + if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil { + return nil, err + } + return &inspectionConfig{ + PipelinePath: loaded.PipelinePath, + Pipeline: loaded.Pipeline, + }, nil +} + +func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool { + return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0 +} + +func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error { + if err := config.ValidatePipelineConfig(pipeline); err != nil { + return fmt.Errorf("pipeline config %q invalid: %w", path, err) + } + return nil +} + +func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error { + if err := config.ValidateCampaignConfig(campaign); err != nil { + return fmt.Errorf("campaign config %q invalid: %w", path, err) + } + return nil +} + +func inspectionRootPath(resolved *inspectionConfig) string { + if resolved == nil { + return "" + } + if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" { + return root + } + return resolved.PipelinePath +} + +func inspectionProfile(pipeline *config.PipelineConfig) string { + if profile, ok := config.SelectedPipelineProfile(pipeline); ok { + return profile.Name + } + return "none" +} + +func writeConfigCommandUsage(out io.Writer) { + fmt.Fprintln(out, "Usage: narratio config ") + fmt.Fprintln(out) + fmt.Fprintln(out, "Use config validate to check an effective pipeline or config show to print normalized effective YAML.") +} diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go new file mode 100644 index 0000000..1367d90 --- /dev/null +++ b/internal/app/config_commands_test.go @@ -0,0 +1,226 @@ +package app + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func TestConfigCommandHelpAndDispatch(t *testing.T) { + for _, test := range []struct { + name string + args []string + want string + code int + }{ + {name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config "}, + {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: "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}, + {name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1}, + } { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Execute(test.args, &stdout, &stderr) + if code != test.code { + t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String()) + } + combined := stdout.String() + stderr.String() + if !strings.Contains(combined, test.want) { + t.Fatalf("output = %q, want %q", combined, test.want) + } + }) + } +} + +func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) { + dir := t.TempDir() + workspaceRoot := filepath.Join(dir, "workspace-does-not-exist") + pipelinePath := filepath.Join(dir, "pipeline.yml") + secretsDir := filepath.Join(dir, "secrets") + const secret = "inspection-secret-must-not-escape" + mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n") + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) + defer server.Close() + mustWriteTestFile(t, pipelinePath, `workspace: + root: `+workspaceRoot+` +secrets: + env_dir: `+secretsDir+` +whisperx: + transcribe_url: `+server.URL+` +`) + t.Setenv("INSPECTION_SECRET", secret) + + var validateOut bytes.Buffer + if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil { + t.Fatalf("ConfigValidate() error = %v", err) + } + if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") { + t.Fatalf("validate output = %q", validateOut.String()) + } + + var showOut bytes.Buffer + if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil { + t.Fatalf("ConfigShow() error = %v", err) + } + show := showOut.String() + if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) { + t.Fatalf("show output = %q", show) + } + if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") { + t.Fatalf("show output leaked resolution-only fields: %q", show) + } + if strings.Contains(show, secret) { + t.Fatalf("show output leaked a raw secret: %q", show) + } + if calls != 0 { + t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls) + } + if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) { + t.Fatalf("workspace root stat error = %v, want absent", err) + } +} + +func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) + enableCommandTestProfiles(t, pipelinePath) + + var defaultOut bytes.Buffer + if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil { + t.Fatalf("default ConfigValidate() error = %v", err) + } + if !strings.Contains(defaultOut.String(), "profile=production") { + t.Fatalf("default output = %q", defaultOut.String()) + } + + var explicitOut bytes.Buffer + if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil { + t.Fatalf("explicit ConfigShow() error = %v", err) + } + if !strings.Contains(explicitOut.String(), "language: fr") { + t.Fatalf("explicit show output = %q", explicitOut.String()) + } + + for _, args := range [][]string{ + {"--config", pipelinePath, "--campaign", "sample-campaign"}, + {"--config", pipelinePath, "--campaign-file", campaignPath}, + } { + var out bytes.Buffer + if err := ConfigValidate(context.Background(), args, &out); err != nil { + t.Fatalf("ConfigValidate(%q) error = %v", args, err) + } + } +} + +func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) { + pipelinePath, campaignPath := writeInspectionFamilyConfig(t) + + for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} { + var out bytes.Buffer + err := command(context.Background(), []string{"--config", pipelinePath}, &out) + if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") { + t.Fatalf("family command without campaign error = %v", err) + } + } + + var show bytes.Buffer + if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil { + t.Fatalf("ConfigShow() error = %v", err) + } + if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") { + t.Fatalf("expanded show output = %q", show.String()) + } + + loadedPipeline, err := config.LoadPipeline(pipelinePath) + if err != nil { + t.Fatal(err) + } + loadedCampaign, err := config.LoadCampaign(campaignPath) + if err != nil { + t.Fatal(err) + } + loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign) + if err != nil { + t.Fatal(err) + } + var validateOut bytes.Buffer + if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil { + t.Fatal(err) + } + if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) { + t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline)) + } +} + +func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) { + pipelinePath, campaignPath := writeInspectionFamilyConfig(t) + mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n") + if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("canonical party error = %v", err) + } + + dir := t.TempDir() + rootPath := filepath.Join(dir, "pipeline.yml") + mustWriteTestFile(t, rootPath, `composition: + imports: [conf.yml] +workspace: + root: /one +whisperx: + transcribe_url: https://transcription.example.com/transcribe +`) + mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n") + err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "workspace.root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") { + t.Fatalf("import diagnostic = %v", err) + } +} + +func writeInspectionFamilyConfig(t *testing.T) (string, string) { + t.Helper() + dir := t.TempDir() + campaignRoot := filepath.Join(dir, "campaigns") + campaignDir := filepath.Join(campaignRoot, "sample-campaign") + pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(campaignDir, "campaign.yml") + mustWriteTestFile(t, pipelinePath, `workspace: + root: `+filepath.Join(dir, "workspace")+` +campaigns: + root: `+campaignRoot+` +whisperx: + transcribe_url: https://transcription.example.com/transcribe +scriptorium: + artifact_families: + character_note: + enabled: false + for_each: party.characters + output_path_pattern: artifacts/characters/{character_id}/note.md +`) + mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml + party_file: ./party.yml +`) + mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: {name: Arannis, classes: [{name: wizard}]} +`) + return pipelinePath, campaignPath +} diff --git a/internal/config/effective_pipeline.go b/internal/config/effective_pipeline.go new file mode 100644 index 0000000..d19444f --- /dev/null +++ b/internal/config/effective_pipeline.go @@ -0,0 +1,60 @@ +package config + +import ( + "fmt" + + "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 +} + +// 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 + } +} diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 9f0aff7..40e6c50 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -34,6 +34,53 @@ inputs: } } +func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pipeline.yml") + if err := os.WriteFile(path, []byte(`scriptorium: + artifacts: + zeta: + enabled: false + output_path: artifacts/zeta.md + alpha: + enabled: false + output_path: artifacts/alpha.md +workspace: + root: /srv/narratio +whisperx: + transcribe_url: https://transcription.example.com/transcribe +`), 0o644); err != nil { + t.Fatal(err) + } + + first, err := LoadPipeline(path) + if err != nil { + t.Fatal(err) + } + second, err := LoadPipeline(path) + if err != nil { + t.Fatal(err) + } + firstYAML, err := MarshalEffectivePipeline(first) + if err != nil { + t.Fatal(err) + } + secondYAML, err := MarshalEffectivePipeline(second) + if err != nil { + t.Fatal(err) + } + if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") { + t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML) + } + output := string(firstYAML) + if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") { + t.Fatalf("effective YAML leaked runtime fields: %q", output) + } + if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") { + t.Fatalf("configured artifacts are not sorted: %q", output) + } +} + func TestValidateMissingAudioSource(t *testing.T) { cfg := loadedValidConfig(t) cfg.Session.Inputs.AudioDir = "" diff --git a/internal/config/validate.go b/internal/config/validate.go index ac2589f..b7720ea 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -31,10 +31,10 @@ func Validate(cfg *Config) error { return fmt.Errorf("session config is required") } - if err := validatePipeline(cfg.Pipeline); err != nil { + if err := ValidatePipelineConfig(cfg.Pipeline); err != nil { return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err) } - if err := validateCampaign(cfg.Campaign); err != nil { + if err := ValidateCampaignConfig(cfg.Campaign); err != nil { return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err) } if err := validateSession(cfg.Session); err != nil { @@ -50,6 +50,23 @@ func Validate(cfg *Config) error { return nil } +// ValidatePipelineConfig validates a loaded, defaulted pipeline without +// requiring session configuration. Callers that require party-driven artifact +// expansion must resolve a campaign first through LoadPipelineCampaign. +func ValidatePipelineConfig(cfg *PipelineConfig) error { + if cfg == nil { + return fmt.Errorf("pipeline config is required") + } + return validatePipeline(cfg) +} + +// ValidateCampaignConfig validates a loaded campaign without requiring a +// session. Party parsing and canonical-party checks remain owned by +// LoadPipelineCampaign, which has the campaign source path available. +func ValidateCampaignConfig(cfg *CampaignConfig) error { + return validateCampaign(cfg) +} + func validateCampaign(cfg *CampaignConfig) error { if cfg == nil { return fmt.Errorf("campaign config is required")