jsdoczoom 0.1.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/dist/barrel.js +84 -0
- package/dist/cli.js +287 -0
- package/dist/drilldown.js +286 -0
- package/dist/errors.js +24 -0
- package/dist/eslint-engine.js +160 -0
- package/dist/eslint-plugin.js +205 -0
- package/dist/file-discovery.js +76 -0
- package/dist/index.js +17 -0
- package/dist/jsdoc-parser.js +238 -0
- package/dist/lint.js +93 -0
- package/dist/selector.js +40 -0
- package/dist/skill-text.js +244 -0
- package/dist/type-declarations.js +101 -0
- package/dist/types.js +16 -0
- package/dist/validate.js +122 -0
- package/package.json +51 -0
- package/types/barrel.d.ts +32 -0
- package/types/cli.d.ts +5 -0
- package/types/drilldown.d.ts +28 -0
- package/types/errors.d.ts +19 -0
- package/types/eslint-engine.d.ts +65 -0
- package/types/eslint-plugin.d.ts +9 -0
- package/types/file-discovery.d.ts +14 -0
- package/types/index.d.ts +17 -0
- package/types/jsdoc-parser.d.ts +24 -0
- package/types/lint.d.ts +38 -0
- package/types/selector.d.ts +18 -0
- package/types/skill-text.d.ts +13 -0
- package/types/type-declarations.d.ts +28 -0
- package/types/types.d.ts +91 -0
- package/types/validate.d.ts +23 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint validation engine for jsdoczoom.
|
|
3
|
+
*
|
|
4
|
+
* @summary Bridge between ESLint API and jsdoczoom validation/lint formats
|
|
5
|
+
*/
|
|
6
|
+
import tsParser from "@typescript-eslint/parser";
|
|
7
|
+
import { ESLint } from "eslint";
|
|
8
|
+
import jsdocPlugin from "eslint-plugin-jsdoc";
|
|
9
|
+
import plugin from "./eslint-plugin.js";
|
|
10
|
+
/**
|
|
11
|
+
* Creates an ESLint instance configured for validation mode.
|
|
12
|
+
*
|
|
13
|
+
* This linter only runs jsdoczoom's custom rules for file-level JSDoc validation.
|
|
14
|
+
*
|
|
15
|
+
* @returns Configured ESLint instance for validation
|
|
16
|
+
*/
|
|
17
|
+
export function createValidationLinter() {
|
|
18
|
+
const eslint = new ESLint({
|
|
19
|
+
overrideConfigFile: true,
|
|
20
|
+
overrideConfig: [
|
|
21
|
+
{
|
|
22
|
+
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
|
23
|
+
plugins: { jsdoczoom: plugin },
|
|
24
|
+
rules: {
|
|
25
|
+
"jsdoczoom/require-file-jsdoc": "error",
|
|
26
|
+
"jsdoczoom/require-file-summary": "error",
|
|
27
|
+
"jsdoczoom/require-file-description": "error",
|
|
28
|
+
},
|
|
29
|
+
languageOptions: {
|
|
30
|
+
parser: tsParser,
|
|
31
|
+
ecmaVersion: "latest",
|
|
32
|
+
sourceType: "module",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
return eslint;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Creates an ESLint instance configured for lint mode.
|
|
41
|
+
*
|
|
42
|
+
* This linter runs both jsdoczoom rules and eslint-plugin-jsdoc rules for comprehensive JSDoc validation.
|
|
43
|
+
*
|
|
44
|
+
* @param cwd - Optional working directory for ESLint base path resolution
|
|
45
|
+
* @returns Configured ESLint instance for lint mode
|
|
46
|
+
*/
|
|
47
|
+
export function createLintLinter(cwd) {
|
|
48
|
+
const eslint = new ESLint({
|
|
49
|
+
cwd,
|
|
50
|
+
overrideConfigFile: true,
|
|
51
|
+
overrideConfig: [
|
|
52
|
+
{
|
|
53
|
+
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
|
54
|
+
plugins: { jsdoczoom: plugin, jsdoc: jsdocPlugin },
|
|
55
|
+
rules: {
|
|
56
|
+
"jsdoczoom/require-file-jsdoc": "error",
|
|
57
|
+
"jsdoczoom/require-file-summary": "error",
|
|
58
|
+
"jsdoczoom/require-file-description": "error",
|
|
59
|
+
"jsdoc/require-jsdoc": ["error", { publicOnly: true }],
|
|
60
|
+
"jsdoc/require-param": "warn",
|
|
61
|
+
"jsdoc/require-param-description": "warn",
|
|
62
|
+
"jsdoc/require-returns": "warn",
|
|
63
|
+
"jsdoc/require-returns-description": "warn",
|
|
64
|
+
"jsdoc/require-throws": "warn",
|
|
65
|
+
"jsdoc/check-param-names": "error",
|
|
66
|
+
"jsdoc/check-tag-names": "error",
|
|
67
|
+
"jsdoc/no-types": "error",
|
|
68
|
+
"jsdoc/informative-docs": "error",
|
|
69
|
+
"jsdoc/tag-lines": "off",
|
|
70
|
+
"jsdoc/no-blank-blocks": "error",
|
|
71
|
+
"jsdoc/require-description": "error",
|
|
72
|
+
},
|
|
73
|
+
languageOptions: {
|
|
74
|
+
parser: tsParser,
|
|
75
|
+
ecmaVersion: "latest",
|
|
76
|
+
sourceType: "module",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
return eslint;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Lints source text for validation mode and returns simplified messages.
|
|
85
|
+
*
|
|
86
|
+
* @param eslint - ESLint instance (typically from createValidationLinter)
|
|
87
|
+
* @param sourceText - Source code to lint
|
|
88
|
+
* @param filePath - Path to the file being linted
|
|
89
|
+
* @returns Simplified message list with ruleId, messageId, and fatal flag
|
|
90
|
+
*/
|
|
91
|
+
export async function lintFileForValidation(eslint, sourceText, filePath) {
|
|
92
|
+
const results = await eslint.lintText(sourceText, { filePath });
|
|
93
|
+
if (results.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
return results[0].messages.map((msg) => ({
|
|
96
|
+
ruleId: msg.ruleId,
|
|
97
|
+
messageId: msg.messageId,
|
|
98
|
+
fatal: msg.fatal,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Lints source text for lint mode and returns detailed diagnostics.
|
|
103
|
+
*
|
|
104
|
+
* @param eslint - ESLint instance (typically from createLintLinter)
|
|
105
|
+
* @param sourceText - Source code to lint
|
|
106
|
+
* @param filePath - Path to the file being linted
|
|
107
|
+
* @returns Array of lint diagnostics with line, column, rule, message, and severity
|
|
108
|
+
*/
|
|
109
|
+
export async function lintFileForLint(eslint, sourceText, filePath) {
|
|
110
|
+
const results = await eslint.lintText(sourceText, { filePath });
|
|
111
|
+
if (results.length === 0)
|
|
112
|
+
return [];
|
|
113
|
+
return results[0].messages.map((msg) => ({
|
|
114
|
+
line: msg.line,
|
|
115
|
+
column: msg.column,
|
|
116
|
+
rule: msg.ruleId ?? "unknown",
|
|
117
|
+
message: msg.message,
|
|
118
|
+
severity: msg.severity === 2 ? "error" : "warning",
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Maps ESLint messages to a single ValidationStatus using priority order.
|
|
123
|
+
*
|
|
124
|
+
* Priority order (first match wins):
|
|
125
|
+
* 1. Parse errors (null ruleId with fatal flag) → syntax_error
|
|
126
|
+
* 2. jsdoczoom/require-file-jsdoc → missing_jsdoc
|
|
127
|
+
* 3. jsdoczoom/require-file-summary with missingSummary → missing_summary
|
|
128
|
+
* 4. jsdoczoom/require-file-summary with multipleSummary → multiple_summary
|
|
129
|
+
* 5. jsdoczoom/require-file-description → missing_description
|
|
130
|
+
* 6. No matches → valid
|
|
131
|
+
*
|
|
132
|
+
* @param messages - Simplified ESLint messages from lintFileForValidation
|
|
133
|
+
* @returns ValidationStatus or "valid"
|
|
134
|
+
*/
|
|
135
|
+
export function mapToValidationStatus(messages) {
|
|
136
|
+
// Priority 1: Parse errors
|
|
137
|
+
if (messages.some((msg) => msg.ruleId === null && msg.fatal)) {
|
|
138
|
+
return "syntax_error";
|
|
139
|
+
}
|
|
140
|
+
// Priority 2: Missing JSDoc
|
|
141
|
+
if (messages.some((msg) => msg.ruleId === "jsdoczoom/require-file-jsdoc")) {
|
|
142
|
+
return "missing_jsdoc";
|
|
143
|
+
}
|
|
144
|
+
// Priority 3: Missing summary
|
|
145
|
+
if (messages.some((msg) => msg.ruleId === "jsdoczoom/require-file-summary" &&
|
|
146
|
+
msg.messageId === "missingSummary")) {
|
|
147
|
+
return "missing_summary";
|
|
148
|
+
}
|
|
149
|
+
// Priority 4: Multiple summary
|
|
150
|
+
if (messages.some((msg) => msg.ruleId === "jsdoczoom/require-file-summary" &&
|
|
151
|
+
msg.messageId === "multipleSummary")) {
|
|
152
|
+
return "multiple_summary";
|
|
153
|
+
}
|
|
154
|
+
// Priority 5: Missing description
|
|
155
|
+
if (messages.some((msg) => msg.ruleId === "jsdoczoom/require-file-description")) {
|
|
156
|
+
return "missing_description";
|
|
157
|
+
}
|
|
158
|
+
// No matches
|
|
159
|
+
return "valid";
|
|
160
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom ESLint plugin for jsdoczoom file-level JSDoc validation.
|
|
3
|
+
*
|
|
4
|
+
* Provides three rules that replicate the file-level JSDoc checks from
|
|
5
|
+
* jsdoc-parser.ts: detecting presence of file-level JSDoc blocks,
|
|
6
|
+
* validating exact lowercase @summary tags, and checking for description
|
|
7
|
+
* content (free-text or description-synonym tags).
|
|
8
|
+
*
|
|
9
|
+
* @summary Custom ESLint plugin with file-level JSDoc validation rules
|
|
10
|
+
*/
|
|
11
|
+
/** Tags whose content is treated as description (free-text). */
|
|
12
|
+
const DESCRIPTION_TAGS = new Set([
|
|
13
|
+
"desc",
|
|
14
|
+
"description",
|
|
15
|
+
"file",
|
|
16
|
+
"fileoverview",
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Find the file-level JSDoc block comment from the source code.
|
|
20
|
+
*
|
|
21
|
+
* The file-level JSDoc block is the first block comment that starts with
|
|
22
|
+
* a single asterisk (not double) and appears before the first non-import
|
|
23
|
+
* statement. When no non-import statements exist, the first qualifying
|
|
24
|
+
* block comment in the file is used.
|
|
25
|
+
*/
|
|
26
|
+
function findFileJsdocComment(sourceCode, programBody) {
|
|
27
|
+
const firstNonImport = programBody.find((node) => node.type !== "ImportDeclaration");
|
|
28
|
+
const allComments = sourceCode.getAllComments();
|
|
29
|
+
// Filter to JSDoc block comments (/** but not /***)
|
|
30
|
+
// In ESLint's AST, /** foo */ has comment.type === "Block" and
|
|
31
|
+
// comment.value === "* foo ". A /*** foo */ has value === "** foo ".
|
|
32
|
+
const jsdocComments = allComments.filter((c) => c.type === "Block" &&
|
|
33
|
+
c.value.startsWith("*") &&
|
|
34
|
+
!c.value.startsWith("**"));
|
|
35
|
+
if (firstNonImport) {
|
|
36
|
+
// Find first JSDoc comment that ends before the first non-import statement starts
|
|
37
|
+
return (jsdocComments.find((c) => c.range !== undefined &&
|
|
38
|
+
firstNonImport.range !== undefined &&
|
|
39
|
+
c.range[1] <= firstNonImport.range[0]) ?? null);
|
|
40
|
+
}
|
|
41
|
+
// If no non-import statements, return first JSDoc comment in file
|
|
42
|
+
return jsdocComments[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Append non-empty text to an accumulator with space separation.
|
|
46
|
+
*/
|
|
47
|
+
function appendText(existing, addition) {
|
|
48
|
+
if (!addition)
|
|
49
|
+
return existing;
|
|
50
|
+
return existing ? `${existing} ${addition}` : addition;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse the inner content of a JSDoc block comment.
|
|
54
|
+
*
|
|
55
|
+
* Extracts summary tags (exact lowercase only), description tags
|
|
56
|
+
* (case-insensitive), and free-text content before any @ tags.
|
|
57
|
+
* Replicates the parsing behavior from jsdoc-parser.ts parseJsdocContent().
|
|
58
|
+
*/
|
|
59
|
+
function parseJsdocBlock(comment) {
|
|
60
|
+
// comment.value is the text between /* and */
|
|
61
|
+
// For /** ... */, value starts with "*". Strip that leading *.
|
|
62
|
+
const innerText = comment.value.slice(1);
|
|
63
|
+
const lines = innerText.split("\n");
|
|
64
|
+
let freeText = "";
|
|
65
|
+
const summaryTags = [];
|
|
66
|
+
let currentTag = null;
|
|
67
|
+
let currentContent = "";
|
|
68
|
+
function flushCurrentTag() {
|
|
69
|
+
if (currentTag === "summary") {
|
|
70
|
+
const trimmed = currentContent.trim();
|
|
71
|
+
// Only count non-whitespace summaries
|
|
72
|
+
if (trimmed.length > 0) {
|
|
73
|
+
summaryTags.push({ name: "summary", content: trimmed });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const rawLine of lines) {
|
|
78
|
+
const stripped = rawLine.replace(/^\s*\*?\s?/, "");
|
|
79
|
+
const tagMatch = stripped.match(/^@([a-zA-Z]+)(?:\s|$)/);
|
|
80
|
+
if (tagMatch) {
|
|
81
|
+
flushCurrentTag();
|
|
82
|
+
const rawTagName = tagMatch[1];
|
|
83
|
+
const tagContent = stripped.slice(tagMatch[0].length);
|
|
84
|
+
if (DESCRIPTION_TAGS.has(rawTagName.toLowerCase())) {
|
|
85
|
+
freeText = appendText(freeText, tagContent);
|
|
86
|
+
currentTag = null;
|
|
87
|
+
currentContent = "";
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Store exact tag name (case-sensitive for @summary)
|
|
91
|
+
currentTag = rawTagName;
|
|
92
|
+
currentContent = tagContent;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Continuation line
|
|
97
|
+
if (currentTag === null) {
|
|
98
|
+
freeText = appendText(freeText, stripped);
|
|
99
|
+
}
|
|
100
|
+
else if (stripped.length > 0) {
|
|
101
|
+
currentContent += ` ${stripped}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
flushCurrentTag();
|
|
105
|
+
const trimmedFreeText = freeText.trim();
|
|
106
|
+
return {
|
|
107
|
+
freeText: trimmedFreeText,
|
|
108
|
+
summaryTags,
|
|
109
|
+
hasDescription: trimmedFreeText.length > 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const requireFileJsdoc = {
|
|
113
|
+
meta: {
|
|
114
|
+
type: "problem",
|
|
115
|
+
messages: {
|
|
116
|
+
missingFileJsdoc: "File is missing a file-level JSDoc block (/** ... */) before the first code statement.",
|
|
117
|
+
},
|
|
118
|
+
schema: [],
|
|
119
|
+
},
|
|
120
|
+
create(context) {
|
|
121
|
+
return {
|
|
122
|
+
Program(node) {
|
|
123
|
+
const sourceCode = context.sourceCode;
|
|
124
|
+
const jsdocComment = findFileJsdocComment(sourceCode, node.body);
|
|
125
|
+
if (jsdocComment === null) {
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
messageId: "missingFileJsdoc",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const requireFileSummary = {
|
|
136
|
+
meta: {
|
|
137
|
+
type: "problem",
|
|
138
|
+
messages: {
|
|
139
|
+
missingSummary: "File-level JSDoc is missing an @summary tag (exact lowercase required).",
|
|
140
|
+
multipleSummary: "File-level JSDoc has multiple @summary tags. Use exactly one.",
|
|
141
|
+
},
|
|
142
|
+
schema: [],
|
|
143
|
+
},
|
|
144
|
+
create(context) {
|
|
145
|
+
return {
|
|
146
|
+
Program(node) {
|
|
147
|
+
const sourceCode = context.sourceCode;
|
|
148
|
+
const jsdocComment = findFileJsdocComment(sourceCode, node.body);
|
|
149
|
+
// If no file-level JSDoc exists, that's rule 1's job to report
|
|
150
|
+
if (jsdocComment === null) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const parsed = parseJsdocBlock(jsdocComment);
|
|
154
|
+
if (parsed.summaryTags.length === 0) {
|
|
155
|
+
context.report({
|
|
156
|
+
node,
|
|
157
|
+
messageId: "missingSummary",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
else if (parsed.summaryTags.length > 1) {
|
|
161
|
+
context.report({
|
|
162
|
+
node,
|
|
163
|
+
messageId: "multipleSummary",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const requireFileDescription = {
|
|
171
|
+
meta: {
|
|
172
|
+
type: "problem",
|
|
173
|
+
messages: {
|
|
174
|
+
missingDescription: "File-level JSDoc is missing a description. Add free-text before @tags or use @desc/@description/@file/@fileoverview.",
|
|
175
|
+
},
|
|
176
|
+
schema: [],
|
|
177
|
+
},
|
|
178
|
+
create(context) {
|
|
179
|
+
return {
|
|
180
|
+
Program(node) {
|
|
181
|
+
const sourceCode = context.sourceCode;
|
|
182
|
+
const jsdocComment = findFileJsdocComment(sourceCode, node.body);
|
|
183
|
+
// If no file-level JSDoc exists, that's rule 1's job to report
|
|
184
|
+
if (jsdocComment === null) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const parsed = parseJsdocBlock(jsdocComment);
|
|
188
|
+
if (!parsed.hasDescription) {
|
|
189
|
+
context.report({
|
|
190
|
+
node,
|
|
191
|
+
messageId: "missingDescription",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const plugin = {
|
|
199
|
+
rules: {
|
|
200
|
+
"require-file-jsdoc": requireFileJsdoc,
|
|
201
|
+
"require-file-summary": requireFileSummary,
|
|
202
|
+
"require-file-description": requireFileDescription,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
export default plugin;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { globSync } from "glob";
|
|
4
|
+
import ignore from "ignore";
|
|
5
|
+
import { JsdocError } from "./errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Walks .gitignore files from cwd to filesystem root, building an ignore
|
|
8
|
+
* filter that glob results pass through. Direct-path lookups bypass the
|
|
9
|
+
* filter since the user explicitly named the file. The ignore instance is
|
|
10
|
+
* created per call -- no caching -- because cwd may differ between invocations.
|
|
11
|
+
*
|
|
12
|
+
* @summary Resolve selector patterns to absolute file paths with gitignore filtering
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Walk from `cwd` up to the filesystem root, collecting .gitignore entries.
|
|
16
|
+
* Returns an Ignore instance loaded with all discovered rules.
|
|
17
|
+
*/
|
|
18
|
+
function loadGitignore(cwd) {
|
|
19
|
+
const ig = ignore();
|
|
20
|
+
let dir = resolve(cwd);
|
|
21
|
+
while (true) {
|
|
22
|
+
const gitignorePath = join(dir, ".gitignore");
|
|
23
|
+
if (existsSync(gitignorePath)) {
|
|
24
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
25
|
+
const prefix = relative(cwd, dir);
|
|
26
|
+
const lines = content
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((l) => l.trim())
|
|
29
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Prefix rules from ancestor .gitignore files so paths are
|
|
32
|
+
// relative to `cwd`, which is where glob results are anchored.
|
|
33
|
+
ig.add(prefix ? `${prefix}/${line}` : line);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir)
|
|
38
|
+
break;
|
|
39
|
+
dir = parent;
|
|
40
|
+
}
|
|
41
|
+
return ig;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a selector pattern to a list of .ts/.tsx file paths.
|
|
45
|
+
*
|
|
46
|
+
* Glob patterns use the glob package. Plain paths resolve to single-element arrays.
|
|
47
|
+
* Results exclude .d.ts files and are sorted alphabetically.
|
|
48
|
+
* When gitignore is true (default), results are filtered through .gitignore rules.
|
|
49
|
+
*
|
|
50
|
+
* @param pattern - A glob pattern or direct file path
|
|
51
|
+
* @param cwd - The working directory for resolving relative paths
|
|
52
|
+
* @param gitignore - Whether to respect .gitignore rules (default true)
|
|
53
|
+
* @returns Array of absolute file paths
|
|
54
|
+
* @throws {JsdocError} FILE_NOT_FOUND when a direct path does not exist
|
|
55
|
+
*/
|
|
56
|
+
export function discoverFiles(pattern, cwd, gitignore = true) {
|
|
57
|
+
const hasGlobChars = /[*?[\]{]/.test(pattern);
|
|
58
|
+
if (hasGlobChars) {
|
|
59
|
+
const matches = globSync(pattern, { cwd, absolute: true });
|
|
60
|
+
let filtered = matches.filter((f) => (f.endsWith(".ts") || f.endsWith(".tsx")) && !f.endsWith(".d.ts"));
|
|
61
|
+
if (gitignore) {
|
|
62
|
+
const ig = loadGitignore(cwd);
|
|
63
|
+
filtered = filtered.filter((abs) => !ig.ignores(relative(cwd, abs)));
|
|
64
|
+
}
|
|
65
|
+
return filtered.sort();
|
|
66
|
+
}
|
|
67
|
+
// Direct path
|
|
68
|
+
const resolved = resolve(cwd, pattern);
|
|
69
|
+
if (!existsSync(resolved)) {
|
|
70
|
+
throw new JsdocError("FILE_NOT_FOUND", `File not found: ${pattern}`);
|
|
71
|
+
}
|
|
72
|
+
if (statSync(resolved).isDirectory()) {
|
|
73
|
+
return discoverFiles(`${resolved}/**`, cwd, gitignore);
|
|
74
|
+
}
|
|
75
|
+
return [resolved];
|
|
76
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports all public functions, classes, and types from internal modules.
|
|
3
|
+
* This is the sole entry point for programmatic consumers of the jsdoczoom
|
|
4
|
+
* package.
|
|
5
|
+
*
|
|
6
|
+
* @summary Public API barrel re-exporting all functions, types, and classes
|
|
7
|
+
*/
|
|
8
|
+
export { getBarrelChildren, isBarrel } from "./barrel.js";
|
|
9
|
+
export { drilldown, drilldownFiles } from "./drilldown.js";
|
|
10
|
+
export { JsdocError } from "./errors.js";
|
|
11
|
+
export { discoverFiles } from "./file-discovery.js";
|
|
12
|
+
export { extractFileJsdoc, parseFileSummaries } from "./jsdoc-parser.js";
|
|
13
|
+
export { lint, lintFiles } from "./lint.js";
|
|
14
|
+
export { parseSelector } from "./selector.js";
|
|
15
|
+
export { generateTypeDeclarations } from "./type-declarations.js";
|
|
16
|
+
export { VALIDATION_STATUS_PRIORITY, } from "./types.js";
|
|
17
|
+
export { validate, validateFiles } from "./validate.js";
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { JsdocError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Uses the TypeScript compiler to locate the first JSDoc block before any
|
|
6
|
+
* code statements, then extracts the first `@summary` tag and free-text
|
|
7
|
+
* description. Syntax errors in the source file produce PARSE_ERROR;
|
|
8
|
+
* whitespace-only summaries are skipped in favor of the next non-empty one.
|
|
9
|
+
*
|
|
10
|
+
* @summary Extract file-level JSDoc summary and description from TypeScript source files
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Extract the first file-level JSDoc block from TypeScript source text.
|
|
14
|
+
*
|
|
15
|
+
* The JSDoc block must appear before any code statements (after imports is OK).
|
|
16
|
+
* Returns the raw JSDoc text without the leading `/**` and trailing `*/` delimiters,
|
|
17
|
+
* or null if no file-level JSDoc is found.
|
|
18
|
+
*/
|
|
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);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Find the first `/** ... */` block comment in the given text range.
|
|
46
|
+
*/
|
|
47
|
+
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;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Find the end position of a block comment starting at `pos` (after the `/*`).
|
|
61
|
+
*/
|
|
62
|
+
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;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Skip past a line comment starting at `pos` (after the `//`).
|
|
73
|
+
*/
|
|
74
|
+
function findLineCommentEnd(sourceText, pos) {
|
|
75
|
+
while (pos < sourceText.length && sourceText[pos] !== "\n") {
|
|
76
|
+
pos++;
|
|
77
|
+
}
|
|
78
|
+
return pos;
|
|
79
|
+
}
|
|
80
|
+
const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
|
|
81
|
+
/**
|
|
82
|
+
* Scan for block comments in the source text between start and end positions.
|
|
83
|
+
* Stops at the first non-whitespace, non-comment character (code).
|
|
84
|
+
*/
|
|
85
|
+
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;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get syntax error diagnostics from a parsed source file.
|
|
112
|
+
*/
|
|
113
|
+
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"));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Append non-empty text to an accumulator with space separation.
|
|
122
|
+
*/
|
|
123
|
+
function appendText(existing, addition) {
|
|
124
|
+
if (addition.length === 0)
|
|
125
|
+
return existing;
|
|
126
|
+
if (existing.length === 0)
|
|
127
|
+
return addition;
|
|
128
|
+
return `${existing} ${addition}`;
|
|
129
|
+
}
|
|
130
|
+
/** Tags whose content is treated as description (free-text). */
|
|
131
|
+
const DESCRIPTION_TAGS = new Set([
|
|
132
|
+
"desc",
|
|
133
|
+
"description",
|
|
134
|
+
"file",
|
|
135
|
+
"fileoverview",
|
|
136
|
+
]);
|
|
137
|
+
/**
|
|
138
|
+
* Parse @summary tag and free-text description from raw JSDoc inner text.
|
|
139
|
+
*
|
|
140
|
+
* Returns:
|
|
141
|
+
* - summary: First non-empty @summary tag content (or null if none)
|
|
142
|
+
* - description: Free-text before first @ tag (or null if none)
|
|
143
|
+
*
|
|
144
|
+
* Only exact lowercase @summary is recognized. Case variants like
|
|
145
|
+
* @Summary or @SUMMARY are ignored (causing missing_summary in
|
|
146
|
+
* validation). The @desc, @description, @file, and @fileoverview
|
|
147
|
+
* tags (case-insensitive) are treated as description synonyms —
|
|
148
|
+
* their content is included in the description field.
|
|
149
|
+
*
|
|
150
|
+
* Additional @summary tags are silently ignored.
|
|
151
|
+
* Whitespace-only @summary tags are skipped.
|
|
152
|
+
*/
|
|
153
|
+
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
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Parse a TypeScript file and extract its summary and description from file-level JSDoc.
|
|
208
|
+
*
|
|
209
|
+
* Reads the file, extracts the first file-level JSDoc block, and parses the first
|
|
210
|
+
* @summary tag and free-text description.
|
|
211
|
+
*/
|
|
212
|
+
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
|
+
};
|
|
238
|
+
}
|