@blumintinc/eslint-plugin-blumint 1.20.85 → 1.20.87

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.
@@ -4,6 +4,7 @@ exports.preferUseDeepCompareMemo = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const importInsertion_1 = require("../utils/importInsertion");
7
8
  const DEEP_COMPARE_MODULE = '@blumintinc/use-deep-compare';
8
9
  const DEEP_COMPARE_HOOK = 'useDeepCompareMemo';
9
10
  // Consider these as memoizing hooks producing stable references
@@ -199,28 +200,23 @@ function bindsHookImport(variable, hookImport) {
199
200
  variable.defs.every((def) => def.node === hookImport));
200
201
  }
201
202
  function ensureDeepCompareImportFixes(context, fixer) {
202
- const fixes = [];
203
203
  const sourceCode = context.sourceCode;
204
204
  const program = sourceCode.ast;
205
205
  // If already imported anywhere, skip adding
206
206
  if (findDeepCompareMemoImport(program))
207
- return fixes;
208
- // Determine insertion point and indentation
209
- const importDecls = program.body.filter((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
210
- const fullText = sourceCode.getText();
211
- let importText = `import { useDeepCompareMemo } from '@blumintinc/use-deep-compare';\n`;
212
- if (importDecls.length === 0) {
213
- fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
214
- }
215
- else {
216
- const firstImport = importDecls[0];
217
- const before = fullText.slice(0, firstImport.range[0]);
218
- const lastNewline = before.lastIndexOf('\n');
219
- const indent = lastNewline >= 0 ? before.slice(lastNewline + 1) : '';
220
- importText = `${indent}${importText}`;
221
- fixes.push(fixer.insertTextBefore(firstImport, importText));
222
- }
223
- return fixes;
207
+ return [];
208
+ // The shared anchor keeps the insertion below whatever opens the file: text
209
+ // spliced above a `#!` shebang stops the file parsing, and text above a
210
+ // `'use client'` directive or a header comment strips the prologue of the
211
+ // meaning it only carries while it leads.
212
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
213
+ const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
214
+ const importText = `${indent}import { ${DEEP_COMPARE_HOOK} } from '${DEEP_COMPARE_MODULE}';\n`;
215
+ // Widened to the anchor's line start because the emitted statement carries
216
+ // its own indentation, leaving the displaced anchor sitting on the original.
217
+ return [
218
+ (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), importText),
219
+ ];
224
220
  }
225
221
  function findVariableInScope(scope, name) {
226
222
  if (!scope)
@@ -4,6 +4,7 @@ exports.reactMemoizeLiterals = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const importInsertion_1 = require("../utils/importInsertion");
7
8
  const LITERAL_DESCRIPTOR_BY_TYPE = {
8
9
  [utils_1.AST_NODE_TYPES.ObjectExpression]: {
9
10
  literalType: 'object literal',
@@ -672,7 +673,8 @@ function bindsReactHook(variable, hookName) {
672
673
  * already imports it. Extending an existing declaration is preferred over a new
673
674
  * one so a file keeps a single `react` specifier list.
674
675
  */
675
- function buildHookImportFix(fixer, program, hookName) {
676
+ function buildHookImportFix(fixer, sourceCode, hookName) {
677
+ const program = sourceCode.ast;
676
678
  if (importsHook(program, hookName)) {
677
679
  return null;
678
680
  }
@@ -694,12 +696,11 @@ function buildHookImportFix(fixer, program, hookName) {
694
696
  if (defaultSpecifier) {
695
697
  return fixer.insertTextAfter(defaultSpecifier, `, { ${hookName} }`);
696
698
  }
699
+ // The shared anchor keeps the declaration below whatever prologue the file
700
+ // opens with: a `'use client'` directive stops being a directive, and a `#!`
701
+ // shebang stops parsing, once a statement is spliced above it.
697
702
  const statement = `import { ${hookName} } from '${REACT_MODULE}';\n`;
698
- const firstImport = program.body.find((node) => node.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
699
- const anchor = firstImport ?? program.body[0];
700
- return anchor
701
- ? fixer.insertTextBefore(anchor, statement)
702
- : fixer.insertTextAfterRange([0, 0], statement);
703
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), statement);
703
704
  }
704
705
  /**
705
706
  * Scope kinds whose bindings are established once per module evaluation:
@@ -834,7 +835,7 @@ function buildMemoSuggestions(node, descriptor, sourceCode, context) {
834
835
  // two disjoint ranges can be applied independently, stranding the
835
836
  // wrapper without its binding.
836
837
  const fixes = [];
837
- const importFix = buildHookImportFix(fixer, sourceCode.ast, descriptor.memoHook);
838
+ const importFix = buildHookImportFix(fixer, sourceCode, descriptor.memoHook);
838
839
  if (importFix) {
839
840
  fixes.push(importFix);
840
841
  }
@@ -6,6 +6,7 @@ exports.requireMemo = void 0;
6
6
  const utils_1 = require("@typescript-eslint/utils");
7
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
8
8
  const createRule_1 = require("../utils/createRule");
9
+ const importInsertion_1 = require("../utils/importInsertion");
9
10
  const isComponentExplicitlyUnmemoized = (componentName) => componentName.toLowerCase().includes('unmemoized');
10
11
  // React's universal convention: only PascalCase-initial identifiers are
11
12
  // treated as components. camelCase names are render-prop callbacks or plain
@@ -190,15 +191,17 @@ function checkFunction(context, node) {
190
191
  // Calculate relative path based on current file location
191
192
  const currentFilePath = context.getFilename();
192
193
  const importPath = calculateImportPath(currentFilePath);
193
- // Find the first import statement to insert after
194
- const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
195
- // Add new import statement for memo
196
194
  const importStatement = `import { memo } from '${importPath}';`;
195
+ const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
196
+ // An existing import hosts the helper import directly after
197
+ // it, keeping the module's imports contiguous. With none to
198
+ // follow, the shared anchor keeps the file's prologue in
199
+ // place: a `'use client'` directive only counts as one while
200
+ // it is the first statement, and a `#!` shebang only parses
201
+ // at character 0.
197
202
  importFix = firstImport
198
- ? // Insert after the first import with a single newline
199
- fixer.insertTextAfter(firstImport, '\n' + importStatement)
200
- : // Insert at the start of the file
201
- fixer.insertTextBeforeRange([program.range[0], program.range[0]], importStatement + '\n');
203
+ ? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
204
+ : (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`);
202
205
  }
203
206
  }
204
207
  const functionKeywordRange = [
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const importInsertion_1 = require("../utils/importInsertion");
8
9
  const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
9
10
  const MEMOIZE_MODULES = new Set([
10
11
  MEMOIZE_PREFERRED_MODULE,
@@ -407,19 +408,13 @@ function getImportFixes(fixer, sourceCode, hasMemoizeImport, scheduledImportFix)
407
408
  return { fixes, scheduledImportFix: true };
408
409
  }
409
410
  }
410
- const firstImport = programBody.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
411
- const anchorNode = (firstImport ?? programBody[0]);
412
- if (anchorNode) {
413
- const text = sourceCode.text;
414
- const anchorStart = anchorNode.range?.[0] ?? 0;
415
- const lineStart = text.lastIndexOf('\n', anchorStart - 1) + 1;
416
- const leadingWhitespace = text.slice(lineStart, anchorStart).match(/^[ \t]*/)?.[0] ?? '';
417
- const importLine = `${leadingWhitespace}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`;
418
- fixes.push(fixer.insertTextBeforeRange([lineStart, lineStart], importLine));
419
- }
420
- else {
421
- fixes.push(fixer.insertTextBeforeRange([0, 0], `import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
422
- }
411
+ // The anchor sits past the file's prologue, so a `'use client'` directive
412
+ // keeps its position as the first statement and a `#!` shebang keeps
413
+ // character 0. Emitting the anchor's own indentation after the import leaves
414
+ // the displaced statement indented exactly as it was.
415
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
416
+ const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
417
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n${indent}`));
423
418
  return { fixes, scheduledImportFix: true };
424
419
  }
425
420
  exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
@@ -0,0 +1,52 @@
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ /**
3
+ * Where a brand-new top-of-file import belongs. `before` anchors at a node (or
4
+ * a comment bound to the node below it); `index` is a raw character offset for
5
+ * files with nothing to anchor to — a shebang-only, directive-only, or empty
6
+ * file.
7
+ */
8
+ export type ImportInsertionAnchor = {
9
+ kind: 'before';
10
+ target: TSESTree.Node | TSESTree.Comment;
11
+ } | {
12
+ kind: 'index';
13
+ index: number;
14
+ };
15
+ /**
16
+ * The source a rule needs to place an import. Narrower than
17
+ * `TSESLint.SourceCode` so tests can drive the helper without a full linter
18
+ * run.
19
+ */
20
+ export type ImportInsertionSource = Pick<TSESLint.SourceCode, 'text' | 'ast' | 'getCommentsBefore'>;
21
+ /**
22
+ * Resolves the position where a fixer may insert a new import declaration
23
+ * without changing what the file's prologue governs. A `'use client'` /
24
+ * `'use server'` directive must stay the first statement or it stops being a
25
+ * directive; a `#!` shebang must stay at character 0 or the file stops
26
+ * parsing; a leading `// @ts-nocheck` (or any header comment) keeps its
27
+ * meaning only above the code it covers. The anchor therefore lands on the
28
+ * first import if one exists, else on the first non-directive statement —
29
+ * both positions sit past the prologue because comments are excluded from a
30
+ * node's range — and only degenerate files (no statements at all) fall back
31
+ * to a computed offset.
32
+ */
33
+ export declare function importInsertionAnchor(sourceCode: ImportInsertionSource): ImportInsertionAnchor;
34
+ /**
35
+ * Applies `text` at `anchor`. Callers keep full control of the inserted
36
+ * statement and its separators; the only adjustment made here is a leading
37
+ * newline when a raw-offset anchor sits mid-line (after a directive's `;` or
38
+ * at the unterminated end of a shebang), where splicing text verbatim would
39
+ * fuse the import onto the prologue's line.
40
+ */
41
+ export declare function insertAtImportAnchor(sourceCode: ImportInsertionSource, fixer: TSESLint.RuleFixer, anchor: ImportInsertionAnchor, text: string): TSESLint.RuleFix;
42
+ /**
43
+ * Widens `anchor` to the start of its line, for rules that emit
44
+ * `${indent}import …\n` so the displaced anchor keeps its own indentation.
45
+ * Raw-offset anchors pass through: they never sit inside an indented line.
46
+ */
47
+ export declare function importAnchorLineStart(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): ImportInsertionAnchor;
48
+ /**
49
+ * The whitespace prefix of the anchor's line, for fixers that replicate the
50
+ * anchor's indentation on the inserted import.
51
+ */
52
+ export declare function importAnchorIndent(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): string;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.importAnchorIndent = exports.importAnchorLineStart = exports.insertAtImportAnchor = exports.importInsertionAnchor = void 0;
4
+ const utils_1 = require("@typescript-eslint/utils");
5
+ const disableDirectives_1 = require("./disableDirectives");
6
+ /**
7
+ * A comment that governs the line directly below it. Splicing an import
8
+ * between such a comment and its subject silently retargets the suppression
9
+ * at the import, so the anchor must climb above the whole run.
10
+ */
11
+ function bindsNextLine(comment) {
12
+ const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
13
+ if (directive?.kind === 'disable-next-line') {
14
+ return true;
15
+ }
16
+ const value = comment.value.trim();
17
+ return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
18
+ }
19
+ /**
20
+ * Walks upward from `anchor` over line-binding suppression comments that sit
21
+ * on consecutive lines, so the eventual insertion lands above them rather
22
+ * than between a suppression and the line it covers.
23
+ */
24
+ function climbBoundComments(sourceCode, anchor) {
25
+ let target = anchor;
26
+ const comments = sourceCode.getCommentsBefore(anchor);
27
+ for (let index = comments.length - 1; index >= 0; index--) {
28
+ const comment = comments[index];
29
+ if (!bindsNextLine(comment) ||
30
+ comment.loc.end.line + 1 !== target.loc.start.line) {
31
+ break;
32
+ }
33
+ target = comment;
34
+ }
35
+ return target;
36
+ }
37
+ function isDirective(statement) {
38
+ return (statement.type === utils_1.AST_NODE_TYPES.ExpressionStatement &&
39
+ typeof statement.directive === 'string');
40
+ }
41
+ /**
42
+ * Resolves the position where a fixer may insert a new import declaration
43
+ * without changing what the file's prologue governs. A `'use client'` /
44
+ * `'use server'` directive must stay the first statement or it stops being a
45
+ * directive; a `#!` shebang must stay at character 0 or the file stops
46
+ * parsing; a leading `// @ts-nocheck` (or any header comment) keeps its
47
+ * meaning only above the code it covers. The anchor therefore lands on the
48
+ * first import if one exists, else on the first non-directive statement —
49
+ * both positions sit past the prologue because comments are excluded from a
50
+ * node's range — and only degenerate files (no statements at all) fall back
51
+ * to a computed offset.
52
+ */
53
+ function importInsertionAnchor(sourceCode) {
54
+ const { body } = sourceCode.ast;
55
+ const anchorNode = body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration) ?? body.find((statement) => !isDirective(statement));
56
+ if (anchorNode) {
57
+ return {
58
+ kind: 'before',
59
+ target: climbBoundComments(sourceCode, anchorNode),
60
+ };
61
+ }
62
+ const lastDirective = [...body].reverse().find(isDirective);
63
+ if (lastDirective) {
64
+ // Start of the line after the directive, so a trailing same-line comment
65
+ // stays glued to its statement and no blank line is spliced in.
66
+ const lineEnd = sourceCode.text.indexOf('\n', lastDirective.range[1]);
67
+ return {
68
+ kind: 'index',
69
+ index: lineEnd === -1 ? sourceCode.text.length : lineEnd + 1,
70
+ };
71
+ }
72
+ if (sourceCode.text.startsWith('#!')) {
73
+ const lineEnd = sourceCode.text.indexOf('\n');
74
+ return {
75
+ kind: 'index',
76
+ index: lineEnd === -1 ? sourceCode.text.length : lineEnd + 1,
77
+ };
78
+ }
79
+ return { kind: 'index', index: 0 };
80
+ }
81
+ exports.importInsertionAnchor = importInsertionAnchor;
82
+ /**
83
+ * Applies `text` at `anchor`. Callers keep full control of the inserted
84
+ * statement and its separators; the only adjustment made here is a leading
85
+ * newline when a raw-offset anchor sits mid-line (after a directive's `;` or
86
+ * at the unterminated end of a shebang), where splicing text verbatim would
87
+ * fuse the import onto the prologue's line.
88
+ */
89
+ function insertAtImportAnchor(sourceCode, fixer, anchor, text) {
90
+ if (anchor.kind === 'before') {
91
+ return fixer.insertTextBefore(anchor.target, text);
92
+ }
93
+ const needsNewline = anchor.index > 0 && sourceCode.text[anchor.index - 1] !== '\n';
94
+ return fixer.insertTextBeforeRange([anchor.index, anchor.index], needsNewline ? `\n${text}` : text);
95
+ }
96
+ exports.insertAtImportAnchor = insertAtImportAnchor;
97
+ /**
98
+ * Widens `anchor` to the start of its line, for rules that emit
99
+ * `${indent}import …\n` so the displaced anchor keeps its own indentation.
100
+ * Raw-offset anchors pass through: they never sit inside an indented line.
101
+ */
102
+ function importAnchorLineStart(sourceCode, anchor) {
103
+ if (anchor.kind === 'index') {
104
+ return anchor;
105
+ }
106
+ const lineStart = sourceCode.text.lastIndexOf('\n', anchor.target.range[0] - 1) + 1;
107
+ return { kind: 'index', index: lineStart };
108
+ }
109
+ exports.importAnchorLineStart = importAnchorLineStart;
110
+ /**
111
+ * The whitespace prefix of the anchor's line, for fixers that replicate the
112
+ * anchor's indentation on the inserted import.
113
+ */
114
+ function importAnchorIndent(sourceCode, anchor) {
115
+ const from = anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
116
+ const lineStart = sourceCode.text.lastIndexOf('\n', from - 1) + 1;
117
+ const match = /^[ \t]*/.exec(sourceCode.text.slice(lineStart, from));
118
+ return match?.[0] ?? '';
119
+ }
120
+ exports.importAnchorIndent = importAnchorIndent;
121
+ //# sourceMappingURL=importInsertion.js.map
@@ -0,0 +1,57 @@
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ /** A half-open `[start, end)` slice of the source text. */
3
+ export type TextRange = readonly [number, number];
4
+ /** An import clause that introduces exactly one local binding. */
5
+ export type ImportBindingSpecifier = TSESTree.ImportSpecifier | TSESTree.ImportDefaultSpecifier | TSESTree.ImportNamespaceSpecifier;
6
+ /**
7
+ * The source a rule needs to unbind an import. Narrower than
8
+ * `TSESLint.SourceCode` so tests can drive the helper without a full linter
9
+ * run.
10
+ */
11
+ export type ImportRemovalSource = Pick<TSESLint.SourceCode, 'text' | 'ast' | 'scopeManager' | 'getTokenBefore' | 'getTokenAfter' | 'getCommentsBefore' | 'getCommentsInside'>;
12
+ /**
13
+ * The text ranges that unbind `specifiers` from their shared declaration, or
14
+ * `null` when no removal is provably safe. All specifiers of the declaration
15
+ * being listed collapses the declaration itself rather than leaving
16
+ * `import {} from './User';` behind, and losing every *named* specifier next to
17
+ * a surviving default takes the braces with it.
18
+ *
19
+ * Any comment inside the declaration declines the removal outright: the ranges
20
+ * computed here span separators, so a comment nested among the specifiers
21
+ * would be swallowed or stranded depending on where it sits.
22
+ */
23
+ export declare function planImportBindingRemoval(source: ImportRemovalSource, specifiers: readonly ImportBindingSpecifier[]): TextRange[] | null;
24
+ /**
25
+ * Fixes unbinding `specifiers`, or `null` when the removal is not provably
26
+ * safe. A caller that gets `null` must drop its whole fix: a rewrite that
27
+ * strips a binding's last use while leaving the binding behind trades its own
28
+ * report for an unused-variable one.
29
+ */
30
+ export declare function removeImportBindingFixes(source: ImportRemovalSource, fixer: TSESLint.RuleFixer, specifiers: readonly ImportBindingSpecifier[]): TSESLint.RuleFix[] | null;
31
+ /**
32
+ * The specifier that binds `variable`, when the variable is a named/default/
33
+ * namespace import. `import X = require('y')` binds through a declaration of
34
+ * its own rather than a specifier and is left alone.
35
+ */
36
+ export declare function importBindingSpecifierOf(variable: TSESLint.Scope.Variable): ImportBindingSpecifier | undefined;
37
+ /**
38
+ * The extra ranges a fix must delete so that removing `removed` leaves nothing
39
+ * bound to nothing, or `null` when some binding would be orphaned yet cannot be
40
+ * unbound safely — an import behind a directive comment, a type alias or a type
41
+ * parameter, all of which this helper refuses to guess at.
42
+ *
43
+ * `null` is a demand, not a suggestion: the caller declines its whole fix. The
44
+ * alternative — deleting the last use and keeping the declaration — converts a
45
+ * file that lints clean into one that fails `no-unused-vars`, and since the
46
+ * original report is resolved by the fix, nothing re-reports the debt.
47
+ *
48
+ * `removed` is one fix's own deletion, judged against the file as it stands.
49
+ * Widening it to "everything this `--fix` run will delete" looks like it would
50
+ * catch a binding two edits share, but it is unsound: a rule cannot see
51
+ * `eslint-disable` comments, because suppression is applied to reports after the
52
+ * rule emits them. A suppressed sibling edit never happens, so the widened view
53
+ * deletes an import that a surviving reference still needs — trading an unused
54
+ * import for a dangling type reference, a lint warning for a compile error.
55
+ * There is deliberately no way to ask this helper that question.
56
+ */
57
+ export declare function planOrphanedImportRemoval(source: ImportRemovalSource, removed: readonly TextRange[]): TextRange[] | null;