jsdoc-scribe 1.0.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,427 +1,443 @@
1
- "use strict";
2
-
3
- /**
4
- * jsdoc-scribe — lib/index.js
5
- * ----------------------------------------
6
- * A PURE, deterministic, AST-based JSDoc comment generator.
7
- * No AI / LLM / external API is used anywhere — every comment line is
8
- * derived mechanically from syntax: names, modifiers, type annotations,
9
- * parameter lists, heritage clauses, enum members, etc.
10
- *
11
- * Works on plain JavaScript (.js/.jsx) AND TypeScript (.ts/.tsx) because
12
- * both are parsed with the TypeScript compiler's parser, which is a
13
- * superset parser for JS (it never type-checks, only parses syntax).
14
- *
15
- * This file exports plain functions so it can be:
16
- * 1. Driven by bin/cli.js as a command-line tool, or
17
- * 2. require()'d directly in another Node script / build pipeline:
18
- * const { processFile } = require('jsdoc-scribe');
19
- */
20
-
21
- const fs = require("fs");
22
- const path = require("path");
23
- const ts = require("typescript");
24
-
25
- const DEFAULT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];
26
- const DEFAULT_IGNORE_DIRS = new Set([
27
- "node_modules",
28
- ".git",
29
- "dist",
30
- "build",
31
- "out",
32
- "coverage",
33
- ".next",
34
- ".turbo",
35
- ".cache",
36
- ]);
37
-
38
- // ---------------------------------------------------------------------------
39
- // Low-level helpers
40
- // ---------------------------------------------------------------------------
41
-
42
- function getScriptKind(file) {
43
- switch (path.extname(file).toLowerCase()) {
44
- case ".tsx":
45
- return ts.ScriptKind.TSX;
46
- case ".ts":
47
- return ts.ScriptKind.TS;
48
- case ".jsx":
49
- return ts.ScriptKind.JSX;
50
- default:
51
- return ts.ScriptKind.JS;
52
- }
53
- }
54
-
55
- /** True if `node` already has a leading /** ... *\/ block right above it. */
56
- function hasLeadingJSDoc(sourceFile, node) {
57
- const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) || [];
58
- return ranges.some((r) => sourceFile.text.slice(r.pos, r.pos + 3) === "/**");
59
- }
60
-
61
- /**
62
- * Walk backwards from the node's first real token to find where its own
63
- * line-indentation begins. Returns the exact insertion offset + the
64
- * whitespace string to reuse for every comment line, so the comment lines
65
- * up visually with the code it documents — without re-printing/reformatting
66
- * the rest of the file.
67
- */
68
- function getIndentAndInsertionPos(sourceFile, node) {
69
- const start = node.getStart(sourceFile);
70
- const text = sourceFile.text;
71
- let i = start;
72
- while (i > 0 && (text[i - 1] === " " || text[i - 1] === "\t")) i--;
73
- return { pos: i, indent: text.slice(i, start) };
74
- }
75
-
76
- function modifiersOf(node) {
77
- const mods = (node.modifiers || []).map((m) => ts.SyntaxKind[m.kind]);
78
- return {
79
- isExported: mods.includes("ExportKeyword"),
80
- isAsync: mods.includes("AsyncKeyword"),
81
- isStatic: mods.includes("StaticKeyword"),
82
- isReadonly: mods.includes("ReadonlyKeyword"),
83
- isPrivate: mods.includes("PrivateKeyword"),
84
- isProtected: mods.includes("ProtectedKeyword"),
85
- isAbstract: mods.includes("AbstractKeyword"),
86
- };
87
- }
88
-
89
- function typeText(node) {
90
- return node && node.type ? node.type.getText() : null;
91
- }
92
-
93
- function isFunctionLikeInitializer(init) {
94
- return !!init && (init.kind === ts.SyntaxKind.ArrowFunction || init.kind === ts.SyntaxKind.FunctionExpression);
95
- }
96
-
97
- /** Purely syntactic: does this block contain a `return <value>;` at its own level (not inside a nested function)? */
98
- function hasReturnWithValue(block) {
99
- let found = false;
100
- function walk(n) {
101
- if (found) return;
102
- if (ts.isReturnStatement(n)) {
103
- if (n.expression) found = true;
104
- return;
105
- }
106
- if (ts.isFunctionLike(n)) return; // don't attribute a nested closure's return to the outer function
107
- ts.forEachChild(n, walk);
108
- }
109
- walk(block);
110
- return found;
111
- }
112
-
113
- /**
114
- * Decide the @returns type with no semantics, no AI — just syntax:
115
- * 1. An explicit type annotation always wins.
116
- * 2. A concise arrow body (`x => x + 1`, no braces) always produces a value.
117
- * 3. A block body is scanned for a top-level `return <value>;`.
118
- * 4. Otherwise it's void.
119
- */
120
- function inferReturnType(fnNode) {
121
- const explicit = typeText(fnNode);
122
- if (explicit) return explicit;
123
- const body = fnNode.body;
124
- if (!body) return "void";
125
- if (body.kind !== ts.SyntaxKind.Block) return "any"; // concise arrow body
126
- return hasReturnWithValue(body) ? "any" : "void";
127
- }
128
-
129
- /** Best-effort, purely syntactic type guess from an initializer expression. No semantics, no AI. */
130
- function inferTypeFromInitializer(init) {
131
- if (!init) return "any";
132
- switch (init.kind) {
133
- case ts.SyntaxKind.StringLiteral:
134
- case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
135
- return "string";
136
- case ts.SyntaxKind.NumericLiteral:
137
- return "number";
138
- case ts.SyntaxKind.TrueKeyword:
139
- case ts.SyntaxKind.FalseKeyword:
140
- return "boolean";
141
- case ts.SyntaxKind.ArrayLiteralExpression:
142
- return "Array";
143
- case ts.SyntaxKind.ObjectLiteralExpression:
144
- return "Object";
145
- case ts.SyntaxKind.ArrowFunction:
146
- case ts.SyntaxKind.FunctionExpression:
147
- return "Function";
148
- case ts.SyntaxKind.NewExpression:
149
- return init.expression ? init.expression.getText() : "Object";
150
- default:
151
- return "any";
152
- }
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Comment builders — every line below comes ONLY from AST shape, never from
157
- // guessing "what the code means".
158
- // ---------------------------------------------------------------------------
159
-
160
- function buildParamLines(params) {
161
- return (params || []).map((p) => {
162
- const name = p.name.getText();
163
- const optional = !!p.questionToken || !!p.initializer;
164
- const type = typeText(p) || (p.initializer ? inferTypeFromInitializer(p.initializer) : "any");
165
- const label = optional ? `[${name}]` : name;
166
- return ` * @param {${type}} ${label}`;
167
- });
168
- }
169
-
170
- function buildFunctionDoc({ name, params, returnType, mods, isGenerator }) {
171
- const lines = ["/**", ` * @function ${name || "anonymous"}`];
172
- if (mods.isExported) lines.push(" * @exported");
173
- if (mods.isAsync) lines.push(" * @async");
174
- if (isGenerator) lines.push(" * @generator");
175
- lines.push(...buildParamLines(params));
176
- lines.push(` * @returns {${returnType || "void"}}`);
177
- lines.push(" */");
178
- return lines;
179
- }
180
-
181
- function buildClassDoc(node) {
182
- const name = node.name ? node.name.getText() : "AnonymousClass";
183
- const mods = modifiersOf(node);
184
- const heritage = node.heritageClauses || [];
185
- const lines = ["/**", ` * @class ${name}`];
186
- if (mods.isExported) lines.push(" * @exported");
187
- if (mods.isAbstract) lines.push(" * @abstract");
188
- for (const h of heritage) {
189
- const kw = h.token === ts.SyntaxKind.ExtendsKeyword ? "@extends" : "@implements";
190
- for (const t of h.types) lines.push(` * ${kw} ${t.getText()}`);
191
- }
192
- lines.push(" */");
193
- return lines;
194
- }
195
-
196
- function buildMethodDoc(node) {
197
- const mods = modifiersOf(node);
198
- const visibility = mods.isPrivate ? "private" : mods.isProtected ? "protected" : "public";
199
- const name = node.name.getText();
200
- const kind = ts.isGetAccessorDeclaration(node) ? "getter" : ts.isSetAccessorDeclaration(node) ? "setter" : "method";
201
- const lines = ["/**", ` * @${kind} ${name}`, ` * @${visibility}`];
202
- if (mods.isStatic) lines.push(" * @static");
203
- if (mods.isAbstract) lines.push(" * @abstract");
204
- if (mods.isAsync) lines.push(" * @async");
205
- if (node.asteriskToken) lines.push(" * @generator");
206
- lines.push(...buildParamLines(node.parameters));
207
- lines.push(` * @returns {${inferReturnType(node)}}`);
208
- lines.push(" */");
209
- return lines;
210
- }
211
-
212
- function buildMemberDoc(node) {
213
- if (ts.isConstructorDeclaration(node)) {
214
- const lines = ["/**", " * @constructor"];
215
- lines.push(...buildParamLines(node.parameters));
216
- lines.push(" */");
217
- return lines;
218
- }
219
- if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
220
- return buildMethodDoc(node);
221
- }
222
- if (ts.isPropertyDeclaration(node)) {
223
- const mods = modifiersOf(node);
224
- const visibility = mods.isPrivate ? "private" : mods.isProtected ? "protected" : "public";
225
- const name = node.name.getText();
226
- const type = typeText(node) || inferTypeFromInitializer(node.initializer);
227
- const lines = ["/**", ` * @member ${name}`, ` * @type {${type}}`, ` * @${visibility}`];
228
- if (mods.isStatic) lines.push(" * @static");
229
- if (mods.isReadonly) lines.push(" * @readonly");
230
- lines.push(" */");
231
- return lines;
232
- }
233
- return null;
234
- }
235
-
236
- function buildSingleVariableDoc(decl, isConst) {
237
- const name = decl.name.getText();
238
- const init = decl.initializer;
239
- if (isFunctionLikeInitializer(init)) {
240
- return buildFunctionDoc({
241
- name,
242
- params: init.parameters,
243
- returnType: inferReturnType(init),
244
- mods: { isAsync: (init.modifiers || []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) },
245
- isGenerator: !!init.asteriskToken,
246
- });
247
- }
248
- const type = typeText(decl) || inferTypeFromInitializer(init);
249
- return ["/**", ` * @${isConst ? "constant" : "variable"} ${name}`, ` * @type {${type}}`, " */"];
250
- }
251
-
252
- function buildVariableStatementDoc(node, isConst) {
253
- const decls = node.declarationList.declarations;
254
- if (decls.length === 1) return buildSingleVariableDoc(decls[0], isConst);
255
- const lines = ["/**"];
256
- for (const decl of decls) {
257
- const type = typeText(decl) || inferTypeFromInitializer(decl.initializer);
258
- lines.push(` * @${isConst ? "constant" : "variable"} ${decl.name.getText()} {${type}}`);
259
- }
260
- lines.push(" */");
261
- return lines;
262
- }
263
-
264
- function buildInterfaceDoc(node) {
265
- const name = node.name.getText();
266
- const lines = ["/**", ` * @interface ${name}`];
267
- for (const m of node.members) {
268
- if (ts.isPropertySignature(m) && m.name) {
269
- lines.push(` * @property {${typeText(m) || "any"}} ${m.name.getText()}`);
270
- }
271
- }
272
- lines.push(" */");
273
- return lines;
274
- }
275
-
276
- function buildTypeAliasDoc(node) {
277
- return ["/**", ` * @typedef {${node.type.getText()}} ${node.name.getText()}`, " */"];
278
- }
279
-
280
- function buildEnumDoc(node) {
281
- const members = node.members.map((m) => m.name.getText()).join(" | ");
282
- return ["/**", ` * @enum ${node.name.getText()}`, ` * @values ${members}`, " */"];
283
- }
284
-
285
- // ---------------------------------------------------------------------------
286
- // AST walk: collect {pos, text} edits, never mutating the source directly.
287
- // ---------------------------------------------------------------------------
288
-
289
- function collectEdits(sourceFile, force) {
290
- const edits = [];
291
-
292
- function addEdit(node, linesFn) {
293
- if (!force && hasLeadingJSDoc(sourceFile, node)) return;
294
- const lines = linesFn();
295
- if (!lines) return;
296
- const { pos, indent } = getIndentAndInsertionPos(sourceFile, node);
297
- const text = lines.map((l) => indent + l).join("\n") + "\n";
298
- edits.push({ pos, text });
299
- }
300
-
301
- function visit(node) {
302
- if (ts.isFunctionDeclaration(node) && node.body) {
303
- addEdit(node, () =>
304
- buildFunctionDoc({
305
- name: node.name ? node.name.getText() : null,
306
- params: node.parameters,
307
- returnType: inferReturnType(node),
308
- mods: modifiersOf(node),
309
- isGenerator: !!node.asteriskToken,
310
- }),
311
- );
312
- } else if (ts.isClassDeclaration(node)) {
313
- addEdit(node, () => buildClassDoc(node));
314
- } else if (
315
- ts.isMethodDeclaration(node) ||
316
- ts.isConstructorDeclaration(node) ||
317
- ts.isPropertyDeclaration(node) ||
318
- ts.isGetAccessorDeclaration(node) ||
319
- ts.isSetAccessorDeclaration(node)
320
- ) {
321
- addEdit(node, () => buildMemberDoc(node));
322
- } else if (ts.isPropertyAssignment(node) && isFunctionLikeInitializer(node.initializer)) {
323
- const init = node.initializer;
324
- addEdit(node, () =>
325
- buildFunctionDoc({
326
- name: node.name.getText(),
327
- params: init.parameters,
328
- returnType: inferReturnType(init),
329
- mods: { isAsync: (init.modifiers || []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) },
330
- isGenerator: !!init.asteriskToken,
331
- }),
332
- );
333
- } else if (ts.isVariableStatement(node)) {
334
- const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
335
- addEdit(node, () => buildVariableStatementDoc(node, isConst));
336
- } else if (ts.isInterfaceDeclaration(node)) {
337
- addEdit(node, () => buildInterfaceDoc(node));
338
- } else if (ts.isTypeAliasDeclaration(node)) {
339
- addEdit(node, () => buildTypeAliasDoc(node));
340
- } else if (ts.isEnumDeclaration(node)) {
341
- addEdit(node, () => buildEnumDoc(node));
342
- }
343
- ts.forEachChild(node, visit);
344
- }
345
-
346
- visit(sourceFile);
347
- return edits;
348
- }
349
-
350
- // ---------------------------------------------------------------------------
351
- // File processing
352
- // ---------------------------------------------------------------------------
353
-
354
- /**
355
- * Process a single file. Returns the number of comment blocks added.
356
- * @param {string} filePath
357
- * @param {{ write?: boolean, force?: boolean, silent?: boolean }} [options]
358
- * @returns {number}
359
- */
360
- function processFile(filePath, options = {}) {
361
- const { write = false, force = false, silent = false } = options;
362
- const sourceText = fs.readFileSync(filePath, "utf8");
363
- const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
364
-
365
- const edits = collectEdits(sourceFile, force);
366
- edits.sort((a, b) => b.pos - a.pos); // bottom-to-top so earlier offsets stay valid
367
-
368
- let output = sourceText;
369
- for (const edit of edits) {
370
- output = output.slice(0, edit.pos) + edit.text + output.slice(edit.pos);
371
- }
372
-
373
- const outPath = write ? filePath : filePath.replace(/(\.[jt]sx?)$/, ".commented$1");
374
- if (edits.length > 0 || write) {
375
- fs.writeFileSync(outPath, output, "utf8");
376
- }
377
- if (!silent) {
378
- console.log(` ${filePath} -> ${outPath} (${edits.length} block${edits.length === 1 ? "" : "s"})`);
379
- }
380
- return edits.length;
381
- }
382
-
383
- // ---------------------------------------------------------------------------
384
- // Directory / project-wide scanning
385
- // ---------------------------------------------------------------------------
386
-
387
- /**
388
- * Recursively collect all matching source files under a path.
389
- * If `inputPath` is itself a file, returns it (if its extension matches).
390
- * @param {string} inputPath
391
- * @param {string[]} [extensions]
392
- * @param {Set<string>} [ignoreDirs]
393
- * @returns {string[]}
394
- */
395
- function collectFiles(inputPath, extensions = DEFAULT_EXTENSIONS, ignoreDirs = DEFAULT_IGNORE_DIRS) {
396
- const stat = fs.statSync(inputPath);
397
- if (stat.isFile()) {
398
- return extensions.includes(path.extname(inputPath).toLowerCase()) ? [inputPath] : [];
399
- }
400
- if (!stat.isDirectory()) return [];
401
-
402
- const results = [];
403
- function walk(dir) {
404
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
405
- const full = path.join(dir, entry.name);
406
- if (entry.isDirectory()) {
407
- if (ignoreDirs.has(entry.name) || entry.name.startsWith(".")) continue;
408
- walk(full);
409
- } else if (entry.isFile()) {
410
- const lower = entry.name.toLowerCase();
411
- if (lower.endsWith(".d.ts")) continue; // type-declaration files have no implementations to document
412
- if (/\.commented\.[jt]sx?$/.test(lower)) continue; // skip our own previous output
413
- if (extensions.includes(path.extname(lower))) results.push(full);
414
- }
415
- }
416
- }
417
- walk(inputPath);
418
- return results;
419
- }
420
-
421
- module.exports = {
422
- processFile,
423
- collectFiles,
424
- collectEdits,
425
- DEFAULT_EXTENSIONS,
426
- DEFAULT_IGNORE_DIRS,
427
- };
1
+ "use strict";
2
+
3
+ /**
4
+ * comment-block-generator — lib/index.js
5
+ * ----------------------------------------
6
+ * A PURE, deterministic, AST-based JSDoc comment generator.
7
+ * No AI / LLM / external API is used anywhere — every comment line is
8
+ * derived mechanically from syntax: names, modifiers, type annotations,
9
+ * parameter lists, heritage clauses, enum members, etc.
10
+ *
11
+ * Works on plain JavaScript (.js/.jsx) AND TypeScript (.ts/.tsx) because
12
+ * both are parsed with the TypeScript compiler's parser, which is a
13
+ * superset parser for JS (it never type-checks, only parses syntax).
14
+ *
15
+ * This file exports plain functions so it can be:
16
+ * 1. Driven by bin/cli.js as a command-line tool, or
17
+ * 2. require()'d directly in another Node script / build pipeline:
18
+ * const { processFile } = require('jsdoc-scribe');
19
+ */
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const ts = require("typescript");
24
+
25
+ const DEFAULT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];
26
+ const DEFAULT_IGNORE_DIRS = new Set([
27
+ "node_modules",
28
+ ".git",
29
+ "dist",
30
+ "build",
31
+ "out",
32
+ "coverage",
33
+ ".next",
34
+ ".turbo",
35
+ ".cache",
36
+ ]);
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Low-level helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function getScriptKind(file) {
43
+ switch (path.extname(file).toLowerCase()) {
44
+ case ".tsx":
45
+ return ts.ScriptKind.TSX;
46
+ case ".ts":
47
+ return ts.ScriptKind.TS;
48
+ case ".jsx":
49
+ return ts.ScriptKind.JSX;
50
+ default:
51
+ return ts.ScriptKind.JS;
52
+ }
53
+ }
54
+
55
+ /** True if `node` already has a leading /** ... *\/ block right above it. */
56
+ function hasLeadingJSDoc(sourceFile, node) {
57
+ const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) || [];
58
+ return ranges.some((r) => sourceFile.text.slice(r.pos, r.pos + 3) === "/**");
59
+ }
60
+
61
+ /**
62
+ * Walk backwards from the node's first real token to find where its own
63
+ * line-indentation begins. Returns the exact insertion offset + the
64
+ * whitespace string to reuse for every comment line, so the comment lines
65
+ * up visually with the code it documents — without re-printing/reformatting
66
+ * the rest of the file.
67
+ */
68
+ function getIndentAndInsertionPos(sourceFile, node) {
69
+ const start = node.getStart(sourceFile);
70
+ const text = sourceFile.text;
71
+ let i = start;
72
+ while (i > 0 && (text[i - 1] === " " || text[i - 1] === "\t")) i--;
73
+ return { pos: i, indent: text.slice(i, start) };
74
+ }
75
+
76
+ function modifiersOf(node) {
77
+ const kinds = new Set((node.modifiers || []).map((m) => m.kind));
78
+ return {
79
+ isExported: kinds.has(ts.SyntaxKind.ExportKeyword),
80
+ isAsync: kinds.has(ts.SyntaxKind.AsyncKeyword),
81
+ isStatic: kinds.has(ts.SyntaxKind.StaticKeyword),
82
+ isReadonly: kinds.has(ts.SyntaxKind.ReadonlyKeyword),
83
+ isPrivate: kinds.has(ts.SyntaxKind.PrivateKeyword),
84
+ isProtected: kinds.has(ts.SyntaxKind.ProtectedKeyword),
85
+ isAbstract: kinds.has(ts.SyntaxKind.AbstractKeyword),
86
+ };
87
+ }
88
+
89
+ function typeText(node) {
90
+ return node && node.type ? node.type.getText() : null;
91
+ }
92
+
93
+ function isFunctionLikeInitializer(init) {
94
+ return !!init && (init.kind === ts.SyntaxKind.ArrowFunction || init.kind === ts.SyntaxKind.FunctionExpression);
95
+ }
96
+
97
+ /** Purely syntactic: does this block contain a `return <value>;` at its own level (not inside a nested function)? */
98
+ function hasReturnWithValue(block) {
99
+ let found = false;
100
+ function walk(n) {
101
+ if (found) return;
102
+ if (ts.isReturnStatement(n)) {
103
+ if (n.expression) found = true;
104
+ return;
105
+ }
106
+ if (ts.isFunctionLike(n)) return; // don't attribute a nested closure's return to the outer function
107
+ ts.forEachChild(n, walk);
108
+ }
109
+ walk(block);
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * Decide the @returns type with no semantics, no AI — just syntax:
115
+ * 1. An explicit type annotation always wins.
116
+ * 2. A concise arrow body (`x => x + 1`, no braces) always produces a value.
117
+ * 3. A block body is scanned for a top-level `return <value>;`.
118
+ * 4. Otherwise it's void.
119
+ */
120
+ function inferReturnType(fnNode) {
121
+ const explicit = typeText(fnNode);
122
+ if (explicit) return explicit;
123
+ const body = fnNode.body;
124
+ if (!body) return "void";
125
+ if (body.kind !== ts.SyntaxKind.Block) return "any"; // concise arrow body
126
+ return hasReturnWithValue(body) ? "any" : "void";
127
+ }
128
+
129
+ /** Best-effort, purely syntactic type guess from an initializer expression. No semantics, no AI. */
130
+ function inferTypeFromInitializer(init) {
131
+ if (!init) return "any";
132
+ switch (init.kind) {
133
+ case ts.SyntaxKind.StringLiteral:
134
+ case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
135
+ return "string";
136
+ case ts.SyntaxKind.NumericLiteral:
137
+ return "number";
138
+ case ts.SyntaxKind.TrueKeyword:
139
+ case ts.SyntaxKind.FalseKeyword:
140
+ return "boolean";
141
+ case ts.SyntaxKind.ArrayLiteralExpression:
142
+ return "Array";
143
+ case ts.SyntaxKind.ObjectLiteralExpression:
144
+ return "Object";
145
+ case ts.SyntaxKind.ArrowFunction:
146
+ case ts.SyntaxKind.FunctionExpression:
147
+ return "Function";
148
+ case ts.SyntaxKind.NewExpression:
149
+ return init.expression ? init.expression.getText() : "Object";
150
+ default:
151
+ return "any";
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Comment builders — every line below comes ONLY from AST shape, never from
157
+ // guessing "what the code means".
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function buildParamLines(params) {
161
+ return (params || []).map((p) => {
162
+ const name = p.name.getText();
163
+ const optional = !!p.questionToken || !!p.initializer;
164
+ const type = typeText(p) || (p.initializer ? inferTypeFromInitializer(p.initializer) : "any");
165
+ const label = optional ? `[${name}]` : name;
166
+ return ` * @param {${type}} ${label}`;
167
+ });
168
+ }
169
+
170
+ function buildFunctionDoc({ name, params, returnType, mods, isGenerator }) {
171
+ const lines = ["/**", ` * @function ${name || "anonymous"}`];
172
+ if (mods.isExported) lines.push(" * @exported");
173
+ if (mods.isAsync) lines.push(" * @async");
174
+ if (isGenerator) lines.push(" * @generator");
175
+ lines.push(...buildParamLines(params));
176
+ lines.push(` * @returns {${returnType || "void"}}`);
177
+ lines.push(" */");
178
+ return lines;
179
+ }
180
+
181
+ function buildClassDoc(node) {
182
+ const name = node.name ? node.name.getText() : "AnonymousClass";
183
+ const mods = modifiersOf(node);
184
+ const heritage = node.heritageClauses || [];
185
+ const lines = ["/**", ` * @class ${name}`];
186
+ if (mods.isExported) lines.push(" * @exported");
187
+ if (mods.isAbstract) lines.push(" * @abstract");
188
+ for (const h of heritage) {
189
+ const kw = h.token === ts.SyntaxKind.ExtendsKeyword ? "@extends" : "@implements";
190
+ for (const t of h.types) lines.push(` * ${kw} ${t.getText()}`);
191
+ }
192
+ lines.push(" */");
193
+ return lines;
194
+ }
195
+
196
+ function buildMethodDoc(node) {
197
+ const mods = modifiersOf(node);
198
+ const visibility = mods.isPrivate ? "private" : mods.isProtected ? "protected" : "public";
199
+ const name = node.name.getText();
200
+ const kind = ts.isGetAccessorDeclaration(node) ? "getter" : ts.isSetAccessorDeclaration(node) ? "setter" : "method";
201
+ const lines = ["/**", ` * @${kind} ${name}`, ` * @${visibility}`];
202
+ if (mods.isStatic) lines.push(" * @static");
203
+ if (mods.isAbstract) lines.push(" * @abstract");
204
+ if (mods.isAsync) lines.push(" * @async");
205
+ if (node.asteriskToken) lines.push(" * @generator");
206
+ lines.push(...buildParamLines(node.parameters));
207
+ lines.push(` * @returns {${inferReturnType(node)}}`);
208
+ lines.push(" */");
209
+ return lines;
210
+ }
211
+
212
+ function buildMemberDoc(node) {
213
+ if (ts.isConstructorDeclaration(node)) {
214
+ const lines = ["/**", " * @constructor"];
215
+ lines.push(...buildParamLines(node.parameters));
216
+ lines.push(" */");
217
+ return lines;
218
+ }
219
+ if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
220
+ return buildMethodDoc(node);
221
+ }
222
+ if (ts.isPropertyDeclaration(node)) {
223
+ const mods = modifiersOf(node);
224
+ const visibility = mods.isPrivate ? "private" : mods.isProtected ? "protected" : "public";
225
+ const name = node.name.getText();
226
+ const type = typeText(node) || inferTypeFromInitializer(node.initializer);
227
+ const lines = ["/**", ` * @member ${name}`, ` * @type {${type}}`, ` * @${visibility}`];
228
+ if (mods.isStatic) lines.push(" * @static");
229
+ if (mods.isReadonly) lines.push(" * @readonly");
230
+ lines.push(" */");
231
+ return lines;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function buildSingleVariableDoc(decl, isConst) {
237
+ const name = decl.name.getText();
238
+ const init = decl.initializer;
239
+ if (isFunctionLikeInitializer(init)) {
240
+ return buildFunctionDoc({
241
+ name,
242
+ params: init.parameters,
243
+ returnType: inferReturnType(init),
244
+ mods: { isAsync: (init.modifiers || []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) },
245
+ isGenerator: !!init.asteriskToken,
246
+ });
247
+ }
248
+ const type = typeText(decl) || inferTypeFromInitializer(init);
249
+ return ["/**", ` * @${isConst ? "constant" : "variable"} ${name}`, ` * @type {${type}}`, " */"];
250
+ }
251
+
252
+ function buildVariableStatementDoc(node, isConst) {
253
+ const decls = node.declarationList.declarations;
254
+ if (decls.length === 1) return buildSingleVariableDoc(decls[0], isConst);
255
+ const lines = ["/**"];
256
+ for (const decl of decls) {
257
+ const type = typeText(decl) || inferTypeFromInitializer(decl.initializer);
258
+ lines.push(` * @${isConst ? "constant" : "variable"} ${decl.name.getText()} {${type}}`);
259
+ }
260
+ lines.push(" */");
261
+ return lines;
262
+ }
263
+
264
+ function buildInterfaceDoc(node) {
265
+ const name = node.name.getText();
266
+ const lines = ["/**", ` * @interface ${name}`];
267
+ for (const m of node.members) {
268
+ if (ts.isPropertySignature(m) && m.name) {
269
+ lines.push(` * @property {${typeText(m) || "any"}} ${m.name.getText()}`);
270
+ }
271
+ }
272
+ lines.push(" */");
273
+ return lines;
274
+ }
275
+
276
+ function buildTypeAliasDoc(node) {
277
+ return ["/**", ` * @typedef {${node.type.getText()}} ${node.name.getText()}`, " */"];
278
+ }
279
+
280
+ function buildEnumDoc(node) {
281
+ const members = node.members.map((m) => m.name.getText()).join(" | ");
282
+ return ["/**", ` * @enum ${node.name.getText()}`, ` * @values ${members}`, " */"];
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // AST walk: collect {pos, text} edits, never mutating the source directly.
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function collectEdits(sourceFile, force) {
290
+ const edits = [];
291
+
292
+ function addEdit(node, linesFn) {
293
+ if (!force && hasLeadingJSDoc(sourceFile, node)) return;
294
+ const lines = linesFn();
295
+ if (!lines) return;
296
+ const { pos, indent } = getIndentAndInsertionPos(sourceFile, node);
297
+ const text = lines.map((l) => indent + l).join("\n") + "\n";
298
+ edits.push({ pos, text });
299
+ }
300
+
301
+ function visit(node) {
302
+ if (ts.isFunctionDeclaration(node) && node.body) {
303
+ addEdit(node, () =>
304
+ buildFunctionDoc({
305
+ name: node.name ? node.name.getText() : null,
306
+ params: node.parameters,
307
+ returnType: inferReturnType(node),
308
+ mods: modifiersOf(node),
309
+ isGenerator: !!node.asteriskToken,
310
+ }),
311
+ );
312
+ } else if (ts.isClassDeclaration(node)) {
313
+ addEdit(node, () => buildClassDoc(node));
314
+ } else if (
315
+ ts.isMethodDeclaration(node) ||
316
+ ts.isConstructorDeclaration(node) ||
317
+ ts.isPropertyDeclaration(node) ||
318
+ ts.isGetAccessorDeclaration(node) ||
319
+ ts.isSetAccessorDeclaration(node)
320
+ ) {
321
+ addEdit(node, () => buildMemberDoc(node));
322
+ } else if (ts.isPropertyAssignment(node) && isFunctionLikeInitializer(node.initializer)) {
323
+ const init = node.initializer;
324
+ addEdit(node, () =>
325
+ buildFunctionDoc({
326
+ name: node.name.getText(),
327
+ params: init.parameters,
328
+ returnType: inferReturnType(init),
329
+ mods: { isAsync: (init.modifiers || []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) },
330
+ isGenerator: !!init.asteriskToken,
331
+ }),
332
+ );
333
+ } else if (ts.isVariableStatement(node)) {
334
+ const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
335
+ addEdit(node, () => buildVariableStatementDoc(node, isConst));
336
+ } else if (ts.isInterfaceDeclaration(node)) {
337
+ addEdit(node, () => buildInterfaceDoc(node));
338
+ } else if (ts.isTypeAliasDeclaration(node)) {
339
+ addEdit(node, () => buildTypeAliasDoc(node));
340
+ } else if (ts.isEnumDeclaration(node)) {
341
+ addEdit(node, () => buildEnumDoc(node));
342
+ }
343
+ ts.forEachChild(node, visit);
344
+ }
345
+
346
+ visit(sourceFile);
347
+ return edits;
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // File processing
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Process a single file. Returns the number of comment blocks added.
356
+ * @param {string} filePath
357
+ * @param {{ write?: boolean, force?: boolean, silent?: boolean }} [options]
358
+ * @returns {number}
359
+ */
360
+ function processFile(filePath, options = {}) {
361
+ const { write = false, force = false, silent = false } = options;
362
+ const sourceText = fs.readFileSync(filePath, "utf8");
363
+ const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
364
+
365
+ const edits = collectEdits(sourceFile, force);
366
+ edits.sort((a, b) => b.pos - a.pos); // bottom-to-top so earlier offsets stay valid
367
+
368
+ let output = sourceText;
369
+ for (const edit of edits) {
370
+ output = output.slice(0, edit.pos) + edit.text + output.slice(edit.pos);
371
+ }
372
+
373
+ const outPath = write ? filePath : filePath.replace(/(\.[jt]sx?)$/, "$1");
374
+ if (edits.length > 0 || write) {
375
+ fs.writeFileSync(outPath, output, "utf8");
376
+ }
377
+ if (!silent) {
378
+ console.log(` ${filePath} -> ${outPath} (${edits.length} block${edits.length === 1 ? "" : "s"})`);
379
+ }
380
+ return edits.length;
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Directory / project-wide scanning
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Recursively collect all matching source files under a path.
389
+ * If `inputPath` is itself a file, returns it (if its extension matches).
390
+ * @param {string} inputPath
391
+ * @param {string[]} [extensions]
392
+ * @param {Set<string>} [ignoreDirs]
393
+ * @returns {string[]}
394
+ */
395
+ function collectFiles(inputPath, extensions = DEFAULT_EXTENSIONS, ignoreDirs = DEFAULT_IGNORE_DIRS, ignorePatterns = []) {
396
+ function matchesIgnore(fullPath) {
397
+ const normalised = fullPath.replace(/\\/g, "/");
398
+ return ignorePatterns.some(pat => {
399
+ const norm = pat.replace(/\\/g, "/").replace(/^\.?\/?/, "");
400
+ if (norm.startsWith("**/")) {
401
+ const suffix = norm.slice(3);
402
+ const re = new RegExp(suffix.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$");
403
+ return re.test(normalised);
404
+ }
405
+ const re = new RegExp(norm.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$");
406
+ return re.test(normalised);
407
+ });
408
+ }
409
+
410
+ const stat = fs.statSync(inputPath);
411
+ if (stat.isFile()) {
412
+ if (matchesIgnore(inputPath)) return [];
413
+ return extensions.includes(path.extname(inputPath).toLowerCase()) ? [inputPath] : [];
414
+ }
415
+ if (!stat.isDirectory()) return [];
416
+
417
+ const results = [];
418
+ function walk(dir) {
419
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
420
+ const full = path.join(dir, entry.name);
421
+ if (entry.isDirectory()) {
422
+ if (ignoreDirs.has(entry.name) || entry.name.startsWith(".")) continue;
423
+ if (matchesIgnore(full)) continue;
424
+ walk(full);
425
+ } else if (entry.isFile()) {
426
+ const lower = entry.name.toLowerCase();
427
+ if (lower.endsWith(".d.ts")) continue;
428
+ if (matchesIgnore(full)) continue;
429
+ if (extensions.includes(path.extname(lower))) results.push(full);
430
+ }
431
+ }
432
+ }
433
+ walk(inputPath);
434
+ return results;
435
+ }
436
+
437
+ module.exports = {
438
+ processFile,
439
+ collectFiles,
440
+ collectEdits,
441
+ DEFAULT_EXTENSIONS,
442
+ DEFAULT_IGNORE_DIRS,
443
+ };