Skip to content

Deep clone JSDoc #1412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 18, 2025
Merged

Deep clone JSDoc #1412

merged 11 commits into from
Jul 18, 2025

Conversation

sandersn
Copy link
Member

This PR switches the approach of JSDoc reparsing from copying pointers plus adding alternate parents to deep-cloning nodes and keeping a map of original nodes to clones. However, this PR does not include the map yet; it's not needed in the checker and the jsdoc support in the language service doesn't work correctly either way (as measured by our tests at least).

Notably, I couldn't figure out how to set parent pointers during the deep clone itself, due to the visitor structure. I think the cost of two passes is likely to be small given the number of jsdoc nodes that will need to be cloned. But suggestions are welcome for how to incorporate parent-setting into the deep clone.

The code is slightly more verbose than it could be, with individual DeepCloneNode calls for all children. I could have instead cloned the entire node at the end of the process, but this is more efficient for hosted tags that would be cloning all children of, say, a function, which would end up cloning the entire body: a possibly-large subtree. I also think that the explicit child clones convey the intent more obviously.

sandersn added 6 commits July 15, 2025 08:56
- No crashes
- this types aren't working
- type checking of `p` in `module.exports.p` isn't working
- symbol baselining of `module.exports.p` no longer includes `p` either

That last is probably fine.
Some things are stricter now, almost all because module.exports.p with
module.exports= no longer binds a symbol. Also, type printing of aliases
of module.exports changed to print the thing itself instead of an
`import('.')`

Need to look at fourslash tests and code now.
As far as I can tell, FAR didn't work with jsdoc before and doesn't work
now at about the same level of brokenness.
@sandersn sandersn requested review from Copilot, weswigham, iisaduan and andrewbranch and removed request for Copilot July 17, 2025 13:17
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR switches JSDoc handling from copying pointers plus alternate parents to deep-cloning nodes and maintaining proper parent pointers. The change improves type accuracy and eliminates complex parent pointer management in JSDoc nodes, though some functionality improvements are deferred to future work.

  • Switches from pointer copying to node deep-cloning for JSDoc processing
  • Improves type accuracy for property assignments and class expressions
  • Removes complex parent pointer handling logic for reparsed nodes

Reviewed Changes

Copilot reviewed 146 out of 146 changed files in this pull request and generated 2 comments.

File Description
test baselines Updated type and symbol baselines reflecting improved JSDoc type resolution
internal/testutil/tsbaseline/type_symbol_baseline.go Modified node traversal logic to handle reparsed nodes in type contexts
internal/testrunner/compiler_runner.go Removed parent pointer validation exceptions for JSDoc export scenarios

