jsdoc-scribe 1.0.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/cli.js +115 -0
- package/lib/index.js +427 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chintan Goswami
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# jsdoc-scribe
|
|
2
|
+
|
|
3
|
+
Pure, deterministic, **AST-based** JSDoc comment generator for JavaScript & TypeScript.
|
|
4
|
+
**No AI / LLM is used anywhere** — every line of every comment is derived
|
|
5
|
+
mechanically from the syntax tree (names, modifiers, type annotations,
|
|
6
|
+
parameter lists, heritage clauses, enum members, etc). Same input always
|
|
7
|
+
produces the same output.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# run once without installing anything
|
|
13
|
+
npx jsdoc-scribe . --write
|
|
14
|
+
|
|
15
|
+
# or add it to your project
|
|
16
|
+
npm install --save-dev jsdoc-scribe
|
|
17
|
+
|
|
18
|
+
# or install it globally
|
|
19
|
+
npm install -g jsdoc-scribe
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Once installed, the command is **`gen-comments`**.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gen-comments <path> [path2 ...] [options]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`<path>` can be a single file **or a directory** — directories are scanned
|
|
31
|
+
recursively for `.js` / `.jsx` / `.ts` / `.tsx` files. `node_modules`, `.git`,
|
|
32
|
+
`dist`, `build`, `out`, `coverage`, `.next`, `.turbo`, `.cache`, and any other
|
|
33
|
+
dotfolder are skipped automatically.
|
|
34
|
+
|
|
35
|
+
| Flag | Description |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `--write`, `-w` | Edit files **in place**. Without this flag, output goes to a sibling `<name>.commented.<ext>` file next to each original, so you can review a diff before committing to it. |
|
|
38
|
+
| `--force`, `-f` | Re-insert comment blocks even on nodes that already have a leading `/** */`. Off by default, to stay idempotent. |
|
|
39
|
+
| `--help`, `-h` | Show usage. |
|
|
40
|
+
| `--version`, `-v` | Show the installed version. |
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
gen-comments src/utils.ts # preview only -> utils.commented.ts
|
|
44
|
+
gen-comments . # scan whole project, preview only
|
|
45
|
+
gen-comments . --write # scan whole project, edit in place
|
|
46
|
+
gen-comments src --write --force # also re-document already-commented files
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you run `--write` outside a git repo, the CLI prints a one-line warning
|
|
50
|
+
(not a blocker) recommending you commit first so you have something to diff
|
|
51
|
+
or revert against.
|
|
52
|
+
|
|
53
|
+
## The algorithm
|
|
54
|
+
|
|
55
|
+
1. **Parse** each file into a `ts.SourceFile` AST using the TypeScript
|
|
56
|
+
compiler's parser (`typescript` npm package). That parser is syntax-only —
|
|
57
|
+
it never type-checks — and it's a superset parser for JavaScript, which is
|
|
58
|
+
why this works on plain `.js`/`.jsx` just as well as `.ts`/`.tsx`.
|
|
59
|
+
2. **Walk** the AST recursively. Tracked node kinds:
|
|
60
|
+
- `FunctionDeclaration` (top-level or nested)
|
|
61
|
+
- `ClassDeclaration` + its `constructor` / methods / properties / get-set
|
|
62
|
+
- `VariableStatement` (`const`/`let`/`var`, including arrow/function inits)
|
|
63
|
+
- `PropertyAssignment` with a function/arrow value inside an object literal
|
|
64
|
+
- `InterfaceDeclaration`, `TypeAliasDeclaration`, `EnumDeclaration`
|
|
65
|
+
3. **Skip** any node that already has a leading `/** ... */` block (checked
|
|
66
|
+
via `ts.getLeadingCommentRanges`) — unless `--force` is passed. This makes
|
|
67
|
+
the tool idempotent: running it twice never duplicates comments.
|
|
68
|
+
4. **Build** the comment block from pure syntax only:
|
|
69
|
+
- An explicit type annotation is always used as-is.
|
|
70
|
+
- With no annotation, it falls back to a syntactic guess: literal kind for
|
|
71
|
+
variables (`'x'` → `string`, `[1,2]` → `Array`, …), and for function
|
|
72
|
+
return types, a scan for a top-level `return <value>;` (so a function
|
|
73
|
+
that clearly returns something is never mislabeled `void` just because
|
|
74
|
+
it lacks a type annotation).
|
|
75
|
+
- Modifiers (`async`, `static`, `private`, `readonly`, `abstract`,
|
|
76
|
+
`export`, generator `*`) are read directly off the AST node.
|
|
77
|
+
5. **Insert** the comment as plain text at the exact byte offset where the
|
|
78
|
+
node's own line indentation begins. All edits across a file are collected
|
|
79
|
+
first, then applied **bottom-to-top** so earlier offsets never shift.
|
|
80
|
+
The rest of the file is never re-printed or reformatted — only insertions
|
|
81
|
+
happen.
|
|
82
|
+
6. **Write** the result — to `<file>.commented.<ext>` by default, or back to
|
|
83
|
+
the original file with `--write`.
|
|
84
|
+
|
|
85
|
+
## Using it as a library
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const { processFile, collectFiles } = require('jsdoc-scribe');
|
|
89
|
+
|
|
90
|
+
// process one file, return number of comment blocks added
|
|
91
|
+
processFile('src/utils.ts', { write: true });
|
|
92
|
+
|
|
93
|
+
// recursively find every matching source file under a directory
|
|
94
|
+
const files = collectFiles('src');
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Known scope / limitations (by design)
|
|
98
|
+
|
|
99
|
+
- Inline anonymous callbacks passed directly as call arguments
|
|
100
|
+
(`arr.map(x => x * 2)`) are **not** commented — inserting a multi-line block
|
|
101
|
+
there would mangle the call expression. Anything with its own declaration
|
|
102
|
+
(function decl, class member, variable, named object property) is covered.
|
|
103
|
+
- Type/return inference is 100% syntactic. It is never "smart" about what
|
|
104
|
+
your code *means* — only what it *looks like* structurally. That's the
|
|
105
|
+
whole point: deterministic, reproducible output, every time.
|
|
106
|
+
- Multi-declarator statements (`const a = 1, b = 2;`) get one combined block
|
|
107
|
+
rather than a per-declarator function-style doc.
|
|
108
|
+
- `.d.ts` files are skipped (no implementation to document).
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git clone <your-repo-url>
|
|
114
|
+
cd jsdoc-scribe
|
|
115
|
+
npm install
|
|
116
|
+
npm test # runs the self-test suite (test/run.js)
|
|
117
|
+
npm run demo # runs the CLI against the bundled example/ files
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Publishing new versions
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm version patch # or minor / major
|
|
124
|
+
npm publish
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`prepublishOnly` runs the test suite automatically before every publish.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { processFile, collectFiles } = require("../lib/index.js");
|
|
7
|
+
const pkg = require("../package.json");
|
|
8
|
+
|
|
9
|
+
function printHelp() {
|
|
10
|
+
console.log(`
|
|
11
|
+
${pkg.name} v${pkg.version}
|
|
12
|
+
Pure, AST-based JSDoc comment generator for JavaScript & TypeScript. No AI involved.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
gen-comments <path> [path2 ...] [options]
|
|
16
|
+
|
|
17
|
+
<path> can be a single file OR a directory (scanned recursively for
|
|
18
|
+
.js/.jsx/.ts/.tsx files; node_modules, .git, dist, build, etc. are skipped).
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--write, -w Overwrite files in place.
|
|
22
|
+
(default: writes a sibling "<name>.commented.<ext>" file
|
|
23
|
+
next to each original, so you can review a diff first)
|
|
24
|
+
--force, -f Add comment blocks even on nodes that already have one.
|
|
25
|
+
--help, -h Show this help.
|
|
26
|
+
--version, -v Show the installed version.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
gen-comments src/utils.ts # preview only, writes utils.commented.ts
|
|
30
|
+
gen-comments . # scan whole project, preview only
|
|
31
|
+
gen-comments . --write # scan whole project, edit files in place
|
|
32
|
+
gen-comments src --write --force # re-document files that already have JSDoc
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const args = { inputs: [], write: false, force: false, help: false, version: false };
|
|
38
|
+
for (const a of argv) {
|
|
39
|
+
if (a === "--write" || a === "-w") args.write = true;
|
|
40
|
+
else if (a === "--force" || a === "-f") args.force = true;
|
|
41
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
42
|
+
else if (a === "--version" || a === "-v") args.version = true;
|
|
43
|
+
else args.inputs.push(a);
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isInsideGitRepo(startDir) {
|
|
49
|
+
let dir = path.resolve(startDir);
|
|
50
|
+
while (true) {
|
|
51
|
+
if (fs.existsSync(path.join(dir, ".git"))) return true;
|
|
52
|
+
const parent = path.dirname(dir);
|
|
53
|
+
if (parent === dir) return false;
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const argv = process.argv.slice(2);
|
|
60
|
+
const { inputs, write, force, help, version } = parseArgs(argv);
|
|
61
|
+
|
|
62
|
+
if (version) {
|
|
63
|
+
console.log(pkg.version);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (help || inputs.length === 0) {
|
|
67
|
+
printHelp();
|
|
68
|
+
process.exitCode = inputs.length === 0 && !help ? 1 : 0;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (write && !isInsideGitRepo(process.cwd())) {
|
|
73
|
+
console.warn(
|
|
74
|
+
"⚠ --write will edit files in place and this folder is not (or you are not inside) a git repo.\n" +
|
|
75
|
+
" Consider committing your work first so you have something to diff/revert against.\n",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let files = [];
|
|
80
|
+
for (const input of inputs) {
|
|
81
|
+
if (!fs.existsSync(input)) {
|
|
82
|
+
console.error(`skip: path not found - ${input}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
files.push(...collectFiles(input));
|
|
86
|
+
}
|
|
87
|
+
files = [...new Set(files)];
|
|
88
|
+
|
|
89
|
+
if (files.length === 0) {
|
|
90
|
+
console.log("No matching .js/.jsx/.ts/.tsx files found.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`Scanning ${files.length} file(s)...`);
|
|
95
|
+
let totalBlocks = 0;
|
|
96
|
+
let touchedFiles = 0;
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
try {
|
|
99
|
+
const count = processFile(file, { write, force });
|
|
100
|
+
totalBlocks += count;
|
|
101
|
+
if (count > 0) touchedFiles += 1;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(` ${file} -> FAILED: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(
|
|
108
|
+
`\nDone. ${totalBlocks} comment block(s) added across ${touchedFiles} file(s) ` + `(${files.length} scanned).`,
|
|
109
|
+
);
|
|
110
|
+
if (!write) {
|
|
111
|
+
console.log("This was a preview run — pass --write to edit the original files in place.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main();
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jsdoc-scribe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pure AST-based JSDoc comment generator for JS/TS - no AI involved.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gen-comments": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"demo": "node bin/cli.js example",
|
|
17
|
+
"test": "node test/run.js",
|
|
18
|
+
"prepublishOnly": "npm test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"jsdoc",
|
|
22
|
+
"comments",
|
|
23
|
+
"documentation",
|
|
24
|
+
"typescript",
|
|
25
|
+
"javascript",
|
|
26
|
+
"cli",
|
|
27
|
+
"ast",
|
|
28
|
+
"code-documentation",
|
|
29
|
+
"comment-generator",
|
|
30
|
+
"autodoc"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "Chintan Goswami"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=14"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"typescript": ">=5.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|