diff --git a/.agents/skills/rule-research/SKILL.md b/.agents/skills/rule-research/SKILL.md index 67b9a6bad..b6d7d4aa0 100644 --- a/.agents/skills/rule-research/SKILL.md +++ b/.agents/skills/rule-research/SKILL.md @@ -68,6 +68,7 @@ Goal: Find examples where . Return: + - Strong positive examples - Pattern-adjacent examples - False-positive traps @@ -93,24 +94,31 @@ Detector precision: Syntax-only | scope-aware | path-aware Evidence: + - Strong positives: + - False-positive traps: + - In scope for v1: + - Out of scope for v1: + - Test seeds: + - Open questions: + - ``` diff --git a/.agents/skills/rule-validate/SKILL.md b/.agents/skills/rule-validate/SKILL.md index 21b2ed97e..d3e5bad0e 100644 --- a/.agents/skills/rule-validate/SKILL.md +++ b/.agents/skills/rule-validate/SKILL.md @@ -96,11 +96,13 @@ Catches . Before: + ```tsx ``` After: + ```tsx ``` @@ -115,14 +117,14 @@ After: ## Eval results -| Check | Result | -| --- | --- | -| Repos scanned | `` | -| RootDir scans | `` | -| Target rule | `` | -| Diagnostics | `` | -| False positives found | `` | -| Output artifact | `` | +| Check | Result | +| --------------------- | ---------------------------------- | +| Repos scanned | `` | +| RootDir scans | `` | +| Target rule | `` | +| Diagnostics | `` | +| False positives found | `` | +| Output artifact | `` | ## Test plan @@ -150,6 +152,7 @@ Return: ```md Validation summary: + - - - @@ -157,8 +160,10 @@ Validation summary: - PR-ready notes: + - Residual risk: + - ``` diff --git a/.agents/skills/rule-writing/SKILL.md b/.agents/skills/rule-writing/SKILL.md index c195c5f29..59f2363a5 100644 --- a/.agents/skills/rule-writing/SKILL.md +++ b/.agents/skills/rule-writing/SKILL.md @@ -107,18 +107,23 @@ When the writing stage is done, report: ```md Implemented: + - Detector behavior: + - - Validation run: + - Known v1 non-goals: + - Next stage: + - Run `rule-validate`. ``` 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/constants/style.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts index e92618c15..631eac48b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts @@ -38,3 +38,273 @@ export const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setIn export const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]); export const BOUNCE_ANIMATION_NAMES = new Set(["bounce", "elastic", "wobble", "jiggle", "spring"]); + +export const VENDOR_PREFIXES = ["-webkit-", "-moz-", "-ms-", "-o-"] as const; + +export const CSS_PROPERTIES = new Set([ + "all", + "display", + "position", + "top", + "right", + "bottom", + "left", + "float", + "clear", + "z-index", + "overflow", + "overflow-x", + "overflow-y", + "overflow-wrap", + "visibility", + "opacity", + "clip", + "clip-path", + "flex", + "flex-basis", + "flex-direction", + "flex-flow", + "flex-grow", + "flex-shrink", + "flex-wrap", + "order", + "justify-content", + "align-content", + "align-items", + "align-self", + "place-content", + "place-items", + "place-self", + "grid", + "grid-area", + "grid-auto-columns", + "grid-auto-flow", + "grid-auto-rows", + "grid-column", + "grid-column-end", + "grid-column-start", + "grid-gap", + "grid-row", + "grid-row-end", + "grid-row-start", + "grid-template", + "grid-template-areas", + "grid-template-columns", + "grid-template-rows", + "gap", + "row-gap", + "column-gap", + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "margin-block", + "margin-block-start", + "margin-block-end", + "margin-inline", + "margin-inline-start", + "margin-inline-end", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "padding-block", + "padding-block-start", + "padding-block-end", + "padding-inline", + "padding-inline-start", + "padding-inline-end", + "box-sizing", + "inline-size", + "block-size", + "min-inline-size", + "max-inline-size", + "min-block-size", + "max-block-size", + "border", + "border-top", + "border-right", + "border-bottom", + "border-left", + "border-width", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "border-style", + "border-top-style", + "border-right-style", + "border-bottom-style", + "border-left-style", + "border-color", + "border-top-color", + "border-right-color", + "border-bottom-color", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-image", + "border-collapse", + "border-spacing", + "background", + "background-attachment", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-repeat", + "background-size", + "color", + "font", + "font-family", + "font-feature-settings", + "font-kerning", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-variant", + "font-variation-settings", + "font-weight", + "letter-spacing", + "line-break", + "line-height", + "text-align", + "text-align-last", + "text-decoration", + "text-decoration-color", + "text-decoration-line", + "text-decoration-style", + "text-decoration-thickness", + "text-emphasis", + "text-indent", + "text-justify", + "text-orientation", + "text-overflow", + "text-rendering", + "text-shadow", + "text-transform", + "text-underline-offset", + "text-underline-position", + "white-space", + "word-break", + "word-spacing", + "word-wrap", + "writing-mode", + "direction", + "unicode-bidi", + "hyphens", + "tab-size", + "quotes", + "list-style", + "list-style-image", + "list-style-position", + "list-style-type", + "table-layout", + "caption-side", + "empty-cells", + "transform", + "transform-origin", + "transform-style", + "perspective", + "perspective-origin", + "backface-visibility", + "animation", + "animation-delay", + "animation-direction", + "animation-duration", + "animation-fill-mode", + "animation-iteration-count", + "animation-name", + "animation-play-state", + "animation-timing-function", + "transition", + "transition-delay", + "transition-duration", + "transition-property", + "transition-timing-function", + "filter", + "backdrop-filter", + "mix-blend-mode", + "isolation", + "box-shadow", + "outline", + "outline-color", + "outline-offset", + "outline-style", + "outline-width", + "object-fit", + "object-position", + "resize", + "contain", + "content", + "counter-increment", + "counter-reset", + "aspect-ratio", + "container", + "container-name", + "container-type", + "scroll-behavior", + "scroll-snap-align", + "scroll-snap-type", + "overscroll-behavior", + "overscroll-behavior-x", + "overscroll-behavior-y", + "cursor", + "caret-color", + "pointer-events", + "touch-action", + "user-select", + "appearance", + "will-change", + "image-rendering", + "columns", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-color", + "column-rule-style", + "column-rule-width", + "column-span", + "column-width", + "page-break-after", + "page-break-before", + "page-break-inside", + "break-after", + "break-before", + "break-inside", + "accent-color", + "color-scheme", + "inset", + "inset-block", + "inset-block-start", + "inset-block-end", + "inset-inline", + "inset-inline-start", + "inset-inline-end", + "fill", + "stroke", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-width", + "fill-opacity", + "fill-rule", + "paint-order", + "dominant-baseline", + "text-anchor", +]); 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..57867f708 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,41 @@ 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 { solidJsxNoUndef } from "./rules/solid/solid-jsx-no-undef.js"; +import { solidJsxUsesVars } from "./rules/solid/solid-jsx-uses-vars.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"; +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 { solidReactivity } from "./rules/solid/solid-reactivity.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 { solidValidateJsxNesting } from "./rules/solid/solid-validate-jsx-nesting.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 +3325,426 @@ 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-jsx-no-undef", + id: "solid-jsx-no-undef", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidJsxNoUndef, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidJsxNoUndef.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-jsx-uses-vars", + id: "solid-jsx-uses-vars", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidJsxUsesVars, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidJsxUsesVars.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-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", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoDestructure, + framework: "solid", + category: "SolidJS", + 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", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoInnerHtml, + framework: "solid", + category: "SolidJS", + 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", + 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", + 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-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", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoSignalMutation, + framework: "solid", + category: "SolidJS", + 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", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidNoUnknownNamespaces, + framework: "solid", + category: "SolidJS", + 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", + 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-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", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidPreferShow, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidPreferShow.tags ?? [])])], + }, + }, + { + key: "react-doctor/solid-reactivity", + id: "solid-reactivity", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidReactivity, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidReactivity.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", + 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/solid-validate-jsx-nesting", + id: "solid-validate-jsx-nesting", + source: "react-doctor", + originallyExternal: false, + rule: { + ...solidValidateJsxNesting, + framework: "solid", + category: "SolidJS", + tags: [...new Set(["solid", ...(solidValidateJsxNesting.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.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts new file mode 100644 index 000000000..cfc9d63c8 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.test.ts @@ -0,0 +1,97 @@ +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); + }); + + it("flags early return but not last return when both paths return JSX", () => { + const result = runRule( + solidComponentsReturnOnce, + `function Comp() { + if (error()) return ; + if (loading()) return ; + return
; + }`, + ); + expect(result.diagnostics).toHaveLength(2); + expect(result.diagnostics.every((d) => d.message.includes("early return"))).toBe(true); + }); + + it("does not flag HOC-wrapped arrow with conditional return", () => { + const result = runRule( + solidComponentsReturnOnce, + `const Comp = memo(() => loading ? : );`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag ternary nested inside JSX children of the returned element", () => { + const result = runRule( + solidComponentsReturnOnce, + `function Comp() { + return ; + }`, + ); + 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 new file mode 100644 index 000000000..4c905fa0d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-components-return-once.ts @@ -0,0 +1,193 @@ +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.some((argument) => argument === node)) 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; + + 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")) { + 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.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts new file mode 100644 index 000000000..24a11f776 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-event-handlers.test.ts @@ -0,0 +1,136 @@ +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(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); + }); + + it("does not flag signal array passed to on() first arg", () => { + const result = runRule( + solidReactivity, + `import { createSignal, createEffect, on } from "solid-js"; + const [a, setA] = createSignal(0); + const [b, setB] = createSignal(0); + createEffect(on([a, b], ([aVal, bVal]) => { + console.log(aVal, bVal); + }));`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("Provider value prop exemption", () => { + it("does not flag reactive variable in Provider value prop (XxxProvider)", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag reactive variable in Xxx.Provider value prop", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("still flags signal on a DOM element value prop", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () => ;`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + expect( + result.diagnostics.some((diagnostic) => + diagnostic.message.includes("called as a function"), + ), + ).toBe(true); + }); + }); + + describe("static* prop exemption", () => { + it("does not flag reactive variable in staticFoo prop on custom component", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () => ;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("still flags reactive variable in staticFoo prop on DOM element", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () =>
;`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("Observer constructor callback tracking", () => { + it("does not flag signal used inside IntersectionObserver callback", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + new IntersectionObserver(() => { console.log(count()); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag signal used inside ResizeObserver callback", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + new ResizeObserver(() => { console.log(count()); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag signal used inside MutationObserver callback", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + new MutationObserver(() => { console.log(count()); });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("ref callback tracking", () => { + it("does not flag function passed to ref prop as called-function", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const App = () =>
{ console.log(count()); }} />;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("splitProps tracking", () => { + it("flags splitProps result accessed outside tracked scope", () => { + const result = runRule( + solidReactivity, + `import { splitProps } from "solid-js"; + const Component = (props) => { + const [local, others] = splitProps(props, ["name"]); + const val = local.name; + return
{val}
; + };`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + expect( + result.diagnostics.some((diagnostic) => + diagnostic.message.includes("should be used within JSX"), + ), + ).toBe(true); + }); + + it("does not flag splitProps result used inside JSX expression", () => { + const result = runRule( + solidReactivity, + `import { splitProps } from "solid-js"; + const Component = (props) => { + const [local, others] = splitProps(props, ["name"]); + return
{local.name}
; + };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("createResource tracking", () => { + it("flags createResource return accessed outside tracked scope", () => { + const result = runRule( + solidReactivity, + `import { createResource } from "solid-js"; + const Component = () => { + const [data] = createResource(fetchUser); + const name = data.name; + return
{name}
; + };`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + }); + + it("does not flag createResource return used inside JSX", () => { + const result = runRule( + solidReactivity, + `import { createResource } from "solid-js"; + const Component = () => { + const [data] = createResource(fetchUser); + return
{data.name}
; + };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("produce() sync callback tracking", () => { + it("does not flag signal used inside produce callback within createEffect", () => { + const result = runRule( + solidReactivity, + `import { createSignal, createEffect } from "solid-js"; + import { produce } from "solid-js/store"; + const [count, setCount] = createSignal(0); + createEffect(() => { + setState(produce((draft) => { + draft.count = count(); + })); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("mergeProps in JSX", () => { + it("does not flag mergeProps result used inside JSX expression", () => { + const result = runRule( + solidReactivity, + `import { mergeProps } from "solid-js"; + const Component = (props) => { + const merged = mergeProps({ name: "default" }, props); + return
{merged.name}
; + };`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("signal in computed member access", () => { + it("flags signal used as computed property key without calling", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [index, setIndex] = createSignal(0); + const items = [1, 2, 3]; + const current = items[index];`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("called as a function"); + expect(result.diagnostics[0].message).toContain("property accesses"); + }); + }); + + describe("signal in addition binary expression", () => { + it("flags signal used with + operator without calling", () => { + const result = runRule( + solidReactivity, + `import { createSignal } from "solid-js"; + const [count, setCount] = createSignal(0); + const incremented = count + 1;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("arithmetic or comparisons"); + }); + }); + + describe("For/Index parameter signal tracking", () => { + it("flags index parameter used without calling in For children", () => { + const result = runRule( + solidReactivity, + `import { createSignal, For } from "solid-js"; + const [items, setItems] = createSignal([1, 2, 3]); + const App = () => ( + + {(item, index) =>
{index}
} +
+ );`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + expect( + result.diagnostics.some((diagnostic) => + diagnostic.message.includes("called as a function"), + ), + ).toBe(true); + }); + + it("does not flag index parameter called as function in For children", () => { + const result = runRule( + solidReactivity, + `import { createSignal, For } from "solid-js"; + const [items, setItems] = createSignal([1, 2, 3]); + const App = () => ( + + {(item, index) =>
{index()}
} +
+ );`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags item parameter used without calling in Index children", () => { + const result = runRule( + solidReactivity, + `import { createSignal, Index } from "solid-js"; + const [items, setItems] = createSignal([1, 2, 3]); + const App = () => ( + + {(item) =>
{item}
} +
+ );`, + ); + expect(result.diagnostics.length).toBeGreaterThanOrEqual(1); + expect( + result.diagnostics.some((diagnostic) => + diagnostic.message.includes("called as a function"), + ), + ).toBe(true); + }); + + it("does not flag item parameter called as function in Index children", () => { + const result = runRule( + solidReactivity, + `import { createSignal, Index } from "solid-js"; + const [items, setItems] = createSignal([1, 2, 3]); + const App = () => ( + + {(item) =>
{item()}
} +
+ );`, + ); + 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..2bcdfcae3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-reactivity.ts @@ -0,0 +1,991 @@ +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 OBSERVER_CONSTRUCTORS = new Set([ + "IntersectionObserver", + "MutationObserver", + "PerformanceObserver", + "ReportingObserver", + "ResizeObserver", +]); + +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 isProviderOpeningElement = (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; +}; + +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 (symbol.kind !== "const") break; + if (!isNodeOfType(symbol.declarationNode, "VariableDeclarator")) break; + const declarator = symbol.declarationNode; + if (!isNodeOfType(declarator.id, "Identifier") || !declarator.init) 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 consumedReferences = new Set(); + + 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 (consumedReferences.has(reference)) continue; + if (isRefInCurrentScope(reference)) { + consumedReferences.add(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 === "value" && + parentAttribute.parent?.type === "JSXOpeningElement" && + isProviderOpeningElement(parentAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">) + ) { + // HACK: Provider value prop is intentionally an expression, not a tracked scope + } else if ( + parentAttribute && + isNodeOfType(parentAttribute.name, "JSXIdentifier") && + /^static[A-Z]/.test(parentAttribute.name.name) && + parentAttribute.parent?.type === "JSXOpeningElement" && + isNodeOfType( + (parentAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">).name, + "JSXIdentifier", + ) && + !isDomElementName( + ( + (parentAttribute.parent as EsTreeNodeOfType<"JSXOpeningElement">) + .name as EsTreeNodeOfType<"JSXIdentifier"> + ).name, + ) + ) { + // HACK: static* props on custom components are intentionally not tracked + } 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, "NewExpression")) { + if ( + isNodeOfType(node.callee, "Identifier") && + OBSERVER_CONSTRUCTORS.has(node.callee.name) && + node.arguments.length >= 1 + ) { + pushTrackedScope(node.arguments[0] as EsTreeNode, "called-function"); + } + } 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 "NewExpression": + 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); + }, + }; + }, +}); 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..ad563b743 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.test.ts @@ -0,0 +1,101 @@ +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); + }); + + it("flags requestAnimationFrame without onCleanup", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect } from "solid-js"; + createEffect(() => { + const id = requestAnimationFrame(draw); + });`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("requestAnimationFrame"); + expect(result.diagnostics[0].message).toContain("cancelAnimationFrame"); + }); + + it("does not flag when onCleanup is imported with alias", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect, onCleanup as cleanup } from "solid-js"; + createEffect(() => { + const id = setInterval(tick, 1000); + cleanup(() => clearInterval(id)); + });`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag setTimeout inside nested function in effect", () => { + const result = runRule( + solidRequireCleanup, + `import { createEffect } from "solid-js"; + createEffect(() => { + const start = () => { + setTimeout(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..7914a167e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-require-cleanup.ts @@ -0,0 +1,114 @@ +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", "requestAnimationFrame"]); +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 timerCancelMap: Record = { + setInterval: "clearInterval", + setTimeout: "clearTimeout", + requestAnimationFrame: "cancelAnimationFrame", + }; + const releaseHint = + firstUsage.kind === "timer" + ? `${timerCancelMap[firstUsage.name] ?? "clearTimeout"}(...)` + : `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})\`.`, + }); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.test.ts new file mode 100644 index 000000000..f3905415d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidSelfClosingComp } from "./solid-self-closing-comp.js"; + +describe("solid-self-closing-comp", () => { + it("flags empty component with closing tag", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () => ;`); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("self-closing"); + }); + + it("flags empty HTML element with closing tag", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("self-closing"); + }); + + it("flags component with only whitespace children", () => { + const result = runRule( + solidSelfClosingComp, + `const Foo = () => +;`, + ); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag component with actual children", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () => content;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag already self-closed component", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () => ;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag already self-closed HTML element", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags empty void HTML element with closing tag", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () => ;`); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag element with JSX expression children", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () =>
{value}
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags member expression component with no children", () => { + const result = runRule(solidSelfClosingComp, `const Foo = () => ;`); + expect(result.diagnostics).toHaveLength(1); + }); +}); 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..5dfc9569e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-self-closing-comp.ts @@ -0,0 +1,80 @@ +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 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 = 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 => { + 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.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.test.ts new file mode 100644 index 000000000..e6935928a --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidStyleProp } from "./solid-style-prop.js"; + +describe("solid-style-prop", () => { + it("flags camelCase CSS property name", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("font-size"); + expect(result.diagnostics[0].message).toContain("kebab-case"); + }); + + it("flags numeric value on length property", () => { + const result = runRule(solidStyleProp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("unit"); + }); + + it("does not flag numeric zero on length property", () => { + const result = runRule(solidStyleProp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags string style prop value", () => { + const result = runRule(solidStyleProp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("object"); + }); + + it("flags template literal style prop value", () => { + const result = runRule(solidStyleProp, "const Foo = () =>
;"); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("object"); + }); + + it("does not flag kebab-case property names", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag CSS custom properties", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag string values on length properties", () => { + const result = runRule(solidStyleProp, `const Foo = () =>
;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags multiple camelCase properties", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(2); + }); + + it("does not flag non-style props", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags invalid CSS property name", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("not a valid CSS property"); + }); + + it("does not flag valid CSS property", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag CSS custom property", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag vendor-prefixed property", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag style prop on custom component", () => { + const result = runRule( + solidStyleProp, + `const Foo = () => ;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("font-size"); + }); + + it("does not flag string style when allowString is true", () => { + const result = runRule(solidStyleProp, `const Foo = () =>
;`, { + settings: { "react-doctor": { solidStyleProp: { allowString: true } } }, + }); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag spread style objects (only direct JSXAttribute)", () => { + const result = runRule( + solidStyleProp, + `const Foo = () =>
;`, + ); + expect(result.diagnostics).toHaveLength(0); + }); +}); 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..6d88cf4f3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-style-prop.ts @@ -0,0 +1,143 @@ +import { CSS_PROPERTIES, VENDOR_PREFIXES } from "../../constants/style.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 { 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 camelToKebab = (name: string): string => + name.replace(/[A-Z]/g, (uppercaseMatch) => `-${uppercaseMatch.toLowerCase()}`); + +const UNITFUL_PROPERTIES = new Set([ + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "border-width", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "font-size", + "top", + "right", + "bottom", + "left", + "gap", + "row-gap", + "column-gap", + "border-radius", + "outline-width", + "letter-spacing", + "word-spacing", + "text-indent", + "inline-size", + "block-size", +]); + +const isVendorPrefixed = (name: string): boolean => + VENDOR_PREFIXES.some((prefix) => name.startsWith(prefix)); + +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; +}; + +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 = readSolidRuleSettings( + context.settings, + "solidStyleProp", + ); + const styleProps = new Set(settings.styleProps ?? ["style"]); + const allowString = Boolean(settings.allowString); + return { + JSXAttribute(node: EsTreeNodeOfType<"JSXAttribute">) { + const propertyName = getJsxAttributeName(node.name); + 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 (!CSS_PROPERTIES.has(keyName) && !isVendorPrefixed(keyName)) { + const kebabName = camelToKebab(keyName); + if (CSS_PROPERTIES.has(kebabName)) { + context.report({ + node: property.key, + message: `Use \`"${kebabName}"\` instead of \`${keyName}\` — Solid expects kebab-case CSS property names.`, + }); + } else { + context.report({ + node: property.key, + message: `\`${keyName}\` is not a valid CSS property.`, + }); + } + continue; + } + if (UNITFUL_PROPERTIES.has(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/rules/solid/solid-validate-jsx-nesting.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-validate-jsx-nesting.test.ts new file mode 100644 index 000000000..8ac95751e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-validate-jsx-nesting.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { solidValidateJsxNesting } from "./solid-validate-jsx-nesting.js"; + +describe("solid-validate-jsx-nesting", () => { + it("flags div inside p", () => { + const result = runRule(solidValidateJsxNesting, `const App = () =>

bad

;`); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("
"); + expect(result.diagnostics[0].message).toContain("

"); + expect(result.diagnostics[0].message).toContain("block element"); + }); + + it("allows span inside p", () => { + const result = runRule(solidValidateJsxNesting, `const App = () =>

ok

;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("allows text inside p", () => { + const result = runRule(solidValidateJsxNesting, `const App = () =>

just text

;`); + expect(result.diagnostics).toHaveLength(0); + }); + + it("flags div inside span", () => { + const result = runRule( + solidValidateJsxNesting, + `const App = () =>
bad
;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("
"); + expect(result.diagnostics[0].message).toContain(""); + }); + + it("flags nested anchor inside anchor", () => { + const result = runRule( + solidValidateJsxNesting, + `const App = () => bad;`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain(""); + expect(result.diagnostics[0].message).toContain("nested inside itself"); + }); + + it("flags button inside button", () => { + const result = runRule( + solidValidateJsxNesting, + `const App = () => ;`, + ); + 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); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-validate-jsx-nesting.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-validate-jsx-nesting.ts new file mode 100644 index 000000000..0ac2cafe0 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/solid/solid-validate-jsx-nesting.ts @@ -0,0 +1,199 @@ +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 SOLID_CONTROL_FLOW_COMPONENTS: ReadonlySet = new Set([ + "For", + "Show", + "Index", + "Switch", + "Match", + "Dynamic", + "Portal", + "Suspense", + "ErrorBoundary", +]); + +const BLOCK_ELEMENTS: ReadonlySet = new Set([ + "div", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "table", + "blockquote", + "pre", + "form", + "section", + "article", + "aside", + "header", + "footer", + "nav", + "main", + "details", + "fieldset", + "figure", + "address", + "dl", + "dd", + "dt", + "figcaption", + "hr", +]); + +const CANNOT_CONTAIN_BLOCK: ReadonlySet = new Set([ + "p", + "span", + "a", + "b", + "i", + "em", + "strong", + "small", + "s", + "cite", + "q", + "dfn", + "abbr", + "code", + "var", + "samp", + "kbd", + "sub", + "sup", + "mark", + "bdi", + "bdo", + "label", + "output", + "time", + "data", +]); + +const INTERACTIVE_ELEMENTS: ReadonlySet = new Set([ + "a", + "button", + "input", + "select", + "textarea", +]); + +const CANNOT_CONTAIN_INTERACTIVE: ReadonlyMap> = new Map([ + ["button", new Set(["button", "a", "input", "select", "textarea"])], + ["a", new Set(["a"])], + ["label", new Set(["label"])], +]); + +const CANNOT_SELF_NEST: ReadonlySet = new Set([ + "a", + "button", + "label", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", +]); + +const HEADING_ELEMENTS: ReadonlySet = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); + +const REQUIRED_PARENT_CHILDREN: ReadonlyMap> = new Map([ + ["ul", new Set(["li"])], + ["ol", new Set(["li"])], + ["table", new Set(["thead", "tbody", "tfoot", "tr", "caption", "colgroup", "col"])], + ["thead", new Set(["tr"])], + ["tbody", new Set(["tr"])], + ["tfoot", new Set(["tr"])], + ["tr", new Set(["td", "th"])], + ["dl", new Set(["dt", "dd", "div"])], + ["select", new Set(["option", "optgroup"])], +]); + +const getJsxElementName = (node: EsTreeNodeOfType<"JSXElement">): string | null => { + const openingElement = node.openingElement; + if (isNodeOfType(openingElement.name, "JSXIdentifier")) { + return openingElement.name.name; + } + return null; +}; + +const isSolidControlFlowComponent = (elementName: string): boolean => + SOLID_CONTROL_FLOW_COMPONENTS.has(elementName); + +export const solidValidateJsxNesting = defineRule({ + id: "solid-validate-jsx-nesting", + severity: "error", + requires: ["solid"], + recommendation: + "Invalid HTML element nesting causes hydration mismatches — browsers auto-correct the DOM tree, breaking Solid's assumptions.", + create: (context: RuleContext) => ({ + JSXElement(node: EsTreeNodeOfType<"JSXElement">) { + const parentName = getJsxElementName(node); + if (!parentName || !isDomElementName(parentName)) return; + + for (const child of node.children) { + if (!isNodeOfType(child as EsTreeNode, "JSXElement")) continue; + const childElement = child as EsTreeNodeOfType<"JSXElement">; + const childName = getJsxElementName(childElement); + if (!childName) continue; + + if (isSolidControlFlowComponent(childName)) continue; + if (!isDomElementName(childName)) continue; + + if (CANNOT_CONTAIN_BLOCK.has(parentName) && BLOCK_ELEMENTS.has(childName)) { + context.report({ + node: childElement, + message: `\`<${childName}>\` is a block element and cannot be nested inside \`<${parentName}>\` — the browser will auto-correct the DOM, causing a hydration mismatch.`, + }); + continue; + } + + if (HEADING_ELEMENTS.has(parentName) && HEADING_ELEMENTS.has(childName)) { + context.report({ + node: childElement, + message: `\`<${childName}>\` cannot be nested inside \`<${parentName}>\` — headings cannot contain other headings.`, + }); + continue; + } + + if (CANNOT_SELF_NEST.has(parentName) && childName === parentName) { + context.report({ + node: childElement, + message: `\`<${childName}>\` cannot be nested inside itself — this produces invalid HTML and causes hydration mismatches.`, + }); + continue; + } + + const disallowedInteractive = CANNOT_CONTAIN_INTERACTIVE.get(parentName); + if (disallowedInteractive?.has(childName)) { + context.report({ + node: childElement, + message: `\`<${childName}>\` cannot be nested inside \`<${parentName}>\` — interactive elements must not contain other interactive elements.`, + }); + continue; + } + + const allowedChildren = REQUIRED_PARENT_CHILDREN.get(parentName); + if (allowedChildren && !allowedChildren.has(childName)) { + context.report({ + node: childElement, + message: `\`<${childName}>\` is not a valid direct child of \`<${parentName}>\` — expected ${[...allowedChildren].map((allowed) => `\`<${allowed}>\``).join(", ")}.`, + }); + } + } + }, + }), +}); 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/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; +}; 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/is-on-intrinsic-html-element.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-on-intrinsic-html-element.ts index 0e8ca072f..121b3603a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-on-intrinsic-html-element.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-on-intrinsic-html-element.ts @@ -1,15 +1,8 @@ import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; +import { isDomElementName } from "./is-dom-element-name.js"; import { isNodeOfType } from "./is-node-of-type.js"; -// True iff the JSXAttribute belongs to an intrinsic HTML element -// (`
`, `