jsdoczoom 0.1.0 → 0.2.1

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.
@@ -17,65 +17,73 @@ import { JsdocError } from "./errors.js";
17
17
  * or null if no file-level JSDoc is found.
18
18
  */
19
19
  export function extractFileJsdoc(sourceText) {
20
- const sourceFile = ts.createSourceFile("input.ts", sourceText, ts.ScriptTarget.Latest, true);
21
- // Check for syntax errors — if the file has diagnostics, throw PARSE_ERROR
22
- const diagnostics = getDiagnostics(sourceFile);
23
- if (diagnostics.length > 0) {
24
- throw new JsdocError("PARSE_ERROR", `TypeScript parse error: ${diagnostics[0]}`);
25
- }
26
- // Find the first non-import top-level statement
27
- let firstNonImportStatement;
28
- for (const statement of sourceFile.statements) {
29
- if (ts.isImportDeclaration(statement)) {
30
- continue;
31
- }
32
- firstNonImportStatement = statement;
33
- break;
34
- }
35
- // If there are no non-import statements, check for comments at file start
36
- if (firstNonImportStatement === undefined) {
37
- return findFirstJsdocBlock(sourceText, 0, sourceText.length);
38
- }
39
- // The JSDoc block sits in the leading trivia of the first non-import statement.
40
- const fullStart = firstNonImportStatement.getFullStart();
41
- const nodeStart = firstNonImportStatement.getStart(sourceFile);
42
- return findFirstJsdocBlock(sourceText, fullStart, nodeStart);
20
+ const sourceFile = ts.createSourceFile(
21
+ "input.ts",
22
+ sourceText,
23
+ ts.ScriptTarget.Latest,
24
+ true,
25
+ );
26
+ // Check for syntax errors — if the file has diagnostics, throw PARSE_ERROR
27
+ const diagnostics = getDiagnostics(sourceFile);
28
+ if (diagnostics.length > 0) {
29
+ throw new JsdocError(
30
+ "PARSE_ERROR",
31
+ `TypeScript parse error: ${diagnostics[0]}`,
32
+ );
33
+ }
34
+ // Find the first non-import top-level statement
35
+ let firstNonImportStatement;
36
+ for (const statement of sourceFile.statements) {
37
+ if (ts.isImportDeclaration(statement)) {
38
+ continue;
39
+ }
40
+ firstNonImportStatement = statement;
41
+ break;
42
+ }
43
+ // If there are no non-import statements, check for comments at file start
44
+ if (firstNonImportStatement === undefined) {
45
+ return findFirstJsdocBlock(sourceText, 0, sourceText.length);
46
+ }
47
+ // The JSDoc block sits in the leading trivia of the first non-import statement.
48
+ const fullStart = firstNonImportStatement.getFullStart();
49
+ const nodeStart = firstNonImportStatement.getStart(sourceFile);
50
+ return findFirstJsdocBlock(sourceText, fullStart, nodeStart);
43
51
  }
44
52
  /**
45
53
  * Find the first `/** ... *​/` block comment in the given text range.
46
54
  */
47
55
  function findFirstJsdocBlock(sourceText, start, end) {
48
- const commentRanges = getAllBlockComments(sourceText, start, end);
49
- for (const range of commentRanges) {
50
- const commentText = sourceText.slice(range.start, range.end);
51
- if (commentText.startsWith("/**") && !commentText.startsWith("/***")) {
52
- // Strip /** and */ delimiters
53
- const inner = commentText.slice(3, -2);
54
- return inner;
55
- }
56
- }
57
- return null;
56
+ const commentRanges = getAllBlockComments(sourceText, start, end);
57
+ for (const range of commentRanges) {
58
+ const commentText = sourceText.slice(range.start, range.end);
59
+ if (commentText.startsWith("/**") && !commentText.startsWith("/***")) {
60
+ // Strip /** and */ delimiters
61
+ const inner = commentText.slice(3, -2);
62
+ return inner;
63
+ }
64
+ }
65
+ return null;
58
66
  }
59
67
  /**
60
68
  * Find the end position of a block comment starting at `pos` (after the `/*`).
61
69
  */
