From 92c51c2dfe6bba06cc87d1919b435d1ad610b483 Mon Sep 17 00:00:00 2001 From: koding88 Date: Thu, 27 Aug 2026 15:31:39 +0700 Subject: [PATCH 1/2] fix: don't treat JSDoc type casts on param defaults and function ids as casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding-position tracking from #167 covers the param nodes themselves, but two binding shapes were still reachable by the JSDoc cast heuristic: 1. A parameter with a default value is an AssignmentPattern; the binding is its left identifier, which was never marked — a comment re-anchored there (as svelte does for snippet params) got wrapped: '(row) = $.noop', which fails to parse. 2. A named function's id was never marked, allowing 'function /** @type */ (h)(...)' output. Track AssignmentPattern.left next to its parent pattern, and track FunctionDeclaration/FunctionExpression ids as binding positions. --- src/languages/ts/index.js | 36 +++++++++++--- test/comment-jsdoc-type-cast-bindings.test.js | 49 +++++++++++++++++++ .../expected.js | 11 +++++ .../expected.js.map | 11 +++++ .../input.js | 11 +++++ 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 test/comment-jsdoc-type-cast-bindings.test.js create mode 100644 test/samples/comment-jsdoc-type-cast-param-default/expected.js create mode 100644 test/samples/comment-jsdoc-type-cast-param-default/expected.js.map create mode 100644 test/samples/comment-jsdoc-type-cast-param-default/input.js diff --git a/src/languages/ts/index.js b/src/languages/ts/index.js index 42aa747..3aad2dd 100644 --- a/src/languages/ts/index.js +++ b/src/languages/ts/index.js @@ -101,6 +101,24 @@ function track_bindings(nodes) { if (nodes) for (const node of nodes) track_binding(node); } +/** + * Tracks function parameters as binding positions. A param with a default value + * is an `AssignmentPattern`; the binding is its `left`, not the pattern itself — + * the comment flush that decides whether a JSDoc `@type` cast gets wrapped in + * parentheses happens on the identifier's own visit. + * @param {(object | null | undefined)[] | null | undefined} params + * @returns {void} + */ +function track_param_bindings(params) { + if (!params) return; + track_bindings(params); + for (const param of params) { + if (/** @type {any} */ (param)?.type === 'AssignmentPattern') { + track_binding(/** @type {any} */ (param).left); + } + } +} + /** * Writes `keyword` bounded by source map locations for the exact character span, * so breakpoints line up on keywords (not only identifiers and braces). @@ -844,13 +862,15 @@ export default (options = {}) => { } } + if (node.id) track_binding(node.id); + if (node.id) context.visit(node.id); if (node.typeParameters) { context.visit(node.typeParameters); } - track_bindings(node.params); + track_param_bindings(node.params); context.write('('); sequence(context, node.params, (node.returnType ?? node.body).loc?.start ?? null, false); context.write(')'); @@ -912,7 +932,7 @@ export default (options = {}) => { // @ts-expect-error `typeParameters` lives on the method node, not its value if (node.typeParameters) context.visit(node.typeParameters); - track_bindings(node.value.params); + track_param_bindings(node.value.params); context.write('('); sequence( context, @@ -1036,7 +1056,7 @@ export default (options = {}) => { } // @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions - track_bindings(node.parameters ?? node.params); + track_param_bindings(node.parameters ?? node.params); context.write('('); sequence( context, @@ -1067,7 +1087,7 @@ export default (options = {}) => { if (node.typeParameters) context.visit(node.typeParameters); // @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions - track_bindings(node.parameters ?? node.params); + track_param_bindings(node.parameters ?? node.params); context.write('('); sequence( context, @@ -1139,7 +1159,7 @@ export default (options = {}) => { context.visit(node.typeParameters); } - track_bindings(node.params); + track_param_bindings(node.params); context.write('('); sequence(context, node.params, (node.returnType ?? node.body).loc?.start ?? null, false); context.write(')'); @@ -1656,7 +1676,7 @@ export default (options = {}) => { if (node.computed) context.write('[', token_before(node.key.loc?.start)); context.visit(node.key); if (node.computed) context.write(']', token_at(node.key.loc?.end)); - track_bindings(node.value.params); + track_param_bindings(node.value.params); context.write('('); sequence( context, @@ -1951,7 +1971,7 @@ export default (options = {}) => { context.visit(node.typeParameters); } - track_bindings(node.params); + track_param_bindings(node.params); context.write('('); sequence(context, node.params, node.returnType?.loc?.start ?? node.loc?.end ?? null, false); context.write(')'); @@ -2279,7 +2299,7 @@ export default (options = {}) => { } // @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions - track_bindings(node.parameters ?? node.params); + track_param_bindings(node.parameters ?? node.params); context.write('('); sequence( context, diff --git a/test/comment-jsdoc-type-cast-bindings.test.js b/test/comment-jsdoc-type-cast-bindings.test.js new file mode 100644 index 0000000..eb76382 --- /dev/null +++ b/test/comment-jsdoc-type-cast-bindings.test.js @@ -0,0 +1,49 @@ +// @ts-check +/** @import { TSESTree } from '@typescript-eslint/types' */ +import { expect, test } from 'vitest'; +import { print } from '../src/index.js'; +import { acornParse } from './common.js'; +import ts from '../src/languages/ts/index.js'; + +// Regression test for https://github.com/sveltejs/esrap/issues/181: +// a JSDoc `@type` comment re-anchored to a binding position (function parameter +// with a default value) must not be treated as a type cast — wrapping the +// binding target in parentheses produces invalid JS (`(row) = $.noop`). +test('JSDoc @type comment on a parameter with a default value is not wrapped', () => { + const input = `const row_template = ($$anchor, row = $.noop) => { + row; +};`; + + const { ast } = acornParse(input); + const arrow = /** @type {TSESTree.ArrowFunctionExpression} */ ( + /** @type {any} */ (ast.body[0]).declarations[0].init + ); + const param = /** @type {TSESTree.AssignmentPattern} */ (arrow.params[1]); + + // svelte re-anchors template comments onto freshly generated AST nodes whose + // synthetic `loc` starts before the binding identifier itself + /** @type {any} */ (param).start = /** @type {any} */ (param.left).start - 4; + param.loc = { + start: { line: 1, column: /** @type {any} */ (param.left).start - 4 }, + end: /** @type {any} */ (param.loc).end + }; + + const anchor = /** @type {any} */ (param.left).start - 3; + const comments = [ + { + type: /** @type {const} */ ('Block'), + value: '* @type {any} ', + start: anchor, + end: anchor, + loc: { start: { line: 1, column: anchor }, end: { line: 1, column: anchor } } + } + ]; + + const { code } = print(ast, ts({ comments }), {}); + + expect(code).toContain('/** @type {any} */ row = $.noop'); + expect(code).not.toContain('(row)'); + + // output must be valid JavaScript + expect(() => new Function(code)).not.toThrow(); +}); diff --git a/test/samples/comment-jsdoc-type-cast-param-default/expected.js b/test/samples/comment-jsdoc-type-cast-param-default/expected.js new file mode 100644 index 0000000..b09982b --- /dev/null +++ b/test/samples/comment-jsdoc-type-cast-param-default/expected.js @@ -0,0 +1,11 @@ +const row_template = ($$anchor, row = $.noop) => { + row; +}; + +const f = ({ a } = {}) => a; + +function g(x = 1) { + return x; +} + +const value = /** @type {number} */ (computed); \ No newline at end of file diff --git a/test/samples/comment-jsdoc-type-cast-param-default/expected.js.map b/test/samples/comment-jsdoc-type-cast-param-default/expected.js.map new file mode 100644 index 0000000..8f2d705 --- /dev/null +++ b/test/samples/comment-jsdoc-type-cast-param-default/expected.js.map @@ -0,0 +1,11 @@ +{ + "version": 3, + "names": [], + "sources": [ + "input.js" + ], + "sourcesContent": [ + "const row_template = ($$anchor, row = $.noop) => {\n\trow;\n};\n\nconst f = ({ a } = {}) => a;\n\nfunction g(x = 1) {\n\treturn x;\n}\n\nconst value = /** @type {number} */ (computed);\n" + ], + "mappings": "AAAA,MAAM,AAAA,YAAY,IAAI,QAAQ,EAAE,GAAG,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC;CACjD,GAAG;AACJ,CAAC;;AAED,MAAM,AAAA,CAAC,MAAM,CAAC,YAAY,CAAC;;AAE3B,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;CAClB,MAAM,CAAC,CAAC;AACT,CAAC;;AAED,MAAM,AAAA,KAAK,0BAA0B,QAAQ" +} \ No newline at end of file diff --git a/test/samples/comment-jsdoc-type-cast-param-default/input.js b/test/samples/comment-jsdoc-type-cast-param-default/input.js new file mode 100644 index 0000000..4b94ebf --- /dev/null +++ b/test/samples/comment-jsdoc-type-cast-param-default/input.js @@ -0,0 +1,11 @@ +const row_template = ($$anchor, row = $.noop) => { + row; +}; + +const f = ({ a } = {}) => a; + +function g(x = 1) { + return x; +} + +const value = /** @type {number} */ (computed); From 15f428543186055d0bd8805cefe18cf3664d6634 Mon Sep 17 00:00:00 2001 From: koding88 Date: Thu, 27 Aug 2026 15:31:46 +0700 Subject: [PATCH 2/2] chore: add changeset --- .changeset/fix-jsdoc-cast-param-bindings.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-jsdoc-cast-param-bindings.md diff --git a/.changeset/fix-jsdoc-cast-param-bindings.md b/.changeset/fix-jsdoc-cast-param-bindings.md new file mode 100644 index 0000000..0327396 --- /dev/null +++ b/.changeset/fix-jsdoc-cast-param-bindings.md @@ -0,0 +1,5 @@ +--- +'esrap': patch +--- + +fix: don't treat JSDoc `@type` comments re-anchored to parameter defaults or named function ids as type casts