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`.
This commit is contained in:
Jeff Williams 2025-05-15 20:40:45 -07:00
parent 30df56712e
commit 7a2e561e11
No known key found for this signature in database
3 changed files with 75 additions and 13 deletions

View File

@ -115,12 +115,34 @@ function isModuleExports(module, doclet) {
return module.longname === doclet.name; 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) { function setModuleScopeMemberOf(parser, doclet) {
const moduleInfo = getModule(); const moduleInfo = getModule();
let parentDoclet; let parentDoclet;
let skipMemberof; 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 (moduleInfo && !isModuleExports(moduleInfo, doclet)) {
if (!doclet.scope) { if (!doclet.scope) {
// is this a method definition? if so, we usually get the scope from the node directly // 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 // Is this something that the module exports? if so, it's a static member.
else if (doclet.meta?.code?.node?.parent?.type === Syntax.ExportNamedDeclaration) { else if (findAncestorWithType(doclet.meta?.code?.node, Syntax.ExportNamedDeclaration)) {
doclet.addTag('static'); doclet.addTag('static');
} }
// otherwise, it must be an inner member // Otherwise, it must be an inner member.
else { else {
doclet.addTag('inner'); 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 // 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) // 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); 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 match;
let memberofName; let memberofName;
if (doclet.alias === '{@thisClass}') { if (doclet.alias === '{@thisClass}') {
memberofName = parser.resolveThis(astNode); memberofName = parser.resolveThis(node);
// "class" refers to the owner of the prototype, not the prototype itself // "class" refers to the owner of the prototype, not the prototype itself
match = memberofName.match(PROTOTYPE_OWNER_REGEXP); 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 // 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); const docletIsModuleObject = isModuleObject(doclet);
let memberof = ''; let memberof = '';
let nameAndPunc; let nameAndPunc;
@ -237,7 +259,7 @@ function findSymbolMemberof(parser, doclet, astNode, nameStartsWith, trailingPun
doclet.addTag('name', currentModule.longname); doclet.addTag('name', currentModule.longname);
doclet.postProcess(); doclet.postProcess();
} else { } else {
memberof = parser.resolveThis(astNode); memberof = parser.resolveThis(node);
// like the following at the top level of a module: // like the following at the top level of a module:
// this.foo = 1; // 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 basename;
let memberof; let memberof;
let memberofInfo; let memberofInfo;
@ -264,7 +286,7 @@ function addSymbolMemberof(parser, doclet, astNode) {
let scopePunc; let scopePunc;
let unresolved; let unresolved;
if (!astNode) { if (!node) {
return; return;
} }
@ -279,7 +301,7 @@ function addSymbolMemberof(parser, doclet, astNode) {
unresolved = resolveTargetRegExp.exec(doclet.name); unresolved = resolveTargetRegExp.exec(doclet.name);
if (unresolved) { if (unresolved) {
memberofInfo = findSymbolMemberof(parser, doclet, astNode, unresolved[1], unresolved[2]); memberofInfo = findSymbolMemberof(parser, doclet, node, unresolved[1], unresolved[2]);
memberof = memberofInfo.memberof; memberof = memberofInfo.memberof;
scopePunc = memberofInfo.scopePunc; scopePunc = memberofInfo.scopePunc;
@ -287,7 +309,7 @@ function addSymbolMemberof(parser, doclet, astNode) {
doclet.name = doclet.name ? memberof + scopePunc + doclet.name : memberof; doclet.name = doclet.name ? memberof + scopePunc + doclet.name : memberof;
} }
} else { } else {
memberofInfo = parser.astnodeToMemberof(astNode); memberofInfo = parser.astnodeToMemberof(node);
basename = memberofInfo.basename; basename = memberofInfo.basename;
memberof = memberofInfo.memberof; memberof = memberofInfo.memberof;
} }

View File

@ -0,0 +1,13 @@
/** @module icecream */
/**
* Ice cream flavors.
*
* @enum
*/
export const FLAVORS = {
/** Vanilla. */
VANILLA: 0,
/** Chocolate. */
CHOCOLATE: 1,
};

View File

@ -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();
});
});