Repository: ota-meshi/eslint-plugin-svelte
Pull request #249 (2022-09-03 09:01:19)

feat: implement `derived-has-same-inputs-outputs`
Related to: https://github.com/ota-meshi/eslint-plugin-svelte/issues/198 (derived-inputs-outputs)

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 9683f6112b28456d07481ff5ccfcc50875df8427

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/baseballyama/eslint-plugin-svelte/new/feature/derived-inputs-outputs?filename=.changeset/seven-numbers-swim.md&value=---%0A%22eslint-plugin-svelte%22%3A%20patch%0A---%0A%0Afeat%3A%20implement%20%60derived-has-same-inputs-outputs%60%0A)


baseballyama: Maybe CI will be fixed after merging https://github.com/ota-meshi/eslint-plugin-svelte/pull/248 and https://github.com/ota-meshi/eslint-plugin-svelte/pull/247
codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/ota-meshi/eslint-plugin-svelte/pr/249/builds/289333) or the icon next to each commit SHA.
baseballyama: @ota-meshi And I added a few more tests. Thank you for your review!

Patch:
diff --git a/.changeset/gentle-bugs-stare.md b/.changeset/gentle-bugs-stare.md
new file mode 100644
index 0000000..e207c81
--- /dev/null
+++ b/.changeset/gentle-bugs-stare.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-svelte": minor
+---
+
+feat: add `svelte/derived-has-same-inputs-outputs` rule
diff --git a/README.md b/README.md
index 9f78407..d14be63 100644
--- a/README.md
+++ b/README.md
@@ -289,6 +289,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | Rule ID | Description |    |
 |:--------|:------------|:---|
 | [svelte/button-has-type](https://ota-meshi.github.io/eslint-plugin-svelte/rules/button-has-type/) | disallow usage of button without an explicit type attribute |  |
+| [svelte/derived-has-same-inputs-outputs](https://ota-meshi.github.io/eslint-plugin-svelte/rules/derived-has-same-inputs-outputs/) | derived store should use same variable names between values and callback |  |
 | [svelte/no-at-debug-tags](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-at-debug-tags/) | disallow the use of `{@debug}` | :star: |
 | [svelte/no-reactive-functions](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-reactive-functions/) | it's not necessary to define functions in reactive statements | :bulb: |
 | [svelte/no-reactive-literals](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :bulb: |
diff --git a/docs-svelte-kit/src/routes/+layout.js b/docs-svelte-kit/src/routes/+layout.js
index def92b2..504efe7 100644
--- a/docs-svelte-kit/src/routes/+layout.js
+++ b/docs-svelte-kit/src/routes/+layout.js
@@ -1,6 +1,8 @@
 import { markdownPath } from "$lib/utils.js"
 const docs = import.meta.glob("../../../docs/**/*.md")
 
+export const prerender = true
+
 /** @type {import('@sveltejs/kit').Load} */
 export async function load({ url }) {
   const markdown = `../../../docs/${markdownPath(url.pathname)}`
diff --git a/docs/rules.md b/docs/rules.md
index 1bf7942..a335ce2 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -42,6 +42,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | Rule ID | Description |    |
 |:--------|:------------|:---|
 | [svelte/button-has-type](./rules/button-has-type.md) | disallow usage of button without an explicit type attribute |  |
+| [svelte/derived-has-same-inputs-outputs](./rules/derived-has-same-inputs-outputs.md) | derived store should use same variable names between values and callback |  |
 | [svelte/no-at-debug-tags](./rules/no-at-debug-tags.md) | disallow the use of `{@debug}` | :star: |
 | [svelte/no-reactive-functions](./rules/no-reactive-functions.md) | it's not necessary to define functions in reactive statements | :bulb: |
 | [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :bulb: |
diff --git a/docs/rules/derived-has-same-inputs-outputs.md b/docs/rules/derived-has-same-inputs-outputs.md
new file mode 100644
index 0000000..e63e488
--- /dev/null
+++ b/docs/rules/derived-has-same-inputs-outputs.md
@@ -0,0 +1,48 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "svelte/derived-has-same-inputs-outputs"
+description: "derived store should use same variable names between values and callback"
+---
+
+#  svelte/derived-has-same-inputs-outputs
+
+> derived store should use same variable names between values and callback
+
+## :book: Rule Details
+
+This rule reports where variable names and callback function's argument names are different.
+This is mainly a recommended rule to avoid implementation confusion.
+
+<ESLintCodeBlock language="javascript">
+
+<!--eslint-skip-->
+
+```js
+/* eslint svelte/derived-has-same-inputs-outputs: "error" */
+
+import { derived } from "svelte/store"
+
+/* ✓ GOOD */
+derived(a, ($a) => {});
+derived(a, ($a, set) => {})
+derived([ a, b ], ([ $a, $b ]) => {})
+
+/* ✗ BAD */
+derived(a, (b) => {});
+derived(a, (b, set) => {});
+derived([ a, b ], ([ one, two ]) => {})
+```
+
+</ESLintCodeBlock>
+
+## :wrench: Options
+
+Nothing.
+
+
+## :books: Further Reading
+
+- [Svelte - Docs > RUN TIME > svelte/store > derived](https://svelte.dev/docs#run-time-svelte-store-derived)
+
+
diff --git a/package.json b/package.json
index 7725c92..a7cbe19 100644
--- a/package.json
+++ b/package.json
@@ -90,8 +90,8 @@
     "@fontsource/fira-mono": "^4.5.0",
     "@ota-meshi/eslint-plugin": "^0.12.0",
     "@sindresorhus/slugify": "^2.1.0",
-    "@sveltejs/adapter-static": "^1.0.0-next.26",
-    "@sveltejs/kit": "^1.0.0-next.406",
+    "@sveltejs/adapter-static": "^1.0.0-next.40",
+    "@sveltejs/kit": "^1.0.0-next.456",
     "@types/babel__core": "^7.1.19",
     "@types/cross-spawn": "^6.0.2",
     "@types/escape-html": "^1.0.2",
diff --git a/src/rules/derived-has-same-inputs-outputs.ts b/src/rules/derived-has-same-inputs-outputs.ts
new file mode 100644
index 0000000..1d9a7a3
--- /dev/null
+++ b/src/rules/derived-has-same-inputs-outputs.ts
@@ -0,0 +1,107 @@
+import type * as ESTree from "estree"
+import { createRule } from "../utils"
+import type { RuleContext } from "../types"
+import { extractStoreReferences } from "./reference-helpers/svelte-store"
+
+export default createRule("derived-has-same-inputs-outputs", {
+  meta: {
+    docs: {
+      description:
+        "derived store should use same variable names between values and callback",
+      category: "Stylistic Issues",
+      recommended: false,
+      conflictWithPrettier: false,
+    },
+    schema: [],
+    messages: {
+      unexpected: "The argument name should be '{{name}}'.",
+    },
+    type: "suggestion",
+  },
+  create(context) {
+    /** check node type */
+    function isIdentifierOrArrayExpression(
+      node: ESTree.SpreadElement | ESTree.Expression,
+    ): node is ESTree.Identifier | ESTree.ArrayExpression {
+      return ["Identifier", "ArrayExpression"].includes(node.type)
+    }
+
+    type ArrowFunctionExpressionOrFunctionExpression =
+      | ESTree.ArrowFunctionExpression
+      | ESTree.FunctionExpression
+
+    /** check node type */
+    function isFunctionExpression(
+      node: ESTree.SpreadElement | ESTree.Expression,
+    ): node is ArrowFunctionExpressionOrFunctionExpression {
+      return ["ArrowFunctionExpression", "FunctionExpression"].includes(
+        node.type,
+      )
+    }
+
+    /**
+     * Check for identifier type.
+     * e.g. derived(a, ($a) => {});
+     */
+    function checkIdentifier(
+      context: RuleContext,
+      args: ESTree.Identifier,
+      fn: ArrowFunctionExpressionOrFunctionExpression,
+    ) {
+      const fnParam = fn.params[0]
+      if (fnParam.type !== "Identifier") return
+      const expectedName = `$${args.name}`
+      if (expectedName !== fnParam.name) {
+        context.report({
+          node: fn,
+          loc: fnParam.loc!,
+          messageId: "unexpected",
+          data: { name: expectedName },
+        })
+      }
+    }
+
+    /**
+     * Check for array type.
+     * e.g. derived([ a, b ], ([ $a, $b ]) => {})
+     */
+    function checkArrayExpression(
+      context: RuleContext,
+      args: ESTree.ArrayExpression,
+      fn: ArrowFunctionExpressionOrFunctionExpression,
+    ) {
+      const fnParam = fn.params[0]
+      if (fnParam.type !== "ArrayPattern") return
+      const argNames = args.elements.map((element) => {
+        return element && element.type === "Identifier" ? element.name : null
+      })
+      fnParam.elements.forEach((element, index) => {
+        const argName = argNames[index]
+        if (element && element.type === "Identifier" && argName) {
+          const expectedName = `$${argName}`
+          if (expectedName !== element.name) {
+            context.report({
+              node: fn,
+              loc: element.loc!,
+              messageId: "unexpected",
+              data: { name: expectedName },
+            })
+          }
+        }
+      })
+    }
+
+    return {
+      Program() {
+        for (const { node } of extractStoreReferences(context, ["derived"])) {
+          const [args, fn] = node.arguments
+          if (!args || !isIdentifierOrArrayExpression(args)) continue
+          if (!fn || !isFunctionExpression(fn)) continue
+          if (!fn.params || fn.params.length === 0) continue
+          if (args.type === "Identifier") checkIdentifier(context, args, fn)
+          else checkArrayExpression(context, args, fn)
+        }
+      },
+    }
+  },
+})
diff --git a/src/rules/no-store-async.ts b/src/rules/no-store-async.ts
index 6934b69..b8dc13e 100644
--- a/src/rules/no-store-async.ts
+++ b/src/rules/no-store-async.ts
@@ -32,7 +32,7 @@ export default createRule("no-store-async", {
             continue
           }
 
-          const start = fn.loc?.start ?? { line: 1, column: 0 }
+          const start = fn.loc!.start
           context.report({
             node: fn,
             loc: {
diff --git a/src/rules/reference-helpers/svelte-store.ts b/src/rules/reference-helpers/svelte-store.ts
index 3e9edc9..ba58abf 100644
--- a/src/rules/reference-helpers/svelte-store.ts
+++ b/src/rules/reference-helpers/svelte-store.ts
@@ -2,22 +2,25 @@ import type * as ESTree from "estree"
 import { ReferenceTracker } from "eslint-utils"
 import type { RuleContext } from "../../types"
 
+type StoreName = "writable" | "readable" | "derived"
+
 /** Extract 'svelte/store' references */
 export function* extractStoreReferences(
   context: RuleContext,
+  storeNames: StoreName[] = ["writable", "readable", "derived"],
 ): Generator<{ node: ESTree.CallExpression; name: string }, void> {
   const referenceTracker = new ReferenceTracker(context.getScope())
   for (const { node, path } of referenceTracker.iterateEsmReferences({
     "svelte/store": {
       [ReferenceTracker.ESM]: true,
       writable: {
-        [ReferenceTracker.CALL]: true,
+        [ReferenceTracker.CALL]: storeNames.includes("writable"),
       },
       readable: {
-        [ReferenceTracker.CALL]: true,
+        [ReferenceTracker.CALL]: storeNames.includes("readable"),
       },
       derived: {
-        [ReferenceTracker.CALL]: true,
+        [ReferenceTracker.CALL]: storeNames.includes("derived"),
       },
     },
   })) {
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index 9efb8fc..f22699f 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -1,6 +1,7 @@
 import type { RuleModule } from "../types"
 import buttonHasType from "../rules/button-has-type"
 import commentDirective from "../rules/comment-directive"
+import derivedHasSameInputsOutputs from "../rules/derived-has-same-inputs-outputs"
 import firstAttributeLinebreak from "../rules/first-attribute-linebreak"
 import htmlClosingBracketSpacing from "../rules/html-closing-bracket-spacing"
 import htmlQuotes from "../rules/html-quotes"
@@ -41,6 +42,7 @@ import validCompile from "../rules/valid-compile"
 export const rules = [
   buttonHasType,
   commentDirective,
+  derivedHasSameInputsOutputs,
   firstAttributeLinebreak,
   htmlClosingBracketSpacing,
   htmlQuotes,
diff --git a/svelte.config.mjs b/svelte.config.mjs
index 71bcdb4..86b3622 100644
--- a/svelte.config.mjs
+++ b/svelte.config.mjs
@@ -37,12 +37,9 @@ const config = {
       pages: outDir,
       assets: outDir,
     }),
-    prerender: {
-      default: true,
-    },
     files: {
       routes: path.join(dirname, "./docs-svelte-kit/src/routes"),
-      template: path.join(dirname, "./docs-svelte-kit/src/app.html"),
+      appTemplate: path.join(dirname, "./docs-svelte-kit/src/app.html"),
       hooks: path.join(dirname, "./docs-svelte-kit/src/hooks"),
       lib: path.join(dirname, "./docs-svelte-kit/src/lib"),
       assets: path.join(dirname, "./docs-svelte-kit/statics"),


Test patch:
diff --git a/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-errors.yaml b/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-errors.yaml
new file mode 100644
index 0000000..9ce09c0
--- /dev/null
+++ b/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-errors.yaml
@@ -0,0 +1,32 @@
+- message: The argument name should be '$a'.
+  line: 3
+  column: 13
+  suggestions: null
+- message: The argument name should be '$c'.
+  line: 6
+  column: 13
+  suggestions: null
+- message: The argument name should be '$e'.
+  line: 9
+  column: 19
+  suggestions: null
+- message: The argument name should be '$f'.
+  line: 9
+  column: 22
+  suggestions: null
+- message: The argument name should be '$i'.
+  line: 12
+  column: 19
+  suggestions: null
+- message: The argument name should be '$j'.
+  line: 12
+  column: 22
+  suggestions: null
+- message: The argument name should be '$l'.
+  line: 15
+  column: 26
+  suggestions: null
+- message: The argument name should be '$o'.
+  line: 18
+  column: 22
+  suggestions: null
diff --git a/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-input.js b/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-input.js
new file mode 100644
index 0000000..e272e95
--- /dev/null
+++ b/tests/fixtures/rules/derived-has-same-inputs-outputs/invalid/test01-input.js
@@ -0,0 +1,20 @@
+import { derived } from "svelte/store"
+
+derived(a, (b) => {
+  /** do nothing */
+})
+derived(c, (d, set) => {
+  /** do nothing */
+})
+derived([e, f], ([g, h]) => {
+  /** do nothing */
+})
+derived([i, j], ([k, l], set) => {
+  /** do nothing */
+})
+derived([null, l], ([$m, $n]) => {
+  /** do nothing */
+})
+derived([o, null], ([$p, $q]) => {
+  /** do nothing */
+})
diff --git a/tests/fixtures/rules/derived-has-same-inputs-outputs/valid/test01-input.js b/tests/fixtures/rules/derived-has-same-inputs-outputs/valid/test01-input.js
new file mode 100644
index 0000000..40e0f2d
--- /dev/null
+++ b/tests/fixtures/rules/derived-has-same-inputs-outputs/valid/test01-input.js
@@ -0,0 +1,26 @@
+import { derived } from "svelte/store"
+
+derived(a, ($a) => {
+  /** do nothing */
+})
+derived(c, ($c, set) => {
+  /** do nothing */
+})
+derived([e, f], ([$e, $f]) => {
+  /** do nothing */
+})
+derived([i, j], ([$i, $j], set) => {
+  /** do nothing */
+})
+derived(null, ($null, set) => {
+  /** do nothing */
+})
+derived(null, ($k, set) => {
+  /** do nothing */
+})
+derived([null, l], ([$m, $l]) => {
+  /** do nothing */
+})
+derived([n, null], ([$n, $o]) => {
+  /** do nothing */
+})
diff --git a/tests/src/rules/derived-has-same-inputs-outputs.ts b/tests/src/rules/derived-has-same-inputs-outputs.ts
new file mode 100644
index 0000000..8c05e2b
--- /dev/null
+++ b/tests/src/rules/derived-has-same-inputs-outputs.ts
@@ -0,0 +1,16 @@
+import { RuleTester } from "eslint"
+import rule from "../../../src/rules/derived-has-same-inputs-outputs"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: "module",
+  },
+})
+
+tester.run(
+  "derived-has-same-inputs-outputs",
+  rule as any,
+  loadTestCases("derived-has-same-inputs-outputs"),
+)