eslint-plugin-imports-regulation 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.
@@ -0,0 +1,46 @@
1
+ declare const _default: {
2
+ configs: {
3
+ recommended: {
4
+ plugins: {
5
+ 'imports-regulation': {
6
+ meta: {
7
+ name: string;
8
+ };
9
+ rules: {
10
+ 'imports-regulation': import("@typescript-eslint/utils/ts-eslint").RuleModule<"groupOrder" | "blankLines" | "unexpectedBlankLine" | "typeSpecifiersLast" | "markTypeOnly" | "inlineTypeSpecifiers" | "afterImports", [{
11
+ local?: string[];
12
+ patterns?: {
13
+ pattern: string;
14
+ group: "package" | "local";
15
+ }[];
16
+ blankLines?: number;
17
+ afterImports?: number;
18
+ addenda?: string[];
19
+ typeOnly?: "declaration" | "inline" | "ignore";
20
+ }?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
21
+ };
22
+ };
23
+ };
24
+ rules: {
25
+ 'imports-regulation/imports-regulation': string;
26
+ };
27
+ };
28
+ };
29
+ meta: {
30
+ name: string;
31
+ };
32
+ rules: {
33
+ 'imports-regulation': import("@typescript-eslint/utils/ts-eslint").RuleModule<"groupOrder" | "blankLines" | "unexpectedBlankLine" | "typeSpecifiersLast" | "markTypeOnly" | "inlineTypeSpecifiers" | "afterImports", [{
34
+ local?: string[];
35
+ patterns?: {
36
+ pattern: string;
37
+ group: "package" | "local";
38
+ }[];
39
+ blankLines?: number;
40
+ afterImports?: number;
41
+ addenda?: string[];
42
+ typeOnly?: "declaration" | "inline" | "ignore";
43
+ }?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
44
+ };
45
+ };
46
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import importsRegulation from './rules/imports-regulation.js';
2
+ const plugin = {
3
+ meta: { name: 'eslint-plugin-imports-regulation' },
4
+ rules: { 'imports-regulation': importsRegulation },
5
+ };
6
+ const recommended = {
7
+ plugins: { 'imports-regulation': plugin },
8
+ rules: { 'imports-regulation/imports-regulation': 'warn' },
9
+ };
10
+ export default { ...plugin, configs: { recommended } };
@@ -0,0 +1,19 @@
1
+ import type { TSESLint } from '@typescript-eslint/utils';
2
+ type MessageIds = 'groupOrder' | 'blankLines' | 'unexpectedBlankLine' | 'typeSpecifiersLast' | 'markTypeOnly' | 'inlineTypeSpecifiers' | 'afterImports';
3
+ type Locality = 'package' | 'local';
4
+ type TypeOnlyStyle = 'declaration' | 'inline' | 'ignore';
5
+ type Options = [
6
+ {
7
+ local?: string[];
8
+ patterns?: {
9
+ pattern: string;
10
+ group: Locality;
11
+ }[];
12
+ blankLines?: number;
13
+ afterImports?: number;
14
+ addenda?: string[];
15
+ typeOnly?: TypeOnlyStyle;
16
+ }?
17
+ ];
18
+ declare const rule: TSESLint.RuleModule<MessageIds, Options>;
19
+ export default rule;
@@ -0,0 +1,350 @@
1
+ const DEFAULT_BLANK_LINES = 1;
2
+ const DEFAULT_AFTER_IMPORTS = 2;
3
+ const DEFAULT_TYPE_ONLY = 'declaration';
4
+ // The order, as written. The blank line falls between 1 and 2 — nowhere else.
5
+ const PACKAGE_TYPE = 0;
6
+ const LOCAL_TYPE = 2;
7
+ const GROUP_NAMES = ['package type-only', 'package', 'local type-only', 'local'];
8
+ const BREAK = /\r\n|\n|\r/;
9
+ const BREAK_GLOBAL = /\r\n|\n|\r/g;
10
+ const nameOfGroup = (group) => GROUP_NAMES[group] ?? 'other';
11
+ const isLocalGroup = (group) => group >= LOCAL_TYPE;
12
+ const escapeRegExp = (text) => text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
13
+ /** `*` stops at a `/`, `**` does not — module specifiers are path-shaped. */
14
+ const globToRegExp = (glob) => {
15
+ const body = glob
16
+ .split('**')
17
+ .map(part => escapeRegExp(part).replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]'))
18
+ .join('.*');
19
+ return new RegExp(`^${body}$`);
20
+ };
21
+ /** A line is not path-shaped, so here `*` matches anything at all. */
22
+ const lineGlobToRegExp = (glob) => new RegExp(`^${escapeRegExp(glob).replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
23
+ const rule = {
24
+ defaultOptions: [],
25
+ meta: {
26
+ type: 'layout',
27
+ docs: {
28
+ description: 'Group imports as package type-only, package, blank line, local type-only, local, with type specifiers last inside each import',
29
+ },
30
+ fixable: 'code',
31
+ schema: [
32
+ {
33
+ type: 'object',
34
+ properties: {
35
+ // Prefixes that mean "mine", alongside the relative paths that always do.
36
+ local: { type: 'array', items: { type: 'string' }, uniqueItems: true },
37
+ // Globs against the raw specifier, consulted before anything else. First match wins.
38
+ patterns: {
39
+ type: 'array',
40
+ items: {
41
+ type: 'object',
42
+ properties: {
43
+ pattern: { type: 'string' },
44
+ group: { type: 'string', enum: ['package', 'local'] },
45
+ },
46
+ required: ['pattern', 'group'],
47
+ additionalProperties: false,
48
+ },
49
+ },
50
+ // How many blank lines separate the package block from the local block. 0 means none.
51
+ blankLines: { type: 'integer', minimum: 0 },
52
+ // How many blank lines separate the imports from whatever follows them.
53
+ afterImports: { type: 'integer', minimum: 0 },
54
+ // Line globs that still count as part of the imports block, so the gap is measured
55
+ // after them rather than after the last import.
56
+ addenda: { type: 'array', items: { type: 'string' }, uniqueItems: true },
57
+ typeOnly: { type: 'string', enum: ['declaration', 'inline', 'ignore'] },
58
+ },
59
+ additionalProperties: false,
60
+ },
61
+ ],
62
+ messages: {
63
+ groupOrder: 'A {{group}} import must come before {{other}} imports.',
64
+ blankLines: 'Expected {{expected}} blank {{lines}} between package imports and local imports, found {{actual}}.',
65
+ unexpectedBlankLine: 'Unexpected blank line between imports in the same group.',
66
+ typeSpecifiersLast: 'Type specifiers must come after value specifiers.',
67
+ markTypeOnly: 'An import of nothing but types must be written `import type { A }`.',
68
+ inlineTypeSpecifiers: 'A type-only import must mark each specifier: `import { type A }`.',
69
+ afterImports: 'Expected {{expected}} blank {{lines}} after the imports, found {{actual}}.',
70
+ },
71
+ },
72
+ create(context) {
73
+ const source = context.sourceCode;
74
+ const localPrefixes = context.options[0]?.local ?? [];
75
+ const patterns = (context.options[0]?.patterns ?? []).map(entry => ({
76
+ test: globToRegExp(entry.pattern),
77
+ group: entry.group,
78
+ }));
79
+ const blankLines = context.options[0]?.blankLines ?? DEFAULT_BLANK_LINES;
80
+ const afterImports = context.options[0]?.afterImports ?? DEFAULT_AFTER_IMPORTS;
81
+ const addenda = (context.options[0]?.addenda ?? []).map(lineGlobToRegExp);
82
+ const typeOnly = context.options[0]?.typeOnly ?? DEFAULT_TYPE_ONLY;
83
+ const eol = BREAK.exec(source.text)?.[0] ?? '\n';
84
+ const localityOf = (path) => {
85
+ for (const { test, group } of patterns)
86
+ if (test.test(path))
87
+ return group;
88
+ if (path === '.' || path === '..' || path.startsWith('./') || path.startsWith('../'))
89
+ return 'local';
90
+ if (localPrefixes.some(prefix => path.startsWith(prefix)))
91
+ return 'local';
92
+ return 'package';
93
+ };
94
+ const namedOf = (node) => {
95
+ const named = [];
96
+ for (const specifier of node.specifiers) {
97
+ if (specifier.type === 'ImportSpecifier')
98
+ named.push(specifier);
99
+ }
100
+ return named;
101
+ };
102
+ // Nothing but inline `type` specifiers: a type-only import that has not said so, and so would
103
+ // otherwise sort into the value group.
104
+ const unmarkedTypeOnly = (node) => node.importKind === 'value'
105
+ && node.specifiers.length > 0
106
+ && node.specifiers.every(s => s.type === 'ImportSpecifier' && s.importKind === 'type');
107
+ const groupOf = (node) => {
108
+ const typeOnly = node.importKind === 'type' || unmarkedTypeOnly(node);
109
+ const base = localityOf(node.source.value) === 'local' ? LOCAL_TYPE : PACKAGE_TYPE;
110
+ return typeOnly ? base : base + 1;
111
+ };
112
+ const braceInterior = (named) => {
113
+ const first = named[0];
114
+ const last = named[named.length - 1];
115
+ if (!first || !last)
116
+ return undefined;
117
+ const open = source.getTokenBefore(first, { filter: token => token.value === '{' });
118
+ const close = source.getTokenAfter(last, { filter: token => token.value === '}' });
119
+ if (!open || !close)
120
+ return undefined;
121
+ return [open.range[1], close.range[0]];
122
+ };
123
+ /** Re-join specifiers in the shape the braces already had, so a multi-line import stays one. */
124
+ const rejoin = (range, texts) => {
125
+ const interior = source.text.slice(range[0], range[1]);
126
+ if (!BREAK.test(interior)) {
127
+ const pad = /^\s/.test(interior) ? ' ' : '';
128
+ return `${pad}${texts.join(', ')}${pad}`;
129
+ }
130
+ const indent = /(?:\r\n|\n|\r)([^\S\r\n]*)\S/.exec(interior)?.[1] ?? ' ';
131
+ const closeIndent = /(?:\r\n|\n|\r)([^\S\r\n]*)$/.exec(interior)?.[1] ?? '';
132
+ const comma = /,\s*$/.test(interior) ? ',' : '';
133
+ return `${eol}${indent}${texts.join(`,${eol}${indent}`)}${comma}${eol}${closeIndent}`;
134
+ };
135
+ const asValueSpecifier = (specifier) => {
136
+ const imported = source.getText(specifier.imported);
137
+ if (specifier.imported.type === 'Identifier' && specifier.imported.name === specifier.local.name) {
138
+ return imported;
139
+ }
140
+ return `${imported} as ${specifier.local.name}`;
141
+ };
142
+ const checkSpecifiers = (node) => {
143
+ const named = namedOf(node);
144
+ const range = braceInterior(named);
145
+ if (!range)
146
+ return;
147
+ if (node.importKind === 'type') {
148
+ // A default or namespace import has nowhere to put an inline marker, so it stays as it is.
149
+ if (typeOnly !== 'inline' || named.length !== node.specifiers.length)
150
+ return;
151
+ const keyword = source.getFirstToken(node);
152
+ const marker = keyword ? source.getTokenAfter(keyword) : undefined;
153
+ if (!keyword || !marker || marker.value !== 'type')
154
+ return;
155
+ context.report({
156
+ node,
157
+ messageId: 'inlineTypeSpecifiers',
158
+ fix: fixer => [
159
+ fixer.removeRange([keyword.range[1], marker.range[1]]),
160
+ fixer.replaceTextRange(range, rejoin(range, named.map(specifier => `type ${source.getText(specifier)}`))),
161
+ ],
162
+ });
163
+ return;
164
+ }
165
+ if (typeOnly === 'declaration' && unmarkedTypeOnly(node)) {
166
+ const keyword = source.getFirstToken(node);
167
+ if (!keyword)
168
+ return;
169
+ context.report({
170
+ node,
171
+ messageId: 'markTypeOnly',
172
+ fix: fixer => [
173
+ fixer.insertTextAfter(keyword, ' type'),
174
+ fixer.replaceTextRange(range, rejoin(range, named.map(asValueSpecifier))),
175
+ ],
176
+ });
177
+ return;
178
+ }
179
+ const values = [];
180
+ const types = [];
181
+ for (const specifier of named) {
182
+ if (specifier.importKind === 'type')
183
+ types.push(specifier);
184
+ else
185
+ values.push(specifier);
186
+ }
187
+ if (types.length === 0 || values.length === 0)
188
+ return;
189
+ const firstType = named.findIndex(specifier => specifier.importKind === 'type');
190
+ const misplaced = named.slice(firstType).find(specifier => specifier.importKind !== 'type');
191
+ if (!misplaced)
192
+ return;
193
+ context.report({
194
+ node: misplaced,
195
+ messageId: 'typeSpecifiersLast',
196
+ fix: fixer => fixer.replaceTextRange(range, rejoin(range, [...values, ...types].map(specifier => source.getText(specifier)))),
197
+ });
198
+ };
199
+ /** An import plus the comments that travel with it. */
200
+ const entriesOf = (chunk) => {
201
+ const entries = [];
202
+ for (const node of chunk) {
203
+ const previous = entries[entries.length - 1];
204
+ let start = node.range[0];
205
+ // An own-line comment belongs to the import beneath it. One above the *first* import of
206
+ // a chunk is left where it is, being as likely a file header as a note about the import.
207
+ if (previous) {
208
+ for (const comment of source.getCommentsBefore(node)) {
209
+ if (comment.range[0] >= previous.end) {
210
+ start = comment.range[0];
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ let end = node.range[1];
216
+ const trailing = source.getCommentsAfter(node)[0];
217
+ if (trailing && trailing.loc.start.line === node.loc.end.line)
218
+ end = trailing.range[1];
219
+ entries.push({ node, group: groupOf(node), start, end });
220
+ }
221
+ return entries;
222
+ };
223
+ const lineAt = (index) => source.lines[index] ?? '';
224
+ const isBlank = (index) => lineAt(index).trim() === '';
225
+ /**
226
+ * Blank lines between the imports block and whatever follows it.
227
+ *
228
+ * Line-based rather than node-based, because `addenda` is about lines: a blank line or a line
229
+ * matching one of those globs is still part of the block, so the gap starts after the last of
230
+ * them. Nothing is reported at end of file — trailing blank lines belong to `start-end-lines`.
231
+ */
232
+ const checkAfterImports = (last) => {
233
+ // `source.lines` is 0-based, so line N sits at index N - 1 and index N is the line after it
234
+ let cursor = source.getLocFromIndex(last.end).line;
235
+ let blockEnd = cursor;
236
+ while (cursor < source.lines.length) {
237
+ if (isBlank(cursor))
238
+ cursor++;
239
+ else if (addenda.some(pattern => pattern.test(lineAt(cursor).trim())))
240
+ blockEnd = ++cursor;
241
+ else
242
+ break;
243
+ }
244
+ let next = blockEnd;
245
+ while (next < source.lines.length && isBlank(next))
246
+ next++;
247
+ if (next >= source.lines.length)
248
+ return;
249
+ const blank = next - blockEnd;
250
+ if (blank === afterImports)
251
+ return;
252
+ const from = source.getIndexFromLoc({ line: blockEnd, column: lineAt(blockEnd - 1).length });
253
+ const to = source.getIndexFromLoc({ line: next + 1, column: 0 });
254
+ context.report({
255
+ node: last.node,
256
+ messageId: 'afterImports',
257
+ data: {
258
+ expected: String(afterImports),
259
+ actual: String(blank),
260
+ lines: afterImports === 1 ? 'line' : 'lines',
261
+ },
262
+ fix: fixer => fixer.replaceTextRange([from, to], eol.repeat(afterImports + 1)),
263
+ });
264
+ };
265
+ const checkChunk = (chunk) => {
266
+ const entries = entriesOf(chunk);
267
+ const first = entries[0];
268
+ const last = entries[entries.length - 1];
269
+ if (!first || !last)
270
+ return;
271
+ checkAfterImports(last);
272
+ if (entries.length < 2)
273
+ return;
274
+ const rewrite = fixer => {
275
+ // Stable, so imports that share a group keep the order they were written in.
276
+ const sorted = [...entries].sort((a, b) => a.group - b.group);
277
+ let text = '';
278
+ let previous;
279
+ for (const entry of sorted) {
280
+ if (previous) {
281
+ const boundary = isLocalGroup(entry.group) !== isLocalGroup(previous.group);
282
+ text += eol.repeat(boundary ? blankLines + 1 : 1);
283
+ }
284
+ text += source.text.slice(entry.start, entry.end);
285
+ previous = entry;
286
+ }
287
+ return fixer.replaceTextRange([first.start, last.end], text);
288
+ };
289
+ let previousGroup = -1;
290
+ for (const entry of entries) {
291
+ if (entry.group < previousGroup) {
292
+ context.report({
293
+ node: entry.node,
294
+ messageId: 'groupOrder',
295
+ data: { group: nameOfGroup(entry.group), other: nameOfGroup(previousGroup) },
296
+ fix: rewrite,
297
+ });
298
+ return;
299
+ }
300
+ previousGroup = entry.group;
301
+ }
302
+ for (let i = 1; i < entries.length; i++) {
303
+ const previous = entries[i - 1];
304
+ const entry = entries[i];
305
+ if (!previous || !entry)
306
+ continue;
307
+ const gap = source.text.slice(previous.end, entry.start);
308
+ const blank = Math.max((gap.match(BREAK_GLOBAL) ?? []).length - 1, 0);
309
+ const boundary = isLocalGroup(entry.group) !== isLocalGroup(previous.group);
310
+ const wanted = boundary ? blankLines : 0;
311
+ if (blank === wanted)
312
+ continue;
313
+ if (boundary) {
314
+ context.report({
315
+ node: entry.node,
316
+ messageId: 'blankLines',
317
+ data: { expected: String(wanted), actual: String(blank), lines: wanted === 1 ? 'line' : 'lines' },
318
+ fix: rewrite,
319
+ });
320
+ }
321
+ else {
322
+ context.report({ node: entry.node, messageId: 'unexpectedBlankLine', fix: rewrite });
323
+ }
324
+ return;
325
+ }
326
+ };
327
+ return {
328
+ ImportDeclaration: checkSpecifiers,
329
+ 'Program:exit'(program) {
330
+ let chunk = [];
331
+ const flush = () => {
332
+ if (chunk.length > 0)
333
+ checkChunk(chunk);
334
+ chunk = [];
335
+ };
336
+ for (const statement of program.body) {
337
+ // A bare `import 'x'` sorts like any other — where its side effect has to run in a
338
+ // particular place, that is what a disable comment is for. Anything that is not an
339
+ // import does end the run, since moving code across it could change what happens.
340
+ if (statement.type === 'ImportDeclaration')
341
+ chunk.push(statement);
342
+ else
343
+ flush();
344
+ }
345
+ flush();
346
+ },
347
+ };
348
+ },
349
+ };
350
+ export default rule;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "eslint-plugin-imports-regulation",
3
+ "version": "0.1.0",
4
+ "description": "Order imports: package type-only, package, blank line, local type-only, local — with type specifiers last inside each import.",
5
+ "author": "Robert Sandiford",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "!dist/**/*.test.*"
15
+ ],
16
+ "keywords": [
17
+ "eslint",
18
+ "eslintplugin",
19
+ "eslint-plugin",
20
+ "typescript",
21
+ "import",
22
+ "import-order"
23
+ ],
24
+ "license": "ISC",
25
+ "dependencies": {
26
+ "@typescript-eslint/utils": "^8.67.0"
27
+ },
28
+ "peerDependencies": {
29
+ "eslint": "^9.39.4 || ^10.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.19.15",
33
+ "eslint": "^10.8.1",
34
+ "typescript-eslint": "^8.67.0"
35
+ },
36
+ "scripts": {
37
+ "compile": "tsc",
38
+ "build": "tsc --watch",
39
+ "test": "node --test --watch \"dist/**/*.test.js\"",
40
+ "test-once": "tsc && node --test \"dist/**/*.test.js\"",
41
+ "release": "node ../../scripts/release-package.ts"
42
+ }
43
+ }