62
70
  function findBlockCommentEnd(sourceText, pos) {
63
- while (pos < sourceText.length) {
64
- if (sourceText[pos] === "*" && sourceText[pos + 1] === "/") {
65
- return pos + 2;
66
- }
67
- pos++;
68
- }
69
- return pos;
71
+ while (pos < sourceText.length) {
72
+ if (sourceText[pos] === "*" && sourceText[pos + 1] === "/") {
73
+ return pos + 2;
74
+ }
75
+ pos++;
76
+ }
77
+ return pos;
70
78
  }
71
79
  /**
72
80
  * Skip past a line comment starting at `pos` (after the `//`).
73
81
  */
74
82
  function findLineCommentEnd(sourceText, pos) {
75
- while (pos < sourceText.length && sourceText[pos] !== "\n") {
76
- pos++;
77
- }
78
- return pos;
83
+ while (pos < sourceText.length && sourceText[pos] !== "\n") {
84
+ pos++;
85
+ }
86
+ return pos;
79
87
  }
80
88
  const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
81
89
  /**
@@ -83,56 +91,52 @@ const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
83
91
  * Stops at the first non-whitespace, non-comment character (code).
84
92
  */
85
93
  function getAllBlockComments(sourceText, start, end) {
86
- const results = [];
87
- let pos = start;
88
- while (pos < end) {
89
- if (WHITESPACE.has(sourceText[pos])) {
90
- pos++;
91
- continue;
92
- }
93
- const nextChar = sourceText[pos + 1];
94
- if (sourceText[pos] !== "/" || pos + 1 >= end)
95
- break;
96
- if (nextChar === "*") {
97
- const commentStart = pos;
98
- pos = findBlockCommentEnd(sourceText, pos + 2);
99
- results.push({ start: commentStart, end: pos });
100
- continue;
101
- }
102
- if (nextChar === "/") {
103
- pos = findLineCommentEnd(sourceText, pos + 2);
104
- continue;
105
- }
106
- break;
107
- }
108
- return results;
94
+ const results = [];
95
+ let pos = start;
96
+ while (pos < end) {
97
+ if (WHITESPACE.has(sourceText[pos])) {
98
+ pos++;
99
+ continue;
100
+ }
101
+ const nextChar = sourceText[pos + 1];
102
+ if (sourceText[pos] !== "/" || pos + 1 >= end) break;
103
+ if (nextChar === "*") {
104
+ const commentStart = pos;
105
+ pos = findBlockCommentEnd(sourceText, pos + 2);
106
+ results.push({ start: commentStart, end: pos });
107
+ continue;
108
+ }
109
+ if (nextChar === "/") {
110
+ pos = findLineCommentEnd(sourceText, pos + 2);
111
+ continue;
112
+ }
113
+ break;
114
+ }
115
+ return results;
109
116
  }
110
117
  /**
111
118
  * Get syntax error diagnostics from a parsed source file.
112
119
  */
113
120
  function getDiagnostics(sourceFile) {
114
- // parseDiagnostics is available on the internal SourceFile
115
- const diags = sourceFile.parseDiagnostics;
116
- if (!diags || diags.length === 0)
117
- return [];
118
- return diags.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
121
+ // parseDiagnostics is available on the internal SourceFile
122
+ const diags = sourceFile.parseDiagnostics;
123
+ if (!diags || diags.length === 0) return [];
124
+ return diags.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
119
125
  }
120
126
  /**
121
127
  * Append non-empty text to an accumulator with space separation.
122
128
  */
123
129
  function appendText(existing, addition) {
124
- if (addition.length === 0)
125
- return existing;
126
- if (existing.length === 0)
127
- return addition;
128
- return `${existing} ${addition}`;
130
+ if (addition.length === 0) return existing;
131
+ if (existing.length === 0) return addition;
132
+ return `${existing} ${addition}`;
129
133
  }
130
134
  /** Tags whose content is treated as description (free-text). */
131
135
  const DESCRIPTION_TAGS = new Set([
132
- "desc",
133
- "description",
134
- "file",
135
- "fileoverview",
136
+ "desc",
137
+ "description",
138
+ "file",
139
+ "fileoverview",
136
140
  ]);
137
141
  /**
138
142
  * Parse @summary tag and free-text description from raw JSDoc inner text.
@@ -151,57 +155,54 @@ const DESCRIPTION_TAGS = new Set([
151
155
  * Whitespace-only @summary tags are skipped.
152
156
  */
