From 3afa978859660ca1cdb43e1843976ee7bfa8435c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:37:37 -0700 Subject: [PATCH 01/17] Enforce compiler record construction and cast invariants The compact compiler layouts depend on every header belonging to the concrete allocation identified by its tag. Unchecked kinds, retagging, and detached headers can otherwise turn a routine mistake into an invalid memory access. Keep that invariant mechanically enforced without adding layout tags or checks to hot accessors. --- tools/customlint/plugin.go | 1 + .../unsafelayout/consumer/consumer.go | 45 ++ .../unsafelayout/consumer/consumer.go.golden | 102 ++++ .../testdata/unsafelayout/internal/ast/ast.go | 87 +++ .../unsafelayout/internal/ast/ast.go.golden | 120 +++++ .../unsafelayout/internal/checker/checker.go | 106 ++++ .../internal/checker/checker.go.golden | 147 +++++ tools/customlint/unsafelayout.go | 507 ++++++++++++++++++ tsc/internal/ast/ast.go | 2 + tsc/internal/ast/nodeaccessors_test.go | 10 +- tsc/internal/checker/checker.go | 26 +- tsc/internal/checker/types_test.go | 47 +- .../transformers/declarations/transform.go | 9 +- .../reference/conformance/importTag5.js | 46 +- .../reference/conformance/importTag5.js.map | 11 + .../conformance/importTag5.sourcemap.txt | 389 ++++++++++++++ .../reference/conformance/importTag5.symbols | 34 ++ .../reference/conformance/importTag5.types | 33 ++ .../cases/conformance/jsdoc/importTag5.ts | 24 + 19 files changed, 1710 insertions(+), 36 deletions(-) create mode 100644 tools/customlint/testdata/unsafelayout/consumer/consumer.go create mode 100644 tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden create mode 100644 tools/customlint/testdata/unsafelayout/internal/ast/ast.go create mode 100644 tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden create mode 100644 tools/customlint/testdata/unsafelayout/internal/checker/checker.go create mode 100644 tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden create mode 100644 tools/customlint/unsafelayout.go create mode 100644 tsc/testdata/baselines/reference/conformance/importTag5.js.map create mode 100644 tsc/testdata/baselines/reference/conformance/importTag5.sourcemap.txt diff --git a/tools/customlint/plugin.go b/tools/customlint/plugin.go index fd9c5b9e7dfce..677f1a0d9012a 100644 --- a/tools/customlint/plugin.go +++ b/tools/customlint/plugin.go @@ -22,6 +22,7 @@ func (f *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { forbidParentAccessAnalyzer, shadowAnalyzer, typedPathsAnalyzer, + unsafeLayoutAnalyzer, unexportedAPIAnalyzer, }, nil } diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go b/tools/customlint/testdata/unsafelayout/consumer/consumer.go new file mode 100644 index 0000000000000..17e3ccd569881 --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go @@ -0,0 +1,45 @@ +package consumer + +import ( + "unsafe" + + "testdata/unsafelayout/internal/ast" + "testdata/unsafelayout/internal/checker" +) + +type BadNode ast.Node + +type NodeAlias = ast.Node + +type BorrowedNode struct { + *ast.Node +} + +func uses(node *ast.Node, declaration *ast.ImportDeclaration, kind ast.Kind, typ *checker.Type) { + node.Kind = ast.KindIdentifier + declaration.Kind = ast.KindImportDeclaration + declaration.Kind = ast.KindJSImportDeclaration + node.AsImportDeclaration().Kind = ast.KindImportDeclaration + declaration.Kind = ast.KindIdentifier + declaration.Kind = kind + declaration.Kind++ + _ = &node.Kind + _ = &ast.Node{Kind: ast.KindIdentifier} + _ = &ast.Identifier{} + _ = new(ast.Node) + _ = new(ast.Identifier) + _ = new(NodeAlias) + _ = unsafe.Sizeof(ast.Node{}) + _ = unsafe.Offsetof(ast.Identifier{}.Node) + _ = (*ast.Identifier)(unsafe.Pointer(node)) + _ = *node + _ = *declaration + _ = *typ + copy := *node + _ = copy + pointer := unsafe.Pointer(node) + _ = pointer + borrowed := BorrowedNode{Node: node} + borrowed.Kind = ast.KindIdentifier + _ = &borrowed.Kind +} diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden new file mode 100644 index 0000000000000..496120266c21f --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden @@ -0,0 +1,102 @@ + package consumer + + import ( + "unsafe" + + "testdata/unsafelayout/internal/ast" + "testdata/unsafelayout/internal/checker" + ) + + type BadNode ast.Node + ~ +!!! unsafelayout: defining a new compiler-record type bypasses checked construction; use a type alias + + type NodeAlias = ast.Node + + type BorrowedNode struct { + *ast.Node + } + + func uses(node *ast.Node, declaration *ast.ImportDeclaration, kind ast.Kind, typ *checker.Type) { + node.Kind = ast.KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = ast.KindImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = ast.KindJSImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + node.AsImportDeclaration().Kind = ast.KindImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = ast.KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = kind + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind++ + ~ +!!! unsafelayout: incrementing a compiler tag bypasses checked tag assignment + _ = &node.Kind + ~ +!!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment + _ = &ast.Node{Kind: ast.KindIdentifier} + ~ +!!! unsafelayout: a bare compiler header has no concrete payload; use a concrete factory + ~ +!!! unsafelayout: construct compiler records through their owning factories, not struct literals + ~ +!!! unsafelayout: compiler tags must be assigned by checked construction, not struct literals + _ = &ast.Identifier{} + ~ +!!! unsafelayout: construct compiler records through their owning factories, not struct literals + _ = new(ast.Node) + ~ +!!! unsafelayout: a bare compiler header has no concrete payload; use a concrete factory + ~ +!!! unsafelayout: allocate compiler records through their owning factories + _ = new(ast.Identifier) + ~ +!!! unsafelayout: allocate compiler records through their owning factories + _ = new(NodeAlias) + ~ +!!! unsafelayout: a bare compiler header has no concrete payload; use a concrete factory + ~ +!!! unsafelayout: allocate compiler records through their owning factories + _ = unsafe.Sizeof(ast.Node{}) + _ = unsafe.Offsetof(ast.Identifier{}.Node) + _ = (*ast.Identifier)(unsafe.Pointer(node)) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + _ = *node + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = *declaration + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = *typ + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + copy := *node + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = copy + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + pointer := unsafe.Pointer(node) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + _ = pointer + borrowed := BorrowedNode{Node: node} + borrowed.Kind = ast.KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + _ = &borrowed.Kind + ~ +!!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go new file mode 100644 index 0000000000000..dd1d08993aa26 --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go @@ -0,0 +1,87 @@ +package ast + +type Kind int16 + +const ( + KindIdentifier Kind = iota + KindImportDeclaration + KindJSImportDeclaration +) + +type Node struct { + Kind Kind +} + +type Identifier struct { + Node + Text string +} + +type ImportDeclaration struct { + Node +} + +func (n *Node) AsNode() *Node { return n } + +func (n *Node) AsIdentifier() *Identifier { + if n.Kind != KindIdentifier { + panic("Invalid node cast") + } + return nil +} + +func (n *Node) AsImportDeclaration() *ImportDeclaration { + switch n.Kind { + case KindImportDeclaration, KindJSImportDeclaration: + return nil + default: + panic("Invalid node cast") + } +} + +func newNode(kind Kind, node *Node) *Node { + node.Kind = kind + return node +} + +func NewIdentifier(text string) *Node { + data := &Identifier{Text: text} + return newNode(KindIdentifier, data.AsNode()) +} + +func badFactory() *Node { + data := &ImportDeclaration{} + return newNode(KindIdentifier, data.AsNode()) +} + +func escapes(kind Kind) *Node { + data := &Identifier{} + _ = newNode + return newNode(kind, data.AsNode()) +} + +type PretendIdentifier struct { + Identifier +} + +func (n *PretendIdentifier) AsNode() *Node { return &n.Node } + +func shadowedUpcast() *Node { + data := &PretendIdentifier{} + return newNode(KindIdentifier, data.AsNode()) +} + +func illegalWrites(node *Node, identifier *Identifier, declaration *ImportDeclaration, kind Kind) { + node.Kind = KindIdentifier + identifier.Kind = KindIdentifier + identifier.Kind = KindImportDeclaration + declaration.Kind = KindImportDeclaration + declaration.Kind = KindJSImportDeclaration + declaration.Kind = kind + declaration.Kind += KindImportDeclaration + node.Kind++ + _ = &node.Kind + (node.Kind) = KindIdentifier + (node.Kind)++ + _ = &(node.Kind) +} diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden new file mode 100644 index 0000000000000..a75ec90b26c09 --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden @@ -0,0 +1,120 @@ + package ast + + type Kind int16 + + const ( + KindIdentifier Kind = iota + KindImportDeclaration + KindJSImportDeclaration + ) + + type Node struct { + Kind Kind + } + + type Identifier struct { + Node + Text string + } + + type ImportDeclaration struct { + Node + } + + func (n *Node) AsNode() *Node { return n } + + func (n *Node) AsIdentifier() *Identifier { + if n.Kind != KindIdentifier { + panic("Invalid node cast") + } + return nil + } + + func (n *Node) AsImportDeclaration() *ImportDeclaration { + switch n.Kind { + case KindImportDeclaration, KindJSImportDeclaration: + return nil + default: + panic("Invalid node cast") + } + } + + func newNode(kind Kind, node *Node) *Node { + node.Kind = kind + return node + } + + func NewIdentifier(text string) *Node { + data := &Identifier{Text: text} + return newNode(KindIdentifier, data.AsNode()) + } + + func badFactory() *Node { + data := &ImportDeclaration{} + return newNode(KindIdentifier, data.AsNode()) + ~ +!!! unsafelayout: newNode must pair a concrete AsNode() receiver with a compatible kind + } + + func escapes(kind Kind) *Node { + data := &Identifier{} + _ = newNode + ~ +!!! unsafelayout: checked construction must be called directly so its allocation/tag pairing can be verified + return newNode(kind, data.AsNode()) + ~ +!!! unsafelayout: newNode must pair a concrete AsNode() receiver with a compatible kind + } + + type PretendIdentifier struct { + Identifier + } + + func (n *PretendIdentifier) AsNode() *Node { return &n.Node } + + func shadowedUpcast() *Node { + data := &PretendIdentifier{} + return newNode(KindIdentifier, data.AsNode()) + ~ +!!! unsafelayout: newNode must pair a concrete AsNode() receiver with a compatible kind + } + + func illegalWrites(node *Node, identifier *Identifier, declaration *ImportDeclaration, kind Kind) { + node.Kind = KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + identifier.Kind = KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + identifier.Kind = KindImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = KindImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = KindJSImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind = kind + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + declaration.Kind += KindImportDeclaration + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + node.Kind++ + ~ +!!! unsafelayout: incrementing a compiler tag bypasses checked tag assignment + _ = &node.Kind + ~ +!!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment + (node.Kind) = KindIdentifier + ~ +!!! unsafelayout: AST kind is immutable outside checked construction; create a new node + (node.Kind)++ + ~ +!!! unsafelayout: incrementing a compiler tag bypasses checked tag assignment + _ = &(node.Kind) + ~ +!!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go new file mode 100644 index 0000000000000..413e77596a967 --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -0,0 +1,106 @@ +package checker + +import "unsafe" + +type typeKind uint8 + +const ( + typeKindLiteral typeKind = iota + typeKindObject +) + +type Type struct { + kind typeKind +} + +type LiteralType struct { + TypeBase +} + +type ObjectType struct { + TypeBase +} + +type TypeBase struct { + Type +} + +func (t *TypeBase) AsType() *Type { return &t.Type } + +type Checker struct{} + +func (c *Checker) newType(flags, objectFlags int, typ *Type, kind typeKind) *Type { + typ.kind = kind + return typ +} + +func castType[T any](t *Type, kind typeKind) *T { + if t.kind != kind { + panic("Invalid type cast") + } + return (*T)(unsafe.Pointer(t)) +} + +func calls(c *Checker, literal *LiteralType, typ *Type, kind typeKind) { + c.newType(0, 0, literal.AsType(), typeKindLiteral) + c.newType(0, 0, literal.AsType(), typeKindObject) + c.newType(0, 0, literal.AsType(), kind) + c.newType(0, 0, typ, typeKindLiteral) + _ = c.newType + _ = castType[LiteralType](typ, typeKindLiteral) + _ = castType[LiteralType](typ, typeKindObject) + _ = castType[ObjectType](typ, typeKindLiteral) + _ = castType[LiteralType](typ, kind) + _ = castType[*LiteralType](typ, typeKindLiteral) + _ = castType[LiteralType] + typ.kind = typeKindLiteral + typ.kind++ + _ = &typ.kind + _ = (*LiteralType)(unsafe.Pointer(typ)) +} + +type PretendLiteralType struct { + LiteralType +} + +func (t *PretendLiteralType) AsType() *Type { return &t.Type } + +func shadowedUpcast(c *Checker) { + data := &PretendLiteralType{} + c.newType(0, 0, data.AsType(), typeKindLiteral) +} + +type typeMapperKind uint8 + +const ( + typeMapperSimple typeMapperKind = iota + typeMapperArray +) + +type TypeMapper struct { + kind typeMapperKind +} + +type SimpleTypeMapper struct { + TypeMapper +} + +type ArrayTypeMapper struct { + TypeMapper +} + +func castTypeMapper[T any](m *TypeMapper, kind typeMapperKind) *T { + if m.kind != kind { + panic("Invalid type mapper cast") + } + return (*T)(unsafe.Pointer(m)) +} + +func mapperCalls(simple *SimpleTypeMapper, header *TypeMapper) { + simple.kind = typeMapperSimple + simple.kind = typeMapperArray + header.kind = typeMapperSimple + _ = castTypeMapper[SimpleTypeMapper](header, typeMapperSimple) + _ = castTypeMapper[SimpleTypeMapper](header, typeMapperArray) + _ = (*SimpleTypeMapper)(unsafe.Pointer(header)) +} diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden new file mode 100644 index 0000000000000..dfe8e7a7f3c81 --- /dev/null +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -0,0 +1,147 @@ + package checker + + import "unsafe" + + type typeKind uint8 + + const ( + typeKindLiteral typeKind = iota + typeKindObject + ) + + type Type struct { + kind typeKind + } + + type LiteralType struct { + TypeBase + } + + type ObjectType struct { + TypeBase + } + + type TypeBase struct { + Type + } + + func (t *TypeBase) AsType() *Type { return &t.Type } + + type Checker struct{} + + func (c *Checker) newType(flags, objectFlags int, typ *Type, kind typeKind) *Type { + typ.kind = kind + return typ + } + + func castType[T any](t *Type, kind typeKind) *T { + if t.kind != kind { + panic("Invalid type cast") + } + return (*T)(unsafe.Pointer(t)) + } + + func calls(c *Checker, literal *LiteralType, typ *Type, kind typeKind) { + c.newType(0, 0, literal.AsType(), typeKindLiteral) + c.newType(0, 0, literal.AsType(), typeKindObject) + ~ +!!! unsafelayout: newType must directly pair a concrete AsType() receiver with its matching constant kind + c.newType(0, 0, literal.AsType(), kind) + ~ +!!! unsafelayout: newType must directly pair a concrete AsType() receiver with its matching constant kind + c.newType(0, 0, typ, typeKindLiteral) + ~ +!!! unsafelayout: newType must directly pair a concrete AsType() receiver with its matching constant kind + _ = c.newType + ~ +!!! unsafelayout: checked construction must be called directly so its allocation/tag pairing can be verified + _ = castType[LiteralType](typ, typeKindLiteral) + _ = castType[LiteralType](typ, typeKindObject) + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + _ = castType[ObjectType](typ, typeKindLiteral) + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + _ = castType[LiteralType](typ, kind) + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + _ = castType[*LiteralType](typ, typeKindLiteral) + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + _ = castType[LiteralType] + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + typ.kind = typeKindLiteral + ~ +!!! unsafelayout: type kind is immutable outside checked construction + typ.kind++ + ~ +!!! unsafelayout: incrementing a compiler tag bypasses checked tag assignment + _ = &typ.kind + ~ +!!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment + _ = (*LiteralType)(unsafe.Pointer(typ)) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + } + + type PretendLiteralType struct { + LiteralType + } + + func (t *PretendLiteralType) AsType() *Type { return &t.Type } + + func shadowedUpcast(c *Checker) { + data := &PretendLiteralType{} + c.newType(0, 0, data.AsType(), typeKindLiteral) + ~ +!!! unsafelayout: newType must directly pair a concrete AsType() receiver with its matching constant kind + } + + type typeMapperKind uint8 + + const ( + typeMapperSimple typeMapperKind = iota + typeMapperArray + ) + + type TypeMapper struct { + kind typeMapperKind + } + + type SimpleTypeMapper struct { + TypeMapper + } + + type ArrayTypeMapper struct { + TypeMapper + } + + func castTypeMapper[T any](m *TypeMapper, kind typeMapperKind) *T { + if m.kind != kind { + panic("Invalid type mapper cast") + } + return (*T)(unsafe.Pointer(m)) + } + + func mapperCalls(simple *SimpleTypeMapper, header *TypeMapper) { + simple.kind = typeMapperSimple + simple.kind = typeMapperArray + ~ +!!! unsafelayout: mapper tag must match the concrete mapper allocation + header.kind = typeMapperSimple + ~ +!!! unsafelayout: mapper tag must match the concrete mapper allocation + _ = castTypeMapper[SimpleTypeMapper](header, typeMapperSimple) + _ = castTypeMapper[SimpleTypeMapper](header, typeMapperArray) + ~ +!!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind + _ = (*SimpleTypeMapper)(unsafe.Pointer(header)) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + } + diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go new file mode 100644 index 0000000000000..ea6ce8661eb06 --- /dev/null +++ b/tools/customlint/unsafelayout.go @@ -0,0 +1,507 @@ +package customlint + +import ( + "go/ast" + "go/constant" + "go/token" + "go/types" + "path/filepath" + "slices" + "strings" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + "golang.org/x/tools/go/ast/inspector" +) + +var unsafeLayoutAnalyzer = &analysis.Analyzer{ + Name: "unsafelayout", + Doc: "enforces construction, immutable tags, and checked casts for compiler records", + Requires: []*analysis.Analyzer{inspect.Analyzer}, + Run: func(pass *analysis.Pass) (any, error) { + in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + p := unsafeLayoutPass{pass: pass} + p.collectNodeLayouts() + for c := range in.Root().Preorder( + (*ast.AssignStmt)(nil), (*ast.IncDecStmt)(nil), (*ast.UnaryExpr)(nil), (*ast.CompositeLit)(nil), + (*ast.CallExpr)(nil), (*ast.IndexExpr)(nil), (*ast.TypeSpec)(nil), + (*ast.StarExpr)(nil), (*ast.SelectorExpr)(nil), (*ast.Ident)(nil), + ) { + p.check(c) + } + return nil, nil + }, +} + +type unsafeLayoutPass struct { + pass *analysis.Pass + nodeLayouts map[*types.TypeName][]int64 +} + +func (p *unsafeLayoutPass) collectNodeLayouts() { + if !strings.HasSuffix(p.pass.Pkg.Path(), "/internal/ast") { + return + } + p.nodeLayouts = make(map[*types.TypeName][]int64) + for _, file := range p.pass.Files { + for _, declaration := range file.Decls { + fn, ok := declaration.(*ast.FuncDecl) + if !ok || fn.Recv == nil || !strings.HasPrefix(fn.Name.Name, "As") { + continue + } + function, _ := p.pass.TypesInfo.Defs[fn.Name].(*types.Func) + if function == nil { + continue + } + signature := function.Type().(*types.Signature) + if signature.Results().Len() != 1 { + continue + } + target := layoutNamedType(signature.Results().At(0).Type()) + if target == nil || layoutFamily(target) != "Node" || fn.Name.Name != "As"+target.Obj().Name() { + continue + } + var kinds []int64 + ast.Inspect(fn.Body, func(node ast.Node) bool { + if expr, isIdentifier := node.(*ast.Ident); isIdentifier { + if value, isConstant := p.pass.TypesInfo.Uses[expr].(*types.Const); isConstant && + value.Pkg() == p.pass.Pkg && layoutNamedType(value.Type()) != nil && + layoutNamedType(value.Type()).Obj().Name() == "Kind" { + if kind, isInteger := constant.Int64Val(value.Val()); isInteger { + kinds = append(kinds, kind) + } + } + } + return true + }) + if len(kinds) != 0 { + p.nodeLayouts[target.Obj()] = kinds + } + } + } +} + +func (p *unsafeLayoutPass) matchingNodeKind(typ types.Type, expr ast.Expr) bool { + named := layoutNamedType(typ) + if named == nil { + return false + } + value := p.pass.TypesInfo.Types[expr].Value + if value == nil || value.Kind() != constant.Int { + return false + } + kind, ok := constant.Int64Val(value) + if !ok { + return false + } + return slices.Contains(p.nodeLayouts[named.Obj()], kind) +} + +func layoutNamedType(typ types.Type) *types.Named { + if typ == nil { + return nil + } + if pointer, ok := types.Unalias(typ).(*types.Pointer); ok { + typ = pointer.Elem() + } + return namedType(typ) +} + +func layoutFamily(typ types.Type) string { + named := layoutNamedType(typ) + if named == nil || named.Obj().Pkg() == nil { + return "" + } + path := named.Obj().Pkg().Path() + if strings.HasSuffix(path, "/internal/ast") && (named.Obj().Name() == "Node" || named.Obj().Name() == "MutableNode") { + return "Node" + } + if strings.HasSuffix(path, "/internal/checker") { + switch named.Obj().Name() { + case "Type", "TypeMapper": + return named.Obj().Name() + } + } + if structure, ok := named.Underlying().(*types.Struct); ok && structure.NumFields() != 0 && structure.Field(0).Embedded() { + if _, pointer := types.Unalias(structure.Field(0).Type()).(*types.Pointer); !pointer { + return layoutFamily(structure.Field(0).Type()) + } + } + return "" +} + +func layoutKind(typ types.Type) *types.Const { + named := layoutNamedType(typ) + if named == nil || named.Obj().Pkg() == nil { + return nil + } + var name string + switch layoutFamily(typ) { + case "Type": + name = "typeKind" + strings.TrimSuffix(named.Obj().Name(), "Type") + case "TypeMapper": + name = "typeMapper" + strings.TrimSuffix(named.Obj().Name(), "TypeMapper") + default: + return nil + } + kind, _ := named.Obj().Pkg().Scope().Lookup(name).(*types.Const) + return kind +} + +func (p *unsafeLayoutPass) matchingKind(typ types.Type, expr ast.Expr) bool { + kind := layoutKind(typ) + value := p.pass.TypesInfo.Types[expr].Value + return kind != nil && value != nil && types.Identical(kind.Type(), p.pass.TypesInfo.TypeOf(expr)) && + constant.Compare(kind.Val(), token.EQL, value) +} + +func layoutFunction(c inspector.Cursor) *ast.FuncDecl { + for parent := c.Parent(); parent.Node() != nil; parent = parent.Parent() { + switch node := parent.Node().(type) { + case *ast.FuncLit: + return nil + case *ast.FuncDecl: + return node + } + } + return nil +} + +func (p *unsafeLayoutPass) ownerFunction(c inspector.Cursor, family, name string) bool { + fn := layoutFunction(c) + if fn == nil || fn.Name.Name != name { + return false + } + pkg := "/internal/checker" + if family == "Node" { + pkg = "/internal/ast" + } + if !strings.HasSuffix(p.pass.Pkg.Path(), pkg) { + return false + } + function, _ := p.pass.TypesInfo.Defs[fn.Name].(*types.Func) + if function == nil { + return false + } + signature := function.Type().(*types.Signature) + switch name { + case "castType", "castTypeMapper": + return signature.Recv() == nil + case "newNode": + return signature.Recv() == nil || + layoutNamedType(signature.Recv().Type()).Obj().Name() == "NodeFactory" + case "newType": + return signature.Recv() != nil && + layoutNamedType(signature.Recv().Type()).Obj().Name() == "Checker" + default: + return signature.Recv() != nil && layoutNamedType(signature.Recv().Type()).Obj().Name() == family + } +} + +func (p *unsafeLayoutPass) check(c inspector.Cursor) { + node := c.Node() + // Tests use detached metadata stubs; checked-cast pairing still applies. + test := strings.HasSuffix(p.pass.Fset.Position(node.Pos()).Filename, "_test.go") + switch node := node.(type) { + case *ast.TypeSpec: + if !node.Assign.IsValid() && layoutFamily(p.pass.TypesInfo.TypeOf(node.Type)) != "" { + // MutableNode changes the method set without changing the header layout. + if node.Name.Name != "MutableNode" || !strings.HasSuffix(p.pass.Pkg.Path(), "/internal/ast") { + p.pass.Reportf(node.Pos(), "defining a new compiler-record type bypasses checked construction; use a type alias") + } + } + case *ast.IndexExpr: + if function, ok := node.X.(*ast.Ident); ok { + object, _ := p.pass.TypesInfo.Uses[function].(*types.Func) + if object != nil && strings.HasSuffix(object.Pkg().Path(), "/internal/checker") && + (object.Name() == "castType" || object.Name() == "castTypeMapper") { + call, ok := c.Parent().Node().(*ast.CallExpr) + if !ok || call.Fun != node || len(call.Args) != 2 || namedType(p.pass.TypesInfo.TypeOf(node.Index)) == nil || + !p.matchingKind(p.pass.TypesInfo.TypeOf(node.Index), call.Args[1]) { + p.pass.Reportf(node.Pos(), "checked cast must directly pair its concrete target type with its matching constant kind") + } + } + } + case *ast.AssignStmt: + if test { + return + } + for _, lhs := range node.Lhs { + if selector, ok := ast.Unparen(lhs).(*ast.SelectorExpr); ok { + p.checkTagWrite(c, selector, node) + } + } + case *ast.IncDecStmt: + if selector, ok := ast.Unparen(node.X).(*ast.SelectorExpr); ok && p.protectedTag(selector) && !test { + p.pass.Reportf(node.Pos(), "incrementing a compiler tag bypasses checked tag assignment") + } + case *ast.UnaryExpr: + if node.Op == token.AND { + if selector, ok := ast.Unparen(node.X).(*ast.SelectorExpr); ok && p.protectedTag(selector) && !test { + p.pass.Reportf(node.Pos(), "taking a compiler tag's address bypasses checked tag assignment") + } + } + case *ast.CompositeLit: + if test || layoutFamily(p.pass.TypesInfo.TypeOf(node)) == "" || p.layoutConstant(c) { + return + } + family := layoutFamily(p.pass.TypesInfo.TypeOf(node)) + named := layoutNamedType(p.pass.TypesInfo.TypeOf(node)) + if named.Obj().Name() == family || named.Obj().Name() == "MutableNode" { + p.pass.Reportf(node.Pos(), "a bare compiler header has no concrete payload; use a concrete factory") + } + owner := "/internal/checker" + if family == "Node" { + owner = "/internal/ast" + } + if !strings.HasSuffix(p.pass.Pkg.Path(), owner) { + p.pass.Reportf(node.Pos(), "construct compiler records through their owning factories, not struct literals") + } + for _, element := range node.Elts { + if field, ok := element.(*ast.KeyValueExpr); ok { + if key, ok := field.Key.(*ast.Ident); ok && (key.Name == "kind" || key.Name == "Kind") { + p.pass.Reportf(field.Pos(), "compiler tags must be assigned by checked construction, not struct literals") + } + } + } + case *ast.CallExpr: + p.checkCall(c, node, test) + case *ast.StarExpr, *ast.SelectorExpr, *ast.Ident: + var object *types.Func + switch expr := node.(type) { + case *ast.SelectorExpr: + object, _ = p.pass.TypesInfo.Uses[expr.Sel].(*types.Func) + case *ast.Ident: + object, _ = p.pass.TypesInfo.Uses[expr].(*types.Func) + } + if object != nil && object.Pkg() != nil && + ((strings.HasSuffix(object.Pkg().Path(), "/internal/checker") && object.Name() == "newType") || + (strings.HasSuffix(object.Pkg().Path(), "/internal/ast") && object.Name() == "newNode")) { + call, ok := c.Parent().Node().(*ast.CallExpr) + if !ok || call.Fun != node { + // The selector's identifier is visited separately. + if selector, ok := c.Parent().Node().(*ast.SelectorExpr); !ok || selector.Sel != node { + p.pass.Reportf(node.Pos(), "checked construction must be called directly so its allocation/tag pairing can be verified") + } + } + } + if test { + return + } + expr := node.(ast.Expr) + value := p.pass.TypesInfo.Types[expr] + if value.IsType() || value.Type == nil { + return + } + if _, pointer := types.Unalias(value.Type).(*types.Pointer); pointer || layoutFamily(value.Type) == "" || p.layoutConstant(c) { + return + } + switch parent := c.Parent().Node().(type) { + case *ast.SelectorExpr: + if parent.X == expr { + return + } + case *ast.UnaryExpr: + if parent.Op == token.AND { + return + } + } + p.pass.Reportf(expr.Pos(), "copying a compiler record by value can detach its tagged header; keep a pointer") + } +} + +func (p *unsafeLayoutPass) protectedTag(selector *ast.SelectorExpr) bool { + return p.tagFamily(selector) != "" +} + +func (p *unsafeLayoutPass) tagFamily(selector *ast.SelectorExpr) string { + selection := p.pass.TypesInfo.Selections[selector] + if selection == nil || selection.Obj().Pkg() == nil { + return "" + } + field := selection.Obj() + if field.Name() != "Kind" && field.Name() != "kind" { + return "" + } + for _, family := range []string{"Node", "Type", "TypeMapper"} { + object := field.Pkg().Scope().Lookup(family) + if object == nil || layoutFamily(object.Type()) != family { + continue + } + structure := object.Type().Underlying().(*types.Struct) + for variable := range structure.Fields() { + if variable == field { + return family + } + } + } + return "" +} + +func (p *unsafeLayoutPass) checkTagWrite(c inspector.Cursor, selector *ast.SelectorExpr, assignment *ast.AssignStmt) { + if !p.protectedTag(selector) { + return + } + family := p.tagFamily(selector) + switch family { + case "Node": + if p.ownerFunction(c, family, "newNode") && layoutFunction(c).Recv == nil { + p.checkInitializer(selector, assignment) + return + } + p.pass.Reportf(selector.Pos(), "AST kind is immutable outside checked construction; create a new node") + case "Type": + if p.ownerFunction(c, family, "newType") { + p.checkInitializer(selector, assignment) + return + } + p.pass.Reportf(selector.Pos(), "type kind is immutable outside checked construction") + case "TypeMapper": + if assignment.Tok == token.ASSIGN && len(assignment.Lhs) == 1 && len(assignment.Rhs) == 1 && + p.matchingKind(p.pass.TypesInfo.TypeOf(selector.X), assignment.Rhs[0]) { + return + } + p.pass.Reportf(selector.Pos(), "mapper tag must match the concrete mapper allocation") + } +} + +func (p *unsafeLayoutPass) checkInitializer(selector *ast.SelectorExpr, assignment *ast.AssignStmt) { + if assignment.Tok == token.ASSIGN && len(assignment.Lhs) == 1 && len(assignment.Rhs) == 1 { + if kind, ok := assignment.Rhs[0].(*ast.Ident); ok && kind.Name == "kind" { + return + } + } + p.pass.Reportf(selector.Pos(), "checked construction must initialize the tag from its kind parameter") +} + +func (p *unsafeLayoutPass) layoutConstant(c inspector.Cursor) bool { + for parent := c.Parent(); parent.Node() != nil; parent = parent.Parent() { + if call, ok := parent.Node().(*ast.CallExpr); ok { + if selector, ok := call.Fun.(*ast.SelectorExpr); ok { + object := p.pass.TypesInfo.Uses[selector.Sel] + if object != nil && object.Pkg() != nil && object.Pkg().Path() == "unsafe" && + (object.Name() == "Offsetof" || object.Name() == "Sizeof") { + return true + } + } + } + } + return false +} + +func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, test bool) { + var function *types.Func + switch expr := call.Fun.(type) { + case *ast.SelectorExpr: + function, _ = p.pass.TypesInfo.Uses[expr.Sel].(*types.Func) + case *ast.Ident: + function, _ = p.pass.TypesInfo.Uses[expr].(*types.Func) + } + if function != nil && function.Pkg() != nil && !test { + if strings.HasSuffix(function.Pkg().Path(), "/internal/checker") && function.Name() == "newType" { + valid := len(call.Args) == 4 && p.matchingUpcast(call.Args[2], call.Args[3], "Type", false) + if !valid { + p.pass.Reportf(call.Pos(), "newType must directly pair a concrete AsType() receiver with its matching constant kind") + } + } + if strings.HasSuffix(function.Pkg().Path(), "/internal/ast") && function.Name() == "newNode" && + !p.ownerFunction(c, "Node", "newNode") { + valid := len(call.Args) >= 2 && p.matchingUpcast(call.Args[1], call.Args[0], "Node", p.generatedAST(c)) + if !valid { + p.pass.Reportf(call.Pos(), "newNode must pair a concrete AsNode() receiver with a compatible kind") + } + } + } + if len(call.Args) != 1 { + return + } + if builtin, ok := call.Fun.(*ast.Ident); ok && builtin.Name == "new" { + family := layoutFamily(p.pass.TypesInfo.TypeOf(call)) + if family != "" && !test { + named := layoutNamedType(p.pass.TypesInfo.TypeOf(call)) + if named.Obj().Name() == family || named.Obj().Name() == "MutableNode" { + p.pass.Reportf(call.Pos(), "a bare compiler header has no concrete payload; use a concrete factory") + } + owner := "/internal/checker" + if family == "Node" { + owner = "/internal/ast" + } + if !strings.HasSuffix(p.pass.Pkg.Path(), owner) { + p.pass.Reportf(call.Pos(), "allocate compiler records through their owning factories") + } + } + } + source := p.pass.TypesInfo.TypeOf(call.Args[0]) + target := p.pass.TypesInfo.TypeOf(call) + isUnsafePointer := func(typ types.Type) bool { + basic, ok := typ.(*types.Basic) + return ok && basic.Kind() == types.UnsafePointer + } + if !isUnsafePointer(source) && !isUnsafePointer(target) { + return + } + family := layoutFamily(target) + if family == "" { + family = layoutFamily(source) + } + if family == "" { + if pointer, ok := call.Args[0].(*ast.CallExpr); ok && len(pointer.Args) == 1 { + family = layoutFamily(p.pass.TypesInfo.TypeOf(pointer.Args[0])) + } + } + if family == "" { + return + } + allowed := false + switch family { + case "Node": + allowed = p.generatedAST(c) || p.ownerFunction(c, family, "AsFlowSwitchClauseData") || + p.ownerFunction(c, family, "AsFlowReduceLabelData") + case "Type": + allowed = p.ownerFunction(c, family, "castType") + for _, base := range []string{"ConstrainedType", "StructuredType", "ObjectType", "TypeReference", "InterfaceType", "UnionOrIntersectionType"} { + allowed = allowed || p.ownerFunction(c, family, "As"+base) + } + case "TypeMapper": + allowed = p.ownerFunction(c, family, "castTypeMapper") + } + if !allowed { + p.pass.Reportf(call.Pos(), "reinterpret compiler records only through checked casts or generated AST dispatch") + } +} + +func (p *unsafeLayoutPass) generatedAST(c inspector.Cursor) bool { + for parent := c; parent.Node() != nil; parent = parent.Parent() { + if file, ok := parent.Node().(*ast.File); ok { + return filepath.Base(p.pass.Fset.Position(file.Pos()).Filename) == "ast_generated.go" && ast.IsGenerated(file) && + strings.HasSuffix(p.pass.Pkg.Path(), "/internal/ast") + } + } + return false +} + +func (p *unsafeLayoutPass) matchingUpcast(expr, kind ast.Expr, family string, generated bool) bool { + call, ok := expr.(*ast.CallExpr) + if !ok || len(call.Args) != 0 { + return false + } + method, ok := call.Fun.(*ast.SelectorExpr) + if !ok || method.Sel.Name != "As"+family { + return false + } + selection := p.pass.TypesInfo.Selections[method] + if selection == nil { + return false + } + signature := selection.Obj().Type().(*types.Signature) + receiver := layoutNamedType(signature.Recv().Type()) + if receiver == nil || layoutFamily(receiver) != family || + (receiver.Obj().Name() != family && !(family == "Type" && receiver.Obj().Name() == "TypeBase")) { + return false + } + if family == "Node" { + return p.matchingNodeKind(p.pass.TypesInfo.TypeOf(method.X), kind) || + (generated && p.pass.TypesInfo.Types[kind].Value == nil) + } + return p.matchingKind(p.pass.TypesInfo.TypeOf(method.X), kind) +} diff --git a/tsc/internal/ast/ast.go b/tsc/internal/ast/ast.go index 48ff1a8d63d56..e996287c91792 100644 --- a/tsc/internal/ast/ast.go +++ b/tsc/internal/ast/ast.go @@ -174,6 +174,8 @@ func (list *ModifierList) Clone(f *NodeFactory) *ModifierList { // Every concrete AST node embeds Node at offset zero. Kind identifies the // enclosing allocation, which generated accessors recover without a data pointer. +// Initialize Kind only in newNode; transformations must construct a new node +// rather than retagging one. The unsafelayout linter enforces this boundary. type Node struct { Kind Kind diff --git a/tsc/internal/ast/nodeaccessors_test.go b/tsc/internal/ast/nodeaccessors_test.go index c18342076dbdb..f842ed1fc2f89 100644 --- a/tsc/internal/ast/nodeaccessors_test.go +++ b/tsc/internal/ast/nodeaccessors_test.go @@ -81,11 +81,11 @@ func TestNodeAccessorsSharedKinds(t *testing.T) { factory := ast.NewNodeFactory(ast.NodeFactoryHooks{}) name := factory.NewIdentifier("T") modifiers := factory.NewModifierList([]*ast.Node{factory.NewToken(ast.KindExportKeyword)}) - node := factory.NewTypeAliasDeclaration(modifiers, name, nil, nil) - data := node.AsTypeAliasDeclaration() - - for _, kind := range []ast.Kind{ast.KindTypeAliasDeclaration, ast.KindJSTypeAliasDeclaration} { - node.Kind = kind + for _, node := range []*ast.Node{ + factory.NewTypeAliasDeclaration(modifiers, name, nil, nil), + factory.NewJSTypeAliasDeclaration(modifiers, name, nil, nil), + } { + data := node.AsTypeAliasDeclaration() assert.Equal(t, node.Name(), name) assert.Equal(t, node.Modifiers(), modifiers) assert.Equal(t, node.DeclarationData(), &data.DeclarationBase) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index e58e96048e94b..abbb9e7dbde6f 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -25659,36 +25659,26 @@ func (c *Checker) newUniqueESSymbolType(symbol *ast.Symbol, name string) *Type { func (c *Checker) newObjectType(objectFlags ObjectFlags, symbol *ast.Symbol) *Type { var t *Type - var kind typeKind switch { case objectFlags&ObjectFlagsClassOrInterface != 0: - t = c.interfaceTypeArena.New().AsType() - kind = typeKindInterface + t = c.newType(TypeFlagsObject, objectFlags, c.interfaceTypeArena.New().AsType(), typeKindInterface) case objectFlags&ObjectFlagsTuple != 0: - t = (&TupleType{}).AsType() - kind = typeKindTuple + t = c.newType(TypeFlagsObject, objectFlags, (&TupleType{}).AsType(), typeKindTuple) case objectFlags&ObjectFlagsReference != 0: - t = c.typeReferenceArena.New().AsType() - kind = typeKindTypeReference + t = c.newType(TypeFlagsObject, objectFlags, c.typeReferenceArena.New().AsType(), typeKindTypeReference) case objectFlags&ObjectFlagsMapped != 0: - t = c.mappedTypeArena.New().AsType() - kind = typeKindMapped + t = c.newType(TypeFlagsObject, objectFlags, c.mappedTypeArena.New().AsType(), typeKindMapped) case objectFlags&ObjectFlagsReverseMapped != 0: - t = (&ReverseMappedType{}).AsType() - kind = typeKindReverseMapped + t = c.newType(TypeFlagsObject, objectFlags, (&ReverseMappedType{}).AsType(), typeKindReverseMapped) case objectFlags&ObjectFlagsEvolvingArray != 0: - t = (&EvolvingArrayType{}).AsType() - kind = typeKindEvolvingArray + t = c.newType(TypeFlagsObject, objectFlags, (&EvolvingArrayType{}).AsType(), typeKindEvolvingArray) case objectFlags&ObjectFlagsInstantiationExpressionType != 0: - t = (&InstantiationExpressionType{}).AsType() - kind = typeKindInstantiationExpression + t = c.newType(TypeFlagsObject, objectFlags, (&InstantiationExpressionType{}).AsType(), typeKindInstantiationExpression) case objectFlags&ObjectFlagsAnonymous != 0: - t = c.objectTypeArena.New().AsType() - kind = typeKindObject + t = c.newType(TypeFlagsObject, objectFlags, c.objectTypeArena.New().AsType(), typeKindObject) default: panic("Unhandled case in newObjectType") } - c.newType(TypeFlagsObject, objectFlags, t, kind) t.symbol = symbol return t } diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 86de2b089e06e..0d4eb99e5ad20 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -98,7 +98,18 @@ func TestTypeCasts(t *testing.T) { typ.flags = TypeFlagsNever typ.objectFlags = ObjectFlagsReference assert.Equal(t, test.cast(typ), any(test.data)) - assert.Assert(t, didPanic(func() { typ.AsLiteralType() }) == (test.kind != typeKindLiteral)) + for _, target := range tests { + switch target.kind { + case typeKindObject: + assert.Equal(t, target.cast(typ), any(test.data.AsObjectType())) + case typeKindTypeReference: + assert.Equal(t, target.cast(typ), any(test.data.AsTypeReference())) + case typeKindInterface: + assert.Equal(t, target.cast(typ), any(test.data.AsInterfaceType())) + default: + assert.Assert(t, didPanic(func() { target.cast(typ) }) == (test.kind != target.kind)) + } + } } } @@ -403,15 +414,15 @@ func TestTypeMapperDispatch(t *testing.T) { {simple, TypeMapperKindSimple, target}, {array, TypeMapperKindArray, target}, {merged, TypeMapperKindMerged, target}, - {newArrayToSingleTypeMapper([]*Type{source}, target), TypeMapperKindArrayToSingle, target}, - {newDeferredTypeMapper([]*Type{source}, []func() *Type{func() *Type { return target }}), TypeMapperKindDeferred, target}, + {newArrayToSingleTypeMapper([]*Type{source}, target), TypeMapperKindUnknown, target}, + {newDeferredTypeMapper([]*Type{source}, []func() *Type{func() *Type { return target }}), TypeMapperKindUnknown, target}, {newFunctionTypeMapper(func(typ *Type) *Type { if typ == source { return target } return typ - }), TypeMapperKindFunction, target}, - {newCompositeTypeMapper(c, newSimpleTypeMapper(other, target), simple), TypeMapperKindComposite, target}, + }), TypeMapperKindUnknown, target}, + {newCompositeTypeMapper(c, newSimpleTypeMapper(other, target), simple), TypeMapperKindUnknown, target}, } { assert.Equal(t, test.mapper.Kind(), test.kind) assert.Equal(t, test.mapper.Map(source), test.result) @@ -441,10 +452,28 @@ func TestCompactTypeMapperLayout(t *testing.T) { c, types := newCompositeTestChecker() mapper := newSimpleTypeMapper(types[0], types[1]) assert.Assert(t, castTypeMapper[SimpleTypeMapper](mapper, TypeMapperKindSimple).source == types[0]) - func() { - defer func() { assert.Assert(t, recover() != nil) }() - castTypeMapper[ArrayTypeMapper](mapper, TypeMapperKindArray) - }() + context := c.newInferenceContextWorker(nil, nil, InferenceFlagsNone, nil) + tests := []struct { + kind TypeMapperKind + mapper *TypeMapper + cast func(*TypeMapper) any + }{ + {TypeMapperKindSimple, mapper, func(m *TypeMapper) any { return castTypeMapper[SimpleTypeMapper](m, TypeMapperKindSimple) }}, + {TypeMapperKindArray, newArrayTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[ArrayTypeMapper](m, TypeMapperKindArray) }}, + {TypeMapperKindArrayToSingle, newArrayToSingleTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[ArrayToSingleTypeMapper](m, TypeMapperKindArrayToSingle) }}, + {TypeMapperKindDeferred, newDeferredTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[DeferredTypeMapper](m, TypeMapperKindDeferred) }}, + {TypeMapperKindFunction, newFunctionTypeMapper(func(t *Type) *Type { return t }), func(m *TypeMapper) any { return castTypeMapper[FunctionTypeMapper](m, TypeMapperKindFunction) }}, + {TypeMapperKindMerged, newMergedTypeMapper(mapper, mapper), func(m *TypeMapper) any { return castTypeMapper[MergedTypeMapper](m, TypeMapperKindMerged) }}, + {TypeMapperKindComposite, newCompositeTypeMapper(c, mapper, mapper), func(m *TypeMapper) any { return castTypeMapper[CompositeTypeMapper](m, TypeMapperKindComposite) }}, + {TypeMapperKindInference, context.mapper, func(m *TypeMapper) any { return castTypeMapper[InferenceTypeMapper](m, TypeMapperKindInference) }}, + } + runtime.GC() + for _, test := range tests { + assert.Equal(t, test.mapper.kind, test.kind) + for _, target := range tests { + assert.Assert(t, didPanic(func() { target.cast(test.mapper) }) == (test.kind != target.kind)) + } + } runtime.GC() assert.Equal(t, mapper.Map(types[0]), types[1]) runtime.KeepAlive(c) diff --git a/tsc/internal/transformers/declarations/transform.go b/tsc/internal/transformers/declarations/transform.go index 9333392d53fb8..1eb9d3cf63838 100644 --- a/tsc/internal/transformers/declarations/transform.go +++ b/tsc/internal/transformers/declarations/transform.go @@ -1738,9 +1738,12 @@ func (tx *DeclarationTransformer) transformTopLevelDeclaration(input *ast.Node) if input.Kind == ast.KindImportDeclaration || input.Kind == ast.KindJSImportDeclaration { res := tx.transformImportDeclaration(input.AsImportDeclaration()) if res != nil && res.Kind != ast.KindImportDeclaration { - res := res.Clone(tx.Factory()) - res.Kind = ast.KindImportDeclaration - return res + decl := res.AsImportDeclaration() + replacement := tx.Factory().NewImportDeclaration(decl.Modifiers(), decl.ImportClause, decl.ModuleSpecifier, decl.Attributes) + replacement.Flags = res.Flags + replacement.Loc = res.Loc + tx.EmitContext().SetOriginal(replacement, res) + return replacement } return res } diff --git a/tsc/testdata/baselines/reference/conformance/importTag5.js b/tsc/testdata/baselines/reference/conformance/importTag5.js index 3d2361c1ad8fb..b8856f6c0ba42 100644 --- a/tsc/testdata/baselines/reference/conformance/importTag5.js +++ b/tsc/testdata/baselines/reference/conformance/importTag5.js @@ -4,6 +4,9 @@ export interface Foo { a: number; } +export interface Unused { + unused: true; +} //// [foo.js] /** @@ -15,6 +18,26 @@ export interface Foo { */ function f(foo) {} +//// [imports.js] +/** + * @import { Foo, Unused } from "./types" + */ +/** + * @param {Foo} foo + */ +export function filtered(foo) { + return foo; +} +/** + * @import * as types from "./types" + */ +/** + * @param {types.Foo} foo + */ +export function namespace(foo) { + return foo; +} + @@ -22,7 +45,10 @@ function f(foo) {} export interface Foo { a: number; } -//// [foo.d.ts] +export interface Unused { + unused: true; +} +//# sourceMappingURL=types.d.ts.map//// [foo.d.ts] /** * @import { Foo } from "./types" */ @@ -31,3 +57,21 @@ import type { Foo } from "./types"; * @param { Foo } foo */ declare function f(foo: Foo): void; +//# sourceMappingURL=foo.d.ts.map//// [imports.d.ts] +import type { Foo } from "./types"; +/** + * @import { Foo, Unused } from "./types" + */ +/** + * @param {Foo} foo + */ +export declare function filtered(foo: Foo): Foo; +import type * as types from "./types"; +/** + * @import * as types from "./types" + */ +/** + * @param {types.Foo} foo + */ +export declare function namespace(foo: types.Foo): Foo; +//# sourceMappingURL=imports.d.ts.map \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/conformance/importTag5.js.map b/tsc/testdata/baselines/reference/conformance/importTag5.js.map new file mode 100644 index 0000000000000..e26abef78ca3b --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/importTag5.js.map @@ -0,0 +1,11 @@ +//// [foo.d.ts.map] +{"version":3,"file":"foo.d.ts","sources":["foo.js"],"names":[],"mappings":"AAAA;;GAEG;AADA,OAAQ,KAAA,EAAE,GAAG,EAAE,MAAM,SAAS,CAAA;AAGjC;;GAEG;AACH,iBAAS,CAAC,CAAC,GAAG,EAFF,GAEE,QAAI"} +//// https://sokra.github.io/source-map-visualization#base64,LyoqDQogKiBAaW1wb3J0IHsgRm9vIH0gZnJvbSAiLi90eXBlcyINCiAqLw0KaW1wb3J0IHR5cGUgeyBGb28gfSBmcm9tICIuL3R5cGVzIjsNCi8qKg0KICogQHBhcmFtIHsgRm9vIH0gZm9vDQogKi8NCmRlY2xhcmUgZnVuY3Rpb24gZihmb286IEZvbyk6IHZvaWQ7DQovLyMgc291cmNlTWFwcGluZ1VSTD1mb28uZC50cy5tYXA=,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9vLmQudHMiLCJzb3VyY2VzIjpbImZvby5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQURBLE9BQVEsS0FBQSxFQUFFLEdBQUcsRUFBRSxNQUFNLFNBQVMsQ0FBQTtBQUdqQzs7R0FFRztBQUNILGlCQUFTLENBQUMsQ0FBQyxHQUFHLEVBRkYsR0FFRSxRQUFJIn0=,LyoqCiAqIEBpbXBvcnQgeyBGb28gfSBmcm9tICIuL3R5cGVzIgogKi8KCi8qKgogKiBAcGFyYW0geyBGb28gfSBmb28KICovCmZ1bmN0aW9uIGYoZm9vKSB7fQo= + +//// [imports.d.ts.map] +{"version":3,"file":"imports.d.ts","sources":["imports.js"],"names":[],"mappings":"AACG,OAAQ,KAAA,EAAE,GAAG,EAAU,MAAM,SAAS,CAAA;AADzC;;GAEG;AACH;;GAEG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAFjB,GAEiB,OAE3B;AAEE,OAAQ,KAAA,KAAK,KAAK,MAAM,SAAS,CAAA;AADpC;;GAEG;AACH;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAFlB,KAAK,CAAC,GAEY,OAE5B"} +//// https://sokra.github.io/source-map-visualization#base64,aW1wb3J0IHR5cGUgeyBGb28gfSBmcm9tICIuL3R5cGVzIjsNCi8qKg0KICogQGltcG9ydCB7IEZvbywgVW51c2VkIH0gZnJvbSAiLi90eXBlcyINCiAqLw0KLyoqDQogKiBAcGFyYW0ge0Zvb30gZm9vDQogKi8NCmV4cG9ydCBkZWNsYXJlIGZ1bmN0aW9uIGZpbHRlcmVkKGZvbzogRm9vKTogRm9vOw0KaW1wb3J0IHR5cGUgKiBhcyB0eXBlcyBmcm9tICIuL3R5cGVzIjsNCi8qKg0KICogQGltcG9ydCAqIGFzIHR5cGVzIGZyb20gIi4vdHlwZXMiDQogKi8NCi8qKg0KICogQHBhcmFtIHt0eXBlcy5Gb299IGZvbw0KICovDQpleHBvcnQgZGVjbGFyZSBmdW5jdGlvbiBuYW1lc3BhY2UoZm9vOiB0eXBlcy5Gb28pOiBGb287DQovLyMgc291cmNlTWFwcGluZ1VSTD1pbXBvcnRzLmQudHMubWFw,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW1wb3J0cy5kLnRzIiwic291cmNlcyI6WyJpbXBvcnRzLmpzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUNHLE9BQVEsS0FBQSxFQUFFLEdBQUcsRUFBVSxNQUFNLFNBQVMsQ0FBQTtBQUR6Qzs7R0FFRztBQUNIOztHQUVHO0FBQ0gsd0JBQWdCLFFBQVEsQ0FBQyxHQUFHLEVBRmpCLEdBRWlCLE9BRTNCO0FBRUUsT0FBUSxLQUFBLEtBQUssS0FBSyxNQUFNLFNBQVMsQ0FBQTtBQURwQzs7R0FFRztBQUNIOztHQUVHO0FBQ0gsd0JBQWdCLFNBQVMsQ0FBQyxHQUFHLEVBRmxCLEtBQUssQ0FBQyxHQUVZLE9BRTVCIn0=,LyoqCiAqIEBpbXBvcnQgeyBGb28sIFVudXNlZCB9IGZyb20gIi4vdHlwZXMiCiAqLwovKioKICogQHBhcmFtIHtGb299IGZvbwogKi8KZXhwb3J0IGZ1bmN0aW9uIGZpbHRlcmVkKGZvbykgewogICAgcmV0dXJuIGZvbzsKfQovKioKICogQGltcG9ydCAqIGFzIHR5cGVzIGZyb20gIi4vdHlwZXMiCiAqLwovKioKICogQHBhcmFtIHt0eXBlcy5Gb299IGZvbwogKi8KZXhwb3J0IGZ1bmN0aW9uIG5hbWVzcGFjZShmb28pIHsKICAgIHJldHVybiBmb287Cn0K + +//// [types.d.ts.map] +{"version":3,"file":"types.d.ts","sources":["types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,GAAG;IAChB,CAAC,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,MAAM;IACnB,MAAM,EAAE,IAAI,CAAC;CAChB"} +//// https://sokra.github.io/source-map-visualization#base64,ZXhwb3J0IGludGVyZmFjZSBGb28gew0KICAgIGE6IG51bWJlcjsNCn0NCmV4cG9ydCBpbnRlcmZhY2UgVW51c2VkIHsNCiAgICB1bnVzZWQ6IHRydWU7DQp9DQovLyMgc291cmNlTWFwcGluZ1VSTD10eXBlcy5kLnRzLm1hcA==,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuZC50cyIsInNvdXJjZXMiOlsidHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsTUFBTSxXQUFXLEdBQUc7SUFDaEIsQ0FBQyxFQUFFLE1BQU0sQ0FBQztDQUNiO0FBQ0QsTUFBTSxXQUFXLE1BQU07SUFDbkIsTUFBTSxFQUFFLElBQUksQ0FBQztDQUNoQiJ9,ZXhwb3J0IGludGVyZmFjZSBGb28gewogICAgYTogbnVtYmVyOwp9CmV4cG9ydCBpbnRlcmZhY2UgVW51c2VkIHsKICAgIHVudXNlZDogdHJ1ZTsKfQo= diff --git a/tsc/testdata/baselines/reference/conformance/importTag5.sourcemap.txt b/tsc/testdata/baselines/reference/conformance/importTag5.sourcemap.txt new file mode 100644 index 0000000000000..5afb9fdff1454 --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/importTag5.sourcemap.txt @@ -0,0 +1,389 @@ +=================================================================== +JsFile: types.d.ts +mapUrl: types.d.ts.map +sourceRoot: +sources: types.ts +=================================================================== +------------------------------------------------------------------- +emittedFile:/types.d.ts +sourceFile:types.ts +------------------------------------------------------------------- +>>>export interface Foo { +1 > +2 >^^^^^^ +3 > ^^^^^^^^^^^ +4 > ^^^ +1 > +2 >export +3 > interface +4 > Foo +1 >Emitted(1, 1) Source(1, 1) + SourceIndex(0) +2 >Emitted(1, 7) Source(1, 7) + SourceIndex(0) +3 >Emitted(1, 18) Source(1, 18) + SourceIndex(0) +4 >Emitted(1, 21) Source(1, 21) + SourceIndex(0) +--- +>>> a: number; +1 >^^^^ +2 > ^ +3 > ^^ +4 > ^^^^^^ +5 > ^ +1 > { + > +2 > a +3 > : +4 > number +5 > ; +1 >Emitted(2, 5) Source(2, 5) + SourceIndex(0) +2 >Emitted(2, 6) Source(2, 6) + SourceIndex(0) +3 >Emitted(2, 8) Source(2, 8) + SourceIndex(0) +4 >Emitted(2, 14) Source(2, 14) + SourceIndex(0) +5 >Emitted(2, 15) Source(2, 15) + SourceIndex(0) +--- +>>>} +1 >^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > + >} +1 >Emitted(3, 2) Source(3, 2) + SourceIndex(0) +--- +>>>export interface Unused { +1-> +2 >^^^^^^ +3 > ^^^^^^^^^^^ +4 > ^^^^^^ +1-> + > +2 >export +3 > interface +4 > Unused +1->Emitted(4, 1) Source(4, 1) + SourceIndex(0) +2 >Emitted(4, 7) Source(4, 7) + SourceIndex(0) +3 >Emitted(4, 18) Source(4, 18) + SourceIndex(0) +4 >Emitted(4, 24) Source(4, 24) + SourceIndex(0) +--- +>>> unused: true; +1 >^^^^ +2 > ^^^^^^ +3 > ^^ +4 > ^^^^ +5 > ^ +1 > { + > +2 > unused +3 > : +4 > true +5 > ; +1 >Emitted(5, 5) Source(5, 5) + SourceIndex(0) +2 >Emitted(5, 11) Source(5, 11) + SourceIndex(0) +3 >Emitted(5, 13) Source(5, 13) + SourceIndex(0) +4 >Emitted(5, 17) Source(5, 17) + SourceIndex(0) +5 >Emitted(5, 18) Source(5, 18) + SourceIndex(0) +--- +>>>} +1 >^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > + >} +1 >Emitted(6, 2) Source(6, 2) + SourceIndex(0) +--- +>>>//# sourceMappingURL=types.d.ts.map=================================================================== +JsFile: foo.d.ts +mapUrl: foo.d.ts.map +sourceRoot: +sources: foo.js +=================================================================== +------------------------------------------------------------------- +emittedFile:/foo.d.ts +sourceFile:foo.js +------------------------------------------------------------------- +>>>/** +1 > +2 >^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > +1 >Emitted(1, 1) Source(1, 1) + SourceIndex(0) +--- +>>> * @import { Foo } from "./types" +>>> */ +1->^^^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1->/** + > * @import { Foo } from "./types" + > */ +1->Emitted(3, 4) Source(3, 4) + SourceIndex(0) +--- +>>>import type { Foo } from "./types"; +1-> +2 >^^^^^^^ +3 > ^^^^^ +4 > ^^ +5 > ^^^ +6 > ^^ +7 > ^^^^^^ +8 > ^^^^^^^^^ +9 > ^ +1-> +2 >@import +3 > +4 > { +5 > Foo +6 > } +7 > from +8 > "./types" +9 > +1->Emitted(4, 1) Source(2, 4) + SourceIndex(0) +2 >Emitted(4, 8) Source(2, 12) + SourceIndex(0) +3 >Emitted(4, 13) Source(2, 12) + SourceIndex(0) +4 >Emitted(4, 15) Source(2, 14) + SourceIndex(0) +5 >Emitted(4, 18) Source(2, 17) + SourceIndex(0) +6 >Emitted(4, 20) Source(2, 19) + SourceIndex(0) +7 >Emitted(4, 26) Source(2, 25) + SourceIndex(0) +8 >Emitted(4, 35) Source(2, 34) + SourceIndex(0) +9 >Emitted(4, 36) Source(2, 34) + SourceIndex(0) +--- +>>>/** +1 > +2 >^^^^^^^^^^^^^^^^^^^^^^-> +1 > + > */ + > + > +1 >Emitted(5, 1) Source(5, 1) + SourceIndex(0) +--- +>>> * @param { Foo } foo +>>> */ +1->^^^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1->/** + > * @param { Foo } foo + > */ +1->Emitted(7, 4) Source(7, 4) + SourceIndex(0) +--- +>>>declare function f(foo: Foo): void; +1-> +2 >^^^^^^^^^^^^^^^^^ +3 > ^ +4 > ^ +5 > ^^^ +6 > ^^ +7 > ^^^ +8 > ^^^^^^^^ +1-> + > +2 >function +3 > f +4 > ( +5 > foo +6 > +7 > Foo } foo + > */ + > function f(foo +8 > ) {} +1->Emitted(8, 1) Source(8, 1) + SourceIndex(0) +2 >Emitted(8, 18) Source(8, 10) + SourceIndex(0) +3 >Emitted(8, 19) Source(8, 11) + SourceIndex(0) +4 >Emitted(8, 20) Source(8, 12) + SourceIndex(0) +5 >Emitted(8, 23) Source(8, 15) + SourceIndex(0) +6 >Emitted(8, 25) Source(6, 13) + SourceIndex(0) +7 >Emitted(8, 28) Source(8, 15) + SourceIndex(0) +8 >Emitted(8, 36) Source(8, 19) + SourceIndex(0) +--- +>>>//# sourceMappingURL=foo.d.ts.map=================================================================== +JsFile: imports.d.ts +mapUrl: imports.d.ts.map +sourceRoot: +sources: imports.js +=================================================================== +------------------------------------------------------------------- +emittedFile:/imports.d.ts +sourceFile:imports.js +------------------------------------------------------------------- +>>>import type { Foo } from "./types"; +1 > +2 >^^^^^^^ +3 > ^^^^^ +4 > ^^ +5 > ^^^ +6 > ^^ +7 > ^^^^^^ +8 > ^^^^^^^^^ +9 > ^ +1 >/** + > * +2 >@import +3 > +4 > { +5 > Foo +6 > , Unused } +7 > from +8 > "./types" +9 > +1 >Emitted(1, 1) Source(2, 4) + SourceIndex(0) +2 >Emitted(1, 8) Source(2, 12) + SourceIndex(0) +3 >Emitted(1, 13) Source(2, 12) + SourceIndex(0) +4 >Emitted(1, 15) Source(2, 14) + SourceIndex(0) +5 >Emitted(1, 18) Source(2, 17) + SourceIndex(0) +6 >Emitted(1, 20) Source(2, 27) + SourceIndex(0) +7 >Emitted(1, 26) Source(2, 33) + SourceIndex(0) +8 >Emitted(1, 35) Source(2, 42) + SourceIndex(0) +9 >Emitted(1, 36) Source(2, 42) + SourceIndex(0) +--- +>>>/** +1 > +2 >^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > +1 >Emitted(2, 1) Source(1, 1) + SourceIndex(0) +--- +>>> * @import { Foo, Unused } from "./types" +>>> */ +1->^^^ +2 > ^-> +1->/** + > * @import { Foo, Unused } from "./types" + > */ +1->Emitted(4, 4) Source(3, 4) + SourceIndex(0) +--- +>>>/** +1-> +2 >^^^^^^^^^^^^^^^^^^^^-> +1-> + > +1->Emitted(5, 1) Source(4, 1) + SourceIndex(0) +--- +>>> * @param {Foo} foo +>>> */ +1->^^^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1->/** + > * @param {Foo} foo + > */ +1->Emitted(7, 4) Source(6, 4) + SourceIndex(0) +--- +>>>export declare function filtered(foo: Foo): Foo; +1-> +2 >^^^^^^^^^^^^^^^^^^^^^^^^ +3 > ^^^^^^^^ +4 > ^ +5 > ^^^ +6 > ^^ +7 > ^^^ +8 > ^^^^^^^ +1-> + > +2 >export function +3 > filtered +4 > ( +5 > foo +6 > +7 > Foo} foo + > */ + > export function filtered(foo +8 > ) { + > return foo; + > } +1->Emitted(8, 1) Source(7, 1) + SourceIndex(0) +2 >Emitted(8, 25) Source(7, 17) + SourceIndex(0) +3 >Emitted(8, 33) Source(7, 25) + SourceIndex(0) +4 >Emitted(8, 34) Source(7, 26) + SourceIndex(0) +5 >Emitted(8, 37) Source(7, 29) + SourceIndex(0) +6 >Emitted(8, 39) Source(5, 12) + SourceIndex(0) +7 >Emitted(8, 42) Source(7, 29) + SourceIndex(0) +8 >Emitted(8, 49) Source(9, 2) + SourceIndex(0) +--- +>>>import type * as types from "./types"; +1 > +2 >^^^^^^^ +3 > ^^^^^ +4 > ^^^^^ +5 > ^^^^^ +6 > ^^^^^^ +7 > ^^^^^^^^^ +8 > ^ +1 > + >/** + > * +2 >@import +3 > +4 > * as +5 > types +6 > from +7 > "./types" +8 > +1 >Emitted(9, 1) Source(11, 4) + SourceIndex(0) +2 >Emitted(9, 8) Source(11, 12) + SourceIndex(0) +3 >Emitted(9, 13) Source(11, 12) + SourceIndex(0) +4 >Emitted(9, 18) Source(11, 17) + SourceIndex(0) +5 >Emitted(9, 23) Source(11, 22) + SourceIndex(0) +6 >Emitted(9, 29) Source(11, 28) + SourceIndex(0) +7 >Emitted(9, 38) Source(11, 37) + SourceIndex(0) +8 >Emitted(9, 39) Source(11, 37) + SourceIndex(0) +--- +>>>/** +1 > +2 >^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > +1 >Emitted(10, 1) Source(10, 1) + SourceIndex(0) +--- +>>> * @import * as types from "./types" +>>> */ +1->^^^ +2 > ^-> +1->/** + > * @import * as types from "./types" + > */ +1->Emitted(12, 4) Source(12, 4) + SourceIndex(0) +--- +>>>/** +1-> +2 >^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1-> + > +1->Emitted(13, 1) Source(13, 1) + SourceIndex(0) +--- +>>> * @param {types.Foo} foo +>>> */ +1->^^^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1->/** + > * @param {types.Foo} foo + > */ +1->Emitted(15, 4) Source(15, 4) + SourceIndex(0) +--- +>>>export declare function namespace(foo: types.Foo): Foo; +1-> +2 >^^^^^^^^^^^^^^^^^^^^^^^^ +3 > ^^^^^^^^^ +4 > ^ +5 > ^^^ +6 > ^^ +7 > ^^^^^ +8 > ^ +9 > ^^^ +10> ^^^^^^^ +1-> + > +2 >export function +3 > namespace +4 > ( +5 > foo +6 > +7 > types +8 > . +9 > Foo} foo + > */ + > export function namespace(foo +10> ) { + > return foo; + > } +1->Emitted(16, 1) Source(16, 1) + SourceIndex(0) +2 >Emitted(16, 25) Source(16, 17) + SourceIndex(0) +3 >Emitted(16, 34) Source(16, 26) + SourceIndex(0) +4 >Emitted(16, 35) Source(16, 27) + SourceIndex(0) +5 >Emitted(16, 38) Source(16, 30) + SourceIndex(0) +6 >Emitted(16, 40) Source(14, 12) + SourceIndex(0) +7 >Emitted(16, 45) Source(14, 17) + SourceIndex(0) +8 >Emitted(16, 46) Source(14, 18) + SourceIndex(0) +9 >Emitted(16, 49) Source(16, 30) + SourceIndex(0) +10>Emitted(16, 56) Source(18, 2) + SourceIndex(0) +--- +>>>//# sourceMappingURL=imports.d.ts.map \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/conformance/importTag5.symbols b/tsc/testdata/baselines/reference/conformance/importTag5.symbols index 35242e6c8988e..9775067933615 100644 --- a/tsc/testdata/baselines/reference/conformance/importTag5.symbols +++ b/tsc/testdata/baselines/reference/conformance/importTag5.symbols @@ -7,6 +7,12 @@ export interface Foo { a: number; >a : Symbol(Foo.a, Decl(types.ts, 0, 22)) } +export interface Unused { +>Unused : Symbol(Unused, Decl(types.ts, 2, 1)) + + unused: true; +>unused : Symbol(Unused.unused, Decl(types.ts, 3, 25)) +} === /foo.js === /** @@ -20,3 +26,31 @@ function f(foo) {} >f : Symbol(f, Decl(foo.js, 0, 0)) >foo : Symbol(foo, Decl(foo.js, 7, 11)) +=== /imports.js === +/** + * @import { Foo, Unused } from "./types" + */ +/** + * @param {Foo} foo + */ +export function filtered(foo) { +>filtered : Symbol(filtered, Decl(imports.js, 0, 0)) +>foo : Symbol(foo, Decl(imports.js, 6, 25)) + + return foo; +>foo : Symbol(foo, Decl(imports.js, 6, 25)) +} +/** + * @import * as types from "./types" + */ +/** + * @param {types.Foo} foo + */ +export function namespace(foo) { +>namespace : Symbol(namespace, Decl(imports.js, 8, 1)) +>foo : Symbol(foo, Decl(imports.js, 15, 26)) + + return foo; +>foo : Symbol(foo, Decl(imports.js, 15, 26)) +} + diff --git a/tsc/testdata/baselines/reference/conformance/importTag5.types b/tsc/testdata/baselines/reference/conformance/importTag5.types index 7d9641be55328..c2ff4244e0694 100644 --- a/tsc/testdata/baselines/reference/conformance/importTag5.types +++ b/tsc/testdata/baselines/reference/conformance/importTag5.types @@ -5,6 +5,11 @@ export interface Foo { a: number; >a : number } +export interface Unused { + unused: true; +>unused : true +>true : true +} === /foo.js === /** @@ -18,3 +23,31 @@ function f(foo) {} >f : (foo: Foo) => void >foo : Foo +=== /imports.js === +/** + * @import { Foo, Unused } from "./types" + */ +/** + * @param {Foo} foo + */ +export function filtered(foo) { +>filtered : (foo: Foo) => Foo +>foo : Foo + + return foo; +>foo : Foo +} +/** + * @import * as types from "./types" + */ +/** + * @param {types.Foo} foo + */ +export function namespace(foo) { +>namespace : (foo: types.Foo) => Foo +>foo : Foo + + return foo; +>foo : Foo +} + diff --git a/tsc/testdata/tests/cases/conformance/jsdoc/importTag5.ts b/tsc/testdata/tests/cases/conformance/jsdoc/importTag5.ts index eb934954c4dba..f8f7836a2eb9c 100644 --- a/tsc/testdata/tests/cases/conformance/jsdoc/importTag5.ts +++ b/tsc/testdata/tests/cases/conformance/jsdoc/importTag5.ts @@ -3,11 +3,15 @@ // @allowJs: true // @declaration: true // @emitDeclarationOnly: true +// @declarationMap: true // @filename: /types.ts export interface Foo { a: number; } +export interface Unused { + unused: true; +} // @filename: /foo.js /** @@ -18,3 +22,23 @@ export interface Foo { * @param { Foo } foo */ function f(foo) {} + +// @filename: /imports.js +/** + * @import { Foo, Unused } from "./types" + */ +/** + * @param {Foo} foo + */ +export function filtered(foo) { + return foo; +} +/** + * @import * as types from "./types" + */ +/** + * @param {types.Foo} foo + */ +export function namespace(foo) { + return foo; +} From 2dd017fb2bbc566ba88fe3d90ff9dcec41338783 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:32:16 -0700 Subject: [PATCH 02/17] Require fresh compiler records to reach checked construction The owning packages may allocate records directly, but a fresh record carries a zero tag until newType, newNode, or its mapper tag assignment runs. If it escaped before then, the mistake surfaced only as a later cast panic. Fresh allocations must now be upcast straight into their constructor, or held in a local used only through its own fields until that upcast; mappers must be tagged immediately. --- .../testdata/unsafelayout/internal/ast/ast.go | 7 + .../unsafelayout/internal/ast/ast.go.golden | 9 ++ .../unsafelayout/internal/checker/checker.go | 35 +++++ .../internal/checker/checker.go.golden | 53 +++++++ tools/customlint/unsafelayout.go | 129 ++++++++++++++++++ 5 files changed, 233 insertions(+) diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go index dd1d08993aa26..82a96cf19a39f 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go @@ -85,3 +85,10 @@ func illegalWrites(node *Node, identifier *Identifier, declaration *ImportDeclar (node.Kind)++ _ = &(node.Kind) } + +func freshNodes() *Node { + tagged := &Identifier{} + tagged.Text = "x" + _ = newNode(KindIdentifier, tagged.AsNode()) + return (&Identifier{}).AsNode() +} diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden index a75ec90b26c09..99174158187e6 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden @@ -118,3 +118,12 @@ !!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment } + func freshNodes() *Node { + tagged := &Identifier{} + tagged.Text = "x" + _ = newNode(KindIdentifier, tagged.AsNode()) + return (&Identifier{}).AsNode() + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go index 413e77596a967..4fb4c6e485363 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -104,3 +104,38 @@ func mapperCalls(simple *SimpleTypeMapper, header *TypeMapper) { _ = castTypeMapper[SimpleTypeMapper](header, typeMapperArray) _ = (*SimpleTypeMapper)(unsafe.Pointer(header)) } + +func freshRecords(c *Checker) *Type { + tagged := &LiteralType{} + c.newType(0, 0, tagged.AsType(), typeKindLiteral) + _ = (&LiteralType{}).AsType() + escaped := &LiteralType{} + c.newType(0, 0, escaped.AsType(), typeKindLiteral) + _ = escaped.AsType() + header := &ObjectType{} + _ = &header.TypeBase + c.newType(0, 0, header.AsType(), typeKindObject) + unused := &ObjectType{} + _ = unused + value := LiteralType{} + _ = value + return new(LiteralType).AsType() +} + +func freshMappers() (*TypeMapper, *TypeMapper) { + tagged := &SimpleTypeMapper{} + tagged.kind = typeMapperSimple + untagged := &ArrayTypeMapper{} + return &tagged.TypeMapper, &untagged.TypeMapper +} + +type arena[T any] struct{} + +func (a *arena[T]) New() *T { return new(T) } + +func arenaRecords(c *Checker, literals *arena[LiteralType]) *LiteralType { + c.newType(0, 0, literals.New().AsType(), typeKindLiteral) + data := literals.New() + c.newType(0, 0, data.AsType(), typeKindLiteral) + return literals.New() +} diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index dfe8e7a7f3c81..b34837639a324 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -145,3 +145,56 @@ !!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch } + func freshRecords(c *Checker) *Type { + tagged := &LiteralType{} + c.newType(0, 0, tagged.AsType(), typeKindLiteral) + _ = (&LiteralType{}).AsType() + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + escaped := &LiteralType{} + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + c.newType(0, 0, escaped.AsType(), typeKindLiteral) + _ = escaped.AsType() + header := &ObjectType{} + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + _ = &header.TypeBase + c.newType(0, 0, header.AsType(), typeKindObject) + unused := &ObjectType{} + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + _ = unused + value := LiteralType{} + ~ +!!! unsafelayout: allocate compiler records by pointer so checked construction can tag them + _ = value + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + return new(LiteralType).AsType() + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + } + + func freshMappers() (*TypeMapper, *TypeMapper) { + tagged := &SimpleTypeMapper{} + tagged.kind = typeMapperSimple + untagged := &ArrayTypeMapper{} + ~ +!!! unsafelayout: a fresh mapper must be tagged by the statement after its allocation + return &tagged.TypeMapper, &untagged.TypeMapper + } + + type arena[T any] struct{} + + func (a *arena[T]) New() *T { return new(T) } + + func arenaRecords(c *Checker, literals *arena[LiteralType]) *LiteralType { + c.newType(0, 0, literals.New().AsType(), typeKindLiteral) + data := literals.New() + c.newType(0, 0, data.AsType(), typeKindLiteral) + return literals.New() + ~ +!!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes + } + diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index ea6ce8661eb06..69f410a600620 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -256,6 +256,10 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { } if !strings.HasSuffix(p.pass.Pkg.Path(), owner) { p.pass.Reportf(node.Pos(), "construct compiler records through their owning factories, not struct literals") + } else if unary, ok := c.Parent().Node().(*ast.UnaryExpr); ok && unary.Op == token.AND { + p.checkFreshRecord(c.Parent(), family) + } else { + p.pass.Reportf(node.Pos(), "allocate compiler records by pointer so checked construction can tag them") } for _, element := range node.Elts { if field, ok := element.(*ast.KeyValueExpr); ok { @@ -412,6 +416,11 @@ func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, tes } } } + if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "New" && len(call.Args) == 0 && !test { + if family := layoutFamily(p.pass.TypesInfo.TypeOf(call)); family != "" { + p.checkFreshRecord(c, family) + } + } if len(call.Args) != 1 { return } @@ -428,6 +437,8 @@ func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, tes } if !strings.HasSuffix(p.pass.Pkg.Path(), owner) { p.pass.Reportf(call.Pos(), "allocate compiler records through their owning factories") + } else { + p.checkFreshRecord(c, family) } } } @@ -505,3 +516,121 @@ func (p *unsafeLayoutPass) matchingUpcast(expr, kind ast.Expr, family string, ge } return p.matchingKind(p.pass.TypesInfo.TypeOf(method.X), kind) } + +// A fresh record has a zero tag until checked construction assigns one, so it +// must not escape before then. Types and nodes must be upcast straight into +// newType or newNode, or held in a local that is only used through its own +// fields until that upcast. Mappers must be tagged by the next statement. +func (p *unsafeLayoutPass) checkFreshRecord(alloc inspector.Cursor, family string) { + parent := alloc.Parent() + for { + if _, ok := parent.Node().(*ast.ParenExpr); !ok { + break + } + alloc, parent = parent, parent.Parent() + } + if p.constructorUpcast(alloc, family) { + return + } + assignment, ok := parent.Node().(*ast.AssignStmt) + if !ok || assignment.Tok != token.DEFINE || len(assignment.Lhs) != 1 || len(assignment.Rhs) != 1 { + p.pass.Reportf(alloc.Node().Pos(), "a fresh compiler record must be checked-constructed before it escapes") + return + } + name, _ := assignment.Lhs[0].(*ast.Ident) + variable := p.pass.TypesInfo.Defs[name] + if variable == nil { + p.pass.Reportf(alloc.Node().Pos(), "a fresh compiler record must be checked-constructed before it escapes") + return + } + if family == "TypeMapper" { + if next, ok := parent.NextSibling(); ok { + if tag, ok := next.Node().(*ast.AssignStmt); ok && len(tag.Lhs) == 1 { + if selector, ok := tag.Lhs[0].(*ast.SelectorExpr); ok && p.protectedTag(selector) { + if x, ok := selector.X.(*ast.Ident); ok && p.pass.TypesInfo.Uses[x] == variable { + return + } + } + } + } + p.pass.Reportf(alloc.Node().Pos(), "a fresh mapper must be tagged by the statement after its allocation") + return + } + scope := parent.Parent() + for scope.Node() != nil { + if _, ok := scope.Node().(*ast.BlockStmt); ok { + if _, ok := scope.Parent().Node().(*ast.FuncDecl); ok { + break + } + if _, ok := scope.Parent().Node().(*ast.FuncLit); ok { + break + } + } + scope = scope.Parent() + } + constructed := false + escaped := false + for use := range scope.Preorder((*ast.Ident)(nil)) { + if p.pass.TypesInfo.Uses[use.Node().(*ast.Ident)] != variable { + continue + } + selector, ok := use.Parent().Node().(*ast.SelectorExpr) + if !ok || selector.X != use.Node() { + escaped = true + continue + } + selection := p.pass.TypesInfo.Selections[selector] + switch { + case selection == nil: + escaped = true + case selection.Kind() == types.FieldVal: + if field := selection.Obj().(*types.Var); field.Embedded() { + escaped = true + } + case p.constructorUpcast(use, family): + constructed = true + default: + escaped = true + } + } + if escaped || !constructed { + p.pass.Reportf(alloc.Node().Pos(), "a fresh compiler record must be checked-constructed before it escapes") + } +} + +// Reports whether receiver is upcast directly into the record argument of +// newType or newNode. checkCall separately verifies the kind pairing. +func (p *unsafeLayoutPass) constructorUpcast(receiver inspector.Cursor, family string) bool { + method, ok := receiver.Parent().Node().(*ast.SelectorExpr) + if !ok || method.X != receiver.Node() || method.Sel.Name != "As"+family { + return false + } + upcast, ok := receiver.Parent().Parent().Node().(*ast.CallExpr) + if !ok || upcast.Fun != method { + return false + } + constructor, ok := receiver.Parent().Parent().Parent().Node().(*ast.CallExpr) + if !ok { + return false + } + var name *ast.Ident + switch fun := constructor.Fun.(type) { + case *ast.SelectorExpr: + name = fun.Sel + case *ast.Ident: + name = fun + } + function, _ := p.pass.TypesInfo.Uses[name].(*types.Func) + if function == nil || function.Pkg() == nil { + return false + } + switch family { + case "Type": + return strings.HasSuffix(function.Pkg().Path(), "/internal/checker") && function.Name() == "newType" && + len(constructor.Args) == 4 && constructor.Args[2] == upcast + case "Node": + return strings.HasSuffix(function.Pkg().Path(), "/internal/ast") && function.Name() == "newNode" && + len(constructor.Args) >= 2 && constructor.Args[1] == upcast + } + return false +} From 629588263f8a2c3a813ede268102c74e94e65085 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:37:25 -0700 Subject: [PATCH 03/17] Reject zero-value records and unsafe.Pointer aliases in layout lint A zero-value node or type record has never been tagged, and returning its address yields a compiler record that skipped checked construction. Named or aliased unsafe.Pointer types also hid reinterpreting conversions from the cast boundary. Both left holes in the invariant the analyzer exists to enforce. --- .../unsafelayout/consumer/consumer.go | 21 +++++++ .../unsafelayout/consumer/consumer.go.golden | 41 +++++++++++++ .../testdata/unsafelayout/internal/ast/ast.go | 5 ++ .../unsafelayout/internal/ast/ast.go.golden | 7 +++ .../unsafelayout/internal/checker/checker.go | 9 +++ .../internal/checker/checker.go.golden | 13 +++++ tools/customlint/unsafelayout.go | 58 ++++++++++++++++++- 7 files changed, 151 insertions(+), 3 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go b/tools/customlint/testdata/unsafelayout/consumer/consumer.go index 17e3ccd569881..7e2220ed38d61 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go @@ -43,3 +43,24 @@ func uses(node *ast.Node, declaration *ast.ImportDeclaration, kind ast.Kind, typ borrowed.Kind = ast.KindIdentifier _ = &borrowed.Kind } + +type Pointer unsafe.Pointer + +type PointerAlias = unsafe.Pointer + +func zeroValues(node *ast.Node) { + var identifier ast.Identifier + _ = &identifier + var identifiers [2]ast.Identifier + _ = &identifiers[0] + var holder struct{ identifier ast.Identifier } + _ = &holder.identifier + var pointers [2]*ast.Identifier + _ = pointers + _ = new([2]ast.Identifier) + _ = make([]ast.Identifier, 1) + _ = make([]*ast.Identifier, 1) + _ = [2]ast.Identifier{} + _ = (*ast.Identifier)(Pointer(node)) + _ = (*ast.Identifier)(PointerAlias(node)) +} diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden index 496120266c21f..b8e8a2ef213c7 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden @@ -100,3 +100,44 @@ !!! unsafelayout: taking a compiler tag's address bypasses checked tag assignment } + type Pointer unsafe.Pointer + + type PointerAlias = unsafe.Pointer + + func zeroValues(node *ast.Node) { + var identifier ast.Identifier + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = &identifier + var identifiers [2]ast.Identifier + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = &identifiers[0] + var holder struct{ identifier ast.Identifier } + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = &holder.identifier + var pointers [2]*ast.Identifier + _ = pointers + _ = new([2]ast.Identifier) + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = make([]ast.Identifier, 1) + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = make([]*ast.Identifier, 1) + _ = [2]ast.Identifier{} + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = (*ast.Identifier)(Pointer(node)) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + _ = (*ast.Identifier)(PointerAlias(node)) + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + ~ +!!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go index 82a96cf19a39f..42d0852ec87ea 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go @@ -92,3 +92,8 @@ func freshNodes() *Node { _ = newNode(KindIdentifier, tagged.AsNode()) return (&Identifier{}).AsNode() } + +func zeroValueNode() *Identifier { + var n Identifier + return &n +} diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden index 99174158187e6..af2b93a9a8aa5 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden @@ -127,3 +127,10 @@ !!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes } + func zeroValueNode() *Identifier { + var n Identifier + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + return &n + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go index 4fb4c6e485363..1c8de225bffe4 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -139,3 +139,12 @@ func arenaRecords(c *Checker, literals *arena[LiteralType]) *LiteralType { c.newType(0, 0, data.AsType(), typeKindLiteral) return literals.New() } + +func zeroValueRecords() (*LiteralType, *SimpleTypeMapper) { + var literal LiteralType + var literals [2]LiteralType + _ = literals + var mappers [2]SimpleTypeMapper + mappers[0].kind = typeMapperSimple + return &literal, &mappers[0] +} diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index b34837639a324..659b961651f53 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -198,3 +198,16 @@ !!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes } + func zeroValueRecords() (*LiteralType, *SimpleTypeMapper) { + var literal LiteralType + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + var literals [2]LiteralType + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + _ = literals + var mappers [2]SimpleTypeMapper + mappers[0].kind = typeMapperSimple + return &literal, &mappers[0] + } + diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index 69f410a600620..ec4a9fe0c6a48 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -24,7 +24,7 @@ var unsafeLayoutAnalyzer = &analysis.Analyzer{ p.collectNodeLayouts() for c := range in.Root().Preorder( (*ast.AssignStmt)(nil), (*ast.IncDecStmt)(nil), (*ast.UnaryExpr)(nil), (*ast.CompositeLit)(nil), - (*ast.CallExpr)(nil), (*ast.IndexExpr)(nil), (*ast.TypeSpec)(nil), + (*ast.CallExpr)(nil), (*ast.IndexExpr)(nil), (*ast.TypeSpec)(nil), (*ast.ValueSpec)(nil), (*ast.StarExpr)(nil), (*ast.SelectorExpr)(nil), (*ast.Ident)(nil), ) { p.check(c) @@ -130,6 +130,28 @@ func layoutFamily(typ types.Type) string { return "" } +// Reports whether typ holds a node or type record by value, directly or +// inside arrays or structs, so declaring it yields untagged zero-value records. +// Mappers are exempt: coallocated mapper storage is tagged by a matching +// kind assignment, which checkTagWrite already verifies. +func containsLayoutRecord(typ types.Type) bool { + if family := layoutFamily(typ); family != "" { + _, pointer := types.Unalias(typ).(*types.Pointer) + return !pointer && family != "TypeMapper" + } + switch underlying := types.Unalias(typ).Underlying().(type) { + case *types.Array: + return containsLayoutRecord(underlying.Elem()) + case *types.Struct: + for field := range underlying.Fields() { + if containsLayoutRecord(field.Type()) { + return true + } + } + } + return false +} + func layoutKind(typ types.Type) *types.Const { named := layoutNamedType(typ) if named == nil || named.Obj().Pkg() == nil { @@ -210,6 +232,15 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { p.pass.Reportf(node.Pos(), "defining a new compiler-record type bypasses checked construction; use a type alias") } } + case *ast.ValueSpec: + if test || len(node.Values) != 0 || p.layoutConstant(c) { + return + } + for _, name := range node.Names { + if variable := p.pass.TypesInfo.Defs[name]; variable != nil && containsLayoutRecord(variable.Type()) { + p.pass.Reportf(name.Pos(), "a zero-value compiler record bypasses checked construction; use its owning factory") + } + } case *ast.IndexExpr: if function, ok := node.X.(*ast.Ident); ok { object, _ := p.pass.TypesInfo.Uses[function].(*types.Func) @@ -242,7 +273,13 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { } } case *ast.CompositeLit: - if test || layoutFamily(p.pass.TypesInfo.TypeOf(node)) == "" || p.layoutConstant(c) { + if test || p.layoutConstant(c) { + return + } + if typ := p.pass.TypesInfo.TypeOf(node); layoutFamily(typ) == "" { + if _, slice := typ.Underlying().(*types.Slice); !slice && containsLayoutRecord(typ) { + p.pass.Reportf(node.Pos(), "a zero-value compiler record bypasses checked construction; use its owning factory") + } return } family := layoutFamily(p.pass.TypesInfo.TypeOf(node)) @@ -421,6 +458,17 @@ func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, tes p.checkFreshRecord(c, family) } } + if builtin, ok := call.Fun.(*ast.Ident); ok && (builtin.Name == "new" || builtin.Name == "make") && !test && len(call.Args) != 0 { + allocated := p.pass.TypesInfo.TypeOf(call.Args[0]) + // new(T) of a record itself is reported with the allocation rules below. + direct := builtin.Name == "new" && layoutFamily(allocated) != "" + if slice, ok := types.Unalias(allocated).(*types.Slice); ok && builtin.Name == "make" { + allocated = slice.Elem() + } + if allocated != nil && !direct && containsLayoutRecord(allocated) { + p.pass.Reportf(call.Pos(), "a zero-value compiler record bypasses checked construction; use its owning factory") + } + } if len(call.Args) != 1 { return } @@ -445,7 +493,11 @@ func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, tes source := p.pass.TypesInfo.TypeOf(call.Args[0]) target := p.pass.TypesInfo.TypeOf(call) isUnsafePointer := func(typ types.Type) bool { - basic, ok := typ.(*types.Basic) + if typ == nil { + return false + } + // Aliases and defined types share unsafe.Pointer's conversion rules. + basic, ok := typ.Underlying().(*types.Basic) return ok && basic.Kind() == types.UnsafePointer } if !isUnsafePointer(source) && !isUnsafePointer(target) { From e8ba9cf450cfe9a6b91960eb9f432f3bfe9dba28 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:59:53 -0700 Subject: [PATCH 04/17] Treat every by-value compiler record as a layout lint violation Enumerating copy and declaration forms one at a time left gaps: range assignments could retag records, named results and parameters could carry untagged zero values, and index, receive, and call expressions could copy a record off its header. Checking every value-producing expression and signature for records held by value closes the class rather than individual forms. --- .../unsafelayout/consumer/consumer.go | 22 +++++ .../unsafelayout/consumer/consumer.go.golden | 40 +++++++++ .../internal/checker/checker.go.golden | 2 + tools/customlint/unsafelayout.go | 87 +++++++++++++++---- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go b/tools/customlint/testdata/unsafelayout/consumer/consumer.go index 7e2220ed38d61..60e453cd8729a 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go @@ -64,3 +64,25 @@ func zeroValues(node *ast.Node) { _ = (*ast.Identifier)(Pointer(node)) _ = (*ast.Identifier)(PointerAlias(node)) } + +func retag(node *ast.Node, kinds []ast.Kind) { + for _, node.Kind = range kinds { + } +} + +func zeroResult() (n ast.Identifier) { + return +} + +func byValueParam(identifier ast.Identifier, identifiers [2]ast.Identifier) {} + +func copies(records map[string]ast.Identifier, channel chan ast.Identifier, get func() ast.Identifier, slice []ast.Identifier) *ast.Identifier { + record := records["key"] + _ = <-channel + _ = get() + _ = slice[0] + _ = &slice[0] + _ = slice[0].Text + _ = len(slice[:1]) + return &record +} diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden index b8e8a2ef213c7..047f0d19825f9 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden @@ -141,3 +141,43 @@ !!! unsafelayout: reinterpret compiler records only through checked casts or generated AST dispatch } + func retag(node *ast.Node, kinds []ast.Kind) { + for _, node.Kind = range kinds { + ~ +!!! unsafelayout: range assignment to a compiler tag bypasses checked tag assignment + } + } + + func zeroResult() (n ast.Identifier) { + ~ +!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer + return + } + + func byValueParam(identifier ast.Identifier, identifiers [2]ast.Identifier) {} + ~ +!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer + ~ +!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer + + func copies(records map[string]ast.Identifier, channel chan ast.Identifier, get func() ast.Identifier, slice []ast.Identifier) *ast.Identifier { + ~ +!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer + record := records["key"] + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = <-channel + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = get() + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = slice[0] + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + _ = &slice[0] + _ = slice[0].Text + _ = len(slice[:1]) + return &record + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index 659b961651f53..a6486bc29c52b 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -206,6 +206,8 @@ ~ !!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory _ = literals + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer var mappers [2]SimpleTypeMapper mappers[0].kind = typeMapperSimple return &literal, &mappers[0] diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index ec4a9fe0c6a48..8fa667612f9bb 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -22,12 +22,11 @@ var unsafeLayoutAnalyzer = &analysis.Analyzer{ in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) p := unsafeLayoutPass{pass: pass} p.collectNodeLayouts() - for c := range in.Root().Preorder( - (*ast.AssignStmt)(nil), (*ast.IncDecStmt)(nil), (*ast.UnaryExpr)(nil), (*ast.CompositeLit)(nil), - (*ast.CallExpr)(nil), (*ast.IndexExpr)(nil), (*ast.TypeSpec)(nil), (*ast.ValueSpec)(nil), - (*ast.StarExpr)(nil), (*ast.SelectorExpr)(nil), (*ast.Ident)(nil), - ) { + for c := range in.Root().Preorder() { p.check(c) + if expr, ok := c.Node().(ast.Expr); ok { + p.checkCopy(c, expr) + } } return nil, nil }, @@ -326,29 +325,79 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { } } } + case *ast.RangeStmt: + if node.Tok == token.ASSIGN && !test { + for _, target := range []ast.Expr{node.Key, node.Value} { + if selector, ok := ast.Unparen(target).(*ast.SelectorExpr); ok && p.protectedTag(selector) { + p.pass.Reportf(selector.Pos(), "range assignment to a compiler tag bypasses checked tag assignment") + } + } + } + case *ast.FuncType: if test { return } - expr := node.(ast.Expr) - value := p.pass.TypesInfo.Types[expr] - if value.IsType() || value.Type == nil { + for _, list := range []*ast.FieldList{node.Params, node.Results} { + if list == nil { + continue + } + for _, field := range list.List { + if typ := p.pass.TypesInfo.TypeOf(field.Type); typ != nil && holdsRecordByValue(typ) { + p.pass.Reportf(field.Pos(), "passing or returning a compiler record by value bypasses checked construction; use a pointer") + } + } + } + } +} + +// Reports whether typ is or contains a compiler record by value, including mappers. +func holdsRecordByValue(typ types.Type) bool { + if _, pointer := types.Unalias(typ).(*types.Pointer); pointer { + return false + } + return layoutFamily(typ) != "" || containsLayoutRecord(typ) +} + +// Every value-producing expression holding a record by value is a copy unless it +// is only addressed, has a field or method selected, or is an indexed array. +func (p *unsafeLayoutPass) checkCopy(c inspector.Cursor, expr ast.Expr) { + switch expr.(type) { + case *ast.CompositeLit, *ast.ParenExpr, *ast.FuncLit, *ast.KeyValueExpr: + return + } + if strings.HasSuffix(p.pass.Fset.Position(expr.Pos()).Filename, "_test.go") { + return + } + value, ok := p.pass.TypesInfo.Types[expr] + if !ok || value.IsType() || value.Type == nil || !holdsRecordByValue(value.Type) || p.layoutConstant(c) { + return + } + parent := c.Parent() + for { + if _, ok := parent.Node().(*ast.ParenExpr); !ok { + break + } + parent = parent.Parent() + } + switch parent := parent.Node().(type) { + case *ast.SelectorExpr: + if ast.Unparen(parent.X) == expr { + return + } + case *ast.UnaryExpr: + if parent.Op == token.AND { return } - if _, pointer := types.Unalias(value.Type).(*types.Pointer); pointer || layoutFamily(value.Type) == "" || p.layoutConstant(c) { + case *ast.IndexExpr: + if _, array := value.Type.Underlying().(*types.Array); array && ast.Unparen(parent.X) == expr { return } - switch parent := c.Parent().Node().(type) { - case *ast.SelectorExpr: - if parent.X == expr { - return - } - case *ast.UnaryExpr: - if parent.Op == token.AND { - return - } + case *ast.SliceExpr: + if ast.Unparen(parent.X) == expr { + return } - p.pass.Reportf(expr.Pos(), "copying a compiler record by value can detach its tagged header; keep a pointer") } + p.pass.Reportf(expr.Pos(), "copying a compiler record by value can detach its tagged header; keep a pointer") } func (p *unsafeLayoutPass) protectedTag(selector *ast.SelectorExpr) bool { From 95ab1fd11eba846f99c7358c26449edd79507e8f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:24:56 -0700 Subject: [PATCH 05/17] Check declared variables and receivers for by-value compiler records A record copied into a range variable or a value receiver detaches from the arena slot that owns its tagged header, so later writes are lost and reads can observe a stale kind. Those declarations never appear as typed expressions, so the copy check could not see them. Checking every defined variable also covers parameters and results, which replaces the narrower function signature rule. --- .../unsafelayout/consumer/consumer.go | 15 +++++++++ .../unsafelayout/consumer/consumer.go.golden | 33 ++++++++++++++++--- .../testdata/unsafelayout/internal/ast/ast.go | 4 +++ .../unsafelayout/internal/ast/ast.go.golden | 6 ++++ .../internal/checker/checker.go.golden | 2 ++ tools/customlint/unsafelayout.go | 31 +++++++++-------- 6 files changed, 72 insertions(+), 19 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go b/tools/customlint/testdata/unsafelayout/consumer/consumer.go index 60e453cd8729a..cbba5900e7530 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go @@ -86,3 +86,18 @@ func copies(records map[string]ast.Identifier, channel chan ast.Identifier, get _ = len(slice[:1]) return &record } + +func rangeCopies(records []ast.Identifier, byKey map[string]ast.Identifier, channel chan ast.Identifier) { + for _, record := range records { + _ = record.Text + } + for _, record := range byKey { + _ = record.Text + } + for record := range channel { + _ = record.Text + } + for i := range records { + _ = &records[i] + } +} diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden index 047f0d19825f9..225f542c21705 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden @@ -82,6 +82,8 @@ ~ !!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer copy := *node + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer ~ !!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer _ = copy @@ -150,20 +152,20 @@ func zeroResult() (n ast.Identifier) { ~ -!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer return } func byValueParam(identifier ast.Identifier, identifiers [2]ast.Identifier) {} ~ -!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer ~ -!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer func copies(records map[string]ast.Identifier, channel chan ast.Identifier, get func() ast.Identifier, slice []ast.Identifier) *ast.Identifier { - ~ -!!! unsafelayout: passing or returning a compiler record by value bypasses checked construction; use a pointer record := records["key"] + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer ~ !!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer _ = <-channel @@ -181,3 +183,24 @@ return &record } + func rangeCopies(records []ast.Identifier, byKey map[string]ast.Identifier, channel chan ast.Identifier) { + for _, record := range records { + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer + _ = record.Text + } + for _, record := range byKey { + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer + _ = record.Text + } + for record := range channel { + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer + _ = record.Text + } + for i := range records { + _ = &records[i] + } + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go index 42d0852ec87ea..d0ffd3677cc2a 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go @@ -97,3 +97,7 @@ func zeroValueNode() *Identifier { var n Identifier return &n } + +func (n Identifier) ValueReceiver() string { + return n.Text +} diff --git a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden index af2b93a9a8aa5..49b61d58be57d 100644 --- a/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/ast/ast.go.golden @@ -134,3 +134,9 @@ return &n } + func (n Identifier) ValueReceiver() string { + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer + return n.Text + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index a6486bc29c52b..113ecaab35502 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -166,6 +166,8 @@ !!! unsafelayout: a fresh compiler record must be checked-constructed before it escapes _ = unused value := LiteralType{} + ~ +!!! unsafelayout: declaring a compiler record by value copies or detaches its tagged header; use a pointer ~ !!! unsafelayout: allocate compiler records by pointer so checked construction can tag them _ = value diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index 8fa667612f9bb..7029a0e83d8d8 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -27,6 +27,9 @@ var unsafeLayoutAnalyzer = &analysis.Analyzer{ if expr, ok := c.Node().(ast.Expr); ok { p.checkCopy(c, expr) } + if name, ok := c.Node().(*ast.Ident); ok { + p.checkDeclaration(c, name) + } } return nil, nil }, @@ -333,23 +336,23 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { } } } - case *ast.FuncType: - if test { - return - } - for _, list := range []*ast.FieldList{node.Params, node.Results} { - if list == nil { - continue - } - for _, field := range list.List { - if typ := p.pass.TypesInfo.TypeOf(field.Type); typ != nil && holdsRecordByValue(typ) { - p.pass.Reportf(field.Pos(), "passing or returning a compiler record by value bypasses checked construction; use a pointer") - } - } - } } } +// Covers receivers, parameters, results, and := and range variables; plain +// var declarations are reported by the zero-value rule. +func (p *unsafeLayoutPass) checkDeclaration(c inspector.Cursor, name *ast.Ident) { + variable, ok := p.pass.TypesInfo.Defs[name].(*types.Var) + if !ok || variable.IsField() || !holdsRecordByValue(variable.Type()) || + strings.HasSuffix(p.pass.Fset.Position(name.Pos()).Filename, "_test.go") { + return + } + if _, ok := c.Parent().Node().(*ast.ValueSpec); ok { + return + } + p.pass.Reportf(name.Pos(), "declaring a compiler record by value copies or detaches its tagged header; use a pointer") +} + // Reports whether typ is or contains a compiler record by value, including mappers. func holdsRecordByValue(typ types.Type) bool { if _, pointer := types.Unalias(typ).(*types.Pointer); pointer { From 02a1674c89202ee069537750931bd0c35ecc1935 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:08:00 -0700 Subject: [PATCH 06/17] Only let by-value mapper storage escape through tagging init functions Mapper storage held by value starts with a zero tag. The lint exempted mappers from its zero-value rule so coallocated inference mappers could live inside their owning context, but that also let a standalone mapper, or the address of untagged coallocated storage, escape and later panic in Map on kind 0. The exemption now applies only to mappers stored as struct fields, and their storage may only be addressed, explicitly or via a pointer-receiver method call, as an argument to an init*TypeMapper function that tags it. --- .../unsafelayout/internal/checker/checker.go | 22 ++++++ .../internal/checker/checker.go.golden | 34 ++++++++ tools/customlint/unsafelayout.go | 78 +++++++++++++++++-- 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go index 1c8de225bffe4..2d557556c765b 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -148,3 +148,25 @@ func zeroValueRecords() (*LiteralType, *SimpleTypeMapper) { mappers[0].kind = typeMapperSimple return &literal, &mappers[0] } + +func (m *SimpleTypeMapper) Map(t *Type) *Type { return t } + +func initSimpleTypeMapper(m *SimpleTypeMapper) *TypeMapper { + m.kind = typeMapperSimple + return &m.TypeMapper +} + +type mapperOwner struct { + mapperStorage [2]SimpleTypeMapper +} + +func coallocatedMappers(owner *mapperOwner, t *Type) (*TypeMapper, *TypeMapper, *Type) { + tagged := initSimpleTypeMapper(&owner.mapperStorage[0]) + escaped := &owner.mapperStorage[1].TypeMapper + return tagged, escaped, owner.mapperStorage[1].Map(t) +} + +func zeroValueMapper() *TypeMapper { + var m SimpleTypeMapper + return &m.TypeMapper +} diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index 113ecaab35502..3da834140197d 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -211,7 +211,41 @@ ~ !!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer var mappers [2]SimpleTypeMapper + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory mappers[0].kind = typeMapperSimple return &literal, &mappers[0] + ~ +!!! unsafelayout: mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first + } + + func (m *SimpleTypeMapper) Map(t *Type) *Type { return t } + + func initSimpleTypeMapper(m *SimpleTypeMapper) *TypeMapper { + m.kind = typeMapperSimple + return &m.TypeMapper + } + + type mapperOwner struct { + mapperStorage [2]SimpleTypeMapper + } + + func coallocatedMappers(owner *mapperOwner, t *Type) (*TypeMapper, *TypeMapper, *Type) { + tagged := initSimpleTypeMapper(&owner.mapperStorage[0]) + escaped := &owner.mapperStorage[1].TypeMapper + ~ +!!! unsafelayout: mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first + return tagged, escaped, owner.mapperStorage[1].Map(t) + ~ +!!! unsafelayout: mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first + } + + func zeroValueMapper() *TypeMapper { + var m SimpleTypeMapper + ~ +!!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory + return &m.TypeMapper + ~ +!!! unsafelayout: mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first } diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index 7029a0e83d8d8..7a5b136bb2495 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -132,21 +132,25 @@ func layoutFamily(typ types.Type) string { return "" } -// Reports whether typ holds a node or type record by value, directly or -// inside arrays or structs, so declaring it yields untagged zero-value records. -// Mappers are exempt: coallocated mapper storage is tagged by a matching -// kind assignment, which checkTagWrite already verifies. +// Reports whether typ holds a compiler record by value, directly or inside +// arrays or structs, so declaring it yields untagged zero-value records. +// Mappers coallocated as struct fields are exempt; checkMapperAddress makes +// sure their storage only escapes through a tagging init function. func containsLayoutRecord(typ types.Type) bool { + return containsRecord(typ, false) +} + +func containsRecord(typ types.Type, field bool) bool { if family := layoutFamily(typ); family != "" { _, pointer := types.Unalias(typ).(*types.Pointer) - return !pointer && family != "TypeMapper" + return !pointer && (family != "TypeMapper" || !field) } switch underlying := types.Unalias(typ).Underlying().(type) { case *types.Array: - return containsLayoutRecord(underlying.Elem()) + return containsRecord(underlying.Elem(), field) case *types.Struct: - for field := range underlying.Fields() { - if containsLayoutRecord(field.Type()) { + for f := range underlying.Fields() { + if containsRecord(f.Type(), true) { return true } } @@ -154,6 +158,52 @@ func containsLayoutRecord(typ types.Type) bool { return false } +func isMapperValue(typ types.Type) bool { + if typ == nil { + return false + } + _, pointer := types.Unalias(typ).(*types.Pointer) + return !pointer && layoutFamily(typ) == "TypeMapper" +} + +// Mapper storage held by value starts with a zero tag, so its address may +// only escape as an argument to a checker init*TypeMapper function, which +// tags it. Selecting the embedded base of a mapper reached through a pointer +// is an ordinary upcast of an already-tagged record. +func (p *unsafeLayoutPass) checkMapperAddress(c inspector.Cursor, operand ast.Expr, pos token.Pos, what string) { + operand = ast.Unparen(operand) + if _, ok := operand.(*ast.CompositeLit); ok || !isMapperValue(p.pass.TypesInfo.TypeOf(operand)) { + return + } + if selector, ok := operand.(*ast.SelectorExpr); ok { + if _, pointer := types.Unalias(p.pass.TypesInfo.TypeOf(selector.X)).(*types.Pointer); pointer && + layoutFamily(p.pass.TypesInfo.TypeOf(selector.X)) == "TypeMapper" { + return + } + } + parent := c.Parent() + for { + if _, ok := parent.Node().(*ast.ParenExpr); !ok { + break + } + parent = parent.Parent() + } + if call, ok := parent.Node().(*ast.CallExpr); ok && what == "address" { + var object *types.Func + switch fun := ast.Unparen(call.Fun).(type) { + case *ast.SelectorExpr: + object, _ = p.pass.TypesInfo.Uses[fun.Sel].(*types.Func) + case *ast.Ident: + object, _ = p.pass.TypesInfo.Uses[fun].(*types.Func) + } + if object != nil && object.Pkg() != nil && strings.HasSuffix(object.Pkg().Path(), "/internal/checker") && + strings.HasPrefix(object.Name(), "init") && strings.HasSuffix(object.Name(), "TypeMapper") { + return + } + } + p.pass.Reportf(pos, "mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first") +} + func layoutKind(typ types.Type) *types.Const { named := layoutNamedType(typ) if named == nil || named.Obj().Pkg() == nil { @@ -270,6 +320,9 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { } case *ast.UnaryExpr: if node.Op == token.AND { + if !test { + p.checkMapperAddress(c, node.X, node.Pos(), "address") + } if selector, ok := ast.Unparen(node.X).(*ast.SelectorExpr); ok && p.protectedTag(selector) && !test { p.pass.Reportf(node.Pos(), "taking a compiler tag's address bypasses checked tag assignment") } @@ -310,6 +363,15 @@ func (p *unsafeLayoutPass) check(c inspector.Cursor) { case *ast.CallExpr: p.checkCall(c, node, test) case *ast.StarExpr, *ast.SelectorExpr, *ast.Ident: + if selector, ok := node.(*ast.SelectorExpr); ok && !test { + if selection := p.pass.TypesInfo.Selections[selector]; selection != nil && selection.Kind() == types.MethodVal { + if signature, ok := selection.Obj().Type().(*types.Signature); ok && signature.Recv() != nil { + if _, pointer := types.Unalias(signature.Recv().Type()).(*types.Pointer); pointer { + p.checkMapperAddress(c, selector.X, selector.Pos(), "method") + } + } + } + } var object *types.Func switch expr := node.(type) { case *ast.SelectorExpr: From 0e162ac710a46d47b8d88f878c12aab416baf4db Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:16:42 -0700 Subject: [PATCH 07/17] Check constructor kind identity and bulk record copies in layout lint A local that shadows the constructor's kind parameter passed the spelling-only check, so newType or newNode could tag a header with an unrelated value while the lint reported checked construction. Bulk copies through copy or variadic append also duplicated records by value without any diagnostic, because their arguments are slices. --- .../unsafelayout/consumer/consumer.go | 7 +++++ .../unsafelayout/consumer/consumer.go.golden | 11 +++++++ .../unsafelayout/internal/checker/checker.go | 4 +++ .../internal/checker/checker.go.golden | 6 ++++ tools/customlint/unsafelayout.go | 31 ++++++++++++++++--- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go b/tools/customlint/testdata/unsafelayout/consumer/consumer.go index cbba5900e7530..e7117fb7492a6 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go @@ -101,3 +101,10 @@ func rangeCopies(records []ast.Identifier, byKey map[string]ast.Identifier, chan _ = &records[i] } } + +func bulkCopies(dst, src []ast.Identifier, pointers []*ast.Identifier) []ast.Identifier { + copy(dst, src) + pointers = append(pointers, pointers...) + copy(pointers, pointers) + return append(dst, src...) +} diff --git a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden index 225f542c21705..b09ccabe852b6 100644 --- a/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden +++ b/tools/customlint/testdata/unsafelayout/consumer/consumer.go.golden @@ -204,3 +204,14 @@ } } + func bulkCopies(dst, src []ast.Identifier, pointers []*ast.Identifier) []ast.Identifier { + copy(dst, src) + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + pointers = append(pointers, pointers...) + copy(pointers, pointers) + return append(dst, src...) + ~ +!!! unsafelayout: copying a compiler record by value can detach its tagged header; keep a pointer + } + diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go index 2d557556c765b..2d8f8363e0fee 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -31,6 +31,10 @@ type Checker struct{} func (c *Checker) newType(flags, objectFlags int, typ *Type, kind typeKind) *Type { typ.kind = kind + if flags != 0 { + kind := typeKindObject + typ.kind = kind + } return typ } diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index 3da834140197d..e5db2d8c955da 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -31,6 +31,12 @@ func (c *Checker) newType(flags, objectFlags int, typ *Type, kind typeKind) *Type { typ.kind = kind + if flags != 0 { + kind := typeKindObject + typ.kind = kind + ~ +!!! unsafelayout: checked construction must initialize the tag from its kind parameter + } return typ } diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index 7a5b136bb2495..c044ab93f993a 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -501,13 +501,13 @@ func (p *unsafeLayoutPass) checkTagWrite(c inspector.Cursor, selector *ast.Selec switch family { case "Node": if p.ownerFunction(c, family, "newNode") && layoutFunction(c).Recv == nil { - p.checkInitializer(selector, assignment) + p.checkInitializer(c, selector, assignment) return } p.pass.Reportf(selector.Pos(), "AST kind is immutable outside checked construction; create a new node") case "Type": if p.ownerFunction(c, family, "newType") { - p.checkInitializer(selector, assignment) + p.checkInitializer(c, selector, assignment) return } p.pass.Reportf(selector.Pos(), "type kind is immutable outside checked construction") @@ -520,10 +520,20 @@ func (p *unsafeLayoutPass) checkTagWrite(c inspector.Cursor, selector *ast.Selec } } -func (p *unsafeLayoutPass) checkInitializer(selector *ast.SelectorExpr, assignment *ast.AssignStmt) { +// The tag must come from the constructor's own kind parameter, not from a +// shadowing local that happens to share its name. +func (p *unsafeLayoutPass) checkInitializer(c inspector.Cursor, selector *ast.SelectorExpr, assignment *ast.AssignStmt) { if assignment.Tok == token.ASSIGN && len(assignment.Lhs) == 1 && len(assignment.Rhs) == 1 { - if kind, ok := assignment.Rhs[0].(*ast.Ident); ok && kind.Name == "kind" { - return + if kind, ok := ast.Unparen(assignment.Rhs[0]).(*ast.Ident); ok { + if function := layoutFunction(c); function != nil && function.Type.Params != nil { + for _, field := range function.Type.Params.List { + for _, name := range field.Names { + if name.Name == "kind" && p.pass.TypesInfo.Uses[kind] == p.pass.TypesInfo.Defs[name] { + return + } + } + } + } } } p.pass.Reportf(selector.Pos(), "checked construction must initialize the tag from its kind parameter") @@ -583,6 +593,17 @@ func (p *unsafeLayoutPass) checkCall(c inspector.Cursor, call *ast.CallExpr, tes p.pass.Reportf(call.Pos(), "a zero-value compiler record bypasses checked construction; use its owning factory") } } + if builtin, ok := ast.Unparen(call.Fun).(*ast.Ident); ok && !test && len(call.Args) != 0 { + if _, ok := p.pass.TypesInfo.Uses[builtin].(*types.Builtin); ok && + (builtin.Name == "copy" || builtin.Name == "append" && call.Ellipsis.IsValid()) { + // Element-wise copies are invisible to checkCopy because the + // arguments are slices. + if slice, ok := types.Unalias(p.pass.TypesInfo.TypeOf(call.Args[0])).Underlying().(*types.Slice); ok && + holdsRecordByValue(slice.Elem()) { + p.pass.Reportf(call.Pos(), "copying a compiler record by value can detach its tagged header; keep a pointer") + } + } + } if len(call.Args) != 1 { return } From 49e43446119e707f7b190cd95a1c3aab65cdbefd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:17:58 -0700 Subject: [PATCH 08/17] Match the public mapper kind names in layout lint Mapper kind constants are now public TypeMapperKind values, so the lint's pairing between concrete mappers and their kind constants has to look them up by the new names. --- .../unsafelayout/internal/checker/checker.go | 26 +++++++++---------- .../internal/checker/checker.go.golden | 26 +++++++++---------- tools/customlint/unsafelayout.go | 2 +- tsc/internal/checker/types_test.go | 12 +++++---- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go index 2d8f8363e0fee..220982c78358d 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go @@ -74,15 +74,15 @@ func shadowedUpcast(c *Checker) { c.newType(0, 0, data.AsType(), typeKindLiteral) } -type typeMapperKind uint8 +type TypeMapperKind uint8 const ( - typeMapperSimple typeMapperKind = iota - typeMapperArray + TypeMapperKindSimple TypeMapperKind = iota + TypeMapperKindArray ) type TypeMapper struct { - kind typeMapperKind + kind TypeMapperKind } type SimpleTypeMapper struct { @@ -93,7 +93,7 @@ type ArrayTypeMapper struct { TypeMapper } -func castTypeMapper[T any](m *TypeMapper, kind typeMapperKind) *T { +func castTypeMapper[T any](m *TypeMapper, kind TypeMapperKind) *T { if m.kind != kind { panic("Invalid type mapper cast") } @@ -101,11 +101,11 @@ func castTypeMapper[T any](m *TypeMapper, kind typeMapperKind) *T { } func mapperCalls(simple *SimpleTypeMapper, header *TypeMapper) { - simple.kind = typeMapperSimple - simple.kind = typeMapperArray - header.kind = typeMapperSimple - _ = castTypeMapper[SimpleTypeMapper](header, typeMapperSimple) - _ = castTypeMapper[SimpleTypeMapper](header, typeMapperArray) + simple.kind = TypeMapperKindSimple + simple.kind = TypeMapperKindArray + header.kind = TypeMapperKindSimple + _ = castTypeMapper[SimpleTypeMapper](header, TypeMapperKindSimple) + _ = castTypeMapper[SimpleTypeMapper](header, TypeMapperKindArray) _ = (*SimpleTypeMapper)(unsafe.Pointer(header)) } @@ -128,7 +128,7 @@ func freshRecords(c *Checker) *Type { func freshMappers() (*TypeMapper, *TypeMapper) { tagged := &SimpleTypeMapper{} - tagged.kind = typeMapperSimple + tagged.kind = TypeMapperKindSimple untagged := &ArrayTypeMapper{} return &tagged.TypeMapper, &untagged.TypeMapper } @@ -149,14 +149,14 @@ func zeroValueRecords() (*LiteralType, *SimpleTypeMapper) { var literals [2]LiteralType _ = literals var mappers [2]SimpleTypeMapper - mappers[0].kind = typeMapperSimple + mappers[0].kind = TypeMapperKindSimple return &literal, &mappers[0] } func (m *SimpleTypeMapper) Map(t *Type) *Type { return t } func initSimpleTypeMapper(m *SimpleTypeMapper) *TypeMapper { - m.kind = typeMapperSimple + m.kind = TypeMapperKindSimple return &m.TypeMapper } diff --git a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden index e5db2d8c955da..423cada86003d 100644 --- a/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden +++ b/tools/customlint/testdata/unsafelayout/internal/checker/checker.go.golden @@ -106,15 +106,15 @@ !!! unsafelayout: newType must directly pair a concrete AsType() receiver with its matching constant kind } - type typeMapperKind uint8 + type TypeMapperKind uint8 const ( - typeMapperSimple typeMapperKind = iota - typeMapperArray + TypeMapperKindSimple TypeMapperKind = iota + TypeMapperKindArray ) type TypeMapper struct { - kind typeMapperKind + kind TypeMapperKind } type SimpleTypeMapper struct { @@ -125,7 +125,7 @@ TypeMapper } - func castTypeMapper[T any](m *TypeMapper, kind typeMapperKind) *T { + func castTypeMapper[T any](m *TypeMapper, kind TypeMapperKind) *T { if m.kind != kind { panic("Invalid type mapper cast") } @@ -133,15 +133,15 @@ } func mapperCalls(simple *SimpleTypeMapper, header *TypeMapper) { - simple.kind = typeMapperSimple - simple.kind = typeMapperArray + simple.kind = TypeMapperKindSimple + simple.kind = TypeMapperKindArray ~ !!! unsafelayout: mapper tag must match the concrete mapper allocation - header.kind = typeMapperSimple + header.kind = TypeMapperKindSimple ~ !!! unsafelayout: mapper tag must match the concrete mapper allocation - _ = castTypeMapper[SimpleTypeMapper](header, typeMapperSimple) - _ = castTypeMapper[SimpleTypeMapper](header, typeMapperArray) + _ = castTypeMapper[SimpleTypeMapper](header, TypeMapperKindSimple) + _ = castTypeMapper[SimpleTypeMapper](header, TypeMapperKindArray) ~ !!! unsafelayout: checked cast must directly pair its concrete target type with its matching constant kind _ = (*SimpleTypeMapper)(unsafe.Pointer(header)) @@ -186,7 +186,7 @@ func freshMappers() (*TypeMapper, *TypeMapper) { tagged := &SimpleTypeMapper{} - tagged.kind = typeMapperSimple + tagged.kind = TypeMapperKindSimple untagged := &ArrayTypeMapper{} ~ !!! unsafelayout: a fresh mapper must be tagged by the statement after its allocation @@ -219,7 +219,7 @@ var mappers [2]SimpleTypeMapper ~ !!! unsafelayout: a zero-value compiler record bypasses checked construction; use its owning factory - mappers[0].kind = typeMapperSimple + mappers[0].kind = TypeMapperKindSimple return &literal, &mappers[0] ~ !!! unsafelayout: mapper storage held by value is untagged until an init*TypeMapper function tags it; pass its address there first @@ -228,7 +228,7 @@ func (m *SimpleTypeMapper) Map(t *Type) *Type { return t } func initSimpleTypeMapper(m *SimpleTypeMapper) *TypeMapper { - m.kind = typeMapperSimple + m.kind = TypeMapperKindSimple return &m.TypeMapper } diff --git a/tools/customlint/unsafelayout.go b/tools/customlint/unsafelayout.go index c044ab93f993a..da7a994ec4faa 100644 --- a/tools/customlint/unsafelayout.go +++ b/tools/customlint/unsafelayout.go @@ -214,7 +214,7 @@ func layoutKind(typ types.Type) *types.Const { case "Type": name = "typeKind" + strings.TrimSuffix(named.Obj().Name(), "Type") case "TypeMapper": - name = "typeMapper" + strings.TrimSuffix(named.Obj().Name(), "TypeMapper") + name = "TypeMapperKind" + strings.TrimSuffix(named.Obj().Name(), "TypeMapper") default: return nil } diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 0d4eb99e5ad20..7b293aeaa70d4 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -414,15 +414,15 @@ func TestTypeMapperDispatch(t *testing.T) { {simple, TypeMapperKindSimple, target}, {array, TypeMapperKindArray, target}, {merged, TypeMapperKindMerged, target}, - {newArrayToSingleTypeMapper([]*Type{source}, target), TypeMapperKindUnknown, target}, - {newDeferredTypeMapper([]*Type{source}, []func() *Type{func() *Type { return target }}), TypeMapperKindUnknown, target}, + {newArrayToSingleTypeMapper([]*Type{source}, target), TypeMapperKindArrayToSingle, target}, + {newDeferredTypeMapper([]*Type{source}, []func() *Type{func() *Type { return target }}), TypeMapperKindDeferred, target}, {newFunctionTypeMapper(func(typ *Type) *Type { if typ == source { return target } return typ - }), TypeMapperKindUnknown, target}, - {newCompositeTypeMapper(c, newSimpleTypeMapper(other, target), simple), TypeMapperKindUnknown, target}, + }), TypeMapperKindFunction, target}, + {newCompositeTypeMapper(c, newSimpleTypeMapper(other, target), simple), TypeMapperKindComposite, target}, } { assert.Equal(t, test.mapper.Kind(), test.kind) assert.Equal(t, test.mapper.Map(source), test.result) @@ -460,7 +460,9 @@ func TestCompactTypeMapperLayout(t *testing.T) { }{ {TypeMapperKindSimple, mapper, func(m *TypeMapper) any { return castTypeMapper[SimpleTypeMapper](m, TypeMapperKindSimple) }}, {TypeMapperKindArray, newArrayTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[ArrayTypeMapper](m, TypeMapperKindArray) }}, - {TypeMapperKindArrayToSingle, newArrayToSingleTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[ArrayToSingleTypeMapper](m, TypeMapperKindArrayToSingle) }}, + {TypeMapperKindArrayToSingle, newArrayToSingleTypeMapper(nil, nil), func(m *TypeMapper) any { + return castTypeMapper[ArrayToSingleTypeMapper](m, TypeMapperKindArrayToSingle) + }}, {TypeMapperKindDeferred, newDeferredTypeMapper(nil, nil), func(m *TypeMapper) any { return castTypeMapper[DeferredTypeMapper](m, TypeMapperKindDeferred) }}, {TypeMapperKindFunction, newFunctionTypeMapper(func(t *Type) *Type { return t }), func(m *TypeMapper) any { return castTypeMapper[FunctionTypeMapper](m, TypeMapperKindFunction) }}, {TypeMapperKindMerged, newMergedTypeMapper(mapper, mapper), func(m *TypeMapper) any { return castTypeMapper[MergedTypeMapper](m, TypeMapperKindMerged) }}, From 15e0d58fe27b352a63ba9a78f45f528e575de694 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:51:47 -0700 Subject: [PATCH 09/17] Compress paged link-store references Dense checker link stores still keep a pointer for every occupied slot. Those tables cost memory and require GC scanning even though values already live in arenas. Use four-byte indexes in the page tables. Retain bounded arena blocks so references stay stable without geometrically over-allocating large stores. Keep map-backed stores unchanged: compressing those did not improve allocation usage. --- tsc/internal/core/arena.go | 39 ++++++++ tsc/internal/core/linkstore.go | 23 ++--- tsc/internal/core/linkstore_test.go | 145 ++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 tsc/internal/core/linkstore_test.go diff --git a/tsc/internal/core/arena.go b/tsc/internal/core/arena.go index bbe802464138c..ed795b7eca0f4 100644 --- a/tsc/internal/core/arena.go +++ b/tsc/internal/core/arena.go @@ -1,6 +1,7 @@ package core import ( + "math/bits" "slices" "unsafe" ) @@ -69,3 +70,41 @@ func nextArenaSize[T any](size int) int { size = min(size*2, 256, maxSize) return size } + +// IndexedArena keeps values at stable addresses and references them with uint32 +// indexes instead of Go pointers. Index zero is nil. Unlike Arena, it retains all +// blocks so that an index alone keeps its value reachable through the arena. +type IndexedArena[T any] struct { + blocks [][]T + size uint32 +} + +const indexedArenaPageShift = 8 + +func (a *IndexedArena[T]) New() (uint32, *T) { + if a.size == ^uint32(0) { + panic("IndexedArena exhausted") + } + if len(a.blocks) == 0 || len(a.blocks[len(a.blocks)-1]) == cap(a.blocks[len(a.blocks)-1]) { + size := 1 << min(len(a.blocks), indexedArenaPageShift) + a.blocks = append(a.blocks, make([]T, 0, size)) + } + block := &a.blocks[len(a.blocks)-1] + *block = (*block)[:len(*block)+1] + a.size++ + return a.size, &(*block)[len(*block)-1] +} + +func (a *IndexedArena[T]) Get(index uint32) *T { + if index == 0 { + return nil + } + // The initial blocks have sizes 1, 2, 4, ..., 128. Subsequent + // blocks have 256 entries, avoiding geometric over-allocation. + if index < 1<>indexedArenaPageShift)][index&((1<> LinkPageShift if int(pageIndex) < len(s.pages) { if page := s.pages[pageIndex]; page != nil { - return page[key&LinkPageMask] + return s.arena.Get(page[key&LinkPageMask]) } } return nil diff --git a/tsc/internal/core/linkstore_test.go b/tsc/internal/core/linkstore_test.go new file mode 100644 index 0000000000000..8b0512bf885a1 --- /dev/null +++ b/tsc/internal/core/linkstore_test.go @@ -0,0 +1,145 @@ +package core_test + +import ( + "fmt" + "runtime" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "gotest.tools/v3/assert" +) + +func TestLinkStore(t *testing.T) { + t.Parallel() + type value struct { + number int + text *string + } + var store core.LinkStore[int, value] + assert.Assert(t, !store.Has(0)) + assert.Assert(t, store.TryGet(0) == nil) + + values := make([]*value, 4096) + for i := range values { + v := store.Get(i) + assert.Equal(t, *v, value{}) + v.number = i + v.text = new("retained") + values[i] = v + } + runtime.GC() + for i, v := range values { + assert.Assert(t, store.Has(i)) + assert.Equal(t, store.Get(i), v) + assert.Equal(t, store.TryGet(i), v) + assert.Equal(t, v.number, i) + assert.Equal(t, *v.text, "retained") + } + assert.Assert(t, !store.Has(len(values))) + assert.Assert(t, store.TryGet(len(values)) == nil) +} + +func TestPagedLinkStore(t *testing.T) { + t.Parallel() + var store core.PagedLinkStore[int] + keys := []uint64{0, 255, 256, 65535, 16777215} + values := make([]*int, len(keys)) + for _, key := range keys { + assert.Assert(t, !store.Has(key)) + } + for i, key := range keys { + v := store.Get(key) + assert.Equal(t, *v, 0) + *v = i + 1 + values[i] = v + } + runtime.GC() + for i, key := range keys { + assert.Assert(t, store.Has(key)) + assert.Equal(t, store.Get(key), values[i]) + assert.Equal(t, *store.TryGet(key), i+1) + } + assert.Assert(t, !store.Has(1)) + assert.Assert(t, store.TryGet(1) == nil) +} + +func BenchmarkLinkStore(b *testing.B) { + type value struct { + typ *int + flags uint32 + } + b.ReportAllocs() + for b.Loop() { + var store core.LinkStore[int, value] + for i := range 10000 { + store.Get(i).flags = uint32(i) + } + for i := range 10000 { + if store.Get(i).flags != uint32(i) { + b.Fatal("link changed during arena growth") + } + } + + } +} + +func TestIndexedArena(t *testing.T) { + t.Parallel() + type value struct { + number int + text *string + } + var arena core.IndexedArena[value] + assert.Assert(t, arena.Get(0) == nil) + values := make([]*value, 4096) + for i := range values { + index, v := arena.New() + assert.Equal(t, index, uint32(i+1)) + assert.Equal(t, *v, value{}) + v.number = i + v.text = new("retained") + values[i] = v + } + runtime.GC() + for i, v := range values { + assert.Equal(t, arena.Get(uint32(i+1)), v) + assert.Equal(t, v.number, i) + assert.Equal(t, *v.text, "retained") + } +} + +func TestIndexedArenaReachability(t *testing.T) { + t.Parallel() + var arena core.IndexedArena[*string] + for i := range 4096 { + _, value := arena.New() + *value = new(fmt.Sprint(i)) + } + runtime.GC() + for i := range 4096 { + assert.Equal(t, **arena.Get(uint32(i + 1)), fmt.Sprint(i)) + } +} + +func BenchmarkPagedLinkStore(b *testing.B) { + for _, stride := range []uint64{1, 16, 256} { + b.Run(fmt.Sprint(stride), func(b *testing.B) { + type value struct { + typ *int + flags uint32 + } + b.ReportAllocs() + for b.Loop() { + var store core.PagedLinkStore[value] + for i := range 10000 { + store.Get(uint64(i) * stride).flags = uint32(i) + } + for i := range 10000 { + if store.Get(uint64(i)*stride).flags != uint32(i) { + b.Fatal("link changed during arena growth") + } + } + } + }) + } +} From ab731f7a82792cdeb1d39258d523120f3173c43d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:23:10 -0700 Subject: [PATCH 10/17] Check link presence without resolving arena indexes A presence query needs only the page entry, not its value. Resolving an occupied index adds arena lookup work to a boolean check. Keep presence checks on the compressed page table, and apply the repository linter fixes to the link-store tests. --- tsc/internal/core/linkstore.go | 3 ++- tsc/internal/core/linkstore_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tsc/internal/core/linkstore.go b/tsc/internal/core/linkstore.go index 93005d76ca3f7..b49b1135d9dad 100644 --- a/tsc/internal/core/linkstore.go +++ b/tsc/internal/core/linkstore.go @@ -69,7 +69,8 @@ func (s *PagedLinkStore[V]) Get(key uint64) *V { } func (s *PagedLinkStore[V]) Has(key uint64) bool { - return s.TryGet(key) != nil + pageIndex := key >> LinkPageShift + return int(pageIndex) < len(s.pages) && s.pages[pageIndex] != nil && s.pages[pageIndex][key&LinkPageMask] != 0 } func (s *PagedLinkStore[V]) TryGet(key uint64) *V { diff --git a/tsc/internal/core/linkstore_test.go b/tsc/internal/core/linkstore_test.go index 8b0512bf885a1..af785a980a7f8 100644 --- a/tsc/internal/core/linkstore_test.go +++ b/tsc/internal/core/linkstore_test.go @@ -1,8 +1,8 @@ package core_test import ( - "fmt" "runtime" + "strconv" "testing" "github.com/microsoft/TypeScript/tsc/internal/core" @@ -113,17 +113,17 @@ func TestIndexedArenaReachability(t *testing.T) { var arena core.IndexedArena[*string] for i := range 4096 { _, value := arena.New() - *value = new(fmt.Sprint(i)) + *value = new(strconv.Itoa(i)) } runtime.GC() for i := range 4096 { - assert.Equal(t, **arena.Get(uint32(i + 1)), fmt.Sprint(i)) + assert.Equal(t, **arena.Get(uint32(i + 1)), strconv.Itoa(i)) } } func BenchmarkPagedLinkStore(b *testing.B) { for _, stride := range []uint64{1, 16, 256} { - b.Run(fmt.Sprint(stride), func(b *testing.B) { + b.Run(strconv.FormatUint(stride, 10), func(b *testing.B) { type value struct { typ *int flags uint32 From 032db46f920e68e6f1be63016c1f55af4cd3ea90 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:20:49 -0700 Subject: [PATCH 11/17] Use page-table presence checks in checker link stores The checker's link-store wrappers answered Has by calling TryGet, so presence checks still resolved arena indexes even though the paged store can answer from the page entry alone. Route them to the store presence checks so checker queries get the cheaper path. --- tsc/internal/checker/links.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tsc/internal/checker/links.go b/tsc/internal/checker/links.go index c21981cae9d80..c8a3e718e7b1c 100644 --- a/tsc/internal/checker/links.go +++ b/tsc/internal/checker/links.go @@ -28,7 +28,11 @@ func (s *nodeLinkStore[V]) Get(node *ast.Node) *V { } func (s *nodeLinkStore[V]) Has(node *ast.Node) bool { - return s.TryGet(node) != nil + id := uint64(s.gen.GetNodeId(node)) + if id >= ast.BlockIdOffset && id < ast.BlockIdOffset+maxPageLinkCount { + return s.pages.Has(id - ast.BlockIdOffset) + } + return s.links.Has(id) } func (s *nodeLinkStore[V]) TryGet(node *ast.Node) *V { @@ -54,7 +58,11 @@ func (s *symbolLinkStore[V]) Get(symbol *ast.Symbol) *V { } func (s *symbolLinkStore[V]) Has(symbol *ast.Symbol) bool { - return s.TryGet(symbol) != nil + id := uint64(s.gen.GetSymbolId(symbol)) + if id >= ast.BlockIdOffset && id < ast.BlockIdOffset+maxPageLinkCount { + return s.pages.Has(id - ast.BlockIdOffset) + } + return s.links.Has(id) } func (s *symbolLinkStore[V]) TryGet(symbol *ast.Symbol) *V { From 0fa70a08437cf6e6b6ac5164ffc9dbbb31b584fd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:24:02 -0700 Subject: [PATCH 12/17] Index checker members without redundant symbol maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instantiated and inherited object types retain a name-to-symbol map beside their ordered property list. Those maps duplicate symbol pointers and string headers across millions of types, increasing both live heap and the amount of memory the collector must scan. Use the existing property list as the source of truth for immutable named members. Small types need no separate index; larger types need only pointer-free integer slots. Keep map semantics for mutable module exports, reserved members, mismatched names, and recursive member resolution. VS Code src, six samples versus cold value-symbol links: goos: linux goarch: amd64 pkg: github.com/microsoft/TypeScript/tsc/internal/checker cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz │ vscode-cold-links.txt │ vscode-member-index-owned.txt │ │ retained-B │ retained-B vs base │ ExternalProgram-20 4.874Gi ± 0% 4.503Gi ± 0% -7.61% (p=0.002 n=6) │ vscode-cold-links.txt │ vscode-member-index-owned.txt │ │ scan-B │ scan-B vs base │ ExternalProgram-20 4.508Gi ± 0% 4.062Gi ± 0% -9.89% (p=0.002 n=6) │ vscode-cold-links.txt │ vscode-member-index-owned.txt │ │ B/op │ B/op vs base │ ExternalProgram-20 6.043Gi ± 0% 5.982Gi ± 0% -1.00% (p=0.002 n=6) │ vscode-cold-links.txt │ vscode-member-index-owned.txt │ │ allocs/op │ allocs/op vs base │ ExternalProgram-20 26.23M ± 0% 26.04M ± 0% -0.71% (p=0.002 n=6) All 321 semantic diagnostics and 10284 files are identical. CLI diagnostic text and compiler counts match exactly. Instruction measurements remain noisy; this change does not claim an established CPU improvement. No GC tuning is used. --- tsc/internal/checker/checker.go | 51 +++++++++++-- tsc/internal/checker/nodebuilderimpl.go | 2 +- tsc/internal/checker/services.go | 13 +++- tsc/internal/checker/types.go | 99 ++++++++++++++++++++++++- tsc/internal/checker/types_test.go | 39 ++++++++++ 5 files changed, 191 insertions(+), 13 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index abbb9e7dbde6f..41e08cd596125 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -690,6 +690,8 @@ type Checker struct { outerTypeParameterListArena core.Arena[[]*Type] valueSymbolExtraArena core.Arena[valueSymbolExtra] structuredTypeExtraArena core.Arena[structuredTypeExtra] + memberIndexArena core.Arena[memberIndex] + memberIndexSlotsArena core.Arena[uint32] freeTypeSets *temporaryTypeSet freePropertyCounts *temporaryPropertyCounts mergedSymbols map[*ast.Symbol]*ast.Symbol @@ -10951,7 +10953,7 @@ func (c *Checker) getInstantiationExpressionType(exprType *Type, node *ast.Node) debug.Assert(t.symbol != nil, "Instantiation expression source type must have a symbol") symbol.SetDeclarations(t.symbol.Declarations()) result := c.newObjectType(ObjectFlagsAnonymous|ObjectFlagsInstantiationExpressionType, symbol) - c.setStructuredTypeMembers(result, resolved.members, callSignatures, constructSignatures, resolved.getIndexInfos()) + c.setStructuredTypeMembers(result, resolved.getMembers(), callSignatures, constructSignatures, resolved.getIndexInfos()) result.AsInstantiationExpressionType().node = node return result } @@ -16163,7 +16165,7 @@ func (c *Checker) cloneTypeAsModuleType(symbol *ast.Symbol, moduleType *Type, re links.target = symbol links.originatingImport = referenceParent resolvedModuleType := c.resolveStructuredTypeMembers(moduleType) - c.valueSymbolLinks.Get(result).resolvedType = c.newAnonymousType(result, resolvedModuleType.members, nil, nil, resolvedModuleType.getIndexInfos()) + c.valueSymbolLinks.Get(result).resolvedType = c.newAnonymousType(result, resolvedModuleType.getMembers(), nil, nil, resolvedModuleType.getIndexInfos()) return result } @@ -19363,7 +19365,7 @@ func (c *Checker) getPropertyOfTypeEx(t *Type, name string, skipObjectFunctionPr switch { case t.flags&TypeFlagsObject != 0: resolved := c.resolveStructuredTypeMembers(t) - symbol := resolved.members[name] + symbol := resolved.getMember(name) if symbol != nil { if !includeTypeOnlyMembers && t.symbol != nil && t.symbol.Flags()&ast.SymbolFlagsValueModule != 0 && c.moduleSymbolLinks.Get(t.symbol).typeOnlyExportStarMap[name] != nil { // If this is the type of a module, `resolved.members.get(name)` might have effectively skipped over @@ -19571,6 +19573,7 @@ func (c *Checker) resolveTypeReferenceMembers(t *Type) { func (c *Checker) resolveObjectTypeMembers(t *Type, source *Type, typeParameters []*Type, typeArguments []*Type) { var mapper *TypeMapper var members ast.SymbolTable + var properties []*ast.Symbol var callSignatures []*Signature var constructSignatures []*Signature var indexInfos []*IndexInfo @@ -19584,7 +19587,19 @@ func (c *Checker) resolveObjectTypeMembers(t *Type, source *Type, typeParameters } else { instantiated = true mapper = newTypeMapper(typeParameters, typeArguments) - members = c.instantiateSymbolTable(resolved.declaredMembers, mapper) + properties = c.getNamedMembers(resolved.declaredMembers, t.symbol) + for _, symbol := range properties { + if resolved.declaredMembers[symbol.Name()] != symbol { + members = c.instantiateSymbolTable(resolved.declaredMembers, mapper) + properties = nil + break + } + } + if members == nil { + for i, symbol := range properties { + properties[i] = c.instantiateSymbol(symbol, mapper) + } + } callSignatures = c.instantiateSignatures(resolved.declaredCallSignatures, mapper) constructSignatures = c.instantiateSignatures(resolved.declaredConstructSignatures, mapper) indexInfos = c.instantiateIndexInfos(resolved.declaredIndexInfos, mapper) @@ -19593,6 +19608,11 @@ func (c *Checker) resolveObjectTypeMembers(t *Type, source *Type, typeParameters if len(baseTypes) != 0 { if !instantiated { members = maps.Clone(members) + } else if members == nil { + members = make(ast.SymbolTable, len(properties)) + for _, symbol := range properties { + members[symbol.Name()] = symbol + } } thisArgument := core.LastOrNil(typeArguments) for _, baseType := range baseTypes { @@ -19615,6 +19635,11 @@ func (c *Checker) resolveObjectTypeMembers(t *Type, source *Type, typeParameters } } c.setStructuredTypeMembers(t, members, callSignatures, constructSignatures, indexInfos) + if instantiated && len(baseTypes) == 0 && members == nil { + data := t.AsStructuredType() + data.properties = properties + data.members = c.newPropertyIndex(properties, nil) + } } func findIndexInfo(indexInfos []*IndexInfo, keyType *Type) *IndexInfo { @@ -21870,7 +21895,7 @@ func (c *Checker) includeMixinType(t *Type, types []*Type, mixinFlags []bool, in func (c *Checker) getPropertyOfObjectType(t *Type, name string) *ast.Symbol { if t.flags&TypeFlagsObject != 0 { resolved := c.resolveStructuredTypeMembers(t) - symbol := resolved.members[name] + symbol := resolved.getMember(name) if symbol != nil && c.symbolIsValue(symbol) { return symbol } @@ -25741,8 +25766,18 @@ func (c *Checker) cloneTypeReference(source *Type) *Type { func (c *Checker) setStructuredTypeMembers(t *Type, members ast.SymbolTable, callSignatures []*Signature, constructSignatures []*Signature, indexInfos []*IndexInfo) { t.objectFlags |= ObjectFlagsMembersResolved data := t.AsStructuredType() - data.members = members + // Named-member filtering may recursively query this type before it finishes. + data.members = nil + if members != nil { + data.members = c.memberIndexArena.New() + data.members.fallback = members + } data.properties = c.getNamedMembers(members, t.symbol) + if data.members != nil && t.symbol != nil && t.symbol.Flags()&ast.SymbolFlagsValueModule != 0 { + data.members.fallback = members + } else { + data.members = c.newMemberIndex(members, data.properties, data.members) + } c.setStructuredTypeSignatures(data, callSignatures, constructSignatures, indexInfos) } @@ -31804,7 +31839,7 @@ func (c *Checker) isFunctionObjectType(t *Type) bool { // We do a quick check for a "bind" property before performing the more expensive subtype // check. This gives us a quicker out in the common case where an object type is not a function. resolved := c.resolveStructuredTypeMembers(t) - return len(resolved.getSignatures()) != 0 || resolved.members["bind"] != nil && c.isTypeSubtypeOf(t, c.globalFunctionType) + return len(resolved.getSignatures()) != 0 || resolved.getMember("bind") != nil && c.isTypeSubtypeOf(t, c.globalFunctionType) } func (c *Checker) getTypeWithFacts(t *Type, include TypeFacts) *Type { @@ -32300,7 +32335,7 @@ func (c *Checker) getSymbolAtLocation(node *ast.Node, ignoreErrors bool) *ast.Sy // member should more exactly be the kind of (declarationless) symbol we want. // (See #44364 and #45031 for relevant implementation PRs) if metaProp.KeywordToken == ast.KindImportKeyword && node.Text() == "meta" { - return c.getGlobalImportMetaExpressionType().AsObjectType().members["meta"] + return c.getGlobalImportMetaExpressionType().AsObjectType().getMember("meta") } // no other meta properties are valid syntax, thus no others should have symbols return nil diff --git a/tsc/internal/checker/nodebuilderimpl.go b/tsc/internal/checker/nodebuilderimpl.go index 3d84ff3bd4094..d5f9f9a203864 100644 --- a/tsc/internal/checker/nodebuilderimpl.go +++ b/tsc/internal/checker/nodebuilderimpl.go @@ -576,7 +576,7 @@ func (b *NodeBuilderImpl) getResolvedTypeWithoutAbstractConstructSignatures(t *S t.extra.objectTypeWithoutAbstractConstructSignatures = t.AsType() return t.AsType() } - typeCopy := b.ch.newAnonymousType(t.symbol, t.members, t.CallSignatures(), core.IfElse(len(constructSignatures) > 0, constructSignatures, []*Signature{}), t.getIndexInfos()) + typeCopy := b.ch.newAnonymousType(t.symbol, t.getMembers(), t.CallSignatures(), core.IfElse(len(constructSignatures) > 0, constructSignatures, []*Signature{}), t.getIndexInfos()) t.extra.objectTypeWithoutAbstractConstructSignatures = typeCopy typeCopy.AsStructuredType().ensureExtra().objectTypeWithoutAbstractConstructSignatures = typeCopy return typeCopy diff --git a/tsc/internal/checker/services.go b/tsc/internal/checker/services.go index 90f3f2f901844..4926d142bbde5 100644 --- a/tsc/internal/checker/services.go +++ b/tsc/internal/checker/services.go @@ -154,10 +154,17 @@ func (c *Checker) ForEachExportAndPropertyOfModule(moduleSymbol *ast.Symbol, cb if reducedType.flags&TypeFlagsStructuredType == 0 { return } - for name, symbol := range c.resolveStructuredTypeMembers(reducedType).members { - if c.isNamedMember(symbol, name) { - cb(symbol, name) + resolved := c.resolveStructuredTypeMembers(reducedType) + if resolved.members != nil && resolved.members.fallback != nil { + for name, symbol := range resolved.members.fallback { + if c.isNamedMember(symbol, name) { + cb(symbol, name) + } } + return + } + for _, symbol := range resolved.properties { + cb(symbol, symbol.Name()) } } diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index a3cfe28c2e735..cb883881f8165 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/evaluator" + "github.com/zeebo/xxh3" ) //go:generate npx hereby generate:checker @@ -1114,7 +1115,7 @@ func (t *ConstrainedType) AsConstrainedType() *ConstrainedType { return t } type StructuredType struct { ConstrainedType - members ast.SymbolTable + members *memberIndex properties []*ast.Symbol extra *structuredTypeExtra } @@ -1168,6 +1169,102 @@ func (t *StructuredType) Properties() []*ast.Symbol { return t.properties } +type memberIndex struct { + slots []uint32 + fallback ast.SymbolTable +} + +// Property order is semantic. Keep it unchanged and index it with pointer-free +// slots instead of retaining a second map of names and symbol pointers. +func (c *Checker) newMemberIndex(members ast.SymbolTable, properties []*ast.Symbol, index *memberIndex) *memberIndex { + needsFallback := len(members) != len(properties) + for _, symbol := range properties { + if members[symbol.Name()] != symbol { + needsFallback = true + break + } + } + if needsFallback { + if index == nil { + index = c.memberIndexArena.New() + } + index.fallback = members + return index + } + if index != nil { + index.fallback = nil + } + return c.newPropertyIndex(properties, index) +} + +func (c *Checker) newPropertyIndex(properties []*ast.Symbol, index *memberIndex) *memberIndex { + if len(properties) <= 8 { + return nil + } + if uint64(len(properties)) > uint64(^uint32(0)) { + panic("Member index exhausted") + } + if index == nil { + index = c.memberIndexArena.New() + } + size := 1 << bits.Len(uint(len(properties))*2-1) + index.slots = c.memberIndexSlotsArena.NewSlice(size) + mask := uint64(size - 1) + for i, symbol := range properties { + slot := xxh3.HashString(symbol.Name()) & mask + for index.slots[slot] != 0 { + slot = (slot + 1) & mask + } + index.slots[slot] = uint32(i + 1) + } + return index +} + +func (t *StructuredType) getMember(name string) *ast.Symbol { + if t.members == nil { + for _, symbol := range t.properties { + if symbol.Name() == name { + return symbol + } + } + return nil + } + if t.members.fallback != nil { + return t.members.fallback[name] + } + slots := t.members.slots + if len(slots) == 0 { + return nil + } + mask := uint64(len(slots) - 1) + slot := xxh3.HashString(name) & mask + for { + index := slots[slot] + if index == 0 { + return nil + } + symbol := t.properties[index-1] + if symbol.Name() == name { + return symbol + } + slot = (slot + 1) & mask + } +} + +func (t *StructuredType) getMembers() ast.SymbolTable { + if t.members != nil && t.members.fallback != nil { + return t.members.fallback + } + if len(t.properties) == 0 { + return nil + } + members := make(ast.SymbolTable, len(t.properties)) + for _, symbol := range t.properties { + members[symbol.Name()] = symbol + } + return members +} + // Except for tuple type references and reverse mapped types, all object types have an associated symbol. // Possible object type instances are listed in the following. diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 7b293aeaa70d4..7304b9791a62d 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -538,6 +538,45 @@ func TestCompactValueSymbolLinksLayout(t *testing.T) { assert.Equal(t, links.getContainingType(), types[2]) } +func TestStructuredMembersLookup(t *testing.T) { + t.Parallel() + c, _ := newCompositeTestChecker() + for _, count := range []int{0, 1, 8, 20, 300} { + members := make(ast.SymbolTable, count) + for i := range count { + name := strconv.Itoa(i) + members[name] = c.newSymbol(ast.SymbolFlagsProperty, name) + } + typ := c.newAnonymousType(nil, members, nil, nil, nil) + resolved := typ.AsStructuredType() + assert.Equal(t, len(resolved.properties), count) + for name, symbol := range members { + assert.Equal(t, resolved.getMember(name), symbol) + } + assert.Assert(t, resolved.getMember("missing") == nil) + other := c.newAnonymousType(nil, resolved.getMembers(), nil, nil, nil) + for name, symbol := range members { + assert.Equal(t, other.AsStructuredType().getMember(name), symbol) + } + members["new"] = c.newSymbol(ast.SymbolFlagsProperty, "new") + assert.Assert(t, resolved.getMember("new") == nil) + members[ast.InternalSymbolNameCall] = c.newSymbol(ast.SymbolFlagsSignature, ast.InternalSymbolNameCall) + withReserved := c.newAnonymousType(nil, members, nil, nil, nil).AsStructuredType() + assert.Equal(t, withReserved.getMember(ast.InternalSymbolNameCall), members[ast.InternalSymbolNameCall]) + for range 300 { + c.newMemberIndex(members, withReserved.properties, nil) + } + runtime.GC() + for name, symbol := range members { + assert.Equal(t, withReserved.getMember(name), symbol) + } + } + renamed := c.newSymbol(ast.SymbolFlagsProperty, "original") + member := c.newAnonymousType(nil, ast.SymbolTable{"different": renamed}, nil, nil, nil).AsStructuredType() + assert.Equal(t, member.getMember("different"), renamed) + assert.Assert(t, member.getMember("original") == nil) +} + func TestCompactStructuredTypeLayout(t *testing.T) { t.Parallel() assert.Equal(t, unsafe.Sizeof(StructuredType{}), unsafe.Sizeof(ConstrainedType{})+5*unsafe.Sizeof(uintptr(0))) From 15e8050d72f4ba1fdbc2e65874ea90c3fad6083e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:26:35 -0700 Subject: [PATCH 13/17] Validate member table pairs before indexing by name The name index and the instantiated fast path both assumed a table whose properties are found under their own names has no other keys. A symbol stored under its own name and an alias satisfies every reverse lookup, so the alias was silently dropped. Checking each (key, symbol) pair directly keeps such tables on the map path. --- tsc/internal/checker/checker.go | 13 ++++--------- tsc/internal/checker/types.go | 21 +++++++++++++-------- tsc/internal/checker/types_test.go | 10 ++++++++++ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 41e08cd596125..f3fdf4ed87ba2 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -19587,18 +19587,13 @@ func (c *Checker) resolveObjectTypeMembers(t *Type, source *Type, typeParameters } else { instantiated = true mapper = newTypeMapper(typeParameters, typeArguments) - properties = c.getNamedMembers(resolved.declaredMembers, t.symbol) - for _, symbol := range properties { - if resolved.declaredMembers[symbol.Name()] != symbol { - members = c.instantiateSymbolTable(resolved.declaredMembers, mapper) - properties = nil - break - } - } - if members == nil { + if symbolTableKeysMatchNames(resolved.declaredMembers) { + properties = c.getNamedMembers(resolved.declaredMembers, t.symbol) for i, symbol := range properties { properties[i] = c.instantiateSymbol(symbol, mapper) } + } else { + members = c.instantiateSymbolTable(resolved.declaredMembers, mapper) } callSignatures = c.instantiateSignatures(resolved.declaredCallSignatures, mapper) constructSignatures = c.instantiateSignatures(resolved.declaredConstructSignatures, mapper) diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index cb883881f8165..7fcac4815a7d4 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -1177,14 +1177,7 @@ type memberIndex struct { // Property order is semantic. Keep it unchanged and index it with pointer-free // slots instead of retaining a second map of names and symbol pointers. func (c *Checker) newMemberIndex(members ast.SymbolTable, properties []*ast.Symbol, index *memberIndex) *memberIndex { - needsFallback := len(members) != len(properties) - for _, symbol := range properties { - if members[symbol.Name()] != symbol { - needsFallback = true - break - } - } - if needsFallback { + if len(members) != len(properties) || !symbolTableKeysMatchNames(members) { if index == nil { index = c.memberIndexArena.New() } @@ -1197,6 +1190,18 @@ func (c *Checker) newMemberIndex(members ast.SymbolTable, properties []*ast.Symb return c.newPropertyIndex(properties, index) } +// Reports whether every symbol is stored under its own name. Checking the +// table's pairs, rather than looking each property up by name, also rejects a +// symbol aliased under a second key, which a name-keyed index would drop. +func symbolTableKeysMatchNames(members ast.SymbolTable) bool { + for name, symbol := range members { + if symbol.Name() != name { + return false + } + } + return true +} + func (c *Checker) newPropertyIndex(properties []*ast.Symbol, index *memberIndex) *memberIndex { if len(properties) <= 8 { return nil diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 7304b9791a62d..d6f3d6b721065 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -575,6 +575,16 @@ func TestStructuredMembersLookup(t *testing.T) { member := c.newAnonymousType(nil, ast.SymbolTable{"different": renamed}, nil, nil, nil).AsStructuredType() assert.Equal(t, member.getMember("different"), renamed) assert.Assert(t, member.getMember("original") == nil) + aliased := c.newSymbol(ast.SymbolFlagsProperty, "original") + aliasedMembers := ast.SymbolTable{"original": aliased, "different": aliased} + for i := range 10 { + name := "p" + strconv.Itoa(i) + aliasedMembers[name] = c.newSymbol(ast.SymbolFlagsProperty, name) + } + aliasedType := c.newAnonymousType(nil, aliasedMembers, nil, nil, nil).AsStructuredType() + for name, symbol := range aliasedMembers { + assert.Equal(t, aliasedType.getMember(name), symbol) + } } func TestCompactStructuredTypeLayout(t *testing.T) { From 953c7fb28ffd1b4884da8cb0953d51e2ef1124cf Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:43:04 -0700 Subject: [PATCH 14/17] Store ordinary relationship keys as exact type ID pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinary type comparisons need only two 32-bit IDs and intersection context, but retained a 128-bit hash for every cached result. Hashing and storing twice the necessary key width wastes allocations and memory. Keep exact 64-bit pairs in separate maps for the four intersection states. Generic-reference normalization still uses its original full-width hash. An explicit key tag separates both namespaces, including recursion guards, without sacrificing hash bits or changing constrained generic behavior. Six fresh-process interleaved VS Code comparisons against the parent commit, using normal GC, reduce retained heap 0.94% (roughly 33 MiB) and total allocated bytes 1.29%. GC scanning, allocation events, and instruction counts are not statistically different. Tests cover directional and identity comparisons, all intersection contexts, result replacement, exact high-bit IDs, namespace isolation, generic parameter normalization, and constrained versus unconstrained recursion keys. Production diagnostics and all semantic counts match exactly. goos: linux goarch: amd64 pkg: github.com/microsoft/TypeScript/tsc/internal/checker cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz │ vscode-relation-before-6.txt │ vscode-relation-6.txt │ │ retained-B │ retained-B vs base │ ExternalProgram-20 3.456Gi ± 0% 3.424Gi ± 0% -0.94% (p=0.002 n=6) │ vscode-relation-before-6.txt │ vscode-relation-6.txt │ │ scan-B │ scan-B vs base │ ExternalProgram-20 2.998Gi ± 0% 2.997Gi ± 0% ~ (p=0.310 n=6) │ vscode-relation-before-6.txt │ vscode-relation-6.txt │ │ B/op │ B/op vs base │ ExternalProgram-20 5.021Gi ± 0% 4.956Gi ± 0% -1.29% (p=0.002 n=6) │ vscode-relation-before-6.txt │ vscode-relation-6.txt │ │ allocs/op │ allocs/op vs base │ ExternalProgram-20 31.08M ± 0% 31.07M ± 0% ~ (p=0.240 n=6) │ relation-before-instructions.txt │ relation-instructions.txt │ │ instructions/op │ instructions/op vs base │ ExternalInstructions-20 269.3G ± 2% 270.0G ± 3% ~ (p=0.937 n=6) --- tsc/internal/checker/checker.go | 13 ++++-- tsc/internal/checker/relater.go | 41 +++++++++++++---- tsc/internal/checker/types_test.go | 73 ++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index f3fdf4ed87ba2..d169e8e320d2e 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -18070,7 +18070,7 @@ func getConditionalTypeKey(typeArguments []*Type, alias *TypeAlias, forConstrain return b.hash() } -func getRelationKey(source *Type, target *Type, intersectionState IntersectionState, isIdentity bool, ignoreConstraints bool) (CacheHashKey, bool) { +func getRelationKey(source *Type, target *Type, intersectionState IntersectionState, isIdentity bool, ignoreConstraints bool) (relationKey, bool) { if isIdentity && source.id > target.id { source, target = target, source } @@ -18080,12 +18080,15 @@ func getRelationKey(source *Type, target *Type, intersectionState IntersectionSt b.writeByte('g') constrained = b.writeGenericTypeReferences(source, target, ignoreConstraints) } else { - b.writeByte('s') - b.writeType(source) - b.writeType(target) + // Ordinary relationships need only the two exact IDs. Intersection + // states select separate maps, leaving generic hashes unmodified. + return relationKey{value: CacheHashKey{ + Lo: uint64(source.id)<<32 | uint64(target.id), + Hi: uint64(intersectionState), + }}, false } b.writeUint32(uint32(intersectionState)) - return b.hash(), constrained + return relationKey{value: b.hash(), generic: true}, constrained } func getNodeListKey(nodes []*ast.Node) CacheHashKey { diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index d3b6c86014498..a47dfbcb8a971 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -96,22 +96,43 @@ func asRecursionId[T *ast.Node | *ast.Symbol | *Type](value T) RecursionId { } type Relation struct { - results map[CacheHashKey]RelationComparisonResult + genericResults map[CacheHashKey]RelationComparisonResult + simpleResults [4]map[uint64]RelationComparisonResult } -func (r *Relation) get(key CacheHashKey) RelationComparisonResult { - return r.results[key] +type relationKey struct { + value CacheHashKey + generic bool } -func (r *Relation) set(key CacheHashKey, result RelationComparisonResult) { - if r.results == nil { - r.results = make(map[CacheHashKey]RelationComparisonResult) +func (r *Relation) get(key relationKey) RelationComparisonResult { + if key.generic { + return r.genericResults[key.value] + } + return r.simpleResults[key.value.Hi][key.value.Lo] +} + +func (r *Relation) set(key relationKey, result RelationComparisonResult) { + if key.generic { + if r.genericResults == nil { + r.genericResults = make(map[CacheHashKey]RelationComparisonResult) + } + r.genericResults[key.value] = result + } else { + results := &r.simpleResults[key.value.Hi] + if *results == nil { + *results = make(map[uint64]RelationComparisonResult) + } + (*results)[key.value.Lo] = result } - r.results[key] = result } func (r *Relation) size() int { - return len(r.results) + size := len(r.genericResults) + for _, results := range r.simpleResults { + size += len(results) + } + return size } func (c *Checker) isTypeIdenticalTo(source *Type, target *Type) bool { @@ -2584,8 +2605,8 @@ type Relater struct { errorNode *ast.Node errorChain *ErrorChain relatedInfo []*ast.Diagnostic - maybeKeys []CacheHashKey - maybeKeysSet collections.Set[CacheHashKey] + maybeKeys []relationKey + maybeKeysSet collections.Set[relationKey] sourceStack []*Type targetStack []*Type maybeCount int diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index d6f3d6b721065..cf8d004b6275d 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -153,6 +153,79 @@ func TestKeyBuilder(t *testing.T) { } } +func TestRelationKeys(t *testing.T) { + t.Parallel() + source := &Type{id: 1} + target := &Type{id: ^TypeId(0)} + var relation Relation + assert.Equal(t, relation.size(), 0) + for state := IntersectionStateNone; state <= IntersectionStateSource|IntersectionStateTarget; state++ { + key, constrained := getRelationKey(source, target, state, false, false) + assert.Assert(t, !constrained) + assert.Equal(t, relation.get(key), RelationComparisonResultNone) + relation.set(key, RelationComparisonResultSucceeded) + reverse, _ := getRelationKey(target, source, state, false, false) + assert.Assert(t, key != reverse) + assert.Equal(t, relation.get(reverse), RelationComparisonResultNone) + identity, _ := getRelationKey(target, source, state, true, false) + assert.Equal(t, key, identity) + relation.set(key, RelationComparisonResultFailed|RelationComparisonResultReportsUnreliable) + assert.Equal(t, relation.get(key), RelationComparisonResultFailed|RelationComparisonResultReportsUnreliable) + } + assert.Equal(t, relation.size(), 4) + for state := IntersectionStateNone; state <= IntersectionStateSource|IntersectionStateTarget; state++ { + key, _ := getRelationKey(source, target, state, false, false) + assert.Equal(t, relation.get(key), RelationComparisonResultFailed|RelationComparisonResultReportsUnreliable) + } +} + +func TestRelationKeyNamespaces(t *testing.T) { + t.Parallel() + simple, _ := getRelationKey(&Type{id: 1}, &Type{id: 2}, IntersectionStateNone, false, false) + generic := relationKey{value: simple.value, generic: true} + var relation Relation + relation.set(simple, RelationComparisonResultSucceeded) + assert.Equal(t, relation.get(generic), RelationComparisonResultNone) + relation.set(generic, RelationComparisonResultFailed) + assert.Equal(t, relation.get(simple), RelationComparisonResultSucceeded) + assert.Equal(t, relation.get(generic), RelationComparisonResultFailed) + assert.Equal(t, relation.size(), 2) +} + +func TestGenericRelationKeys(t *testing.T) { + t.Parallel() + c, types := newCompositeTestChecker() + c.noConstraintType = c.newIntrinsicType(TypeFlagsNever, "no constraint") + root := types[0] + newParameter := func(constraint *Type) *Type { + parameter := &TypeParameter{constraint: constraint} + return c.newType(TypeFlagsTypeParameter, ObjectFlagsNone, parameter.AsType(), typeKindTypeParameter) + } + newReference := func(argument *Type) *Type { + reference := &TypeReference{resolvedTypeArguments: []*Type{argument}} + reference.target = root + return c.newType(TypeFlagsObject, ObjectFlagsReference, reference.AsType(), typeKindTypeReference) + } + left := newReference(newParameter(c.noConstraintType)) + right := newReference(newParameter(c.noConstraintType)) + equivalentLeft := newReference(newParameter(c.noConstraintType)) + equivalentRight := newReference(newParameter(c.noConstraintType)) + key, constrained := getRelationKey(left, right, IntersectionStateNone, false, false) + assert.Assert(t, !constrained) + equivalent, _ := getRelationKey(equivalentLeft, equivalentRight, IntersectionStateNone, false, false) + assert.Equal(t, key, equivalent) + var relation Relation + relation.set(key, RelationComparisonResultSucceeded) + assert.Equal(t, relation.get(equivalent), RelationComparisonResultSucceeded) + constrainedLeft := newReference(newParameter(types[1])) + constrainedKey, constrained := getRelationKey(constrainedLeft, right, IntersectionStateNone, false, false) + assert.Assert(t, constrained) + assert.Assert(t, constrainedKey != key) + broadest, constrained := getRelationKey(constrainedLeft, right, IntersectionStateNone, false, true) + assert.Assert(t, !constrained) + assert.Equal(t, broadest, key) +} + func TestTypeListKeyAllocations(t *testing.T) { //nolint:paralleltest if race.Enabled { t.Skip("race instrumentation changes hashing allocations") From 7e29b1eb63153f209fa49522687e10be378a4ffe Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:12:19 -0700 Subject: [PATCH 15/17] Share complete union and intersection property cache entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete non-augmented properties are valid for augmented lookup, but copying each result into both caches retains duplicate name tables. MUI keeps 2,734,919 identical entries this way, nearly doubling its union/intersection cache storage without adding semantic information. Preserve independent augmented results and partial-property behavior while sharing complete entries. A failed lookup needs no empty table. The MUI census falls from 230242 tables and 5628201 entries to 116635 tables and 2893282 entries; all distinct cached identities remain. Six fresh-process interleaved single-checker comparisons against the parent commit, with ordinary GC, save 135.63 MiB of retained MUI heap (-8.15%) and 132.00 MiB of GC scanning (-9.22%). Total allocated bytes fall 4.45% and allocation events fall 1.46%. VS Code's corresponding reductions are small: retained -0.15%, scan -0.17%, allocated -0.16%. Instruction differences in these single-checker comparisons are inconclusive; elapsed time is noisy on the shared machine. Six default-threaded production CLI pairs confirm MUI's reported heap falls 9.29% and instructions fall 2.60%. VS Code and Webpack have small heap reductions and inconclusive instruction differences. These are incremental savings on top of the earlier branch optimizations. CLI-reported heap is not the forced-GC retained-heap measure above. MUI uses the CI-pinned source and restored scenario dependencies at 6780195595c12252a130aa5ae5c5bd461229c6b7. VS Code uses 8bf178e1e1c2be3723f14683f7b1e5fdb3f780e4. Webpack uses an isolated 2289141381f2c1c965cb65118fa0ffe2e9e19834 with restored dependencies. Production diagnostics and all semantic counts match exactly. Tests cover both query orders, both composite kinds, cache growth and GC reachability, missing entries, read/write partial flags, differing mode-specific identities, and real function-property augmentation. The storage regressions fail before this change. Single-checker retained heap and allocation measurements: │ mui-shared-union-before.txt │ mui-shared-union-shared.txt │ │ retained-B │ retained-B vs base │ ExternalMemory-20 1.624Gi ± 0% 1.492Gi ± 0% -8.15% (p=0.002 n=6) │ mui-shared-union-before.txt │ mui-shared-union-shared.txt │ │ scan-B │ scan-B vs base │ ExternalMemory-20 1.398Gi ± 0% 1.270Gi ± 0% -9.22% (p=0.002 n=6) │ mui-shared-union-before.txt │ mui-shared-union-shared.txt │ │ B/op │ B/op vs base │ ExternalMemory-20 5.364Gi ± 0% 5.125Gi ± 0% -4.45% (p=0.002 n=6) │ mui-shared-union-before.txt │ mui-shared-union-shared.txt │ │ allocs/op │ allocs/op vs base │ ExternalMemory-20 25.17M ± 0% 24.80M ± 1% -1.46% (p=0.002 n=6) │ vscode-shared-union-before.txt │ vscode-shared-union-shared.txt │ │ retained-B │ retained-B vs base │ ExternalMemory-20 2.619Gi ± 0% 2.615Gi ± 0% -0.15% (p=0.002 n=6) │ vscode-shared-union-before.txt │ vscode-shared-union-shared.txt │ │ scan-B │ scan-B vs base │ ExternalMemory-20 2.291Gi ± 0% 2.287Gi ± 0% -0.17% (p=0.002 n=6) │ vscode-shared-union-before.txt │ vscode-shared-union-shared.txt │ │ B/op │ B/op vs base │ ExternalMemory-20 3.754Gi ± 0% 3.748Gi ± 0% -0.16% (p=0.002 n=6) │ vscode-shared-union-before.txt │ vscode-shared-union-shared.txt │ │ allocs/op │ allocs/op vs base │ ExternalMemory-20 23.61M ± 0% 23.59M ± 0% -0.08% (p=0.002 n=6) Single-checker instructions: │ mui-shared-union-before.instructions │ mui-shared-union-shared.instructions │ │ instructions/op │ instructions/op vs base │ ExternalInstructions-20 218.9G ± 3% 217.3G ± 2% ~ (p=0.310 n=6) │ vscode-shared-union-before.instructions │ vscode-shared-union-shared.instructions │ │ instructions/op │ instructions/op vs base │ ExternalInstructions-20 188.8G ± 3% 192.1G ± 2% ~ (p=0.394 n=6) Default-threaded production CLI: │ shared-union-cli-before.txt │ shared-union-cli-after.txt │ │ instructions/op │ instructions/op vs base │ mui-20 283.3G ± 1% 275.9G ± 2% -2.60% (p=0.015 n=6) vscode-20 308.2G ± 7% 293.0G ± 6% ~ (p=0.818 n=6) webpack-20 51.96G ± 5% 51.82G ± 4% ~ (p=1.000 n=6) geomean 165.6G 161.2G -2.62% │ shared-union-cli-before.txt │ shared-union-cli-after.txt │ │ heap-B/op │ heap-B/op vs base │ mui-20 2.059Gi ± 0% 1.868Gi ± 0% -9.29% (p=0.002 n=6) vscode-20 2.835Gi ± 0% 2.829Gi ± 0% -0.21% (p=0.004 n=6) webpack-20 666.9Mi ± 0% 663.0Mi ± 0% -0.57% (p=0.002 n=6) geomean 1.561Gi 1.507Gi -3.45% │ shared-union-cli-before.txt │ shared-union-cli-after.txt │ │ allocs/op │ allocs/op vs base │ mui-20 29.84M ± 0% 29.55M ± 0% -0.97% (p=0.002 n=6) vscode-20 36.67M ± 2% 36.64M ± 0% ~ (p=0.132 n=6) webpack-20 6.235M ± 2% 6.208M ± 0% -0.43% (p=0.002 n=6) geomean 18.97M 18.87M -0.49% │ shared-union-cli-before.txt │ shared-union-cli-after.txt │ │ check-sec/op │ check-sec/op vs base │ mui-20 8.986 ± 6% 8.559 ± 4% -4.75% (p=0.026 n=6) vscode-20 8.345 ± 4% 8.900 ± 10% ~ (p=0.132 n=6) webpack-20 1.851 ± 8% 1.818 ± 8% ~ (p=0.310 n=6) geomean 5.178 5.173 -0.08% --- tsc/internal/checker/checker.go | 35 ++++++++----- tsc/internal/checker/checker_test.go | 46 ++++++++++++++++ tsc/internal/checker/types.go | 2 +- tsc/internal/checker/types_test.go | 78 ++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 13 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index d169e8e320d2e..ba2385343023e 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -21916,24 +21916,35 @@ func (c *Checker) getPropertyOfUnionOrIntersectionType(t *Type, name string, ski // these partial properties when identifying discriminant properties, but otherwise they are filtered out // and do not appear to be present in the union type. func (c *Checker) getUnionOrIntersectionProperty(t *Type, name string, skipObjectFunctionPropertyAugment bool) *ast.Symbol { - var cache ast.SymbolTable + data := t.AsUnionOrIntersectionType() if skipObjectFunctionPropertyAugment { - cache = ast.GetSymbolTable(&t.AsUnionOrIntersectionType().propertyCacheWithoutFunctionPropertyAugment) + if prop := data.propertyCacheWithoutFunctionPropertyAugment[name]; prop != nil { + return prop + } } else { - cache = ast.GetSymbolTable(&t.AsUnionOrIntersectionType().propertyCache) - } - if prop := cache[name]; prop != nil { - return prop + if data.propertyCache != nil { + if prop := data.propertyCache[name]; prop != nil { + return prop + } + } + // Complete non-augmented properties are also valid for augmented + // lookup. Share the entry instead of retaining it in both tables. + if prop := data.propertyCacheWithoutFunctionPropertyAugment[name]; prop != nil && prop.CheckFlags()&ast.CheckFlagsPartial == 0 { + return prop + } } prop := c.createUnionOrIntersectionProperty(t, name, skipObjectFunctionPropertyAugment) if prop != nil { - cache[name] = prop - // Propagate an entry from the non-augmented cache to the augmented cache unless the property is partial. - if skipObjectFunctionPropertyAugment && prop.CheckFlags()&ast.CheckFlagsPartial == 0 { - augmentedCache := ast.GetSymbolTable(&t.AsUnionOrIntersectionType().propertyCache) - if augmentedCache[name] == nil { - augmentedCache[name] = prop + if skipObjectFunctionPropertyAugment { + ast.GetSymbolTable(&data.propertyCacheWithoutFunctionPropertyAugment)[name] = prop + if prop.CheckFlags()&ast.CheckFlagsPartial == 0 && data.propertyCache[name] == prop { + delete(data.propertyCache, name) + if len(data.propertyCache) == 0 { + data.propertyCache = nil + } } + } else { + ast.GetSymbolTable(&data.propertyCache)[name] = prop } } return prop diff --git a/tsc/internal/checker/checker_test.go b/tsc/internal/checker/checker_test.go index 31cea19751525..fdb95f6a70a08 100644 --- a/tsc/internal/checker/checker_test.go +++ b/tsc/internal/checker/checker_test.go @@ -3,6 +3,7 @@ package checker_test import ( "fmt" "path/filepath" + "strconv" "strings" "testing" @@ -117,6 +118,51 @@ export type E = D;`, assert.Equal(t, defaultClause, defaultReference) } +func TestUnionPropertyFunctionAugmentation(t *testing.T) { + t.Parallel() + for _, lookupFirst := range []bool{false, true} { + t.Run(strconv.FormatBool(lookupFirst), func(t *testing.T) { + t.Parallel() + fs := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/main.ts": `type Callable = { (): string; shared: string }; +type Other = { call: number; shared: number }; +declare const instance: Callable | Other; +instance;`, + "/function.d.ts": `interface Function { + call(thisArg: any, ...args: any[]): any; +} +interface CallableFunction extends Function {}`, + "/tsconfig.json": `{"compilerOptions":{"strict":true,"noLib":true},"files":["main.ts","function.d.ts"]}`, + }, tspath.CaseInsensitive)) + host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) + parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, fs, nil) + assert.Equal(t, len(errors), 0) + program := compiler.NewProgram(compiler.ProgramOptions{Config: parsed, Host: host}) + program.BindSourceFiles() + c, done := program.GetTypeChecker(t.Context()) + defer done() + file := program.GetSourceFile("/main.ts") + typ := c.GetTypeAtLocation(file.Statements.Nodes[3].Expression()) + var call *ast.Symbol + if lookupFirst { + call = c.GetPropertyOfType(typ, "call") + assert.Assert(t, call != nil) + } + properties := c.GetPropertiesOfType(typ) + assert.Equal(t, len(properties), 1) + assert.Equal(t, properties[0].Name(), "shared") + if call == nil { + call = c.GetPropertyOfType(typ, "call") + assert.Assert(t, call != nil) + } + assert.Equal(t, c.GetPropertyOfType(typ, "call"), call) + assert.Equal(t, c.GetPropertyOfType(typ, "shared"), properties[0]) + assert.Equal(t, len(c.GetPropertiesOfType(typ)), 1) + assert.Equal(t, len(c.GetDiagnostics(t.Context(), file)), 0) + }) + } +} + func BenchmarkNewChecker(b *testing.B) { fs := bundled.WrapFS(osvfs.FS()) rootPath := tspath.RootedDirectoryPathFromAbsolute(filepath.Join(repo.TestDataPath(), "fixtures/compiler")) diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 7fcac4815a7d4..0da6652adeb40 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -1464,7 +1464,7 @@ type EvolvingArrayType struct { type UnionOrIntersectionType struct { StructuredType types []*Type - propertyCache ast.SymbolTable + propertyCache ast.SymbolTable // Augmented entries not shared with the non-augmented cache. propertyCacheWithoutFunctionPropertyAugment ast.SymbolTable resolvedProperties []*ast.Symbol } diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index cf8d004b6275d..dc422eed2b8cf 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -660,6 +660,84 @@ func TestStructuredMembersLookup(t *testing.T) { } } +func TestUnionIntersectionPropertyCacheSharing(t *testing.T) { + t.Parallel() + for _, intersection := range []bool{false, true} { + for _, augmentedFirst := range []bool{false, true} { + t.Run(strconv.FormatBool(intersection)+"/"+strconv.FormatBool(augmentedFirst), func(t *testing.T) { + t.Parallel() + c, _ := newCompositeTestChecker() + members := make(ast.SymbolTable) + for i := range 300 { + name := strconv.Itoa(i) + members[name] = c.newSymbol(ast.SymbolFlagsProperty, name) + } + members[""] = c.newSymbol(ast.SymbolFlagsProperty, "") + object := c.newAnonymousType(nil, members, nil, nil, nil) + var typ *Type + if intersection { + typ = c.newIntersectionType(ObjectFlagsNone, []*Type{object, object}) + } else { + typ = c.newUnionType(ObjectFlagsNone, []*Type{object, object}) + } + data := typ.AsUnionOrIntersectionType() + for name, symbol := range members { + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, !augmentedFirst), symbol) + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, augmentedFirst), symbol) + } + assert.Equal(t, len(data.propertyCacheWithoutFunctionPropertyAugment), len(members)) + assert.Assert(t, data.propertyCache == nil) + runtime.GC() + for name, symbol := range members { + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, true), symbol) + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, false), symbol) + } + runtime.KeepAlive(c) + }) + } + } +} + +func TestUnionIntersectionPropertyCacheModes(t *testing.T) { + t.Parallel() + for _, intersection := range []bool{false, true} { + t.Run(strconv.FormatBool(intersection), func(t *testing.T) { + t.Parallel() + c, _ := newCompositeTestChecker() + var typ *Type + if intersection { + typ = c.newIntersectionType(ObjectFlagsNone, nil) + } else { + typ = c.newUnionType(ObjectFlagsNone, nil) + } + data := typ.AsUnionOrIntersectionType() + assert.Assert(t, c.getUnionOrIntersectionProperty(typ, "missing", true) == nil) + assert.Assert(t, c.getUnionOrIntersectionProperty(typ, "missing", false) == nil) + assert.Assert(t, data.propertyCache == nil) + assert.Assert(t, data.propertyCacheWithoutFunctionPropertyAugment == nil) + data.propertyCacheWithoutFunctionPropertyAugment = make(ast.SymbolTable) + data.propertyCache = make(ast.SymbolTable) + for _, flags := range []ast.CheckFlags{ast.CheckFlagsReadPartial, ast.CheckFlagsWritePartial} { + name := strconv.Itoa(int(flags)) + partial := c.newSymbolEx(ast.SymbolFlagsProperty, name, flags) + complete := c.newSymbol(ast.SymbolFlagsProperty, name) + data.propertyCacheWithoutFunctionPropertyAugment[name] = partial + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, true), partial) + assert.Assert(t, c.getUnionOrIntersectionProperty(typ, name, false) == nil) + assert.Equal(t, c.getPropertyOfUnionOrIntersectionType(typ, name, true) == nil, flags&ast.CheckFlagsReadPartial != 0) + data.propertyCache[name] = complete + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, name, false), complete) + } + nonAugmented := c.newSymbol(ast.SymbolFlagsProperty, "different") + augmented := c.newSymbol(ast.SymbolFlagsProperty, "different") + data.propertyCacheWithoutFunctionPropertyAugment["different"] = nonAugmented + data.propertyCache["different"] = augmented + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, "different", true), nonAugmented) + assert.Equal(t, c.getUnionOrIntersectionProperty(typ, "different", false), augmented) + }) + } +} + func TestCompactStructuredTypeLayout(t *testing.T) { t.Parallel() assert.Equal(t, unsafe.Sizeof(StructuredType{}), unsafe.Sizeof(ConstrainedType{})+5*unsafe.Sizeof(uintptr(0))) From 24739493b88b8fb2f9cc8872ca4e72905c0866fb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:39:55 -0700 Subject: [PATCH 16/17] Check never-reducing intersection properties without creating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getReducedType runs somePropertyReducesToNever on intersections. It used to create and cache a synthetic property for every property name shared by more than one constituent, only to inspect its flags. With large JSX prop intersections, as in MUI, most of those properties were never looked up again, yet they stayed in the intersection's property cache with their symbols and links. Collect the constituent properties without building the combined symbol, and only create the real property in the rare merged instantiation case. mui-docs: │ before │ after │ │ memory-used-B │ memory-used-B vs base │ MUIDocs 1.857Gi ± 0% 1.717Gi ± 0% -7.55% (p=0.002 n=6) │ before │ after │ │ allocs │ allocs vs base │ MUIDocs 27.29M ± 0% 25.19M ± 0% -7.72% (p=0.002 n=6) │ before │ after │ │ allocated-B │ allocated-B vs base │ MUIDocs 4.730Gi ± 0% 4.556Gi ± 0% -3.68% (p=0.002 n=6) │ before │ after │ │ symbols │ symbols vs base │ MUIDocs 4.332M ± 0% 3.663M ± 0% -15.43% (p=0.002 n=6) --- tsc/internal/checker/checker.go | 120 +++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 10 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index ba2385343023e..02ae5b68e1485 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -21950,15 +21950,34 @@ func (c *Checker) getUnionOrIntersectionProperty(t *Type, name string, skipObjec return prop } -func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name string, skipObjectFunctionPropertyAugment bool) *ast.Symbol { +// unionOrIntersectionPropertySources describes the constituent properties from +// which a union or intersection property would be synthesized. +type unionOrIntersectionPropertySources struct { + singleProp *ast.Symbol + propSet collections.OrderedSet[*ast.Symbol] + indexTypes []*Type + propFlags ast.SymbolFlags + optionalFlag ast.SymbolFlags + checkFlags ast.CheckFlags + syntheticFlag ast.CheckFlags + mergedInstantiations bool +} + +// isSynthetic reports whether the sources require a new synthetic symbol rather +// than reusing (or cloning) singleProp. +func (s *unionOrIntersectionPropertySources) isSynthetic() bool { + return s.propSet.Size() != 0 || s.checkFlags&ast.CheckFlagsReadPartial != 0 || len(s.indexTypes) != 0 +} + +func (c *Checker) getUnionOrIntersectionPropertySources(containingType *Type, name string, skipObjectFunctionPropertyAugment bool, sources *unionOrIntersectionPropertySources) bool { propFlags := ast.SymbolFlagsNone - var singleProp *ast.Symbol - var propSet collections.OrderedSet[*ast.Symbol] - var indexTypes []*Type isUnion := containingType.flags&TypeFlagsUnion != 0 // Flags we want to propagate to the result if they exist in all source symbols var checkFlags ast.CheckFlags var optionalFlag ast.SymbolFlags + var singleProp *ast.Symbol + propSet := &sources.propSet + var indexTypes []*Type if !isUnion { checkFlags = ast.CheckFlagsReadonly optionalFlag = ast.SymbolFlagsOptional @@ -22060,17 +22079,17 @@ func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name s } if singleProp == nil { // No property was found - return nil + return false } if isUnion && (propSet.Size() != 0 || checkFlags&ast.CheckFlagsPartial != 0) && checkFlags&(ast.CheckFlagsContainsPrivate|ast.CheckFlagsContainsProtected|ast.CheckFlagsContainsWritePrivate|ast.CheckFlagsContainsWriteProtected) != 0 && - !(propSet.Size() != 0 && c.hasCommonDeclaration(&propSet)) { + !(propSet.Size() != 0 && c.hasCommonDeclaration(propSet)) { // A property in a union has a private or protected declaration in one constituent, but is missing // or has a different declaration in another constituent. If the private or protected declaration is // for reading, we don't create a property. if checkFlags&(ast.CheckFlagsContainsPrivate|ast.CheckFlagsContainsProtected) != 0 { - return nil + return false } // Otherwise, if the private or protected declaration is for writing, reduce accessibility to that of // the most restricted constituent. @@ -22080,8 +22099,31 @@ func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name s checkFlags &^= ast.CheckFlagsContainsWritePublic } } - if propSet.Size() == 0 && checkFlags&ast.CheckFlagsReadPartial == 0 && len(indexTypes) == 0 { - if !mergedInstantiations { + sources.singleProp = singleProp + sources.indexTypes = indexTypes + sources.propFlags = propFlags + sources.optionalFlag = optionalFlag + sources.checkFlags = checkFlags + sources.syntheticFlag = syntheticFlag + sources.mergedInstantiations = mergedInstantiations + return true +} + +func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name string, skipObjectFunctionPropertyAugment bool) *ast.Symbol { + var sources unionOrIntersectionPropertySources + if !c.getUnionOrIntersectionPropertySources(containingType, name, skipObjectFunctionPropertyAugment, &sources) { + return nil + } + singleProp := sources.singleProp + propSet := &sources.propSet + indexTypes := sources.indexTypes + propFlags := sources.propFlags + optionalFlag := sources.optionalFlag + checkFlags := sources.checkFlags + syntheticFlag := sources.syntheticFlag + isUnion := containingType.flags&TypeFlagsUnion != 0 + if !sources.isSynthetic() { + if !sources.mergedInstantiations { return singleProp } // No symbol from a union/intersection should have a `.parent` set (since unions/intersections don't act as symbol parents) @@ -22390,7 +22432,7 @@ func (c *Checker) somePropertyReducesToNever(t *Type) bool { // Go in the order the properties were found so the combined properties are created in the same order every time. for propName, count := range counts.Entries() { if count > 1 { - if prop := c.getPropertyOfUnionOrIntersectionType(t, propName, true /*skipObjectFunctionPropertyAugment*/); prop != nil && c.isNeverReducedProperty(prop) { + if c.intersectionPropertyReducesToNever(t, propName) { return true } } @@ -22398,6 +22440,64 @@ func (c *Checker) somePropertyReducesToNever(t *Type) bool { return false } +// intersectionPropertyReducesToNever reports whether the combined property named +// name would satisfy isNeverReducedProperty, without creating and caching that +// property. Most such properties are never otherwise looked up, so creating +// them only to inspect their flags retains a large number of symbols. +func (c *Checker) intersectionPropertyReducesToNever(t *Type, name string) bool { + if prop := t.AsUnionOrIntersectionType().propertyCacheWithoutFunctionPropertyAugment[name]; prop != nil { + return c.isNeverReducedProperty(prop) + } + var sources unionOrIntersectionPropertySources + if !c.getUnionOrIntersectionPropertySources(t, name, true /*skipObjectFunctionPropertyAugment*/, &sources) { + return false + } + if !sources.isSynthetic() { + if !sources.mergedInstantiations { + return c.isNeverReducedProperty(sources.singleProp) + } + prop := c.getUnionOrIntersectionProperty(t, name, true /*skipObjectFunctionPropertyAugment*/) + return prop != nil && c.isNeverReducedProperty(prop) + } + if sources.propSet.Size() == 0 { + sources.propSet.Add(sources.singleProp) + } + checkFlags := sources.checkFlags + var firstType *Type + var firstValueDeclaration *ast.Node + hasNonUniformValueDeclaration := false + propTypes := make([]*Type, 0, sources.propSet.Size()+len(sources.indexTypes)) + for prop := range sources.propSet.Values() { + if firstValueDeclaration == nil { + firstValueDeclaration = prop.ValueDeclaration() + } else if prop.ValueDeclaration() != nil && prop.ValueDeclaration() != firstValueDeclaration { + hasNonUniformValueDeclaration = true + } + propType := c.getTypeOfSymbol(prop) + if firstType == nil { + firstType = propType + } + if propType != firstType { + checkFlags |= ast.CheckFlagsHasNonUniformType + } + if isLiteralType(propType) || c.isPatternLiteralType(propType) { + checkFlags |= ast.CheckFlagsHasLiteralType + } + if propType.flags&TypeFlagsNever != 0 && propType != c.uniqueLiteralType { + checkFlags |= ast.CheckFlagsHasNeverType + } + propTypes = append(propTypes, propType) + } + propTypes = append(propTypes, sources.indexTypes...) + hasValueDeclaration := !hasNonUniformValueDeclaration && firstValueDeclaration != nil + if !hasValueDeclaration && checkFlags&ast.CheckFlagsContainsPrivate != 0 { + return true + } + return (sources.propFlags|sources.optionalFlag)&ast.SymbolFlagsOptional == 0 && + checkFlags&(ast.CheckFlagsNonUniformAndLiteral|ast.CheckFlagsHasNeverType) == ast.CheckFlagsNonUniformAndLiteral && + c.getIntersectionType(propTypes).flags&TypeFlagsNever != 0 +} + func (c *Checker) getReducedUnionType(unionType *Type) *Type { reducedTypes := core.SameMap(unionType.Types(), c.getReducedType) if core.Same(reducedTypes, unionType.Types()) { From e8b256d98a75a17f1035a1f435ee0331337db2f2 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:17:24 -0700 Subject: [PATCH 17/17] Avoid map-backed sets for union and intersection property sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesizing or probing a union or intersection property gathers the distinct constituent properties into a set. That set rarely holds more than a handful of symbols, but collections.OrderedSet always allocates both a map and a key slice, and this runs for every property of every combined type that is enumerated during relation checks. Keep the distinct properties in a plain slice, and index them with a map only once the slice passes the same threshold used by orderedSet. mui-docs: │ before │ after │ │ allocs │ allocs vs base │ MUIDocs 25.19M ± 0% 23.32M ± 1% -7.41% (p=0.002 n=6) │ before │ after │ │ allocated-B │ allocated-B vs base │ MUIDocs 4.556Gi ± 0% 4.389Gi ± 1% -3.66% (p=0.002 n=6) --- tsc/internal/checker/checker.go | 63 +++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 02ae5b68e1485..efe984f219a4f 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -21953,8 +21953,12 @@ func (c *Checker) getUnionOrIntersectionProperty(t *Type, name string, skipObjec // unionOrIntersectionPropertySources describes the constituent properties from // which a union or intersection property would be synthesized. type unionOrIntersectionPropertySources struct { - singleProp *ast.Symbol - propSet collections.OrderedSet[*ast.Symbol] + singleProp *ast.Symbol + // props holds distinct constituent properties in first-seen order, or is + // empty when every constituent shares singleProp. propsByKey indexes it + // only once it is large enough for hashing to beat a linear scan. + props []*ast.Symbol + propsByKey map[*ast.Symbol]struct{} indexTypes []*Type propFlags ast.SymbolFlags optionalFlag ast.SymbolFlags @@ -21966,7 +21970,30 @@ type unionOrIntersectionPropertySources struct { // isSynthetic reports whether the sources require a new synthetic symbol rather // than reusing (or cloning) singleProp. func (s *unionOrIntersectionPropertySources) isSynthetic() bool { - return s.propSet.Size() != 0 || s.checkFlags&ast.CheckFlagsReadPartial != 0 || len(s.indexTypes) != 0 + return len(s.props) != 0 || s.checkFlags&ast.CheckFlagsReadPartial != 0 || len(s.indexTypes) != 0 +} + +// addProp adds prop to props if it is not already present, preserving +// first-insertion order. +func (s *unionOrIntersectionPropertySources) addProp(prop *ast.Symbol) { + if s.propsByKey == nil { + if slices.Contains(s.props, prop) { + return + } + s.props = append(s.props, prop) + if len(s.props) > orderedSetMapThreshold { + s.propsByKey = make(map[*ast.Symbol]struct{}, len(s.props)) + for _, p := range s.props { + s.propsByKey[p] = struct{}{} + } + } + return + } + if _, ok := s.propsByKey[prop]; ok { + return + } + s.props = append(s.props, prop) + s.propsByKey[prop] = struct{}{} } func (c *Checker) getUnionOrIntersectionPropertySources(containingType *Type, name string, skipObjectFunctionPropertyAugment bool, sources *unionOrIntersectionPropertySources) bool { @@ -21976,7 +22003,6 @@ func (c *Checker) getUnionOrIntersectionPropertySources(containingType *Type, na var checkFlags ast.CheckFlags var optionalFlag ast.SymbolFlags var singleProp *ast.Symbol - propSet := &sources.propSet var indexTypes []*Type if !isUnion { checkFlags = ast.CheckFlagsReadonly @@ -22013,10 +22039,10 @@ func (c *Checker) getUnionOrIntersectionPropertySources(containingType *Type, na // back and not `Array.length` when we're looking at a `.length` access on a `string[] | number[]` mergedInstantiations = singleProp.Parent() != nil && len(c.getLocalTypeParametersOfClassOrInterfaceOrTypeAlias(singleProp.Parent())) != 0 } else { - if propSet.Size() == 0 { - propSet.Add(singleProp) + if len(sources.props) == 0 { + sources.addProp(singleProp) } - propSet.Add(prop) + sources.addProp(prop) } // classes created by mixins are represented as intersections // and overriding a property in a derived class redefines it completely at runtime @@ -22082,9 +22108,9 @@ func (c *Checker) getUnionOrIntersectionPropertySources(containingType *Type, na return false } if isUnion && - (propSet.Size() != 0 || checkFlags&ast.CheckFlagsPartial != 0) && + (len(sources.props) != 0 || checkFlags&ast.CheckFlagsPartial != 0) && checkFlags&(ast.CheckFlagsContainsPrivate|ast.CheckFlagsContainsProtected|ast.CheckFlagsContainsWritePrivate|ast.CheckFlagsContainsWriteProtected) != 0 && - !(propSet.Size() != 0 && c.hasCommonDeclaration(propSet)) { + !(len(sources.props) != 0 && c.hasCommonDeclaration(sources.props)) { // A property in a union has a private or protected declaration in one constituent, but is missing // or has a different declaration in another constituent. If the private or protected declaration is // for reading, we don't create a property. @@ -22115,7 +22141,6 @@ func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name s return nil } singleProp := sources.singleProp - propSet := &sources.propSet indexTypes := sources.indexTypes propFlags := sources.propFlags optionalFlag := sources.optionalFlag @@ -22146,8 +22171,8 @@ func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name s links.setWriteType(c, c.getWriteTypeOfSymbol(singleProp)) return clone } - if propSet.Size() == 0 { - propSet.Add(singleProp) + if len(sources.props) == 0 { + sources.props = []*ast.Symbol{singleProp} } var declarations []*ast.Node var firstType *Type @@ -22156,7 +22181,7 @@ func (c *Checker) createUnionOrIntersectionProperty(containingType *Type, name s var writeTypes []*Type var firstValueDeclaration *ast.Node var hasNonUniformValueDeclaration bool - for prop := range propSet.Values() { + for _, prop := range sources.props { if firstValueDeclaration == nil { firstValueDeclaration = prop.ValueDeclaration() } else if prop.ValueDeclaration() != nil && prop.ValueDeclaration() != firstValueDeclaration { @@ -22240,9 +22265,9 @@ func isPrototypeProperty(symbol *ast.Symbol) bool { return symbol.Flags()&ast.SymbolFlagsMethod != 0 || symbol.CheckFlags()&ast.CheckFlagsSyntheticMethod != 0 } -func (c *Checker) hasCommonDeclaration(symbols *collections.OrderedSet[*ast.Symbol]) bool { +func (c *Checker) hasCommonDeclaration(symbols []*ast.Symbol) bool { var commonDeclarations collections.Set[*ast.Node] - for symbol := range symbols.Values() { + for _, symbol := range symbols { if len(symbol.Declarations()) == 0 { return false } @@ -22459,15 +22484,15 @@ func (c *Checker) intersectionPropertyReducesToNever(t *Type, name string) bool prop := c.getUnionOrIntersectionProperty(t, name, true /*skipObjectFunctionPropertyAugment*/) return prop != nil && c.isNeverReducedProperty(prop) } - if sources.propSet.Size() == 0 { - sources.propSet.Add(sources.singleProp) + if len(sources.props) == 0 { + sources.props = []*ast.Symbol{sources.singleProp} } checkFlags := sources.checkFlags var firstType *Type var firstValueDeclaration *ast.Node hasNonUniformValueDeclaration := false - propTypes := make([]*Type, 0, sources.propSet.Size()+len(sources.indexTypes)) - for prop := range sources.propSet.Values() { + propTypes := make([]*Type, 0, len(sources.props)+len(sources.indexTypes)) + for _, prop := range sources.props { if firstValueDeclaration == nil { firstValueDeclaration = prop.ValueDeclaration() } else if prop.ValueDeclaration() != nil && prop.ValueDeclaration() != firstValueDeclaration {