eslint-plugin-no-mistakes 0.96.0 → 0.98.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/rules/async-call-disposition.js +24 -9
  3. package/src/rules/async-disposition-consumers.js +41 -0
  4. package/src/rules/async-disposition-expressions.js +13 -8
  5. package/src/rules/async-disposition-mocks.js +33 -6
  6. package/src/rules/async-schema.js +19 -1
  7. package/src/rules/async-targets.js +10 -2
  8. package/src/rules/no-object-mutation-call.js +4 -2
  9. package/src/rules/no-object-mutation-expr.js +1 -1
  10. package/src/rules/no-object-mutation-fn.js +7 -2
  11. package/src/rules/no-object-mutation-invoke.js +36 -1
  12. package/src/rules/no-object-mutation-reduce.js +92 -0
  13. package/src/rules/no-object-mutation-scan.js +1 -1
  14. package/src/rules/no-vitest-sequential.js +5 -16
  15. package/src/rules/nullable-option-defaults-meta.js +25 -0
  16. package/src/rules/nullable-option-scalar-alias.js +24 -0
  17. package/src/rules/playwright-interactive-names.js +1 -0
  18. package/src/rules/playwright-interactive-slots.js +55 -0
  19. package/src/rules/playwright-require-interactive-test-id.js +61 -9
  20. package/src/rules/postgres-no-async-sql-builder-types.js +1 -0
  21. package/src/rules/postgres-no-executor-adapter-source.js +41 -0
  22. package/src/rules/postgres-no-executor-adapter-target.js +142 -0
  23. package/src/rules/postgres-no-executor-adapter.js +18 -7
  24. package/src/rules/postgres-no-manual-transaction.js +35 -1
  25. package/src/rules/postgres-scoped-executors.js +1 -1
  26. package/src/rules/react-no-use-promise-resolve-alias.js +168 -0
  27. package/src/rules/react-no-use-promise-resolve.js +33 -1
  28. package/src/rules/test-message-captured-value.js +96 -0
  29. package/src/rules/test-message-expect.js +2 -1
  30. package/src/rules/test-no-delayed-rejects-promise-alias.js +69 -0
  31. package/src/rules/test-no-delayed-rejects.js +4 -33
  32. package/src/rules/test-no-error-message-matching.js +9 -4
  33. package/src/rules/test-no-shared-state-callees.js +20 -0
  34. package/src/rules/test-no-shared-state-helpers.js +8 -3
  35. package/src/rules/test-no-shared-state-rule-helpers.js +5 -3
  36. package/src/rules/test-no-shared-state.js +10 -5
  37. package/src/rules/test-no-skips-bindings.js +29 -19
  38. package/src/rules/test-no-skips-context.js +43 -0
  39. package/src/rules/test-no-skips.js +11 -2
  40. package/src/rules/ts-no-const-aliases.js +2 -4
  41. package/src/rules/ts-no-function-aliases-ast.js +7 -1
  42. package/src/rules/ts-no-function-aliases-cast-proof.js +145 -0
  43. package/src/rules/ts-no-function-aliases-contract.js +8 -30
  44. package/src/rules/ts-no-function-aliases.js +7 -1
  45. package/src/rules/ts-preserve-null-option-defaults.js +137 -154
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-no-mistakes",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": "ESLint and Oxlint rules that keep code static enough for no-mistakes analyzers and coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,18 +14,19 @@ const {
14
14
  promiseAllDisposed,
15
15
  mappedPromise,
16
16
  } = require("./async-disposition-expressions");
17
- const { targetOptionsSchema } = require("./async-schema");
17
+ const { dispositionOptionsSchema } = require("./async-schema");
18
18
  const { createTargetMatcher } = require("./async-targets");
19
+ const { createAwaitingConsumers } = require("./async-disposition-consumers");
19
20
 