153
157
  function parseJsdocContent(jsdocText) {
154
- const lines = jsdocText.split("\n");
155
- let freeText = "";
156
- let firstSummary = null;
157
- let summaryCount = 0;
158
- let currentTag = null;
159
- let currentContent = "";
160
- /** Flush a pending @summary tag, counting all non-empty occurrences. */
161
- function flushSummary() {
162
- if (currentTag !== "summary")
163
- return;
164
- const trimmed = currentContent.trim();
165
- if (trimmed.length > 0) {
166
- summaryCount++;
167
- if (firstSummary === null) {
168
- firstSummary = trimmed;
169
- }
170
- }
171
- }
172
- for (const rawLine of lines) {
173
- const stripped = rawLine.replace(/^\s*\*?\s?/, "");
174
- const tagMatch = stripped.match(/^@([a-zA-Z]+)(?:\s|$)/);
175
- if (tagMatch) {
176
- flushSummary();
177
- const rawTagName = tagMatch[1];
178
- const tagContent = stripped.slice(tagMatch[0].length);
179
- if (DESCRIPTION_TAGS.has(rawTagName.toLowerCase())) {
180
- freeText = appendText(freeText, tagContent);
181
- currentTag = null;
182
- currentContent = "";
183
- }
184
- else {
185
- currentTag = rawTagName;
186
- currentContent = tagContent;
187
- }
188
- continue;
189
- }
190
- // Continuation line
191
- if (currentTag === null) {
192
- freeText = appendText(freeText, stripped);
193
- }
194
- else if (stripped.length > 0) {
195
- currentContent += ` ${stripped}`;
196
- }
197
- }
198
- flushSummary();
199
- const trimmedFreeText = freeText.trim();
200
- return {
201
- summary: firstSummary,
202
- description: trimmedFreeText.length > 0 ? trimmedFreeText : null,
203
- summaryCount,
204
- };
158
+ const lines = jsdocText.split("\n");
159
+ let freeText = "";
160
+ let firstSummary = null;
161
+ let summaryCount = 0;
162
+ let currentTag = null;
163
+ let currentContent = "";
164
+ /** Flush a pending @summary tag, counting all non-empty occurrences. */
165
+ function flushSummary() {
166
+ if (currentTag !== "summary") return;
167
+ const trimmed = currentContent.trim();
168
+ if (trimmed.length > 0) {
169
+ summaryCount++;
170
+ if (firstSummary === null) {
171
+ firstSummary = trimmed;
172
+ }
173
+ }
174
+ }
175
+ for (const rawLine of lines) {
176
+ const stripped = rawLine.replace(/^\s*\*?\s?/, "");
177
+ const tagMatch = stripped.match(/^@([a-zA-Z]+)(?:\s|$)/);
178
+ if (tagMatch) {
179
+ flushSummary();
180
+ const rawTagName = tagMatch[1];
181
+ const tagContent = stripped.slice(tagMatch[0].length);
182
+ if (DESCRIPTION_TAGS.has(rawTagName.toLowerCase())) {
183
+ freeText = appendText(freeText, tagContent);
184
+ currentTag = null;
185
+ currentContent = "";
186
+ } else {
187
+ currentTag = rawTagName;
188
+ currentContent = tagContent;
189
+ }
190
+ continue;
191
+ }
192
+ // Continuation line
193
+ if (currentTag === null) {
194
+ freeText = appendText(freeText, stripped);
195
+ } else if (stripped.length > 0) {
196
+ currentContent += ` ${stripped}`;
197
+ }
198
+ }
199
+ flushSummary();
200
+ const trimmedFreeText = freeText.trim();
201
+ return {
202
+ summary: firstSummary,
203
+ description: trimmedFreeText.length > 0 ? trimmedFreeText : null,
204
+ summaryCount,
205
+ };
205
206
  }
206
207
  /**
207
208
  * Parse a TypeScript file and extract its summary and description from file-level JSDoc.
@@ -210,29 +211,28 @@ function parseJsdocContent(jsdocText) {
210
211
  * @summary tag and free-text description.
211
212
  */
