From 013a294b1055ea92b1745f9fc62e261034d2483c Mon Sep 17 00:00:00 2001 From: xiachao Date: Wed, 26 Aug 2026 15:02:25 +0800 Subject: [PATCH 1/2] Escape unique-symbol names in TS4094 diagnostics Internal symbol names use a "\xFE" prefix. Reporting propertySymbol.Name raw put invalid UTF-8 in TS4094; escape it like getNameOfSymbolAsWritten. Co-authored-by: Cursor --- tsc/internal/checker/nodebuilderimpl.go | 3 +- .../compiler/declaration_emit_ts4094_test.go | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tsc/internal/compiler/declaration_emit_ts4094_test.go diff --git a/tsc/internal/checker/nodebuilderimpl.go b/tsc/internal/checker/nodebuilderimpl.go index 53007b500e8c6..91d09ce879147 100644 --- a/tsc/internal/checker/nodebuilderimpl.go +++ b/tsc/internal/checker/nodebuilderimpl.go @@ -2680,7 +2680,8 @@ func (b *NodeBuilderImpl) createTypeNodesFromResolvedType(resolvedType *Structur continue } if getDeclarationModifierFlagsFromSymbol(propertySymbol)&(ast.ModifierFlagsPrivate|ast.ModifierFlagsProtected) != 0 { - b.ctx.tracker.ReportPrivateInBaseOfClassExpression(propertySymbol.Name) + // Unique-symbol names use the "\xFE" sentinel; diagnostics must show the escaped "__" form. + b.ctx.tracker.ReportPrivateInBaseOfClassExpression(ast.EscapeInternalSymbolName(propertySymbol.Name)) } if IsPrivateIdentifierSymbol(propertySymbol) { b.ctx.tracker.ReportPrivateInBaseOfClassExpression(ast.SymbolName(propertySymbol)) diff --git a/tsc/internal/compiler/declaration_emit_ts4094_test.go b/tsc/internal/compiler/declaration_emit_ts4094_test.go new file mode 100644 index 0000000000000..cd66c8429ca02 --- /dev/null +++ b/tsc/internal/compiler/declaration_emit_ts4094_test.go @@ -0,0 +1,86 @@ +package compiler_test + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/compiler" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" +) + +// TS4094 for unique-symbol private names must print the escaped "__@…" form, not the "\xFE" sentinel. +func TestTS4094EscapesInternalUniqueSymbolName(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + fs := vfstest.FromMap(map[string]string{ + "/dev/src/helper.ts": `declare const brand: unique symbol; +class Foo { + private [brand]: number = 1; +} +export function makeFoo() { + return new Foo(); +} +`, + "/dev/src/index.ts": `import { makeFoo } from "./helper"; +export const f = () => makeFoo(); +`, + }, true /*useCaseSensitiveFileNames*/) + fs = bundled.WrapFS(fs) + + opts := core.CompilerOptions{ + Target: core.ScriptTargetESNext, + Module: core.ModuleKindESNext, + Declaration: core.TSTrue, + EmitDeclarationOnly: core.TSTrue, + Strict: core.TSTrue, + } + host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil, nil) + p := compiler.NewProgram(compiler.ProgramOptions{ + Config: &tsoptions.ParsedCommandLine{ + ParsedConfig: &tsoptions.ParsedOptions{ + FileNames: []string{"/dev/src/helper.ts", "/dev/src/index.ts"}, + CompilerOptions: &opts, + }, + }, + Host: host, + }) + + result := p.Emit(t.Context(), compiler.EmitOptions{ + WriteFile: func(string, string, *compiler.WriteFileData) error { return nil }, + }) + if result == nil { + t.Fatal("expected emit result") + } + + var found4094 bool + for _, d := range result.Diagnostics { + if d.Code() != 4094 { + continue + } + found4094 = true + msg := d.String() + if strings.Contains(msg, "\xFE") || !utf8.ValidString(msg) { + t.Fatalf("TS4094 leaked internal sentinel: %q", msg) + } + if strings.ContainsRune(msg, '\uFFFD') { + t.Fatalf("TS4094 used replacement character: %q", msg) + } + if !strings.Contains(msg, "__@brand@") { + t.Fatalf("TS4094 should name the escaped unique symbol, got %q", msg) + } + } + if !found4094 { + var msgs []string + for _, d := range result.Diagnostics { + msgs = append(msgs, d.MessageText()) + } + t.Fatalf("expected TS4094; diagnostics: %v", msgs) + } +} From 864926a40b1c89deb9deb64e2e1569efbc0748c9 Mon Sep 17 00:00:00 2001 From: xiachao Date: Mon, 31 Aug 2026 01:58:57 +0800 Subject: [PATCH 2/2] Test TS4094 unique-symbol escape via compiler baselines Replace the ad hoc Go emit test with a compiler fixture. Strip the process-global symbol id from the diagnostic name so "__@brand" is stable. Co-authored-by: Cursor --- tsc/internal/checker/nodebuilderimpl.go | 19 +++- .../compiler/declaration_emit_ts4094_test.go | 86 ------------------- ...onymousClassUniqueSymbolPrivate.errors.txt | 25 ++++++ ...onEmitAnonymousClassUniqueSymbolPrivate.js | 38 ++++++++ ...tAnonymousClassUniqueSymbolPrivate.symbols | 29 +++++++ ...mitAnonymousClassUniqueSymbolPrivate.types | 33 +++++++ ...onEmitAnonymousClassUniqueSymbolPrivate.ts | 16 ++++ 7 files changed, 158 insertions(+), 88 deletions(-) delete mode 100644 tsc/internal/compiler/declaration_emit_ts4094_test.go create mode 100644 tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.errors.txt create mode 100644 tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.js create mode 100644 tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.symbols create mode 100644 tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.types create mode 100644 tsc/testdata/tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts diff --git a/tsc/internal/checker/nodebuilderimpl.go b/tsc/internal/checker/nodebuilderimpl.go index 91d09ce879147..b56b2f211cc89 100644 --- a/tsc/internal/checker/nodebuilderimpl.go +++ b/tsc/internal/checker/nodebuilderimpl.go @@ -2680,8 +2680,7 @@ func (b *NodeBuilderImpl) createTypeNodesFromResolvedType(resolvedType *Structur continue } if getDeclarationModifierFlagsFromSymbol(propertySymbol)&(ast.ModifierFlagsPrivate|ast.ModifierFlagsProtected) != 0 { - // Unique-symbol names use the "\xFE" sentinel; diagnostics must show the escaped "__" form. - b.ctx.tracker.ReportPrivateInBaseOfClassExpression(ast.EscapeInternalSymbolName(propertySymbol.Name)) + b.ctx.tracker.ReportPrivateInBaseOfClassExpression(escapeInternalNameForTS4094(propertySymbol.Name)) } if IsPrivateIdentifierSymbol(propertySymbol) { b.ctx.tracker.ReportPrivateInBaseOfClassExpression(ast.SymbolName(propertySymbol)) @@ -3636,3 +3635,19 @@ func (b *NodeBuilderImpl) lookupExpressionChainTypeArgumentNodes(chain []*ast.Sy func (b *NodeBuilderImpl) shouldWriteTypeParametersInQualifiedName(chain []*ast.Symbol, index int) bool { return b.ctx.flags&nodebuilder.FlagsWriteTypeParametersInQualifiedName != 0 && index < len(chain)-1 } + +// escapeInternalNameForTS4094 prints unique-symbol names as "__@brand", not the "\xFE" sentinel +// and not the process-global "@" suffix (ast.nextSymbolId), which would flake baselines. +func escapeInternalNameForTS4094(name string) string { + escaped := ast.EscapeInternalSymbolName(name) + at := strings.LastIndexByte(escaped, '@') + if at <= 0 || at+1 >= len(escaped) { + return escaped + } + for i := at + 1; i < len(escaped); i++ { + if escaped[i] < '0' || escaped[i] > '9' { + return escaped + } + } + return escaped[:at] +} diff --git a/tsc/internal/compiler/declaration_emit_ts4094_test.go b/tsc/internal/compiler/declaration_emit_ts4094_test.go deleted file mode 100644 index cd66c8429ca02..0000000000000 --- a/tsc/internal/compiler/declaration_emit_ts4094_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package compiler_test - -import ( - "strings" - "testing" - "unicode/utf8" - - "github.com/microsoft/TypeScript/tsc/internal/bundled" - "github.com/microsoft/TypeScript/tsc/internal/compiler" - "github.com/microsoft/TypeScript/tsc/internal/core" - "github.com/microsoft/TypeScript/tsc/internal/tsoptions" - "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" -) - -// TS4094 for unique-symbol private names must print the escaped "__@…" form, not the "\xFE" sentinel. -func TestTS4094EscapesInternalUniqueSymbolName(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - fs := vfstest.FromMap(map[string]string{ - "/dev/src/helper.ts": `declare const brand: unique symbol; -class Foo { - private [brand]: number = 1; -} -export function makeFoo() { - return new Foo(); -} -`, - "/dev/src/index.ts": `import { makeFoo } from "./helper"; -export const f = () => makeFoo(); -`, - }, true /*useCaseSensitiveFileNames*/) - fs = bundled.WrapFS(fs) - - opts := core.CompilerOptions{ - Target: core.ScriptTargetESNext, - Module: core.ModuleKindESNext, - Declaration: core.TSTrue, - EmitDeclarationOnly: core.TSTrue, - Strict: core.TSTrue, - } - host := compiler.NewCompilerHost("/dev/src", fs, bundled.LibPath(), nil, nil, nil) - p := compiler.NewProgram(compiler.ProgramOptions{ - Config: &tsoptions.ParsedCommandLine{ - ParsedConfig: &tsoptions.ParsedOptions{ - FileNames: []string{"/dev/src/helper.ts", "/dev/src/index.ts"}, - CompilerOptions: &opts, - }, - }, - Host: host, - }) - - result := p.Emit(t.Context(), compiler.EmitOptions{ - WriteFile: func(string, string, *compiler.WriteFileData) error { return nil }, - }) - if result == nil { - t.Fatal("expected emit result") - } - - var found4094 bool - for _, d := range result.Diagnostics { - if d.Code() != 4094 { - continue - } - found4094 = true - msg := d.String() - if strings.Contains(msg, "\xFE") || !utf8.ValidString(msg) { - t.Fatalf("TS4094 leaked internal sentinel: %q", msg) - } - if strings.ContainsRune(msg, '\uFFFD') { - t.Fatalf("TS4094 used replacement character: %q", msg) - } - if !strings.Contains(msg, "__@brand@") { - t.Fatalf("TS4094 should name the escaped unique symbol, got %q", msg) - } - } - if !found4094 { - var msgs []string - for _, d := range result.Diagnostics { - msgs = append(msgs, d.MessageText()) - } - t.Fatalf("expected TS4094; diagnostics: %v", msgs) - } -} diff --git a/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.errors.txt b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.errors.txt new file mode 100644 index 0000000000000..df00a4fc5dc6a --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.errors.txt @@ -0,0 +1,25 @@ +index.ts(3,14): error TS4023: Exported variable 'f' has or is using name 'brand' from external module "helper" but cannot be named. +index.ts(3,14): error TS4094: Property '__@brand' of exported anonymous class type may not be private or protected. + + +==== helper.ts (0 errors) ==== + declare const brand: unique symbol; + + class Foo { + private [brand]: number = 1; + } + + export function makeFoo() { + return new Foo(); + } + +==== index.ts (2 errors) ==== + import { makeFoo } from "./helper"; + + export const f = () => makeFoo(); + ~ +!!! error TS4023: Exported variable 'f' has or is using name 'brand' from external module "helper" but cannot be named. + ~ +!!! error TS4094: Property '__@brand' of exported anonymous class type may not be private or protected. +!!! related TS9027 index.ts:3:14: Add a type annotation to the variable f. + \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.js b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.js new file mode 100644 index 0000000000000..a7acc10af417c --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.js @@ -0,0 +1,38 @@ +//// [tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts] //// + +//// [helper.ts] +declare const brand: unique symbol; + +class Foo { + private [brand]: number = 1; +} + +export function makeFoo() { + return new Foo(); +} + +//// [index.ts] +import { makeFoo } from "./helper"; + +export const f = () => makeFoo(); + + +//// [helper.js] +class Foo { + [brand] = 1; +} +export function makeFoo() { + return new Foo(); +} +//// [index.js] +import { makeFoo } from "./helper"; +export const f = () => makeFoo(); + + +//// [helper.d.ts] +declare const brand: unique symbol; +declare class Foo { + private [brand]; +} +export declare function makeFoo(): Foo; +export {}; diff --git a/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.symbols b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.symbols new file mode 100644 index 0000000000000..9505748314f46 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.symbols @@ -0,0 +1,29 @@ +//// [tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts] //// + +=== helper.ts === +declare const brand: unique symbol; +>brand : Symbol(brand, Decl(helper.ts, 0, 13)) + +class Foo { +>Foo : Symbol(Foo, Decl(helper.ts, 0, 35)) + + private [brand]: number = 1; +>[brand] : Symbol(Foo[brand], Decl(helper.ts, 2, 11)) +>brand : Symbol(brand, Decl(helper.ts, 0, 13)) +} + +export function makeFoo() { +>makeFoo : Symbol(makeFoo, Decl(helper.ts, 4, 1)) + + return new Foo(); +>Foo : Symbol(Foo, Decl(helper.ts, 0, 35)) +} + +=== index.ts === +import { makeFoo } from "./helper"; +>makeFoo : Symbol(makeFoo, Decl(index.ts, 0, 8)) + +export const f = () => makeFoo(); +>f : Symbol(f, Decl(index.ts, 2, 12)) +>makeFoo : Symbol(makeFoo, Decl(index.ts, 0, 8)) + diff --git a/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.types b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.types new file mode 100644 index 0000000000000..e965d785347aa --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.types @@ -0,0 +1,33 @@ +//// [tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts] //// + +=== helper.ts === +declare const brand: unique symbol; +>brand : unique symbol + +class Foo { +>Foo : Foo + + private [brand]: number = 1; +>[brand] : number +>brand : unique symbol +>1 : 1 +} + +export function makeFoo() { +>makeFoo : () => Foo + + return new Foo(); +>new Foo() : Foo +>Foo : typeof Foo +} + +=== index.ts === +import { makeFoo } from "./helper"; +>makeFoo : () => Foo + +export const f = () => makeFoo(); +>f : () => Foo +>() => makeFoo() : () => Foo +>makeFoo() : Foo +>makeFoo : () => Foo + diff --git a/tsc/testdata/tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts b/tsc/testdata/tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts new file mode 100644 index 0000000000000..bc3d9f72f0b7d --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/declarationEmitAnonymousClassUniqueSymbolPrivate.ts @@ -0,0 +1,16 @@ +// @declaration: true +// @filename: helper.ts +declare const brand: unique symbol; + +class Foo { + private [brand]: number = 1; +} + +export function makeFoo() { + return new Foo(); +} + +// @filename: index.ts +import { makeFoo } from "./helper"; + +export const f = () => makeFoo();