eslint-plugin-imports-regulation 0.2.1 → 0.2.3

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.
@@ -1,5 +1,5 @@
1
1
  import type { TSESLint } from '@typescript-eslint/utils';
2
- type MessageIds = 'groupOrder' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'typeSpecifiersLast' | 'markTypeOnly' | 'blankLinesAfter';
2
+ type MessageIds = 'groupOrder' | 'blankLinesBetween' | 'unexpectedBlankLine' | 'typeSpecifiersLast' | 'markTypeOnly' | 'typeOnlyImport' | 'typeOnlySpecifier' | 'blankLinesAfter';
3
3
  type Origin = 'external' | 'internal';
4
4
  type Options = [
5
5
  {
@@ -12,6 +12,7 @@ type Options = [
12
12
  blankLinesAfter?: number;
13
13
  importsAddenda?: string[];
14
14
  preferTypeDeclarations?: boolean;
15
+ inferTypeOnlyImports?: boolean;
15
16
  }?
16
17
  ];
17
18
  declare const rule: TSESLint.RuleModule<MessageIds, Options>;
@@ -56,6 +56,8 @@ const rule = {
56
56
  // An import of nothing but types is rewritten `import type { A }`. Off, and the
57
57
  // spelling is left alone either way.
58
58
  preferTypeDeclarations: { type: 'boolean' },
59
+ // Mark bindings that scope analysis shows are only ever used in type positions.
60
+ inferTypeOnlyImports: { type: 'boolean' },
59
61
  },
60
62
  additionalProperties: false,
61
63
  },
@@ -66,6 +68,8 @@ const rule = {
66
68
  unexpectedBlankLine: 'Unexpected blank line between imports in the same group.',
67
69
  typeSpecifiersLast: 'Type specifiers must come after value specifiers.',
68
70
  markTypeOnly: 'An import of nothing but types must be written `import type { A }`.',
71
+ typeOnlyImport: 'Everything this imports is only used as a type — write `import type`.',
72
+ typeOnlySpecifier: '`{{name}}` is only used as a type — mark it `type`.',
69
73
  blankLinesAfter: 'Expected {{expected}} blank {{lines}} after the imports, found {{actual}}.',
70
74
  },
71
75
  },
@@ -81,6 +85,7 @@ const rule = {
81
85
  const blankLinesAfter = options?.blankLinesAfter ?? DEFAULT_BLANK_LINES_AFTER;
82
86
  const addenda = (options?.importsAddenda ?? []).map(lineGlobToRegExp);
83
87
  const preferTypeDeclarations = options?.preferTypeDeclarations ?? true;
88
+ const inferTypeOnlyImports = options?.inferTypeOnlyImports ?? true;
84
89
  const eol = BREAK.exec(source.text)?.[0] ?? '\n';
85
90
  const originOf = (path) => {
86
91
  for (const { test, group } of patterns)
@@ -141,13 +146,80 @@ const rule = {
141
146
  }
142
147
  return `${imported} as ${specifier.local.name}`;
143
148
  };
149
+ /**
150
+ * Local names this import binds that are only ever referenced from a type position.
151
+ *
152
+ * Scope analysis, not type information: the parser's scope manager flags each reference as a
153
+ * value or a type one. A binding with *no* references says nothing either way, so it is left
154
+ * out — deciding it is a type would fight `no-unused-vars` over an import that is on its way
155
+ * out anyway. Under a non-TypeScript parser no reference is a type reference, so this yields
156
+ * nothing and the check quietly does not apply.
157
+ */
158
+ const typeOnlyBindings = (node) => {
159
+ const names = new Set();
160
+ for (const variable of source.getDeclaredVariables(node)) {
161
+ if (variable.references.length === 0)
162
+ continue;
163
+ if (variable.references.every(reference => reference.isTypeReference))
164
+ names.add(variable.name);
165
+ }
166
+ return names;
167
+ };
168
+ /** Returns whether it reported, so the syntax-only checks can stand down. */
169
+ const checkInferredTypes = (node, named) => {
170
+ // Already spelled with inline markers throughout: `markTypeOnly` owns that case.
171
+ if (unmarkedTypeOnly(node))
172
+ return false;
173
+ const typeOnly = typeOnlyBindings(node);
174
+ if (typeOnly.size === 0)
175
+ return false;
176
+ const everyBinding = node.specifiers.length > 0
177
+ && node.specifiers.every(specifier => typeOnly.has(specifier.local.name));
178
+ // Not gated on `preferTypeDeclarations`: that option is about leaving a spelling you
179
+ // wrote alone. Here there is no spelling yet, and `import type` is the one to write.
180
+ if (everyBinding) {
181
+ const keyword = source.getFirstToken(node);
182
+ if (!keyword)
183
+ return false;
184
+ const range = braceInterior(named);
185
+ context.report({
186
+ node,
187
+ messageId: 'typeOnlyImport',
188
+ fix: fixer => {
189
+ const fixes = [fixer.insertTextAfter(keyword, ' type')];
190
+ // An inline marker is redundant under `import type`, and illegal besides.
191
+ if (range && named.some(specifier => specifier.importKind === 'type')) {
192
+ fixes.push(fixer.replaceTextRange(range, rejoin(range, named.map(asValueSpecifier))));
193
+ }
194
+ return fixes;
195
+ },
196
+ });
197
+ return true;
198
+ }
199
+ // A default or namespace binding has nowhere to put an inline marker, so only named
200
+ // specifiers can be marked one at a time.
201
+ const unmarked = named.filter(specifier => specifier.importKind !== 'type' && typeOnly.has(specifier.local.name));
202
+ if (unmarked.length === 0)
203
+ return false;
204
+ for (const specifier of unmarked) {
205
+ context.report({
206
+ node: specifier,
207
+ messageId: 'typeOnlySpecifier',
208
+ data: { name: specifier.local.name },
209
+ fix: fixer => fixer.insertTextBefore(specifier, 'type '),
210
+ });
211
+ }
212
+ return true;
213
+ };
144
214
  const checkSpecifiers = (node) => {
215
+ if (node.importKind === 'type')
216
+ return;
145
217
  const named = namedOf(node);
218
+ if (inferTypeOnlyImports && checkInferredTypes(node, named))
219
+ return;
146
220
  const range = braceInterior(named);
147
221
  if (!range)
148
222
  return;
149
- if (node.importKind === 'type')
150
- return;
151
223
  if (preferTypeDeclarations && unmarkedTypeOnly(node)) {
152
224
  const keyword = source.getFirstToken(node);
153
225
  if (!keyword)
@@ -239,6 +311,13 @@ const rule = {
239
311
  const to = source.getIndexFromLoc({ line: next + 1, column: 0 });
240
312
  context.report({
241
313
  node: last.node,
314
+ // The line that follows the gap, not the import above it: the import is where it
315
+ // belongs, and squiggling it reads as though it were the thing at fault. This also
316
+ // matches the between-imports checks, which report the import *after* their gap.
317
+ loc: {
318
+ start: { line: next + 1, column: 0 },
319
+ end: { line: next + 1, column: lineAt(next).length },
320
+ },
242
321
  messageId: 'blankLinesAfter',
243
322
  data: {
244
323
  expected: String(blankLinesAfter),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-imports-regulation",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Order imports: package type-only, package, blank line, local type-only, local — with type specifiers last inside each import.",
5
5
  "author": "Robert Sandiford",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.19.15",
33
33
  "eslint": "^10.8.1",
34
- "typescript-eslint": "^8.67.0"
34
+ "typescript-eslint": "^8.70.0"
35
35
  },
36
36
  "scripts": {
37
37
  "compile": "tsc",