20
- function isDisposed(node, context, seen = new Set()) {
21
+ function isDisposed(node, context, returnsThroughConsumer, seen = new Set()) {
21
22
  if (seen.has(node)) return false;
22
23
  seen.add(node);
23
24
  const observed = observedExpression(node, context);
24
- const disposed = (value) => isDisposed(value, context, seen);
25
+ const disposed = (value) => isDisposed(value, context, returnsThroughConsumer, seen);
25
26
  return (
26
- directlyDisposed(node, context) ||
27
- directlyDisposed(observed, context) ||
28
- promiseAllDisposed(observed, context) ||
27
+ directlyDisposed(node, context, returnsThroughConsumer) ||
28
+ directlyDisposed(observed, context, returnsThroughConsumer) ||
29
+ promiseAllDisposed(observed, context, returnsThroughConsumer) ||
29
30
  retainedPromise(observed, context, disposed) ||
30
31
  mappedPromise(observed, (value) =>
31
32
  retainedCollection(
@@ -53,7 +54,7 @@ module.exports = rule(
53
54
  recommended: false,
54
55
  },
55
56
  hasSuggestions: true,
56
- schema: targetOptionsSchema,
57
+ schema: dispositionOptionsSchema,
57
58
  messages: {
58
59
  disposition:
59
60
  "Handle this async promise explicitly with await, return, Promise.all(...), or void.",
@@ -62,13 +63,27 @@ module.exports = rule(
62
63
  },
63
64
  (context) => {
64
65
  const matcher = createTargetMatcher(context);
66
+ const consumers = createAwaitingConsumers(context);
65
67
  const suppressed = ruleSuppression(context, "async-call-disposition");
66
68
  if (!matcher.hasTargets) return {};
67
69
 
68
70
  return {
69
- ...matcher.visitors,
71
+ ...Object.fromEntries(
72
+ Object.keys(matcher.visitors).map((key) => [
73
+ key,
74
+ (node) => {
75
+ matcher.visitors[key](node);
76
+ consumers.visitors[key](node);
77
+ },
78
+ ]),
79
+ ),
70
80
  CallExpression(node) {
71
- if (!matcher.isTargetCall(node) || suppressed(node) || isDisposed(node, context)) return;
81
+ if (
82
+ !matcher.isTargetCall(node) ||
83
+ suppressed(node) ||
84
+ isDisposed(node, context, consumers.returnsThroughConsumer)
85
+ )
86
+ return;
72
87
  context.report({
73
88
  node,
74
89
  messageId: "disposition",
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+
3
+ const { unwrapTransparentParent } = require("./async-ast");
4
+ const { compileTargets, targetMatches } = require("./async-patterns");
5
+ const { propertiesWritten } = require("./async-disposition-mocks");
6
+ const { createTargetMatcher } = require("./async-targets");
7
+
8
+ function createAwaitingConsumers(context) {
9
+ const matcher = createTargetMatcher(
10
+ context,
11
+ "awaitingConsumers",
12
+ (id) => !propertiesWritten(id, context, new Set(), null, true),
13
+ true,
14
+ );
15
+ const consumers = (context.options?.[0]?.awaitingConsumers || []).map((consumer) => ({
16
+ targets: compileTargets({ targets: [consumer] }, "targets"),
17
+ indexes: consumer.callbackArgumentIndexes,
18
+ }));
19
+ return {
20
+ visitors: matcher.visitors,
21
+ returnsThroughConsumer(fn) {
22
+ const callback = unwrapTransparentParent(fn);
23
+ const target = matcher.resolveCallTarget(callback.parent);
24
+ if (!target) return false;
25
+ const index = target.arguments.indexOf(callback);
26
+ if (
27
+ index < 0 ||
28
+ target.arguments.slice(0, index).some((arg) => arg.type === "SpreadElement")
29
+ ) {
30
+ return false;
31
+ }
32
+ return consumers.some(
33
+ (consumer) =>
34
+ consumer.indexes.includes(index) &&
35
+ targetMatches(consumer.targets, target.source, target.calleeName),
36
+ );
37
+ },
38
+ };
39
+ }
40
+
41
+ module.exports = { createAwaitingConsumers };
@@ -11,16 +11,21 @@ const {
11
11
  } = require("./async-ast");
12
12
  const { memberPropertyName } = require("./async-targets");
13
13
 
14
- function directlyDisposed(node, context) {
14
+ function directlyDisposed(node, context, returnsThroughConsumer) {
15
15
  const expression = unwrapTransparentParent(node);
16
16
  const parent = expression.parent;
17
17
  if (parent?.type === "ReturnStatement") {
18
18
  const fn = findContainingFunction(parent);
19
- return !isCallArgument(fn) || returnsThroughMock(fn, context);
19
+ return !isCallArgument(fn) || returnsThroughMock(fn, context) || returnsThroughConsumer(fn);
20
20
  }
21
21
  if (parent?.type === "ArrowFunctionExpression" && parent.body === expression) {
22
- if (isCallCallee(parent)) return directlyDisposed(parent.parent, context);
23
- return !isCallArgument(parent) || returnsThroughMock(parent, context);
22
+ if (isCallCallee(parent))
23
+ return directlyDisposed(parent.parent, context, returnsThroughConsumer);
24
+ return (
25
+ !isCallArgument(parent) ||
26
+ returnsThroughMock(parent, context) ||
27
+ returnsThroughConsumer(parent)
28
+ );
24
29
  }
25
30
  return (
26
31
  parent?.type === "AwaitExpression" ||
@@ -130,14 +135,14 @@ function observedExpression(node, context) {
130
135
  return current;
131
136
  }
132
137
 
133
- function promiseAllDisposed(node, context) {
138
+ function promiseAllDisposed(node, context, returnsThroughConsumer) {
134
139
  let current = node.parent;
135
140
  while (current) {
136
141
  if (isPromiseAllCall(current, context) && canReachPromiseAll(node, current)) {
137
142
  return (
138
- directlyDisposed(current, context) ||
139
- directlyDisposed(observedExpression(current, context), context) ||
140
- promiseAllDisposed(current, context)
143
+ directlyDisposed(current, context, returnsThroughConsumer) ||
144
+ directlyDisposed(observedExpression(current, context), context, returnsThroughConsumer) ||
145
+ promiseAllDisposed(current, context, returnsThroughConsumer)
141
146
  );
142
147
  }
143
148
  current = current.parent;
@@ -9,9 +9,34 @@ const {
9
9
  const { mockInitializer } = require("./async-disposition-mock-bindings");
10
10
  const { memberPropertyName } = require("./async-targets");
11
11
 
12
+ // Destructuring and loop targets also replace a consumer method's identity.
13
+ function propertyTargetWritten(value) {
14
+ if (value.type !== "MemberExpression") return false;
15
+ let target = value;
16
+ while (
17
+ ["ObjectPattern", "ArrayPattern", "RestElement"].includes(target.parent.type) ||
18
+ (target.parent.type === "Property" && target.parent.value === target) ||
19
+ (target.parent.type === "AssignmentPattern" && target.parent.left === target)
20
+ )
21
+ target = target.parent;
22
+ const parent = target.parent;
23
+ return (
24
+ (["AssignmentExpression", "ForOfStatement", "ForInStatement"].includes(parent.type) &&
25
+ parent.left === target) ||
26
+ parent.type === "UpdateExpression" ||
27
+ (parent.type === "UnaryExpression" && parent.operator === "delete")
28
+ );
29
+ }
30
+
12
31
  // Property writes invalidate provenance, including writes through stable local aliases.
13
32
  // This follows syntax in this file, not arbitrary callees' side effects.
14
- function propertiesWritten(node, context, seen = new Set(), initialization = null) {
33
+ function propertiesWritten(
34
+ node,
35
+ context,
36
+ seen = new Set(),
37
+ initialization = null,
38
+ propertiesOnly = false,
39
+ ) {
15
40
  const variable = resolveVariable(node, context);
16
41
  const key = variable || node.name;
17
42
  if (seen.has(key)) return false;
@@ -40,9 +65,11 @@ function propertiesWritten(node, context, seen = new Set(), initialization = nul
40
65
  const parent = value.parent;
41
66
  if (parent === initialization && parent.left === value) return false;
42
67
  if (
43
- (parent.type === "AssignmentExpression" && parent.left === value) ||
44
- parent.type === "UpdateExpression" ||
45
- (parent.type === "UnaryExpression" && parent.operator === "delete")
68
+ propertiesOnly
69
+ ? propertyTargetWritten(value)
70
+ : (parent.type === "AssignmentExpression" && parent.left === value) ||
71
+ parent.type === "UpdateExpression" ||
72
+ (parent.type === "UnaryExpression" && parent.operator === "delete")
46
73
  )
47
74
  return true;
48
75
  if (
@@ -51,13 +78,13 @@ function propertiesWritten(node, context, seen = new Set(), initialization = nul
51
78
  parent.id.type === "Identifier" &&
52
79
  ["const", "let"].includes(parent.parent.kind)
53
80
  )
54
- return propertiesWritten(parent.id, context, seen, initialization);
81
+ return propertiesWritten(parent.id, context, seen, initialization, propertiesOnly);
55
82
  if (
56
83
  parent.type === "AssignmentExpression" &&
57
84
  parent.right === value &&
58
85
  parent.left.type === "Identifier"
59
86
  )
60
- return propertiesWritten(parent.left, context, seen, parent);
87
+ return propertiesWritten(parent.left, context, seen, parent, propertiesOnly);
61
88
  return false;
62
89
  });
63
90
  }
@@ -25,4 +25,22 @@ function optionsSchema(propertyName) {
25
25
  const handlerOptionsSchema = optionsSchema("handlers");
26
26
  const targetOptionsSchema = optionsSchema("targets");
27
27
 
28
- module.exports = { handlerOptionsSchema, targetOptionsSchema };
28
+ const dispositionOptionsSchema = optionsSchema("targets");
29
+ dispositionOptionsSchema[0].properties.awaitingConsumers = {
30
+ type: "array",
31
+ items: {
32
+ ...targetOptionsSchema[0].properties.targets.items,
33
+ properties: {
34
+ ...targetOptionsSchema[0].properties.targets.items.properties,
35
+ callbackArgumentIndexes: {
36
+ type: "array",
37
+ minItems: 1,
38
+ uniqueItems: true,
39
+ items: { type: "integer", minimum: 0 },
40
+ },
41
+ },
42
+ required: ["sourceSpecifierPatterns", "calleeNamePatterns", "callbackArgumentIndexes"],
43
+ },
44
+ };
45
+
46
+ module.exports = { handlerOptionsSchema, targetOptionsSchema, dispositionOptionsSchema };
@@ -33,7 +33,12 @@ function requireSource(node, context) {
33
33
  return source;
34
34
  }
35
35
 
36
- function createTargetMatcher(context, optionKey = "targets") {
36
+ function createTargetMatcher(
37
+ context,
38
+ optionKey = "targets",
39
+ bindingIsStable = null,
40
+ strictLogicalCalls = false,
41
+ ) {
37
42
  const targets = compileTargets(context.options?.[0] || {}, optionKey);
38
43
  const sourceSpecifierPatterns = targets.flatMap((target) => target.sourceSpecifierPatterns);
39
44
  const directBindings = new Map();
@@ -43,6 +48,7 @@ function createTargetMatcher(context, optionKey = "targets") {
43
48
  if (
44
49
  id?.type !== "Identifier" ||
45
50
  isReassigned(id, context) ||
51
+ (bindingIsStable && !bindingIsStable(id)) ||
46
52
  !targetMatches(targets, source, calleeName)
47
53
  ) {
48
54
  return;
@@ -55,6 +61,7 @@ function createTargetMatcher(context, optionKey = "targets") {
55
61
  if (
56
62
  id.type !== "Identifier" ||
57
63
  isReassigned(id, context) ||
64
+ (bindingIsStable && !bindingIsStable(id)) ||
58
65
  !matchesAny(source, sourceSpecifierPatterns)
59
66
  ) {
60
67
  return;
@@ -153,6 +160,7 @@ function createTargetMatcher(context, optionKey = "targets") {
153
160
  if (!name) return null;
154
161
  let object = unwrapExpression(node.object);
155
162
  for (;;) {
163
+ if (object?.type === "Identifier" && bindingIsStable && !bindingIsStable(object)) return null;
156
164
  const source =
157
165
  requireSource(object, context) ||
158
166
  (object?.type === "Identifier"
@@ -181,7 +189,7 @@ function createTargetMatcher(context, optionKey = "targets") {
181
189
  if (node?.type !== "CallExpression") return null;
182
190
  const direct = resolveCallee(node.callee);
183
191
  if (direct) return { ...direct, arguments: node.arguments };
184
- const invoked = logicalCall(node);
192
+ const invoked = logicalCall(node, strictLogicalCalls ? resolveCallee : undefined);
185
193
  if (!invoked) return null;
186
194
  const forwarded = resolveCallee(invoked.callee);
187
195
  return forwarded ? { ...forwarded, arguments: invoked.arguments } : null;
@@ -79,13 +79,15 @@ function visitCall(state, node, scope, role) {
79
79
  return unknown();
80
80
  }
81
81
  const known = visitKnownCall(state, node, scope, role);
82
- if (known !== undefined) {
82
+ if (known !== undefined && !known.fallbackCalleeTag) {
83
83
  return known;
84
84
  }
85
85
  const callee = unwrap(node.callee);
86
86
  const forward = callee.type === "MemberExpression" ? memberKey(callee) : null;
87
87
  const forwarded = forward === "call" || forward === "apply";
88
- const calleeTag = api.visit(state, forwarded ? callee.object : node.callee, scope, "read");
88
+ const calleeTag =
89
+ known?.fallbackCalleeTag ||
90
+ api.visit(state, forwarded ? callee.object : node.callee, scope, "read");
89
91
  if (calleeTag.mutator) {
90
92
  return visitAliasedMutation(state, node, scope, calleeTag.mutator, forwarded ? forward : null);
91
93
  }
@@ -104,7 +104,7 @@ function visitExpression(state, node, scope, role) {
104
104
  memberKey(node),
105
105
  node.object,
106
106
  );
107
- if (role === "escape" && member.mutator) api.releaseEscaping(state, member);
107
+ if (role === "escape") api.releaseEscaping(state, member);
108
108
  return member;
109
109
  }
110
110
  case "ObjectExpression":
@@ -76,8 +76,13 @@ function walkFunctionBody(state, node, scope, tag) {
76
76
  fnEnv.inClass = arrow ? Boolean(scope.inClass) : classMethod;
77
77
  fnEnv.hasArguments = arrow ? Boolean(scope.hasArguments) : true;
78
78
  fnEnv.receiver = arrow ? scope.receiver || null : tag.receiver || null;
79
- for (const param of node.params) {
80
- bindPattern(state, param, shared("parameter"), fnEnv, "param");
79
+ if (tag.reduceSeed) tag.reduceSeed.ownerEnv = fnEnv;
80
+ for (const [index, param] of node.params.entries()) {
81
+ if (index === 0 && tag.reduceSeed) {
82
+ bindName(param.name, tag.reduceSeed, fnEnv, "param");
83
+ } else {
84
+ bindPattern(state, param, shared("parameter"), fnEnv, "param");
85
+ }
81
86
  }
82
87
  const body = functionStatements(node);
83
88
  if (Array.isArray(body)) {
@@ -1,13 +1,16 @@
1
1
  "use strict";
2
2
 
3
3
  const api = require("./no-object-mutation-runtime");
4
+ const { callFunction } = require("./no-object-mutation-fn");
5
+ const { reducerCallback } = require("./no-object-mutation-reduce");
4
6
  const { iteratedContents } = require("./no-object-mutation-read");
5
- const { methodName, unwrap } = require("./no-object-mutation-syntax");
7
+ const { methodName, staticKey, unwrap } = require("./no-object-mutation-syntax");
6
8
  const {
7
9
  callable,
8
10
  createFresh,
9
11
  joinValues,
10
12
  lookup,
13
+ readMember,
11
14
  remember,
12
15
  unknown,
13
16
  } = require("./no-object-mutation-values");
@@ -21,6 +24,38 @@ function memberCall(node, name) {
21
24
  }
22
25
 
23
26
  function visitKnownCall(state, node, scope, role) {
27
+ const reducer = reducerCallback(node);
28
+ if (reducer) {
29
+ const callee = unwrap(node.callee);
30
+ const receiver = api.visit(state, callee.object, scope, "read");
31
+ const method = readMember(receiver, methodName(callee));
32
+ if (
33
+ callable(method) ||
34
+ (receiver.type === "fresh" &&
35
+ (receiver.fields.has(methodName(callee)) ||
36
+ receiver.items.some(
37
+ (item) =>
38
+ item.node?.parent?.key && staticKey(item.node.parent.key) === methodName(callee),
39
+ )))
40
+ ) {
41
+ // Reuse the evaluated receiver in the ordinary call path. A custom
42
+ // method may supply a shared accumulator or retain its callback.
43
+ return { fallbackCalleeTag: method };
44
+ }
45
+ const seed = api.visit(state, node.arguments[1], scope, "read");
46
+ const callback = api.visit(state, reducer, scope, "read");
47
+ callback.reduceSeed = seed;
48
+ const escapeEpoch = state.escapeEpoch;
49
+ callFunction(state, callback);
50
+ if (state.escapeEpoch !== escapeEpoch) {
51
+ // A leak at the end of one iteration is visible at the next entry.
52
+ callback.walked = false;
53
+ callFunction(state, callback);
54
+ }
55
+ seed.ownerEnv = scope.ownerEnv;
56
+ if (role === "escape") api.releaseEscaping(state, seed);
57
+ return seed;
58
+ }
24
59
  const values = memberCall(node, "values");
25
60
  if (values && isGlobalObject(values.object, scope)) {
26
61
  const target = node.arguments[0];
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+
3
+ const { methodName, unwrap } = require("./no-object-mutation-syntax");
4
+ const { ARRAY_METHODS } = require("./no-object-mutation-ast");
5
+
6
+ // Limit the exemption to an inline, synchronous accumulator builder. Every
7
+ // callback exit must preserve the same accumulator for the next iteration.
8
+ function reducerCallback(node) {
9
+ const callee = unwrap(node.callee);
10
+ if (callee?.type !== "MemberExpression" || node.arguments.length !== 2) return null;
11
+ if (!["reduce", "reduceRight"].includes(methodName(callee))) return null;
12
+ const seed = unwrap(node.arguments[1]);
13
+ if (!["ObjectExpression", "ArrayExpression"].includes(seed?.type)) return null;
14
+ const callback = unwrap(node.arguments[0]);
15
+ if (!["ArrowFunctionExpression", "FunctionExpression"].includes(callback?.type)) return null;
16
+ if (callback.async || callback.generator || callback.params[0]?.type !== "Identifier")
17
+ return null;
18
+ const name = callback.params[0].name;
19
+ const body = callback.body;
20
+ if (body.type !== "BlockStatement") return null;
21
+ if (body.body.at(-1)?.type !== "ReturnStatement") return null;
22
+ return preservesAccumulator(body, name) ? callback : null;
23
+ }
24
+
25
+ function preservesAccumulator(node, name, nested = false) {
26
+ if (!node || typeof node.type !== "string") return true;
27
+ if (
28
+ ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(node.type)
29
+ ) {
30
+ // Returns in nested closures do not define the reduction result. Still
31
+ // reject rebinding there: a closure can change the next accumulator.
32
+ nested = true;
33
+ }
34
+ if (node.type === "ReturnStatement" && !nested) {
35
+ const value = unwrap(node.argument);
36
+ return value?.type === "Identifier" && value.name === name;
37
+ }
38
+ // Rebinding could pass a shared value into the following iteration. Keep
39
+ // destructuring and shadowing conservative rather than prove control flow.
40
+ if (
41
+ [
42
+ "AssignmentExpression",
43
+ "UpdateExpression",
44
+ "VariableDeclarator",
45
+ "ForInStatement",
46
+ "ForOfStatement",
47
+ ].includes(node.type)
48
+ ) {
49
+ const target = node.left || node.argument || node.id;
50
+ if (target.type !== "MemberExpression" && mentions(target, name)) return false;
51
+ }
52
+ if (node.type === "Identifier" && node.name === name && !directAccumulatorUse(node)) return false;
53
+ if (node.type === "CatchClause" && mentions(node.param, name)) return false;
54
+ for (const [key, value] of Object.entries(node)) {
55
+ if (["parent", "loc", "range", "tokens"].includes(key)) continue;
56
+ if (Array.isArray(value)) {
57
+ if (!value.every((part) => preservesAccumulator(part, name, nested))) return false;
58
+ } else if (value && typeof value === "object" && !preservesAccumulator(value, name, nested))
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ // Only direct container writes are exempt. Member reads and aliases can carry
65
+ // shared nested provenance across arbitrarily many iterations, so abstain.
66
+ function directAccumulatorUse(node) {
67
+ const parent = node.parent;
68
+ if (parent.type === "ReturnStatement") return true;
69
+ if (parent.type === "CallExpression") return parent.arguments.includes(node);
70
+ if (parent.type !== "MemberExpression") return false;
71
+ if (parent.property === node && !parent.computed) return true;
72
+ if (parent.object !== node) return false;
73
+ const use = parent.parent;
74
+ if (use.type === "AssignmentExpression" && use.left === parent) return true;
75
+ if (use.type === "UpdateExpression" && use.argument === parent) return true;
76
+ return (
77
+ use.type === "CallExpression" && use.callee === parent && ARRAY_METHODS.has(methodName(parent))
78
+ );
79
+ }
80
+
81
+ function mentions(node, name) {
82
+ if (!node || typeof node.type !== "string") return false;
83
+ if (node.type === "Identifier") return node.name === name;
84
+ return Object.entries(node).some(([key, value]) => {
85
+ if (["parent", "loc", "range", "tokens"].includes(key)) return false;
86
+ return Array.isArray(value)
87
+ ? value.some((part) => mentions(part, name))
88
+ : mentions(value, name);
89
+ });
90
+ }
91
+
92
+ module.exports = { reducerCallback };
@@ -113,7 +113,7 @@ function visit(state, node, scope, role) {
113
113
  return unknown();
114
114
  case "ReturnStatement":
115
115
  if (node.argument) {
116
- visit(state, node.argument, scope, "escape");
116
+ visit(state, node.argument, scope, scope.fnTag?.reduceSeed ? "read" : "escape");
117
117
  }
118
118
  return unknown();
119
119
  case "LabeledStatement":
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { rule } = require("../helpers");
4
4
  const { ruleSuppression } = require("../rule-suppression");
5
+ const { unwrapExpression, unwrapSequence: unwrap } = require("./async-ast");
5
6
  const { literalString } = require("./module-mock-helpers");
6
7
  const { resolveVariable } = require("./test-no-shared-state-aliases");
7
8
 
@@ -13,20 +14,13 @@ function isTestFile(filename) {
13
14
  }
14
15
 
15
16
  function staticName(node, computed) {
17
+ node = unwrapExpression(node);
16
18
  if (!node) return null;
17
19
  if (computed && node.type === "Identifier") return null;
18
20
  if (!computed && typeof node.name === "string" && node.type !== "Literal") return node.name;
19
21
  return literalString(node);
20
22
  }
21
23
 
22
- function unwrap(node) {
23
- while (node?.type === "ChainExpression") node = node.expression;
24
- if (node?.type === "SequenceExpression" && node.expressions?.length) {
25
- return unwrap(node.expressions.at(-1));
26
- }
27
- return node;
28
- }
29
-
30
24
  function constInit(node, context, seen) {
31
25
  if (!context || node?.type !== "Identifier" || seen.has(node.name)) return null;
32
26
  const variable = resolveVariable(node, context);
@@ -72,14 +66,9 @@ function hasSequentialOption(node) {
72
66
  value?.type === "ObjectExpression" &&
73
67
  value.properties.some((property) => {
74
68
  if (property.type !== "Property") return false;
75
- const key = property.computed
76
- ? literalString(property.key)
77
- : staticName(property.key, false);
78
- return (
79
- key === "sequential" &&
80
- property.value?.type === "Literal" &&
81
- property.value.value === true
82
- );
69
+ const key = staticName(property.key, property.computed);
70
+ const option = unwrapExpression(property.value);
71
+ return key === "sequential" && option?.type === "Literal" && option.value === true;
83
72
  })
84
73
  );
85
74
  });
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+
3
+ module.exports = {
4
+ type: "problem",
5
+ docs: {
6
+ description: "preserve explicit null in nullable option defaults",
7
+ recommended: false,
8
+ },
9
+ schema: [
10
+ {
11
+ type: "object",
12
+ properties: {
13
+ includePathPatterns: { type: "array", items: { type: "string" } },
14
+ excludePathPatterns: { type: "array", items: { type: "string" } },
15
+ optionObjectNames: { type: "array", items: { type: "string" } },
16
+ optionObjectNamePatterns: { type: "array", items: { type: "string" } },
17
+ },
18
+ additionalProperties: false,
19
+ },
20
+ ],
21
+ messages: {
22
+ default:
23
+ "Do not default nullable option '{{name}}' with ??, ||, ??=, or ||=. Preserve explicit null and check undefined explicitly.",
24
+ },
25
+ };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ const { resolveVariable } = require("./async-target-bindings");
4
+
5
+ // Scope facts are sequential: consult real bindings to reject hoisted/TDZ shadows.
6
+ function scalarConstAlias(node, context) {
7
+ if (node.parent.kind !== "const" || node.init.type !== "Identifier") return false;
8
+ const variable = resolveVariable(node.init, context);
9
+ if (
10
+ !variable ||
11
+ variable.defs.length !== 1 ||
12
+ variable.references.some((reference) => reference.isWrite() && !reference.init)
13
+ )
14
+ return false;
15
+ const def = variable.defs[0];
16
+ // Parameter initializers can run before a later binding is initialized.
17
+ // Follow known nullable parameters only from their function body.
18
+ if (def.type === "Parameter") return def.node.body.range[0] < node.init.range[0];
19
+ return (
20
+ def.type === "Variable" && def.parent.kind === "const" && def.node.range[1] < node.init.range[0]
21
+ );
22
+ }
23
+
24
+ module.exports = { scalarConstAlias };
@@ -120,6 +120,7 @@ module.exports = {
120
120
  isInteractiveElement,
121
121
  jsxCandidateNames,
122
122
  mappedImport,
123
+ matchesAny,
123
124
  rememberAlias,
124
125
  rememberImport,
125
126
  };
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ const { attributeName, isStaticString, literalString, selectorValueNode } = require("../helpers");
4
+ const { unwrapExpression } = require("./async-ast");
5
+
6
+ // A spread invalidates earlier props; a later explicit prop establishes its value again.
7
+ function effectiveAttribute(opening, name) {
8
+ let found = null;
9
+ for (const attribute of opening.attributes) {
10
+ if (attribute.type === "JSXSpreadAttribute") found = null;
11
+ else if (attributeName(attribute) === name) found = attribute;
12
+ }
13
+ return found;
14
+ }
15
+
16
+ function hasSlotProp(opening, names) {
17
+ return names.some((name) => {
18
+ const attribute = effectiveAttribute(opening, name);
19
+ if (!attribute) return false;
20
+ if (!attribute.value) return true;
21
+ const value = unwrapExpression(selectorValueNode(attribute));
22
+ if (value?.type === "Literal") return Boolean(value.value);
23
+ return isStaticString(value) && Boolean(literalString(value));
24
+ });
25
+ }
26
+
27
+ function literalSelectors(opening, names) {
28
+ return names.filter((name) => {
29
+ const attribute = effectiveAttribute(opening, name);
30
+ return attribute && isStaticString(unwrapExpression(selectorValueNode(attribute)));
31
+ });
32
+ }
33
+
34
+ function singleElementChild(element) {
35
+ const children = element.children.filter((child) => {
36
+ if (child.type === "JSXExpressionContainer" && child.expression.type === "JSXEmptyExpression")
37
+ return false;
38
+ if (child.type !== "JSXText") return true;
39
+ // JSX drops indentation around newlines. Same-line spaces and nonbreaking
40
+ // spaces are actual text siblings, so a Slot cannot merge onto one element.
41
+ return child.value !== "" && !(/^[ \t\r\n]*$/.test(child.value) && /[\r\n]/.test(child.value));
42
+ });
43
+ return children.length === 1 && children[0].type === "JSXElement" ? children[0] : null;
44
+ }
45
+
46
+ function childKeepsSelector(opening, names) {
47
+ return names.some(
48
+ (name) =>
49
+ !opening.attributes.some(
50
+ (attribute) => attribute.type === "JSXSpreadAttribute" || attributeName(attribute) === name,
51
+ ),
52
+ );
53
+ }
54
+
55
+ module.exports = { childKeepsSelector, hasSlotProp, literalSelectors, singleElementChild };