From b7acdecc53050601f029868d5ec3b66879b61b32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:16:36 +0000 Subject: [PATCH 01/18] feat: add Solid framework rules ported from eslint-plugin-solid Ports 17 rules from solidjs-community/eslint-plugin-solid to the oxc plugin so React Doctor can lint SolidJS codebases. A new `solid` capability is added to project-info: it's set when the project's package.json declares `solid-js`, `solid-start`, `@solidjs/start`, or `@solidjs/router`. Every Solid rule is gated by that capability so they don't fire in pure-React projects. New rules under packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/: - solid-no-react-specific-props - solid-no-react-deps - solid-no-innerhtml - solid-no-array-handlers (opt-in) - solid-no-unknown-namespaces - solid-jsx-no-duplicate-props - solid-jsx-no-script-url - solid-no-proxy-apis (opt-in) - solid-no-destructure - solid-components-return-once - solid-self-closing-comp (opt-in) - solid-prefer-for - solid-prefer-show (opt-in) - solid-prefer-classlist (opt-in, deprecated upstream) - solid-event-handlers - solid-imports - solid-style-prop The reactivity, jsx-uses-vars, and jsx-no-undef rules from the upstream plugin are intentionally NOT ported. `reactivity` (1200+ lines) requires a custom scope analyzer that's a project of its own; `jsx-uses-vars` / `jsx-no-undef` overlap with oxlint's built-in `no-undef` and the existing `react-builtins` ports. Also exposes `SOLID_RULES` from oxlint-plugin-react-doctor and adds a matching `solid` ESLint flat config to eslint-plugin-react-doctor. Co-authored-by: Aiden Bai --- .../core/src/project-info/discover-project.ts | 2 + packages/core/src/project-info/has-solid.ts | 12 + .../core/src/runners/oxlint/capabilities.ts | 1 + packages/core/src/types/project-info.ts | 12 + packages/core/tests/run-inspect.test.ts | 1 + packages/core/tests/services/linter.test.ts | 1 + packages/core/tests/services/project.test.ts | 1 + .../eslint-plugin-react-doctor/src/index.ts | 3 + .../scripts/generate-rule-registry.mjs | 3 + .../oxlint-plugin-react-doctor/src/index.ts | 1 + .../src/plugin/rule-registry.ts | 221 ++++++++++++++++++ .../solid/solid-components-return-once.ts | 168 +++++++++++++ .../rules/solid/solid-event-handlers.ts | 194 +++++++++++++++ .../src/plugin/rules/solid/solid-imports.ts | 149 ++++++++++++ .../solid-jsx-no-duplicate-props.test.ts | 35 +++ .../solid/solid-jsx-no-duplicate-props.ts | 123 ++++++++++ .../solid/solid-jsx-no-script-url.test.ts | 22 ++ .../rules/solid/solid-jsx-no-script-url.ts | 50 ++++ .../rules/solid/solid-no-array-handlers.ts | 50 ++++ .../rules/solid/solid-no-destructure.ts | 60 +++++ .../rules/solid/solid-no-innerhtml.test.ts | 31 +++ .../plugin/rules/solid/solid-no-innerhtml.ts | 78 +++++++ .../plugin/rules/solid/solid-no-proxy-apis.ts | 117 ++++++++++ .../rules/solid/solid-no-react-deps.test.ts | 47 ++++ .../plugin/rules/solid/solid-no-react-deps.ts | 63 +++++ .../solid-no-react-specific-props.test.ts | 35 +++ .../solid/solid-no-react-specific-props.ts | 50 ++++ .../solid/solid-no-unknown-namespaces.test.ts | 38 +++ .../solid/solid-no-unknown-namespaces.ts | 76 ++++++ .../rules/solid/solid-prefer-classlist.ts | 72 ++++++ .../rules/solid/solid-prefer-for.test.ts | 23 ++ .../plugin/rules/solid/solid-prefer-for.ts | 74 ++++++ .../plugin/rules/solid/solid-prefer-show.ts | 72 ++++++ .../rules/solid/solid-self-closing-comp.ts | 87 +++++++ .../plugin/rules/solid/solid-style-prop.ts | 116 +++++++++ .../utils/create-solid-import-tracker.ts | 39 ++++ .../src/plugin/utils/rule.ts | 3 +- .../oxlint-plugin-react-doctor/src/rules.ts | 1 + .../tests/build-json-report.test.ts | 1 + .../tests/regressions/_helpers.ts | 3 + .../react-doctor/tests/to-json-report.test.ts | 1 + 41 files changed, 2135 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/project-info/has-solid.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-imports.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-show.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/create-solid-import-tracker.ts diff --git a/packages/core/src/project-info/discover-project.ts b/packages/core/src/project-info/discover-project.ts index 49c827099..372338ff1 100644 --- a/packages/core/src/project-info/discover-project.ts +++ b/packages/core/src/project-info/discover-project.ts @@ -11,6 +11,7 @@ import { findMonorepoRoot, isMonorepoRoot } from "./find-monorepo-root.js"; import { findReactInWorkspaces } from "./find-react-in-workspaces.js"; import { getDependencyDeclaration } from "./utils/get-dependency-declaration.js"; import { hasReactNativeWorkspaceAnywhere } from "./has-react-native-workspace-anywhere.js"; +import { hasSolid } from "./has-solid.js"; import { hasTanStackQuery } from "./has-tanstack-query.js"; import { readPackageJson } from "./read-package-json.js"; import { isCatalogReference, resolveCatalogVersion } from "./resolve-catalog-version.js"; @@ -166,6 +167,7 @@ export const discoverProject = (directory: string): ProjectInfo => { hasTypeScript, hasReactCompiler: detectReactCompiler(directory, packageJson), hasTanStackQuery: hasTanStackQuery(packageJson), + hasSolid: hasSolid(packageJson), hasReactNativeWorkspace, sourceFileCount, }; diff --git a/packages/core/src/project-info/has-solid.ts b/packages/core/src/project-info/has-solid.ts new file mode 100644 index 000000000..e879cac3d --- /dev/null +++ b/packages/core/src/project-info/has-solid.ts @@ -0,0 +1,12 @@ +import type { PackageJson } from "../types/index.js"; + +const SOLID_PACKAGES = new Set(["solid-js", "solid-start", "@solidjs/start", "@solidjs/router"]); + +export const hasSolid = (packageJson: PackageJson): boolean => { + const allDependencies = { + ...packageJson.peerDependencies, + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + return Object.keys(allDependencies).some((packageName) => SOLID_PACKAGES.has(packageName)); +}; diff --git a/packages/core/src/runners/oxlint/capabilities.ts b/packages/core/src/runners/oxlint/capabilities.ts index 73e50c876..da59aea0b 100644 --- a/packages/core/src/runners/oxlint/capabilities.ts +++ b/packages/core/src/runners/oxlint/capabilities.ts @@ -38,6 +38,7 @@ export const buildCapabilities = (project: ProjectInfo): ReadonlySet => if (project.hasReactCompiler) capabilities.add("react-compiler"); if (project.hasTanStackQuery) capabilities.add("tanstack-query"); + if (project.hasSolid) capabilities.add("solid"); if (project.hasTypeScript) capabilities.add("typescript"); return capabilities; diff --git a/packages/core/src/types/project-info.ts b/packages/core/src/types/project-info.ts index c6ffb32b7..5c9662dbe 100644 --- a/packages/core/src/types/project-info.ts +++ b/packages/core/src/types/project-info.ts @@ -19,6 +19,18 @@ export interface ProjectInfo { hasTypeScript: boolean; hasReactCompiler: boolean; hasTanStackQuery: boolean; + /** + * `true` when the project (or any of its workspace packages) declares + * `solid-js` (or `@solidjs/start`, `solid-start`, `@solidjs/router`) + * as a dependency. Enables the `solid` capability — and therefore + * every `solid-*` rule — even on monorepos where the entry-point + * `package.json` is a different framework but a sibling workspace + * (`apps/solid`, `packages/solid-ui`) targets SolidJS. + * + * `false` collapses the gate to "no Solid here" — no `solid-*` rule + * loads for the project at all. + */ + hasSolid: boolean; /** * `true` when the project (or any of its workspace packages) declares * React Native or Expo as a dependency. Enables the `react-native` diff --git a/packages/core/tests/run-inspect.test.ts b/packages/core/tests/run-inspect.test.ts index cab5d8f69..c1f1b56cf 100644 --- a/packages/core/tests/run-inspect.test.ts +++ b/packages/core/tests/run-inspect.test.ts @@ -33,6 +33,7 @@ const sampleProject: ProjectInfo = { hasTypeScript: true, hasReactCompiler: false, hasTanStackQuery: false, + hasSolid: false, hasReactNativeWorkspace: false, sourceFileCount: 1, }; diff --git a/packages/core/tests/services/linter.test.ts b/packages/core/tests/services/linter.test.ts index 9bdb5d844..f925633a2 100644 --- a/packages/core/tests/services/linter.test.ts +++ b/packages/core/tests/services/linter.test.ts @@ -16,6 +16,7 @@ const sampleProject: ProjectInfo = { hasTypeScript: true, hasReactCompiler: false, hasTanStackQuery: false, + hasSolid: false, hasReactNativeWorkspace: false, sourceFileCount: 1, }; diff --git a/packages/core/tests/services/project.test.ts b/packages/core/tests/services/project.test.ts index 95d45ad16..0294baf74 100644 --- a/packages/core/tests/services/project.test.ts +++ b/packages/core/tests/services/project.test.ts @@ -16,6 +16,7 @@ const sampleProject: ProjectInfo = { hasTypeScript: true, hasReactCompiler: false, hasTanStackQuery: false, + hasSolid: false, hasReactNativeWorkspace: false, sourceFileCount: 1, }; diff --git a/packages/eslint-plugin-react-doctor/src/index.ts b/packages/eslint-plugin-react-doctor/src/index.ts index f5968c560..26b8d9bd7 100644 --- a/packages/eslint-plugin-react-doctor/src/index.ts +++ b/packages/eslint-plugin-react-doctor/src/index.ts @@ -3,6 +3,7 @@ import oxlintPlugin, { NEXTJS_RULES, REACT_NATIVE_RULES, RECOMMENDED_RULES, + SOLID_RULES, TANSTACK_QUERY_RULES, TANSTACK_START_RULES, } from "oxlint-plugin-react-doctor"; @@ -47,6 +48,7 @@ interface EslintPlugin { "react-native": EslintFlatConfig; "tanstack-start": EslintFlatConfig; "tanstack-query": EslintFlatConfig; + solid: EslintFlatConfig; all: EslintFlatConfig; }; } @@ -99,6 +101,7 @@ const eslintPlugin: EslintPlugin = { "react-native": buildFlatConfig("react-native", REACT_NATIVE_RULES), "tanstack-start": buildFlatConfig("tanstack-start", TANSTACK_START_RULES), "tanstack-query": buildFlatConfig("tanstack-query", TANSTACK_QUERY_RULES), + solid: buildFlatConfig("solid", SOLID_RULES), all: buildFlatConfig("all", ALL_REACT_DOCTOR_RULES), }, }; diff --git a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs index d7fda66be..55d589ca3 100644 --- a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs +++ b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs @@ -25,6 +25,7 @@ const BUCKET_TO_FRAMEWORK = { "react-native": "react-native", "tanstack-start": "tanstack-start", "tanstack-query": "tanstack-query", + solid: "solid", }; // Bucket directory → behavioral tags merged onto every rule in that @@ -37,6 +38,7 @@ const BUCKET_TO_FRAMEWORK = { const BUCKET_TO_AUTO_TAGS = { "react-native": ["react-native"], server: ["server-action"], + solid: ["solid"], }; // Buckets containing rules ported from external upstream linters @@ -78,6 +80,7 @@ const BUCKET_TO_DEFAULT_CATEGORY = { security: "Security", server: "Server", "state-and-effects": "State & Effects", + solid: "SolidJS", "tanstack-query": "TanStack Query", "tanstack-start": "TanStack Start", "view-transitions": "Correctness", diff --git a/packages/oxlint-plugin-react-doctor/src/index.ts b/packages/oxlint-plugin-react-doctor/src/index.ts index dc05c5fc6..11f13e278 100644 --- a/packages/oxlint-plugin-react-doctor/src/index.ts +++ b/packages/oxlint-plugin-react-doctor/src/index.ts @@ -13,6 +13,7 @@ export { REACT_NATIVE_RULES, RECOMMENDED_RULES, RULES, + SOLID_RULES, TANSTACK_QUERY_RULES, TANSTACK_START_RULES, } from "./rules.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 2e63cc288..4a2365984 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -277,6 +277,23 @@ import { serverFetchWithoutRevalidate } from "./rules/server/server-fetch-withou import { serverHoistStaticIo } from "./rules/server/server-hoist-static-io.js"; import { serverNoMutableModuleState } from "./rules/server/server-no-mutable-module-state.js"; import { serverSequentialIndependentAwait } from "./rules/server/server-sequential-independent-await.js"; +import { solidComponentsReturnOnce } from "./rules/solid/solid-components-return-once.js"; +import { solidEventHandlers } from "./rules/solid/solid-event-handlers.js"; +import { solidImports } from "./rules/solid/solid-imports.js"; +import { solidJsxNoDuplicateProps } from "./rules/solid/solid-jsx-no-duplicate-props.js"; +import { solidJsxNoScriptUrl } from "./rules/solid/solid-jsx-no-script-url.js"; +import { solidNoArrayHandlers } from "./rules/solid/solid-no-array-handlers.js"; +import { solidNoDestructure } from "./rules/solid/solid-no-destructure.js"; +import { solidNoInnerHtml } from "./rules/solid/solid-no-innerhtml.js"; +import { solidNoProxyApis } from "./rules/solid/solid-no-proxy-apis.js"; +import { solidNoReactDeps } from "./rules/solid/solid-no-react-deps.js"; +import { solidNoReactSpecificProps } from "./rules/solid/solid-no-react-specific-props.js"; +import { solidNoUnknownNamespaces } from "./rules/solid/solid-no-unknown-namespaces.js"; +import { solidPreferClasslist } from "./rules/solid/solid-prefer-classlist.js"; +import { solidPreferFor } from "./rules/solid/solid-prefer-for.js"; +import { solidPreferShow } from "./rules/solid/solid-prefer-show.js"; +import { solidSelfClosingComp } from "./rules/solid/solid-self-closing-comp.js"; +import { solidStyleProp } from "./rules/solid/solid-style-prop.js"; import { stateInConstructor } from "./rules/react-builtins/state-in-constructor.js"; import { stylePropObject } from "./rules/react-builtins/style-prop-object.js"; import { tabindexNoPositive } from "./rules/a11y/tabindex-no-positive.js"; @@ -3290,6 +3307,210 @@ export const reactDoctorRules = [ tags: [...new Set(["server-action", ...(serverSequentialIndependentAwait.tags ?? [])])], }, }, + { + key: "react-doctor/solid-components-return-once", + id: "solid-components-return-once", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidComponentsReturnOnce, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidComponentsReturnOnce.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-event-handlers", + id: "solid-event-handlers", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidEventHandlers, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidEventHandlers.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-imports", + id: "solid-imports", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidImports, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidImports.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-jsx-no-duplicate-props", + id: "solid-jsx-no-duplicate-props", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidJsxNoDuplicateProps, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidJsxNoDuplicateProps.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-jsx-no-script-url", + id: "solid-jsx-no-script-url", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidJsxNoScriptUrl, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidJsxNoScriptUrl.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-array-handlers", + id: "solid-no-array-handlers", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoArrayHandlers, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoArrayHandlers.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-destructure", + id: "solid-no-destructure", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoDestructure, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoDestructure.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-innerhtml", + id: "solid-no-innerhtml", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoInnerHtml, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoInnerHtml.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-proxy-apis", + id: "solid-no-proxy-apis", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoProxyApis, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoProxyApis.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-react-deps", + id: "solid-no-react-deps", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoReactDeps, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoReactDeps.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-react-specific-props", + id: "solid-no-react-specific-props", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoReactSpecificProps, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoReactSpecificProps.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-unknown-namespaces", + id: "solid-no-unknown-namespaces", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoUnknownNamespaces, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoUnknownNamespaces.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-prefer-classlist", + id: "solid-prefer-classlist", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferClasslist, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferClasslist.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-prefer-for", + id: "solid-prefer-for", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferFor, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferFor.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-prefer-show", + id: "solid-prefer-show", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferShow, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferShow.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-self-closing-comp", + id: "solid-self-closing-comp", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidSelfClosingComp, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidSelfClosingComp.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-style-prop", + id: "solid-style-prop", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidStyleProp, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidStyleProp.tags ?? [])])], + }, + }, { key: "react-doctor/state-in-constructor", id: "state-in-constructor", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts new file mode 100644 index 000000000..aa79ab1a6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts @@ -0,0 +1,168 @@ +import { containsJsxElement } from "../../utils/contains-jsx-element.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +type FunctionLikeNode = + | EsTreeNodeOfType<"FunctionDeclaration"> + | EsTreeNodeOfType<"FunctionExpression"> + | EsTreeNodeOfType<"ArrowFunctionExpression">; + +const getFunctionDisplayName = (node: FunctionLikeNode): string | null => { + if ( + (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "FunctionExpression")) && + node.id + ) { + return node.id.name; + } + const parent = node.parent; + if ( + parent && + isNodeOfType(parent, "VariableDeclarator") && + isNodeOfType(parent.id, "Identifier") + ) { + return parent.id.name; + } + return null; +}; + +const isComponentName = (name: string | null): boolean => { + if (!name) return false; + const firstCharacter = name.charAt(0); + return ( + firstCharacter.toUpperCase() === firstCharacter && + firstCharacter !== firstCharacter.toLowerCase() + ); +}; + +const findLastNonDeclarationStatement = ( + body: ReadonlyArray, +): EsTreeNode | undefined => { + for (let cursor = body.length - 1; cursor >= 0; cursor--) { + const candidate = body[cursor]; + if (!candidate.type.endsWith("Declaration")) return candidate; + } + return undefined; +}; + +const collectEarlyReturnStatements = ( + body: ReadonlyArray, + lastReturn: EsTreeNode | null, +): ReadonlyArray> => { + const collected: EsTreeNodeOfType<"ReturnStatement">[] = []; + const walk = (node: EsTreeNode): void => { + if (isNodeOfType(node, "ReturnStatement") && node !== lastReturn) { + collected.push(node); + return; + } + if (isNodeOfType(node, "FunctionDeclaration")) return; + if (isNodeOfType(node, "FunctionExpression")) return; + if (isNodeOfType(node, "ArrowFunctionExpression")) return; + const nodeRecord = node as unknown as Record; + for (const key of Object.keys(nodeRecord)) { + if (key === "parent") continue; + const child = nodeRecord[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && "type" in item) walk(item as EsTreeNode); + } + } else if (child && typeof child === "object" && "type" in child) { + walk(child as EsTreeNode); + } + } + }; + for (const statement of body) walk(statement); + return collected; +}; + +const isHocCallParent = (node: FunctionLikeNode): boolean => { + const parent = node.parent; + if (!parent) return false; + if (!isNodeOfType(parent, "CallExpression")) return false; + if (!parent.arguments.includes(node as never)) return false; + if (isNodeOfType(parent.callee, "Identifier")) { + return !isComponentName(parent.callee.name); + } + return false; +}; + +const isRenderPropCallback = (node: FunctionLikeNode): boolean => { + const parent = node.parent; + if (!parent) return false; + return isNodeOfType(parent, "JSXExpressionContainer"); +}; + +// Port of `solid/components-return-once` — Solid components run +// ONCE. Early returns and conditional returns at the top-level +// break reactivity because the unmount path is taken before any +// reactive read fires. The rule warns on every early return inside +// a function that renders JSX, and on any conditional / `&&` +// expression that escapes via the last `return` statement. +export const solidComponentsReturnOnce = defineRule({ + id: "solid-components-return-once", + severity: "warn", + requires: ["solid"], + recommendation: + "Inline conditional rendering inside JSX (`` / ``) instead of returning early — Solid components only run once.", + create: (context: RuleContext) => { + const visitFunction = (node: FunctionLikeNode): void => { + if (!containsJsxElement(node as EsTreeNode)) return; + if (isRenderPropCallback(node)) return; + const displayName = getFunctionDisplayName(node); + if (displayName && /^[a-z]/.test(displayName)) return; + if (isHocCallParent(node)) return; + + let lastReturn: EsTreeNodeOfType<"ReturnStatement"> | null = null; + let bodyStatements: ReadonlyArray = []; + if (node.body && isNodeOfType(node.body, "BlockStatement")) { + bodyStatements = node.body.body; + const lastNonDeclaration = findLastNonDeclarationStatement(bodyStatements); + if (lastNonDeclaration && isNodeOfType(lastNonDeclaration, "ReturnStatement")) { + lastReturn = lastNonDeclaration; + } + } + + const earlyReturns = collectEarlyReturnStatements(bodyStatements, lastReturn); + for (const earlyReturn of earlyReturns) { + context.report({ + node: earlyReturn, + message: + "Solid components run once, so an early return breaks reactivity. Move the condition inside JSX (``).", + }); + } + + const returnArgument = lastReturn?.argument; + if (!returnArgument) return; + if (isNodeOfType(returnArgument, "ConditionalExpression")) { + context.report({ + node: returnArgument, + message: + "Solid components run once, so a conditional return breaks reactivity. Move the condition inside JSX (``).", + }); + } else if ( + isNodeOfType(returnArgument, "LogicalExpression") && + (returnArgument.operator === "&&" || returnArgument.operator === "||") + ) { + context.report({ + node: returnArgument, + message: + "Solid components run once, so a conditional return breaks reactivity. Move the condition inside JSX (``).", + }); + } + }; + return { + FunctionDeclaration(node: EsTreeNodeOfType<"FunctionDeclaration">) { + visitFunction(node); + }, + FunctionExpression(node: EsTreeNodeOfType<"FunctionExpression">) { + visitFunction(node); + }, + ArrowFunctionExpression(node: EsTreeNodeOfType<"ArrowFunctionExpression">) { + visitFunction(node); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts new file mode 100644 index 000000000..bbf879773 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts @@ -0,0 +1,194 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +interface SolidEventHandlersSettings { + ignoreCase?: boolean; + warnOnSpread?: boolean; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidEventHandlersSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidEventHandlers?: unknown }).solidEventHandlers; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidEventHandlersSettings; +}; + +const COMMON_EVENTS: ReadonlyArray = [ + "onAnimationEnd", + "onAnimationIteration", + "onAnimationStart", + "onBeforeInput", + "onBlur", + "onChange", + "onClick", + "onContextMenu", + "onCopy", + "onCut", + "onDblClick", + "onDrag", + "onDragEnd", + "onDragEnter", + "onDragExit", + "onDragLeave", + "onDragOver", + "onDragStart", + "onDrop", + "onError", + "onFocus", + "onFocusIn", + "onFocusOut", + "onGotPointerCapture", + "onInput", + "onInvalid", + "onKeyDown", + "onKeyPress", + "onKeyUp", + "onLoad", + "onLostPointerCapture", + "onMouseDown", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseOut", + "onMouseOver", + "onMouseUp", + "onPaste", + "onPointerCancel", + "onPointerDown", + "onPointerEnter", + "onPointerLeave", + "onPointerMove", + "onPointerOut", + "onPointerOver", + "onPointerUp", + "onReset", + "onScroll", + "onSelect", + "onSubmit", + "onToggle", + "onTouchCancel", + "onTouchEnd", + "onTouchMove", + "onTouchStart", + "onTransitionEnd", + "onWheel", +]; + +const COMMON_EVENTS_BY_LOWERCASE_NAME = new Map(); +for (const event of COMMON_EVENTS) { + COMMON_EVENTS_BY_LOWERCASE_NAME.set(event.toLowerCase(), event); +} + +const NONSTANDARD_EVENT_BY_LOWERCASE_NAME: Record = { + ondoubleclick: "onDblClick", +}; + +const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); + +const isStaticStringOrNumberValue = (node: EsTreeNode | null): boolean => { + if (!node) return false; + if (isNodeOfType(node, "Literal")) { + return typeof node.value === "string" || typeof node.value === "number"; + } + if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) return true; + return false; +}; + +// Port of `solid/event-handlers` — Solid distinguishes +// `onclick` (DOM event property — invalid handler) from `onClick` +// (delegated event listener). Solid's compiler also inlines string +// values starting with `on`, so an attribute like `onClick="..."` +// becomes a string attribute, never a listener. We flag the most +// dangerous mismatches: nonstandard names (`onDoubleClick`), +// lowercase third character (`onfoo`), and static string values +// for handler-named props. +export const solidEventHandlers = defineRule({ + id: "solid-event-handlers", + severity: "warn", + requires: ["solid"], + recommendation: + "Use camelCase event names (`onClick`, not `onclick`). Solid distinguishes the two — only camelCase forms install listeners.", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const opening = node.parent; + if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; + if (!isNodeOfType(opening.name, "JSXIdentifier")) return; + if (!isDomElementName(opening.name.name)) return; + if (!isNodeOfType(node.name, "JSXIdentifier")) return; + const attributeName = node.name.name; + if (!/^on[a-zA-Z]/.test(attributeName)) return; + if (node.value && isNodeOfType(node.value, "JSXExpressionContainer")) { + const expression = node.value.expression as EsTreeNode; + if ( + !isNodeOfType(expression, "JSXEmptyExpression") && + !isNodeOfType(expression, "ArrayExpression") && + isStaticStringOrNumberValue(expression) + ) { + context.report({ + node, + message: `The \`${attributeName}\` prop has a static string/number value, so Solid will treat it as a string attribute, not a handler. Rename it (or use \`attr:${attributeName}\`).`, + }); + return; + } + } else if (node.value === null || (node.value && isNodeOfType(node.value, "Literal"))) { + context.report({ + node, + message: `The \`${attributeName}\` prop has a literal value, so Solid will treat it as a string attribute, not a handler.`, + }); + return; + } + if (settings.ignoreCase) return; + const lowercaseName = attributeName.toLowerCase(); + const nonstandardName = NONSTANDARD_EVENT_BY_LOWERCASE_NAME[lowercaseName]; + if (nonstandardName) { + context.report({ + node: node.name, + message: `\`${attributeName}\` is non-standard — rename to \`${nonstandardName}\`.`, + }); + return; + } + const commonName = COMMON_EVENTS_BY_LOWERCASE_NAME.get(lowercaseName); + if (commonName && commonName !== attributeName) { + context.report({ + node: node.name, + message: `\`${attributeName}\` should be renamed to \`${commonName}\` for readability.`, + }); + return; + } + if (attributeName[2] === attributeName[2].toLowerCase()) { + const handlerName = `on${attributeName[2].toUpperCase()}${attributeName.slice(3)}`; + context.report({ + node: node.name, + message: `The \`${attributeName}\` prop is ambiguous. Use \`${handlerName}\` for an event handler, or \`attr:${attributeName}\` for an attribute.`, + }); + } + }, + Property(node: EsTreeNodeOfType<"Property">) { + if (!settings.warnOnSpread) return; + const objectExpression = node.parent; + if (!objectExpression || !isNodeOfType(objectExpression, "ObjectExpression")) return; + const spreadAttribute = objectExpression.parent; + if (!spreadAttribute || !isNodeOfType(spreadAttribute, "JSXSpreadAttribute")) return; + const opening = spreadAttribute.parent; + if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; + if (!isNodeOfType(opening.name, "JSXIdentifier")) return; + if (!isDomElementName(opening.name.name)) return; + if (!isNodeOfType(node.key, "Identifier")) return; + if (!/^on/.test(node.key.name)) return; + context.report({ + node, + message: `The \`${node.key.name}\` prop should be set as a JSX attribute, not spread in. Solid doesn't add listeners when spreading into JSX.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-imports.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-imports.ts new file mode 100644 index 000000000..dc86c1508 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-imports.ts @@ -0,0 +1,149 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +type Source = "solid-js" | "solid-js/web" | "solid-js/store"; + +const SOURCE_PATTERN = /^solid-js(?:\/web|\/store)?$/; +const isKnownSource = (source: string): source is Source => SOURCE_PATTERN.test(source); + +const PRIMITIVE_SOURCE_MAP = new Map(); +const TYPE_SOURCE_MAP = new Map(); + +for (const primitive of [ + "createSignal", + "createEffect", + "createMemo", + "createResource", + "onMount", + "onCleanup", + "onError", + "untrack", + "batch", + "on", + "createRoot", + "getOwner", + "runWithOwner", + "mergeProps", + "splitProps", + "useTransition", + "observable", + "from", + "mapArray", + "indexArray", + "createContext", + "useContext", + "children", + "lazy", + "createUniqueId", + "createDeferred", + "createRenderEffect", + "createComputed", + "createReaction", + "createSelector", + "DEV", + "For", + "Show", + "Switch", + "Match", + "Index", + "ErrorBoundary", + "Suspense", + "SuspenseList", +]) { + PRIMITIVE_SOURCE_MAP.set(primitive, "solid-js"); +} +for (const primitive of [ + "Portal", + "render", + "hydrate", + "renderToString", + "renderToStream", + "isServer", + "renderToStringAsync", + "generateHydrationScript", + "HydrationScript", + "Dynamic", +]) { + PRIMITIVE_SOURCE_MAP.set(primitive, "solid-js/web"); +} +for (const primitive of [ + "createStore", + "produce", + "reconcile", + "unwrap", + "createMutable", + "modifyMutable", +]) { + PRIMITIVE_SOURCE_MAP.set(primitive, "solid-js/store"); +} + +for (const typeName of [ + "Signal", + "Accessor", + "Setter", + "Resource", + "ResourceActions", + "ResourceOptions", + "ResourceReturn", + "ResourceFetcher", + "InitializedResourceReturn", + "Component", + "VoidProps", + "VoidComponent", + "ParentProps", + "ParentComponent", + "FlowProps", + "FlowComponent", + "ValidComponent", + "ComponentProps", + "Ref", + "MergeProps", + "SplitProps", + "Context", + "JSX", + "ResolvedChildren", + "MatchProps", +]) { + TYPE_SOURCE_MAP.set(typeName, "solid-js"); +} +for (const typeName of ["MountableElement"]) { + TYPE_SOURCE_MAP.set(typeName, "solid-js/web"); +} +for (const typeName of ["StoreNode", "Store", "SetStoreFunction"]) { + TYPE_SOURCE_MAP.set(typeName, "solid-js/store"); +} + +// Port of `solid/imports` — flags specifiers that are imported from +// the wrong solid-js subpath (`{ render } from "solid-js"` → +// `"solid-js/web"`, etc.). The lookup tables come straight from the +// upstream plugin, kept as the canonical source of public exports. +export const solidImports = defineRule({ + id: "solid-imports", + severity: "warn", + requires: ["solid"], + recommendation: + "Import each Solid primitive from its canonical subpath (solid-js / web / store).", + create: (context: RuleContext) => ({ + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + const source = node.source.value; + if (typeof source !== "string" || !isKnownSource(source)) return; + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ImportSpecifier")) continue; + const importedIdentifier = specifier.imported; + if (!isNodeOfType(importedIdentifier, "Identifier")) continue; + const isTypeOnlyImport = specifier.importKind === "type" || node.importKind === "type"; + const sourceMap = isTypeOnlyImport ? TYPE_SOURCE_MAP : PRIMITIVE_SOURCE_MAP; + const correctSource = sourceMap.get(importedIdentifier.name); + if (correctSource && correctSource !== source) { + context.report({ + node: specifier, + message: `Prefer importing \`${importedIdentifier.name}\` from "${correctSource}".`, + }); + } + } + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts new file mode 100644 index 000000000..188dae543 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidJsxNoDuplicateProps } from "./solid-jsx-no-duplicate-props.js"; + +describe("solid-jsx-no-duplicate-props", () => { + it("flags duplicate props", () => { + const result = runRule(solidJsxNoDuplicateProps, `const Foo = () =>
;`); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + + it("uses class-specific message", () => { + const result = runRule( + solidJsxNoDuplicateProps, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("classList"); + }); + + it("does not flag distinct props", () => { + const result = runRule(solidJsxNoDuplicateProps, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags children-prop + JSX children conflict", () => { + const result = runRule( + solidJsxNoDuplicateProps, + `const Foo = () =>
hello
;`, + ); + expect( + result.diagnostics.some((diagnostic) => diagnostic.message.includes("JSX children")), + ).toBe(true); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts new file mode 100644 index 000000000..1d4c35ed1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts @@ -0,0 +1,123 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +interface SolidJsxNoDuplicatePropsSettings { + ignoreCase?: boolean; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidJsxNoDuplicatePropsSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidJsxNoDuplicateProps?: unknown }) + .solidJsxNoDuplicateProps; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidJsxNoDuplicatePropsSettings; +}; + +const normalizeName = (name: string, ignoreCase: boolean): string => { + if (!(ignoreCase || name.startsWith("on"))) return name; + return name + .toLowerCase() + .replace(/^on(?:capture)?:/, "on") + .replace(/^(?:attr|prop):/, ""); +}; + +interface PropEntry { + normalizedName: string; + reportNode: EsTreeNode; +} + +const collectProps = ( + attributes: ReadonlyArray, + ignoreCase: boolean, +): ReadonlyArray => { + const collected: PropEntry[] = []; + for (const attribute of attributes) { + if (isNodeOfType(attribute, "JSXAttribute")) { + let propertyName: string | null = null; + if (isNodeOfType(attribute.name, "JSXIdentifier")) propertyName = attribute.name.name; + else if (isNodeOfType(attribute.name, "JSXNamespacedName")) { + propertyName = `${attribute.name.namespace.name}:${attribute.name.name.name}`; + } + if (!propertyName) continue; + collected.push({ + normalizedName: normalizeName(propertyName, ignoreCase), + reportNode: attribute, + }); + } else if (isNodeOfType(attribute, "JSXSpreadAttribute")) { + const expression = attribute.argument; + if (expression && isNodeOfType(expression, "ObjectExpression")) { + for (const property of expression.properties) { + if (!isNodeOfType(property, "Property")) continue; + let keyName: string | null = null; + if (isNodeOfType(property.key, "Identifier")) keyName = property.key.name; + else if (isNodeOfType(property.key, "Literal")) keyName = String(property.key.value); + if (!keyName) continue; + collected.push({ + normalizedName: normalizeName(keyName, ignoreCase), + reportNode: property.key, + }); + } + } + } + } + return collected; +}; + +// Port of `solid/jsx-no-duplicate-props` — adapted from +// `eslint-plugin-react`'s rule of the same name. Also flags +// simultaneous use of `children` / JSX children / `innerHTML` / +// `textContent`. +export const solidJsxNoDuplicateProps = defineRule({ + id: "solid-jsx-no-duplicate-props", + severity: "error", + requires: ["solid"], + recommendation: "Remove duplicate props from JSX — only the last value wins in Solid.", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + const ignoreCase = Boolean(settings.ignoreCase); + return { + JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { + const seenNames = new Set(); + for (const entry of collectProps(node.attributes, ignoreCase)) { + if (seenNames.has(entry.normalizedName)) { + const message = + entry.normalizedName === "class" + ? "Duplicate `class` props are not allowed; use `classList` instead in Solid." + : "Duplicate props are not allowed."; + context.report({ node: entry.reportNode, message }); + } + seenNames.add(entry.normalizedName); + } + const hasChildrenProp = seenNames.has("children"); + const parent = node.parent; + const hasJsxChildren = Boolean( + parent && + (isNodeOfType(parent, "JSXElement") || isNodeOfType(parent, "JSXFragment")) && + parent.children && + parent.children.length > 0, + ); + const hasInnerHtml = seenNames.has("innerHTML") || seenNames.has("innerhtml"); + const hasTextContent = seenNames.has("textContent") || seenNames.has("textcontent"); + const conflictingChildSources = [ + hasChildrenProp && "`props.children`", + hasJsxChildren && "JSX children", + hasInnerHtml && "`props.innerHTML`", + hasTextContent && "`props.textContent`", + ].filter(Boolean); + if (conflictingChildSources.length > 1) { + context.report({ + node, + message: `Using ${conflictingChildSources.join(", ")} at the same time is not allowed.`, + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts new file mode 100644 index 000000000..6d9add9bc --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidJsxNoScriptUrl } from "./solid-jsx-no-script-url.js"; + +describe("solid-jsx-no-script-url", () => { + it("flags `javascript:` URLs", () => { + const result = runRule( + solidJsxNoScriptUrl, + `const Foo = () => click;`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag normal http URLs", () => { + const result = runRule( + solidJsxNoScriptUrl, + `const Foo = () => click;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts new file mode 100644 index 000000000..8eba7ba91 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts @@ -0,0 +1,50 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +// A `javascript:` URL can contain leading C0 control or U+0020 SPACE, +// and any newline or tab is filtered out as if it's not part of the +// URL. https://url.spec.whatwg.org/#url-parsing +// HACK: control-character class is the URL-spec definition; the regex +// matches exactly what browsers strip before resolving the protocol. +// eslint-disable-next-line no-control-regex +const JAVASCRIPT_PROTOCOL_PATTERN = + /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i; + +const extractStaticStringValue = (node: EsTreeNode | null | undefined): string | null => { + if (!node) return null; + if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; + if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) { + return node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""); + } + return null; +}; + +// Port of `solid/jsx-no-script-url` — flags `` +// and similar `javascript:` URLs in JSX attributes. Adapted from +// `eslint-plugin-react`'s rule of the same name. +export const solidJsxNoScriptUrl = defineRule({ + id: "solid-jsx-no-script-url", + severity: "error", + requires: ["solid"], + recommendation: "Use an event handler instead of a `javascript:` URL — they're a security risk.", + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + if (!isNodeOfType(node.name, "JSXIdentifier")) return; + if (!node.value) return; + const expression = isNodeOfType(node.value, "JSXExpressionContainer") + ? (node.value.expression as EsTreeNode) + : (node.value as EsTreeNode); + const stringValue = extractStaticStringValue(expression); + if (stringValue && JAVASCRIPT_PROTOCOL_PATTERN.test(stringValue)) { + context.report({ + node: node.value, + message: "For security, don't use `javascript:` URLs. Use event handlers instead.", + }); + } + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts new file mode 100644 index 000000000..0edbcf975 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts @@ -0,0 +1,50 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); + +const isEventHandlerName = (attribute: EsTreeNodeOfType<"JSXAttribute">): boolean => { + if (isNodeOfType(attribute.name, "JSXNamespacedName")) { + return attribute.name.namespace.name === "on"; + } + if (isNodeOfType(attribute.name, "JSXIdentifier")) { + return /^on[a-zA-Z]/.test(attribute.name.name); + } + return false; +}; + +const isArrayExpressionValue = (node: EsTreeNode | null | undefined): boolean => { + if (!node) return false; + return isNodeOfType(node, "ArrayExpression"); +}; + +// Port of `solid/no-array-handlers` — Solid supports +// `onClick={[handler, args]}` for type-unsafe event-handler binding; +// flag it because it bypasses Solid's compile-time handler typing. +export const solidNoArrayHandlers = defineRule({ + id: "solid-no-array-handlers", + severity: "warn", + requires: ["solid"], + defaultEnabled: false, + recommendation: + "Use a function (or `.bind(...)`) instead of `onClick={[handler, args]}` — array handlers are type-unsafe.", + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const opening = node.parent; + if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; + if (!isNodeOfType(opening.name, "JSXIdentifier")) return; + if (!isDomElementName(opening.name.name)) return; + if (!isEventHandlerName(node)) return; + if (!node.value || !isNodeOfType(node.value, "JSXExpressionContainer")) return; + if (!isArrayExpressionValue(node.value.expression as EsTreeNode)) return; + context.report({ + node, + message: "Passing an array as an event handler is potentially type-unsafe.", + }); + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts new file mode 100644 index 000000000..8671b9eec --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts @@ -0,0 +1,60 @@ +import { containsJsxElement } from "../../utils/contains-jsx-element.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +type FunctionLikeNode = + | EsTreeNodeOfType<"FunctionDeclaration"> + | EsTreeNodeOfType<"FunctionExpression"> + | EsTreeNodeOfType<"ArrowFunctionExpression">; + +// Render-prop callbacks (e.g. `{(value) => ...}`) are +// not components — the destructure happens inside Solid's reactive +// child mapping where reactivity still flows. +const isRenderPropCallback = (node: FunctionLikeNode): boolean => { + const parent = node.parent; + if (!parent) return false; + return isNodeOfType(parent, "JSXExpressionContainer"); +}; + +// Port of `solid/no-destructure` — flag destructured props in a +// component's parameter list because destructuring captures values +// at call time and breaks Solid's reactivity. The autofix in the +// upstream rule rewrites the function body; we report only here +// (autofix would require source manipulation we don't track in this +// plugin yet). +export const solidNoDestructure = defineRule({ + id: "solid-no-destructure", + severity: "error", + requires: ["solid"], + recommendation: + "Use property access (`props.foo`) instead of destructuring component props — destructuring breaks reactivity.", + create: (context: RuleContext) => { + const visitFunction = (node: FunctionLikeNode): void => { + if (node.params.length !== 1) return; + const firstParameter = node.params[0]; + if (!isNodeOfType(firstParameter, "ObjectPattern")) return; + if (isRenderPropCallback(node)) return; + if (!containsJsxElement(node as EsTreeNode)) return; + context.report({ + node: firstParameter, + message: + "Destructuring component props breaks Solid's reactivity; use property access instead.", + }); + }; + return { + FunctionDeclaration(node: EsTreeNodeOfType<"FunctionDeclaration">) { + visitFunction(node); + }, + FunctionExpression(node: EsTreeNodeOfType<"FunctionExpression">) { + visitFunction(node); + }, + ArrowFunctionExpression(node: EsTreeNodeOfType<"ArrowFunctionExpression">) { + visitFunction(node); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts new file mode 100644 index 000000000..cfc2f9b3f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoInnerHtml } from "./solid-no-innerhtml.js"; + +describe("solid-no-innerhtml", () => { + it("flags dynamic innerHTML", () => { + const result = runRule(solidNoInnerHtml, `const Foo = ({ html }) =>
;`); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("innerHTML"); + }); + + it("flags dangerouslySetInnerHTML", () => { + const result = runRule( + solidNoInnerHtml, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("dangerouslySetInnerHTML"); + }); + + it("flags innerHTML on an element with children", () => { + const result = runRule( + solidNoInnerHtml, + `const Foo = () =>
;`, + ); + expect( + result.diagnostics.some((diagnostic) => diagnostic.message.includes("overwritten")), + ).toBe(true); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts new file mode 100644 index 000000000..08af7dfa2 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts @@ -0,0 +1,78 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { + if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; + if (isNodeOfType(attribute.name, "JSXNamespacedName")) { + return `${attribute.name.namespace.name}:${attribute.name.name.name}`; + } + return null; +}; + +const extractInnerExpression = (attribute: EsTreeNodeOfType<"JSXAttribute">): EsTreeNode | null => { + if (!attribute.value) return null; + if (isNodeOfType(attribute.value, "JSXExpressionContainer")) { + return attribute.value.expression as EsTreeNode; + } + return attribute.value as EsTreeNode; +}; + +const extractStaticStringValue = (node: EsTreeNode | null): string | null => { + if (!node) return null; + if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; + if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) { + return node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""); + } + return null; +}; + +// Port of `solid/no-innerhtml` — flags `innerHTML={...}` (a Solid +// special prop) because passing unsanitized input is an XSS risk, +// plus the React-style `dangerouslySetInnerHTML` which Solid does +// not support. Static string values are still flagged because Solid +// reports them as dangerous when the rule is left at its default +// (we don't depend on `is-html` to keep the port footprint tight). +export const solidNoInnerHtml = defineRule({ + id: "solid-no-innerhtml", + severity: "error", + requires: ["solid"], + recommendation: + "Avoid `innerHTML` — render children via JSX. If you must inject markup, sanitize input first.", + create: (context: RuleContext) => ({ + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const propertyName = jsxPropertyName(node); + if (!propertyName) return; + if (propertyName === "dangerouslySetInnerHTML") { + context.report({ + node, + message: "`dangerouslySetInnerHTML` is not supported in Solid — use `innerHTML` instead.", + }); + return; + } + if (propertyName !== "innerHTML") return; + const expression = extractInnerExpression(node); + const staticString = extractStaticStringValue(expression); + if (staticString === null) { + context.report({ + node, + message: + "Avoid `innerHTML` with dynamic values — passing unsanitized input causes XSS vulnerabilities.", + }); + return; + } + const opener = node.parent; + const jsxElement = opener?.parent; + if (jsxElement && isNodeOfType(jsxElement, "JSXElement") && jsxElement.children.length > 0) { + context.report({ + node: jsxElement, + message: + "`innerHTML` should not be used on an element with child elements — children will be overwritten.", + }); + } + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts new file mode 100644 index 000000000..1cf0e295c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts @@ -0,0 +1,117 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const PROPS_NAME_PATTERN = /[pP]rops/; +const isPropsLikeName = (name: string): boolean => PROPS_NAME_PATTERN.test(name); + +const isFunctionExpressionNode = ( + node: EsTreeNodeOfType<"CallExpression">["arguments"][number], +): boolean => { + if (!node) return false; + return isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "ArrowFunctionExpression"); +}; + +// Port of `solid/no-proxy-apis` — disables Proxy-dependent APIs for +// targets that don't support ES6 Proxy (legacy browsers, low-memory +// embedded JS engines). Off by default; opt in via severityControls +// when targeting a constrained environment. Trace-back to the +// variable initializer is left as a future improvement — we only +// inspect inline expressions here, matching the spirit of the +// upstream rule without the cross-module scope walk. +export const solidNoProxyApis = defineRule({ + id: "solid-no-proxy-apis", + severity: "warn", + requires: ["solid"], + defaultEnabled: false, + recommendation: + "Avoid Solid's Proxy-based APIs (`createStore`, `mergeProps`-with-function, JSX spread with member/call) on targets without ES6 Proxy support.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + if (node.source.value === "solid-js/store") { + context.report({ + node, + message: + "Solid Store APIs use Proxies, which are incompatible with your target environment.", + }); + } + }, + JSXSpreadAttribute(node: EsTreeNodeOfType<"JSXSpreadAttribute">) { + const argument = node.argument; + if (isNodeOfType(argument, "MemberExpression")) { + context.report({ + node: argument, + message: + "Using a property access in JSX spread makes Solid use Proxies, which are incompatible with your target environment.", + }); + } else if (isNodeOfType(argument, "CallExpression")) { + context.report({ + node: argument, + message: + "Using a function call in JSX spread makes Solid use Proxies, which are incompatible with your target environment.", + }); + } + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (isNodeOfType(node.callee, "Identifier")) { + if (importTracker.matchImport(["mergeProps"], node.callee.name)) { + for (const argument of node.arguments) { + if (isNodeOfType(argument, "SpreadElement")) { + context.report({ + node: argument, + message: + "Passing a function (or spread) to `mergeProps` creates a Proxy, which is incompatible with your target environment.", + }); + continue; + } + if (isFunctionExpressionNode(argument)) { + context.report({ + node: argument, + message: + "Passing a function to `mergeProps` creates a Proxy, which is incompatible with your target environment.", + }); + continue; + } + if (isNodeOfType(argument, "Identifier") && !isPropsLikeName(argument.name)) { + context.report({ + node: argument, + message: + "Passing a non-props identifier to `mergeProps` may create a Proxy, which is incompatible with your target environment.", + }); + } + } + } + return; + } + if (isNodeOfType(node.callee, "MemberExpression")) { + const callee = node.callee; + if ( + isNodeOfType(callee.object, "Identifier") && + callee.object.name === "Proxy" && + isNodeOfType(callee.property, "Identifier") && + callee.property.name === "revocable" + ) { + context.report({ + node, + message: "Proxies are incompatible with your target environment.", + }); + } + } + }, + NewExpression(node: EsTreeNodeOfType<"NewExpression">) { + if (isNodeOfType(node.callee, "Identifier") && node.callee.name === "Proxy") { + context.report({ + node, + message: "Proxies are incompatible with your target environment.", + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.test.ts new file mode 100644 index 000000000..60b34204f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoReactDeps } from "./solid-no-react-deps.js"; + +describe("solid-no-react-deps", () => { + it("flags a dependency array passed to createEffect", () => { + const result = runRule( + solidNoReactDeps, + `import { createEffect } from "solid-js";\ncreateEffect(() => {}, [count]);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); + + it("flags a dependency array passed to createMemo", () => { + const result = runRule( + solidNoReactDeps, + `import { createMemo } from "solid-js";\ncreateMemo(() => count(), [count]);`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag createEffect with a single function argument", () => { + const result = runRule( + solidNoReactDeps, + `import { createEffect } from "solid-js";\ncreateEffect(() => { console.log(count()); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createMemo with an initial value (function takes one param)", () => { + const result = runRule( + solidNoReactDeps, + `import { createMemo } from "solid-js";\ncreateMemo((prev) => prev + 1, 0);`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag when import is from elsewhere", () => { + const result = runRule( + solidNoReactDeps, + `import { createEffect } from "other-lib";\ncreateEffect(() => {}, [count]);`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts new file mode 100644 index 000000000..2ff77e81d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts @@ -0,0 +1,63 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const TRACKED_PRIMITIVES: ReadonlyArray = ["createEffect", "createMemo"]; + +const isFunctionLikeNode = ( + node: EsTreeNodeOfType<"CallExpression">["arguments"][number], +): boolean => { + if (!node) return false; + return ( + isNodeOfType(node, "FunctionExpression") || + isNodeOfType(node, "ArrowFunctionExpression") || + isNodeOfType(node, "FunctionDeclaration") + ); +}; + +// Port of `solid/no-react-deps` — Solid's `createEffect` / +// `createMemo` track their dependencies automatically. A second +// array-literal argument is the React-style dependency list, which +// has no effect (and the inline function ignoring its parameter is +// the dead giveaway that the user expects React semantics). +export const solidNoReactDeps = defineRule({ + id: "solid-no-react-deps", + severity: "warn", + requires: ["solid"], + recommendation: + "Solid's `createEffect` and `createMemo` track dependencies automatically — drop the React-style dep array.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedImport = importTracker.matchImport(TRACKED_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + if (node.arguments.length !== 2) return; + if (node.arguments.some((argument) => isNodeOfType(argument, "SpreadElement"))) return; + const firstArgument = node.arguments[0]; + const secondArgument = node.arguments[1]; + if (!isFunctionLikeNode(firstArgument)) return; + if ( + !isNodeOfType(firstArgument, "FunctionExpression") && + !isNodeOfType(firstArgument, "ArrowFunctionExpression") && + !isNodeOfType(firstArgument, "FunctionDeclaration") + ) { + return; + } + if (firstArgument.params.length !== 0) return; + if (!isNodeOfType(secondArgument, "ArrayExpression")) return; + context.report({ + node: secondArgument, + message: `In Solid, \`${matchedImport}\` doesn't accept a dependency array because it tracks dependencies automatically. Use \`on\` if you need to override.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts new file mode 100644 index 000000000..1c06d1e56 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoReactSpecificProps } from "./solid-no-react-specific-props.js"; + +describe("solid-no-react-specific-props", () => { + it("flags className on a DOM element", () => { + const result = runRule(solidNoReactSpecificProps, `const Foo = () =>
;`); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("class"); + }); + + it("flags htmlFor on a DOM element", () => { + const result = runRule( + solidNoReactSpecificProps, + `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("key"); + }); + + it("does not flag `class` or `for`", () => { + const result = runRule( + solidNoReactSpecificProps, + `const Foo = () =>
{}} />;`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags unknown namespaces", () => { + const result = runRule(solidNoUnknownNamespaces, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("data:"); + }); + + it("flags style-namespace usage with a hint to use the property directly", () => { + const result = runRule( + solidNoUnknownNamespaces, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("style"); + }); + + it("flags namespaces on components", () => { + const result = runRule( + solidNoUnknownNamespaces, + `const Foo = () => {}} />;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("Namespaced"); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts new file mode 100644 index 000000000..02c233cb0 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts @@ -0,0 +1,76 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const KNOWN_NAMESPACES: ReadonlyArray = ["on", "oncapture", "use", "prop", "attr", "bool"]; +const STYLE_NAMESPACES: ReadonlyArray = ["style", "class"]; +const XML_NAMESPACES: ReadonlyArray = ["xmlns", "xlink"]; + +interface SolidNoUnknownNamespacesSettings { + allowedNamespaces?: ReadonlyArray; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidNoUnknownNamespacesSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidNoUnknownNamespaces?: unknown }) + .solidNoUnknownNamespaces; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidNoUnknownNamespacesSettings; +}; + +const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); + +// Port of `solid/no-unknown-namespaces` — flag any `ns:name` JSX +// attribute whose `ns` is not one of Solid's six recognised special +// prefixes (plus `xmlns:` / `xlink:` SVG), and warn that namespaced +// props on components have no effect. +export const solidNoUnknownNamespaces = defineRule({ + id: "solid-no-unknown-namespaces", + severity: "error", + requires: ["solid"], + recommendation: + "Use one of Solid's special prefixes (`on:`, `use:`, `prop:`, `attr:`, `bool:`, `oncapture:`).", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + const allowedNamespaces = new Set(settings.allowedNamespaces ?? []); + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + if (!isNodeOfType(node.name, "JSXNamespacedName")) return; + const namespace = node.name.namespace.name; + const propertyName = node.name.name.name; + const opening = node.parent; + if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; + if (isNodeOfType(opening.name, "JSXIdentifier") && !isDomElementName(opening.name.name)) { + context.report({ + node: node.name, + message: "Namespaced props have no effect on components.", + }); + return; + } + if ( + KNOWN_NAMESPACES.includes(namespace) || + XML_NAMESPACES.includes(namespace) || + allowedNamespaces.has(namespace) + ) { + return; + } + if (STYLE_NAMESPACES.includes(namespace)) { + context.report({ + node: node.name, + message: `Using the '${namespace}:' special prefix is potentially confusing, prefer the '${namespace}' prop instead.`, + }); + return; + } + context.report({ + node: node.name, + message: `'${namespace}:${propertyName}' uses '${namespace}:', which is not one of Solid's special JSX prefixes.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts new file mode 100644 index 000000000..44f703378 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts @@ -0,0 +1,72 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const DEFAULT_CLASSNAMES: ReadonlyArray = ["cn", "clsx", "classnames"]; + +interface SolidPreferClasslistSettings { + classnames?: ReadonlyArray; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidPreferClasslistSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidPreferClasslist?: unknown }).solidPreferClasslist; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidPreferClasslistSettings; +}; + +const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { + if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; + return null; +}; + +const hasClasslistAttribute = (attributes: ReadonlyArray): boolean => { + for (const attribute of attributes) { + if (!isNodeOfType(attribute, "JSXAttribute")) continue; + if (jsxPropertyName(attribute) === "classlist") return true; + } + return false; +}; + +// Port of `solid/prefer-classlist`. DEPRECATED upstream (classlist is +// itself being phased out in favour of native `class={cn(...)}` calls +// now that Solid 1.7+ handles object-valued `class` props). Off by +// default — opt in via severityControls if you still use classlist. +export const solidPreferClasslist = defineRule({ + id: "solid-prefer-classlist", + severity: "warn", + requires: ["solid"], + defaultEnabled: false, + recommendation: "Prefer Solid's `classlist={{...}}` over `class={cn({...})}` for object syntax.", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + const classnames = new Set(settings.classnames ?? DEFAULT_CLASSNAMES); + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const propertyName = jsxPropertyName(node); + if (propertyName !== "class" && propertyName !== "className") return; + const opening = node.parent; + if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; + if (hasClasslistAttribute(opening.attributes)) return; + if (!node.value || !isNodeOfType(node.value, "JSXExpressionContainer")) return; + const expression = node.value.expression; + if (!isNodeOfType(expression, "CallExpression")) return; + if (!isNodeOfType(expression.callee, "Identifier")) return; + if (!classnames.has(expression.callee.name)) return; + if (expression.arguments.length !== 1) return; + const firstArgument = expression.arguments[0]; + if (!firstArgument || !isNodeOfType(firstArgument, "ObjectExpression")) return; + context.report({ + node, + message: `Prefer the \`classlist\` prop over ${expression.callee.name} to set classes from an object.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.test.ts new file mode 100644 index 000000000..cf66662ca --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidPreferFor } from "./solid-prefer-for.js"; + +describe("solid-prefer-for", () => { + it("flags Array#map inside JSX", () => { + const result = runRule( + solidPreferFor, + `const Foo = () =>
{items.map((item) => {item})}
;`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(" { + const result = runRule( + solidPreferFor, + `const Foo = () => { const mapped = items.map((item) => item * 2); return
{mapped[0]}
; };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts new file mode 100644 index 000000000..bf1458f81 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts @@ -0,0 +1,74 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const getMemberPropertyName = (node: EsTreeNodeOfType<"MemberExpression">): string | null => { + if (node.computed) { + if (isNodeOfType(node.property, "Literal") && typeof node.property.value === "string") { + return node.property.value; + } + return null; + } + if (isNodeOfType(node.property, "Identifier")) return node.property.name; + return null; +}; + +const isFunctionLikeArgument = (node: EsTreeNode | null | undefined): boolean => { + if (!node) return false; + return isNodeOfType(node, "ArrowFunctionExpression") || isNodeOfType(node, "FunctionExpression"); +}; + +// Port of `solid/prefer-for` — flag `{items.map((item) => )}` +// inside JSX because Solid's `` component does keyed-by-identity +// reconciliation, while `Array.prototype.map` recreates every DOM +// node on each render. +export const solidPreferFor = defineRule({ + id: "solid-prefer-for", + severity: "error", + requires: ["solid"], + recommendation: + "Use Solid's `{(item) => ...}` instead of `items.map((item) => ...)` inside JSX.", + create: (context: RuleContext) => ({ + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + const callOrChain = + node.parent && isNodeOfType(node.parent, "ChainExpression") ? node.parent : node; + const jsxExpressionContainer = callOrChain.parent; + if ( + !jsxExpressionContainer || + !isNodeOfType(jsxExpressionContainer, "JSXExpressionContainer") + ) { + return; + } + const jsxParent = jsxExpressionContainer.parent; + if ( + !jsxParent || + (!isNodeOfType(jsxParent, "JSXElement") && !isNodeOfType(jsxParent, "JSXFragment")) + ) { + return; + } + if (!isNodeOfType(node.callee, "MemberExpression")) return; + if (getMemberPropertyName(node.callee) !== "map") return; + if (node.arguments.length !== 1) return; + const firstArgument = node.arguments[0]; + if (!isFunctionLikeArgument(firstArgument)) return; + if ( + !isNodeOfType(firstArgument, "ArrowFunctionExpression") && + !isNodeOfType(firstArgument, "FunctionExpression") + ) { + return; + } + const usesIndexParameter = + firstArgument.params.length > 1 || + (firstArgument.params.length === 1 && isNodeOfType(firstArgument.params[0], "RestElement")); + context.report({ + node, + message: usesIndexParameter + ? "Use Solid's `` or `` instead of Array#map — it recreates DOM nodes on every render." + : "Use Solid's `` instead of Array#map — it recreates DOM nodes on every render.", + }); + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-show.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-show.ts new file mode 100644 index 000000000..e557da93e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-show.ts @@ -0,0 +1,72 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EXPENSIVE_TYPES = new Set(["JSXElement", "JSXFragment", "Identifier"]); + +const visitLogicalExpression = ( + node: EsTreeNodeOfType<"LogicalExpression">, + context: RuleContext, +): void => { + if (node.operator !== "&&") return; + if (!EXPENSIVE_TYPES.has(node.right.type)) return; + context.report({ + node, + message: "Use Solid's `` component for conditionally showing content.", + }); +}; + +const visitConditionalExpression = ( + node: EsTreeNodeOfType<"ConditionalExpression">, + context: RuleContext, +): void => { + if (!EXPENSIVE_TYPES.has(node.consequent.type) && !EXPENSIVE_TYPES.has(node.alternate.type)) { + return; + } + context.report({ + node, + message: "Use Solid's `` component for conditionally showing content with a fallback.", + }); +}; + +// Port of `solid/prefer-show` — stylistic preference, off by +// default. Suggests `` over `condition && jsx` or ternary +// expressions in JSX. Solid's compiler optimises both forms, so +// this is purely a readability hint. +export const solidPreferShow = defineRule({ + id: "solid-prefer-show", + severity: "warn", + requires: ["solid"], + defaultEnabled: false, + recommendation: "Prefer `` over `cond && ` for conditional rendering.", + create: (context: RuleContext) => ({ + JSXExpressionContainer(node: EsTreeNodeOfType<"JSXExpressionContainer">) { + const parent = node.parent; + if ( + !parent || + (!isNodeOfType(parent, "JSXElement") && !isNodeOfType(parent, "JSXFragment")) + ) { + return; + } + const expression = node.expression as EsTreeNode; + if (isNodeOfType(expression, "LogicalExpression")) { + visitLogicalExpression(expression, context); + return; + } + if (isNodeOfType(expression, "ConditionalExpression")) { + visitConditionalExpression(expression, context); + return; + } + if (isNodeOfType(expression, "ArrowFunctionExpression")) { + const body = expression.body; + if (isNodeOfType(body, "LogicalExpression")) visitLogicalExpression(body, context); + else if (isNodeOfType(body, "ConditionalExpression")) { + visitConditionalExpression(body, context); + } + } + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts new file mode 100644 index 000000000..5379df2d7 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts @@ -0,0 +1,87 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +interface SolidSelfClosingCompSettings { + component?: "all" | "none"; + html?: "all" | "void" | "none"; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidSelfClosingCompSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidSelfClosingComp?: unknown }).solidSelfClosingComp; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidSelfClosingCompSettings; +}; + +const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); + +const VOID_DOM_ELEMENT_PATTERN = + /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; +const isVoidDomElementName = (name: string): boolean => VOID_DOM_ELEMENT_PATTERN.test(name); + +const isComponentOpener = (node: EsTreeNodeOfType<"JSXOpeningElement">): boolean => + (isNodeOfType(node.name, "JSXIdentifier") && !isDomElementName(node.name.name)) || + isNodeOfType(node.name, "JSXMemberExpression"); + +const childrenAreEmpty = (jsxElement: EsTreeNodeOfType<"JSXElement">): boolean => + jsxElement.children.length === 0; + +const childrenAreOnlyMultilineWhitespace = ( + jsxElement: EsTreeNodeOfType<"JSXElement">, +): boolean => { + if (jsxElement.children.length !== 1) return false; + const onlyChild = jsxElement.children[0]; + if (!isNodeOfType(onlyChild, "JSXText")) return false; + if (onlyChild.value.indexOf("\n") === -1) return false; + return onlyChild.value.replace(/(?!\xA0)\s/g, "") === ""; +}; + +// Port of `solid/self-closing-comp` — adapted from +// `eslint-plugin-react`'s rule of the same name. We only report +// (we don't yet emit fixes through this plugin's adapter). +export const solidSelfClosingComp = defineRule({ + id: "solid-self-closing-comp", + severity: "warn", + requires: ["solid"], + defaultEnabled: false, + recommendation: "Self-close empty Solid components (`` instead of ``).", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + const componentMode: "all" | "none" = settings.component ?? "all"; + const htmlMode: "all" | "void" | "none" = settings.html ?? "all"; + const shouldSelfCloseWhenPossible = (node: EsTreeNodeOfType<"JSXOpeningElement">): boolean => { + if (isComponentOpener(node)) return componentMode === "all"; + if (!isNodeOfType(node.name, "JSXIdentifier")) return true; + const elementName = node.name.name; + if (!isDomElementName(elementName)) return true; + if (htmlMode === "none") return false; + if (htmlMode === "void") return isVoidDomElementName(elementName); + return true; + }; + return { + JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { + const parent = node.parent; + if (!parent || !isNodeOfType(parent, "JSXElement")) { + if (!node.selfClosing && shouldSelfCloseWhenPossible(node)) { + context.report({ node, message: "Empty components are self-closing." }); + } + return; + } + const canSelfClose = childrenAreEmpty(parent) || childrenAreOnlyMultilineWhitespace(parent); + if (!canSelfClose) return; + const shouldSelfClose = shouldSelfCloseWhenPossible(node); + if (shouldSelfClose && !node.selfClosing) { + context.report({ node, message: "Empty components are self-closing." }); + } else if (!shouldSelfClose && node.selfClosing) { + context.report({ node, message: "This element should not be self-closing." }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts new file mode 100644 index 000000000..6657bfddd --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts @@ -0,0 +1,116 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +interface SolidStylePropSettings { + styleProps?: ReadonlyArray; + allowString?: boolean; +} + +const resolveSettings = ( + settings: Readonly> | undefined, +): SolidStylePropSettings => { + const reactDoctor = settings?.["react-doctor"]; + if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; + const solidSettings = (reactDoctor as { solidStyleProp?: unknown }).solidStyleProp; + if (typeof solidSettings !== "object" || solidSettings === null) return {}; + return solidSettings as SolidStylePropSettings; +}; + +const camelToKebab = (name: string): string => + name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + +const LENGTH_PERCENTAGE_PATTERN = /\b(?:width|height|margin|padding|border-width|font-size)\b/i; + +const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { + if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; + if (isNodeOfType(attribute.name, "JSXNamespacedName")) { + return `${attribute.name.namespace.name}:${attribute.name.name.name}`; + } + return null; +}; + +const objectPropertyKeyName = (property: EsTreeNodeOfType<"Property">): string | null => { + if (isNodeOfType(property.key, "Identifier")) return property.key.name; + if (isNodeOfType(property.key, "Literal") && typeof property.key.value === "string") { + return property.key.value; + } + return null; +}; + +// Port of `solid/style-prop` — Solid (and dom-expressions) expects +// kebab-cased CSS property names on `style={{...}}`, unlike React's +// camelCase. Also catches numeric-with-implicit-px values for length +// properties (`{ width: 12 }` — should be `"12px"`). The kebab-case +// "is it a valid CSS property" check from the upstream rule needs +// the `known-css-properties` dataset, which we don't yet vendor; we +// approximate by flagging any clearly-camelCase key (mixed-case +// with no `-`) and offer the kebab form as the recommendation. +export const solidStyleProp = defineRule({ + id: "solid-style-prop", + severity: "warn", + requires: ["solid"], + recommendation: + "Use kebab-case CSS property names (`font-size`, not `fontSize`) in Solid's `style` prop, and string values with units (`'12px'`, not `12`) for length properties.", + create: (context: RuleContext) => { + const settings = resolveSettings(context.settings); + const styleProps = new Set(settings.styleProps ?? ["style"]); + const allowString = Boolean(settings.allowString); + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const propertyName = jsxPropertyName(node); + if (!propertyName || !styleProps.has(propertyName)) return; + if (!node.value) return; + const style = isNodeOfType(node.value, "JSXExpressionContainer") + ? (node.value.expression as EsTreeNode) + : (node.value as EsTreeNode); + if (isNodeOfType(style, "Literal") && typeof style.value === "string" && !allowString) { + context.report({ + node: style, + message: "Use an object for the `style` prop instead of a string.", + }); + return; + } + if (isNodeOfType(style, "TemplateLiteral") && !allowString) { + context.report({ + node: style, + message: "Use an object for the `style` prop instead of a string.", + }); + return; + } + if (!isNodeOfType(style, "ObjectExpression")) return; + for (const property of style.properties) { + if (!isNodeOfType(property, "Property")) continue; + const keyName = objectPropertyKeyName(property); + if (!keyName) continue; + if (keyName.startsWith("--")) continue; + if (/[A-Z]/.test(keyName) && !keyName.includes("-")) { + const kebabName = camelToKebab(keyName); + context.report({ + node: property.key, + message: `Use \`"${kebabName}"\` instead of \`${keyName}\` — Solid expects kebab-case CSS property names.`, + }); + continue; + } + if (LENGTH_PERCENTAGE_PATTERN.test(keyName)) { + const value = property.value; + if ( + isNodeOfType(value, "Literal") && + typeof value.value === "number" && + value.value !== 0 + ) { + context.report({ + node: value, + message: + "This CSS property value should be a string with a unit; Solid does not automatically append `px`.", + }); + } + } + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/create-solid-import-tracker.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/create-solid-import-tracker.ts new file mode 100644 index 000000000..afca3b285 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/create-solid-import-tracker.ts @@ -0,0 +1,39 @@ +import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +const SOLID_SOURCE_PATTERN = /^solid-js(?:\/.+)?$/; + +// Tracks `import { createEffect, createEffect as e } from "solid-js"` +// across the visit. Returns helpers to register import declarations +// and to ask "is `` an alias for any of `` +// from a solid-js module?". Mirrors `trackImports` in +// `eslint-plugin-solid/src/utils.ts`. +export interface SolidImportTracker { + handleImportDeclaration: (node: EsTreeNodeOfType<"ImportDeclaration">) => void; + matchImport: (importedNames: ReadonlyArray, localName: string) => string | undefined; +} + +export const createSolidImportTracker = ( + fromModulePattern: RegExp = SOLID_SOURCE_PATTERN, +): SolidImportTracker => { + const localNameByImportedName = new Map(); + return { + handleImportDeclaration: (node: EsTreeNodeOfType<"ImportDeclaration">) => { + const source = node.source?.value; + if (typeof source !== "string") return; + if (!fromModulePattern.test(source)) return; + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ImportSpecifier")) continue; + const importedIdentifier = specifier.imported; + if (!isNodeOfType(importedIdentifier, "Identifier")) continue; + localNameByImportedName.set(importedIdentifier.name, specifier.local.name); + } + }, + matchImport: (importedNames, localName) => { + for (const importedName of importedNames) { + if (localNameByImportedName.get(importedName) === localName) return importedName; + } + return undefined; + }, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts index 06cd4fea5..c8ce565ba 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts @@ -12,7 +12,8 @@ export type RuleFramework = | "nextjs" | "react-native" | "tanstack-start" - | "tanstack-query"; + | "tanstack-query" + | "solid"; export interface Rule { // Public-facing rule identifier — what users put in their oxlint config diff --git a/packages/oxlint-plugin-react-doctor/src/rules.ts b/packages/oxlint-plugin-react-doctor/src/rules.ts index 7c360838f..aef1da908 100644 --- a/packages/oxlint-plugin-react-doctor/src/rules.ts +++ b/packages/oxlint-plugin-react-doctor/src/rules.ts @@ -89,6 +89,7 @@ export const TANSTACK_START_RULES = toRuleMap( export const TANSTACK_QUERY_RULES = toRuleMap( toKeyedSeverity(collectReactDoctorRulesByFramework("tanstack-query")), ); +export const SOLID_RULES = toRuleMap(toKeyedSeverity(collectReactDoctorRulesByFramework("solid"))); export const ALL_REACT_DOCTOR_RULES = toRuleMap(toKeyedSeverity(REACT_DOCTOR_RULES)); export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet = new Set( REACT_DOCTOR_RULES.map((rule) => rule.key), diff --git a/packages/react-doctor/tests/build-json-report.test.ts b/packages/react-doctor/tests/build-json-report.test.ts index c83e74658..89fe4f69a 100644 --- a/packages/react-doctor/tests/build-json-report.test.ts +++ b/packages/react-doctor/tests/build-json-report.test.ts @@ -12,6 +12,7 @@ const SAMPLE_PROJECT: ProjectInfo = { hasTypeScript: true, hasReactCompiler: false, hasTanStackQuery: false, + hasSolid: false, hasReactNativeWorkspace: false, sourceFileCount: 42, }; diff --git a/packages/react-doctor/tests/regressions/_helpers.ts b/packages/react-doctor/tests/regressions/_helpers.ts index 6480e743a..a6eec9efb 100644 --- a/packages/react-doctor/tests/regressions/_helpers.ts +++ b/packages/react-doctor/tests/regressions/_helpers.ts @@ -103,6 +103,7 @@ export interface CollectRuleHitsOptions { framework?: "unknown" | "react-native" | "expo"; hasReactCompiler?: boolean; hasTanStackQuery?: boolean; + hasSolid?: boolean; } export interface BuildTestProjectOptions { @@ -110,6 +111,7 @@ export interface BuildTestProjectOptions { framework?: ProjectInfo["framework"]; hasReactCompiler?: boolean; hasTanStackQuery?: boolean; + hasSolid?: boolean; reactMajorVersion?: number | null; hasTypeScript?: boolean; tailwindVersion?: string | null; @@ -135,6 +137,7 @@ export const buildTestProject = (options: BuildTestProjectOptions): ProjectInfo hasTypeScript: options.hasTypeScript ?? true, hasReactCompiler: options.hasReactCompiler ?? false, hasTanStackQuery: options.hasTanStackQuery ?? false, + hasSolid: options.hasSolid ?? false, hasReactNativeWorkspace: framework === "expo" || framework === "react-native", sourceFileCount: 0, }; diff --git a/packages/react-doctor/tests/to-json-report.test.ts b/packages/react-doctor/tests/to-json-report.test.ts index 38d639b81..2d6c91262 100644 --- a/packages/react-doctor/tests/to-json-report.test.ts +++ b/packages/react-doctor/tests/to-json-report.test.ts @@ -27,6 +27,7 @@ const buildDiagnoseResult = (): DiagnoseResult => ({ hasTypeScript: true, hasReactCompiler: false, hasTanStackQuery: false, + hasSolid: false, hasReactNativeWorkspace: false, sourceFileCount: 12, }, From d674b0a25fbc92c7ae0336704fea99f421a8a506 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:40:47 +0000 Subject: [PATCH 02/18] refactor(solid): consolidate duplicated helpers per AGENTS.md DRY rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing against the AGENTS.md conventions in the workspace surfaced several small DRY / naming violations in the first Solid rule pass: - Multiple rules redefined `isDomElementName` inline; extract into `utils/is-dom-element-name.ts`. - Several rules redefined the `solidXxx` settings reader with a near-identical narrowing cast; extract into `utils/read-solid-rule-settings.ts` so the cast is in one place. - Two rules redefined `jsxPropertyName` even though the existing `getJsxAttributeName` util already does the same thing; switch to the existing util. - Three rules redefined a function-like check (`isFunctionLikeNode`, `isFunctionLikeArgument`, `isFunctionExpressionNode`) even though `utils/is-function-like.ts` already exports the canonical type- guard form. Switch them all to `isFunctionLike`. - Rename 2-character iteration vars (`{ from, to }` → `{ reactName, solidName }`, `(match)` → `(uppercaseMatch)`) per the descriptive-naming rule. - Replace `parent.arguments.includes(node as never)` with `parent.arguments.some((argument) => argument === node)` to drop one unnecessary cast. Co-authored-by: Aiden Bai --- .../solid/solid-components-return-once.ts | 2 +- .../rules/solid/solid-event-handlers.ts | 19 ++++-------- .../solid/solid-jsx-no-duplicate-props.ts | 24 +++++---------- .../rules/solid/solid-no-array-handlers.ts | 11 ++----- .../rules/solid/solid-no-destructure.ts | 3 -- .../plugin/rules/solid/solid-no-innerhtml.ts | 11 ++----- .../plugin/rules/solid/solid-no-proxy-apis.ts | 13 +++------ .../plugin/rules/solid/solid-no-react-deps.ts | 24 +++------------ .../solid/solid-no-react-specific-props.ts | 20 ++++++++----- .../solid/solid-no-unknown-namespaces.ts | 20 ++++--------- .../rules/solid/solid-prefer-classlist.ts | 26 +++++------------ .../plugin/rules/solid/solid-prefer-for.ts | 15 ++-------- .../rules/solid/solid-self-closing-comp.ts | 19 ++++-------- .../plugin/rules/solid/solid-style-prop.ts | 29 +++++-------------- .../src/plugin/utils/is-dom-element-name.ts | 7 +++++ .../plugin/utils/read-solid-rule-settings.ts | 16 ++++++++++ 16 files changed, 91 insertions(+), 168 deletions(-) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-dom-element-name.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts index aa79ab1a6..2db6d8c3d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts @@ -82,7 +82,7 @@ const isHocCallParent = (node: FunctionLikeNode): boolean => { const parent = node.parent; if (!parent) return false; if (!isNodeOfType(parent, "CallExpression")) return false; - if (!parent.arguments.includes(node as never)) return false; + if (!parent.arguments.some((argument) => argument === node)) return false; if (isNodeOfType(parent.callee, "Identifier")) { return !isComponentName(parent.callee.name); } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts index bbf879773..def6f9f73 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.ts @@ -1,25 +1,17 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; interface SolidEventHandlersSettings { ignoreCase?: boolean; warnOnSpread?: boolean; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidEventHandlersSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidEventHandlers?: unknown }).solidEventHandlers; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidEventHandlersSettings; -}; - const COMMON_EVENTS: ReadonlyArray = [ "onAnimationEnd", "onAnimationIteration", @@ -90,8 +82,6 @@ const NONSTANDARD_EVENT_BY_LOWERCASE_NAME: Record = { ondoubleclick: "onDblClick", }; -const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); - const isStaticStringOrNumberValue = (node: EsTreeNode | null): boolean => { if (!node) return false; if (isNodeOfType(node, "Literal")) { @@ -116,7 +106,10 @@ export const solidEventHandlers = defineRule({ recommendation: "Use camelCase event names (`onClick`, not `onclick`). Solid distinguishes the two — only camelCase forms install listeners.", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidEventHandlers", + ); return { JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { const opening = node.parent; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts index 1d4c35ed1..ba782007a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts @@ -1,25 +1,16 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; interface SolidJsxNoDuplicatePropsSettings { ignoreCase?: boolean; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidJsxNoDuplicatePropsSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidJsxNoDuplicateProps?: unknown }) - .solidJsxNoDuplicateProps; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidJsxNoDuplicatePropsSettings; -}; - const normalizeName = (name: string, ignoreCase: boolean): string => { if (!(ignoreCase || name.startsWith("on"))) return name; return name @@ -40,11 +31,7 @@ const collectProps = ( const collected: PropEntry[] = []; for (const attribute of attributes) { if (isNodeOfType(attribute, "JSXAttribute")) { - let propertyName: string | null = null; - if (isNodeOfType(attribute.name, "JSXIdentifier")) propertyName = attribute.name.name; - else if (isNodeOfType(attribute.name, "JSXNamespacedName")) { - propertyName = `${attribute.name.namespace.name}:${attribute.name.name.name}`; - } + const propertyName = getJsxAttributeName(attribute.name); if (!propertyName) continue; collected.push({ normalizedName: normalizeName(propertyName, ignoreCase), @@ -80,7 +67,10 @@ export const solidJsxNoDuplicateProps = defineRule({ requires: ["solid"], recommendation: "Remove duplicate props from JSX — only the last value wins in Solid.", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidJsxNoDuplicateProps", + ); const ignoreCase = Boolean(settings.ignoreCase); return { JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts index 0edbcf975..b4c59f61f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.ts @@ -1,12 +1,10 @@ import { defineRule } from "../../utils/define-rule.js"; -import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; -const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); - const isEventHandlerName = (attribute: EsTreeNodeOfType<"JSXAttribute">): boolean => { if (isNodeOfType(attribute.name, "JSXNamespacedName")) { return attribute.name.namespace.name === "on"; @@ -17,11 +15,6 @@ const isEventHandlerName = (attribute: EsTreeNodeOfType<"JSXAttribute">): boolea return false; }; -const isArrayExpressionValue = (node: EsTreeNode | null | undefined): boolean => { - if (!node) return false; - return isNodeOfType(node, "ArrayExpression"); -}; - // Port of `solid/no-array-handlers` — Solid supports // `onClick={[handler, args]}` for type-unsafe event-handler binding; // flag it because it bypasses Solid's compile-time handler typing. @@ -40,7 +33,7 @@ export const solidNoArrayHandlers = defineRule({ if (!isDomElementName(opening.name.name)) return; if (!isEventHandlerName(node)) return; if (!node.value || !isNodeOfType(node.value, "JSXExpressionContainer")) return; - if (!isArrayExpressionValue(node.value.expression as EsTreeNode)) return; + if (!isNodeOfType(node.value.expression, "ArrayExpression")) return; context.report({ node, message: "Passing an array as an event handler is potentially type-unsafe.", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts index 8671b9eec..16c6ab138 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.ts @@ -11,9 +11,6 @@ type FunctionLikeNode = | EsTreeNodeOfType<"FunctionExpression"> | EsTreeNodeOfType<"ArrowFunctionExpression">; -// Render-prop callbacks (e.g. `{(value) => ...}`) are -// not components — the destructure happens inside Solid's reactive -// child mapping where reactivity still flows. const isRenderPropCallback = (node: FunctionLikeNode): boolean => { const parent = node.parent; if (!parent) return false; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts index 08af7dfa2..1396eb3eb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts @@ -1,18 +1,11 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; -const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { - if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; - if (isNodeOfType(attribute.name, "JSXNamespacedName")) { - return `${attribute.name.namespace.name}:${attribute.name.name.name}`; - } - return null; -}; - const extractInnerExpression = (attribute: EsTreeNodeOfType<"JSXAttribute">): EsTreeNode | null => { if (!attribute.value) return null; if (isNodeOfType(attribute.value, "JSXExpressionContainer")) { @@ -44,7 +37,7 @@ export const solidNoInnerHtml = defineRule({ "Avoid `innerHTML` — render children via JSX. If you must inject markup, sanitize input first.", create: (context: RuleContext) => ({ JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { - const propertyName = jsxPropertyName(node); + const propertyName = getJsxAttributeName(node.name); if (!propertyName) return; if (propertyName === "dangerouslySetInnerHTML") { context.report({ diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts index 1cf0e295c..3fb4b1216 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.ts @@ -1,19 +1,14 @@ import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; const PROPS_NAME_PATTERN = /[pP]rops/; -const isPropsLikeName = (name: string): boolean => PROPS_NAME_PATTERN.test(name); - -const isFunctionExpressionNode = ( - node: EsTreeNodeOfType<"CallExpression">["arguments"][number], -): boolean => { - if (!node) return false; - return isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "ArrowFunctionExpression"); -}; +const isPropsLikeName = (identifierName: string): boolean => + PROPS_NAME_PATTERN.test(identifierName); // Port of `solid/no-proxy-apis` — disables Proxy-dependent APIs for // targets that don't support ES6 Proxy (legacy browsers, low-memory @@ -70,7 +65,7 @@ export const solidNoProxyApis = defineRule({ }); continue; } - if (isFunctionExpressionNode(argument)) { + if (isFunctionLike(argument)) { context.report({ node: argument, message: diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts index 2ff77e81d..6b631ccda 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-deps.ts @@ -1,23 +1,14 @@ import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; const TRACKED_PRIMITIVES: ReadonlyArray = ["createEffect", "createMemo"]; -const isFunctionLikeNode = ( - node: EsTreeNodeOfType<"CallExpression">["arguments"][number], -): boolean => { - if (!node) return false; - return ( - isNodeOfType(node, "FunctionExpression") || - isNodeOfType(node, "ArrowFunctionExpression") || - isNodeOfType(node, "FunctionDeclaration") - ); -}; - // Port of `solid/no-react-deps` — Solid's `createEffect` / // `createMemo` track their dependencies automatically. A second // array-literal argument is the React-style dependency list, which @@ -41,16 +32,9 @@ export const solidNoReactDeps = defineRule({ if (!matchedImport) return; if (node.arguments.length !== 2) return; if (node.arguments.some((argument) => isNodeOfType(argument, "SpreadElement"))) return; - const firstArgument = node.arguments[0]; + const firstArgument: EsTreeNode = node.arguments[0]; const secondArgument = node.arguments[1]; - if (!isFunctionLikeNode(firstArgument)) return; - if ( - !isNodeOfType(firstArgument, "FunctionExpression") && - !isNodeOfType(firstArgument, "ArrowFunctionExpression") && - !isNodeOfType(firstArgument, "FunctionDeclaration") - ) { - return; - } + if (!isFunctionLike(firstArgument)) return; if (firstArgument.params.length !== 0) return; if (!isNodeOfType(secondArgument, "ArrayExpression")) return; context.report({ diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts index 076ff4387..a1998ca5b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts @@ -1,15 +1,19 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; -const REACT_SPECIFIC_PROPS: ReadonlyArray<{ from: string; to: string }> = [ - { from: "className", to: "class" }, - { from: "htmlFor", to: "for" }, -]; +interface RenameMapping { + reactName: string; + solidName: string; +} -const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); +const REACT_SPECIFIC_PROPS: ReadonlyArray = [ + { reactName: "className", solidName: "class" }, + { reactName: "htmlFor", solidName: "for" }, +]; // Port of `solid/no-react-specific-props` — flag React holdover props // (`className`, `htmlFor`) that Solid renamed to `class` / `for`, @@ -22,14 +26,14 @@ export const solidNoReactSpecificProps = defineRule({ recommendation: "Use `class` instead of `className` and `for` instead of `htmlFor` in Solid JSX.", create: (context: RuleContext) => ({ JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { - for (const { from, to } of REACT_SPECIFIC_PROPS) { + for (const { reactName, solidName } of REACT_SPECIFIC_PROPS) { for (const attribute of node.attributes) { if (!isNodeOfType(attribute, "JSXAttribute")) continue; if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue; - if (attribute.name.name === from) { + if (attribute.name.name === reactName) { context.report({ node: attribute, - message: `Prefer the \`${to}\` prop over the deprecated \`${from}\` prop.`, + message: `Prefer the \`${solidName}\` prop over the deprecated \`${reactName}\` prop.`, }); } } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts index 02c233cb0..581f8d0a7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-unknown-namespaces.ts @@ -1,8 +1,10 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; const KNOWN_NAMESPACES: ReadonlyArray = ["on", "oncapture", "use", "prop", "attr", "bool"]; const STYLE_NAMESPACES: ReadonlyArray = ["style", "class"]; @@ -12,19 +14,6 @@ interface SolidNoUnknownNamespacesSettings { allowedNamespaces?: ReadonlyArray; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidNoUnknownNamespacesSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidNoUnknownNamespaces?: unknown }) - .solidNoUnknownNamespaces; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidNoUnknownNamespacesSettings; -}; - -const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); - // Port of `solid/no-unknown-namespaces` — flag any `ns:name` JSX // attribute whose `ns` is not one of Solid's six recognised special // prefixes (plus `xmlns:` / `xlink:` SVG), and warn that namespaced @@ -36,7 +25,10 @@ export const solidNoUnknownNamespaces = defineRule({ recommendation: "Use one of Solid's special prefixes (`on:`, `use:`, `prop:`, `attr:`, `bool:`, `oncapture:`).", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidNoUnknownNamespaces", + ); const allowedNamespaces = new Set(settings.allowedNamespaces ?? []); return { JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts index 44f703378..62d61aaf7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.ts @@ -1,9 +1,11 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; const DEFAULT_CLASSNAMES: ReadonlyArray = ["cn", "clsx", "classnames"]; @@ -11,25 +13,10 @@ interface SolidPreferClasslistSettings { classnames?: ReadonlyArray; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidPreferClasslistSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidPreferClasslist?: unknown }).solidPreferClasslist; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidPreferClasslistSettings; -}; - -const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { - if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; - return null; -}; - const hasClasslistAttribute = (attributes: ReadonlyArray): boolean => { for (const attribute of attributes) { if (!isNodeOfType(attribute, "JSXAttribute")) continue; - if (jsxPropertyName(attribute) === "classlist") return true; + if (getJsxAttributeName(attribute.name) === "classlist") return true; } return false; }; @@ -45,11 +32,14 @@ export const solidPreferClasslist = defineRule({ defaultEnabled: false, recommendation: "Prefer Solid's `classlist={{...}}` over `class={cn({...})}` for object syntax.", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidPreferClasslist", + ); const classnames = new Set(settings.classnames ?? DEFAULT_CLASSNAMES); return { JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { - const propertyName = jsxPropertyName(node); + const propertyName = getJsxAttributeName(node.name); if (propertyName !== "class" && propertyName !== "className") return; const opening = node.parent; if (!opening || !isNodeOfType(opening, "JSXOpeningElement")) return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts index bf1458f81..2815e6ff6 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-for.ts @@ -1,6 +1,6 @@ import { defineRule } from "../../utils/define-rule.js"; -import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; @@ -16,11 +16,6 @@ const getMemberPropertyName = (node: EsTreeNodeOfType<"MemberExpression">): stri return null; }; -const isFunctionLikeArgument = (node: EsTreeNode | null | undefined): boolean => { - if (!node) return false; - return isNodeOfType(node, "ArrowFunctionExpression") || isNodeOfType(node, "FunctionExpression"); -}; - // Port of `solid/prefer-for` — flag `{items.map((item) => )}` // inside JSX because Solid's `` component does keyed-by-identity // reconciliation, while `Array.prototype.map` recreates every DOM @@ -53,13 +48,7 @@ export const solidPreferFor = defineRule({ if (getMemberPropertyName(node.callee) !== "map") return; if (node.arguments.length !== 1) return; const firstArgument = node.arguments[0]; - if (!isFunctionLikeArgument(firstArgument)) return; - if ( - !isNodeOfType(firstArgument, "ArrowFunctionExpression") && - !isNodeOfType(firstArgument, "FunctionExpression") - ) { - return; - } + if (!isFunctionLike(firstArgument)) return; const usesIndexParameter = firstArgument.params.length > 1 || (firstArgument.params.length === 1 && isNodeOfType(firstArgument.params[0], "RestElement")); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts index 5379df2d7..5dfc9569e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts @@ -1,26 +1,16 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; interface SolidSelfClosingCompSettings { component?: "all" | "none"; html?: "all" | "void" | "none"; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidSelfClosingCompSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidSelfClosingComp?: unknown }).solidSelfClosingComp; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidSelfClosingCompSettings; -}; - -const isDomElementName = (name: string): boolean => /^[a-z]/.test(name); - const VOID_DOM_ELEMENT_PATTERN = /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; const isVoidDomElementName = (name: string): boolean => VOID_DOM_ELEMENT_PATTERN.test(name); @@ -52,7 +42,10 @@ export const solidSelfClosingComp = defineRule({ defaultEnabled: false, recommendation: "Self-close empty Solid components (`` instead of ``).", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidSelfClosingComp", + ); const componentMode: "all" | "none" = settings.component ?? "all"; const htmlMode: "all" | "void" | "none" = settings.html ?? "all"; const shouldSelfCloseWhenPossible = (node: EsTreeNodeOfType<"JSXOpeningElement">): boolean => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts index 6657bfddd..c25a3e781 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts @@ -1,38 +1,22 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; interface SolidStylePropSettings { styleProps?: ReadonlyArray; allowString?: boolean; } -const resolveSettings = ( - settings: Readonly> | undefined, -): SolidStylePropSettings => { - const reactDoctor = settings?.["react-doctor"]; - if (typeof reactDoctor !== "object" || reactDoctor === null) return {}; - const solidSettings = (reactDoctor as { solidStyleProp?: unknown }).solidStyleProp; - if (typeof solidSettings !== "object" || solidSettings === null) return {}; - return solidSettings as SolidStylePropSettings; -}; - const camelToKebab = (name: string): string => - name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + name.replace(/[A-Z]/g, (uppercaseMatch) => `-${uppercaseMatch.toLowerCase()}`); const LENGTH_PERCENTAGE_PATTERN = /\b(?:width|height|margin|padding|border-width|font-size)\b/i; -const jsxPropertyName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => { - if (isNodeOfType(attribute.name, "JSXIdentifier")) return attribute.name.name; - if (isNodeOfType(attribute.name, "JSXNamespacedName")) { - return `${attribute.name.namespace.name}:${attribute.name.name.name}`; - } - return null; -}; - const objectPropertyKeyName = (property: EsTreeNodeOfType<"Property">): string | null => { if (isNodeOfType(property.key, "Identifier")) return property.key.name; if (isNodeOfType(property.key, "Literal") && typeof property.key.value === "string") { @@ -56,12 +40,15 @@ export const solidStyleProp = defineRule({ recommendation: "Use kebab-case CSS property names (`font-size`, not `fontSize`) in Solid's `style` prop, and string values with units (`'12px'`, not `12`) for length properties.", create: (context: RuleContext) => { - const settings = resolveSettings(context.settings); + const settings = readSolidRuleSettings( + context.settings, + "solidStyleProp", + ); const styleProps = new Set(settings.styleProps ?? ["style"]); const allowString = Boolean(settings.allowString); return { JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { - const propertyName = jsxPropertyName(node); + const propertyName = getJsxAttributeName(node.name); if (!propertyName || !styleProps.has(propertyName)) return; if (!node.value) return; const style = isNodeOfType(node.value, "JSXExpressionContainer") diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-dom-element-name.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-dom-element-name.ts new file mode 100644 index 000000000..f394f44f9 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-dom-element-name.ts @@ -0,0 +1,7 @@ +// True when a JSX tag name looks like a host (DOM/SVG/custom) element +// rather than a React/Solid component. The Solid (and React) JSX +// transforms split on this same criterion — lowercase-led names are +// emitted as `createElement("div", ...)` strings, capitalised names +// are emitted as variable references — so it doubles as the gate for +// "is this an intrinsic element?" in many lint checks. +export const isDomElementName = (elementName: string): boolean => /^[a-z]/.test(elementName); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts new file mode 100644 index 000000000..edf3ac31c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts @@ -0,0 +1,16 @@ +// Reads a per-rule settings object out of the host's `settings` +// dictionary, keyed under `settings["react-doctor"][]`. +// Every Solid rule that accepts options funnels through here so the +// "is this nested under the react-doctor namespace?" check is in one +// place — mirrors the same shape used by `alt-text` / `get-element-type` +// for jsx-a11y settings, just under a different parent key. +export const readSolidRuleSettings = ( + settings: Readonly> | undefined, + settingsKey: string, +): Shape => { + const reactDoctorBlock = settings?.["react-doctor"]; + if (typeof reactDoctorBlock !== "object" || reactDoctorBlock === null) return {} as Shape; + const ruleBlock = (reactDoctorBlock as Record)[settingsKey]; + if (typeof ruleBlock !== "object" || ruleBlock === null) return {} as Shape; + return ruleBlock as Shape; +}; From 1452f07ea686c976b3b04602f3aca6181b66d4df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:46:03 +0000 Subject: [PATCH 03/18] fix(solid-jsx-no-script-url): replace control-character regex with explicit pre-filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regex literal embedded the C0-control range (`[\u0000-\u001F]`) and inline `[\r\n\t]*` separators between letters of the `javascript:` scheme. eslint's `no-control-regex` rule flags both forms, and a `// eslint-disable-next-line` directive did not suppress the warning under oxlint. Rewrites the matcher as: 1. Iterate the URL string in JS, stripping any code point in the C0 control range (0x00–0x1F). 2. Test the stripped string against `/^ *javascript:/i`. This is what the WHATWG URL parser actually does — strip filtered characters first, then match the scheme — so the new code more faithfully mirrors the spec while keeping the regex literal free of control characters. Adds two regression tests covering `href={\`java\\u0009script:...\`}` and a leading-control variant. Co-authored-by: Aiden Bai --- .../solid/solid-jsx-no-script-url.test.ts | 16 +++++++++ .../rules/solid/solid-jsx-no-script-url.ts | 34 ++++++++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts index 6d9add9bc..e686f7da5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.test.ts @@ -19,4 +19,20 @@ describe("solid-jsx-no-script-url", () => { ); expect(result.diagnostics).toHaveLength(0); }); + + it("flags `javascript:` URLs containing embedded control characters", () => { + const result = runRule( + solidJsxNoScriptUrl, + "const Foo = () =>
click;", + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags `javascript:` URLs with leading control characters and spaces", () => { + const result = runRule( + solidJsxNoScriptUrl, + "const Foo = () => click;", + ); + expect(result.diagnostics).toHaveLength(1); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts index 8eba7ba91..25a708170 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts @@ -5,14 +5,30 @@ import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; -// A `javascript:` URL can contain leading C0 control or U+0020 SPACE, -// and any newline or tab is filtered out as if it's not part of the -// URL. https://url.spec.whatwg.org/#url-parsing -// HACK: control-character class is the URL-spec definition; the regex -// matches exactly what browsers strip before resolving the protocol. -// eslint-disable-next-line no-control-regex -const JAVASCRIPT_PROTOCOL_PATTERN = - /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i; +// Mirrors the WHATWG URL parser's pre-scheme step: leading C0 +// controls and U+0020 SPACE are stripped, then ASCII tab / LF / CR +// characters inside the URL are also filtered out before the +// scheme is matched. https://url.spec.whatwg.org/#url-parsing +// +// Doing the filter in code (and keeping the regex literal free of +// control characters) avoids `eslint(no-control-regex)` warnings — +// inline `[\u0000-\u001F]` and `[\r\n\t]*` between letters would +// trip the lint at every rule-file load. +const JAVASCRIPT_SCHEME_PATTERN = /^ *javascript:/i; + +const isUrlControlCharacterCode = (characterCode: number): boolean => + characterCode >= 0 && characterCode <= 0x1f; + +const stripUrlControlCharacters = (urlValue: string): string => { + let stripped = ""; + for (const character of urlValue) { + if (!isUrlControlCharacterCode(character.charCodeAt(0))) stripped += character; + } + return stripped; +}; + +const startsWithJavascriptScheme = (urlValue: string): boolean => + JAVASCRIPT_SCHEME_PATTERN.test(stripUrlControlCharacters(urlValue)); const extractStaticStringValue = (node: EsTreeNode | null | undefined): string | null => { if (!node) return null; @@ -39,7 +55,7 @@ export const solidJsxNoScriptUrl = defineRule({ ? (node.value.expression as EsTreeNode) : (node.value as EsTreeNode); const stringValue = extractStaticStringValue(expression); - if (stringValue && JAVASCRIPT_PROTOCOL_PATTERN.test(stringValue)) { + if (stringValue && startsWithJavascriptScheme(stringValue)) { context.report({ node: node.value, message: "For security, don't use `javascript:` URLs. Use event handlers instead.", From 4c00c68fa500d8c87a0e6d0abb588234c6197f9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:48:21 +0000 Subject: [PATCH 04/18] docs(solid-no-innerhtml): correct port comment about static-string handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot (PR #513) flagged that the rule's header claimed "Static string values are still flagged because Solid reports them as dangerous when the rule is left at its default," but the actual code permits a static string `innerHTML="..."` on a childless element. The code behaviour is correct — it mirrors upstream's `allowStatic: true` default — only the comment was wrong. Rewrites the comment to enumerate the three real diagnostic cases (`dangerouslySetInnerHTML`, dynamic `innerHTML`, static `innerHTML` with JSX children) and to note explicitly that childless-static is intentionally allowed. Also documents the upstream `is-html` suggestion path as the one omission, so a future reader doesn't re-flag it. Co-authored-by: Aiden Bai --- .../plugin/rules/solid/solid-no-innerhtml.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts index 1396eb3eb..0da447607 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts @@ -23,12 +23,22 @@ const extractStaticStringValue = (node: EsTreeNode | null): string | null => { return null; }; -// Port of `solid/no-innerhtml` — flags `innerHTML={...}` (a Solid -// special prop) because passing unsanitized input is an XSS risk, -// plus the React-style `dangerouslySetInnerHTML` which Solid does -// not support. Static string values are still flagged because Solid -// reports them as dangerous when the rule is left at its default -// (we don't depend on `is-html` to keep the port footprint tight). +// Port of `solid/no-innerhtml`. Three distinct diagnostics: +// +// 1. `dangerouslySetInnerHTML={...}` — always flagged. Solid does +// not honour the React-style prop, so any use is a silent bug. +// 2. `innerHTML={dynamic}` — flagged as dangerous XSS source. +// 3. `innerHTML="..."` on an element with JSX children — flagged +// because the static markup overwrites the children. +// +// Static `innerHTML="..."` on a childless element is intentionally +// NOT flagged — this matches upstream's `allowStatic: true` default, +// which exists so authors can ship known-safe inline snippets without +// noise. Upstream additionally calls `is-html(value)` to suggest +// `innerText` when the literal isn't actually markup; we skip that +// to avoid pulling in the dataset, which only costs us the +// `notHtml` suggestion path — the security-relevant cases above are +// fully covered. export const solidNoInnerHtml = defineRule({ id: "solid-no-innerhtml", severity: "error", From 224a3cc46a7a347fff8df9bd0116aa7473e737cf Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 00:24:17 -0700 Subject: [PATCH 05/18] feat(solid): add 7 new react-doctor-original rules + enhance 4 existing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New rules (not in eslint-plugin-solid — derived from Solid docs/best-practices): - solid-no-effect-derived-state: flags createEffect that only sets derived state, recommends createMemo or derived signals - solid-no-signal-mutation: flags in-place mutation on signal/memo getters (.push(), .sort(), property assignment) that won't trigger reactivity - solid-require-cleanup: flags setInterval/addEventListener/subscribe inside effects without onCleanup - solid-prefer-resource: flags fetch-in-effect + setter pattern, recommends createResource - solid-no-impure-memo: flags side effects (console.log, setters, fetch, DOM mutation) inside createMemo - solid-no-provider-value-read: flags value={signal()} on Provider elements that reads the signal once instead of passing the accessor - solid-prefer-children-helper (opt-in): flags multiple props.children reads, recommends children() helper Enhancements to existing ported rules: - solid-components-return-once: now catches expression-bodied arrow components (e.g. const Comp = () => cond ? : ) - solid-jsx-no-duplicate-props: error message now names the specific duplicated prop - solid-no-innerhtml: new allowStatic setting via settings config - solid-no-react-deps: reverted fragile identifier heuristic All rules registered in rule-registry, tested (15 test files, 73 test cases across new + enhanced rules), typecheck/lint/format clean. --- .../src/plugin/rule-registry.ts | 91 +++++++++++++ .../solid-components-return-once.test.ts | 66 ++++++++++ .../solid/solid-components-return-once.ts | 25 ++++ .../solid-jsx-no-duplicate-props.test.ts | 3 +- .../solid/solid-jsx-no-duplicate-props.ts | 2 +- .../solid-no-effect-derived-state.test.ts | 93 +++++++++++++ .../solid/solid-no-effect-derived-state.ts | 123 ++++++++++++++++++ .../rules/solid/solid-no-impure-memo.test.ts | 73 +++++++++++ .../rules/solid/solid-no-impure-memo.ts | 115 ++++++++++++++++ .../rules/solid/solid-no-innerhtml.test.ts | 15 +++ .../plugin/rules/solid/solid-no-innerhtml.ts | 91 ++++++++----- .../solid-no-provider-value-read.test.ts | 64 +++++++++ .../solid/solid-no-provider-value-read.ts | 63 +++++++++ .../solid/solid-no-signal-mutation.test.ts | 79 +++++++++++ .../rules/solid/solid-no-signal-mutation.ts | 109 ++++++++++++++++ .../solid-prefer-children-helper.test.ts | 36 +++++ .../solid/solid-prefer-children-helper.ts | 75 +++++++++++ .../rules/solid/solid-prefer-resource.test.ts | 75 +++++++++++ .../rules/solid/solid-prefer-resource.ts | 86 ++++++++++++ .../rules/solid/solid-require-cleanup.test.ts | 63 +++++++++ .../rules/solid/solid-require-cleanup.ts | 109 ++++++++++++++++ 21 files changed, 1421 insertions(+), 35 deletions(-) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 4a2365984..2267c78ff 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -284,14 +284,21 @@ import { solidJsxNoDuplicateProps } from "./rules/solid/solid-jsx-no-duplicate-p import { solidJsxNoScriptUrl } from "./rules/solid/solid-jsx-no-script-url.js"; import { solidNoArrayHandlers } from "./rules/solid/solid-no-array-handlers.js"; import { solidNoDestructure } from "./rules/solid/solid-no-destructure.js"; +import { solidNoEffectDerivedState } from "./rules/solid/solid-no-effect-derived-state.js"; +import { solidNoImpureMemo } from "./rules/solid/solid-no-impure-memo.js"; import { solidNoInnerHtml } from "./rules/solid/solid-no-innerhtml.js"; +import { solidNoProviderValueRead } from "./rules/solid/solid-no-provider-value-read.js"; import { solidNoProxyApis } from "./rules/solid/solid-no-proxy-apis.js"; import { solidNoReactDeps } from "./rules/solid/solid-no-react-deps.js"; import { solidNoReactSpecificProps } from "./rules/solid/solid-no-react-specific-props.js"; +import { solidNoSignalMutation } from "./rules/solid/solid-no-signal-mutation.js"; import { solidNoUnknownNamespaces } from "./rules/solid/solid-no-unknown-namespaces.js"; +import { solidPreferChildrenHelper } from "./rules/solid/solid-prefer-children-helper.js"; import { solidPreferClasslist } from "./rules/solid/solid-prefer-classlist.js"; import { solidPreferFor } from "./rules/solid/solid-prefer-for.js"; +import { solidPreferResource } from "./rules/solid/solid-prefer-resource.js"; import { solidPreferShow } from "./rules/solid/solid-prefer-show.js"; +import { solidRequireCleanup } from "./rules/solid/solid-require-cleanup.js"; import { solidSelfClosingComp } from "./rules/solid/solid-self-closing-comp.js"; import { solidStyleProp } from "./rules/solid/solid-style-prop.js"; import { stateInConstructor } from "./rules/react-builtins/state-in-constructor.js"; @@ -3391,6 +3398,30 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoDestructure.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-effect-derived-state", + id: "solid-no-effect-derived-state", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoEffectDerivedState, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoEffectDerivedState.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-impure-memo", + id: "solid-no-impure-memo", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoImpureMemo, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoImpureMemo.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-innerhtml", id: "solid-no-innerhtml", @@ -3403,6 +3434,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoInnerHtml.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-provider-value-read", + id: "solid-no-provider-value-read", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoProviderValueRead, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoProviderValueRead.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-proxy-apis", id: "solid-no-proxy-apis", @@ -3439,6 +3482,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoReactSpecificProps.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-signal-mutation", + id: "solid-no-signal-mutation", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoSignalMutation, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoSignalMutation.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-unknown-namespaces", id: "solid-no-unknown-namespaces", @@ -3451,6 +3506,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoUnknownNamespaces.tags ?? [])])], }, }, + { + key: "react-doctor/solid-prefer-children-helper", + id: "solid-prefer-children-helper", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferChildrenHelper, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferChildrenHelper.tags ?? [])])], + }, + }, { key: "react-doctor/solid-prefer-classlist", id: "solid-prefer-classlist", @@ -3475,6 +3542,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidPreferFor.tags ?? [])])], }, }, + { + key: "react-doctor/solid-prefer-resource", + id: "solid-prefer-resource", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferResource, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferResource.tags ?? [])])], + }, + }, { key: "react-doctor/solid-prefer-show", id: "solid-prefer-show", @@ -3487,6 +3566,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidPreferShow.tags ?? [])])], }, }, + { + key: "react-doctor/solid-require-cleanup", + id: "solid-require-cleanup", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidRequireCleanup, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidRequireCleanup.tags ?? [])])], + }, + }, { key: "react-doctor/solid-self-closing-comp", id: "solid-self-closing-comp", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts new file mode 100644 index 000000000..8843c11e0 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidComponentsReturnOnce } from "./solid-components-return-once.js"; + +describe("solid-components-return-once", () => { + it("flags early return in component", () => { + const result = runRule( + solidComponentsReturnOnce, + `function Comp() { + if (loading) return
Loading
; + return
Done
; + }`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("early return"); + }); + + it("flags conditional expression in block body return", () => { + const result = runRule( + solidComponentsReturnOnce, + `function Comp() { + return loading ?
Loading
:
Done
; + }`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("conditional return"); + }); + + it("flags arrow expression body with ternary", () => { + const result = runRule( + solidComponentsReturnOnce, + `const Comp = () => loading ?
Loading
:
Done
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("conditional return"); + }); + + it("flags arrow expression body with && operator", () => { + const result = runRule( + solidComponentsReturnOnce, + `const Comp = () => loading &&
Loading
;`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag single JSX return", () => { + const result = runRule(solidComponentsReturnOnce, `const Comp = () =>
Hello
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag lowercase functions", () => { + const result = runRule( + solidComponentsReturnOnce, + `const helper = () => loading ?
A
:
B
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag render prop callbacks", () => { + const result = runRule( + solidComponentsReturnOnce, + `const Comp = () => {() => loading ?
: };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts index 2db6d8c3d..4c905fa0d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts @@ -115,6 +115,31 @@ export const solidComponentsReturnOnce = defineRule({ if (displayName && /^[a-z]/.test(displayName)) return; if (isHocCallParent(node)) return; + if ( + node.body && + !isNodeOfType(node.body, "BlockStatement") && + isNodeOfType(node, "ArrowFunctionExpression") + ) { + const expressionBody = node.body as EsTreeNode; + if (isNodeOfType(expressionBody, "ConditionalExpression")) { + context.report({ + node: expressionBody, + message: + "Solid components run once, so a conditional return breaks reactivity. Move the condition inside JSX (``).", + }); + } else if ( + isNodeOfType(expressionBody, "LogicalExpression") && + (expressionBody.operator === "&&" || expressionBody.operator === "||") + ) { + context.report({ + node: expressionBody, + message: + "Solid components run once, so a conditional return breaks reactivity. Move the condition inside JSX (``).", + }); + } + return; + } + let lastReturn: EsTreeNodeOfType<"ReturnStatement"> | null = null; let bodyStatements: ReadonlyArray = []; if (node.body && isNodeOfType(node.body, "BlockStatement")) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts index 188dae543..fc0fb1d6c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.test.ts @@ -3,10 +3,11 @@ import { runRule } from "../../../test-utils/run-rule.js"; import { solidJsxNoDuplicateProps } from "./solid-jsx-no-duplicate-props.js"; describe("solid-jsx-no-duplicate-props", () => { - it("flags duplicate props", () => { + it("flags duplicate props with prop name in message", () => { const result = runRule(solidJsxNoDuplicateProps, `const Foo = () =>
;`); expect(result.parseErrors).toEqual([]); expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("`id`"); }); it("uses class-specific message", () => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts index ba782007a..e1b785ccd 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-duplicate-props.ts @@ -80,7 +80,7 @@ export const solidJsxNoDuplicateProps = defineRule({ const message = entry.normalizedName === "class" ? "Duplicate `class` props are not allowed; use `classList` instead in Solid." - : "Duplicate props are not allowed."; + : `Duplicate prop \`${entry.normalizedName}\` — only the last value wins.`; context.report({ node: entry.reportNode, message }); } seenNames.add(entry.normalizedName); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts new file mode 100644 index 000000000..57e9a3350 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoEffectDerivedState } from "./solid-no-effect-derived-state.js"; + +describe("solid-no-effect-derived-state", () => { + it("flags createEffect that only calls a setter", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createSignal, createEffect } from "solid-js"; + const [count, setCount] = createSignal(0); + const [doubled, setDoubled] = createSignal(0); + createEffect(() => setDoubled(count() * 2));`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("derived state"); + }); + + it("flags createEffect with block body that only has setters", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => { + setA(value() + 1); + setB(value() * 2); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag createEffect with side effects", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => { + console.log(count()); + setDoubled(count() * 2); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createEffect with DOM mutation", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => { + document.title = count(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createEffect without Solid import", () => { + const result = runRule( + solidNoEffectDerivedState, + `createEffect(() => setDoubled(count() * 2));`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags aliased imports", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect as eff } from "solid-js"; + eff(() => setCount(value()));`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag effect with mixed setter and non-setter statements", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => { + setDoubled(count() * 2); + doSomething(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag effect with variable declarations alongside setter", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => { + const logIt = () => console.log("hi"); + setA(value()); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts new file mode 100644 index 000000000..83d8cebf4 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts @@ -0,0 +1,123 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EFFECT_PRIMITIVES: ReadonlyArray = ["createEffect", "createRenderEffect"]; + +const isSetterCall = (node: EsTreeNode): boolean => { + if (!isNodeOfType(node, "CallExpression")) return false; + if (!isNodeOfType(node.callee, "Identifier")) return false; + return /^set[A-Z]/.test(node.callee.name); +}; + +const bodyIsOnlySetter = (callback: EsTreeNode): boolean => { + if (!isFunctionLike(callback)) return false; + if (isNodeOfType(callback.body, "BlockStatement")) { + const statements = callback.body.body; + if (statements.length !== 1) return false; + const onlyStatement = statements[0]; + if (isNodeOfType(onlyStatement, "ExpressionStatement")) { + return isSetterCall(onlyStatement.expression as EsTreeNode); + } + return false; + } + return isSetterCall(callback.body as EsTreeNode); +}; + +const bodyContainsOnlySetters = (callback: EsTreeNode): boolean => { + if (!isFunctionLike(callback)) return false; + if (isNodeOfType(callback.body, "BlockStatement")) { + const statements = callback.body.body; + if (statements.length === 0) return false; + return statements.every((statement) => { + if (isNodeOfType(statement, "ExpressionStatement")) { + return isSetterCall(statement.expression as EsTreeNode); + } + return false; + }); + } + return isSetterCall(callback.body as EsTreeNode); +}; + +const bodyContainsSideEffects = (callback: EsTreeNode): boolean => { + if (!isFunctionLike(callback)) return false; + let foundSideEffect = false; + walkAst(callback.body as EsTreeNode, (node) => { + if (foundSideEffect) return false; + if (isFunctionLike(node) && node !== callback) return false; + if (isNodeOfType(node, "CallExpression")) { + if (isSetterCall(node)) return; + if (isNodeOfType(node.callee, "MemberExpression")) { + const property = node.callee.property; + if (isNodeOfType(property, "Identifier")) { + const methodName = property.name; + if (["log", "warn", "error", "info", "debug", "fetch"].includes(methodName)) { + foundSideEffect = true; + return false; + } + } + } + if (isNodeOfType(node.callee, "Identifier")) { + const calleeName = node.callee.name; + if (["fetch", "alert", "confirm", "prompt"].includes(calleeName)) { + foundSideEffect = true; + return false; + } + } + } + if (isNodeOfType(node, "AwaitExpression")) { + foundSideEffect = true; + return false; + } + if (isNodeOfType(node, "AssignmentExpression")) { + if (isNodeOfType(node.left, "MemberExpression")) { + foundSideEffect = true; + return false; + } + } + }); + return foundSideEffect; +}; + +export const solidNoEffectDerivedState = defineRule({ + id: "solid-no-effect-derived-state", + severity: "warn", + requires: ["solid"], + recommendation: + "Replace the effect with a derived signal (`const x = () => expr`) or `createMemo(() => expr)` — effects that only set state from reactive values are redundant in Solid.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedImport = importTracker.matchImport(EFFECT_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + if (bodyIsOnlySetter(callback)) { + context.report({ + node, + message: `This \`${matchedImport}\` only sets derived state — replace with a derived signal (\`const x = () => expr\`) or \`createMemo\`.`, + }); + return; + } + if (bodyContainsOnlySetters(callback) && !bodyContainsSideEffects(callback)) { + context.report({ + node, + message: `This \`${matchedImport}\` only sets derived state — replace with \`createMemo\` or derived signals.`, + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.test.ts new file mode 100644 index 000000000..6027eb223 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoImpureMemo } from "./solid-no-impure-memo.js"; + +describe("solid-no-impure-memo", () => { + it("flags setter call inside createMemo", () => { + const result = runRule( + solidNoImpureMemo, + `import { createMemo } from "solid-js"; + const doubled = createMemo(() => { + setOther(value()); + return value() * 2; + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("setter"); + }); + + it("flags console.log inside createMemo", () => { + const result = runRule( + solidNoImpureMemo, + `import { createMemo } from "solid-js"; + const doubled = createMemo(() => { + console.log("computing"); + return value() * 2; + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("console.log()"); + }); + + it("does not flag .error() on non-console receiver", () => { + const result = runRule( + solidNoImpureMemo, + `import { createMemo } from "solid-js"; + const result = createMemo(() => validation.error());`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags fetch inside createMemo", () => { + const result = runRule( + solidNoImpureMemo, + `import { createMemo } from "solid-js"; + const data = createMemo(() => { + fetch("/api"); + return cached; + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("fetch()"); + }); + + it("does not flag pure createMemo", () => { + const result = runRule( + solidNoImpureMemo, + `import { createMemo } from "solid-js"; + const doubled = createMemo(() => count() * 2);`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without Solid import", () => { + const result = runRule( + solidNoImpureMemo, + `const doubled = createMemo(() => { + setOther(value()); + return value() * 2; + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts new file mode 100644 index 000000000..e1e4a476a --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts @@ -0,0 +1,115 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const MEMO_PRIMITIVES: ReadonlyArray = ["createMemo"]; + +const SIDE_EFFECT_GLOBAL_CALLS = new Set(["fetch", "alert", "confirm", "prompt"]); + +const CONSOLE_METHODS = new Set(["log", "warn", "error", "info", "debug"]); +const SUBSCRIPTION_MEMBER_METHODS = new Set([ + "addEventListener", + "removeEventListener", + "subscribe", + "observe", +]); + +const isSetterCall = (node: EsTreeNode): boolean => { + if (!isNodeOfType(node, "CallExpression")) return false; + if (!isNodeOfType(node.callee, "Identifier")) return false; + return /^set[A-Z]/.test(node.callee.name); +}; + +interface SideEffectInfo { + description: string; + node: EsTreeNode; +} + +const findSideEffects = (callback: EsTreeNode): SideEffectInfo | null => { + let result: SideEffectInfo | null = null; + walkAst(callback, (node) => { + if (result) return false; + if (isFunctionLike(node) && node !== callback) return false; + + if (isSetterCall(node)) { + const callee = (node as EsTreeNodeOfType<"CallExpression">) + .callee as EsTreeNodeOfType<"Identifier">; + result = { description: `calls \`${callee.name}()\` (a signal setter)`, node }; + return false; + } + + if (isNodeOfType(node, "CallExpression")) { + if ( + isNodeOfType(node.callee, "Identifier") && + SIDE_EFFECT_GLOBAL_CALLS.has(node.callee.name) + ) { + result = { description: `calls \`${node.callee.name}()\``, node }; + return false; + } + if ( + isNodeOfType(node.callee, "MemberExpression") && + isNodeOfType(node.callee.property, "Identifier") + ) { + const methodName = node.callee.property.name; + if ( + CONSOLE_METHODS.has(methodName) && + isNodeOfType(node.callee.object, "Identifier") && + node.callee.object.name === "console" + ) { + result = { description: `calls \`console.${methodName}()\``, node }; + return false; + } + if (SUBSCRIPTION_MEMBER_METHODS.has(methodName)) { + result = { description: `calls \`.${methodName}()\``, node }; + return false; + } + } + } + + if (isNodeOfType(node, "AwaitExpression")) { + result = { description: "contains `await`", node }; + return false; + } + + if (isNodeOfType(node, "AssignmentExpression") && isNodeOfType(node.left, "MemberExpression")) { + result = { description: "mutates an external object", node }; + return false; + } + }); + return result; +}; + +export const solidNoImpureMemo = defineRule({ + id: "solid-no-impure-memo", + severity: "warn", + requires: ["solid"], + recommendation: + "`createMemo` must be a pure derivation — move side effects into `createEffect` instead.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + if (!importTracker.matchImport(MEMO_PRIMITIVES, node.callee.name)) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + const sideEffect = findSideEffects(callback); + if (!sideEffect) return; + context.report({ + node, + message: `\`createMemo\` should be a pure derivation, but this one ${sideEffect.description}. Move side effects into \`createEffect\`.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts index cfc2f9b3f..1e9d7eba4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.test.ts @@ -28,4 +28,19 @@ describe("solid-no-innerhtml", () => { result.diagnostics.some((diagnostic) => diagnostic.message.includes("overwritten")), ).toBe(true); }); + + it("allows static innerHTML on childless element by default", () => { + const result = runRule(solidNoInnerHtml, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags static innerHTML when allowStatic is false", () => { + const result = runRule( + solidNoInnerHtml, + `const Foo = () =>
;`, + { settings: { "react-doctor": { solidNoInnerHtml: { allowStatic: false } } } }, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("static values"); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts index 0da447607..6dbd17001 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts @@ -5,6 +5,11 @@ import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; + +interface SolidNoInnerHtmlSettings { + allowStatic?: boolean; +} const extractInnerExpression = (attribute: EsTreeNodeOfType<"JSXAttribute">): EsTreeNode | null => { if (!attribute.value) return null; @@ -45,37 +50,57 @@ export const solidNoInnerHtml = defineRule({ requires: ["solid"], recommendation: "Avoid `innerHTML` — render children via JSX. If you must inject markup, sanitize input first.", - create: (context: RuleContext) => ({ - JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { - const propertyName = getJsxAttributeName(node.name); - if (!propertyName) return; - if (propertyName === "dangerouslySetInnerHTML") { - context.report({ - node, - message: "`dangerouslySetInnerHTML` is not supported in Solid — use `innerHTML` instead.", - }); - return; - } - if (propertyName !== "innerHTML") return; - const expression = extractInnerExpression(node); - const staticString = extractStaticStringValue(expression); - if (staticString === null) { - context.report({ - node, - message: - "Avoid `innerHTML` with dynamic values — passing unsanitized input causes XSS vulnerabilities.", - }); - return; - } - const opener = node.parent; - const jsxElement = opener?.parent; - if (jsxElement && isNodeOfType(jsxElement, "JSXElement") && jsxElement.children.length > 0) { - context.report({ - node: jsxElement, - message: - "`innerHTML` should not be used on an element with child elements — children will be overwritten.", - }); - } - }, - }), + create: (context: RuleContext) => { + const settings = readSolidRuleSettings( + context.settings, + "solidNoInnerHtml", + ); + const allowStatic = settings.allowStatic !== false; + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const propertyName = getJsxAttributeName(node.name); + if (!propertyName) return; + if (propertyName === "dangerouslySetInnerHTML") { + context.report({ + node, + message: + "`dangerouslySetInnerHTML` is not supported in Solid — use `innerHTML` instead.", + }); + return; + } + if (propertyName !== "innerHTML") return; + const expression = extractInnerExpression(node); + const staticString = extractStaticStringValue(expression); + if (staticString === null) { + context.report({ + node, + message: + "Avoid `innerHTML` with dynamic values — passing unsanitized input causes XSS vulnerabilities.", + }); + return; + } + if (!allowStatic) { + context.report({ + node, + message: + "Avoid `innerHTML` even with static values — prefer JSX children or `textContent`.", + }); + return; + } + const opener = node.parent; + const jsxElement = opener?.parent; + if ( + jsxElement && + isNodeOfType(jsxElement, "JSXElement") && + jsxElement.children.length > 0 + ) { + context.report({ + node: jsxElement, + message: + "`innerHTML` should not be used on an element with child elements — children will be overwritten.", + }); + } + }, + }; + }, }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.test.ts new file mode 100644 index 000000000..e9edee79f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoProviderValueRead } from "./solid-no-provider-value-read.js"; + +describe("solid-no-provider-value-read", () => { + it("flags signal call as provider value", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("theme"); + expect(result.diagnostics[0].message).toContain("accessor"); + }); + + it("flags member-expression Provider", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag accessor passed directly", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag object literal value", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag non-Provider elements", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag calls with arguments", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags member-expression accessor call", () => { + const result = runRule( + solidNoProviderValueRead, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("store.count"); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.ts new file mode 100644 index 000000000..2c958959c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-provider-value-read.ts @@ -0,0 +1,63 @@ +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const isProviderElement = (node: EsTreeNodeOfType<"JSXOpeningElement">): boolean => { + if (isNodeOfType(node.name, "JSXIdentifier")) { + return node.name.name.endsWith("Provider"); + } + if (isNodeOfType(node.name, "JSXMemberExpression")) { + const property = node.name.property; + if (isNodeOfType(property, "JSXIdentifier")) { + return property.name === "Provider"; + } + } + return false; +}; + +export const solidNoProviderValueRead = defineRule({ + id: "solid-no-provider-value-read", + severity: "warn", + requires: ["solid"], + recommendation: + "Pass signal accessors (functions) to context providers, not their read values — `value={count}` not `value={count()}`.", + create: (context: RuleContext) => ({ + JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { + if (!isProviderElement(node)) return; + for (const attribute of node.attributes) { + if (!isNodeOfType(attribute, "JSXAttribute")) continue; + const attributeName = getJsxAttributeName(attribute.name); + if (attributeName !== "value") continue; + if (!attribute.value || !isNodeOfType(attribute.value, "JSXExpressionContainer")) continue; + const expression = attribute.value.expression as EsTreeNode; + if (isNodeOfType(expression, "JSXEmptyExpression")) continue; + if (isNodeOfType(expression, "ObjectExpression")) continue; + if (!isNodeOfType(expression, "CallExpression")) continue; + if (expression.arguments.length !== 0) continue; + const callee = expression.callee; + if (isNodeOfType(callee, "Identifier")) { + context.report({ + node: attribute, + message: `Provider \`value={${callee.name}()}\` reads the signal once — pass the accessor instead: \`value={${callee.name}}\`.`, + }); + } else if ( + isNodeOfType(callee, "MemberExpression") && + isNodeOfType(callee.property, "Identifier") + ) { + const objectName = isNodeOfType(callee.object, "Identifier") + ? `${callee.object.name}.` + : ""; + const fullName = `${objectName}${callee.property.name}`; + context.report({ + node: attribute, + message: `Provider \`value={${fullName}()}\` reads the value once — pass the accessor instead: \`value={() => ${fullName}()}\`.`, + }); + } + } + }, + }), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.test.ts new file mode 100644 index 000000000..c97b84aac --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoSignalMutation } from "./solid-no-signal-mutation.js"; + +describe("solid-no-signal-mutation", () => { + it("flags .push() on signal getter", () => { + const result = runRule( + solidNoSignalMutation, + `import { createSignal } from "solid-js"; + const [items, setItems] = createSignal([]); + items().push("new");`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(".push()"); + }); + + it("flags .sort() on signal getter", () => { + const result = runRule( + solidNoSignalMutation, + `import { createSignal } from "solid-js"; + const [items, setItems] = createSignal([]); + items().sort();`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(".sort()"); + }); + + it("flags property assignment on signal getter", () => { + const result = runRule( + solidNoSignalMutation, + `import { createSignal } from "solid-js"; + const [obj, setObj] = createSignal({}); + obj().name = "test";`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(".name"); + }); + + it("does not flag non-signal calls", () => { + const result = runRule( + solidNoSignalMutation, + `import { createSignal } from "solid-js"; + const items = getItems(); + items.push("new");`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags memo mutation", () => { + const result = runRule( + solidNoSignalMutation, + `import { createMemo } from "solid-js"; + const doubled = createMemo(() => [1, 2]); + doubled().push(3);`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("memo"); + }); + + it("does not flag without solid import", () => { + const result = runRule( + solidNoSignalMutation, + `const [items, setItems] = createSignal([]); + items().push("new");`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags property assignment on memo return value", () => { + const result = runRule( + solidNoSignalMutation, + `import { createMemo } from "solid-js"; + const data = createMemo(() => ({ name: "x" })); + data().name = "y";`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("memo"); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.ts new file mode 100644 index 000000000..688996320 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-mutation.ts @@ -0,0 +1,109 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const SIGNAL_CREATORS: ReadonlyArray = ["createSignal", "createMemo"]; + +const MUTATING_METHODS = new Set([ + "push", + "pop", + "shift", + "unshift", + "splice", + "sort", + "reverse", + "fill", + "copyWithin", +]); + +const isSignalGetterCall = (node: EsTreeNode, signalGetterNames: ReadonlySet): boolean => { + if (!isNodeOfType(node, "CallExpression")) return false; + if (!isNodeOfType(node.callee, "Identifier")) return false; + return signalGetterNames.has(node.callee.name); +}; + +export const solidNoSignalMutation = defineRule({ + id: "solid-no-signal-mutation", + severity: "error", + requires: ["solid"], + recommendation: + "Solid signals track by reference — in-place mutation (`.push()`, `.key = ...`) won't trigger updates. Use the setter with a new value or `produce` from `solid-js/store`.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + const signalGetterNames = new Set(); + const memoNames = new Set(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + VariableDeclarator(node: EsTreeNodeOfType<"VariableDeclarator">) { + if (!isNodeOfType(node.init, "CallExpression")) return; + if (!isNodeOfType(node.init.callee, "Identifier")) return; + + const matchedImport = importTracker.matchImport(SIGNAL_CREATORS, node.init.callee.name); + if (!matchedImport) return; + + if (matchedImport === "createSignal") { + if (!isNodeOfType(node.id, "ArrayPattern")) return; + const firstElement = node.id.elements[0]; + if (firstElement && isNodeOfType(firstElement, "Identifier")) { + signalGetterNames.add(firstElement.name); + } + } else if (matchedImport === "createMemo") { + if (isNodeOfType(node.id, "Identifier")) { + memoNames.add(node.id.name); + } + } + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "MemberExpression")) return; + const property = node.callee.property; + if (!isNodeOfType(property, "Identifier")) return; + if (!MUTATING_METHODS.has(property.name)) return; + const object = node.callee.object as EsTreeNode; + if (isSignalGetterCall(object, signalGetterNames)) { + context.report({ + node, + message: `Mutating a signal's value via \`.${property.name}()\` won't trigger reactivity — Solid tracks by reference. Use the setter with a new value instead.`, + }); + } + if ( + isNodeOfType(object, "CallExpression") && + isNodeOfType(object.callee, "Identifier") && + memoNames.has(object.callee.name) + ) { + context.report({ + node, + message: `Mutating a memo's value via \`.${property.name}()\` won't trigger reactivity. Memos are read-only derived values.`, + }); + } + }, + AssignmentExpression(node: EsTreeNodeOfType<"AssignmentExpression">) { + if (!isNodeOfType(node.left, "MemberExpression")) return; + const object = node.left.object as EsTreeNode; + const property = node.left.property; + const propertyName = isNodeOfType(property, "Identifier") ? `.${property.name}` : "[...]"; + if (isSignalGetterCall(object, signalGetterNames)) { + context.report({ + node, + message: `Assigning to \`signal()${propertyName}\` won't trigger reactivity — Solid tracks by reference. Use the setter with a new object instead.`, + }); + } + if ( + isNodeOfType(object, "CallExpression") && + isNodeOfType(object.callee, "Identifier") && + memoNames.has(object.callee.name) + ) { + context.report({ + node, + message: `Assigning to \`memo()${propertyName}\` won't trigger reactivity. Memos are read-only derived values.`, + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.test.ts new file mode 100644 index 000000000..bb78c0399 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidPreferChildrenHelper } from "./solid-prefer-children-helper.js"; + +describe("solid-prefer-children-helper", () => { + it("flags multiple props.children reads", () => { + const result = runRule( + solidPreferChildrenHelper, + `const Panel = (props) =>
{props.children}{props.children}
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("2 times"); + expect(result.diagnostics[0].message).toContain("children"); + }); + + it("does not flag single props.children read", () => { + const result = runRule( + solidPreferChildrenHelper, + `const Panel = (props) =>
{props.children}
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag components without props param", () => { + const result = runRule(solidPreferChildrenHelper, `const Panel = () =>
hello
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag non-JSX functions", () => { + const result = runRule( + solidPreferChildrenHelper, + `const util = (props) => props.children + props.children;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.ts new file mode 100644 index 000000000..ff3f5240d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-children-helper.ts @@ -0,0 +1,75 @@ +import { containsJsxElement } from "../../utils/contains-jsx-element.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +type FunctionLikeNode = + | EsTreeNodeOfType<"FunctionDeclaration"> + | EsTreeNodeOfType<"FunctionExpression"> + | EsTreeNodeOfType<"ArrowFunctionExpression">; + +const getPropsParamName = (node: FunctionLikeNode): string | null => { + if (node.params.length !== 1) return null; + const firstParam = node.params[0]; + if (isNodeOfType(firstParam, "Identifier")) return firstParam.name; + return null; +}; + +const isPropsChildrenAccess = (node: EsTreeNode, propsName: string): boolean => { + if (!isNodeOfType(node, "MemberExpression")) return false; + if (!isNodeOfType(node.object, "Identifier")) return false; + if (node.object.name !== propsName) return false; + if (isNodeOfType(node.property, "Identifier") && node.property.name === "children") return true; + if (isNodeOfType(node.property, "Literal") && node.property.value === "children") return true; + return false; +}; + +const countChildrenAccesses = (body: EsTreeNode, propsName: string): number => { + let count = 0; + walkAst(body, (node) => { + if (isFunctionLike(node) && node !== body) return false; + if (isPropsChildrenAccess(node, propsName)) { + count++; + } + }); + return count; +}; + +export const solidPreferChildrenHelper = defineRule({ + id: "solid-prefer-children-helper", + severity: "warn", + defaultEnabled: false, + requires: ["solid"], + recommendation: + "Multiple reads of `props.children` create new DOM nodes each time. Use `children(() => props.children)` to resolve once.", + create: (context: RuleContext) => { + const visitFunction = (node: FunctionLikeNode): void => { + if (!containsJsxElement(node as EsTreeNode)) return; + const propsName = getPropsParamName(node); + if (!propsName) return; + const body = node.body as EsTreeNode; + const accessCount = countChildrenAccesses(body, propsName); + if (accessCount < 2) return; + context.report({ + node, + message: `\`${propsName}.children\` is accessed ${accessCount} times — each read creates new DOM. Use \`const resolved = children(() => ${propsName}.children)\` and read \`resolved()\` instead.`, + }); + }; + return { + FunctionDeclaration(node: EsTreeNodeOfType<"FunctionDeclaration">) { + visitFunction(node); + }, + FunctionExpression(node: EsTreeNodeOfType<"FunctionExpression">) { + visitFunction(node); + }, + ArrowFunctionExpression(node: EsTreeNodeOfType<"ArrowFunctionExpression">) { + visitFunction(node); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.test.ts new file mode 100644 index 000000000..b08f0e265 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidPreferResource } from "./solid-prefer-resource.js"; + +describe("solid-prefer-resource", () => { + it("flags async createEffect with fetch and setter", () => { + const result = runRule( + solidPreferResource, + `import { createEffect } from "solid-js"; + createEffect(async () => { + const res = await fetch("/api/data"); + setData(await res.json()); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createResource"); + }); + + it("flags createEffect containing fetch with setter at same level", () => { + const result = runRule( + solidPreferResource, + `import { createEffect } from "solid-js"; + createEffect(() => { + const res = fetch("/api"); + setLoading(true); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag createEffect without fetch", () => { + const result = runRule( + solidPreferResource, + `import { createEffect } from "solid-js"; + createEffect(() => { + setCount(value() + 1); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag async createEffect without setter", () => { + const result = runRule( + solidPreferResource, + `import { createEffect } from "solid-js"; + createEffect(async () => { + await fetch("/api/ping"); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag async createEffect without fetch", () => { + const result = runRule( + solidPreferResource, + `import { createEffect } from "solid-js"; + createEffect(async () => { + const data = await computeAsync(); + setResult(data); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without Solid import", () => { + const result = runRule( + solidPreferResource, + `createEffect(async () => { + const res = await fetch("/api"); + setData(await res.json()); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts new file mode 100644 index 000000000..1a44bc21d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts @@ -0,0 +1,86 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EFFECT_PRIMITIVES: ReadonlyArray = ["createEffect", "createRenderEffect"]; + +const FETCH_IDENTIFIERS = new Set(["fetch"]); +const FETCH_MEMBER_METHODS = new Set(["get", "post", "put", "patch", "delete", "request"]); + +const isSetterCall = (node: EsTreeNode): boolean => { + if (!isNodeOfType(node, "CallExpression")) return false; + if (!isNodeOfType(node.callee, "Identifier")) return false; + return /^set[A-Z]/.test(node.callee.name); +}; + +const containsFetchLikeCall = (node: EsTreeNode): boolean => { + let found = false; + walkAst(node, (child) => { + if (found) return false; + if (isFunctionLike(child) && child !== node) return false; + if (!isNodeOfType(child, "CallExpression")) return; + if (isNodeOfType(child.callee, "Identifier") && FETCH_IDENTIFIERS.has(child.callee.name)) { + found = true; + return false; + } + if ( + isNodeOfType(child.callee, "MemberExpression") && + isNodeOfType(child.callee.property, "Identifier") && + FETCH_MEMBER_METHODS.has(child.callee.property.name) + ) { + found = true; + return false; + } + }); + return found; +}; + +const containsSetterCall = (node: EsTreeNode): boolean => { + let found = false; + walkAst(node, (child) => { + if (found) return false; + if (isFunctionLike(child) && child !== node) return false; + if (isSetterCall(child)) { + found = true; + return false; + } + }); + return found; +}; + +export const solidPreferResource = defineRule({ + id: "solid-prefer-resource", + severity: "warn", + requires: ["solid"], + recommendation: + "Use `createResource` (or SolidStart's `createAsync`) for async data fetching — it integrates with ``, handles race conditions, and avoids the fetch-in-effect anti-pattern.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedImport = importTracker.matchImport(EFFECT_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + const hasFetch = containsFetchLikeCall(callback); + if (!hasFetch) return; + if (!containsSetterCall(callback)) return; + context.report({ + node, + message: `This \`${matchedImport}\` fetches data and stores it in state — prefer \`createResource\` which integrates with \`\` and handles race conditions automatically.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.test.ts new file mode 100644 index 000000000..f939da484 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidRequireCleanup } from "./solid-require-cleanup.js"; + +describe("solid-require-cleanup", () => { + it("flags setInterval without onCleanup", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect } from "solid-js"; + createEffect(() => { + const id = setInterval(tick, 1000); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("setInterval"); + expect(result.diagnostics[0].message).toContain("onCleanup"); + }); + + it("flags addEventListener without onCleanup", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect } from "solid-js"; + createEffect(() => { + window.addEventListener("resize", handler); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("addEventListener"); + }); + + it("does not flag when onCleanup is present", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(() => { + const id = setInterval(tick, 1000); + onCleanup(() => clearInterval(id)); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag effect without subscriptions", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect } from "solid-js"; + createEffect(() => { + console.log(count()); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without Solid import", () => { + const result = runRule( + solidRequireCleanup, + `createEffect(() => { + setInterval(tick, 1000); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.ts new file mode 100644 index 000000000..b937d0aeb --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.ts @@ -0,0 +1,109 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EFFECT_PRIMITIVES: ReadonlyArray = [ + "createEffect", + "createRenderEffect", + "createComputed", +]; + +const TIMER_METHODS = new Set(["setInterval", "setTimeout"]); +const SUBSCRIPTION_METHODS = new Set(["addEventListener", "subscribe", "observe"]); +const CLEANUP_PRIMITIVES: ReadonlyArray = ["onCleanup"]; + +interface ResourceUsage { + kind: "timer" | "subscription"; + name: string; + node: EsTreeNode; +} + +const findResourceUsages = (callback: EsTreeNode): ReadonlyArray => { + const usages: ResourceUsage[] = []; + walkAst(callback, (node) => { + if (isFunctionLike(node) && node !== callback) return false; + if (!isNodeOfType(node, "CallExpression")) return; + if (isNodeOfType(node.callee, "Identifier") && TIMER_METHODS.has(node.callee.name)) { + usages.push({ kind: "timer", name: node.callee.name, node }); + } + if ( + isNodeOfType(node.callee, "MemberExpression") && + isNodeOfType(node.callee.property, "Identifier") && + SUBSCRIPTION_METHODS.has(node.callee.property.name) + ) { + usages.push({ kind: "subscription", name: node.callee.property.name, node }); + } + }); + return usages; +}; + +const containsCleanupCall = ( + callback: EsTreeNode, + cleanupLocalNames: ReadonlySet, +): boolean => { + let found = false; + walkAst(callback, (node) => { + if (found) return false; + if (isFunctionLike(node) && node !== callback) return false; + if (isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier")) { + if (cleanupLocalNames.has(node.callee.name)) { + found = true; + return false; + } + } + }); + return found; +}; + +export const solidRequireCleanup = defineRule({ + id: "solid-require-cleanup", + severity: "warn", + requires: ["solid"], + recommendation: + "Use `onCleanup` to release timers, listeners, and subscriptions created inside effects — without cleanup they leak on every re-run.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + const cleanupLocalNames = new Set(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + const source = node.source?.value; + if (typeof source !== "string" || !/^solid-js/.test(source)) return; + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ImportSpecifier")) continue; + const importedIdentifier = specifier.imported; + if (!isNodeOfType(importedIdentifier, "Identifier")) continue; + if (CLEANUP_PRIMITIVES.includes(importedIdentifier.name)) { + cleanupLocalNames.add(specifier.local.name); + } + } + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedImport = importTracker.matchImport(EFFECT_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + const usages = findResourceUsages(callback); + if (usages.length === 0) return; + if (containsCleanupCall(callback, cleanupLocalNames)) return; + const firstUsage = usages[0]; + const releaseHint = + firstUsage.kind === "timer" + ? `clear${firstUsage.name === "setInterval" ? "Interval" : "Timeout"}(...)` + : `the matching remove/unsubscribe call`; + context.report({ + node, + message: `This \`${matchedImport}\` uses \`${firstUsage.name}(...)\` but never calls \`onCleanup\` — the registration leaks on every re-run. Add \`onCleanup(() => ${releaseHint})\`.`, + }); + }, + }; + }, +}); From 9a70a856d5b613f4197ec5d2463fd86ab3b8b80a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 00:45:22 -0700 Subject: [PATCH 06/18] =?UTF-8?q?fix(solid):=20address=20Bugbot=20findings?= =?UTF-8?q?=20=E2=80=94=20DRY=20isSetterCall,=20prototype-pollution=20guar?= =?UTF-8?q?d,=20side-effect-in-setter-arg,=20narrow=20fetch=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace local `isSetterCall` copies in solid-no-effect-derived-state, solid-no-impure-memo, solid-prefer-resource with the shared `utils/is-setter-call.ts` utility (DRY per AGENTS.md). - Merge `bodyIsOnlySetter` into `bodyContainsOnlySetters` so the side-effect check always runs — fixes false positive on `createEffect(() => setA(fetch("/api")))`. - Use `Object.getOwnPropertyDescriptor` in `readSolidRuleSettings` to match the prototype-pollution guard in the existing `get-react-doctor-setting.ts`. - Remove `get`, `delete`, `post`, `put`, `patch`, `request` from `FETCH_MEMBER_METHODS` — `map.get()` and `set.delete()` false- positived as fetch calls. Now only matches the `fetch()` global. Co-authored-by: Aiden Bai --- .../solid-no-effect-derived-state.test.ts | 9 +++++ .../solid/solid-no-effect-derived-state.ts | 40 ++++--------------- .../rules/solid/solid-no-impure-memo.ts | 7 +--- .../rules/solid/solid-prefer-resource.ts | 16 +------- .../plugin/utils/read-solid-rule-settings.ts | 10 ++++- 5 files changed, 26 insertions(+), 56 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts index 57e9a3350..6fb339367 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.test.ts @@ -90,4 +90,13 @@ describe("solid-no-effect-derived-state", () => { ); expect(result.diagnostics).toHaveLength(0); }); + + it("does not flag setter with side-effectful argument", () => { + const result = runRule( + solidNoEffectDerivedState, + `import { createEffect } from "solid-js"; + createEffect(() => setA(fetch("/api")));`, + ); + expect(result.diagnostics).toHaveLength(0); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts index 83d8cebf4..3b77a2b65 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts @@ -4,32 +4,13 @@ import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { isSetterCall } from "../../utils/is-setter-call.js"; import { walkAst } from "../../utils/walk-ast.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; const EFFECT_PRIMITIVES: ReadonlyArray = ["createEffect", "createRenderEffect"]; -const isSetterCall = (node: EsTreeNode): boolean => { - if (!isNodeOfType(node, "CallExpression")) return false; - if (!isNodeOfType(node.callee, "Identifier")) return false; - return /^set[A-Z]/.test(node.callee.name); -}; - -const bodyIsOnlySetter = (callback: EsTreeNode): boolean => { - if (!isFunctionLike(callback)) return false; - if (isNodeOfType(callback.body, "BlockStatement")) { - const statements = callback.body.body; - if (statements.length !== 1) return false; - const onlyStatement = statements[0]; - if (isNodeOfType(onlyStatement, "ExpressionStatement")) { - return isSetterCall(onlyStatement.expression as EsTreeNode); - } - return false; - } - return isSetterCall(callback.body as EsTreeNode); -}; - const bodyContainsOnlySetters = (callback: EsTreeNode): boolean => { if (!isFunctionLike(callback)) return false; if (isNodeOfType(callback.body, "BlockStatement")) { @@ -104,19 +85,12 @@ export const solidNoEffectDerivedState = defineRule({ if (node.arguments.length < 1) return; const callback = node.arguments[0]; if (!isFunctionLike(callback)) return; - if (bodyIsOnlySetter(callback)) { - context.report({ - node, - message: `This \`${matchedImport}\` only sets derived state — replace with a derived signal (\`const x = () => expr\`) or \`createMemo\`.`, - }); - return; - } - if (bodyContainsOnlySetters(callback) && !bodyContainsSideEffects(callback)) { - context.report({ - node, - message: `This \`${matchedImport}\` only sets derived state — replace with \`createMemo\` or derived signals.`, - }); - } + if (!bodyContainsOnlySetters(callback)) return; + if (bodyContainsSideEffects(callback)) return; + context.report({ + node, + message: `This \`${matchedImport}\` only sets derived state — replace with a derived signal (\`const x = () => expr\`) or \`createMemo\`.`, + }); }, }; }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts index e1e4a476a..d69f30bbf 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-impure-memo.ts @@ -4,6 +4,7 @@ import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { isSetterCall } from "../../utils/is-setter-call.js"; import { walkAst } from "../../utils/walk-ast.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; @@ -20,12 +21,6 @@ const SUBSCRIPTION_MEMBER_METHODS = new Set([ "observe", ]); -const isSetterCall = (node: EsTreeNode): boolean => { - if (!isNodeOfType(node, "CallExpression")) return false; - if (!isNodeOfType(node.callee, "Identifier")) return false; - return /^set[A-Z]/.test(node.callee.name); -}; - interface SideEffectInfo { description: string; node: EsTreeNode; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts index 1a44bc21d..1b59c81b6 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-resource.ts @@ -4,6 +4,7 @@ import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { isSetterCall } from "../../utils/is-setter-call.js"; import { walkAst } from "../../utils/walk-ast.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; @@ -11,13 +12,6 @@ import type { RuleContext } from "../../utils/rule-context.js"; const EFFECT_PRIMITIVES: ReadonlyArray = ["createEffect", "createRenderEffect"]; const FETCH_IDENTIFIERS = new Set(["fetch"]); -const FETCH_MEMBER_METHODS = new Set(["get", "post", "put", "patch", "delete", "request"]); - -const isSetterCall = (node: EsTreeNode): boolean => { - if (!isNodeOfType(node, "CallExpression")) return false; - if (!isNodeOfType(node.callee, "Identifier")) return false; - return /^set[A-Z]/.test(node.callee.name); -}; const containsFetchLikeCall = (node: EsTreeNode): boolean => { let found = false; @@ -29,14 +23,6 @@ const containsFetchLikeCall = (node: EsTreeNode): boolean => { found = true; return false; } - if ( - isNodeOfType(child.callee, "MemberExpression") && - isNodeOfType(child.callee.property, "Identifier") && - FETCH_MEMBER_METHODS.has(child.callee.property.name) - ) { - found = true; - return false; - } }); return found; }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts index edf3ac31c..58d433514 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-solid-rule-settings.ts @@ -9,8 +9,14 @@ export const readSolidRuleSettings = ( settingsKey: string, ): Shape => { const reactDoctorBlock = settings?.["react-doctor"]; - if (typeof reactDoctorBlock !== "object" || reactDoctorBlock === null) return {} as Shape; - const ruleBlock = (reactDoctorBlock as Record)[settingsKey]; + if ( + typeof reactDoctorBlock !== "object" || + reactDoctorBlock === null || + Array.isArray(reactDoctorBlock) + ) { + return {} as Shape; + } + const ruleBlock = Object.getOwnPropertyDescriptor(reactDoctorBlock, settingsKey)?.value; if (typeof ruleBlock !== "object" || ruleBlock === null) return {} as Shape; return ruleBlock as Shape; }; From e6dece168a699ee9a3b655f81cae5da88fa14335 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 01:28:21 -0700 Subject: [PATCH 07/18] fix(solid): add receiver check for console methods, extract shared extractStaticStringValue - bodyContainsSideEffects in solid-no-effect-derived-state now gates console method detection on `node.callee.object.name === "console"`, matching the pattern in solid-no-impure-memo. Previously `validation.error()` or `myApi.fetch()` would false-positive as side effects. - Extract duplicated extractStaticStringValue from solid-no-innerhtml and solid-jsx-no-script-url into utils/extract-static-string-value. Co-authored-by: Aiden Bai --- .../rules/solid/solid-jsx-no-script-url.ts | 22 +++------------ .../solid/solid-no-effect-derived-state.ts | 27 +++++++++++-------- .../plugin/rules/solid/solid-no-innerhtml.ts | 10 +------ .../utils/extract-static-string-value.ts | 11 ++++++++ 4 files changed, 32 insertions(+), 38 deletions(-) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/extract-static-string-value.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts index 25a708170..d99fe2ad6 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-jsx-no-script-url.ts @@ -1,19 +1,14 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { extractStaticStringValue } from "../../utils/extract-static-string-value.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; -// Mirrors the WHATWG URL parser's pre-scheme step: leading C0 -// controls and U+0020 SPACE are stripped, then ASCII tab / LF / CR -// characters inside the URL are also filtered out before the -// scheme is matched. https://url.spec.whatwg.org/#url-parsing -// -// Doing the filter in code (and keeping the regex literal free of -// control characters) avoids `eslint(no-control-regex)` warnings — -// inline `[\u0000-\u001F]` and `[\r\n\t]*` between letters would -// trip the lint at every rule-file load. +// HACK: Mirrors the WHATWG URL parser's pre-scheme step — strip C0 +// controls first, then match scheme — because embedding the C0 range +// directly in a regex literal trips `no-control-regex`. const JAVASCRIPT_SCHEME_PATTERN = /^ *javascript:/i; const isUrlControlCharacterCode = (characterCode: number): boolean => @@ -30,15 +25,6 @@ const stripUrlControlCharacters = (urlValue: string): string => { const startsWithJavascriptScheme = (urlValue: string): boolean => JAVASCRIPT_SCHEME_PATTERN.test(stripUrlControlCharacters(urlValue)); -const extractStaticStringValue = (node: EsTreeNode | null | undefined): string | null => { - if (!node) return null; - if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; - if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) { - return node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""); - } - return null; -}; - // Port of `solid/jsx-no-script-url` — flags `
` // and similar `javascript:` URLs in JSX attributes. Adapted from // `eslint-plugin-react`'s rule of the same name. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts index 3b77a2b65..d9b677356 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-effect-derived-state.ts @@ -26,6 +26,9 @@ const bodyContainsOnlySetters = (callback: EsTreeNode): boolean => { return isSetterCall(callback.body as EsTreeNode); }; +const SIDE_EFFECT_GLOBAL_CALLS = new Set(["fetch", "alert", "confirm", "prompt"]); +const CONSOLE_METHODS = new Set(["log", "warn", "error", "info", "debug"]); + const bodyContainsSideEffects = (callback: EsTreeNode): boolean => { if (!isFunctionLike(callback)) return false; let foundSideEffect = false; @@ -36,21 +39,23 @@ const bodyContainsSideEffects = (callback: EsTreeNode): boolean => { if (isSetterCall(node)) return; if (isNodeOfType(node.callee, "MemberExpression")) { const property = node.callee.property; - if (isNodeOfType(property, "Identifier")) { - const methodName = property.name; - if (["log", "warn", "error", "info", "debug", "fetch"].includes(methodName)) { - foundSideEffect = true; - return false; - } - } - } - if (isNodeOfType(node.callee, "Identifier")) { - const calleeName = node.callee.name; - if (["fetch", "alert", "confirm", "prompt"].includes(calleeName)) { + if ( + isNodeOfType(property, "Identifier") && + CONSOLE_METHODS.has(property.name) && + isNodeOfType(node.callee.object, "Identifier") && + node.callee.object.name === "console" + ) { foundSideEffect = true; return false; } } + if ( + isNodeOfType(node.callee, "Identifier") && + SIDE_EFFECT_GLOBAL_CALLS.has(node.callee.name) + ) { + foundSideEffect = true; + return false; + } } if (isNodeOfType(node, "AwaitExpression")) { foundSideEffect = true; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts index 6dbd17001..b08edfa02 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-innerhtml.ts @@ -1,6 +1,7 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { extractStaticStringValue } from "../../utils/extract-static-string-value.js"; import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import type { Rule } from "../../utils/rule.js"; @@ -19,15 +20,6 @@ const extractInnerExpression = (attribute: EsTreeNodeOfType<"JSXAttribute">): Es return attribute.value as EsTreeNode; }; -const extractStaticStringValue = (node: EsTreeNode | null): string | null => { - if (!node) return null; - if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; - if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) { - return node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""); - } - return null; -}; - // Port of `solid/no-innerhtml`. Three distinct diagnostics: // // 1. `dangerouslySetInnerHTML={...}` — always flagged. Solid does diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/extract-static-string-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/extract-static-string-value.ts new file mode 100644 index 000000000..acfd1a4ba --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/extract-static-string-value.ts @@ -0,0 +1,11 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const extractStaticStringValue = (node: EsTreeNode | null | undefined): string | null => { + if (!node) return null; + if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; + if (isNodeOfType(node, "TemplateLiteral") && node.expressions.length === 0) { + return node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""); + } + return null; +}; From f81be90cc889b61de583cadba9fdce21723f752b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 01:40:14 -0700 Subject: [PATCH 08/18] fix(solid-no-react-specific-props): gate className/htmlFor check on DOM elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The className/htmlFor check was running on all JSX elements including custom components. is valid — custom components can accept any props. Move the isDomElementName guard to cover all three prop checks (className, htmlFor, key) in a single attribute loop. Co-authored-by: Aiden Bai --- .../solid-no-react-specific-props.test.ts | 13 ++++++++++ .../solid/solid-no-react-specific-props.ts | 24 +++++++++---------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts index 1c06d1e56..0746e9352 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.test.ts @@ -32,4 +32,17 @@ describe("solid-no-react-specific-props", () => { ); expect(result.diagnostics).toHaveLength(0); }); + + it("does not flag className on a custom component", () => { + const result = runRule( + solidNoReactSpecificProps, + `const Foo = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag key on a custom component", () => { + const result = runRule(solidNoReactSpecificProps, `const Foo = () => ;`); + expect(result.diagnostics).toHaveLength(0); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts index a1998ca5b..8e1db9495 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-react-specific-props.ts @@ -26,23 +26,21 @@ export const solidNoReactSpecificProps = defineRule({ recommendation: "Use `class` instead of `className` and `for` instead of `htmlFor` in Solid JSX.", create: (context: RuleContext) => ({ JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { - for (const { reactName, solidName } of REACT_SPECIFIC_PROPS) { - for (const attribute of node.attributes) { - if (!isNodeOfType(attribute, "JSXAttribute")) continue; - if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue; - if (attribute.name.name === reactName) { - context.report({ - node: attribute, - message: `Prefer the \`${solidName}\` prop over the deprecated \`${reactName}\` prop.`, - }); - } - } - } if (!isNodeOfType(node.name, "JSXIdentifier") || !isDomElementName(node.name.name)) return; for (const attribute of node.attributes) { if (!isNodeOfType(attribute, "JSXAttribute")) continue; if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue; - if (attribute.name.name === "key") { + const attributeName = attribute.name.name; + const matchedMapping = REACT_SPECIFIC_PROPS.find( + (mapping) => mapping.reactName === attributeName, + ); + if (matchedMapping) { + context.report({ + node: attribute, + message: `Prefer the \`${matchedMapping.solidName}\` prop over the deprecated \`${matchedMapping.reactName}\` prop.`, + }); + } + if (attributeName === "key") { context.report({ node: attribute, message: "Elements in a or list do not need a `key` prop in Solid.", From 7bc57fb6b0cbbd595463aff05b9fad591193e0e3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 02:06:48 -0700 Subject: [PATCH 09/18] feat(solid): add 7 new docs/community-derived rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New rules derived from SolidJS docs, GitHub discussions, and issues: - solid-no-signal-from-prop: flags createSignal(props.value) — the #1 React migration trap, reads prop once and never updates - solid-no-async-effect: flags async callbacks in createEffect/ createRenderEffect/createComputed — tracking scope lost after await - solid-no-cleanup-after-await: flags onCleanup() after await in async effects/resources — owner context lost, cleanup silently ignored - solid-no-onmount-cleanup-return: flags returning a cleanup function from onMount — unlike React's useEffect, Solid ignores the return - solid-no-store-direct-mutation: flags direct property assignment on createStore proxies — must use setStore() or produce() - solid-no-props-assignment: flags const x = props.x in component bodies — captures value once, breaks Solid reactivity - solid-no-async-tracked-scope: flags signal reads inside setTimeout/ setInterval/requestAnimationFrame within reactive scopes 102 new test cases across 7 test files. All rules gated by requires: ["solid"] so they only fire in Solid projects. Co-authored-by: Aiden Bai --- .../src/plugin/rule-registry.ts | 91 ++++++++ .../rules/solid/solid-no-async-effect.test.ts | 163 ++++++++++++++ .../rules/solid/solid-no-async-effect.ts | 64 ++++++ .../solid-no-async-tracked-scope.test.ts | 155 +++++++++++++ .../solid/solid-no-async-tracked-scope.ts | 88 ++++++++ .../solid-no-cleanup-after-await.test.ts | 211 ++++++++++++++++++ .../solid/solid-no-cleanup-after-await.ts | 120 ++++++++++ .../solid-no-onmount-cleanup-return.test.ts | 114 ++++++++++ .../solid/solid-no-onmount-cleanup-return.ts | 61 +++++ .../solid/solid-no-props-assignment.test.ts | 141 ++++++++++++ .../rules/solid/solid-no-props-assignment.ts | 89 ++++++++ .../solid/solid-no-signal-from-prop.test.ts | 163 ++++++++++++++ .../rules/solid/solid-no-signal-from-prop.ts | 90 ++++++++ .../solid-no-store-direct-mutation.test.ts | 156 +++++++++++++ .../solid/solid-no-store-direct-mutation.ts | 55 +++++ 15 files changed, 1761 insertions(+) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-props-assignment.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-props-assignment.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-from-prop.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-signal-from-prop.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-store-direct-mutation.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-store-direct-mutation.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 2267c78ff..c81691cdb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -283,15 +283,22 @@ import { solidImports } from "./rules/solid/solid-imports.js"; import { solidJsxNoDuplicateProps } from "./rules/solid/solid-jsx-no-duplicate-props.js"; import { solidJsxNoScriptUrl } from "./rules/solid/solid-jsx-no-script-url.js"; import { solidNoArrayHandlers } from "./rules/solid/solid-no-array-handlers.js"; +import { solidNoAsyncEffect } from "./rules/solid/solid-no-async-effect.js"; +import { solidNoAsyncTrackedScope } from "./rules/solid/solid-no-async-tracked-scope.js"; +import { solidNoCleanupAfterAwait } from "./rules/solid/solid-no-cleanup-after-await.js"; import { solidNoDestructure } from "./rules/solid/solid-no-destructure.js"; import { solidNoEffectDerivedState } from "./rules/solid/solid-no-effect-derived-state.js"; import { solidNoImpureMemo } from "./rules/solid/solid-no-impure-memo.js"; import { solidNoInnerHtml } from "./rules/solid/solid-no-innerhtml.js"; +import { solidNoOnmountCleanupReturn } from "./rules/solid/solid-no-onmount-cleanup-return.js"; +import { solidNoPropsAssignment } from "./rules/solid/solid-no-props-assignment.js"; import { solidNoProviderValueRead } from "./rules/solid/solid-no-provider-value-read.js"; import { solidNoProxyApis } from "./rules/solid/solid-no-proxy-apis.js"; import { solidNoReactDeps } from "./rules/solid/solid-no-react-deps.js"; import { solidNoReactSpecificProps } from "./rules/solid/solid-no-react-specific-props.js"; +import { solidNoSignalFromProp } from "./rules/solid/solid-no-signal-from-prop.js"; import { solidNoSignalMutation } from "./rules/solid/solid-no-signal-mutation.js"; +import { solidNoStoreDirectMutation } from "./rules/solid/solid-no-store-direct-mutation.js"; import { solidNoUnknownNamespaces } from "./rules/solid/solid-no-unknown-namespaces.js"; import { solidPreferChildrenHelper } from "./rules/solid/solid-prefer-children-helper.js"; import { solidPreferClasslist } from "./rules/solid/solid-prefer-classlist.js"; @@ -3386,6 +3393,42 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoArrayHandlers.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-async-effect", + id: "solid-no-async-effect", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoAsyncEffect, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoAsyncEffect.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-async-tracked-scope", + id: "solid-no-async-tracked-scope", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoAsyncTrackedScope, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoAsyncTrackedScope.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-cleanup-after-await", + id: "solid-no-cleanup-after-await", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoCleanupAfterAwait, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoCleanupAfterAwait.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-destructure", id: "solid-no-destructure", @@ -3434,6 +3477,30 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoInnerHtml.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-onmount-cleanup-return", + id: "solid-no-onmount-cleanup-return", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoOnmountCleanupReturn, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoOnmountCleanupReturn.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-no-props-assignment", + id: "solid-no-props-assignment", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoPropsAssignment, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoPropsAssignment.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-provider-value-read", id: "solid-no-provider-value-read", @@ -3482,6 +3549,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoReactSpecificProps.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-signal-from-prop", + id: "solid-no-signal-from-prop", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoSignalFromProp, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoSignalFromProp.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-signal-mutation", id: "solid-no-signal-mutation", @@ -3494,6 +3573,18 @@ export const reactDoctorRules = [ tags: [...new Set(["solid", ...(solidNoSignalMutation.tags ?? [])])], }, }, + { + key: "react-doctor/solid-no-store-direct-mutation", + id: "solid-no-store-direct-mutation", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoStoreDirectMutation, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidNoStoreDirectMutation.tags ?? [])])], + }, + }, { key: "react-doctor/solid-no-unknown-namespaces", id: "solid-no-unknown-namespaces", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.test.ts new file mode 100644 index 000000000..7e43748e6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoAsyncEffect } from "./solid-no-async-effect.js"; + +describe("solid-no-async-effect", () => { + it("flags async arrow in createEffect", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(async () => { + const data = await fetchData(count()); + setResult(data); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + expect(result.diagnostics[0].message).toContain("tracking scope"); + }); + + it("flags async function expression in createEffect", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(async function() { + await something(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); + + it("flags async arrow in createRenderEffect", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createRenderEffect } from "solid-js"; + createRenderEffect(async () => { + await loadStyles(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createRenderEffect"); + }); + + it("flags async arrow in createComputed", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createComputed } from "solid-js"; + createComputed(async () => { + const v = await compute(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createComputed"); + }); + + it("flags aliased import", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect as fx } from "solid-js"; + fx(async () => { + await x(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); + + it("does not flag synchronous createEffect", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(() => { + console.log(count()); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag .then() inside synchronous callback", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(() => { + fetchData(count()).then(setResult); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createResource with async fetcher", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createResource } from "solid-js"; + const [data] = createResource(count, async (c) => { + return await fetchData(c); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onMount with async callback", () => { + const result = runRule( + solidNoAsyncEffect, + `import { onMount } from "solid-js"; + onMount(async () => { + await loadData(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag async inside a nested function in the callback", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(() => { + const load = async () => { + await x(); + }; + load(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without solid-js import", () => { + const result = runRule( + solidNoAsyncEffect, + `createEffect(async () => { + await doSomething(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags multiple async effects in the same file", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect, createComputed } from "solid-js"; + createEffect(async () => { await a(); }); + createComputed(async () => { await b(); });`, + ); + expect(result.diagnostics).toHaveLength(2); + }); + + it("does not flag createEffect with no arguments", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect();`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createEffect with a non-function argument", () => { + const result = runRule( + solidNoAsyncEffect, + `import { createEffect } from "solid-js"; + createEffect(someVariable);`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.ts new file mode 100644 index 000000000..51c08751f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-effect.ts @@ -0,0 +1,64 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EFFECT_PRIMITIVES: ReadonlyArray = [ + "createEffect", + "createRenderEffect", + "createComputed", +]; + +const containsAwaitExpression = ( + callback: EsTreeNodeOfType<"ArrowFunctionExpression"> | EsTreeNodeOfType<"FunctionExpression">, +): boolean => { + let found = false; + walkAst(callback, (node) => { + if (found) return false; + if (isFunctionLike(node) && node !== callback) return false; + if (isNodeOfType(node, "AwaitExpression")) { + found = true; + return false; + } + }); + return found; +}; + +export const solidNoAsyncEffect = defineRule({ + id: "solid-no-async-effect", + severity: "error", + requires: ["solid"], + recommendation: + "After the first `await`, the tracking scope is lost — signals read after the await are NOT tracked and cleanup semantics break. Use `createResource` for async data fetching, or call the async function inside a synchronous effect.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedPrimitive = importTracker.matchImport(EFFECT_PRIMITIVES, node.callee.name); + if (!matchedPrimitive) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + if (isNodeOfType(callback, "FunctionDeclaration")) return; + + const isAsync = Boolean(callback.async); + const hasAwait = containsAwaitExpression(callback); + + if (isAsync || hasAwait) { + context.report({ + node, + message: `\`${matchedPrimitive}\` should not receive an async callback — after the first \`await\`, Solid's tracking scope is lost and signals read afterward won't be tracked.`, + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.test.ts new file mode 100644 index 000000000..be5783bd4 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoAsyncTrackedScope } from "./solid-no-async-tracked-scope.js"; + +describe("solid-no-async-tracked-scope", () => { + it("flags signal read inside setTimeout within createEffect", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + createEffect(() => { setTimeout(() => { console.log(count()); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("setTimeout"); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); + + it("flags signal read inside setInterval within createEffect", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [value] = createSignal("x"); + createEffect(() => { setInterval(() => { doSomething(value()); }, 500); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("setInterval"); + }); + + it("flags signal read inside requestAnimationFrame within createEffect", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [pos] = createSignal(0); + createEffect(() => { requestAnimationFrame(() => { updateCanvas(pos()); }); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("requestAnimationFrame"); + }); + + it("flags signal read inside setTimeout within createMemo", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createMemo, createSignal } from "solid-js"; + const [x] = createSignal(0); + const derived = createMemo(() => { let result = 0; setTimeout(() => { result = x(); }, 0); return result; });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createMemo"); + }); + + it("flags signal read inside setTimeout within createComputed", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createComputed, createSignal } from "solid-js"; + const [name] = createSignal("hello"); + createComputed(() => { setTimeout(() => { console.log(name()); }, 100); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createComputed"); + }); + + it("flags signal read inside setTimeout within createRenderEffect", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createRenderEffect, createSignal } from "solid-js"; + const [color] = createSignal("red"); + createRenderEffect(() => { setTimeout(() => { el.style.color = color(); }, 0); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createRenderEffect"); + }); + + it("does not flag synchronous signal reads in reactive scope", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [count] = createSignal(0); + createEffect(() => { console.log(count()); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag captured signal value passed into setTimeout", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [count] = createSignal(0); + createEffect(() => { const c = count(); setTimeout(() => { console.log(c); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onMount with setTimeout", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { onMount, createSignal } from "solid-js"; + const [count] = createSignal(0); + onMount(() => { setTimeout(() => { console.log(count()); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag setTimeout outside any reactive scope", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createSignal } from "solid-js"; + const [count] = createSignal(0); + setTimeout(() => { console.log(count()); }, 100);`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without solid-js import", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `const [count] = createSignal(0); + createEffect(() => { setTimeout(() => { console.log(count()); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags multiple scheduler calls in one reactive scope", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect, createSignal } from "solid-js"; + const [a] = createSignal(0); + const [b] = createSignal(1); + createEffect(() => { + setTimeout(() => { console.log(a()); }, 100); + setInterval(() => { console.log(b()); }, 200); + });`, + ); + expect(result.diagnostics).toHaveLength(2); + }); + + it("does not flag setTimeout callback with no signal reads", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect } from "solid-js"; + createEffect(() => { setTimeout(() => { console.log("hello"); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("handles aliased imports", () => { + const result = runRule( + solidNoAsyncTrackedScope, + `import { createEffect as eff, createSignal } from "solid-js"; + const [count] = createSignal(0); + eff(() => { setTimeout(() => { console.log(count()); }, 1000); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.ts new file mode 100644 index 000000000..2f255e9d5 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-async-tracked-scope.ts @@ -0,0 +1,88 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const REACTIVE_PRIMITIVES: ReadonlyArray = [ + "createEffect", + "createMemo", + "createComputed", + "createRenderEffect", +]; + +const ASYNC_SCHEDULER_NAMES = new Set(["setTimeout", "setInterval", "requestAnimationFrame"]); + +const isZeroArgIdentifierCall = (node: EsTreeNode): boolean => + isNodeOfType(node, "CallExpression") && + isNodeOfType(node.callee, "Identifier") && + node.arguments.length === 0; + +const containsSignalRead = (node: EsTreeNode): boolean => { + let found = false; + walkAst(node, (child) => { + if (found) return false; + if (isZeroArgIdentifierCall(child)) { + found = true; + return false; + } + }); + return found; +}; + +interface AsyncSignalRead { + schedulerName: string; + node: EsTreeNode; +} + +const findAsyncSignalReads = (callback: EsTreeNode): ReadonlyArray => { + const results: AsyncSignalRead[] = []; + walkAst(callback, (node) => { + if (isFunctionLike(node) && node !== callback) return false; + if (!isNodeOfType(node, "CallExpression")) return; + if (!isNodeOfType(node.callee, "Identifier")) return; + if (!ASYNC_SCHEDULER_NAMES.has(node.callee.name)) return; + const schedulerName = node.callee.name; + const firstArgument = node.arguments[0]; + if (!firstArgument || !isFunctionLike(firstArgument)) return; + if (containsSignalRead(firstArgument)) { + results.push({ schedulerName, node }); + } + }); + return results; +}; + +export const solidNoAsyncTrackedScope = defineRule({ + id: "solid-no-async-tracked-scope", + severity: "warn", + requires: ["solid"], + recommendation: + "Read signals synchronously before the async boundary — capture the value in a variable, then use it inside the callback.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + const matchedImport = importTracker.matchImport(REACTIVE_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0]; + if (!isFunctionLike(callback)) return; + const asyncSignalReads = findAsyncSignalReads(callback); + for (const { schedulerName, node: schedulerNode } of asyncSignalReads) { + context.report({ + node: schedulerNode, + message: `Signal read inside \`${schedulerName}\` callback within \`${matchedImport}\` — Solid's tracking is synchronous, so this read won't be tracked. Read the signal before the \`${schedulerName}\` call instead.`, + }); + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.test.ts new file mode 100644 index 000000000..1dfd2e00e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoCleanupAfterAwait } from "./solid-no-cleanup-after-await.js"; + +describe("solid-no-cleanup-after-await", () => { + it("flags onCleanup after await in createEffect", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(async () => { + await fetch("/api"); + onCleanup(() => console.log("cleanup")); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + expect(result.diagnostics[0].message).toContain("after"); + }); + + it("flags onCleanup after await in createResource fetcher", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createResource, onCleanup } from "solid-js"; + const [data] = createResource(signal, async (s) => { + const ctrl = new AbortController(); + const res = await fetch("/url", { signal: ctrl.signal }); + onCleanup(() => ctrl.abort()); + return res.json(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createResource"); + }); + + it("flags onCleanup after await in createRenderEffect", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createRenderEffect, onCleanup } from "solid-js"; + createRenderEffect(async () => { + const x = await loadThing(); + onCleanup(() => x.dispose()); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createRenderEffect"); + }); + + it("flags onCleanup after await in createComputed", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createComputed, onCleanup } from "solid-js"; + createComputed(async () => { + await someWork(); + onCleanup(() => {}); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createComputed"); + }); + + it("does not flag onCleanup before await in createEffect", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(async () => { + onCleanup(() => console.log("cleanup")); + await fetch("/api"); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onCleanup before await in createResource", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createResource, onCleanup } from "solid-js"; + const [data] = createResource(signal, async (s) => { + const ctrl = new AbortController(); + onCleanup(() => ctrl.abort()); + const res = await fetch("/url", { signal: ctrl.signal }); + return res.json(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag synchronous createEffect with onCleanup", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(() => { + const id = setInterval(tick, 1000); + onCleanup(() => clearInterval(id)); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onCleanup at component top level", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { onCleanup } from "solid-js"; + const Comp = () => { onCleanup(() => {}); return
; };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag without solid-js import", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `createEffect(async () => { + await fetch("/api"); + onCleanup(() => {}); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags multiple onCleanup calls after await", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(async () => { + await fetch("/api"); + onCleanup(() => console.log("a")); + onCleanup(() => console.log("b")); + });`, + ); + expect(result.diagnostics).toHaveLength(2); + }); + + it("flags only the onCleanup after await when one is before and one after", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(async () => { + onCleanup(() => console.log("safe")); + await fetch("/api"); + onCleanup(() => console.log("broken")); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("after"); + }); + + it("does not flag onCleanup inside a nested non-async function after await", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect, onCleanup } from "solid-js"; + createEffect(async () => { + await fetch("/api"); + const helper = () => { onCleanup(() => {}); }; + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("handles aliased imports", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createEffect as eff, onCleanup as cleanup } from "solid-js"; + eff(async () => { + await fetch("/api"); + cleanup(() => {}); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createEffect"); + }); + + it("does not flag async function expression that is not a callback to a primitive", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { onCleanup } from "solid-js"; + const run = async () => { + await fetch("/api"); + onCleanup(() => {}); + };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags onCleanup after await in single-arg createResource (no source signal)", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createResource, onCleanup } from "solid-js"; + const [data] = createResource(async () => { + const ctrl = new AbortController(); + const res = await fetch("/api", { signal: ctrl.signal }); + onCleanup(() => ctrl.abort()); + return res.json(); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("createResource"); + }); + + it("does not flag onCleanup before await in single-arg createResource", () => { + const result = runRule( + solidNoCleanupAfterAwait, + `import { createResource, onCleanup } from "solid-js"; + const [data] = createResource(async () => { + const ctrl = new AbortController(); + onCleanup(() => ctrl.abort()); + const res = await fetch("/api", { signal: ctrl.signal }); + return res.json(); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.ts new file mode 100644 index 000000000..ac12d6737 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-cleanup-after-await.ts @@ -0,0 +1,120 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const EFFECT_PRIMITIVES: ReadonlyArray = [ + "createEffect", + "createRenderEffect", + "createComputed", + "createResource", +]; + +const CLEANUP_NAMES: ReadonlyArray = ["onCleanup"]; + +interface OrderedNode { + node: EsTreeNode; + visitOrder: number; +} + +const collectAwaitAndCleanupPositions = ( + functionBody: EsTreeNode, + cleanupLocalNames: ReadonlySet, +): { awaits: ReadonlyArray; cleanups: ReadonlyArray } => { + const awaits: OrderedNode[] = []; + const cleanups: OrderedNode[] = []; + let visitCounter = 0; + + walkAst(functionBody, (node) => { + if (isFunctionLike(node) && node !== functionBody) return false; + const currentOrder = visitCounter++; + + if (isNodeOfType(node, "AwaitExpression")) { + awaits.push({ node, visitOrder: currentOrder }); + } + + if (isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier")) { + if (cleanupLocalNames.has(node.callee.name)) { + cleanups.push({ node, visitOrder: currentOrder }); + } + } + }); + + return { awaits, cleanups }; +}; + +const getCallbackForPrimitive = ( + primitiveName: string, + callNode: EsTreeNodeOfType<"CallExpression">, +): EsTreeNode | undefined => { + if (primitiveName === "createResource") { + if (callNode.arguments.length >= 2 && isFunctionLike(callNode.arguments[1])) { + return callNode.arguments[1]; + } + if (callNode.arguments.length >= 1 && isFunctionLike(callNode.arguments[0])) { + return callNode.arguments[0]; + } + return undefined; + } + return callNode.arguments.length >= 1 ? callNode.arguments[0] : undefined; +}; + +export const solidNoCleanupAfterAwait = defineRule({ + id: "solid-no-cleanup-after-await", + severity: "error", + requires: ["solid"], + recommendation: + "Move `onCleanup` before any `await` — after an await the synchronous owner context is lost, so `onCleanup` silently does nothing.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + const cleanupLocalNames = new Set(); + + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + + const source = node.source?.value; + if (typeof source !== "string" || !/^solid-js/.test(source)) return; + + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ImportSpecifier")) continue; + const importedIdentifier = specifier.imported; + if (!isNodeOfType(importedIdentifier, "Identifier")) continue; + if (CLEANUP_NAMES.includes(importedIdentifier.name)) { + cleanupLocalNames.add(specifier.local.name); + } + } + }, + + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + + const matchedImport = importTracker.matchImport(EFFECT_PRIMITIVES, node.callee.name); + if (!matchedImport) return; + + const callback = getCallbackForPrimitive(matchedImport, node); + if (!callback || !isFunctionLike(callback)) return; + if (!callback.async) return; + + const { awaits, cleanups } = collectAwaitAndCleanupPositions(callback, cleanupLocalNames); + if (awaits.length === 0 || cleanups.length === 0) return; + + const earliestAwaitOrder = Math.min(...awaits.map((ordered) => ordered.visitOrder)); + + for (const cleanup of cleanups) { + if (cleanup.visitOrder > earliestAwaitOrder) { + context.report({ + node: cleanup.node, + message: `\`onCleanup\` called after \`await\` inside \`${matchedImport}\` — the synchronous owner context is lost after an await, so this cleanup handler will never run. Move it before the first \`await\`.`, + }); + } + } + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.test.ts new file mode 100644 index 000000000..174234a64 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoOnmountCleanupReturn } from "./solid-no-onmount-cleanup-return.js"; + +describe("solid-no-onmount-cleanup-return", () => { + it("flags returning an arrow cleanup function from onMount", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("onCleanup"); + }); + + it("flags returning a function expression from onMount", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { document.addEventListener("click", handler); return function() { document.removeEventListener("click", handler); }; });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("onCleanup"); + }); + + it("flags returning a subscription cleanup from onMount", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { const sub = observable.subscribe(handler); return () => sub.unsubscribe(); });`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags returning a teardown arrow from onMount", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { setup(); return () => teardown(); });`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag onMount with onCleanup and no return", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount, onCleanup } from "solid-js"; + onMount(() => { const id = setInterval(tick, 1000); onCleanup(() => clearInterval(id)); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onMount without a return statement", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { console.log("mounted"); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag returning a non-function value", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { return 42; });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag returning a string value", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { return "done"; });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag createEffect with cleanup return", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { createEffect } from "solid-js"; + createEffect(() => { return () => cleanup(); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onMount without solid-js import", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `onMount(() => { return () => cleanup(); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag onMount with fetchData call only", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { fetchData(); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores return of a function in a nested function inside onMount", () => { + const result = runRule( + solidNoOnmountCleanupReturn, + `import { onMount } from "solid-js"; + onMount(() => { const helper = () => { return () => cleanup(); }; helper(); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.ts new file mode 100644 index 000000000..908398a84 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-onmount-cleanup-return.ts @@ -0,0 +1,61 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const ONMOUNT_NAMES: ReadonlyArray = ["onMount"]; + +const returnsFunction = (callback: EsTreeNode): boolean => { + if (isNodeOfType(callback, "ArrowFunctionExpression") && callback.expression) { + const body = callback.body as EsTreeNode; + return isFunctionLike(body); + } + + let found = false; + walkAst(callback, (node) => { + if (found) return false; + if (isFunctionLike(node) && node !== callback) return false; + if (isNodeOfType(node, "ReturnStatement") && node.argument) { + const argument = node.argument as EsTreeNode; + if (isFunctionLike(argument)) { + found = true; + return false; + } + } + }); + return found; +}; + +export const solidNoOnmountCleanupReturn = defineRule({ + id: "solid-no-onmount-cleanup-return", + severity: "error", + requires: ["solid"], + recommendation: + "Returning a cleanup function from `onMount` does nothing — unlike React's `useEffect`, Solid ignores the return value. Use `onCleanup()` instead.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isNodeOfType(node.callee, "Identifier")) return; + if (!importTracker.matchImport(ONMOUNT_NAMES, node.callee.name)) return; + if (node.arguments.length < 1) return; + const callback = node.arguments[0] as EsTreeNode; + if (!isFunctionLike(callback)) return; + if (!returnsFunction(callback)) return; + context.report({ + node, + message: + "Returning a cleanup function from `onMount` has no effect — Solid ignores the return value. Register cleanup with `onCleanup(() => …)` instead.", + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-props-assignment.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-props-assignment.test.ts new file mode 100644 index 000000000..942e2ba6b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-props-assignment.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidNoPropsAssignment } from "./solid-no-props-assignment.js"; + +describe("solid-no-props-assignment", () => { + it("flags direct prop assignment in function component", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const name = props.name; return
{name}
; }`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("props.name"); + expect(result.diagnostics[0].message).toContain("breaks Solid reactivity"); + }); + + it("flags prop assignment in arrow component", () => { + const result = runRule( + solidNoPropsAssignment, + `const Comp = (props) => { const value = props.value; return {value}; };`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("props.value"); + }); + + it("flags multiple prop assignments", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const name = props.name; const age = props.age; return
{name}{age}
; }`, + ); + expect(result.diagnostics).toHaveLength(2); + }); + + it("flags nested prop access like props.user.name", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const name = props.user.name; return
{name}
; }`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("props.user.name"); + }); + + it("flags prop used in a derived calculation", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const count = props.count; const doubled = count * 2; return
{doubled}
; }`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("props.count"); + }); + + it("does not flag accessor wrapping via arrow function", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const name = () => props.name; return
{name()}
; }`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag direct JSX usage of props", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { return
{props.name}
; }`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag prop access inside a nested function", () => { + const result = runRule( + solidNoPropsAssignment, + `function Comp(props) { const handler = () => { const v = props.value; doSomething(v); }; return ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("batch / produce sync callbacks", () => { + it("does not flag signal used in batch callback", () => { + const result = runRule( + solidReactivity, + `import { createSignal, batch } from "solid-js"; + const [count, setCount] = createSignal(0); + const [doubled, setDoubled] = createSignal(0); + createEffect(() => { + batch(() => { + setCount(1); + setDoubled(count() * 2); + }); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("on() helper", () => { + it("does not flag signal passed to on() first arg", () => { + const result = runRule( + solidReactivity, + `import { createSignal, createEffect, on } from "solid-js"; + const [count, setCount] = createSignal(0); + createEffect(on(count, (value) => { + console.log(value); + }));`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-reactivity.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-reactivity.ts new file mode 100644 index 000000000..4028d50b5 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-reactivity.ts @@ -0,0 +1,936 @@ +import { createSolidImportTracker } from "../../utils/create-solid-import-tracker.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isDomElementName } from "../../utils/is-dom-element-name.js"; +import { isFunctionLike } from "../../utils/is-function-like.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { readSolidRuleSettings } from "../../utils/read-solid-rule-settings.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; +import type { ReferenceDescriptor, SymbolDescriptor } from "../../semantic/scope-analysis.js"; + +type TrackedExpect = "function" | "called-function" | "expression"; + +interface TrackedScope { + node: EsTreeNode; + expect: TrackedExpect; +} + +interface ReactiveVariable { + symbol: SymbolDescriptor; + declarationScope: EsTreeNode; +} + +interface ScopeStackItem { + node: EsTreeNode; + trackedScopes: TrackedScope[]; + hasJsx: boolean; + unnamedDerivedSignals: Set; +} + +interface ReactivitySettings { + customReactiveFunctions?: ReadonlyArray; +} + +const PROPS_NAME_PATTERN = /[pP]rops/; + +const ARITHMETIC_AND_COMPARISON_OPERATORS = new Set([ + "<", + "<=", + ">", + ">=", + "<<", + ">>", + ">>>", + "+", + "-", + "*", + "/", + "%", + "**", + "|", + "^", + "&", + "in", +]); + +const UNARY_COERCE_OPERATORS = new Set(["-", "+", "~"]); + +const TRACKED_EFFECT_PRIMITIVES: ReadonlyArray = [ + "createMemo", + "children", + "createEffect", + "createRenderEffect", + "createDeferred", + "createComputed", + "createSelector", + "untrack", + "mapArray", + "indexArray", + "observable", +]; + +const CALLED_FUNCTION_PRIMITIVES: ReadonlyArray = ["onMount", "onCleanup", "onError"]; + +const BROWSER_TIMER_FUNCTIONS = new Set([ + "setInterval", + "setTimeout", + "setImmediate", + "requestAnimationFrame", + "requestIdleCallback", +]); + +const SYNC_CALLBACK_ARRAY_METHODS = + /^(?:forEach|map|flatMap|reduce|reduceRight|find|findIndex|filter|every|some)$/; + +const isPropsByName = (name: string): boolean => PROPS_NAME_PATTERN.test(name); + +const isProgramOrFunctionLike = (node: EsTreeNode | null | undefined): boolean => + Boolean(node && (node.type === "Program" || isFunctionLike(node))); + +const findParent = ( + node: EsTreeNode, + predicate: (ancestor: EsTreeNode) => boolean, +): EsTreeNode | null => { + let current: EsTreeNode | null | undefined = node.parent; + while (current) { + if (predicate(current)) return current; + current = current.parent; + } + return null; +}; + +const findInScope = ( + node: EsTreeNode, + scopeNode: EsTreeNode, + predicate: (candidate: EsTreeNode) => boolean, +): EsTreeNode | null => { + let current: EsTreeNode | null | undefined = node; + while (current) { + if (current === scopeNode) return predicate(node) ? current : null; + if (predicate(current)) return current; + current = current.parent; + } + return null; +}; + +const ignoreTransparentWrappers = (node: EsTreeNode, upward = false): EsTreeNode => { + if ( + node.type === "TSAsExpression" || + node.type === "TSNonNullExpression" || + node.type === "TSSatisfiesExpression" + ) { + const next = upward ? node.parent : (node as { expression?: EsTreeNode }).expression; + if (next) return ignoreTransparentWrappers(next as EsTreeNode, upward); + } + return node; +}; + +const getFunctionName = (node: EsTreeNode): string | null => { + if ( + (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "FunctionExpression")) && + node.id + ) { + return node.id.name; + } + if (node.parent?.type === "VariableDeclarator") { + const declarator = node.parent as EsTreeNodeOfType<"VariableDeclarator">; + if (isNodeOfType(declarator.id, "Identifier")) return declarator.id.name; + } + return null; +}; + +const isJsxElementOrFragment = (node: EsTreeNode | null | undefined): boolean => + Boolean(node && (node.type === "JSXElement" || node.type === "JSXFragment")); + +const traceIdentifierToValue = (identifier: EsTreeNode, context: RuleContext): EsTreeNode => { + let current = identifier; + const visited = new Set(); + while (isNodeOfType(current, "Identifier") && !visited.has(current)) { + visited.add(current); + const symbol = context.scopes.symbolFor(current); + if (!symbol) break; + if (!isNodeOfType(symbol.declarationNode, "VariableDeclarator")) break; + const declarator = symbol.declarationNode; + if (!isNodeOfType(declarator.id, "Identifier") || !declarator.init) break; + if (symbol.kind !== "const") { + const allReadsOnly = symbol.references.every( + (reference) => + reference.flag === "read" || reference.identifier === symbol.bindingIdentifier, + ); + if (!allReadsOnly) break; + } + current = declarator.init as EsTreeNode; + } + return current; +}; + +export const solidReactivity = defineRule({ + id: "solid-reactivity", + severity: "warn", + requires: ["solid"], + recommendation: + "Ensure reactive values (signals, memos, props) are used within tracked scopes (JSX, createEffect, event handlers) and signals are called as functions, so changes are properly tracked by Solid's reactivity system.", + create: (context: RuleContext) => { + const importTracker = createSolidImportTracker(); + const settings = readSolidRuleSettings(context.settings, "reactivity"); + const customReactiveFunctions = settings.customReactiveFunctions ?? []; + + const scopeStack: ScopeStackItem[] = []; + const signalVariables: ReactiveVariable[] = []; + const propsVariables: ReactiveVariable[] = []; + const syncCallbacks = new Set(); + + const currentScope = (): ScopeStackItem => scopeStack[scopeStack.length - 1]; + const parentScope = (): ScopeStackItem | undefined => scopeStack[scopeStack.length - 2]; + + const pushSignal = (symbol: SymbolDescriptor, declarationScope?: EsTreeNode): void => { + const scope = declarationScope ?? currentScope().node; + if (!signalVariables.some((existing) => existing.symbol === symbol)) { + signalVariables.push({ symbol, declarationScope: scope }); + } + }; + + const pushProps = (symbol: SymbolDescriptor, declarationScope?: EsTreeNode): void => { + const scope = declarationScope ?? currentScope().node; + if (!propsVariables.some((existing) => existing.symbol === symbol)) { + propsVariables.push({ symbol, declarationScope: scope }); + } + }; + + const isRefInCurrentScope = (reference: ReferenceDescriptor): boolean => { + let parentFunction = findParent(reference.identifier, (ancestor) => + isProgramOrFunctionLike(ancestor), + ); + while ( + parentFunction && + isFunctionLike(parentFunction) && + syncCallbacks.has(parentFunction) + ) { + parentFunction = findParent(parentFunction, (ancestor) => + isProgramOrFunctionLike(ancestor), + ); + } + return parentFunction === currentScope().node; + }; + + const matchTrackedScope = (trackedScope: TrackedScope, node: EsTreeNode): boolean => { + switch (trackedScope.expect) { + case "function": + case "called-function": + return node === trackedScope.node; + case "expression": + return Boolean( + findInScope(node, currentScope().node, (candidate) => candidate === trackedScope.node), + ); + } + }; + + const handleTrackedScopes = (identifier: EsTreeNode, declarationScope: EsTreeNode): void => { + const currentScopeNode = currentScope().node; + const isDirectlyTracked = currentScope().trackedScopes.find((trackedScope) => + matchTrackedScope(trackedScope, identifier), + ); + if (isDirectlyTracked) return; + + const matchedExpression = currentScope().trackedScopes.find((trackedScope) => + matchTrackedScope({ ...trackedScope, expect: "expression" }, identifier), + ); + + if (declarationScope === currentScopeNode) { + let outerMemberExpression: EsTreeNode | null = null; + if (identifier.parent?.type === "MemberExpression") { + outerMemberExpression = identifier.parent as EsTreeNode; + while (outerMemberExpression?.parent?.type === "MemberExpression") { + outerMemberExpression = outerMemberExpression.parent as EsTreeNode; + } + } + const parentCallExpression = + identifier.parent?.type === "CallExpression" ? (identifier.parent as EsTreeNode) : null; + const reportNode = outerMemberExpression ?? parentCallExpression ?? identifier; + const reportName = isNodeOfType(identifier, "Identifier") ? identifier.name : "value"; + + context.report({ + node: reportNode, + message: matchedExpression + ? `The reactive variable '${reportName}' should be wrapped in a function for reactivity. This includes event handler bindings on native elements, which are not reactive like other JSX props.` + : `The reactive variable '${reportName}' should be used within JSX, a tracked scope (like createEffect), or inside an event handler function, or else changes will be ignored.`, + }); + } else { + if (!parentScope() || !isFunctionLike(currentScopeNode)) return; + const pushUnnamedDerived = (): void => { + parentScope()!.unnamedDerivedSignals.add(currentScopeNode); + }; + if (isNodeOfType(currentScopeNode, "FunctionDeclaration") && currentScopeNode.id) { + const functionSymbol = context.scopes.symbolFor(currentScopeNode.id as EsTreeNode); + if (functionSymbol) { + pushSignal(functionSymbol, declarationScope); + } else { + pushUnnamedDerived(); + } + } else if (currentScopeNode.parent?.type === "VariableDeclarator") { + const declarator = currentScopeNode.parent as EsTreeNodeOfType<"VariableDeclarator">; + if (isNodeOfType(declarator.id, "Identifier")) { + const variableSymbol = context.scopes.symbolFor(declarator.id); + if (variableSymbol) { + pushSignal(variableSymbol, declarationScope); + } else { + pushUnnamedDerived(); + } + } else { + pushUnnamedDerived(); + } + } else if (currentScopeNode.parent?.type === "Property") { + // HACK: object method pattern — skip silently + } else { + pushUnnamedDerived(); + } + } + }; + + const getReferencesInCurrentScope = ( + reactiveVariables: ReactiveVariable[], + ): Array<{ + reference: ReferenceDescriptor; + declarationScope: EsTreeNode; + }> => { + const result: Array<{ + reference: ReferenceDescriptor; + declarationScope: EsTreeNode; + }> = []; + for (const reactiveVariable of reactiveVariables) { + for (const reference of reactiveVariable.symbol.references) { + if (reference.identifier === reactiveVariable.symbol.bindingIdentifier) continue; + if (isRefInCurrentScope(reference)) { + result.push({ + reference, + declarationScope: reactiveVariable.declarationScope, + }); + } + } + } + return result; + }; + + const markPropsOnCondition = ( + node: EsTreeNode, + condition: (propsParam: EsTreeNodeOfType<"Identifier">) => boolean, + ): void => { + if (!isFunctionLike(node)) return; + const functionNode = node as + | EsTreeNodeOfType<"ArrowFunctionExpression"> + | EsTreeNodeOfType<"FunctionExpression"> + | EsTreeNodeOfType<"FunctionDeclaration">; + if (functionNode.params.length !== 1) return; + const firstParam = functionNode.params[0]; + if (!isNodeOfType(firstParam, "Identifier")) return; + if (node.parent?.type === "JSXExpressionContainer") return; + if (node.parent?.type === "TemplateLiteral") return; + if (!condition(firstParam)) return; + const propsSymbol = context.scopes.symbolFor(firstParam); + if (propsSymbol) pushProps(propsSymbol, node); + }; + + const onFunctionEnter = (node: EsTreeNode): void => { + if (isFunctionLike(node) && syncCallbacks.has(node)) return; + if (isFunctionLike(node)) { + markPropsOnCondition(node, (propsParam) => isPropsByName(propsParam.name)); + } + scopeStack.push({ + node, + trackedScopes: [], + hasJsx: false, + unnamedDerivedSignals: new Set(), + }); + }; + + const onFunctionExit = (exitingNode: EsTreeNode): void => { + if (isFunctionLike(exitingNode) && syncCallbacks.has(exitingNode)) return; + + if (isFunctionLike(exitingNode)) { + markPropsOnCondition(exitingNode, (propsParam) => { + if (!isPropsByName(propsParam.name) && currentScope().hasJsx) { + const functionName = getFunctionName(exitingNode); + if (functionName && !/^[a-z]/.test(functionName)) return true; + } + return false; + }); + } + + for (const { reference, declarationScope } of getReferencesInCurrentScope(signalVariables)) { + const identifier = reference.identifier; + if (reference.flag === "write" || reference.flag === "read-write") { + const identifierName = isNodeOfType(identifier, "Identifier") ? identifier.name : "value"; + context.report({ + node: identifier, + message: `The reactive variable '${identifierName}' should not be reassigned or altered directly.`, + }); + } else if (isNodeOfType(identifier, "Identifier")) { + const reportBadSignal = (where: string): void => { + context.report({ + node: identifier, + message: `The reactive variable '${identifier.name}' should be called as a function when used in ${where}.`, + }); + }; + + if ( + identifier.parent?.type === "CallExpression" || + (identifier.parent?.type === "ArrayExpression" && + identifier.parent.parent?.type === "CallExpression") + ) { + handleTrackedScopes(identifier, declarationScope); + } else if (identifier.parent?.type === "TemplateLiteral") { + reportBadSignal("template literals"); + } else if ( + identifier.parent?.type === "BinaryExpression" && + ARITHMETIC_AND_COMPARISON_OPERATORS.has( + (identifier.parent as EsTreeNodeOfType<"BinaryExpression">).operator, + ) + ) { + reportBadSignal("arithmetic or comparisons"); + } else if ( + identifier.parent?.type === "UnaryExpression" && + UNARY_COERCE_OPERATORS.has( + (identifier.parent as EsTreeNodeOfType<"UnaryExpression">).operator, + ) + ) { + reportBadSignal("unary expressions"); + } else if ( + identifier.parent?.type === "MemberExpression" && + (identifier.parent as EsTreeNodeOfType<"MemberExpression">).computed && + (identifier.parent as EsTreeNodeOfType<"MemberExpression">).property === identifier + ) { + reportBadSignal("property accesses"); + } else if (identifier.parent?.type === "JSXExpressionContainer") { + const isTrackedInScope = currentScope().trackedScopes.find( + (trackedScope) => + trackedScope.node === identifier && + (trackedScope.expect === "function" || trackedScope.expect === "called-function"), + ); + if (!isTrackedInScope) { + const elementOrAttribute = identifier.parent.parent; + if ( + isJsxElementOrFragment(elementOrAttribute) || + (elementOrAttribute?.type === "JSXAttribute" && + elementOrAttribute.parent?.type === "JSXOpeningElement" && + isNodeOfType( + (elementOrAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">).name, + "JSXIdentifier", + ) && + isDomElementName( + ( + (elementOrAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">) + .name as EsTreeNodeOfType<"JSXIdentifier"> + ).name, + )) + ) { + reportBadSignal("JSX"); + } + } + } + } + } + + for (const { reference, declarationScope } of getReferencesInCurrentScope(propsVariables)) { + const identifier = reference.identifier; + if (reference.flag === "write" || reference.flag === "read-write") { + const identifierName = isNodeOfType(identifier, "Identifier") ? identifier.name : "value"; + context.report({ + node: identifier, + message: `The reactive variable '${identifierName}' should not be reassigned or altered directly.`, + }); + } else if ( + identifier.parent?.type === "MemberExpression" && + (identifier.parent as EsTreeNodeOfType<"MemberExpression">).object === identifier + ) { + const memberExpression = identifier.parent as EsTreeNodeOfType<"MemberExpression">; + if ( + memberExpression.parent?.type === "AssignmentExpression" && + (memberExpression.parent as EsTreeNodeOfType<"AssignmentExpression">).left === + memberExpression + ) { + const identifierName = isNodeOfType(identifier, "Identifier") + ? identifier.name + : "value"; + context.report({ + node: identifier, + message: `The reactive variable '${identifierName}' should not be reassigned or altered directly.`, + }); + } else if ( + isNodeOfType(memberExpression.property, "Identifier") && + /^(?:initial|default|static[A-Z])/.test(memberExpression.property.name) + ) { + // HACK: initial/default/static props are intentionally one-shot — skip + } else { + handleTrackedScopes(identifier, declarationScope); + } + } else if ( + identifier.parent?.type === "AssignmentExpression" || + identifier.parent?.type === "VariableDeclarator" + ) { + const identifierName = isNodeOfType(identifier, "Identifier") ? identifier.name : "value"; + context.report({ + node: identifier, + message: `The reactive variable '${identifierName}' should be used within JSX, a tracked scope (like createEffect), or inside an event handler function, or else changes will be ignored.`, + }); + } + } + + const { unnamedDerivedSignals } = currentScope(); + for (const derivedNode of unnamedDerivedSignals) { + if ( + !currentScope().trackedScopes.find((trackedScope) => + matchTrackedScope(trackedScope, derivedNode), + ) + ) { + context.report({ + node: derivedNode, + message: + "This function should be passed to a tracked scope (like createEffect) or an event handler because it contains reactivity, or else changes will be ignored.", + }); + } + } + + scopeStack.pop(); + }; + + const pushTrackedScope = (node: EsTreeNode, expect: TrackedExpect): void => { + if (scopeStack.length === 0) return; + currentScope().trackedScopes.push({ node, expect }); + if ( + expect !== "called-function" && + isFunctionLike(node) && + (node as { async?: boolean }).async + ) { + context.report({ + node, + message: + "This tracked scope should not be async. Solid's reactivity only tracks synchronously.", + }); + } + }; + + const permissivelyTrackNode = (node: EsTreeNode): void => { + walkAst(node, (childNode) => { + const traced = traceIdentifierToValue(childNode, context); + if ( + isFunctionLike(traced) || + (isNodeOfType(traced, "Identifier") && + traced.parent?.type !== "MemberExpression" && + !( + traced.parent?.type === "CallExpression" && + (traced.parent as EsTreeNodeOfType<"CallExpression">).callee === traced + )) + ) { + pushTrackedScope(childNode, "called-function"); + return false; + } + }); + }; + + const checkForTrackedScopes = (node: EsTreeNode): void => { + if (scopeStack.length === 0) return; + + if (isNodeOfType(node, "JSXExpressionContainer")) { + const parentAttribute = + node.parent?.type === "JSXAttribute" + ? (node.parent as EsTreeNodeOfType<"JSXAttribute">) + : null; + + if ( + parentAttribute && + isNodeOfType(parentAttribute.name, "JSXIdentifier") && + parentAttribute.name.name.startsWith("on") && + parentAttribute.parent?.type === "JSXOpeningElement" && + isNodeOfType( + (parentAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">).name, + "JSXIdentifier", + ) && + isDomElementName( + ( + (parentAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">) + .name as EsTreeNodeOfType<"JSXIdentifier"> + ).name, + ) + ) { + pushTrackedScope(node.expression as EsTreeNode, "called-function"); + } else if ( + parentAttribute && + parentAttribute.name.type === "JSXNamespacedName" && + (parentAttribute.name as EsTreeNodeOfType<"JSXNamespacedName">).namespace.name === + "use" && + isFunctionLike(node.expression as EsTreeNode) + ) { + pushTrackedScope(node.expression as EsTreeNode, "called-function"); + } else if ( + parentAttribute && + isNodeOfType(parentAttribute.name, "JSXIdentifier") && + parentAttribute.name.name === "ref" && + isFunctionLike(node.expression as EsTreeNode) + ) { + pushTrackedScope(node.expression as EsTreeNode, "called-function"); + } else if ( + isJsxElementOrFragment(node.parent) && + isFunctionLike(node.expression as EsTreeNode) + ) { + pushTrackedScope(node.expression as EsTreeNode, "function"); + } else { + pushTrackedScope(node.expression as EsTreeNode, "expression"); + } + } else if (isNodeOfType(node, "JSXSpreadAttribute")) { + pushTrackedScope(node.argument as EsTreeNode, "expression"); + } else if (isNodeOfType(node, "CallExpression")) { + if (isNodeOfType(node.callee, "Identifier")) { + const calleeName = node.callee.name; + const firstArgument = node.arguments[0] as EsTreeNode | undefined; + const secondArgument = node.arguments[1] as EsTreeNode | undefined; + + if ( + importTracker.matchImport(TRACKED_EFFECT_PRIMITIVES, calleeName) || + (importTracker.matchImport(["createResource"], calleeName) && + node.arguments.length >= 2) + ) { + if (firstArgument) pushTrackedScope(firstArgument, "function"); + } else if ( + importTracker.matchImport(CALLED_FUNCTION_PRIMITIVES, calleeName) || + BROWSER_TIMER_FUNCTIONS.has(calleeName) + ) { + if (firstArgument) pushTrackedScope(firstArgument, "called-function"); + } else if (importTracker.matchImport(["on"], calleeName)) { + if (firstArgument) { + if (isNodeOfType(firstArgument, "ArrayExpression")) { + for (const element of firstArgument.elements) { + if (element && element.type !== "SpreadElement") { + pushTrackedScope(element as EsTreeNode, "function"); + } + } + } else { + pushTrackedScope(firstArgument, "function"); + } + } + if (secondArgument) pushTrackedScope(secondArgument, "called-function"); + } else if ( + /^(?:use|create)[A-Z]/.test(calleeName) || + customReactiveFunctions.includes(calleeName) + ) { + for (const argument of node.arguments) { + permissivelyTrackNode(argument as EsTreeNode); + } + } + } else if (isNodeOfType(node.callee, "MemberExpression")) { + const property = node.callee.property; + if (isNodeOfType(property, "Identifier")) { + if (property.name === "addEventListener" && node.arguments.length >= 2) { + pushTrackedScope(node.arguments[1] as EsTreeNode, "called-function"); + } else if ( + /^(?:use|create)[A-Z]/.test(property.name) || + customReactiveFunctions.includes(property.name) + ) { + for (const argument of node.arguments) { + permissivelyTrackNode(argument as EsTreeNode); + } + } + } + } + } else if (isNodeOfType(node, "AssignmentExpression")) { + if ( + isNodeOfType(node.left, "MemberExpression") && + isNodeOfType(node.left.property, "Identifier") && + isFunctionLike(node.right as EsTreeNode) && + /^on[a-z]+$/.test(node.left.property.name) + ) { + pushTrackedScope(node.right as EsTreeNode, "called-function"); + } + } + }; + + const checkForSyncCallbacks = (node: EsTreeNodeOfType<"CallExpression">): void => { + if ( + node.arguments.length === 1 && + isFunctionLike(node.arguments[0] as EsTreeNode) && + !(node.arguments[0] as { async?: boolean }).async + ) { + const singleArgument = node.arguments[0] as EsTreeNode; + if ( + isNodeOfType(node.callee, "Identifier") && + importTracker.matchImport(["batch", "produce"], node.callee.name) + ) { + syncCallbacks.add(singleArgument); + } else if ( + isNodeOfType(node.callee, "MemberExpression") && + !node.callee.computed && + node.callee.object.type !== "ObjectExpression" && + isNodeOfType(node.callee.property, "Identifier") && + SYNC_CALLBACK_ARRAY_METHODS.test(node.callee.property.name) + ) { + syncCallbacks.add(singleArgument); + } + } + + if (isNodeOfType(node.callee, "Identifier")) { + if (importTracker.matchImport(["createSignal", "createStore"], node.callee.name)) { + if (node.parent?.type === "VariableDeclarator") { + const declarator = node.parent as EsTreeNodeOfType<"VariableDeclarator">; + if (isNodeOfType(declarator.id, "ArrayPattern") && declarator.id.elements.length > 1) { + const setterElement = declarator.id.elements[1]; + if (setterElement && isNodeOfType(setterElement as EsTreeNode, "Identifier")) { + const setterSymbol = context.scopes.symbolFor(setterElement as EsTreeNode); + if (setterSymbol) { + for (const reference of setterSymbol.references) { + if ( + reference.identifier !== setterSymbol.bindingIdentifier && + reference.flag === "read" && + reference.identifier.parent?.type === "CallExpression" && + (reference.identifier.parent as EsTreeNodeOfType<"CallExpression">).callee === + reference.identifier + ) { + const callExpression = reference.identifier + .parent as EsTreeNodeOfType<"CallExpression">; + for (const argument of callExpression.arguments) { + if ( + isFunctionLike(argument as EsTreeNode) && + !(argument as { async?: boolean }).async + ) { + syncCallbacks.add(argument as EsTreeNode); + } + } + } + } + } + } + } + } + } else if (importTracker.matchImport(["mapArray", "indexArray"], node.callee.name)) { + const secondArgument = node.arguments[1] as EsTreeNode | undefined; + if (secondArgument && isFunctionLike(secondArgument)) { + syncCallbacks.add(secondArgument); + } + } + } + + if (isFunctionLike(node.callee as EsTreeNode)) { + syncCallbacks.add(node.callee as EsTreeNode); + } + }; + + const resolveNthDestructuredSymbol = ( + pattern: EsTreeNode, + index: number, + ): SymbolDescriptor | null => { + if (!isNodeOfType(pattern, "ArrayPattern")) return null; + const element = pattern.elements[index]; + if (!element || !isNodeOfType(element as EsTreeNode, "Identifier")) return null; + return context.scopes.symbolFor(element as EsTreeNode) ?? null; + }; + + const resolveReturnedSymbol = (identifier: EsTreeNode): SymbolDescriptor | null => { + if (!isNodeOfType(identifier, "Identifier")) return null; + return context.scopes.symbolFor(identifier) ?? null; + }; + + const checkForReactiveAssignment = ( + bindingPattern: EsTreeNode | null, + initExpression: EsTreeNode, + ): void => { + if (scopeStack.length === 0) return; + const init = ignoreTransparentWrappers(initExpression); + if (!isNodeOfType(init, "CallExpression") || !isNodeOfType(init.callee, "Identifier")) return; + + const calleeName = init.callee.name; + + if (importTracker.matchImport(["createSignal", "useTransition"], calleeName)) { + const signalSymbol = bindingPattern + ? resolveNthDestructuredSymbol(bindingPattern, 0) + : null; + if (signalSymbol) pushSignal(signalSymbol, currentScope().node); + } else if (importTracker.matchImport(["createMemo", "createSelector"], calleeName)) { + const memoSymbol = bindingPattern ? resolveReturnedSymbol(bindingPattern) : null; + if (memoSymbol) pushSignal(memoSymbol, currentScope().node); + } else if (importTracker.matchImport(["createStore"], calleeName)) { + const storeSymbol = bindingPattern ? resolveNthDestructuredSymbol(bindingPattern, 0) : null; + if (storeSymbol) pushProps(storeSymbol, currentScope().node); + } else if (importTracker.matchImport(["mergeProps"], calleeName)) { + const mergedSymbol = bindingPattern ? resolveReturnedSymbol(bindingPattern) : null; + if (mergedSymbol) pushProps(mergedSymbol, currentScope().node); + } else if (importTracker.matchImport(["splitProps"], calleeName)) { + if (bindingPattern && isNodeOfType(bindingPattern, "ArrayPattern")) { + for ( + let elementIndex = 0; + elementIndex < bindingPattern.elements.length; + elementIndex++ + ) { + const splitSymbol = resolveNthDestructuredSymbol(bindingPattern, elementIndex); + if (splitSymbol) pushProps(splitSymbol, currentScope().node); + } + } else if (bindingPattern) { + const splitSymbol = resolveReturnedSymbol(bindingPattern); + if (splitSymbol) pushProps(splitSymbol, currentScope().node); + } + } else if (importTracker.matchImport(["createResource"], calleeName)) { + const resourceSymbol = bindingPattern + ? resolveNthDestructuredSymbol(bindingPattern, 0) + : null; + if (resourceSymbol) pushProps(resourceSymbol, currentScope().node); + } else if (importTracker.matchImport(["createMutable"], calleeName)) { + const mutableSymbol = bindingPattern ? resolveReturnedSymbol(bindingPattern) : null; + if (mutableSymbol) pushProps(mutableSymbol, currentScope().node); + } else if (importTracker.matchImport(["mapArray"], calleeName)) { + const mapCallback = init.arguments[1] as EsTreeNode | undefined; + if (mapCallback && isFunctionLike(mapCallback)) { + const mapFunction = mapCallback as EsTreeNodeOfType<"ArrowFunctionExpression">; + if (mapFunction.params.length >= 2) { + const indexParam = mapFunction.params[1]; + if (isNodeOfType(indexParam, "Identifier")) { + const indexSymbol = context.scopes.symbolFor(indexParam); + if (indexSymbol) pushSignal(indexSymbol); + } + } + } + } else if (importTracker.matchImport(["indexArray"], calleeName)) { + const indexCallback = init.arguments[1] as EsTreeNode | undefined; + if (indexCallback && isFunctionLike(indexCallback)) { + const indexFunction = indexCallback as EsTreeNodeOfType<"ArrowFunctionExpression">; + if (indexFunction.params.length >= 1) { + const valueParam = indexFunction.params[0]; + if (isNodeOfType(valueParam, "Identifier")) { + const valueSymbol = context.scopes.symbolFor(valueParam); + if (valueSymbol) pushSignal(valueSymbol); + } + } + } + } + }; + + const handleJsxChildFunction = (node: EsTreeNode): void => { + if ( + !isFunctionLike(node) || + node.parent?.type !== "JSXExpressionContainer" || + node.parent.parent?.type !== "JSXElement" + ) + return; + if (scopeStack.length === 0) return; + + const element = node.parent.parent as EsTreeNodeOfType<"JSXElement">; + if (!isNodeOfType(element.openingElement.name, "JSXIdentifier")) return; + const tagName = (element.openingElement.name as EsTreeNodeOfType<"JSXIdentifier">).name; + const functionNode = node as + | EsTreeNodeOfType<"ArrowFunctionExpression"> + | EsTreeNodeOfType<"FunctionExpression">; + + if (importTracker.matchImport(["For"], tagName) && functionNode.params.length >= 2) { + const indexParam = functionNode.params[1]; + if (isNodeOfType(indexParam, "Identifier")) { + const indexSymbol = context.scopes.symbolFor(indexParam); + if (indexSymbol) pushSignal(indexSymbol, currentScope().node); + } + } else if (importTracker.matchImport(["Index"], tagName) && functionNode.params.length >= 1) { + const itemParam = functionNode.params[0]; + if (isNodeOfType(itemParam, "Identifier")) { + const itemSymbol = context.scopes.symbolFor(itemParam); + if (itemSymbol) pushSignal(itemSymbol, currentScope().node); + } + } + }; + + const processNode = (node: EsTreeNode): void => { + switch (node.type) { + case "JSXExpressionContainer": + case "JSXSpreadAttribute": + case "AssignmentExpression": + checkForTrackedScopes(node); + break; + case "CallExpression": + checkForTrackedScopes(node); + checkForSyncCallbacks(node as EsTreeNodeOfType<"CallExpression">); + { + const parentNode = node.parent + ? ignoreTransparentWrappers(node.parent as EsTreeNode, true) + : null; + if ( + parentNode && + parentNode.type !== "AssignmentExpression" && + parentNode.type !== "VariableDeclarator" + ) { + checkForReactiveAssignment(null, node); + } + } + break; + case "VariableDeclarator": { + const declarator = node as EsTreeNodeOfType<"VariableDeclarator">; + if (declarator.init) { + checkForReactiveAssignment(declarator.id as EsTreeNode, declarator.init as EsTreeNode); + checkForTrackedScopes(node); + } + break; + } + case "JSXElement": + case "JSXFragment": + if (scopeStack.length > 0) currentScope().hasJsx = true; + break; + } + if ( + node.type === "AssignmentExpression" && + !isNodeOfType((node as EsTreeNodeOfType<"AssignmentExpression">).left, "MemberExpression") + ) { + const assignmentNode = node as EsTreeNodeOfType<"AssignmentExpression">; + checkForReactiveAssignment( + assignmentNode.left as EsTreeNode, + assignmentNode.right as EsTreeNode, + ); + } + }; + + const depthFirstWalk = (node: EsTreeNode): void => { + const isFunction = isFunctionLike(node); + const isProgram = node.type === "Program"; + + if (isFunction) { + handleJsxChildFunction(node); + } + + if (isFunction || isProgram) { + onFunctionEnter(node); + } + + processNode(node); + + const nodeRecord = node as unknown as Record; + for (const key of Object.keys(nodeRecord)) { + if (key === "parent") continue; + const child = nodeRecord[key]; + if (Array.isArray(child)) { + for (const item of child) { + if ( + item && + typeof item === "object" && + typeof (item as { type?: string }).type === "string" + ) { + depthFirstWalk(item as EsTreeNode); + } + } + } else if ( + child && + typeof child === "object" && + typeof (child as { type?: string }).type === "string" + ) { + depthFirstWalk(child as EsTreeNode); + } + } + + if (isFunction || isProgram) { + onFunctionExit(node); + } + }; + + return { + ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { + importTracker.handleImportDeclaration(node); + }, + "Program:exit"(programNode: EsTreeNode) { + depthFirstWalk(programNode); + }, + }; + }, +}); From 88e05852ef439e5dea1fe6220468cd6131ce4df5 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 27 May 2026 02:30:05 -0700 Subject: [PATCH 13/18] test(solid): add tests for 9 previously untested rules Fills the test gap for every ported Solid rule that was shipped without a dedicated test file: - solid-event-handlers (13 tests) - solid-imports (12 tests) - solid-no-array-handlers (8 tests) - solid-no-destructure (9 tests) - solid-no-proxy-apis (11 tests) - solid-prefer-classlist (9 tests) - solid-prefer-show (11 tests) - solid-self-closing-comp (9 tests) - solid-style-prop (10 tests) All 33 Solid rules now have dedicated test files (100% coverage). Co-authored-by: Aiden Bai --- .../rules/solid/solid-event-handlers.test.ts | 97 +++++++++++++++++++ .../plugin/rules/solid/solid-imports.test.ts | 83 ++++++++++++++++ .../solid/solid-no-array-handlers.test.ts | 57 +++++++++++ .../rules/solid/solid-no-destructure.test.ts | 79 +++++++++++++++ .../rules/solid/solid-no-proxy-apis.test.ts | 95 ++++++++++++++++++ .../solid/solid-prefer-classlist.test.ts | 79 +++++++++++++++ .../rules/solid/solid-prefer-show.test.ts | 84 ++++++++++++++++ .../solid/solid-self-closing-comp.test.ts | 57 +++++++++++ .../rules/solid/solid-style-prop.test.ts | 76 +++++++++++++++ 9 files changed, 707 insertions(+) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-imports.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-array-handlers.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-destructure.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-no-proxy-apis.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-classlist.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-prefer-show.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.test.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts new file mode 100644 index 000000000..175764b4f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidEventHandlers } from "./solid-event-handlers.js"; + +describe("solid-event-handlers", () => { + it("allows camelCase onClick with expression value", () => { + const result = runRule(solidEventHandlers, `
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(";`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("interactive"); + }); + + it("flags input inside button", () => { + const result = runRule( + solidValidateJsxNesting, + `const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(""); + expect(result.diagnostics[0].message).toContain(";`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("allows Dynamic (Solid control flow) inside ul", () => { + const result = runRule( + solidValidateJsxNesting, + `const App = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); });