From 75a38d4909db7c63918e68047b738fd19aa007b6 Mon Sep 17 00:00:00 2001 From: tcernicovad1 Date: Fri, 25 Sep 2026 22:54:06 +0100 Subject: [PATCH 1/2] Add getJSDocCommentsAndTags back with functionality of 6.0 --- packages/typescript/src/ast/jsdoc.ts | 28 ++-- packages/typescript/test/async/api.test.ts | 154 ++++++++++++++++++++- packages/typescript/test/sync/api.test.ts | 118 ++++++++++++++++ 3 files changed, 288 insertions(+), 12 deletions(-) diff --git a/packages/typescript/src/ast/jsdoc.ts b/packages/typescript/src/ast/jsdoc.ts index 4047923fa2e4c..8a90f3989786c 100644 --- a/packages/typescript/src/ast/jsdoc.ts +++ b/packages/typescript/src/ast/jsdoc.ts @@ -34,7 +34,7 @@ import { /** Get all JSDoc tags related to a node, including those on parent nodes. */ export function getJSDocTags(node: Node): readonly JSDocTag[] { - return getJSDocCommentsAndTags(node); + return getJSDocCommentsAndTags(node).flatMap(j => isJSDoc(j) ? j.tags ?? [] : j); } /** Gets all JSDoc tags that match a specified predicate */ @@ -95,21 +95,27 @@ function ownsJSDocTag(hostNode: Node, tag: JSDocTag): boolean { || tag.parent.parent === hostNode; } -function filterOwnedJSDocTags(hostNode: Node, comments: JSDoc[]): JSDocTag[] { - const result: JSDocTag[] = []; +function filterOwnedJSDocTags(hostNode: Node, comments: JSDoc[]): (JSDoc | JSDocTag)[] { + const result: (JSDoc | JSDocTag)[] = []; const lastJSDoc = comments[comments.length - 1]; for (const jsDoc of comments) { - if (!jsDoc.tags) { - continue; - } if (jsDoc === lastJSDoc) { - for (const tag of jsDoc.tags) { - if (ownsJSDocTag(hostNode, tag)) { - result.push(tag); + const onlyOwnTags = jsDoc.tags?.every(t => ownsJSDocTag(hostNode, t)) ?? true; + if (!onlyOwnTags && jsDoc.tags) { + for (const tag of jsDoc.tags) { + if (ownsJSDocTag(hostNode, tag)) { + result.push(tag); + } } } + else { + result.push(jsDoc); + } } else { + if (!jsDoc.tags) { + continue; + } // Tags from earlier comments only contribute their `@overload` tags. for (const tag of jsDoc.tags) { if (isJSDocOverloadTag(tag)) { @@ -181,8 +187,8 @@ function getNextJSDocCommentLocation(node: Node): Node | undefined { return undefined; } -function getJSDocCommentsAndTags(hostNode: Node): JSDocTag[] { - const result: JSDocTag[] = []; +export function getJSDocCommentsAndTags(hostNode: Node): (JSDoc | JSDocTag)[] { + const result: (JSDoc | JSDocTag)[] = []; // Pull parameter comments from a declaring initializer (e.g. `var x = function () {}`). if (isVariableLike(hostNode)) { const initializer = (hostNode as { initializer?: Node; }).initializer; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 3226d409e17a8..2db40fb72fbe2 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3,8 +3,10 @@ import { cast, escapeLeadingUnderscores, type Expression, + getJSDocCommentsAndTags, getJSDocTags, getSynthesizedDeepClone, + getTextOfJSDocComment, InternalSymbolName, isCallExpression, isExpressionStatement, @@ -12,6 +14,7 @@ import { isIdentifier, isImportDeclaration, isInterfaceDeclaration, + isJSDoc, isJSDocParameterTag, isModuleDeclaration, isNamedImports, @@ -5739,6 +5742,155 @@ const cast = /** @type {number} */ (someValue); }); }); +describe("ast - getJSDocCommentsAndTags", { concurrency }, () => { + test("returns the whole JSDoc comment when the host node owns all of its tags", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * The answer to everything. + * @deprecated use theAnswer instead + */ +export const answer = 42; +`, + }); + + const snapshot = await api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const answer = [...sourceFile.statements].filter(isVariableStatement)[0].declarationList.declarations[0]; + assert.ok(answer); + + // Every tag on `answer`'s JSDoc comment is owned by `answer`, so the whole + // comment is returned as a single entry rather than its individual tags. + const commentsAndTags = getJSDocCommentsAndTags(answer); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + + // Flattening still yields the same tags as getJSDocTags. + assert.deepEqual(getJSDocTags(answer).map(t => t.tagName.text), ["deprecated"]); + }); + + test("returns the whole JSDoc comment when it has a description but no tags", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * The answer to everything. + */ +export const answer = 42; +`, + }); + + const snapshot = await api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const answer = [...sourceFile.statements].filter(isVariableStatement)[0].declarationList.declarations[0]; + assert.ok(answer); + + // With no tags to discard, the JSDoc node (and its description) is + // still returned rather than an empty tag list. + const commentsAndTags = getJSDocCommentsAndTags(answer); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + assert.equal(getTextOfJSDocComment(commentsAndTags[0].comment), "The answer to everything."); + + assert.deepEqual(getJSDocTags(answer), []); + }); + + test("returns individual tags when the host node does not own all of them", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { allowJs: true, checkJs: true } }), + "/src/main.js": ` +/** @type {string} */ +const value = "hello"; + +const cast = /** @type {number} */ (someValue); +`, + }); + + const snapshot = await api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.js"); + assert.ok(sourceFile); + const statements = [...sourceFile.statements].filter(isVariableStatement); + + // The @type cast tag is not owned by `castDecl`, so no tags qualify and + // the JSDoc comment is not returned at all. + const castDecl = statements[1].declarationList.declarations[0]; + assert.deepEqual(getJSDocCommentsAndTags(castDecl), []); + }); + + test("returns an individual JSDocTag for a parameter matched by name", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { allowJs: true, checkJs: true } }), + "/src/main.js": ` +/** + * @param {string} name the name to measure + * @returns {number} the length + */ +var measure = function (name) { + return name.length; +}; +`, + }); + + const snapshot = await api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.js"); + assert.ok(sourceFile); + const variable = sourceFile.statements.find(isVariableStatement); + assert.ok(variable); + const funcExpr = variable.declarationList.declarations[0].initializer; + assert.ok(funcExpr); + const param = (funcExpr as unknown as { parameters: NodeArray; }).parameters[0]; + + // A Parameter host node returns its matching @param tag directly as a + // JSDocTag, not wrapped in the enclosing JSDoc comment. + const commentsAndTags = getJSDocCommentsAndTags(param); + assert.equal(commentsAndTags.length, 1); + const [tag] = commentsAndTags; + assert.ok(isJSDocParameterTag(tag)); + assert.ok(isIdentifier(tag.name)); + assert.equal(tag.name.text, "name"); + }); + + test("returns the whole JSDoc comment when the host node owns multiple tags", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * Adds two numbers. + * @param a the first number + * @param b the second number + * @returns the sum + */ +export function add(a: number, b: number): number { + return a + b; +} +`, + }); + + const snapshot = await api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const add = [...sourceFile.statements].find(isFunctionDeclaration); + assert.ok(add); + + // All three tags on `add`'s JSDoc comment are owned by `add`, so the + // whole comment is returned as a single entry rather than three tags. + const commentsAndTags = getJSDocCommentsAndTags(add); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + + // Flattening still yields all three tags, in order, via getJSDocTags. + assert.deepEqual(getJSDocTags(add).map(t => t.tagName.text), ["param", "param", "returns"]); + }); +}); + describe("Checker - getPropertiesOfType", { concurrency }, () => { test("returns properties of an object type", async () => { await using api = spawnAPI({ @@ -8413,7 +8565,7 @@ describe("Timing", { concurrency }, () => { assert.equal(info.totals.sourceFilesFetched, 1); assert.ok( info.totals.nodesMaterialized > 0 - && info.totals.nodesMaterialized <= info.totals.nodesFetched, + && info.totals.nodesMaterialized <= info.totals.nodesFetched, "materialized nodes should be in (0, nodesFetched]", ); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index aaa550e6ba456..ecf4be80480c0 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -11,8 +11,10 @@ import { cast, escapeLeadingUnderscores, type Expression, + getJSDocCommentsAndTags, getJSDocTags, getSynthesizedDeepClone, + getTextOfJSDocComment, InternalSymbolName, isCallExpression, isExpressionStatement, @@ -20,6 +22,7 @@ import { isIdentifier, isImportDeclaration, isInterfaceDeclaration, + isJSDoc, isJSDocParameterTag, isModuleDeclaration, isNamedImports, @@ -5573,6 +5576,121 @@ const cast = /** @type {number} */ (someValue); }); }); +describe("ast - getJSDocCommentsAndTags", { concurrency }, () => { + test("returns the whole JSDoc comment when the host node owns all of its tags", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * The answer to everything. + * @deprecated use theAnswer instead + */ +export const answer = 42; +`, + }); + + const snapshot = api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const answer = [...sourceFile.statements].filter(isVariableStatement)[0].declarationList.declarations[0]; + assert.ok(answer); + + // Every tag on `answer`'s JSDoc comment is owned by `answer`, so the whole + // comment is returned as a single entry rather than its individual tags. + const commentsAndTags = getJSDocCommentsAndTags(answer); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + + // Flattening still yields the same tags as getJSDocTags. + assert.deepEqual(getJSDocTags(answer).map(t => t.tagName.text), ["deprecated"]); + }); + + test("returns the whole JSDoc comment when it has a description but no tags", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * The answer to everything. + */ +export const answer = 42; +`, + }); + + const snapshot = api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const answer = [...sourceFile.statements].filter(isVariableStatement)[0].declarationList.declarations[0]; + assert.ok(answer); + + // With no tags to discard, the JSDoc node (and its description) is + // still returned rather than an empty tag list. + const commentsAndTags = getJSDocCommentsAndTags(answer); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + assert.equal(getTextOfJSDocComment(commentsAndTags[0].comment), "The answer to everything."); + + assert.deepEqual(getJSDocTags(answer), []); + }); + + test("returns individual tags when the host node does not own all of them", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { allowJs: true, checkJs: true } }), + "/src/main.js": ` +/** @type {string} */ +const value = "hello"; + +const cast = /** @type {number} */ (someValue); +`, + }); + + const snapshot = api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.js"); + assert.ok(sourceFile); + const statements = [...sourceFile.statements].filter(isVariableStatement); + + // The @type cast tag is not owned by `castDecl`, so no tags qualify and + // the JSDoc comment is not returned at all. + const castDecl = statements[1].declarationList.declarations[0]; + assert.deepEqual(getJSDocCommentsAndTags(castDecl), []); + }); + + test("returns the whole JSDoc comment when the host node owns multiple tags", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": ` +/** + * Adds two numbers. + * @param a the first number + * @param b the second number + * @returns the sum + */ +export function add(a: number, b: number): number { + return a + b; +} +`, + }); + + const snapshot = api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.ts"); + assert.ok(sourceFile); + const add = [...sourceFile.statements].find(isFunctionDeclaration); + assert.ok(add); + + // All three tags on `add`'s JSDoc comment are owned by `add`, so the + // whole comment is returned as a single entry rather than three tags. + const commentsAndTags = getJSDocCommentsAndTags(add); + assert.equal(commentsAndTags.length, 1); + assert.ok(isJSDoc(commentsAndTags[0])); + + // Flattening still yields all three tags, in order, via getJSDocTags. + assert.deepEqual(getJSDocTags(add).map(t => t.tagName.text), ["param", "param", "returns"]); + }); +}); + describe("Checker - getPropertiesOfType", { concurrency }, () => { test("returns properties of an object type", () => { using api = spawnAPI({ From eaf2802a5152baa7a9e777f9ebe72aa6bbb025ec Mon Sep 17 00:00:00 2001 From: tcernicovad1 Date: Mon, 28 Sep 2026 17:39:12 +0100 Subject: [PATCH 2/2] Fix format an regenerate sync --- packages/typescript/test/async/api.test.ts | 2 +- packages/typescript/test/sync/api.test.ts | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 2db40fb72fbe2..81a17bb96aacf 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -8565,7 +8565,7 @@ describe("Timing", { concurrency }, () => { assert.equal(info.totals.sourceFilesFetched, 1); assert.ok( info.totals.nodesMaterialized > 0 - && info.totals.nodesMaterialized <= info.totals.nodesFetched, + && info.totals.nodesMaterialized <= info.totals.nodesFetched, "materialized nodes should be in (0, nodesFetched]", ); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index ecf4be80480c0..2f5edad5daea1 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -5657,6 +5657,40 @@ const cast = /** @type {number} */ (someValue); assert.deepEqual(getJSDocCommentsAndTags(castDecl), []); }); + test("returns an individual JSDocTag for a parameter matched by name", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { allowJs: true, checkJs: true } }), + "/src/main.js": ` +/** + * @param {string} name the name to measure + * @returns {number} the length + */ +var measure = function (name) { + return name.length; +}; +`, + }); + + const snapshot = api.createSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/main.js"); + assert.ok(sourceFile); + const variable = sourceFile.statements.find(isVariableStatement); + assert.ok(variable); + const funcExpr = variable.declarationList.declarations[0].initializer; + assert.ok(funcExpr); + const param = (funcExpr as unknown as { parameters: NodeArray; }).parameters[0]; + + // A Parameter host node returns its matching @param tag directly as a + // JSDocTag, not wrapped in the enclosing JSDoc comment. + const commentsAndTags = getJSDocCommentsAndTags(param); + assert.equal(commentsAndTags.length, 1); + const [tag] = commentsAndTags; + assert.ok(isJSDocParameterTag(tag)); + assert.ok(isIdentifier(tag.name)); + assert.equal(tag.name.text, "name"); + }); + test("returns the whole JSDoc comment when the host node owns multiple tags", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }),