From 459b499786e3c1d8200010fd7318799d33e4a91d Mon Sep 17 00:00:00 2001 From: Oleksandr Tarasiuk Date: Fri, 28 Aug 2026 10:53:55 +0300 Subject: [PATCH 1/2] fix(64058): fix reparse jsdoc @extends type arguments for call expressions --- tsc/internal/ast/utilities.go | 24 ++++++-- tsc/internal/parser/parser_test.go | 60 +++++++++++++++++++ .../reference/conformance/extendsTag7.symbols | 37 ++++++++++++ .../reference/conformance/extendsTag7.types | 40 +++++++++++++ .../conformance/extendsTag8.errors.txt | 20 +++++++ .../reference/conformance/extendsTag8.symbols | 27 +++++++++ .../reference/conformance/extendsTag8.types | 28 +++++++++ .../cases/conformance/jsdoc/extendsTag7.ts | 23 +++++++ .../cases/conformance/jsdoc/extendsTag8.ts | 19 ++++++ 9 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 tsc/testdata/baselines/reference/conformance/extendsTag7.symbols create mode 100644 tsc/testdata/baselines/reference/conformance/extendsTag7.types create mode 100644 tsc/testdata/baselines/reference/conformance/extendsTag8.errors.txt create mode 100644 tsc/testdata/baselines/reference/conformance/extendsTag8.symbols create mode 100644 tsc/testdata/baselines/reference/conformance/extendsTag8.types create mode 100644 tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts create mode 100644 tsc/testdata/tests/cases/conformance/jsdoc/extendsTag8.ts diff --git a/tsc/internal/ast/utilities.go b/tsc/internal/ast/utilities.go index 42058ea2c399f..fac1bdec9375a 100644 --- a/tsc/internal/ast/utilities.go +++ b/tsc/internal/ast/utilities.go @@ -1645,12 +1645,24 @@ func IsDottedName(node *Node) bool { return false } -func HasSamePropertyAccessName(node1, node2 *Node) bool { - if node1.Kind == KindIdentifier && node2.Kind == KindIdentifier { - return node1.Text() == node2.Text() - } else if node1.Kind == KindPropertyAccessExpression && node2.Kind == KindPropertyAccessExpression { - return node1.AsPropertyAccessExpression().Name().Text() == node2.AsPropertyAccessExpression().Name().Text() && - HasSamePropertyAccessName(node1.Expression(), node2.Expression()) +func HasSamePropertyAccessName(target, source *Node) bool { + target = SkipParentheses(target) + source = SkipParentheses(source) + if IsCallExpression(target) { + target = SkipParentheses(target.Expression()) + if IsPropertyAccessExpression(target) { + if HasSamePropertyAccessName(target, source) { + return true + } + target = SkipParentheses(target.Expression()) + } + } + if target.Kind == KindIdentifier && source.Kind == KindIdentifier { + return target.Text() == source.Text() + } + if target.Kind == KindPropertyAccessExpression && source.Kind == KindPropertyAccessExpression { + return target.AsPropertyAccessExpression().Name().Text() == source.AsPropertyAccessExpression().Name().Text() && + HasSamePropertyAccessName(target.Expression(), source.Expression()) } return false } diff --git a/tsc/internal/parser/parser_test.go b/tsc/internal/parser/parser_test.go index 200064160228b..50f5a86608609 100644 --- a/tsc/internal/parser/parser_test.go +++ b/tsc/internal/parser/parser_test.go @@ -308,6 +308,66 @@ function foo(options) {}` assert.Equal(t, scanner.GetTokenPosOfNode(typeNode, file, false /*includeJSDoc*/), strings.Index(sourceText, "{{")+1) } +func TestJSDocAugmentsTypeArgumentsReparse(t *testing.T) { + t.Parallel() + sourceText := `/** @template T */ +class A { + static extend() { + return this; + } +} + +/** @extends {A} */ +class B extends A.extend() {} + +/** @extends {Other} */ +class C extends A.extend() {}` + opts := ast.SourceFileParseOptions{ + FileName: "/index.js", + Path: "/index.js", + } + + file := parser.ParseSourceFile(opts, sourceText, core.ScriptKindJS) + statements := file.Statements.Nodes + assert.Equal(t, len(statements), 3) + + classB := statements[1] + assert.Assert(t, ast.IsClassDeclaration(classB)) + + baseType := ast.GetClassExtendsHeritageElement(classB) + assert.Assert(t, baseType != nil) + assert.Assert(t, ast.IsCallExpression(baseType.Expression())) + + typeArguments := baseType.TypeArguments() + assert.Equal(t, len(typeArguments), 1) + + typeArgument := typeArguments[0] + assert.Equal(t, typeArgument.Kind, ast.KindStringKeyword) + assert.Assert(t, typeArgument.Flags&ast.NodeFlagsReparsed != 0) + + jsDocs := classB.JSDoc(file) + assert.Equal(t, len(jsDocs), 1) + + tags := jsDocs[0].AsJSDoc().Tags + assert.Assert(t, tags != nil) + assert.Equal(t, len(tags.Nodes), 1) + + tag := tags.Nodes[0] + assert.Assert(t, ast.IsJSDocAugmentsTag(tag)) + + sourceTypeArguments := tag.ClassName().TypeArguments() + assert.Equal(t, len(sourceTypeArguments), 1) + assert.Equal(t, ast.GetReparsedNodeForNode(sourceTypeArguments[0]), typeArgument) + + classC := statements[2] + assert.Assert(t, ast.IsClassDeclaration(classC)) + + mismatchedBaseType := ast.GetClassExtendsHeritageElement(classC) + assert.Assert(t, mismatchedBaseType != nil) + assert.Assert(t, ast.IsCallExpression(mismatchedBaseType.Expression())) + assert.Equal(t, len(mismatchedBaseType.TypeArguments()), 0) +} + func TestSourceFilePositionMapWithNonASCIIStringLiteral(t *testing.T) { t.Parallel() sourceText := `const x = "─"; diff --git a/tsc/testdata/baselines/reference/conformance/extendsTag7.symbols b/tsc/testdata/baselines/reference/conformance/extendsTag7.symbols new file mode 100644 index 0000000000000..20349b715c45d --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/extendsTag7.symbols @@ -0,0 +1,37 @@ +//// [tests/cases/conformance/jsdoc/extendsTag7.ts] //// + +=== a.js === +/** @template T */ +class A { +>A : Symbol(A, Decl(a.js, 0, 0)) + + /** @returns {T} */ + get value() { +>value : Symbol(A.value, Decl(a.js, 1, 9)) + + throw new Error(); +>Error : Symbol(Error, Decl(lib.es5.d.ts, --, --), Decl(lib.es5.d.ts, --, --)) + } + + /** @returns {typeof A} */ + static extend() { +>extend : Symbol(A.extend, Decl(a.js, 5, 5)) + + return this; +>this : Symbol(A, Decl(a.js, 0, 0)) + } +} + +/** @extends {A} */ +class B extends A.extend() {} +>B : Symbol(B, Decl(a.js, 11, 1)) +>A.extend : Symbol(A.extend, Decl(a.js, 5, 5)) +>A : Symbol(A, Decl(a.js, 0, 0)) +>extend : Symbol(A.extend, Decl(a.js, 5, 5)) + +const value = new B().value; +>value : Symbol(value, Decl(a.js, 16, 5)) +>new B().value : Symbol(A.value, Decl(a.js, 1, 9)) +>B : Symbol(B, Decl(a.js, 11, 1)) +>value : Symbol(A.value, Decl(a.js, 1, 9)) + diff --git a/tsc/testdata/baselines/reference/conformance/extendsTag7.types b/tsc/testdata/baselines/reference/conformance/extendsTag7.types new file mode 100644 index 0000000000000..50b3b50060c01 --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/extendsTag7.types @@ -0,0 +1,40 @@ +//// [tests/cases/conformance/jsdoc/extendsTag7.ts] //// + +=== a.js === +/** @template T */ +class A { +>A : A + + /** @returns {T} */ + get value() { +>value : T + + throw new Error(); +>new Error() : Error +>Error : ErrorConstructor + } + + /** @returns {typeof A} */ + static extend() { +>extend : () => typeof A + + return this; +>this : typeof A + } +} + +/** @extends {A} */ +class B extends A.extend() {} +>B : B +>A.extend() : A +>A.extend : () => typeof A +>A : typeof A +>extend : () => typeof A + +const value = new B().value; +>value : string +>new B().value : string +>new B() : B +>B : typeof B +>value : string + diff --git a/tsc/testdata/baselines/reference/conformance/extendsTag8.errors.txt b/tsc/testdata/baselines/reference/conformance/extendsTag8.errors.txt new file mode 100644 index 0000000000000..1d1b5a53efbe2 --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/extendsTag8.errors.txt @@ -0,0 +1,20 @@ +a.js(13,17): error TS8026: Expected A type arguments; provide these with an '@extends' tag. + + +==== a.js (1 errors) ==== + /** @template T */ + class A { + /** @returns {typeof A} */ + static extend() { + return this; + } + } + + /** @template T */ + class C {} + + /** @extends {C} */ + class B extends A.extend() {} + ~~~~~~~~~~ +!!! error TS8026: Expected A type arguments; provide these with an '@extends' tag. + \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/conformance/extendsTag8.symbols b/tsc/testdata/baselines/reference/conformance/extendsTag8.symbols new file mode 100644 index 0000000000000..8b10f3499d474 --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/extendsTag8.symbols @@ -0,0 +1,27 @@ +//// [tests/cases/conformance/jsdoc/extendsTag8.ts] //// + +=== a.js === +/** @template T */ +class A { +>A : Symbol(A, Decl(a.js, 0, 0)) + + /** @returns {typeof A} */ + static extend() { +>extend : Symbol(A.extend, Decl(a.js, 1, 9)) + + return this; +>this : Symbol(A, Decl(a.js, 0, 0)) + } +} + +/** @template T */ +class C {} +>C : Symbol(C, Decl(a.js, 6, 1)) + +/** @extends {C} */ +class B extends A.extend() {} +>B : Symbol(B, Decl(a.js, 9, 10)) +>A.extend : Symbol(A.extend, Decl(a.js, 1, 9)) +>A : Symbol(A, Decl(a.js, 0, 0)) +>extend : Symbol(A.extend, Decl(a.js, 1, 9)) + diff --git a/tsc/testdata/baselines/reference/conformance/extendsTag8.types b/tsc/testdata/baselines/reference/conformance/extendsTag8.types new file mode 100644 index 0000000000000..9c62961a7581e --- /dev/null +++ b/tsc/testdata/baselines/reference/conformance/extendsTag8.types @@ -0,0 +1,28 @@ +//// [tests/cases/conformance/jsdoc/extendsTag8.ts] //// + +=== a.js === +/** @template T */ +class A { +>A : A + + /** @returns {typeof A} */ + static extend() { +>extend : () => typeof A + + return this; +>this : typeof A + } +} + +/** @template T */ +class C {} +>C : C + +/** @extends {C} */ +class B extends A.extend() {} +>B : B +>A.extend() : A +>A.extend : () => typeof A +>A : typeof A +>extend : () => typeof A + diff --git a/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts new file mode 100644 index 0000000000000..6177d00b31490 --- /dev/null +++ b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts @@ -0,0 +1,23 @@ +// @target: es2015 +// @allowJs: true +// @checkJs: true +// @noEmit: true + +// @filename: a.js +/** @template T */ +class A { + /** @returns {T} */ + get value() { + throw new Error(); + } + + /** @returns {typeof A} */ + static extend() { + return this; + } +} + +/** @extends {A} */ +class B extends A.extend() {} + +const value = new B().value; diff --git a/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag8.ts b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag8.ts new file mode 100644 index 0000000000000..d63c39d8f257c --- /dev/null +++ b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag8.ts @@ -0,0 +1,19 @@ +// @target: es2015 +// @allowJs: true +// @checkJs: true +// @noEmit: true + +// @filename: a.js +/** @template T */ +class A { + /** @returns {typeof A} */ + static extend() { + return this; + } +} + +/** @template T */ +class C {} + +/** @extends {C} */ +class B extends A.extend() {} From 0c1088c1dc2a6ba9022777af76c970ccc96a7c7e Mon Sep 17 00:00:00 2001 From: Oleksandr Tarasiuk Date: Fri, 28 Aug 2026 23:39:37 +0300 Subject: [PATCH 2/2] preserve jsdoc base identity for call-expression heritage --- tsc/internal/ast/utilities.go | 24 ++++---------- tsc/internal/parser/parser_test.go | 28 ++++++++-------- tsc/internal/parser/reparser.go | 25 ++++++++++----- .../cases/conformance/jsdoc/extendsTag7.ts | 2 +- .../cases/conformance/jsdoc/extendsTag9.ts | 32 +++++++++++++++++++ 5 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 tsc/testdata/tests/cases/conformance/jsdoc/extendsTag9.ts diff --git a/tsc/internal/ast/utilities.go b/tsc/internal/ast/utilities.go index fac1bdec9375a..42058ea2c399f 100644 --- a/tsc/internal/ast/utilities.go +++ b/tsc/internal/ast/utilities.go @@ -1645,24 +1645,12 @@ func IsDottedName(node *Node) bool { return false } -func HasSamePropertyAccessName(target, source *Node) bool { - target = SkipParentheses(target) - source = SkipParentheses(source) - if IsCallExpression(target) { - target = SkipParentheses(target.Expression()) - if IsPropertyAccessExpression(target) { - if HasSamePropertyAccessName(target, source) { - return true - } - target = SkipParentheses(target.Expression()) - } - } - if target.Kind == KindIdentifier && source.Kind == KindIdentifier { - return target.Text() == source.Text() - } - if target.Kind == KindPropertyAccessExpression && source.Kind == KindPropertyAccessExpression { - return target.AsPropertyAccessExpression().Name().Text() == source.AsPropertyAccessExpression().Name().Text() && - HasSamePropertyAccessName(target.Expression(), source.Expression()) +func HasSamePropertyAccessName(node1, node2 *Node) bool { + if node1.Kind == KindIdentifier && node2.Kind == KindIdentifier { + return node1.Text() == node2.Text() + } else if node1.Kind == KindPropertyAccessExpression && node2.Kind == KindPropertyAccessExpression { + return node1.AsPropertyAccessExpression().Name().Text() == node2.AsPropertyAccessExpression().Name().Text() && + HasSamePropertyAccessName(node1.Expression(), node2.Expression()) } return false } diff --git a/tsc/internal/parser/parser_test.go b/tsc/internal/parser/parser_test.go index 50f5a86608609..45e3e8e0b9aed 100644 --- a/tsc/internal/parser/parser_test.go +++ b/tsc/internal/parser/parser_test.go @@ -318,10 +318,7 @@ class A { } /** @extends {A} */ -class B extends A.extend() {} - -/** @extends {Other} */ -class C extends A.extend() {}` +class B extends A.extend() {}` opts := ast.SourceFileParseOptions{ FileName: "/index.js", Path: "/index.js", @@ -329,14 +326,24 @@ class C extends A.extend() {}` file := parser.ParseSourceFile(opts, sourceText, core.ScriptKindJS) statements := file.Statements.Nodes - assert.Equal(t, len(statements), 3) + assert.Equal(t, len(statements), 2) classB := statements[1] assert.Assert(t, ast.IsClassDeclaration(classB)) baseType := ast.GetClassExtendsHeritageElement(classB) assert.Assert(t, baseType != nil) - assert.Assert(t, ast.IsCallExpression(baseType.Expression())) + + baseExpression := baseType.Expression() + assert.Assert(t, ast.IsAsExpression(baseExpression)) + + callExpression := baseExpression.Expression() + assert.Assert(t, ast.IsCallExpression(callExpression)) + assert.Equal(t, scanner.GetTextOfNode(callExpression), "A.extend()") + + typeQuery := baseExpression.Type() + assert.Equal(t, typeQuery.Kind, ast.KindTypeQuery) + assert.Assert(t, typeQuery.Flags&ast.NodeFlagsReparsed != 0) typeArguments := baseType.TypeArguments() assert.Equal(t, len(typeArguments), 1) @@ -354,18 +361,11 @@ class C extends A.extend() {}` tag := tags.Nodes[0] assert.Assert(t, ast.IsJSDocAugmentsTag(tag)) + assert.Equal(t, ast.GetReparsedNodeForNode(tag.ClassName().Expression()), typeQuery.AsTypeQueryNode().ExprName) sourceTypeArguments := tag.ClassName().TypeArguments() assert.Equal(t, len(sourceTypeArguments), 1) assert.Equal(t, ast.GetReparsedNodeForNode(sourceTypeArguments[0]), typeArgument) - - classC := statements[2] - assert.Assert(t, ast.IsClassDeclaration(classC)) - - mismatchedBaseType := ast.GetClassExtendsHeritageElement(classC) - assert.Assert(t, mismatchedBaseType != nil) - assert.Assert(t, ast.IsCallExpression(mismatchedBaseType.Expression())) - assert.Equal(t, len(mismatchedBaseType.TypeArguments()), 0) } func TestSourceFilePositionMapWithNonASCIIStringLiteral(t *testing.T) { diff --git a/tsc/internal/parser/reparser.go b/tsc/internal/parser/reparser.go index 58a58cf974bd6..b78e9be7133ef 100644 --- a/tsc/internal/parser/reparser.go +++ b/tsc/internal/parser/reparser.go @@ -597,14 +597,14 @@ func (p *Parser) reparseHosted(tag *ast.Node, parent *ast.Node, jsDoc *ast.Node) }); extendsClause != nil && len(extendsClause.AsHeritageClause().Types.Nodes) == 1 { target := extendsClause.AsHeritageClause().Types.Nodes[0].AsExpressionWithTypeArguments() source := tag.ClassName().AsExpressionWithTypeArguments() - if ast.HasSamePropertyAccessName(target.Expression, source.Expression) { - if target.TypeArguments == nil && source.TypeArguments != nil { - newArguments := p.nodeSliceArena.NewSlice(len(source.TypeArguments.Nodes)) - for i, arg := range source.TypeArguments.Nodes { - newArguments[i] = p.addDeepCloneReparse(arg) - } - target.TypeArguments = p.newNodeList(source.TypeArguments.Loc, newArguments) - p.finishMutatedNode(target.AsNode()) + if target.TypeArguments == nil && source.TypeArguments != nil { + if ast.IsCallExpression(ast.SkipParentheses(target.Expression)) { + typeQuery := p.factory.NewTypeQueryNode(p.addDeepCloneReparse(source.Expression), nil) + p.finishReparsedNode(typeQuery, source.Expression) + target.Expression = p.makeNewCast(typeQuery, target.Expression, true /*isAssertion*/) + p.setReparsedTypeArguments(target, source.TypeArguments) + } else if ast.HasSamePropertyAccessName(target.Expression, source.Expression) { + p.setReparsedTypeArguments(target, source.TypeArguments) } } } @@ -612,6 +612,15 @@ func (p *Parser) reparseHosted(tag *ast.Node, parent *ast.Node, jsDoc *ast.Node) } } +func (p *Parser) setReparsedTypeArguments(target *ast.ExpressionWithTypeArguments, source *ast.NodeList) { + typeArguments := p.nodeSliceArena.NewSlice(len(source.Nodes)) + for i, typeArgument := range source.Nodes { + typeArguments[i] = p.addDeepCloneReparse(typeArgument) + } + target.TypeArguments = p.newNodeList(source.Loc, typeArguments) + p.finishMutatedNode(target.AsNode()) +} + func (p *Parser) makeQuestionIfOptional(parameter *ast.JSDocParameterOrPropertyTag) *ast.Node { var questionToken *ast.Node if parameter.IsBracketed || parameter.TypeExpression != nil && parameter.TypeExpression.Type().Kind == ast.KindJSDocOptionalType { diff --git a/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts index 6177d00b31490..ffa297ccc6d58 100644 --- a/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts +++ b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag7.ts @@ -1,7 +1,7 @@ // @target: es2015 // @allowJs: true // @checkJs: true -// @noEmit: true +// @declaration: true // @filename: a.js /** @template T */ diff --git a/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag9.ts b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag9.ts new file mode 100644 index 0000000000000..a5fe80b7e5fde --- /dev/null +++ b/tsc/testdata/tests/cases/conformance/jsdoc/extendsTag9.ts @@ -0,0 +1,32 @@ +// @target: es2015 +// @allowJs: true +// @checkJs: true +// @noEmit: true + +// @filename: a.js +/** @template T */ +class A { + /** @returns {T} */ + get a() { + throw new Error(); + } + + /** @returns {typeof C} */ + static extend() { + return C; + } +} + +/** @template T */ +class C { + /** @returns {T} */ + get c() { + throw new Error(); + } +} + +/** @extends {A} */ +class B extends A.extend() {} + +const a = new B().a; +const c = new B().c;