[object Object]

← back to Cli Printing Press

Fix catalog display_name precedence (#675)

3da34f16de0953f5fe2583f814108c98cfc6fc55 · 2026-05-07 02:48:48 -0700 · Trevin Chow

Files touched

Diff

commit 3da34f16de0953f5fe2583f814108c98cfc6fc55
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 7 02:48:48 2026 -0700

    Fix catalog display_name precedence (#675)
---
 internal/cli/generate_test.go         | 32 +++++++++++++++++++++++++++++++-
 internal/cli/root.go                  |  4 ++++
 internal/openapi/parser.go            | 23 +++++++++++++----------
 internal/openapi/parser_test.go       |  3 +++
 internal/pipeline/climanifest.go      | 16 +++++++++-------
 internal/pipeline/climanifest_test.go | 30 ++++++++++++++++++++++++++++++
 internal/pipeline/publish.go          | 19 ++++++++++++-------
 internal/pipeline/publish_test.go     | 22 ++++++++++++++++++++++
 internal/spec/spec.go                 |  4 ++++
 9 files changed, 128 insertions(+), 25 deletions(-)

diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index a45d1ee9..9098be7f 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -771,7 +771,8 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
 	apiSpec := &spec.APISpec{Name: "test-api"}
 
 	enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
-		OwnerName: "Trevin Chow",
+		DisplayName: "Test.API",
+		OwnerName:   "Trevin Chow",
 		MCP: spec.MCPConfig{
 			Transport:     []string{"stdio", "http"},
 			Orchestration: "code",
@@ -779,8 +780,37 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
 		},
 	})
 
+	assert.Equal(t, "Test.API", apiSpec.DisplayName)
 	assert.Equal(t, "Trevin Chow", apiSpec.OwnerName)
 	assert.Equal(t, []string{"stdio", "http"}, apiSpec.MCP.Transport)
 	assert.Equal(t, "code", apiSpec.MCP.Orchestration)
 	assert.Equal(t, "hidden", apiSpec.MCP.EndpointTools)
 }
+
+func TestEnrichSpecFromCatalogReplacesTitleDerivedDisplayName(t *testing.T) {
+	apiSpec := &spec.APISpec{
+		Name:                        "trigger-dev",
+		DisplayName:                 "Trigger Dev",
+		DisplayNameDerivedFromTitle: true,
+	}
+
+	enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
+		DisplayName: "Trigger.dev",
+	})
+
+	assert.Equal(t, "Trigger.dev", apiSpec.DisplayName)
+	assert.False(t, apiSpec.DisplayNameDerivedFromTitle)
+}
+
+func TestEnrichSpecFromCatalogKeepsExplicitDisplayName(t *testing.T) {
+	apiSpec := &spec.APISpec{
+		Name:        "trigger-dev",
+		DisplayName: "Spec.dev",
+	}
+
+	enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
+		DisplayName: "Trigger.dev",
+	})
+
+	assert.Equal(t, "Spec.dev", apiSpec.DisplayName)
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index ce4f6b8a..55eff172 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1125,6 +1125,10 @@ func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
 	if entry.OwnerName != "" && apiSpec.OwnerName == "" {
 		apiSpec.OwnerName = entry.OwnerName
 	}
+	if entry.DisplayName != "" && (apiSpec.DisplayName == "" || apiSpec.DisplayNameDerivedFromTitle) {
+		apiSpec.DisplayName = entry.DisplayName
+		apiSpec.DisplayNameDerivedFromTitle = false
+	}
 	if entry.HTTPTransport != "" && apiSpec.HTTPTransport == "" {
 		apiSpec.HTTPTransport = entry.HTTPTransport
 	}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index fb2fdfca..7f070103 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -205,6 +205,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	// don't get flattened by EffectiveDisplayName's HumanName(slug)
 	// fallback (the slug is ASCII-folded for filesystem safety).
 	var displayName string
