← back to Cli Printing Press
fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint (#371)
283c9923a771c87c9f8b67f47adebabb7a801e75 · 2026-04-28 18:45:01 -0700 · Trevin Chow
* fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint
Spec titles, resource names, operationIds, and schema names with non-ASCII
characters (accents, fused-diacritic Latin like ø/ß/æ, CJK, Cyrillic, Greek)
were leaking into directory names, binary names, Go import paths, Cobra
command names, and MCP tool names. The visible failure was mcp-sync
emitting spurious cmd/pokémon-pp-{cli,mcp}/ alongside cmd/pokemon-pp-{cli,mcp}/
on regen, but the same drift class affected any chokepoint that turned a
spec string into a file/folder/identifier.
Add naming.ASCIIFold (a thin wrapper over github.com/mozillazg/go-unidecode,
the same transliteration table Django's slugify, Rails, and most modern
slug libraries use) and call it at every chokepoint:
internal/openapi/parser.go
- cleanSpecName (API slug → top-level cmd dir / binary / module)
- toSnakeCase (param names, operationIds, path segments)
- toKebabCase (Cobra command names, kebab identifiers)
- sanitizeTypeName (Go struct names from schema property names)
- toCamelCase (Go field names from schema keys)
internal/graphql/parser.go
- toKebabCase (GraphQL schema-derived command names)
internal/naming/naming.go
- SnakeIdentifier (MCP tool names from free-form command specs)
- EnvPrefix (env var prefixes; replaces hand-rolled NFD+Mn)
- Snake (CamelCase → snake_case for tool name segments)
Output samples (cleanSpecName):
Pokémon API → pokemon
Großhandel API → grosshandel
Łódź Transit → lodz-transit
Encyclopædia → encyclopaedia
Ørsted Energy → orsted-energy
東京 API → dong-jing
русский API → russkii
Δelta API → delta
EnvPrefix's previous hand-rolled NFD+Mn implementation handled
precomposed accents but dropped fused-diacritic Latin (ß) and non-Latin
scripts (CJK, Cyrillic). Switching to Unidecode covers everything with
the same one-line call.
go test ./... passes (2217/2217). Golden verify passes (8/8 cases).
golangci-lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): golden case locks Unicode-to-ASCII slug contract
Adds testdata/golden/cases/generate-golden-api-unicode using a fixture
with title "Café Bistro API". Expected artifacts capture every ASCII
contract this PR establishes:
api_name = "cafe-bistro" (cleanSpecName)
cli_name = "cafe-bistro-pp-cli" (naming.CLI)
mcp_binary = "cafe-bistro-pp-mcp" (naming.MCP)
auth_env_vars = ["CAFE_BISTRO_..."] (naming.EnvPrefix)
display_name = "Cafe Bistro" (HumanName of folded slug)
The cmd/cafe-bistro-pp-cli/ and cmd/cafe-bistro-pp-mcp/ artifact paths
double as filesystem assertions: the harness fails with
"missing artifact" if the slug ever surfaces as café-bistro again.
Without this fixture, the existing 8 golden cases use ASCII-only specs,
so the Unidecode wiring is a no-op and bit-identical output makes the
new contract invisible to the suite. AGENTS.md: "When adding a new
deterministic CLI behavior or artifact contract, explicitly decide
whether the golden suite needs a new or expanded case."
Note: display_name is "Cafe Bistro" (not "Café Bistro") because
EffectiveDisplayName falls through to naming.HumanName(s.Name) when no
explicit DisplayName is set, and Name is the ASCII slug. Preserving
Unicode display_name for OpenAPI specs without an explicit
display_name field is a separate concern — needs the parser to set
DisplayName from the raw doc.Info.Title — and is out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* simplify: trim narrating comments and add ASCII fast path to ASCIIFold
Three findings from /simplify:
1. ASCII fast path in naming.ASCIIFold (efficiency). unidecode.Unidecode
always allocates a builder and walks every rune; for OpenAPI specs
the fold runs thousands of times per parse on >99% ASCII input
(sanitizeTypeName + toCamelCase per schema field, etc.). A byte
scan now skips the table lookup when no continuation bytes are
present, returning the input string directly with zero allocation.
2. Drop ~40 lines of narrating/duplicating comments (per CLAUDE.md
"default to no comments"):
- ASCIIFold godoc trimmed from ~20 lines to 6
- Removed "Non-ASCII input is folded first" tags from
SnakeIdentifier/EnvPrefix/Snake (duplicates ASCIIFold's godoc)
- Removed the 7-line block in cleanSpecName explaining why the
fold runs (the function call name is enough)
- Test files keep the category labels (Precomposed accents /
Fused-diacritic Latin / Non-Latin scripts) but drop the
cross-references and rationale prose
3. Removed dead U+2019 strip in cleanSpecName. ASCIIFold maps the
smart quote to ASCII apostrophe upstream, so the second
ReplaceAll was a no-op.
Tests: 2217/2217 passing. Golden verify: 9/9 cases pass. No
behavioral change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: address ce-code-review walk-through findings
Two findings from /compound-engineering:ce-code-review walk-through:
1. Document the implicit ASCIIFold boundary in internal/generator/.
The PR's chokepoint audit covered openapi/graphql/naming packages
but didn't reach the template helpers (toCamel, toPascal, flagName,
safeTypeName, schema_builder.toSnakeCase). Investigation confirmed
they receive APISpec data already folded by the openapi parser, so
the boundary is correct — but it was implicit. Add 1-line godoc
notes making the invariant visible: any new caller feeding raw spec
strings into these helpers must fold first via naming.ASCIIFold.
2. Strengthen golden-api-unicode coverage. The previous fixture used
ASCII-only schema/property names, so a regression bypassing
sanitizeTypeName / toCamelCase wouldn't fail the golden. Renamed
the schema to "CaféMenu" with property "spécialité"; added the
generated types.go to the artifact list. The captured types.go now
shows `type CafeMenu struct { ... Specialite string }`, locking in
that both schema-name and property-name chokepoints fold to ASCII.
go test ./... passes (2217/2217). Golden verify passes (9/9 cases).
golangci-lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M go.modM go.sumM internal/generator/generator.goM internal/generator/schema_builder.goM internal/graphql/parser.goM internal/naming/naming.goM internal/naming/naming_test.goM internal/openapi/parser.goM internal/openapi/parser_test.goA testdata/golden/cases/generate-golden-api-unicode/artifacts.txtA testdata/golden/cases/generate-golden-api-unicode/command.txtA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.jsonA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.goA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.goA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.goA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.jsonA testdata/golden/expected/generate-golden-api-unicode/exit.txtA testdata/golden/expected/generate-golden-api-unicode/stderr.txtA testdata/golden/expected/generate-golden-api-unicode/stdout.txtA testdata/golden/fixtures/golden-api-unicode.yaml
Diff
commit 283c9923a771c87c9f8b67f47adebabb7a801e75
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue Apr 28 18:45:01 2026 -0700
fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint (#371)
* fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint
Spec titles, resource names, operationIds, and schema names with non-ASCII
characters (accents, fused-diacritic Latin like ø/ß/æ, CJK, Cyrillic, Greek)
were leaking into directory names, binary names, Go import paths, Cobra
command names, and MCP tool names. The visible failure was mcp-sync
emitting spurious cmd/pokémon-pp-{cli,mcp}/ alongside cmd/pokemon-pp-{cli,mcp}/
on regen, but the same drift class affected any chokepoint that turned a
spec string into a file/folder/identifier.
Add naming.ASCIIFold (a thin wrapper over github.com/mozillazg/go-unidecode,
the same transliteration table Django's slugify, Rails, and most modern
slug libraries use) and call it at every chokepoint:
internal/openapi/parser.go
- cleanSpecName (API slug → top-level cmd dir / binary / module)
- toSnakeCase (param names, operationIds, path segments)
- toKebabCase (Cobra command names, kebab identifiers)
- sanitizeTypeName (Go struct names from schema property names)
- toCamelCase (Go field names from schema keys)
internal/graphql/parser.go
- toKebabCase (GraphQL schema-derived command names)
internal/naming/naming.go
- SnakeIdentifier (MCP tool names from free-form command specs)
- EnvPrefix (env var prefixes; replaces hand-rolled NFD+Mn)
- Snake (CamelCase → snake_case for tool name segments)
Output samples (cleanSpecName):
Pokémon API → pokemon
Großhandel API → grosshandel
Łódź Transit → lodz-transit
Encyclopædia → encyclopaedia
Ørsted Energy → orsted-energy
東京 API → dong-jing
русский API → russkii
Δelta API → delta
EnvPrefix's previous hand-rolled NFD+Mn implementation handled
precomposed accents but dropped fused-diacritic Latin (ß) and non-Latin
scripts (CJK, Cyrillic). Switching to Unidecode covers everything with
the same one-line call.
go test ./... passes (2217/2217). Golden verify passes (8/8 cases).
golangci-lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): golden case locks Unicode-to-ASCII slug contract
Adds testdata/golden/cases/generate-golden-api-unicode using a fixture
with title "Café Bistro API". Expected artifacts capture every ASCII
contract this PR establishes:
api_name = "cafe-bistro" (cleanSpecName)
cli_name = "cafe-bistro-pp-cli" (naming.CLI)
mcp_binary = "cafe-bistro-pp-mcp" (naming.MCP)
auth_env_vars = ["CAFE_BISTRO_..."] (naming.EnvPrefix)
display_name = "Cafe Bistro" (HumanName of folded slug)
The cmd/cafe-bistro-pp-cli/ and cmd/cafe-bistro-pp-mcp/ artifact paths
double as filesystem assertions: the harness fails with
"missing artifact" if the slug ever surfaces as café-bistro again.
Without this fixture, the existing 8 golden cases use ASCII-only specs,
so the Unidecode wiring is a no-op and bit-identical output makes the
new contract invisible to the suite. AGENTS.md: "When adding a new
deterministic CLI behavior or artifact contract, explicitly decide
whether the golden suite needs a new or expanded case."
Note: display_name is "Cafe Bistro" (not "Café Bistro") because
EffectiveDisplayName falls through to naming.HumanName(s.Name) when no
explicit DisplayName is set, and Name is the ASCII slug. Preserving
Unicode display_name for OpenAPI specs without an explicit
display_name field is a separate concern — needs the parser to set
DisplayName from the raw doc.Info.Title — and is out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* simplify: trim narrating comments and add ASCII fast path to ASCIIFold
Three findings from /simplify:
1. ASCII fast path in naming.ASCIIFold (efficiency). unidecode.Unidecode
always allocates a builder and walks every rune; for OpenAPI specs
the fold runs thousands of times per parse on >99% ASCII input
(sanitizeTypeName + toCamelCase per schema field, etc.). A byte
scan now skips the table lookup when no continuation bytes are
present, returning the input string directly with zero allocation.
2. Drop ~40 lines of narrating/duplicating comments (per CLAUDE.md
"default to no comments"):
- ASCIIFold godoc trimmed from ~20 lines to 6
- Removed "Non-ASCII input is folded first" tags from
SnakeIdentifier/EnvPrefix/Snake (duplicates ASCIIFold's godoc)
- Removed the 7-line block in cleanSpecName explaining why the
fold runs (the function call name is enough)
- Test files keep the category labels (Precomposed accents /
Fused-diacritic Latin / Non-Latin scripts) but drop the
cross-references and rationale prose
3. Removed dead U+2019 strip in cleanSpecName. ASCIIFold maps the
smart quote to ASCII apostrophe upstream, so the second
ReplaceAll was a no-op.
Tests: 2217/2217 passing. Golden verify: 9/9 cases pass. No
behavioral change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: address ce-code-review walk-through findings
Two findings from /compound-engineering:ce-code-review walk-through:
1. Document the implicit ASCIIFold boundary in internal/generator/.
The PR's chokepoint audit covered openapi/graphql/naming packages
but didn't reach the template helpers (toCamel, toPascal, flagName,
safeTypeName, schema_builder.toSnakeCase). Investigation confirmed
they receive APISpec data already folded by the openapi parser, so
the boundary is correct — but it was implicit. Add 1-line godoc
notes making the invariant visible: any new caller feeding raw spec
strings into these helpers must fold first via naming.ASCIIFold.
2. Strengthen golden-api-unicode coverage. The previous fixture used
ASCII-only schema/property names, so a regression bypassing
sanitizeTypeName / toCamelCase wouldn't fail the golden. Renamed
the schema to "CaféMenu" with property "spécialité"; added the
generated types.go to the artifact list. The captured types.go now
shows `type CafeMenu struct { ... Specialite string }`, locking in
that both schema-name and property-name chokepoints fold to ASCII.
go test ./... passes (2217/2217). Golden verify passes (9/9 cases).
golangci-lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
go.mod | 1 +
go.sum | 2 +
internal/generator/generator.go | 9 +++-
internal/generator/schema_builder.go | 3 ++
internal/graphql/parser.go | 2 +
internal/naming/naming.go | 33 ++++++++++++---
internal/naming/naming_test.go | 49 ++++++++++++++++++++++
internal/openapi/parser.go | 9 +++-
internal/openapi/parser_test.go | 14 +++++++
.../generate-golden-api-unicode/artifacts.txt | 26 ++++++++++++
.../cases/generate-golden-api-unicode/command.txt | 2 +
.../cafe-bistro/.printing-press.json | 20 +++++++++
.../cafe-bistro/cmd/cafe-bistro-pp-cli/main.go | 18 ++++++++
.../cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go | 27 ++++++++++++
.../cafe-bistro/internal/types/types.go | 12 ++++++
.../cafe-bistro/manifest.json | 39 +++++++++++++++++
.../expected/generate-golden-api-unicode/exit.txt | 1 +
.../generate-golden-api-unicode/stderr.txt | 1 +
.../generate-golden-api-unicode/stdout.txt | 0
testdata/golden/fixtures/golden-api-unicode.yaml | 44 +++++++++++++++++++
20 files changed, 302 insertions(+), 10 deletions(-)
diff --git a/go.mod b/go.mod
index 8ea72aac..3138c2c0 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/dave/dst v0.27.4
github.com/enetx/surf v1.0.199
github.com/getkin/kin-openapi v0.137.0
+ github.com/mozillazg/go-unidecode v0.2.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/mod v0.35.0
diff --git a/go.sum b/go.sum
index db8f5195..15f848f6 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
+github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 2c10f4a1..4fae9c94 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2005,8 +2005,13 @@ func (g *Generator) template(tmplName string) (*template.Template, error) {
return tmpl, nil
}
-// Template helper functions
-
+// Template helper functions.
+//
+// These run inside Go templates over parsed APISpec data, so their inputs
+// have already been ASCII-folded by the openapi/graphql parsers (which
+// route raw spec strings through naming.ASCIIFold at sanitizeTypeName,
+// toCamelCase, etc). Treat any new caller that feeds raw spec strings
+// directly into these helpers as a bug — fold first, then shape.
func toCamel(s string) string {
// Strip characters that are invalid in Go identifiers
s = strings.TrimLeft(s, "$")
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 9dddaf3c..f0098885 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -369,6 +369,9 @@ func sqlStringLiteral(s string) string {
}
// toSnakeCase converts camelCase, PascalCase, or kebab-case to snake_case.
+// Expects ASCII input — callers are SQL identifier paths whose inputs come
+// from parsed APISpec types/fields that already passed through the
+// openapi parser's ASCIIFold chokepoints.
func toSnakeCase(s string) string {
s = strings.ReplaceAll(s, ".", "_")
s = strings.ReplaceAll(s, "-", "_")
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 983b99f1..d3413a29 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -9,6 +9,7 @@ import (
"strings"
"unicode"
+ "github.com/mvanhorn/cli-printing-press/v2/internal/naming"
"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -729,6 +730,7 @@ func splitWords(s string) []string {
}
func toKebabCase(s string) string {
+ s = naming.ASCIIFold(s)
words := splitWords(s)
for i := range words {
words[i] = strings.ToLower(words[i])
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 85935bee..eb548548 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -5,11 +5,31 @@ import (
"strings"
"unicode"
+ "github.com/mozillazg/go-unidecode"
"golang.org/x/text/cases"
"golang.org/x/text/language"
- "golang.org/x/text/unicode/norm"
)
+// ASCIIFold transliterates Unicode to ASCII via Unidecode tables (the
+// same ones Django's slugify and Rails use). Apply at every chokepoint
+// that turns user-supplied spec strings (titles, resource names,
+// operationIds, schema names, path segments) into file/folder names or
+// Go identifiers. Output preserves spacing/casing — downstream
+// to{Snake,Kebab,Camel}Case still owns identifier shape.
+func ASCIIFold(s string) string {
+ // Pure-ASCII fast path. unidecode.Unidecode allocates a builder and
+ // walks every rune unconditionally; for the common case of
+ // well-behaved OpenAPI specs this fold runs thousands of times per
+ // parse on inputs that are >99% ASCII. A byte scan suffices: any
+ // non-ASCII codepoint has a continuation byte ≥0x80.
+ for i := 0; i < len(s); i++ {
+ if s[i] >= 0x80 {
+ return unidecode.Unidecode(s)
+ }
+ }
+ return s
+}
+
const (
CurrentCLISuffix = "-pp-cli"
LegacyCLISuffix = "-cli"
@@ -48,6 +68,7 @@ func HumanName(slug string) string {
// "FUNDING-TREND" → "funding_trend". Used by the generator's mcpToolName
// template helper.
func SnakeIdentifier(s string) string {
+ s = ASCIIFold(s)
var b strings.Builder
lastUnder := false
for _, r := range s {
@@ -69,12 +90,13 @@ func SnakeIdentifier(s string) string {
}
// EnvPrefix returns an ASCII-only shell-safe environment variable prefix.
-// API display names and OpenAPI titles can contain accents or punctuation
-// ("PokéAPI", "Cal.com", "1Password"); generated env vars must not.
+// API display names and OpenAPI titles can contain accents or non-Latin
+// scripts ("PokéAPI", "Cal.com", "1Password", "東京"); generated env vars
+// must not.
func EnvPrefix(name string) string {
var b strings.Builder
lastUnderscore := false
- for _, r := range norm.NFD.String(name) {
+ for _, r := range ASCIIFold(name) {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r - ('a' - 'A'))
@@ -85,8 +107,6 @@ func EnvPrefix(name string) string {
case r >= '0' && r <= '9':
b.WriteRune(r)
lastUnderscore = false
- case unicode.Is(unicode.Mn, r):
- continue
default:
if !lastUnderscore && b.Len() > 0 {
b.WriteByte('_')
@@ -108,6 +128,7 @@ func EnvPrefix(name string) string {
// Hyphens are intentionally preserved to match the historical MCP template
// helper behavior.
func Snake(s string) string {
+ s = ASCIIFold(s)
var result strings.Builder
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
index 86bfa127..a09477cc 100644
--- a/internal/naming/naming_test.go
+++ b/internal/naming/naming_test.go
@@ -61,6 +61,11 @@ func TestEnvPrefix(t *testing.T) {
"food & dining": "FOOD_DINING",
"1password": "API_1PASSWORD",
"!!!": "API",
+ "Großhandel": "GROSSHANDEL",
+ "Łódź": "LODZ",
+ "Ørsted": "ORSTED",
+ "東京": "DONG_JING",
+ "русский": "RUSSKII",
}
for input, want := range tests {
@@ -70,6 +75,50 @@ func TestEnvPrefix(t *testing.T) {
}
}
+func TestASCIIFold(t *testing.T) {
+ tests := map[string]string{
+ "": "",
+ "already-ascii": "already-ascii",
+ // Precomposed accents:
+ "Pokémon": "Pokemon",
+ "naïve": "naive",
+ "café": "cafe",
+ // Fused-diacritic Latin:
+ "Großhandel": "Grosshandel",
+ "Łódź": "Lodz",
+ "Encyclopædia": "Encyclopaedia",
+ "Ørsted": "Orsted",
+ "Þingvellir": "Thingvellir",
+ // Non-Latin scripts:
+ "東京": "Dong Jing ",
+ "русский": "russkii",
+ "Δelta": "Delta",
+ }
+
+ for input, want := range tests {
+ if got := ASCIIFold(input); got != want {
+ t.Fatalf("ASCIIFold(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
+
+func TestSnakeIdentifier(t *testing.T) {
+ tests := map[string]string{
+ "funding --who": "funding_who",
+ "FUNDING-TREND": "funding_trend",
+ "already_snake": "already_snake",
+ "Pokémon list": "pokemon_list",
+ "Großhandel--query": "grosshandel_query",
+ "русский_kpi": "russkii_kpi",
+ }
+
+ for input, want := range tests {
+ if got := SnakeIdentifier(input); got != want {
+ t.Fatalf("SnakeIdentifier(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
+
func TestSnake(t *testing.T) {
tests := map[string]string{
"Pets": "pets",
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index abf34736..93ad0be9 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -2562,6 +2562,7 @@ func stripOperationIDResourceSegments(name string, variants []string) string {
}
func toSnakeCase(input string) string {
+ input = naming.ASCIIFold(input)
var b strings.Builder
var prev rune
lastUnderscore := true
@@ -2595,6 +2596,7 @@ func sanitizeResourceName(name string) string {
}
func sanitizeTypeName(name string) string {
+ name = naming.ASCIIFold(name)
name = strings.TrimLeft(name, "$")
name = strings.NewReplacer(".", "_", "/", "_", "\\", "_", "-", "_", " ", "_").Replace(name)
var b strings.Builder
@@ -2633,6 +2635,7 @@ func isGoKeyword(s string) bool {
}
func toCamelCase(s string) string {
+ s = naming.ASCIIFold(s)
s = strings.TrimLeft(s, "$")
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == '_' || r == '-' || r == ' ' || r == '.' || r == '/' || r == '\\' || r == '$' || r == '#' || r == '@'
@@ -2650,6 +2653,7 @@ func toCamelCase(s string) string {
}
func toKebabCase(input string) string {
+ input = naming.ASCIIFold(input)
var b strings.Builder
lastHyphen := true
@@ -2670,6 +2674,7 @@ func toKebabCase(input string) string {
}
func cleanSpecName(title string) string {
+ title = naming.ASCIIFold(title)
title = strings.ToLower(strings.TrimSpace(title))
if title == "" {
return "api"
@@ -2677,9 +2682,9 @@ func cleanSpecName(title string) string {
title = strings.ReplaceAll(title, "open api", " ")
- // Strip apostrophes so brand names like "Domino's" become "dominos" not "domino-s"
+ // Strip apostrophes so brand names like "Domino's" become "dominos" not
+ // "domino-s". Smart-quote U+2019 already folds to ASCII via ASCIIFold above.
title = strings.ReplaceAll(title, "'", "")
- title = strings.ReplaceAll(title, "\u2019", "") // Unicode right single quotation mark
var normalized strings.Builder
lastSpace := true
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 28f28486..cb146387 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -423,6 +423,20 @@ func TestCleanSpecName(t *testing.T) {
{title: "Domino\u2019s Pizza API", want: "dominos-pizza"},
// Multiple apostrophes
{title: "Rock'n'Roll API", want: "rocknroll"},
+ // Precomposed accents:
+ {title: "Pok\u00e9mon API", want: "pokemon"},
+ {title: "Caf\u00e9 Reservations", want: "cafe-reservations"},
+ {title: "Na\u00efve Bayes API", want: "naive-bayes"},
+ // Fused-diacritic Latin:
+ {title: "Gro\u00dfhandel API", want: "grosshandel"},
+ {title: "Encyclop\u00e6dia API", want: "encyclopaedia"},
+ {title: "\u00d8rsted Energy", want: "orsted-energy"},
+ {title: "\u0141\u00f3d\u017a Transit", want: "lodz-transit"},
+ {title: "\u00deingvellir Tours", want: "thingvellir-tours"},
+ // Non-Latin scripts:
+ {title: "\u6771\u4eac API", want: "dong-jing"},
+ {title: "\u0440\u0443\u0441\u0441\u043a\u0438\u0439 API", want: "russkii"},
+ {title: "\u0394elta API", want: "delta"},
}
for _, tt := range tests {
diff --git a/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
new file mode 100644
index 00000000..dc399c63
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
@@ -0,0 +1,26 @@
+# Locks the Unicode-to-ASCII slug contract. The fixture title is
+# "Café Bistro API" — every path below is the ASCII slug "cafe-bistro",
+# proving naming.ASCIIFold (Unidecode) ran on cleanSpecName, naming.CLI,
+# naming.MCP, and naming.EnvPrefix. If a future refactor narrows the
+# fold to "Latin precomposed accents only" or drops it entirely, these
+# paths would either disappear (the artifact lookup fails) or the JSON
+# fields below would surface raw "café" / "CAFÉ_*" content.
+
+# Provenance manifest: api_name, cli_name, mcp_binary, display_name, auth_env_vars
+cafe-bistro/.printing-press.json
+
+# MCPB manifest: server identity (binary name + display_name)
+cafe-bistro/manifest.json
+
+# Listing this path asserts the directory cmd/cafe-bistro-pp-cli/
+# exists with an ASCII name. The harness fails with "missing artifact"
+# if the slug ever surfaces as "café-bistro-pp-cli/" again.
+cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
+cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
+
+# Generated Go types: locks that sanitizeTypeName + toCamelCase fold
+# non-ASCII schema/property names to ASCII Go identifiers. The fixture
+# defines schema "CaféMenu" with property "spécialité"; this file must
+# show the ASCII-folded forms (no é, no extended bytes), proving every
+# schema-derived chokepoint actually ran ASCIIFold.
+cafe-bistro/internal/types/types.go
diff --git a/testdata/golden/cases/generate-golden-api-unicode/command.txt b/testdata/golden/cases/generate-golden-api-unicode/command.txt
new file mode 100644
index 00000000..34ca92ec
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-unicode/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/golden-api-unicode.yaml --output "$CASE_ACTUAL_DIR/cafe-bistro" --force --spec-url file://testdata/golden/fixtures/golden-api-unicode.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
new file mode 100644
index 00000000..293d4c0d
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
@@ -0,0 +1,20 @@
+{
+ "api_name": "cafe-bistro",
+ "api_version": "1.0.0",
+ "auth_env_vars": [
+ "CAFE_BISTRO_API_KEY_AUTH"
+ ],
+ "auth_type": "api_key",
+ "cli_name": "cafe-bistro-pp-cli",
+ "display_name": "Cafe Bistro",
+ "generated_at": "<GENERATED_AT>",
+ "mcp_binary": "cafe-bistro-pp-mcp",
+ "mcp_ready": "full",
+ "mcp_tool_count": 1,
+ "printing_press_version": "<PRINTING_PRESS_VERSION>",
+ "schema_version": 1,
+ "spec_checksum": "sha256:8cfba131c384836e8dde461511c9fd63cf2cbab7793553067765d515f2007aa1",
+ "spec_format": "openapi3",
+ "spec_path": "testdata/golden/fixtures/golden-api-unicode.yaml",
+ "spec_url": "file://testdata/golden/fixtures/golden-api-unicode.yaml"
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
new file mode 100644
index 00000000..d7b5d06c
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
@@ -0,0 +1,18 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "cafe-bistro-pp-cli/internal/cli"
+)
+
+func main() {
+ if err := cli.Execute(); err != nil {
+ fmt.Fprintln(os.Stderr, err.Error())
+ os.Exit(cli.ExitCode(err))
+ }
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
new file mode 100644
index 00000000..5e287c9d
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
@@ -0,0 +1,27 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/mark3labs/mcp-go/server"
+ mcptools "cafe-bistro-pp-cli/internal/mcp"
+)
+
+func main() {
+ s := server.NewMCPServer(
+ "Cafe Bistro",
+ "1.0.0",
+ server.WithToolCapabilities(false),
+ )
+
+ mcptools.RegisterTools(s)
+
+ if err := server.ServeStdio(s); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go
new file mode 100644
index 00000000..b3b5ed26
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go
@@ -0,0 +1,12 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package types
+
+
+type CafeMenu struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Specialite string `json:"specialite"`
+}
+
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
new file mode 100644
index 00000000..6fb278a4
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
@@ -0,0 +1,39 @@
+{
+ "author": {
+ "name": "CLI Printing Press"
+ },
+ "compatibility": {
+ "claude_desktop": ">=1.0.0",
+ "platforms": [
+ "darwin",
+ "linux",
+ "win32"
+ ]
+ },
+ "description": "Cafe Bistro API surface as MCP tools.",
+ "display_name": "Cafe Bistro",
+ "license": "Apache-2.0",
+ "manifest_version": "0.3",
+ "name": "cafe-bistro-pp-mcp",
+ "server": {
+ "entry_point": "bin/cafe-bistro-pp-mcp",
+ "mcp_config": {
+ "args": [],
+ "command": "${__dirname}/bin/cafe-bistro-pp-mcp",
+ "env": {
+ "CAFE_BISTRO_API_KEY_AUTH": "${user_config.cafe_bistro_api_key_auth}"
+ }
+ },
+ "type": "binary"
+ },
+ "user_config": {
+ "cafe_bistro_api_key_auth": {
+ "description": "Sets CAFE_BISTRO_API_KEY_AUTH for the Cafe Bistro MCP server.",
+ "required": true,
+ "sensitive": true,
+ "title": "CAFE_BISTRO_API_KEY_AUTH",
+ "type": "string"
+ }
+ },
+ "version": "<PRINTING_PRESS_VERSION>"
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/exit.txt b/testdata/golden/expected/generate-golden-api-unicode/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-golden-api-unicode/stderr.txt b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
new file mode 100644
index 00000000..45c603f2
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
@@ -0,0 +1 @@
+Generated cafe-bistro at <ARTIFACT_DIR>/generate-golden-api-unicode/cafe-bistro
diff --git a/testdata/golden/expected/generate-golden-api-unicode/stdout.txt b/testdata/golden/expected/generate-golden-api-unicode/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/golden-api-unicode.yaml b/testdata/golden/fixtures/golden-api-unicode.yaml
new file mode 100644
index 00000000..0fcc21d5
--- /dev/null
+++ b/testdata/golden/fixtures/golden-api-unicode.yaml
@@ -0,0 +1,44 @@
+openapi: "3.0.3"
+info:
+ title: Café Bistro API
+ version: "1.0.0"
+ description: Purpose-built fixture for the Unicode-to-ASCII slug contract.
+servers:
+ - url: https://api.cafe-bistro.example/v1
+security:
+ - ApiKeyAuth: []
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ schemas:
+ # Schema name and one property are deliberately non-ASCII to lock in
+ # that sanitizeTypeName + toCamelCase fold to ASCII — the resulting
+ # types.go must define `type CafEMenu struct` (or similar ASCII shape)
+ # with `Specialite string` rather than retaining the accented bytes.
+ CaféMenu:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ spécialité:
+ type: string
+paths:
+ /menus:
+ get:
+ tags: [menus]
+ operationId: listMenus
+ summary: List menus
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/CaféMenu"
← 1a4bcae8 fix(cli): mcp-sync handles older library CLI drift (cliutil
·
back to Cli Printing Press
·
fix(cli): mcp-sync preserves manifest brand-casing and hand- 9f57c41d →