212
213
  export function parseFileSummaries(filePath) {
213
- let sourceText;
214
- try {
215
- sourceText = readFileSync(filePath, "utf-8");
216
- }
217
- catch {
218
- throw new JsdocError("FILE_NOT_FOUND", `File not found: ${filePath}`);
219
- }
220
- const jsdocText = extractFileJsdoc(sourceText);
221
- if (jsdocText === null) {
222
- return {
223
- path: filePath,
224
- summary: null,
225
- description: null,
226
- summaryCount: 0,
227
- hasFileJsdoc: false,
228
- };
229
- }
230
- const { summary, description, summaryCount } = parseJsdocContent(jsdocText);
231
- return {
232
- path: filePath,
233
- summary,
234
- description,
235
- summaryCount,
236
- hasFileJsdoc: true,
237
- };
214
+ let sourceText;
215
+ try {
216
+ sourceText = readFileSync(filePath, "utf-8");
217
+ } catch {
218
+ throw new JsdocError("FILE_NOT_FOUND", `File not found: ${filePath}`);
219
+ }
220
+ const jsdocText = extractFileJsdoc(sourceText);
221
+ if (jsdocText === null) {
222
+ return {
223
+ path: filePath,
224
+ summary: null,
225
+ description: null,
226
+ summaryCount: 0,
227
+ hasFileJsdoc: false,
228
+ };
229
+ }
230
+ const { summary, description, summaryCount } = parseJsdocContent(jsdocText);
231
+ return {
232
+ path: filePath,
233
+ summary,
234
+ description,
235
+ summaryCount,
236
+ hasFileJsdoc: true,
237
+ };
238
238
  }
package/dist/lint.js CHANGED
@@ -13,6 +13,7 @@ import { relative } from "node:path";
13
13
  import { JsdocError } from "./errors.js";
14
14
  import { createLintLinter, lintFileForLint } from "./eslint-engine.js";
15
15
  import { discoverFiles } from "./file-discovery.js";
16
+
16
17
  /**
17
18
  * Lint a single file and return per-file diagnostics.
18
19
  *
@@ -22,12 +23,12 @@ import { discoverFiles } from "./file-discovery.js";
22
23
  * @returns File result with relative path and diagnostics array
23
24
  */
24
25
  async function lintSingleFile(eslint, filePath, cwd) {
25
- const sourceText = readFileSync(filePath, "utf-8");
26
- const diagnostics = await lintFileForLint(eslint, sourceText, filePath);
27
- return {
28
- filePath: relative(cwd, filePath),
29
- diagnostics,
30
- };
26
+ const sourceText = readFileSync(filePath, "utf-8");
27
+ const diagnostics = await lintFileForLint(eslint, sourceText, filePath);
28
+ return {
29
+ filePath: relative(cwd, filePath),
30
+ diagnostics,
31
+ };
31
32
  }
32
33
  /**
33
34
  * Build a LintResult from per-file results, applying a limit to files with issues.
@@ -38,17 +39,22 @@ async function lintSingleFile(eslint, filePath, cwd) {
38
39
  * @returns Aggregated lint result with summary statistics
39
40
  */
40
41
  function buildLintResult(fileResults, totalFiles, limit) {
41
- const filesWithIssues = fileResults.filter((f) => f.diagnostics.length > 0);
42
- const totalDiagnostics = filesWithIssues.reduce((sum, f) => sum + f.diagnostics.length, 0);
43
- const cappedFiles = filesWithIssues.slice(0, limit);
44
- return {
45
- files: cappedFiles,
46
- summary: {
47
- totalFiles,
48
- filesWithIssues: filesWithIssues.length,
49
- totalDiagnostics,
50
- },
51
- };
42
+ const filesWithIssues = fileResults.filter((f) => f.diagnostics.length > 0);
43
+ const totalDiagnostics = filesWithIssues.reduce(
44
+ (sum, f) => sum + f.diagnostics.length,
45
+ 0,
46
+ );
47
+ const cappedFiles = filesWithIssues.slice(0, limit);
48
+ const truncated = filesWithIssues.length > limit;
49
+ return {
50
+ files: cappedFiles,
51
+ summary: {
52
+ totalFiles,
53
+ filesWithIssues: filesWithIssues.length,
54
+ totalDiagnostics,
55
+ ...(truncated ? { truncated: true } : {}),
56
+ },
57
+ };
52
58
  }
53
59
  /**
54
60
  * Lint files matching a selector pattern for comprehensive JSDoc quality.
@@ -65,14 +71,19 @@ function buildLintResult(fileResults, totalFiles, limit) {
65
71
  * @throws {JsdocError} NO_FILES_MATCHED if glob selector matches no files
66
72
  */