+	displayNameDerivedFromTitle := false
 	if raw, ok := lookupOpenAPIInfoExtension(doc, extensionDisplayName); ok {
 		if s, ok := raw.(string); ok {
 			displayName = strings.TrimSpace(s)
@@ -213,6 +214,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	if displayName == "" && doc.Info != nil {
 		if derived := cleanSpecNameUnicode(doc.Info.Title); derived != "" && derived != "api" {
 			displayName = naming.HumanName(derived)
+			displayNameDerivedFromTitle = true
 		}
 	}
 
@@ -279,16 +281,17 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	}
 
 	result := &spec.APISpec{
-		Name:        name,
-		DisplayName: displayName,
-		Description: description,
-		Version:     version,
-		BaseURL:     baseURL,
-		BasePath:    basePath,
-		WebsiteURL:  websiteURL,
-		ProxyRoutes: proxyRoutes,
-		Auth:        auth,
-		TierRouting: tierRouting,
+		Name:                        name,
+		DisplayName:                 displayName,
+		DisplayNameDerivedFromTitle: displayNameDerivedFromTitle,
+		Description:                 description,
+		Version:                     version,
+		BaseURL:                     baseURL,
+		BasePath:                    basePath,
+		WebsiteURL:                  websiteURL,
+		ProxyRoutes:                 proxyRoutes,
+		Auth:                        auth,
+		TierRouting:                 tierRouting,
 		Config: spec.ConfigSpec{
 			Format: "toml",
 			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a9e47fa7..51cc29e2 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -531,6 +531,7 @@ paths:
 	parsed, err := Parse(spec)
 	require.NoError(t, err)
 	assert.Equal(t, "Cal.com", parsed.DisplayName)
+	assert.False(t, parsed.DisplayNameDerivedFromTitle)
 }
 
 func TestParseTrimsWhitespaceFromXDisplayName(t *testing.T) {
@@ -553,6 +554,7 @@ paths:
 	parsed, err := Parse(spec)
 	require.NoError(t, err)
 	assert.Equal(t, "Brand Name", parsed.DisplayName)
+	assert.False(t, parsed.DisplayNameDerivedFromTitle)
 }
 
 // TestParseDerivesDisplayNameFromTitle locks the dual contract when no
@@ -593,6 +595,7 @@ paths:
 			require.NoError(t, err)
 			assert.Equal(t, tc.wantSlug, parsed.Name)
 			assert.Equal(t, tc.wantDisplay, parsed.DisplayName)
+			assert.True(t, parsed.DisplayNameDerivedFromTitle)
 		})
 	}
 }
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 6f0e00ba..8b507307 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -280,9 +280,12 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
 	m.AuthDescription = parsed.Auth.Description
 	m.AuthOptional = parsed.Auth.Optional
 	// DisplayName precedence: explicit spec field > catalog-set existing
-	// value > slug-derived fallback. Unconditional fallback would clobber a
-	// curated catalog value when the spec is silent.
-	if parsed.DisplayName != "" {
+	// value > spec/title-derived fallback > slug-derived fallback.
+	// OpenAPI info.title is useful as a fallback, but it is not explicit
+	// enough to clobber a curated catalog value.
+	if parsed.DisplayName != "" && !parsed.DisplayNameDerivedFromTitle {
+		m.DisplayName = parsed.DisplayName
+	} else if m.DisplayName == "" && parsed.DisplayName != "" {
 		m.DisplayName = parsed.DisplayName
 	} else if m.DisplayName == "" {
 		m.DisplayName = parsed.EffectiveDisplayName()
@@ -455,14 +458,13 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 		}
 	}
 
-	// Look up catalog entry for category/description enrichment.
+	// Look up catalog entry for category/description/display-name enrichment.
 	if entry, err := catalogpkg.LookupFS(catalog.FS, p.APIName); err == nil {
 		m.CatalogEntry = entry.Name
 		m.Category = entry.Category
 		m.Description = entry.Description
-		// Catalog's display_name wins over spec/title-case fallback when both
-		// are present. Spec authors and catalog curators sometimes both set
-		// it; the catalog is the curated cross-CLI source of truth.
+		// Catalog's display_name wins over spec/title fallback, while explicit
+		// spec display_name / x-display-name still wins in populateMCPMetadata.
 		if entry.DisplayName != "" {
 			m.DisplayName = entry.DisplayName
 		}
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index c3e3eb20..0dfa5202 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -433,6 +433,25 @@ func TestWriteManifestForGenerateWithLocalSpec(t *testing.T) {
 	assert.Equal(t, "/tmp/my-spec.yaml", got.SpecPath)
 }
 
+func TestWriteManifestForGenerateKeepsCatalogDisplayNameOverTitleFallback(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "producthunt",
+		OutputDir: dir,
+		Spec: &spec.APISpec{
+			Name:                        "producthunt",
+			DisplayName:                 "Producthunt",
+			DisplayNameDerivedFromTitle: true,
+			Auth:                        spec.AuthConfig{Type: "none"},
+		},
+	})
+	require.NoError(t, err)
+
+	got := readPublishedManifest(t, dir)
+	assert.Equal(t, "Product Hunt", got.DisplayName)
+}
+
 func TestWriteManifestForGenerateWithDocsURL(t *testing.T) {
 	dir := t.TempDir()
 
@@ -1266,6 +1285,17 @@ func TestPopulateMCPMetadataDisplayNamePrecedence(t *testing.T) {
 		assert.Equal(t, "Catalog Name", m.DisplayName)
 	})
 
+	t.Run("existing catalog value preserved over title-derived fallback", func(t *testing.T) {
+		m := CLIManifest{DisplayName: "Catalog Name"}
+		populateMCPMetadata(&m, &spec.APISpec{
+			Name:                        "catalog-name",
+			DisplayName:                 "Catalog Name API",
+			DisplayNameDerivedFromTitle: true,
+			Auth:                        spec.AuthConfig{Type: "none"},
+		})
+		assert.Equal(t, "Catalog Name", m.DisplayName)
+	})
+
 	t.Run("title-case fallback fires only when both spec and existing are empty", func(t *testing.T) {
 		var m CLIManifest
 		populateMCPMetadata(&m, &spec.APISpec{
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index d30c04ab..30f0c6ef 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -222,6 +222,18 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		}
 	}
 
+	// Catalog metadata must be present before parsing refreshes display_name:
+	// explicit spec display_name wins, but OpenAPI info.title-derived fallback
+	// should not clobber curated catalog display_name.
+	if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
+		m.CatalogEntry = entry.Name
+		m.Category = entry.Category
+		m.Description = entry.Description
+		if entry.DisplayName != "" {
+			m.DisplayName = entry.DisplayName
+		}
+	}
+
 	// Detect spec format and compute checksum from the spec file archived
 	// alongside the CLI. generate writes spec.json for JSON inputs and
 	// spec.yaml for YAML inputs; --docs / --plan runs leave no archive and
@@ -261,13 +273,6 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		}
 	}
 
-	// Look up catalog entry by API name; empty string if not found.
-	if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
-		m.CatalogEntry = entry.Name
-		m.Category = entry.Category
-		m.Description = entry.Description
-	}
-
 	// Load novel features from research.json if available, populating the
 	// manifest's NovelFeatures field so publish-validate's transcendence
 	// check passes without manual patching.
diff --git a/internal/pipeline/publish_test.go b/internal/pipeline/publish_test.go
index 07e88e6c..7632b653 100644
--- a/internal/pipeline/publish_test.go
+++ b/internal/pipeline/publish_test.go
@@ -175,6 +175,28 @@ func TestWriteCLIManifestForPublish_NovelFeaturesFromSkillFlowResearch(t *testin
 	assert.Equal(t, "today", m.NovelFeatures[1].Command)
 }
 
+func TestWriteCLIManifestForPublishKeepsCatalogDisplayNameOverTitleFallback(t *testing.T) {
+	tmp := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", tmp)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+	state := NewStateWithRun("producthunt", filepath.Join(tmp, "working", "producthunt-pp-cli"), "20260507-display-name", "test-scope")
+	require.NoError(t, os.MkdirAll(state.WorkingDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.WorkingDir, "spec.yaml"), []byte(`
+openapi: "3.0.0"
+info:
+  title: Producthunt API
+  version: "1.0"
+paths: {}
+`), 0o644))
+
+	require.NoError(t, writeCLIManifestForPublish(state, state.WorkingDir))
+
+	m := readPublishedManifest(t, state.WorkingDir)
+	assert.Equal(t, "Product Hunt", m.DisplayName)
+}
+
 // TestWriteCLIManifestForPublish_NovelFeaturesFromPrintFlowResearch covers the
 // printing-press print flow: research.json lives at <RunRoot>/pipeline/research.json
 // alongside phase artifacts. The fallback path keeps print-flow CLIs working.
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 1304ada7..bd966de0 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -71,6 +71,10 @@ type APISpec struct {
 	// Name as a fallback. The generate command also fills this from a
 	// matching catalog entry's display_name when available.
 	DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
+	// DisplayNameDerivedFromTitle marks OpenAPI parser fallbacks from
+	// info.title. Catalog enrichment may replace that fallback, but must not
+	// replace explicit display_name / x-display-name values.
+	DisplayNameDerivedFromTitle bool `yaml:"-" json:"-"`
 	// Description describes the API itself ("REST API for ordering pizza").
 	// It flows into generated docs and SKILL.md but is intentionally NOT used
 	// as the printed CLI's --help text; that's CLIDescription's job.

← a880a7f7 Annotate PM workflows as read-only (#674)  ·  back to Cli Printing Press  ·  Clarify polish source-of-truth guidance (#676) 5701f3e7 →