From e3e2aa61e569f92308361f9d5cce03f5efb280cd Mon Sep 17 00:00:00 2001 From: ziho Date: Fri, 23 Jan 2026 12:51:27 +0900 Subject: [PATCH 1/6] fix: enhance JSDoc parameter parsing for deeply nested properties --- .../cli/src/core/parser/jsdoc/jsdoc-parser.ts | 84 +++++++++++++------ .../core/parser/jsdoc/jsdoc-parser.spec.ts | 39 +++++++++ 2 files changed, 99 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts index 53b0dd5..8c78576 100644 --- a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts +++ b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts @@ -1,13 +1,14 @@ -import { JSDoc } from "ts-morph"; import * as commentParser from "comment-parser"; +import { isEmpty } from "es-toolkit/compat"; +import { JSDoc } from "ts-morph"; import { - ParsedJSDoc, + ExampleData, ParameterData, + ParsedJSDoc, ReturnData, + SeeData, ThrowsData, TypedefData, - ExampleData, - SeeData, VersionData, } from "../../types/parser.types.js"; @@ -140,28 +141,63 @@ export class JSDocParser { paramMap: Map, parameters: ParameterData[] ): void { - const parts = param.name.split("."); - const parentName = parts[0]; - if (!parentName) return; - - let parent = paramMap.get(parentName); - - if (!parent) { - parent = { - name: parentName, - type: "Object", - description: "", - required: true, - defaultValue: undefined, - nested: [], - }; - paramMap.set(parentName, parent); - parameters.push(parent); + const [rootName, ...nestedPath] = param.name.split("."); + if (rootName == null || isEmpty(nestedPath)) { + return; + } + + const existingRoot = paramMap.get(rootName); + const rootParam = existingRoot ?? this.createPlaceholderObjectParam(rootName); + if (existingRoot == null) { + paramMap.set(rootName, rootParam); + parameters.push(rootParam); + } + + this.insertParamAtPath({ + into: rootParam.nested ?? [], + path: nestedPath, + param, + }); + } + + private insertParamAtPath({ into, path, param }: { + into: ParameterData[]; + path: string[]; + param: ParameterData; + }): void { + const [currentSegment, ...remainingPath] = path; + if (currentSegment == null) { + return; + } + + if (isEmpty(remainingPath)) { + into.push({ ...param, name: currentSegment, nested: [] }); + + return; + } + + const existingChild = into.find((n) => n.name === currentSegment); + const child = existingChild ?? this.createPlaceholderObjectParam(currentSegment); + if (existingChild == null) { + into.push(child); } - const nestedParam = { ...param, name: parts.slice(1).join(".") }; - parent.nested = parent.nested || []; - parent.nested.push(nestedParam); + this.insertParamAtPath({ + into: child.nested ?? [], + path: remainingPath, + param, + }); + } + + private createPlaceholderObjectParam(name: string): ParameterData { + return { + name, + type: "Object", + description: "", + required: true, + defaultValue: undefined, + nested: [], + }; } private extractReturns(block: commentParser.Block): ReturnData | undefined { diff --git a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts index 83fb89a..f42f03c 100644 --- a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts +++ b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts @@ -261,6 +261,45 @@ export function processOptions(options: { name: string; age: number; active?: bo expect(result.parameters?.[0].nested?.[2].defaultValue).toBe("true"); }); + it("should parse deeply nested parameter properties (3+ levels)", () => { + const sourceFile = project.createSourceFile( + "test.ts", + ` +/** + * @param {Object} config - Configuration object + * @param {Object} config.database - Database settings + * @param {Object} config.database.connection - Connection settings + * @param {string} config.database.connection.host - Database host + * @param {number} config.database.connection.port - Database port + * @param {string} config.database.name - Database name + */ +export function configure(config: any): void { + // implementation +} + ` + ); + + const func = sourceFile.getFunction("configure")!; + const jsDoc = func.getJsDocs()[0]!; + + const result = parser.parse(jsDoc); + + expect(result.parameters).toMatchObject([ + { + name: "config", + nested: [ + { + name: "database", + nested: [ + { name: "connection", nested: [{ name: "host" }, { name: "port" }] }, + { name: "name" }, + ], + }, + ], + }, + ]); + }); + it("should handle JSDoc without any documentation", () => { const result = parser.parse(null as unknown as JSDoc); From cf3e53906f50d0aeba8936488619d36a197657a7 Mon Sep 17 00:00:00 2001 From: ziho Date: Mon, 26 Jan 2026 23:11:40 +0900 Subject: [PATCH 2/6] add test cases --- .../core/parser/jsdoc/jsdoc-parser.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts index f42f03c..c51f378 100644 --- a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts +++ b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts @@ -300,6 +300,87 @@ export function configure(config: any): void { ]); }); + it("should parse deeply nested parameters regardless of declaration order", () => { + const sourceFile = project.createSourceFile( + "test.ts", + ` +/** + * @param {Object} config.database.connection - Connection settings + * @param {string} config.database.connection.host - Database host + * @param {number} config.database.connection.port - Database port + * @param {string} config.database.name - Database name + * @param {Object} config.database - Database settings + * @param {Object} config - Configuration object + */ +export function configure(config: any): void { + // implementation +} + ` + ); + + const func = sourceFile.getFunction("configure")!; + const jsDoc = func.getJsDocs()[0]!; + + const result = parser.parse(jsDoc); + + expect(result.parameters).toHaveLength(1); + expect(result.parameters).toMatchObject([ + { + name: "config", + type: "Object", + nested: [ + { + name: "database", + type: "Object", + nested: [ + { + name: "connection", + type: "Object", + nested: [{ name: "host" }, { name: "port" }], + }, + { name: "name" }, + ], + }, + ], + }, + ]); + }); + + it("should create placeholder for parent when only nested parameter is defined", () => { + const sourceFile = project.createSourceFile( + "test.ts", + ` +/** + * @param {Object} config.database - Database settings + * @param {string} config.database.host - Database host + */ +export function configure(config: any): void { + // implementation +} + ` + ); + + const func = sourceFile.getFunction("configure")!; + const jsDoc = func.getJsDocs()[0]!; + + const result = parser.parse(jsDoc); + + expect(result.parameters).toHaveLength(1); + expect(result.parameters?.[0]).toMatchObject({ + name: "config", + type: "Object", + description: "", + nested: [ + { + name: "database", + type: "Object", + description: "- Database settings", + nested: [{ name: "host", type: "string" }], + }, + ], + }); + }); + it("should handle JSDoc without any documentation", () => { const result = parser.parse(null as unknown as JSDoc); From e7ae3ad47f27fd8328e2fb104b0534e7e7d56fbc Mon Sep 17 00:00:00 2001 From: ziho Date: Mon, 26 Jan 2026 23:12:15 +0900 Subject: [PATCH 3/6] refactor: mutable -> immutable --- .../cli/src/core/parser/jsdoc/jsdoc-parser.ts | 120 +++++++++--------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts index 8c78576..a1a8b1f 100644 --- a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts +++ b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts @@ -1,5 +1,5 @@ import * as commentParser from "comment-parser"; -import { isEmpty } from "es-toolkit/compat"; +import { isEmpty, sortBy } from "es-toolkit/compat"; import { JSDoc } from "ts-morph"; import { ExampleData, @@ -105,91 +105,93 @@ export class JSDocParser { private extractParameters(block: commentParser.Block): ParameterData[] { const paramTags = block.tags.filter(tag => tag.tag === "param"); - const parameters: ParameterData[] = []; - const paramMap = new Map(); - - for (const tag of paramTags) { - const param = this.parseParameterTag(tag); - if (!param) continue; - - if (param.name.includes(".")) { - this.handleNestedParameter(param, paramMap, parameters); - } else { - paramMap.set(param.name, param); - parameters.push(param); + const sortedTags = this.sortByNestingDepth(paramTags); + + return sortedTags.reduce((parameters, tag) => { + const parameter = this.parseParameterTag(tag); + + if (this.isNestedParameter(parameter)) { + return this.addNestedParameter(parameters, parameter); } - } - return parameters; + return [...parameters, parameter]; + }, []); } - private parseParameterTag(tag: commentParser.Spec): ParameterData { - const { type, name, description, optional, default: defaultValue } = tag; + private sortByNestingDepth(tags: commentParser.Spec[]): commentParser.Spec[] { + return sortBy(tags, [tag => tag.name.split(".").length]); + } + private parseParameterTag(tag: commentParser.Spec): ParameterData { return { - name, - type, - description, - required: !optional, - defaultValue: defaultValue, + name: tag.name, + type: tag.type, + description: tag.description, + required: !tag.optional, + defaultValue: tag.default, nested: [], }; } - private handleNestedParameter( - param: ParameterData, - paramMap: Map, - parameters: ParameterData[] - ): void { - const [rootName, ...nestedPath] = param.name.split("."); - if (rootName == null || isEmpty(nestedPath)) { - return; + private isNestedParameter(param: ParameterData) { + return param.name.includes("."); + } + + private addNestedParameter(parameters: ParameterData[], parameter: ParameterData): ParameterData[] { + const [rootName, ...remainingPath] = parameter.name.split("."); + if (rootName == null) { + return parameters; } - const existingRoot = paramMap.get(rootName); - const rootParam = existingRoot ?? this.createPlaceholderObjectParam(rootName); + const existingRoot = parameters.find(x => x.name === rootName); if (existingRoot == null) { - paramMap.set(rootName, rootParam); - parameters.push(rootParam); + const newRoot = this.createPlaceholderObjectParameter(rootName); + + return [...parameters, this.insertNestedParameter(newRoot, remainingPath, parameter)]; } - this.insertParamAtPath({ - into: rootParam.nested ?? [], - path: nestedPath, - param, - }); + const updatedRoot = this.insertNestedParameter(existingRoot, remainingPath, parameter); + return parameters.map(x => (x.name === rootName ? updatedRoot : x)); } - private insertParamAtPath({ into, path, param }: { - into: ParameterData[]; - path: string[]; - param: ParameterData; - }): void { - const [currentSegment, ...remainingPath] = path; - if (currentSegment == null) { - return; + private insertNestedParameter( + parent: ParameterData, + remainingPath: string[], + parameter: ParameterData + ): ParameterData { + const [name, ...restPath] = remainingPath; + if (name == null) { + return parent; } - if (isEmpty(remainingPath)) { - into.push({ ...param, name: currentSegment, nested: [] }); + const nested = parent.nested ?? []; + const existingNested = nested.find(x => x.name === name); - return; + if (isEmpty(restPath)) { + const leafParameter = { ...parameter, name, nested: existingNested?.nested ?? [] }; + + return { ...parent, nested: this.upsertNested(nested, leafParameter) }; } - const existingChild = into.find((n) => n.name === currentSegment); - const child = existingChild ?? this.createPlaceholderObjectParam(currentSegment); + const intermediateParameter = this.insertNestedParameter( + existingNested ?? this.createPlaceholderObjectParameter(name), + restPath, + parameter + ); + + return { ...parent, nested: this.upsertNested(nested, intermediateParameter) }; + } + + private upsertNested(nested: ParameterData[], newParameter: ParameterData): ParameterData[] { + const existingChild = nested.find(child => child.name === newParameter.name); if (existingChild == null) { - into.push(child); + return [...nested, newParameter]; } - this.insertParamAtPath({ - into: child.nested ?? [], - path: remainingPath, - param, - }); + return nested.map(x => (x.name === newParameter.name ? newParameter : x)); } - private createPlaceholderObjectParam(name: string): ParameterData { + private createPlaceholderObjectParameter(name: string): ParameterData { return { name, type: "Object", From a3d58cda54292f66114a245c455b8190e6965a45 Mon Sep 17 00:00:00 2001 From: ziho Date: Mon, 26 Jan 2026 23:19:50 +0900 Subject: [PATCH 4/6] fix: duplicate jsdoc tag case --- packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts index a1a8b1f..c7abef4 100644 --- a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts +++ b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts @@ -105,7 +105,8 @@ export class JSDocParser { private extractParameters(block: commentParser.Block): ParameterData[] { const paramTags = block.tags.filter(tag => tag.tag === "param"); - const sortedTags = this.sortByNestingDepth(paramTags); + const uniqueTags = this.removeDuplicateJSDocTags(paramTags); + const sortedTags = this.sortByNestingDepth(uniqueTags); return sortedTags.reduce((parameters, tag) => { const parameter = this.parseParameterTag(tag); @@ -122,6 +123,15 @@ export class JSDocParser { return sortBy(tags, [tag => tag.name.split(".").length]); } + private removeDuplicateJSDocTags(tags: commentParser.Spec[]): commentParser.Spec[] { + const uniqueByName = new Map(); + for (const tag of tags) { + uniqueByName.set(tag.name, tag); + } + + return [...uniqueByName.values()]; + } + private parseParameterTag(tag: commentParser.Spec): ParameterData { return { name: tag.name, From 438196bc6ede270a39758ba45255b1d0333e4abd Mon Sep 17 00:00:00 2001 From: ziho Date: Mon, 26 Jan 2026 23:26:55 +0900 Subject: [PATCH 5/6] fix: format --- .../cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts index c51f378..a5b97a6 100644 --- a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts +++ b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts @@ -290,10 +290,7 @@ export function configure(config: any): void { nested: [ { name: "database", - nested: [ - { name: "connection", nested: [{ name: "host" }, { name: "port" }] }, - { name: "name" }, - ], + nested: [{ name: "connection", nested: [{ name: "host" }, { name: "port" }] }, { name: "name" }], }, ], }, From 5931920bf6ba5d57c99e34dee9dd8f012235001e Mon Sep 17 00:00:00 2001 From: ziho Date: Tue, 27 Jan 2026 17:27:51 +0900 Subject: [PATCH 6/6] refactor: simplify logic --- .../cli/src/core/parser/jsdoc/jsdoc-parser.ts | 119 +++++++----------- 1 file changed, 46 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts index c7abef4..9e28c6e 100644 --- a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts +++ b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts @@ -1,5 +1,5 @@ import * as commentParser from "comment-parser"; -import { isEmpty, sortBy } from "es-toolkit/compat"; +import { initial, uniq } from "es-toolkit"; import { JSDoc } from "ts-morph"; import { ExampleData, @@ -105,110 +105,83 @@ export class JSDocParser { private extractParameters(block: commentParser.Block): ParameterData[] { const paramTags = block.tags.filter(tag => tag.tag === "param"); - const uniqueTags = this.removeDuplicateJSDocTags(paramTags); - const sortedTags = this.sortByNestingDepth(uniqueTags); + const uniqueParamTags = this.removeDuplicateParamTags(paramTags); + const allParamTags = [...uniqueParamTags, ...this.createMissingParentParams(uniqueParamTags)]; + const topLevelParams = allParamTags.filter(paramTag => !paramTag.name.includes(".")); - return sortedTags.reduce((parameters, tag) => { - const parameter = this.parseParameterTag(tag); - - if (this.isNestedParameter(parameter)) { - return this.addNestedParameter(parameters, parameter); - } - - return [...parameters, parameter]; - }, []); + return topLevelParams.map(paramTag => this.paramTagToParameterData(paramTag, allParamTags)); } - private sortByNestingDepth(tags: commentParser.Spec[]): commentParser.Spec[] { - return sortBy(tags, [tag => tag.name.split(".").length]); - } - - private removeDuplicateJSDocTags(tags: commentParser.Spec[]): commentParser.Spec[] { + // only keep the last tag with the same name + private removeDuplicateParamTags(paramTags: commentParser.Spec[]): commentParser.Spec[] { const uniqueByName = new Map(); - for (const tag of tags) { - uniqueByName.set(tag.name, tag); + for (const paramTag of paramTags) { + uniqueByName.set(paramTag.name, paramTag); } return [...uniqueByName.values()]; } - private parseParameterTag(tag: commentParser.Spec): ParameterData { + private paramTagToParameterData(paramTag: commentParser.Spec, allParamTags: commentParser.Spec[]): ParameterData { return { - name: tag.name, - type: tag.type, - description: tag.description, - required: !tag.optional, - defaultValue: tag.default, - nested: [], + name: this.getParamShortName(paramTag.name), + type: paramTag.type, + description: paramTag.description, + required: !paramTag.optional, + defaultValue: paramTag.default, + nested: this.buildNestedParams(paramTag.name, allParamTags), }; } - private isNestedParameter(param: ParameterData) { - return param.name.includes("."); - } - - private addNestedParameter(parameters: ParameterData[], parameter: ParameterData): ParameterData[] { - const [rootName, ...remainingPath] = parameter.name.split("."); - if (rootName == null) { - return parameters; - } + private buildNestedParams(parentParamName: string, allParamTags: commentParser.Spec[]): ParameterData[] { + const childParams = allParamTags.filter(paramTag => this.getParentParamName(paramTag.name) === parentParamName); - const existingRoot = parameters.find(x => x.name === rootName); - if (existingRoot == null) { - const newRoot = this.createPlaceholderObjectParameter(rootName); + return childParams.map(paramTag => this.paramTagToParameterData(paramTag, allParamTags)); + } - return [...parameters, this.insertNestedParameter(newRoot, remainingPath, parameter)]; - } + private createMissingParentParams(paramTags: commentParser.Spec[]): commentParser.Spec[] { + const existingParamNames = paramTags.map(paramTag => paramTag.name); + const ancestorParamNames = paramTags.flatMap(paramTag => this.getParamAncestorNames(paramTag.name)); + const missingParentNames = uniq(ancestorParamNames.filter(name => !existingParamNames.includes(name))); - const updatedRoot = this.insertNestedParameter(existingRoot, remainingPath, parameter); - return parameters.map(x => (x.name === rootName ? updatedRoot : x)); + return missingParentNames.map(name => this.createParamPlaceholderTag(name)); } - private insertNestedParameter( - parent: ParameterData, - remainingPath: string[], - parameter: ParameterData - ): ParameterData { - const [name, ...restPath] = remainingPath; - if (name == null) { - return parent; + private getParamAncestorNames(paramName: string): string[] { + const parentName = this.getParentParamName(paramName); + if (parentName == null) { + return []; } - const nested = parent.nested ?? []; - const existingNested = nested.find(x => x.name === name); - - if (isEmpty(restPath)) { - const leafParameter = { ...parameter, name, nested: existingNested?.nested ?? [] }; + return [parentName, ...this.getParamAncestorNames(parentName)]; + } - return { ...parent, nested: this.upsertNested(nested, leafParameter) }; + private getParentParamName(paramName: string): string | null { + const segments = paramName.split("."); + const hasParent = segments.length > 1; + if (hasParent) { + return initial(segments).join("."); } - const intermediateParameter = this.insertNestedParameter( - existingNested ?? this.createPlaceholderObjectParameter(name), - restPath, - parameter - ); - - return { ...parent, nested: this.upsertNested(nested, intermediateParameter) }; + return null; } - private upsertNested(nested: ParameterData[], newParameter: ParameterData): ParameterData[] { - const existingChild = nested.find(child => child.name === newParameter.name); - if (existingChild == null) { - return [...nested, newParameter]; - } + private getParamShortName(paramName: string) { + const segments = paramName.split("."); - return nested.map(x => (x.name === newParameter.name ? newParameter : x)); + return segments.at(-1) ?? paramName; } - private createPlaceholderObjectParameter(name: string): ParameterData { + private createParamPlaceholderTag(name: string): commentParser.Spec { return { + tag: "param", name, type: "Object", description: "", - required: true, - defaultValue: undefined, - nested: [], + optional: false, + default: undefined, + source: [], + problems: [], }; }