From 2b97ef3722281b82ca6db791bb2dc60c159ebaf9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 7 Sep 2026 04:46:20 +0000 Subject: [PATCH] Add effective artifact catalog command --- docs/cli.md | 16 ++- docs/config.md | 3 + docs/roadmap/implementation.md | 4 +- internal/app/config_artifacts_test.go | 182 ++++++++++++++++++++++++++ internal/app/config_commands.go | 73 ++++++++++- internal/app/config_commands_test.go | 17 ++- internal/config/artifact_catalog.go | 132 +++++++++++++++++++ 7 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 internal/app/config_artifacts_test.go create mode 100644 internal/config/artifact_catalog.go diff --git a/docs/cli.md b/docs/cli.md index 0a14089..b90b04e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,7 @@ Top-level commands: - `publish `: force-run publish. - `clean ` or `clean --all`: remove local work/spool state. - `session `: session helper commands. -- `config `: validate, display, source-trace, or compare resolved pipeline configuration. +- `config `: validate, display, source-trace, catalog, or compare resolved pipeline configuration. Session subcommands: @@ -82,12 +82,13 @@ Commands with additional positionals keep their command-specific order: ## Command Reference -### `config validate`, `config show`, `config sources`, and `config diff` +### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff` ```bash narratio config validate [--config ] [--campaign | --campaign-file ] [--profile ] [--secrets-dir ] narratio config show [--config ] [--campaign | --campaign-file ] [--profile ] [--secrets-dir ] narratio config sources [--config ] [--campaign | --campaign-file ] [--profile ] [--secrets-dir ] +narratio config artifacts [--config ] [--campaign | --campaign-file ] [--profile ] [--secrets-dir ] narratio config diff [--config ] [--campaign | --campaign-file ] [--secrets-dir ] ``` @@ -119,6 +120,17 @@ and generated family ownership. A generated party member has one family record and one party record at the same logical path. The output never reads or prints secret values. +`config artifacts` prints the concrete catalog used by analysis, ordered by +artifact key. Its tab-separated header is `key`, `source`, `definitions`, +`enabled`, `output`, `dependencies`, `family`, `character`, `family_source`, and +`party_source`. Definitions and effective dependencies are sorted comma-separated +lists, and control characters in fields are escaped. Family-member rows identify +their family, character, defining family file, and party file. An empty catalog +prints only the header. Ordinary artifacts can be inspected without a campaign; +party-driven families retain the campaign requirement. See +[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the +configuration layout. + `config diff` resolves both supplied profile names independently and compares their fully resolved, secret-free effective mappings. It does not accept `--profile`; the two positional names must be distinct, declared diff --git a/docs/config.md b/docs/config.md index 58bdb7b..f7c767e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -467,6 +467,9 @@ effective dependency on that configured artifact. Narratio unions these inferred edges with explicit `depends_on` entries, then trims, deduplicates, and sorts the result during final configuration resolution. Authors use `depends_on` only for ordering constraints that are not already expressed by an artifact input. +Use [`narratio config artifacts`](./cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff) +to inspect keys, definition sources, outputs, and effective dependencies without +loading a session or creating runtime state. ### Scriptorium Artifact Families diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b87f6ea..30784ba 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -395,7 +395,9 @@ internals with the implemented inference rule. ## Stage 9 — Add the effective artifact catalog command -Status: Pending +Status: Complete — Added the read-only `config artifacts` catalog over finalized +configuration, including effective dependencies and definition/family/party +provenance; documented it and verified focused, full, and race test suites. Read: `internal/app/config_commands.go`, config diff/source rendering, the safe projection and family-origin catalogs, and `docs/cli.md`. diff --git a/internal/app/config_artifacts_test.go b/internal/app/config_artifacts_test.go new file mode 100644 index 0000000..95ecf80 --- /dev/null +++ b/internal/app/config_artifacts_test.go @@ -0,0 +1,182 @@ +package app + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const artifactCatalogHeader = "key\tsource\tdefinitions\tenabled\toutput\tdependencies\tfamily\tcharacter\tfamily_source\tparty_source\n" + +func TestConfigArtifactsReportsOrdinaryCatalogWithoutCampaign(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + mustWriteTestFile(t, pipelinePath, `workspace: + root: `+filepath.Join(dir, "workspace")+` +whisperx: + transcribe_url: https://transcription.example.com/transcribe +scriptorium: + binary: scriptorium + artifacts: + zeta: + enabled: false + output_path: artifacts/zeta.md + alpha: + enabled: false + output_path: artifacts/alpha.md + inputs: + upstream: {source: narratio.artifact.zeta, required: true} +`) + + var out bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, &out); err != nil { + t.Fatalf("ConfigArtifacts() error = %v", err) + } + lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[1], "alpha\t") || !strings.HasPrefix(lines[2], "zeta\t") { + t.Fatalf("catalog order/output = %q", out.String()) + } + alpha := strings.Split(lines[1], "\t") + if len(alpha) != 10 { + t.Fatalf("alpha fields = %#v", alpha) + } + if alpha[1] != "narratio.artifact.alpha" || alpha[2] != pipelinePath || alpha[3] != "false" || alpha[4] != "artifacts/alpha.md" || alpha[5] != "zeta" { + t.Fatalf("alpha record = %#v", alpha) + } +} + +func TestConfigArtifactsEmptyCatalogWritesOnlyHeader(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n") + var out bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, &out); err != nil { + t.Fatal(err) + } + if out.String() != artifactCatalogHeader { + t.Fatalf("empty catalog = %q, want header only", out.String()) + } +} + +func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) { + pipelinePath, campaignPath := writeInspectionFamilyConfig(t) + var out bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil { + t.Fatal(err) + } + records := catalogRecordsByKey(t, out.String()) + meta := records["character_meta_arannis"] + note := records["character_note_arannis"] + partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml") + if len(meta) != 10 || meta[6] != "character_meta" || meta[7] != "arannis" || meta[8] != pipelinePath || meta[9] != partyPath { + t.Fatalf("generated meta record = %#v", meta) + } + if len(note) != 10 || note[5] != "character_meta_arannis" || note[6] != "character_note" { + t.Fatalf("generated note record = %#v", note) + } +} + +func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + profilePath := filepath.Join(dir, "profiles", "testing.yml") + mustWriteTestFile(t, pipelinePath, `workspace: + root: /srv/narratio +whisperx: + transcribe_url: https://transcription.example.com/transcribe +scriptorium: + binary: scriptorium + artifacts: + notes: + enabled: false + output_path: artifacts/notes.md +`) + mustWriteTestFile(t, profilePath, `scriptorium: + artifacts: + notes: + enabled: true + prompt_id: notes + profile_only: + enabled: false + output_path: artifacts/profile-only.md +`) + + var out bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { + t.Fatal(err) + } + records := catalogRecordsByKey(t, out.String()) + if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { + t.Fatalf("notes definitions = %#v", got) + } + if got := records["profile_only"][2]; got != profilePath { + t.Fatalf("profile-only definition = %q, want %q", got, profilePath) + } +} + +func TestConfigArtifactsIdentityDoesNotDependOnDefinitionFilename(t *testing.T) { + inlineDir := t.TempDir() + inlinePath := filepath.Join(inlineDir, "pipeline.yml") + mustWriteTestFile(t, inlinePath, artifactIdentityPipeline("", "recap")) + importDir := t.TempDir() + importPath := filepath.Join(importDir, "pipeline.yml") + mustWriteTestFile(t, importPath, artifactIdentityPipeline("artifacts.yml", "")) + mustWriteTestFile(t, filepath.Join(importDir, "artifacts.yml"), "scriptorium:\n artifacts:\n recap:\n enabled: false\n output_path: artifacts/recap.md\n") + + for _, path := range []string{inlinePath, importPath} { + var out bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", path}, &out); err != nil { + t.Fatal(err) + } + if got := catalogRecordsByKey(t, out.String())["recap"][1]; got != "narratio.artifact.recap" { + t.Fatalf("source identity for %s = %q", path, got) + } + } +} + +func TestConfigArtifactsPropagatesWriterErrorsAndEscapesFields(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + mustWriteTestFile(t, pipelinePath, "workspace:\n root: /srv/narratio\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n") + want := errors.New("write rejected") + err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, rejectingCatalogWriter{err: want}) + if !errors.Is(err, want) { + t.Fatalf("ConfigArtifacts() error = %v, want writer error", err) + } + if got := escapeCatalogField("a\\b\tc\nd\r"); got != `a\\b\tc\nd\r` { + t.Fatalf("escaped field = %q", got) + } +} + +func catalogRecordsByKey(t *testing.T, output string) map[string][]string { + t.Helper() + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + if len(lines) == 0 || lines[0]+"\n" != artifactCatalogHeader { + t.Fatalf("catalog header = %q", output) + } + records := make(map[string][]string, len(lines)-1) + for _, line := range lines[1:] { + fields := strings.Split(line, "\t") + records[fields[0]] = fields + } + return records +} + +func artifactIdentityPipeline(importPath, inlineKey string) string { + document := "workspace:\n root: /srv/narratio\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\nscriptorium:\n binary: scriptorium\n" + if importPath != "" { + document = "configkit:\n imports:\n - path: " + importPath + "\n" + document + } + if inlineKey != "" { + document += " artifacts:\n " + inlineKey + ":\n enabled: false\n output_path: artifacts/recap.md\n" + } + return document +} + +type rejectingCatalogWriter struct{ err error } + +func (w rejectingCatalogWriter) Write([]byte) (int, error) { return 0, w.err } diff --git a/internal/app/config_commands.go b/internal/app/config_commands.go index b163570..dc695d3 100644 --- a/internal/app/config_commands.go +++ b/internal/app/config_commands.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sort" + "strconv" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -36,7 +37,7 @@ func Config(ctx context.Context, args []string, out io.Writer) error { func configWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { deps = normalizeCommandDependencies(deps) if len(args) == 0 { - return fmt.Errorf("config: expected subcommand: validate|show|sources|diff") + return fmt.Errorf("config: expected subcommand: validate|show|sources|artifacts|diff") } if args[0] == "--help" || args[0] == "-h" { writeConfigCommandUsage(out) @@ -49,6 +50,8 @@ func configWithDependencies(ctx context.Context, args []string, out io.Writer, d return configShowWithDependencies(ctx, args[1:], out, deps) case "sources": return configSourcesWithDependencies(ctx, args[1:], out, deps) + case "artifacts": + return configArtifactsWithDependencies(ctx, args[1:], out, deps) case "diff": return configDiffWithDependencies(ctx, args[1:], out, deps) default: @@ -140,6 +143,70 @@ func configSourcesWithDependencies(_ context.Context, args []string, out io.Writ return nil } +// ConfigArtifacts validates and reports the effective concrete artifact catalog +// without loading session state or runtime collaborators. +func ConfigArtifacts(ctx context.Context, args []string, out io.Writer) error { + return configArtifactsWithDependencies(ctx, args, out, commandDependencies{}) +} + +func configArtifactsWithDependencies(_ context.Context, args []string, out io.Writer, deps commandDependencies) error { + flags, err := parseInspectionFlags("config artifacts", args, out) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + resolved, err := resolveInspectionConfigWithDependencies(flags, deps) + if err != nil { + return fmt.Errorf("config artifacts: %w", err) + } + records, err := config.EffectiveArtifactCatalog(resolved.Pipeline, resolved.Party) + if err != nil { + return fmt.Errorf("config artifacts: %w", err) + } + if _, err := fmt.Fprintln(out, "key\tsource\tdefinitions\tenabled\toutput\tdependencies\tfamily\tcharacter\tfamily_source\tparty_source"); err != nil { + return err + } + for _, record := range records { + fields := []string{ + record.Key, + record.SourceID, + strings.Join(normalizedCatalogPaths(record.DefinitionSources), ","), + strconv.FormatBool(record.Enabled), + record.OutputPath, + strings.Join(record.Dependencies, ","), + record.FamilyKey, + record.CharacterID, + config.NormalizedConfigurationPath(record.FamilySource), + config.NormalizedConfigurationPath(record.PartySource), + } + for index := range fields { + fields[index] = escapeCatalogField(fields[index]) + } + if _, err := fmt.Fprintln(out, strings.Join(fields, "\t")); err != nil { + return err + } + } + return nil +} + +func normalizedCatalogPaths(paths []string) []string { + result := make([]string, len(paths)) + for index, path := range paths { + result[index] = config.NormalizedConfigurationPath(path) + } + sort.Strings(result) + return result +} + +func escapeCatalogField(value string) string { + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "\t", "\\t") + value = strings.ReplaceAll(value, "\r", "\\r") + return strings.ReplaceAll(value, "\n", "\\n") +} + func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) { fs := flag.NewFlagSet(command, flag.ContinueOnError) fs.SetOutput(out) @@ -256,9 +323,9 @@ func inspectionProfile(pipeline *config.PipelineConfig) string { } func writeConfigCommandUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: narratio config ") + fmt.Fprintln(out, "Usage: narratio config ") fmt.Fprintln(out) - fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.") + fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two profiles.") } func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error { diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index cd8bf83..1e29c28 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -22,13 +22,14 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) { want string code int }{ - {name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config "}, + {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: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"}, + {name: "artifacts help", args: []string{"config", "artifacts", "--help"}, want: "Usage: narratio config artifacts"}, {name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"}, {name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1}, - {name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|diff", code: 1}, + {name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|artifacts|diff", 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}, @@ -53,7 +54,7 @@ func TestExecuteConfigUsesInvocationPipelineLoader(t *testing.T) { pipelinePath := filepath.Join(dir, "pipeline.yml") mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n") - for _, subcommand := range []string{"validate", "show", "sources"} { + for _, subcommand := range []string{"validate", "show", "sources", "artifacts"} { t.Run(subcommand, func(t *testing.T) { calls := 0 deps := commandDependencies{ @@ -336,11 +337,15 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil { t.Fatalf("config sources: %v", err) } + var catalog bytes.Buffer + if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &catalog); err != nil { + t.Fatalf("config artifacts: %v", err) + } var diff bytes.Buffer if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil { t.Fatalf("config diff: %v", err) } - for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "diff": diff.String()} { + for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} { if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) { t.Fatalf("%s output exposed credential value: %q", label, output) } @@ -364,7 +369,7 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin t.Fatalf("example workspace stat = %v, want absent", err) } - for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources} { + for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources, ConfigArtifacts} { if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "production"}, io.Discard); err != nil { t.Fatalf("inspection command %T error = %v", command, err) } @@ -423,7 +428,7 @@ func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) { pipelinePath, campaignPath := writeInspectionFamilyConfig(t) - for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} { + for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigArtifacts} { var out bytes.Buffer err := command(context.Background(), []string{"--config", pipelinePath}, &out) if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") { diff --git a/internal/config/artifact_catalog.go b/internal/config/artifact_catalog.go new file mode 100644 index 0000000..c4c9f80 --- /dev/null +++ b/internal/config/artifact_catalog.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "sort" + "strings" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" +) + +// EffectiveArtifactRecord describes one concrete artifact after configuration +// resolution and family expansion. +type EffectiveArtifactRecord struct { + Key string + SourceID string + DefinitionSources []string + Enabled bool + OutputPath string + Dependencies []string + FamilyKey string + CharacterID string + FamilySource string + PartySource string +} + +// EffectiveArtifactCatalog returns the deterministic concrete artifact catalog +// consumed by runtime planning. +func EffectiveArtifactCatalog(cfg *PipelineConfig, party ResolvedParty) ([]EffectiveArtifactRecord, error) { + if cfg == nil { + return nil, fmt.Errorf("pipeline config is required") + } + if cfg.Scriptorium == nil || len(cfg.Scriptorium.Artifacts) == 0 { + return []EffectiveArtifactRecord{}, nil + } + + keys := make([]string, 0, len(cfg.Scriptorium.Artifacts)) + for key := range cfg.Scriptorium.Artifacts { + keys = append(keys, key) + } + sort.Strings(keys) + records := make([]EffectiveArtifactRecord, 0, len(keys)) + for _, key := range keys { + artifact := cfg.Scriptorium.Artifacts[key] + record := EffectiveArtifactRecord{ + Key: key, + SourceID: artifactpolicy.ConfiguredSourceID(key), + Enabled: artifact.Enabled, + OutputPath: artifact.OutputPath, + Dependencies: append([]string(nil), artifact.DependsOn...), + } + if member, ok := cfg.familyCatalog.Members[key]; ok { + record.DefinitionSources = nonEmptySortedStrings([]string{member.Source}) + record.FamilyKey = member.Family + record.CharacterID = member.CharacterID + record.FamilySource = member.Source + record.PartySource = party.Source.Path + } else { + record.DefinitionSources = artifactDefinitionSources(cfg, key) + } + record.Dependencies = nonEmptySortedStrings(record.Dependencies) + records = append(records, record) + } + return records, nil +} + +func artifactDefinitionSources(cfg *PipelineConfig, key string) []string { + if cfg.resolution == nil { + return nil + } + pathsByID := make(map[string]string, len(cfg.resolution.configkitSources)) + for _, source := range cfg.resolution.configkitSources { + path := source.Path + if path == "" { + path = source.Label + } + pathsByID[source.ID] = path + } + + 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 + } + for _, location := range change.Origin.Locations { + source := pathsByID[location.SourceID] + if source == "" { + source = location.SourceID + } + seen[source] = struct{}{} + } + } + if len(seen) == 0 { + prefix := "scriptorium.artifacts." + key + for _, ownership := range cfg.resolution.ownership { + if ownership.path != prefix && !strings.HasPrefix(ownership.path, prefix+".") { + continue + } + for _, source := range ownership.sources { + if source != pipelineDefaultOwnershipSource && source != "resolution" { + seen[source] = struct{}{} + } + } + } + } + if len(seen) == 0 && cfg.resolution.rootPath != "" { + seen[cfg.resolution.rootPath] = struct{}{} + } + result := make([]string, 0, len(seen)) + for source := range seen { + result = append(result, source) + } + sort.Strings(result) + return result +} + +func nonEmptySortedStrings(values []string) []string { + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + result := make([]string, 0, len(seen)) + for value := range seen { + result = append(result, value) + } + sort.Strings(result) + return result +}