From 7a2e561e1136a1b4b7322459bcc6cd74df5bedfc Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Thu, 15 May 2025 20:40:45 -0700 Subject: [PATCH] fix(jsdoc-parse): use the correct scopes when exported objects have properties Previously, JSDoc parsed the test code and found the namepath `module:icecream.FLAVORS` (correct), but also `module:icecream~FLAVORS.VANILLA` (wrong, because `FLAVORS` is a static member of the module, not an inner member). With this change, JSDoc should consistently identify `FLAVORS` as a static member of `module:icecream`. --- packages/jsdoc-parse/lib/handlers.js | 48 ++++++++++++++----- packages/jsdoc/test/fixtures/moduleexport.js | 13 +++++ .../test/specs/documentation/moduleexport.js | 27 +++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 packages/jsdoc/test/fixtures/moduleexport.js create mode 100644 packages/jsdoc/test/specs/documentation/moduleexport.js diff --git a/packages/jsdoc-parse/lib/handlers.js b/packages/jsdoc-parse/lib/handlers.js index 3b1b47e5..ecae3703 100644 --- a/packages/jsdoc-parse/lib/handlers.js +++ b/packages/jsdoc-parse/lib/handlers.js @@ -115,12 +115,34 @@ function isModuleExports(module, doclet) { return module.longname === doclet.name; } +/** + * Finds an AST node's closest ancestor with the specified type. + * + * @private + * @param {Object} node - The AST node. + * @param {(module:@jsdoc/ast.Syntax|string)} ancestorType - The type of ancestor node to find. + * @return {?Object} The closest ancestor with the specified type. + */ +function findAncestorWithType(node, ancestorType) { + let parent = node?.parent; + + while (parent) { + if (parent.type === ancestorType) { + return parent; + } + + parent = parent.parent; + } + + return null; +} + function setModuleScopeMemberOf(parser, doclet) { const moduleInfo = getModule(); let parentDoclet; let skipMemberof; - // Handle CommonJS module symbols that are _not_ assigned to `module.exports`. + // Handle module symbols, excluding CommonJS `module.exports`. if (moduleInfo && !isModuleExports(moduleInfo, doclet)) { if (!doclet.scope) { // is this a method definition? if so, we usually get the scope from the node directly @@ -141,11 +163,11 @@ function setModuleScopeMemberOf(parser, doclet) { } } } - // is this something that the module exports? if so, it's a static member - else if (doclet.meta?.code?.node?.parent?.type === Syntax.ExportNamedDeclaration) { + // Is this something that the module exports? if so, it's a static member. + else if (findAncestorWithType(doclet.meta?.code?.node, Syntax.ExportNamedDeclaration)) { doclet.addTag('static'); } - // otherwise, it must be an inner member + // Otherwise, it must be an inner member. else { doclet.addTag('inner'); } @@ -153,7 +175,7 @@ function setModuleScopeMemberOf(parser, doclet) { // if the doclet isn't a memberof anything yet, and it's not a global, it must be a memberof // the current module (unless we were told to skip adding memberof) - if (!doclet.memberof && doclet.scope !== SCOPE.NAMES.GLOBAL && !skipMemberof) { + if (!skipMemberof && !doclet.memberof && doclet.scope !== SCOPE.NAMES.GLOBAL) { doclet.addTag('memberof', moduleInfo.longname); } } @@ -180,12 +202,12 @@ function addDoclet(parser, newDoclet) { } } -function processAlias(parser, doclet, astNode) { +function processAlias(parser, doclet, node) { let match; let memberofName; if (doclet.alias === '{@thisClass}') { - memberofName = parser.resolveThis(astNode); + memberofName = parser.resolveThis(node); // "class" refers to the owner of the prototype, not the prototype itself match = memberofName.match(PROTOTYPE_OWNER_REGEXP); @@ -204,7 +226,7 @@ function isModuleObject(doclet) { } // TODO: separate code that resolves `this` from code that resolves the module object -function findSymbolMemberof(parser, doclet, astNode, nameStartsWith, trailingPunc) { +function findSymbolMemberof(parser, doclet, node, nameStartsWith, trailingPunc) { const docletIsModuleObject = isModuleObject(doclet); let memberof = ''; let nameAndPunc; @@ -237,7 +259,7 @@ function findSymbolMemberof(parser, doclet, astNode, nameStartsWith, trailingPun doclet.addTag('name', currentModule.longname); doclet.postProcess(); } else { - memberof = parser.resolveThis(astNode); + memberof = parser.resolveThis(node); // like the following at the top level of a module: // this.foo = 1; @@ -255,7 +277,7 @@ function findSymbolMemberof(parser, doclet, astNode, nameStartsWith, trailingPun }; } -function addSymbolMemberof(parser, doclet, astNode) { +function addSymbolMemberof(parser, doclet, node) { let basename; let memberof; let memberofInfo; @@ -264,7 +286,7 @@ function addSymbolMemberof(parser, doclet, astNode) { let scopePunc; let unresolved; - if (!astNode) { + if (!node) { return; } @@ -279,7 +301,7 @@ function addSymbolMemberof(parser, doclet, astNode) { unresolved = resolveTargetRegExp.exec(doclet.name); if (unresolved) { - memberofInfo = findSymbolMemberof(parser, doclet, astNode, unresolved[1], unresolved[2]); + memberofInfo = findSymbolMemberof(parser, doclet, node, unresolved[1], unresolved[2]); memberof = memberofInfo.memberof; scopePunc = memberofInfo.scopePunc; @@ -287,7 +309,7 @@ function addSymbolMemberof(parser, doclet, astNode) { doclet.name = doclet.name ? memberof + scopePunc + doclet.name : memberof; } } else { - memberofInfo = parser.astnodeToMemberof(astNode); + memberofInfo = parser.astnodeToMemberof(node); basename = memberofInfo.basename; memberof = memberofInfo.memberof; } diff --git a/packages/jsdoc/test/fixtures/moduleexport.js b/packages/jsdoc/test/fixtures/moduleexport.js new file mode 100644 index 00000000..89afebf5 --- /dev/null +++ b/packages/jsdoc/test/fixtures/moduleexport.js @@ -0,0 +1,13 @@ +/** @module icecream */ + +/** + * Ice cream flavors. + * + * @enum + */ +export const FLAVORS = { + /** Vanilla. */ + VANILLA: 0, + /** Chocolate. */ + CHOCOLATE: 1, +}; diff --git a/packages/jsdoc/test/specs/documentation/moduleexport.js b/packages/jsdoc/test/specs/documentation/moduleexport.js new file mode 100644 index 00000000..efd80454 --- /dev/null +++ b/packages/jsdoc/test/specs/documentation/moduleexport.js @@ -0,0 +1,27 @@ +/* + Copyright 2025 the JSDoc Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +describe('symbols exported by an ES2015 module', () => { + const docSet = jsdoc.getDocSetFromFile('test/fixtures/moduleexport.js'); + + it('uses the correct scopes when exported objects have properties', () => { + const chocolate = docSet.getByLongname('module:icecream.FLAVORS.CHOCOLATE')[0]; + const vanilla = docSet.getByLongname('module:icecream.FLAVORS.VANILLA')[0]; + + expect(chocolate).toBeObject(); + expect(vanilla).toBeObject(); + }); +});