diff --git a/.changeset/curvy-bananas-pretend.md b/.changeset/curvy-bananas-pretend.md
new file mode 100644
index 000000000..1072e606e
--- /dev/null
+++ b/.changeset/curvy-bananas-pretend.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-svelte": minor
+---
+
+feat: add `svelte/no-immutable-reactive-statements` rule
diff --git a/README.md b/README.md
index 0a9910f12..d50461aa4 100644
--- a/README.md
+++ b/README.md
@@ -345,6 +345,7 @@ These rules relate to better ways of doing things to help you avoid problems:
| [svelte/block-lang](https://sveltejs.github.io/eslint-plugin-svelte/rules/block-lang/) | disallows the use of languages other than those specified in the configuration for the lang attribute of `
+
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/src/rules/no-immutable-reactive-statements.ts)
+- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/tests/src/rules/no-immutable-reactive-statements.ts)
diff --git a/src/rules/no-immutable-reactive-statements.ts b/src/rules/no-immutable-reactive-statements.ts
new file mode 100644
index 000000000..df154c54c
--- /dev/null
+++ b/src/rules/no-immutable-reactive-statements.ts
@@ -0,0 +1,162 @@
+import type { AST } from "svelte-eslint-parser"
+import { createRule } from "../utils"
+import type {
+ Scope,
+ Variable,
+ Reference,
+ Definition,
+} from "@typescript-eslint/scope-manager"
+
+export default createRule("no-immutable-reactive-statements", {
+ meta: {
+ docs: {
+ description:
+ "disallow reactive statements that don't reference reactive values.",
+ category: "Best Practices",
+ // TODO Switch to recommended in the major version.
+ recommended: false,
+ },
+ schema: [],
+ messages: {
+ immutable:
+ "This statement is not reactive because all variables referenced in the reactive statement are immutable.",
+ },
+ type: "suggestion",
+ },
+ create(context) {
+ const scopeManager = context.getSourceCode().scopeManager
+ const globalScope = scopeManager.globalScope
+ const toplevelScope =
+ globalScope?.childScopes.find((scope) => scope.type === "module") ||
+ globalScope
+ if (!globalScope || !toplevelScope) {
+ return {}
+ }
+
+ const cacheMutableVariable = new WeakMap()
+
+ /**
+ * Checks whether the given reference is a mutable variable or not.
+ */
+ function isMutableVariableReference(reference: Reference) {
+ if (reference.identifier.name.startsWith("$")) {
+ // It is reactive store reference.
+ return true
+ }
+ if (!reference.resolved) {
+ // Unknown variable
+ return true
+ }
+ return isMutableVariable(reference.resolved)
+ }
+
+ /**
+ * Checks whether the given variable is a mutable variable or not.
+ */
+ function isMutableVariable(variable: Variable) {
+ const cache = cacheMutableVariable.get(variable)
+ if (cache != null) {
+ return cache
+ }
+ if (variable.defs.length === 0) {
+ // Global variables are assumed to be immutable.
+ return true
+ }
+ const isMutable = variable.defs.some((def) => {
+ if (def.type === "Variable") {
+ const parent = def.parent
+ if (parent.kind === "const") {
+ return false
+ }
+ const pp = parent.parent
+ if (
+ pp &&
+ pp.type === "ExportNamedDeclaration" &&
+ pp.declaration === parent
+ ) {
+ // Props
+ return true
+ }
+ return hasWrite(variable)
+ }
+ if (def.type === "ImportBinding") {
+ return false
+ }
+
+ if (def.node.type === "AssignmentExpression") {
+ // Reactive values
+ return true
+ }
+ return false
+ })
+ cacheMutableVariable.set(variable, isMutable)
+ return isMutable
+ }
+
+ /** Checks whether the given variable has a write or reactive store reference or not. */
+ function hasWrite(variable: Variable) {
+ const defIds = variable.defs.map((def: Definition) => def.name)
+ return variable.references.some(
+ (reference) =>
+ reference.isWrite() &&
+ !defIds.some(
+ (defId) =>
+ defId.range[0] <= reference.identifier.range[0] &&
+ reference.identifier.range[1] <= defId.range[1],
+ ),
+ )
+ }
+
+ /**
+ * Iterates through references to top-level variables in the given range.
+ */
+ function* iterateRangeReferences(scope: Scope, range: [number, number]) {
+ for (const variable of scope.variables) {
+ for (const reference of variable.references) {
+ if (
+ range[0] <= reference.identifier.range[0] &&
+ reference.identifier.range[1] <= range[1]
+ ) {
+ yield reference
+ }
+ }
+ }
+ }
+
+ return {
+ SvelteReactiveStatement(node: AST.SvelteReactiveStatement) {
+ for (const reference of iterateRangeReferences(
+ toplevelScope,
+ node.range,
+ )) {
+ if (reference.isWriteOnly()) {
+ continue
+ }
+ if (isMutableVariableReference(reference)) {
+ return
+ }
+ }
+ if (
+ globalScope.through.some(
+ (reference) =>
+ node.range[0] <= reference.identifier.range[0] &&
+ reference.identifier.range[1] <= node.range[1],
+ )
+ ) {
+ // Do not report if there are missing references.
+ return
+ }
+
+ context.report({
+ node:
+ node.body.type === "ExpressionStatement" &&
+ node.body.expression.type === "AssignmentExpression" &&
+ node.body.expression.operator === "="
+ ? node.body.expression.right
+ : node.body,
+ messageId: "immutable",
+ })
+ },
+ }
+ },
+})
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index b720d98b7..4382cf6dd 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -24,6 +24,7 @@ import noDupeUseDirectives from "../rules/no-dupe-use-directives"
import noDynamicSlotName from "../rules/no-dynamic-slot-name"
import noExportLoadInSvelteModuleInKitPages from "../rules/no-export-load-in-svelte-module-in-kit-pages"
import noExtraReactiveCurlies from "../rules/no-extra-reactive-curlies"
+import noImmutableReactiveStatements from "../rules/no-immutable-reactive-statements"
import noInnerDeclarations from "../rules/no-inner-declarations"
import noNotFunctionHandler from "../rules/no-not-function-handler"
import noObjectInTextMustaches from "../rules/no-object-in-text-mustaches"
@@ -79,6 +80,7 @@ export const rules = [
noDynamicSlotName,
noExportLoadInSvelteModuleInKitPages,
noExtraReactiveCurlies,
+ noImmutableReactiveStatements,
noInnerDeclarations,
noNotFunctionHandler,
noObjectInTextMustaches,
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-errors.yaml b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-errors.yaml
new file mode 100644
index 000000000..1dbd98360
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-errors.yaml
@@ -0,0 +1,18 @@
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 3
+ column: 18
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 4
+ column: 18
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 5
+ column: 6
+ suggestions: null
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-input.svelte b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-input.svelte
new file mode 100644
index 000000000..d27b883a8
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable-let-input.svelte
@@ -0,0 +1,10 @@
+
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-errors.yaml b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-errors.yaml
new file mode 100644
index 000000000..d8e384b3a
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-errors.yaml
@@ -0,0 +1,24 @@
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 7
+ column: 18
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 8
+ column: 18
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 9
+ column: 6
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 10
+ column: 6
+ suggestions: null
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-input.svelte b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-input.svelte
new file mode 100644
index 000000000..b70ec3dfd
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/immutable01-input.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-errors.yaml b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-errors.yaml
new file mode 100644
index 000000000..965b9a55c
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-errors.yaml
@@ -0,0 +1,18 @@
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 12
+ column: 17
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 13
+ column: 17
+ suggestions: null
+- message:
+ This statement is not reactive because all variables referenced in the
+ reactive statement are immutable.
+ line: 14
+ column: 17
+ suggestions: null
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-input.svelte b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-input.svelte
new file mode 100644
index 000000000..cded22396
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/invalid/readonly-export01-input.svelte
@@ -0,0 +1,15 @@
+
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/valid/mutable01-input.svelte b/tests/fixtures/rules/no-immutable-reactive-statements/valid/mutable01-input.svelte
new file mode 100644
index 000000000..b7c3c9bd9
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/valid/mutable01-input.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/tests/fixtures/rules/no-immutable-reactive-statements/valid/unknown01-input.svelte b/tests/fixtures/rules/no-immutable-reactive-statements/valid/unknown01-input.svelte
new file mode 100644
index 000000000..1e7203315
--- /dev/null
+++ b/tests/fixtures/rules/no-immutable-reactive-statements/valid/unknown01-input.svelte
@@ -0,0 +1,3 @@
+
diff --git a/tests/src/rules/no-immutable-reactive-statements.ts b/tests/src/rules/no-immutable-reactive-statements.ts
new file mode 100644
index 000000000..3e1a8b507
--- /dev/null
+++ b/tests/src/rules/no-immutable-reactive-statements.ts
@@ -0,0 +1,16 @@
+import { RuleTester } from "eslint"
+import rule from "../../../src/rules/no-immutable-reactive-statements"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+})
+
+tester.run(
+ "no-immutable-reactive-statements",
+ rule as any,
+ loadTestCases("no-immutable-reactive-statements"),
+)
diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts
index 01dca2a37..04e6e2560 100644
--- a/tests/utils/utils.ts
+++ b/tests/utils/utils.ts
@@ -207,6 +207,9 @@ function writeFixtures(
},
...config.parserOptions,
},
+ globals: {
+ console: "readonly",
+ },
},
config.filename,
)
@@ -282,6 +285,9 @@ function getConfig(ruleName: string, inputFile: string) {
},
extraFileExtensions: [".svelte"],
},
+ globals: {
+ console: "readonly",
+ },
},
config,
{ code, filename: inputFile },