@blumintinc/eslint-plugin-blumint 1.20.135 → 1.20.137

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/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.135',
226
+ version: '1.20.137',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -607,11 +607,27 @@ module.exports = (0, createRule_1.createRule)({
607
607
  globalVar.references.forEach((ref) => references.add(ref));
608
608
  }
609
609
  }
610
+ // A binding that leaves the module is one end of a cross-file
611
+ // contract. Renaming `export const handleClick` to `click` strands
612
+ // every `import { handleClick }` with TS2724, and a single-file fixer
613
+ // cannot reach those importers — the same reasoning that already
614
+ // withholds the JSX prop rename and the destructured one, which is
615
+ // where `isExportedBinding` was first needed. The violation still
616
+ // reports; only the rename is withheld.
617
+ const declaredVariable = context
618
+ .getDeclaredVariables(node)
619
+ .find((v) => v.identifiers.includes(node.id));
620
+ const leavesModule = declaredVariable
621
+ ? isExportedBinding(declaredVariable)
622
+ : isExportedDeclaration(node);
610
623
  context.report({
611
624
  node,
612
625
  messageId: 'callbackFunctionPrefix',
613
626
  data: { functionName },
614
627
  fix(fixer) {
628
+ if (leavesModule) {
629
+ return null;
630
+ }
615
631
  // Remove 'handle' prefix and convert first character to lowercase
616
632
  const newName = stripHandlePrefix(functionName);
617
633
  // `const handleDelete = fn` would become `const delete = fn`,
@@ -256,6 +256,54 @@ function isNumericCall(node) {
256
256
  callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
257
257
  callee.object.name === 'Math');
258
258
  }
259
+ /**
260
+ * The property names `assertSafe` exists to reject. A key that provably cannot
261
+ * spell one of these cannot reach the prototype surface, which is the entire
262
+ * hazard — so proving it is what earns an exemption, the same standard the
263
+ * numeric analysis already meets.
264
+ */
265
+ const PROTOTYPE_REACHING_KEYS = ['__proto__', 'constructor', 'prototype'];
266
+ /**
267
+ * Whether a template's FIXED text still leaves room to spell `target`.
268
+ *
269
+ * The producible set is `q0 + * + q1 + * + … + * + qN`, each `*` an arbitrary
270
+ * substitution. `target` is producible iff it starts with `q0`, ends with `qN`,
271
+ * and the interior quasis occur in order in between without overlapping. So
272
+ * `` `user-${id}` `` can never be `__proto__` (no such prefix) while
273
+ * `` `__pro${x}` `` can — with `x` = `'to__'`, which resolves to
274
+ * `Object.prototype` at runtime.
275
+ *
276
+ * Interior quasis are matched greedily from the left. That is sufficient
277
+ * because they are fixed strings: taking the earliest occurrence never consumes
278
+ * a character a later quasi needed, so no backtracking can succeed where the
279
+ * greedy pass fails.
280
+ *
281
+ * A template with no substitutions produces exactly one string and is a static
282
+ * key like any other string literal, so it is never treated as reaching.
283
+ */
284
+ function templateCanSpell(quasis, target) {
285
+ if (quasis.length < 2) {
286
+ return false;
287
+ }
288
+ const first = quasis[0];
289
+ const last = quasis[quasis.length - 1];
290
+ if (!target.startsWith(first) || !target.endsWith(last)) {
291
+ return false;
292
+ }
293
+ const limit = target.length - last.length;
294
+ let cursor = first.length;
295
+ if (cursor > limit) {
296
+ return false;
297
+ }
298
+ for (const middle of quasis.slice(1, -1)) {
299
+ const at = target.indexOf(middle, cursor);
300
+ if (at < 0 || at + middle.length > limit) {
301
+ return false;
302
+ }
303
+ cursor = at + middle.length;
304
+ }
305
+ return true;
306
+ }
259
307
  /**
260
308
  * A `: number` annotation on a binding name. Parameters and variable
261
309
  * declarators are the bindings that carry one, and TypeScript checks every
@@ -1133,19 +1181,34 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
1133
1181
  if (isLikelyArray) {
1134
1182
  return;
1135
1183
  }
1136
- // Only flag simple template literals that are just `${id}`
1137
- // Complex templates with additional text like `prefix_${id}_suffix` are allowed
1184
+ // A template whose every substitution is provably numeric can only
1185
+ // widen into digits, and no dangerous property name is the string
1186
+ // form of a number — the same proof the identifier path accepts.
1187
+ const canWidenToText = property.expressions.some((expr) => !isStaticallyNumeric(expr));
1188
+ const quasis = property.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw);
1189
+ const reachesPrototype = canWidenToText &&
1190
+ PROTOTYPE_REACHING_KEYS.some((key) => templateCanSpell(quasis, key));
1191
+ // Fixed text on either side of the substitution can rule a property
1192
+ // name out — `user-${id}` is never `__proto__` — and the rule skips
1193
+ // a key it can prove harmless. What it must NOT do is assume that:
1194
+ // `__pro${x}` carries fixed text too and still reaches the
1195
+ // prototype (#1880).
1196
+ if (!reachesPrototype) {
1197
+ return;
1198
+ }
1199
+ // `${id}` alone is the whole key, so the remedy names the inner
1200
+ // expression and the fix wraps it directly. A template carrying
1201
+ // fixed text has no such inner key — the string it builds is the
1202
+ // key — so that whole template is what gets wrapped, which is the
1203
+ // shape the docs show for `assertSafe(`${id}_suffix`)`.
1138
1204
  const isSimpleVarInterpolation = property.expressions.length === 1 &&
1139
1205
  property.quasis.length === 2 &&
1140
1206
  property.quasis[0].value.raw === '' &&
1141
1207
  property.quasis[1].value.raw === '';
1142
- if (!isSimpleVarInterpolation) {
1143
- // Complex template literals with additional text are fine
1144
- return;
1145
- }
1146
- const expr = property.expressions[0];
1147
- const exprText = context.sourceCode.getText(expr);
1148
- reportWrittenKey(written, property, exprText);
1208
+ const unwrapped = isSimpleVarInterpolation
1209
+ ? property.expressions[0]
1210
+ : property;
1211
+ reportWrittenKey(written, property, context.sourceCode.getText(unwrapped));
1149
1212
  return;
1150
1213
  }
1151
1214
  // Check for direct variable usage (identifiers)
@@ -340,6 +340,33 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
340
340
  }
341
341
  });
342
342
  }
343
+ /**
344
+ * The member name a `this.<x>` access reads, whatever its spelling.
345
+ *
346
+ * Keying the check on the dot spelling alone left `this['settings']`
347
+ * invisible, so the rename shipped and stranded it — the class no longer had
348
+ * the member the getter reads (#1881). A computed access with a static
349
+ * string is the SAME member as the dot form, and the fixer cannot rewrite it
350
+ * either, so it has to count. `null` marks a genuinely dynamic key, which
351
+ * names no member statically.
352
+ */
353
+ function staticMemberName(node) {
354
+ if (!node.computed) {
355
+ return node.property.type === utils_1.AST_NODE_TYPES.Identifier
356
+ ? node.property.name
357
+ : null;
358
+ }
359
+ if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
360
+ typeof node.property.value === 'string') {
361
+ return node.property.value;
362
+ }
363
+ if (node.property.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
364
+ node.property.expressions.length === 0 &&
365
+ node.property.quasis.length === 1) {
366
+ return node.property.quasis[0].value.cooked;
367
+ }
368
+ return null;
369
+ }
343
370
  // Determine whether renaming a constructor parameter property is unsafe to
344
371
  // autofix. A parameter property (`private readonly foo: T`) creates BOTH a
345
372
  // constructor-local binding and a `this.foo` class field, so a
@@ -355,8 +382,7 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
355
382
  }
356
383
  if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
357
384
  node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
358
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
359
- node.property.name === name) {
385
+ staticMemberName(node) === name) {
360
386
  unsafe = true;
361
387
  return;
362
388
  }
@@ -42,6 +42,33 @@ const getEnclosingClass = (node) => {
42
42
  * cannot be resolved through scope analysis, the fix is withheld whenever the
43
43
  * name occurs anywhere in the class other than at its declaration.
44
44
  */
45
+ /**
46
+ * The member name a `this.<x>` access reads, whatever its spelling.
47
+ *
48
+ * Keying the check on the dot spelling alone left `this['settings']` invisible,
49
+ * so the rename shipped and stranded it — the class no longer had the member the
50
+ * getter reads (#1882, the sibling of #1881). A computed access with a static
51
+ * string is the SAME member as the dot form, and the fixer cannot rewrite it
52
+ * either, so it has to count. `null` marks a genuinely dynamic key, which names
53
+ * no member statically and therefore strands nothing.
54
+ */
55
+ const staticMemberName = (node) => {
56
+ if (!node.computed) {
57
+ return node.property.type === utils_1.AST_NODE_TYPES.Identifier
58
+ ? node.property.name
59
+ : null;
60
+ }
61
+ if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
62
+ typeof node.property.value === 'string') {
63
+ return node.property.value;
64
+ }
65
+ if (node.property.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
66
+ node.property.expressions.length === 0 &&
67
+ node.property.quasis.length === 1) {
68
+ return node.property.quasis[0].value.cooked;
69
+ }
70
+ return null;
71
+ };
45
72
  const parameterPropertyRenameIsUnsafe = (classNode, name, declarationId) => {
46
73
  let unsafe = false;
47
74
  const visit = (node) => {
@@ -50,8 +77,7 @@ const parameterPropertyRenameIsUnsafe = (classNode, name, declarationId) => {
50
77
  }
51
78
  if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
52
79
  node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
53
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
54
- node.property.name === name) {
80
+ staticMemberName(node) === name) {
55
81
  unsafe = true;
56
82
  return;
57
83
  }
@@ -828,12 +828,31 @@ function truncateWithEllipsis(text, max = 60) {
828
828
  * handlers with contradictory targets, so the first handler to claim it wins and
829
829
  * the handler call order in `detectViolations` is the tie-break.
830
830
  */
831
- function record(sink, statement, messageId, data, fromIndex, toIndex) {
831
+ function record(sink, statement, messageId, data, fromIndex, toIndex, relocatable = true) {
832
832
  if (sink.flagged.has(statement)) {
833
833
  return;
834
834
  }
835
835
  sink.flagged.add(statement);
836
- sink.violations.push({ statement, messageId, data, fromIndex, toIndex });
836
+ sink.violations.push({
837
+ statement,
838
+ messageId,
839
+ data,
840
+ fromIndex,
841
+ toIndex,
842
+ relocatable,
843
+ });
844
+ }
845
+ /**
846
+ * The violations the reordering search is allowed to expand into moves, and the
847
+ * only ones its zero-violation goal test answers about.
848
+ *
849
+ * Screening here rather than at detection is what keeps the fix path identical to
850
+ * one that never saw an unrelocatable violation: the search's candidate moves, its
851
+ * budget and the order it certifies clean are all computed as if the report were
852
+ * absent, so adding a report can never redirect, weaken or block a fix.
853
+ */
854
+ function relocatableViolations(violations) {
855
+ return violations.filter((violation) => violation.relocatable);
837
856
  }
838
857
  function isGuardIfStatement(statement) {
839
858
  if (statement.type !== utils_1.AST_NODE_TYPES.IfStatement || statement.alternate) {
@@ -1058,23 +1077,58 @@ function unwrappedInitOf(declarator) {
1058
1077
  *
1059
1078
  * The classification runs on the unwrapped initializer: an assertion is erased
1060
1079
  * before the code runs, so `1 as const` is exactly the movable literal `1` is.
1080
+ *
1081
+ * Every declarator must qualify, because the statement is diagnosed — and moved —
1082
+ * as a whole. A sibling binding does not disqualify the statement: how many
1083
+ * bindings a declaration introduces decides whether the fix may relocate it, not
1084
+ * whether the declaration is far from the first use of what it declares (#1889).
1061
1085
  */
1062
- function lateDeclarationCandidateOf(statement) {
1086
+ function lateDeclarationDeclaratorsOf(statement) {
1063
1087
  const declaration = variableDeclarationOf(statement);
1064
- if (!declaration || declaration.declarations.length !== 1) {
1088
+ if (!declaration || declaration.declarations.length === 0) {
1065
1089
  return null;
1066
1090
  }
1067
- const [declarator] = declaration.declarations;
1068
- if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
1069
- return null;
1091
+ const declarators = [];
1092
+ for (const declarator of declaration.declarations) {
1093
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
1094
+ return null;
1095
+ }
1096
+ const init = unwrappedInitOf(declarator);
1097
+ if (init &&
1098
+ init.type !== utils_1.AST_NODE_TYPES.Identifier &&
1099
+ init.type !== utils_1.AST_NODE_TYPES.Literal) {
1100
+ return null;
1101
+ }
1102
+ declarators.push(declarator);
1070
1103
  }
1071
- const init = unwrappedInitOf(declarator);
1072
- if (init &&
1073
- init.type !== utils_1.AST_NODE_TYPES.Identifier &&
1074
- init.type !== utils_1.AST_NODE_TYPES.Literal) {
1075
- return null;
1104
+ return declarators;
1105
+ }
1106
+ /**
1107
+ * Whether the reordering fix may relocate this statement to satisfy a
1108
+ * late-declaration report.
1109
+ *
1110
+ * The fix moves whole statements, so relocating `const x = 1, y = 2;` carries `y`
1111
+ * along — past its own first use in the general case, and always further than the
1112
+ * report asked for. Splitting the declaration is the developer's call, so a
1113
+ * declaration with sibling bindings is reported and left where it stands.
1114
+ */
1115
+ function isRelocatableLateDeclaration(statement) {
1116
+ return lateDeclarationDeclaratorsOf(statement)?.length === 1;
1117
+ }
1118
+ /**
1119
+ * The binding the report names: the one whose own first use is the group's.
1120
+ *
1121
+ * A statement's bindings share its position, so the declaration is late relative to
1122
+ * whichever of them is read first; naming a sibling read later would point the
1123
+ * reader at the wrong line.
1124
+ */
1125
+ function earliestUsedDeclarator(declarators, body, afterIndex, usageIndex) {
1126
+ if (declarators.length === 1) {
1127
+ return declarators[0];
1076
1128
  }
1077
- return declarator;
1129
+ const earliest = declarators.find((declarator) => findFirstUsageIndex(body, new Set([declarator.id.name]), afterIndex) ===
1130
+ usageIndex);
1131
+ return earliest ?? declarators[0];
1078
1132
  }
1079
1133
  const LOOP_TYPES = new Set([
1080
1134
  utils_1.AST_NODE_TYPES.ForStatement,
@@ -1110,17 +1164,18 @@ function isMutatedInLoop(body, usageIndex, nameSet) {
1110
1164
  }
1111
1165
  function handleLateDeclarations(sink, body) {
1112
1166
  body.forEach((statement, index) => {
1113
- const declarator = lateDeclarationCandidateOf(statement);
1114
- if (!declarator) {
1167
+ const declarators = lateDeclarationDeclaratorsOf(statement);
1168
+ if (!declarators) {
1115
1169
  return;
1116
1170
  }
1117
- const name = declarator.id.name;
1118
1171
  const dependencies = new Set();
1119
- const init = unwrappedInitOf(declarator);
1120
- if (init && init.type === utils_1.AST_NODE_TYPES.Identifier) {
1121
- dependencies.add(init.name);
1122
- }
1123
- const nameSet = new Set([name]);
1172
+ declarators.forEach((declarator) => {
1173
+ const init = unwrappedInitOf(declarator);
1174
+ if (init && init.type === utils_1.AST_NODE_TYPES.Identifier) {
1175
+ dependencies.add(init.name);
1176
+ }
1177
+ });
1178
+ const nameSet = new Set(declarators.map((declarator) => declarator.id.name));
1124
1179
  const usageIndex = findFirstUsageIndex(body, nameSet, index + 1);
1125
1180
  if (usageIndex === -1 || usageIndex <= index + 1) {
1126
1181
  return;
@@ -1140,7 +1195,9 @@ function handleLateDeclarations(sink, body) {
1140
1195
  // Do not hop over another declaration that is used at the same index or earlier
1141
1196
  // if it is also a candidate for being moved.
1142
1197
  // This prevents circular swapping of related declarations (like resolve/reject pairs).
1143
- if (lateDeclarationCandidateOf(stmt)) {
1198
+ // The test asks about *relocation*, so a declaration the fix will never move
1199
+ // is not one of the two ends of such a swap and does not block the hop.
1200
+ if (isRelocatableLateDeclaration(stmt)) {
1144
1201
  const declaredNames = getDeclaredNames(stmt);
1145
1202
  const firstUsageOfIntervening = findFirstUsageIndex(body, declaredNames, index + 1 + i + 1);
1146
1203
  if (firstUsageOfIntervening !== -1 &&
@@ -1163,7 +1220,11 @@ function handleLateDeclarations(sink, body) {
1163
1220
  if (crossesImpureOrTracked) {
1164
1221
  return;
1165
1222
  }
1166
- record(sink, statement, 'moveDeclarationCloser', { name }, index, usageIndex);
1223
+ const subject = earliestUsedDeclarator(declarators, body, index + 1, usageIndex);
1224
+ // The same test `isRelocatableLateDeclaration` applies to an intervening
1225
+ // statement, read off the declarators already in hand.
1226
+ const relocatable = declarators.length === 1;
1227
+ record(sink, statement, 'moveDeclarationCloser', { name: subject.id.name }, index, usageIndex, relocatable);
1167
1228
  });
1168
1229
  }
1169
1230
  /**
@@ -1655,8 +1716,12 @@ function orderKey(order, indices) {
1655
1716
  return order.map((statement) => indices.get(statement)).join(',');
1656
1717
  }
1657
1718
  /**
1658
- * Shortest sequence of moves reaching an order with **zero** violations, or null when
1659
- * the search bounds contain no such order.
1719
+ * Shortest sequence of moves reaching an order with **zero** relocatable violations,
1720
+ * or null when the search bounds contain no such order.
1721
+ *
1722
+ * `violations` is pre-screened by `relocatableViolations`, and so is every detection
1723
+ * the search performs: a violation whose statement the fix may not move is neither a
1724
+ * candidate move nor a reason to reject an order, since no reordering can answer it.
1660
1725
  *
1661
1726
  * Breadth-first for two reasons: the emitted fix is then the smallest reordering that
1662
1727
  * satisfies every constraint, and a block whose single named move already suffices
@@ -1706,7 +1771,7 @@ function findResolvingMoves(sourceCode, body, violations, maxMoves) {
1706
1771
  }
1707
1772
  seen.add(key);
1708
1773
  budget -= 1;
1709
- const next = detectViolations(sourceCode, order);
1774
+ const next = relocatableViolations(detectViolations(sourceCode, order));
1710
1775
  const moves = [...node.moves, { fromIndex, toIndex }];
1711
1776
  if (next.length === 0) {
1712
1777
  return moves;
@@ -1765,10 +1830,15 @@ function buildReorderFix(body, moves, parent, sourceCode, fixer) {
1765
1830
  return fixer.replaceTextRange([bounds[first], bounds[last + 1]], reordered.slice(first, last + 1).join(''));
1766
1831
  }
1767
1832
  /**
1768
- * A fix is emitted only for a reordering the detector scores at zero violations, and
1769
- * the whole reordering ships as a single fix. Relocating one statement per report
1770
- * satisfies its own adjacency constraint while breaking another's, which under
1771
- * `--fix` oscillates or exhausts the pass budget (#1405).
1833
+ * A fix is emitted only for a reordering the detector scores at zero relocatable
1834
+ * violations, and the whole reordering ships as a single fix. Relocating one
1835
+ * statement per report satisfies its own adjacency constraint while breaking
1836
+ * another's, which under `--fix` oscillates or exhausts the pass budget (#1405).
1837
+ *
1838
+ * A violation no reordering may answer — a declaration whose sibling bindings the
1839
+ * fix would drag along — is reported and otherwise invisible here: it does not
1840
+ * carry the fix, does not veto one, and cannot keep the block from settling, since
1841
+ * the fixed text scores it exactly as the input did.
1772
1842
  *
1773
1843
  * Convergence is structural rather than argued: the emitted order is verified clean
1774
1844
  * by the same detector that produced the reports, and detection depends only on
@@ -1786,13 +1856,14 @@ function handleBlock(ruleContext, node) {
1786
1856
  if (violations.length === 0) {
1787
1857
  return;
1788
1858
  }
1789
- const moves = findResolvingMoves(sourceCode, body, violations, searchDepthFor(violations.length, body.length));
1790
- violations.forEach((violation, index) => {
1859
+ const relocatable = relocatableViolations(violations);
1860
+ const moves = findResolvingMoves(sourceCode, body, relocatable, searchDepthFor(relocatable.length, body.length));
1861
+ violations.forEach((violation) => {
1791
1862
  context.report({
1792
1863
  node: violation.statement,
1793
1864
  messageId: violation.messageId,
1794
1865
  data: violation.data,
1795
- fix: moves && index === 0
1866
+ fix: moves && violation === relocatable[0]
1796
1867
  ? (fixer) => buildReorderFix(body, moves, node, sourceCode, fixer)
1797
1868
  : null,
1798
1869
  });