67
73
  export async function lint(selector, cwd, limit = 100, gitignore = true) {
68
- const files = discoverFiles(selector.pattern, cwd, gitignore);
69
- if (files.length === 0 && selector.type === "glob") {
70
- throw new JsdocError("NO_FILES_MATCHED", `No files matched: ${selector.pattern}`);
71
- }
72
- const tsFiles = files.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
73
- const eslint = createLintLinter(cwd);
74
- const fileResults = await Promise.all(tsFiles.map((f) => lintSingleFile(eslint, f, cwd)));
75
- return buildLintResult(fileResults, tsFiles.length, limit);
74
+ const files = discoverFiles(selector.pattern, cwd, gitignore);
75
+ if (files.length === 0 && selector.type === "glob") {
76
+ throw new JsdocError(
77
+ "NO_FILES_MATCHED",
78
+ `No files matched: ${selector.pattern}`,
79
+ );
80
+ }
81
+ const tsFiles = files.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
82
+ const eslint = createLintLinter(cwd);
83
+ const fileResults = await Promise.all(
84
+ tsFiles.map((f) => lintSingleFile(eslint, f, cwd)),
85
+ );
86
+ return buildLintResult(fileResults, tsFiles.length, limit);
76
87
  }
77
88
  /**
78
89
  * Lint an explicit list of file paths for comprehensive JSDoc quality.
@@ -86,8 +97,12 @@ export async function lint(selector, cwd, limit = 100, gitignore = true) {
86
97
  * @returns Lint result with per-file diagnostics and summary
87
98
  */
88
99
  export async function lintFiles(filePaths, cwd, limit = 100) {
89
- const tsFiles = filePaths.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
90
- const eslint = createLintLinter(cwd);
91
- const fileResults = await Promise.all(tsFiles.map((f) => lintSingleFile(eslint, f, cwd)));
92
- return buildLintResult(fileResults, tsFiles.length, limit);
100
+ const tsFiles = filePaths.filter(
101
+ (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
102
+ );
103
+ const eslint = createLintLinter(cwd);
104
+ const fileResults = await Promise.all(
105
+ tsFiles.map((f) => lintSingleFile(eslint, f, cwd)),
106
+ );
107
+ return buildLintResult(fileResults, tsFiles.length, limit);
93
108
  }
package/dist/selector.js CHANGED
@@ -16,25 +16,28 @@ import { JsdocError } from "./errors.js";
16
16
  * @throws JsdocError INVALID_DEPTH if depth is negative, non-integer, or float
17
17
  */
18
18
  export function parseSelector(input) {
19
- if (!input || input.trim() === "") {
20
- throw new JsdocError("INVALID_SELECTOR", "Selector cannot be empty");
21
- }
22
- // Reject float depths like @2.5 before extracting integer depth
23
- if (/@\d+\.\d+$/.test(input)) {
24
- throw new JsdocError("INVALID_DEPTH", "Depth must be an integer, not a float");
25
- }
26
- let pattern = input;
27
- let depth;
28
- // Match @<digits> at the end of the string
29
- const depthMatch = input.match(/@(\d+)$/);
30
- if (depthMatch) {
31
- depth = parseInt(depthMatch[1], 10);
32
- pattern = input.substring(0, depthMatch.index);
33
- }
34
- const hasGlobChars = /[*?[\]{]/.test(pattern);
35
- return {
36
- type: hasGlobChars ? "glob" : "path",
37
- pattern,
38
- depth,
39
- };
19
+ if (!input || input.trim() === "") {
20
+ throw new JsdocError("INVALID_SELECTOR", "Selector cannot be empty");
21
+ }
22
+ // Reject float depths like @2.5 before extracting integer depth
23
+ if (/@\d+\.\d+$/.test(input)) {
24
+ throw new JsdocError(
25
+ "INVALID_DEPTH",
26
+ "Depth must be an integer, not a float",
27
+ );
28
+ }
29
+ let pattern = input;
30
+ let depth;
31
+ // Match @<digits> at the end of the string
32
+ const depthMatch = input.match(/@(\d+)$/);
33
+ if (depthMatch) {
34
+ depth = parseInt(depthMatch[1], 10);
35
+ pattern = input.substring(0, depthMatch.index);
36
+ }
37
+ const hasGlobChars = /[*?[\]{]/.test(pattern);
38
+ return {
39
+ type: hasGlobChars ? "glob" : "path",
40
+ pattern,
41
+ depth,
42
+ };
40
43
  }