@@ -327,8 +327,9 @@ func forEachASTNode(node *ast.Node) []*ast.Node {
for len(work) > 0 {
elem := work[len(work)-1]
work = work[:len(work)-1]
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression {
if elem.Flags&ast.NodeFlagsReparsed == 0 {
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression ||
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition on line 330-332 is complex and spans multiple lines with unclear logic. Consider extracting this into a helper function with a descriptive name like shouldIncludeNodeInBaseline to improve readability and maintainability.

Copilot uses AI. Check for mistakes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, but it's also in test code, and is an ad-hoc condition that will only ever appear here. The mess makes that obvious; extracting to a function would make readers think that we might want to use it elsewhere.

I'm on the fence. Any humans want to weigh in?

Comment on lines 330 to 334
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression ||
((elem.Parent.Kind == ast.KindSatisfiesExpression || elem.Parent.Kind == ast.KindAsExpression) && elem == elem.Parent.Expression()) {
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Parent.Kind == ast.KindAsExpression || elem.Parent.Kind == ast.KindSatisfiesExpression {
result = append(result, elem)
}
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested condition duplicates logic from the outer condition and creates confusing control flow. The logic for handling reparsed nodes should be consolidated and clearly documented.

Suggested change
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression ||
((elem.Parent.Kind == ast.KindSatisfiesExpression || elem.Parent.Kind == ast.KindAsExpression) && elem == elem.Parent.Expression()) {
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Parent.Kind == ast.KindAsExpression || elem.Parent.Kind == ast.KindSatisfiesExpression {
result = append(result, elem)
}
if (elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression ||
((elem.Parent.Kind == ast.KindSatisfiesExpression || elem.Parent.Kind == ast.KindAsExpression) && elem == elem.Parent.Expression())) &&
(elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Parent.Kind == ast.KindAsExpression || elem.Parent.Kind == ast.KindSatisfiesExpression) {
result = append(result, elem)

Copilot uses AI. Check for mistakes.

@@ -327,8 +327,9 @@ func forEachASTNode(node *ast.Node) []*ast.Node {
for len(work) > 0 {
elem := work[len(work)-1]
work = work[:len(work)-1]
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression {
if elem.Flags&ast.NodeFlagsReparsed == 0 {
if elem.Flags&ast.NodeFlagsReparsed == 0 || elem.Kind == ast.KindAsExpression || elem.Kind == ast.KindSatisfiesExpression ||
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, but it's also in test code, and is an ad-hoc condition that will only ever appear here. The mess makes that obvious; extracting to a function would make readers think that we might want to use it elsewhere.

I'm on the fence. Any humans want to weigh in?

@@ -5,7 +5,9 @@ import (
"github.com/microsoft/typescript-go/internal/core"
)

func (p *Parser) finishReparsedNode(node *ast.Node) {
func (p *Parser) finishReparsedNode(node *ast.Node, locationNode *ast.Node) {
node.Flags = p.contextFlags | ast.NodeFlagsReparsed
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is responsible for a lot of the diff, but that's because it dedupes a lot of code.

func (p *Parser) gatherTypeParameters(j *ast.Node, tagWithTypeParameters *ast.Node, host *ast.Node) *ast.NodeList {
typeParameters := p.nodeSlicePool.NewSlice(0)
func (p *Parser) gatherTypeParameters(j *ast.Node, tagWithTypeParameters *ast.Node) *ast.NodeList {
var typeParameters []*ast.Node
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why I didn't lazy-initialise this before, because the common case is to have no parameters, which previously called NewSlice(0) each time anyway.

p.finishReparsedNode(declaration)
if declaration.AsVariableDeclaration().Type == nil && tag.AsJSDocTypeTag().TypeExpression != nil {
declaration.AsVariableDeclaration().Type = p.factory.DeepCloneReparse(tag.AsJSDocTypeTag().TypeExpression.Type())
p.overrideParentInImmediateChildren(declaration)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since finishReparsedNode now sets location and flags, this just (re)sets parent pointers of the existing node's children. I think this makes more sense since the variable declaration is not a reparsed node, just its type annotation.

func getClassLikeData(parent *ast.Node) *ast.ClassLikeBase {
var class *ast.ClassLikeBase
if parent.Kind == ast.KindClassDeclaration {
switch parent.Kind {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gopls or some tool wouldn't be quiet until I accepted this change. I guess it's better.

+>exports : typeof import(".")
+>class Thing { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof import(".")
+>Thing : typeof import(".")
+>module.exports = class Thing { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof Thing
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this type printing changed, but it seems like an improvement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thing is in scope at the return position now because the .Parent chain isn't inside the jsdoc comment anymore, so it's resolving at the actual export now. I think.

Or we're mistakenly binding Thing into the local scope. One of those.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like the first then, because the only weirdness with binding module.exports= compared to export= is that the former always binds with the source file as a container, even if it's inside some other container. Which doesn't apply here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this test by changing it to use export=, and that result is the same as the new result, so this is an improvement inasmuch as it makes JS more like TS.

+>exports : typeof import(".")
+>class { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof import(".")
+>module.exports = class { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof exports
+>module.exports : typeof (Anonymous class)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, except arguably import(".") is better than (Anonymous class) since it's unique.

@@ -24,18 +24,18 @@
*/
constructor(compiler) {
->compiler : import("MC")
+>compiler : import("./MC")
+>compiler : MC
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is similar, except here MC is the name of the type alias and import("./MC") is its value.

@@ -75,7 +75,7 @@
+>module.exports : { justExport: number; bothBefore: number; bothAfter: number; }
+>module : { "export=": { justExport: number; bothBefore: number; bothAfter: number; }; }
+>exports : { justExport: number; bothBefore: number; bothAfter: number; }
+>bothBefore : "string"
+>bothBefore : number
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corsa dropped the combination of module.exports.both=1 with module.exports={ both: "string" } -- now that the module.exports= always wins, it always provides the type. Before, the property would sometimes win (when it comes first?).

@@ -80,8 +75,7 @@
+>module["exports"] : typeof import("./mod1")
+>module : { "\"mod1\"": typeof import("./mod1"); }
>"exports" : "exports"
->"d" : "d"
+>"d" : {}
>"d" : "d"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this change, but it's the way Strada works. Maybe it's checking the literal itself instead of the property with the literal name.

+>exports : typeof import(".")
+>class Thing { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof import(".")
+>Thing : typeof import(".")
+>module.exports = class Thing { /** * @param {number} p */ constructor(p) { this.t = 12 + p; }} : typeof Thing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thing is in scope at the return position now because the .Parent chain isn't inside the jsdoc comment anymore, so it's resolving at the actual export now. I think.

Or we're mistakenly binding Thing into the local scope. One of those.

@sandersn sandersn added this pull request to the merge queue Jul 18, 2025
Merged via the queue into microsoft:main with commit 96c0688 Jul 18, 2025
22 checks passed
@sandersn sandersn deleted the deepclone-jsdoc branch July 18, 2025 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants