@agoric/eslint-plugin 0.1.1-dev-d6fae11.0.d6fae11 → 0.1.1-dev-7b17bb6.0.7b17bb6
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/package.json +2 -2
- package/src/index.js +3 -0
- package/src/rules/group-jsdoc-imports.js +311 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agoric/eslint-plugin",
|
|
3
|
-
"version": "0.1.1-dev-
|
|
3
|
+
"version": "0.1.1-dev-7b17bb6.0.7b17bb6",
|
|
4
4
|
"description": "ESLint plugin for Agoric best practices",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": "^20.9 || ^22.11"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "7b17bb64b1ba5357d073027fb7457c7378d744b7"
|
|
34
34
|
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const pkg = JSON.parse(
|
|
|
7
7
|
|
|
8
8
|
// Import rules
|
|
9
9
|
const noTypedefImport = require('./rules/no-typedef-import.js');
|
|
10
|
+
const groupJsdocImports = require('./rules/group-jsdoc-imports.js');
|
|
10
11
|
|
|
11
12
|
module.exports = {
|
|
12
13
|
meta: {
|
|
@@ -17,6 +18,7 @@ module.exports = {
|
|
|
17
18
|
// Rule definitions
|
|
18
19
|
rules: {
|
|
19
20
|
'no-typedef-import': noTypedefImport,
|
|
21
|
+
'group-jsdoc-imports': groupJsdocImports,
|
|
20
22
|
},
|
|
21
23
|
|
|
22
24
|
// Recommended config
|
|
@@ -25,6 +27,7 @@ module.exports = {
|
|
|
25
27
|
plugins: ['@agoric'],
|
|
26
28
|
rules: {
|
|
27
29
|
'@agoric/no-typedef-import': 'error',
|
|
30
|
+
'@agoric/group-jsdoc-imports': 'warn',
|
|
28
31
|
},
|
|
29
32
|
},
|
|
30
33
|
},
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: group-jsdoc-imports
|
|
3
|
+
*
|
|
4
|
+
* Moves inline JSDoc type imports (e.g. \@type {import('foo').Bar})
|
|
5
|
+
* into a top-level JSDoc block with \@import lines, then references the
|
|
6
|
+
* import by name (e.g. \@type {Bar}).
|
|
7
|
+
*
|
|
8
|
+
* Usage example in your .eslintrc.js:
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "plugins": ["@agoric"],
|
|
12
|
+
* "rules": {
|
|
13
|
+
* "@agoric/group-jsdoc-imports": "warning"
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* ESLint rule: group-jsdoc-imports (one-fix-per-inline-import version)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: /** @type {const} */ ('suggestion'),
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Move inline type import to top-level JSDoc import block',
|
|
28
|
+
category: 'Stylistic Issues',
|
|
29
|
+
recommended: false,
|
|
30
|
+
},
|
|
31
|
+
fixable: /** @type {const} */ ('code'),
|
|
32
|
+
schema: [
|
|
33
|
+
{
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
paths: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
messages: {
|
|
45
|
+
moveInline: 'Move inline type import to top-level JSDoc import block',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
create(context) {
|
|
50
|
+
const sourceCode = context.getSourceCode();
|
|
51
|
+
const { paths: allowedPaths = [] } = context.options[0] || {};
|
|
52
|
+
|
|
53
|
+
// Use the `g` flag so we can match multiple inline imports in one comment.
|
|
54
|
+
const INLINE_IMPORT_REGEX =
|
|
55
|
+
/import\(['"]([^'"]+)['"]\)\.([A-Za-z0-9_$]+(?:<[A-Za-z0-9_<>{},\s]+>)?)/g;
|
|
56
|
+
|
|
57
|
+
function isAllowedPath(importPath) {
|
|
58
|
+
if (
|
|
59
|
+
importPath.startsWith('./') ||
|
|
60
|
+
importPath.startsWith('../') ||
|
|
61
|
+
importPath === '.' ||
|
|
62
|
+
importPath === '..'
|
|
63
|
+
) {
|
|
64
|
+
return true; // always allow relative paths
|
|
65
|
+
}
|
|
66
|
+
if (!allowedPaths.length) {
|
|
67
|
+
return true; // no filtering if no paths configured
|
|
68
|
+
}
|
|
69
|
+
return allowedPaths.some(prefix => importPath.startsWith(prefix));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Finds an existing top-level `@import` block comment (one that contains "@import")
|
|
74
|
+
* or returns `null` if none exists.
|
|
75
|
+
*/
|
|
76
|
+
function findTopBlockComment(allComments) {
|
|
77
|
+
for (const comment of allComments) {
|
|
78
|
+
if (comment.type === 'Block') {
|
|
79
|
+
// Rebuild with the /* ... */ so we can search for "@import"
|
|
80
|
+
const text = `/*${comment.value}*/`;
|
|
81
|
+
if (text.includes('@import ')) {
|
|
82
|
+
return comment;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Checks if the doc block already imports a specific type from a path,
|
|
91
|
+
* returning `true` if found.
|
|
92
|
+
*/
|
|
93
|
+
function alreadyHasImport(blockValue, typeName, importPath) {
|
|
94
|
+
// blockValue is the text inside /* ... */
|
|
95
|
+
// Look for lines like: @import {Type} from 'xyz'
|
|
96
|
+
// Possibly could handle multiple types in one line, but let's keep it simple:
|
|
97
|
+
const lines = blockValue.split('\n');
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const m = line.match(/@import\s+{([^}]+)}\s+from\s+['"]([^'"]+)['"]/);
|
|
100
|
+
if (m) {
|
|
101
|
+
const importedTypes = m[1].split(',').map(s => s.trim());
|
|
102
|
+
const fromPath = m[2];
|
|
103
|
+
if (fromPath === importPath && importedTypes.includes(typeName)) {
|
|
104
|
+
return true; // Found a matching import already
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const allComments = sourceCode.getAllComments();
|
|
112
|
+
|
|
113
|
+
const buildImportLine = (typeName, importPath) =>
|
|
114
|
+
` * @import {${typeName}} from '${importPath}';\n`;
|
|
115
|
+
|
|
116
|
+
const buildNewBlock = (typeName, importPath) =>
|
|
117
|
+
['/**', buildImportLine(typeName, importPath).trimEnd(), ' */', ''].join(
|
|
118
|
+
'\n',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const appendImportToComment = (commentNode, typeName, importPath) => {
|
|
122
|
+
const existingText = sourceCode.getText(commentNode);
|
|
123
|
+
const closingIndex = existingText.lastIndexOf('*/');
|
|
124
|
+
const importLine = buildImportLine(typeName, importPath);
|
|
125
|
+
|
|
126
|
+
if (closingIndex === -1) {
|
|
127
|
+
return `${existingText}\n${importLine}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const beforeCloseFull = existingText.slice(0, closingIndex);
|
|
131
|
+
const lastNewlineIndex = beforeCloseFull.lastIndexOf('\n');
|
|
132
|
+
let beforeClose = beforeCloseFull;
|
|
133
|
+
let closingIndent = '';
|
|
134
|
+
|
|
135
|
+
if (lastNewlineIndex !== -1) {
|
|
136
|
+
const tail = beforeCloseFull.slice(lastNewlineIndex + 1);
|
|
137
|
+
if (/^[\t ]*$/.test(tail)) {
|
|
138
|
+
closingIndent = tail;
|
|
139
|
+
beforeClose = beforeCloseFull.slice(0, lastNewlineIndex + 1);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
beforeClose = beforeCloseFull.trimEnd();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const suffixIndent =
|
|
146
|
+
closingIndent !== ''
|
|
147
|
+
? closingIndent
|
|
148
|
+
: existingText.startsWith('/*')
|
|
149
|
+
? ' '
|
|
150
|
+
: '';
|
|
151
|
+
const suffix = `${suffixIndent}${existingText.slice(closingIndex)}`;
|
|
152
|
+
const needsNewline = beforeClose.endsWith('\n') ? '' : '\n';
|
|
153
|
+
|
|
154
|
+
return `${beforeClose}${needsNewline}${importLine}${suffix}`;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const advancePastWhitespaceNewline = startIndex => {
|
|
158
|
+
const match = sourceCode.text.slice(startIndex).match(/^[^\S\r\n]*\r?\n/);
|
|
159
|
+
if (match) {
|
|
160
|
+
return { matched: true, nextIndex: startIndex + match[0].length };
|
|
161
|
+
}
|
|
162
|
+
return { matched: false, nextIndex: startIndex };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const getNewBlockInsertPlacement = () => {
|
|
166
|
+
const programBody = sourceCode.ast.body || [];
|
|
167
|
+
const importNodes = programBody.filter(
|
|
168
|
+
node => node.type === 'ImportDeclaration',
|
|
169
|
+
);
|
|
170
|
+
const firstContentIndex = sourceCode.text.search(/\S/);
|
|
171
|
+
let insertIndex = firstContentIndex === -1 ? 0 : firstContentIndex;
|
|
172
|
+
let prefix = '';
|
|
173
|
+
let needsTrailingBlankLine = false;
|
|
174
|
+
|
|
175
|
+
if (importNodes.length) {
|
|
176
|
+
insertIndex = importNodes[importNodes.length - 1].range[1];
|
|
177
|
+
|
|
178
|
+
let step = advancePastWhitespaceNewline(insertIndex);
|
|
179
|
+
const hasNewlineAfterImports = step.matched;
|
|
180
|
+
insertIndex = step.nextIndex;
|
|
181
|
+
|
|
182
|
+
step = advancePastWhitespaceNewline(insertIndex);
|
|
183
|
+
const hasBlankLine = step.matched;
|
|
184
|
+
insertIndex = step.nextIndex;
|
|
185
|
+
|
|
186
|
+
if (!hasNewlineAfterImports) {
|
|
187
|
+
prefix += '\n';
|
|
188
|
+
}
|
|
189
|
+
if (!hasBlankLine) {
|
|
190
|
+
prefix += '\n';
|
|
191
|
+
} else {
|
|
192
|
+
needsTrailingBlankLine = true;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
const blankLineMatch = /\r?\n[^\S\r\n]*\r?\n/.exec(sourceCode.text);
|
|
196
|
+
if (blankLineMatch) {
|
|
197
|
+
insertIndex = blankLineMatch.index + blankLineMatch[0].length;
|
|
198
|
+
needsTrailingBlankLine = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { insertIndex, prefix, needsTrailingBlankLine };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Walk all block comments and look for inline imports.
|
|
206
|
+
for (const comment of allComments) {
|
|
207
|
+
if (comment.type !== 'Block') {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const { value: commentText } = comment;
|
|
211
|
+
|
|
212
|
+
// We collect all matches in this comment for `import('...').SomeType`
|
|
213
|
+
const matches = [...commentText.matchAll(INLINE_IMPORT_REGEX)];
|
|
214
|
+
for (const match of matches) {
|
|
215
|
+
const [fullMatch, importPath, rawTypeName] = match;
|
|
216
|
+
if (!isAllowedPath(importPath)) {
|
|
217
|
+
continue; // skip if path is not in allowedPaths
|
|
218
|
+
}
|
|
219
|
+
const typeName =
|
|
220
|
+
typeof rawTypeName === 'string'
|
|
221
|
+
? rawTypeName
|
|
222
|
+
: String(rawTypeName ?? '');
|
|
223
|
+
const importTypeName = typeName.includes('<')
|
|
224
|
+
? typeName.slice(0, typeName.indexOf('<'))
|
|
225
|
+
: typeName;
|
|
226
|
+
|
|
227
|
+
// We have an inline import. We'll report exactly one error
|
|
228
|
+
// for *this* inline import. The fix will:
|
|
229
|
+
// 1) Insert/update the doc block
|
|
230
|
+
// 2) Remove inline usage from this comment
|
|
231
|
+
context.report({
|
|
232
|
+
loc: comment.loc,
|
|
233
|
+
messageId: 'moveInline',
|
|
234
|
+
fix: fixer => {
|
|
235
|
+
// We'll build a set of fix operations for both the doc block
|
|
236
|
+
// (somewhere in the file) and this inline usage.
|
|
237
|
+
const fixOps = [];
|
|
238
|
+
|
|
239
|
+
const inlineStart =
|
|
240
|
+
comment.range[0] +
|
|
241
|
+
2 +
|
|
242
|
+
(match.index ?? commentText.indexOf(fullMatch));
|
|
243
|
+
const inlineEnd = inlineStart + fullMatch.length;
|
|
244
|
+
|
|
245
|
+
// (a) We locate or create a top-level block
|
|
246
|
+
const topBlockComment = findTopBlockComment(allComments);
|
|
247
|
+
const topBlockIsSameAsComment =
|
|
248
|
+
topBlockComment &&
|
|
249
|
+
topBlockComment.range[0] === comment.range[0] &&
|
|
250
|
+
topBlockComment.range[1] === comment.range[1];
|
|
251
|
+
|
|
252
|
+
if (!topBlockComment) {
|
|
253
|
+
// If no block with @import found, create a new block at top
|
|
254
|
+
const {
|
|
255
|
+
insertIndex,
|
|
256
|
+
prefix: newBlockPrefix,
|
|
257
|
+
needsTrailingBlankLine,
|
|
258
|
+
} = getNewBlockInsertPlacement();
|
|
259
|
+
const trailingSpacer = needsTrailingBlankLine ? '\n' : '';
|
|
260
|
+
fixOps.push(
|
|
261
|
+
fixer.insertTextBeforeRange(
|
|
262
|
+
[insertIndex, insertIndex],
|
|
263
|
+
`${newBlockPrefix}${buildNewBlock(importTypeName, importPath)}${trailingSpacer}`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
} else if (topBlockIsSameAsComment) {
|
|
267
|
+
fixOps.push(
|
|
268
|
+
fixer.insertTextBeforeRange(
|
|
269
|
+
[comment.range[0], comment.range[0]],
|
|
270
|
+
`${buildNewBlock(importTypeName, importPath)}\n`,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
// If we do have a block, ensure it includes `@import {typeName} from 'importPath'`
|
|
275
|
+
if (
|
|
276
|
+
!alreadyHasImport(
|
|
277
|
+
topBlockComment.value,
|
|
278
|
+
importTypeName,
|
|
279
|
+
importPath,
|
|
280
|
+
)
|
|
281
|
+
) {
|
|
282
|
+
fixOps.push(
|
|
283
|
+
fixer.replaceTextRange(
|
|
284
|
+
topBlockComment.range,
|
|
285
|
+
appendImportToComment(
|
|
286
|
+
topBlockComment,
|
|
287
|
+
importTypeName,
|
|
288
|
+
importPath,
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// (b) Remove the inline usage from *this* comment
|
|
296
|
+
// Replace `import('...').Foo` → `Foo`
|
|
297
|
+
fixOps.push(
|
|
298
|
+
fixer.replaceTextRange([inlineStart, inlineEnd], typeName),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return fixOps;
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// We do NOT do anything in Program:exit, because each inline usage
|
|
308
|
+
// is already handled in a single fix above.
|
|
309
|
+
return {};
|
|
310
|
+
},
|
|
311
|
+
};
|