From a61bf03cc8726f015dd8735d4b6af892486ea65e Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Mon, 24 Aug 2026 10:04:32 -0700 Subject: [PATCH] fix(compiler): qualify cross-file protobuf bindings through the owning const map A same-package sibling field constructor qualified the referenced message class with the Go safe identifier of the struct name. When protoc-gen-go applies digit-camel capitalization the sibling binding's exported const spells the name differently, such as V86fs versus the struct name V86Fs, so the emitted reference dangled. Record each binding file's bound message names in the package-wide registry and qualify cross-file constructors through that map, emitting the actual exported const spelling. Fall back to the safe identifier when a message has no bound name. Signed-off-by: Christian Stewart --- compiler/protobuf-ts-binding.go | 15 +++- compiler/protobuf_ts_binding_matrix3_test.go | 79 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 compiler/protobuf_ts_binding_matrix3_test.go diff --git a/compiler/protobuf-ts-binding.go b/compiler/protobuf-ts-binding.go index 954fcce1..9a28048e 100644 --- a/compiler/protobuf-ts-binding.go +++ b/compiler/protobuf-ts-binding.go @@ -22,10 +22,13 @@ type protobufTypeScriptBinding struct { } // protobufTypeScriptBoundMessage records the sibling binding file that -// publishes a bound message class. +// publishes a bound message class. messageNames is that file's bound +// message names, so cross-file qualification emits the actual exported +// TypeScript const spelling rather than the Go safe identifier. type protobufTypeScriptBoundMessage struct { importSource string outputName string + messageNames map[string]string } // protobufTypeScriptBindingSiblingImports mints side-effect imports of @@ -137,6 +140,7 @@ func protobufTypeScriptBindings(semPkg *semanticPackage, options LoweringOptions packageMessages[name] = protobufTypeScriptBoundMessage{ importSource: binding.importSource, outputName: binding.outputName, + messageNames: binding.messageNames, } } } @@ -893,7 +897,14 @@ func protobufTypeScriptBindingFieldCtor(field loweredStructField, pkgName string if !crossFile { return "", true } - return siblings.aliasFor(sibling.importSource, sibling.outputName) + "." + protobufTypeScriptBindingSafeIdentifier(refType), true + + // Qualify the sibling binding's actual exported const spelling; the + // Go safe identifier can differ in digit-camel capitalization. + refName, bound := sibling.messageNames[refType] + if !bound { + refName = protobufTypeScriptBindingSafeIdentifier(refType) + } + return siblings.aliasFor(sibling.importSource, sibling.outputName) + "." + refName, true } return protobufTypeScriptBindingImportedCtor(field.typ, refType, file), true } diff --git a/compiler/protobuf_ts_binding_matrix3_test.go b/compiler/protobuf_ts_binding_matrix3_test.go new file mode 100644 index 00000000..05ed7382 --- /dev/null +++ b/compiler/protobuf_ts_binding_matrix3_test.go @@ -0,0 +1,79 @@ +package compiler + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +// TestProtobufTypeScriptBindingCrossFileDigitCamelFieldCtor verifies that a +// message-kind field resolves a sibling binding's bound const when the Go +// struct applies protoc-gen-go's digit-camel capitalization in another file +// of the same proto package. The case-insensitive fallback and the +// package-wide registry must compose: the field constructor qualifies the +// sibling binding's actual exported const spelling, not the Go safe +// identifier, while an exactly matching struct elsewhere still binds to its +// own const exactly. +func TestProtobufTypeScriptBindingCrossFileDigitCamelFieldCtor(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module example.test/digitcrosspb\n\ngo 1.25\n") + writeTestFile(t, dir, "world.pb.go", `package digitcrosspb + +type V86Fs struct { + Name string +} +`) + writeTestFile(t, dir, "world.pb.ts", `export interface V86fs { + name?: string +} +export const V86fs = {} as any +`) + writeTestFile(t, dir, "root.pb.go", `package digitcrosspb + +type WorldCommit struct { + Checkpoint *V86Fs `+"`"+`protobuf:"bytes,1,opt,name=checkpoint,json=checkpoint,proto3" json:"checkpoint,omitempty"`+"`"+` +} + +type ExactMsg struct { + Name string `+"`"+`protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`+"`"+` +} +`) + writeTestFile(t, dir, "root.pb.ts", `import { V86fs } from './world.pb.js' + +export interface WorldCommit { + checkpoint?: V86fs +} +export const WorldCommit = {} as any +export interface ExactMsg { + name?: string +} +export const ExactMsg = {} as any +`) + + out := filepath.Join(dir, "out") + comp, err := NewCompiler(&Config{ + Dir: dir, + OutputPath: out, + ProtobufTypeScriptBinding: true, + }, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := comp.CompilePackages(context.Background(), "."); err != nil { + t.Fatalf("compile with protobuf TypeScript binding: %v", err) + } + + root := readTestFile(t, filepath.Join(out, "@goscript", "example.test", "digitcrosspb", "root.pb.ts")) + if !strings.Contains(root, `"checkpoint": __protobuf_ts_world_pb.V86fs`) { + t.Fatalf("cross-file field should qualify the sibling binding's actual const spelling V86fs, got:\n%s", root) + } + if !strings.Contains(root, `__protobufTypeScriptMessage = __protobuf_ts.ExactMsg;`) { + t.Fatalf("exactly matching struct elsewhere should still bind to its own const exactly, got:\n%s", root) + } + + world := readTestFile(t, filepath.Join(out, "@goscript", "example.test", "digitcrosspb", "world.pb.ts")) + if !strings.Contains(world, `__protobufTypeScriptMessage = __protobuf_ts.V86fs;`) { + t.Fatalf("sibling binding should keep binding V86Fs to its exported const V86fs, got:\n%s", world) + } +}