From ac0d78fa6a30eb1ff32de4fc12956e63adff6f45 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 30 Jul 2026 22:25:52 +0000 Subject: [PATCH 1/6] perf: reduce scan startup and workspace contention --- .changeset/faster-sidecar-probes.md | 11 + packages/api/src/diagnose.ts | 29 +- .../core/src/build-diagnostic-pipeline.ts | 4 +- packages/core/src/check-reduced-motion.ts | 2 +- packages/core/src/check-security-scan.ts | 8 +- .../build-security-scan-diagnostic.ts | 2 +- .../collect-security-scan-files.ts | 4 +- .../src/dead-code/dead-code-worker-slots.ts | 67 +- .../core/src/get-diagnostic-rule-identity.ts | 4 +- packages/core/src/index.ts | 2 + .../core/src/project-info/capabilities.ts | 12 +- packages/core/src/refs.ts | 8 + packages/core/src/rule-metadata.ts | 4 +- packages/core/src/run-oxlint.ts | 14 +- packages/core/src/runners/oxlint/config.ts | 11 +- .../core/src/runners/oxlint/parse-output.ts | 12 +- .../src/runners/oxlint/plugin-resolution.ts | 2 +- .../core/src/runners/oxlint/spawn-batches.ts | 51 +- .../core/src/runners/oxlint/spawn-oxlint.ts | 2 + .../oxlint/validate-rule-registration.ts | 12 +- packages/core/src/schemas.ts | 2 +- packages/core/src/services/linter.ts | 3 + packages/core/src/types/project-info.ts | 2 +- .../src/utils/create-oxlint-spawn-slots.ts | 15 + .../core/src/utils/create-worker-slots.ts | 65 + .../src/utils/has-published-fix-recipe.ts | 5 +- .../core/tests/create-worker-slots.test.ts | 98 + ...ect-target-blank-opener-protection.test.ts | 34 + packages/core/tests/spawn-batches.test.ts | 134 + packages/core/vite.config.ts | 4 + .../no-mirror-prop-effect--block-return.tsx | 12 + packages/oxlint-plugin-react-doctor/README.md | 7 + .../oxlint-plugin-react-doctor/package.json | 8 +- .../scripts/generate-rule-registry.mjs | 132 +- .../oxlint-plugin-react-doctor/src/core.ts | 44 + .../src/external-rules.ts | 40 + .../oxlint-plugin-react-doctor/src/index.ts | 7 +- .../src/plugin/core-rule-registry-data.json | 13012 ++++++++++++++++ .../src/plugin/core-rule-registry.test.ts | 70 + .../src/plugin/core-rule-registry.ts | 44 + ...ss-file-dependencies-deduplication.test.ts | 26 + .../plugin/cross-file-dependencies.test.ts | 135 + .../src/plugin/cross-file-dependencies.ts | 93 +- .../src/plugin/rules/a11y/alt-text.ts | 3 +- .../rules/a11y/anchor-ambiguous-text.ts | 3 +- .../rules/a11y/dialog-has-accessible-name.ts | 3 +- .../plugin/rules/a11y/img-redundant-alt.ts | 3 +- .../src/plugin/rules/a11y/no-access-key.ts | 3 +- .../a11y/no-static-motion-config-never.ts | 4 +- .../plugin/rules/a11y/prefer-html-dialog.ts | 15 +- .../architecture/no-many-boolean-props.ts | 14 +- ...react-router-v8-no-removed-future-flags.ts | 5 +- .../rules/bundle-size/no-barrel-import.ts | 3 +- .../bundle-size/prefer-dynamic-import.ts | 3 +- ...ocalstorage-no-version.regressions.test.ts | 19 + .../client/client-localstorage-no-version.ts | 24 +- .../client/client-passive-event-listeners.ts | 5 +- .../no-array-index-as-key.regressions.test.ts | 30 + .../correctness/no-array-index-as-key.ts | 13 +- ...dex-deref-without-bounds-or-empty-guard.ts | 3 +- ...etch-response-used-without-status-check.ts | 3 +- .../correctness/no-indeterminate-attribute.ts | 3 +- ...no-mutate-queried-dom-node-in-component.ts | 2 + .../no-unescaped-dynamic-string-in-regexp.ts | 3 +- ...nguarded-browser-global-at-module-scope.ts | 9 +- ...react-router-guard-aborted-handle-error.ts | 6 +- ...outer-no-client-module-in-server-render.ts | 5 +- .../design/no-inline-exhaustive-style.ts | 3 +- ...ink-ctrl-c-handler-requires-exit-option.ts | 1 + .../rules/js-performance/no-sync-xhr.ts | 3 +- .../nextjs-no-client-fetch-for-server-data.ts | 3 +- .../nextjs/nextjs-no-client-side-redirect.ts | 10 +- .../rules/nextjs/nextjs-no-img-element.ts | 3 +- ...der-value-from-unmemoized-local-literal.ts | 3 - .../no-hydration-branch-on-browser-global.ts | 5 +- .../performance/no-locale-format-in-render.ts | 5 +- .../no-match-media-in-state-initializer.ts | 5 +- ...d-browser-global-in-render-or-hook-init.ts | 5 +- .../rendering-hydration-mismatch-time.ts | 3 +- .../r3f/r3f-no-extend-three-namespace.ts | 5 +- .../jsx-key.regressions.test.ts | 25 + .../plugin/rules/react-builtins/jsx-key.ts | 90 +- .../jsx-no-constructed-context-values.ts | 1 + .../react-builtins/jsx-no-jsx-as-prop.ts | 1 + .../jsx-no-new-array-as-prop.ts | 1 + .../jsx-no-new-function-as-prop.ts | 1 + .../jsx-no-new-object-as-prop.ts | 1 + .../react-builtins/only-export-components.ts | 7 +- .../testlike-program-analysis.test.ts | 46 + .../react-native/expo-no-non-inlined-env.ts | 6 +- .../react-native/rn-detox-missing-await.ts | 6 +- .../rn-no-legacy-shadow-styles.ts | 6 +- .../react-native/rn-prefer-expo-image.ts | 6 +- .../rn-style-prefer-box-shadow.ts | 6 +- .../rules/react-ui/no-em-dash-in-jsx-text.ts | 4 +- ...ostmessage-origin-risk.regressions.test.ts | 8 + .../security-scan/postmessage-origin-risk.ts | 8 +- .../src/plugin/rules/security/no-eval.ts | 4 +- .../security/no-secrets-in-client-code.ts | 21 +- .../rules/server/server-auth-actions.ts | 3 +- ...oolean-toggle-without-functional-update.ts | 22 +- .../no-create-store-in-render.ts | 23 +- .../no-direct-state-mutation.ts | 257 +- .../no-event-trigger-state.ts | 14 +- .../no-impure-state-updater.test.ts | 13 + .../no-impure-state-updater.ts | 94 +- .../no-loading-flag-reset-outside-finally.ts | 3 +- .../no-mirror-prop-effect.regressions.test.ts | 13 + .../no-mirror-prop-effect.ts | 6 +- ...state-only-in-handlers.regressions.test.ts | 35 + .../rerender-state-only-in-handlers.ts | 138 +- ...-external-location-invalidation-checker.ts | 2 + .../state-and-effects/utils/effect/ast.ts | 17 +- .../utils/effect/get-program-analysis.ts | 10 +- .../state-and-effects/utils/effect/react.ts | 12 +- .../utils/expand-transitive-dependencies.ts | 4 +- ...mponent-render-dependency-analysis.test.ts | 60 + ...et-component-render-dependency-analysis.ts | 45 + .../tanstack-start-missing-head-content.ts | 3 +- .../tanstack-start-missing-scripts.ts | 3 +- .../tanstack-start-no-anchor-element.ts | 3 +- .../tanstack-start-no-navigate-in-render.ts | 3 +- .../tanstack-start-no-use-effect-fetch.ts | 3 +- .../src/plugin/security-scan-rule-registry.ts | 551 + .../src/plugin/semantic/control-flow-graph.ts | 47 +- .../src/plugin/semantic/scope-analysis.ts | 86 +- .../utils/collect-effect-invoked-functions.ts | 23 +- .../collect-possible-assigned-expressions.ts | 43 +- .../src/plugin/utils/contains-jsx-element.ts | 24 +- .../src/plugin/utils/core-rule-metadata.ts | 26 + .../plugin/utils/cross-file-probe-recorder.ts | 39 +- .../src/plugin/utils/define-retired-rule.ts | 3 +- .../src/plugin/utils/define-rule.test.ts | 61 + .../src/plugin/utils/define-rule.ts | 17 +- .../src/plugin/utils/empty-rule-visitors.ts | 3 + .../find-forwarded-fresh-hook-dependencies.ts | 9 +- .../utils/find-variable-initializer.test.ts | 88 + .../plugin/utils/find-variable-initializer.ts | 274 +- .../utils/get-equivalent-symbols.test.ts | 43 + .../plugin/utils/get-equivalent-symbols.ts | 26 +- .../get-fast-refresh-file-status.test.ts | 22 + .../utils/get-fast-refresh-file-status.ts | 30 +- .../get-program-browser-global-syntax.test.ts | 33 + .../get-program-browser-global-syntax.ts | 48 + .../src/plugin/utils/is-ast-node.test.ts | 22 + .../src/plugin/utils/is-ast-node.ts | 5 +- .../src/plugin/utils/is-function-like.ts | 12 +- .../is-generated-image-render-context.ts | 14 +- .../src/plugin/utils/is-node-of-type.test.ts | 22 + .../src/plugin/utils/is-node-of-type.ts | 3 +- .../src/plugin/utils/is-path-inside.ts | 11 + .../src/plugin/utils/is-react-api-call.ts | 85 +- .../src/plugin/utils/is-react-hook-call.ts | 17 +- .../plugin/utils/node-with-optional-type.ts | 3 + .../src/plugin/utils/non-react-jsx-dialect.ts | 33 +- .../utils/push-child-nodes-in-reverse.ts | 36 + .../plugin/utils/reset-filesystem-caches.ts | 9 + .../resolve-client-secret-recommendation.ts | 20 + .../utils/resolve-const-identifier-alias.ts | 17 +- .../resolve-cross-file-function-export.ts | 45 +- .../resolve-declared-workspace-directories.ts | 108 + .../plugin/utils/resolve-ink-render-calls.ts | 5 + ...e-static-export-redirect-recommendation.ts | 9 + .../utils/resolve-tsconfig-alias.test.ts | 41 + .../plugin/utils/resolve-tsconfig-alias.ts | 67 +- .../src/plugin/utils/rule-context.ts | 6 + .../src/plugin/utils/runtime-visitor-keys.ts | 2 + .../plugin/utils/skip-non-production-files.ts | 3 +- .../src/plugin/utils/some-ast.test.ts | 28 + .../src/plugin/utils/some-ast.ts | 14 + .../plugin/utils/strip-paren-expression.ts | 23 +- .../src/plugin/utils/walk-ast.ts | 7 +- .../utils/walk-inside-statement-blocks.ts | 21 +- .../plugin/utils/walk-own-function-scope.ts | 8 +- .../src/plugin/utils/wrap-ink-rule.ts | 6 +- .../src/plugin/utils/wrap-nextjs-rule.ts | 6 +- .../plugin/utils/wrap-react-native-rule.ts | 6 +- .../plugin/utils/wrap-react-router-rule.ts | 6 +- .../utils/wrap-with-semantic-context.test.ts | 84 + .../utils/wrap-with-semantic-context.ts | 58 +- .../oxlint-plugin-react-doctor/src/rules.ts | 40 +- .../oxlint-plugin-react-doctor/vite.config.ts | 3 +- .../react-doctor/src/cli/commands/inspect.ts | 8 +- .../react-doctor/src/cli/ink/run-scan-app.tsx | 25 +- .../src/cli/utils/build-runtime-layers.ts | 22 +- .../src/cli/utils/rule-catalog.ts | 4 +- packages/react-doctor/src/inspect.ts | 45 +- .../tests/ink/run-scan-app.test.ts | 12 +- .../tests/inspect-action-exit-code.test.ts | 12 +- .../tests/inspect-action-setup-prompt.test.ts | 12 +- .../tests/inspect-action-staged-guard.test.ts | 14 +- .../inspect-action-staged-materialize.test.ts | 8 +- 192 files changed, 16869 insertions(+), 1219 deletions(-) create mode 100644 .changeset/faster-sidecar-probes.md create mode 100644 packages/core/src/utils/create-oxlint-spawn-slots.ts create mode 100644 packages/core/src/utils/create-worker-slots.ts create mode 100644 packages/core/tests/create-worker-slots.test.ts create mode 100644 packages/fuzz/corpus/true-positives/no-mirror-prop-effect--block-return.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/core.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/external-rules.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies-deduplication.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/testlike-program-analysis.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/get-component-render-dependency-analysis.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/get-component-render-dependency-analysis.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/security-scan-rule-registry.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/core-rule-metadata.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/define-rule.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/empty-rule-visitors.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/find-variable-initializer.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/get-equivalent-symbols.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/get-program-browser-global-syntax.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/get-program-browser-global-syntax.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-ast-node.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-node-of-type.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-path-inside.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/node-with-optional-type.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/push-child-nodes-in-reverse.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/reset-filesystem-caches.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-client-secret-recommendation.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-declared-workspace-directories.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-static-export-redirect-recommendation.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/some-ast.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/some-ast.ts diff --git a/.changeset/faster-sidecar-probes.md b/.changeset/faster-sidecar-probes.md new file mode 100644 index 0000000000..3eaa843699 --- /dev/null +++ b/.changeset/faster-sidecar-probes.md @@ -0,0 +1,11 @@ +--- +"@react-doctor/core": patch +"oxlint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Reduce scan startup time and workspace contention by loading lightweight rule +metadata, sharing Oxlint subprocess capacity across projects, and reusing +semantic and filesystem analysis within each scan. Keep cached diagnostics +correct when imported browser guards, Next.js manifests, nested project +targets, or TypeScript path configuration change. diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index 3b3d2ccb86..0be96788e5 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; import { buildSkippedChecks, Config, + createOxlintSpawnSlots, DEFAULT_PROJECT_SCAN_CONCURRENCY, DEFAULT_SHOW_WARNINGS, DeadCode, @@ -15,6 +16,8 @@ import { LintPartialFailures, mapWithConcurrency, mergeReactDoctorConfigs, + OxlintConcurrency, + OxlintSpawnSlots, Progress, Project, Reporter, @@ -25,6 +28,7 @@ import { SupplyChain, type InspectOutput, type ResolvedScanTarget, + type WorkerSlots, } from "@react-doctor/core"; import type { DiagnoseOptions, @@ -53,6 +57,8 @@ interface DiagnoseLayerInput { readonly config: ReactDoctorConfig | null; readonly shouldRunLint: boolean; readonly shouldRunDeadCode: boolean; + readonly oxlintConcurrency: number; + readonly oxlintSpawnSlots: WorkerSlots; readonly configOverrideTarget?: Pick< ResolvedScanTarget, "resolvedDirectory" | "configSourceDirectory" @@ -86,6 +92,8 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => { Git.layerNode, input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]), LintPartialFailures.layerLive, + Layer.succeed(OxlintConcurrency, input.oxlintConcurrency), + Layer.succeed(OxlintSpawnSlots, input.oxlintSpawnSlots), Progress.layerNoop, Reporter.layerNoop, Score.layerHttp, @@ -154,6 +162,8 @@ const diagnoseDirectory = async ( const program = buildInspectProgram(scanTarget, options); const shouldRunLint = resolveShouldRunLint(options, scanTarget.userConfig); const shouldRunDeadCode = resolveShouldRunDeadCode(options, scanTarget.userConfig); + const oxlintConcurrency = Effect.runSync(OxlintConcurrency); + const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency); const output: InspectOutput = await Effect.runPromise( restoreLegacyThrow( @@ -163,6 +173,8 @@ const diagnoseDirectory = async ( config: scanTarget.userConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, }), ), Effect.provide(layerOtlp), @@ -190,6 +202,8 @@ const diagnoseProject = async ( projectDefinition: ProjectDefinition, baseOptions: DiagnoseOptions, batchConfig: ReactDoctorConfig | undefined, + oxlintConcurrency: number, + oxlintSpawnSlots: WorkerSlots, ): Promise => { const startTime = globalThis.performance.now(); @@ -220,6 +234,8 @@ const diagnoseProject = async ( config: effectiveConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, configOverrideTarget: { resolvedDirectory: scanTarget.resolvedDirectory, configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory, @@ -229,6 +245,8 @@ const diagnoseProject = async ( config: effectiveConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, }; const layer = buildDiagnoseLayer(diagnoseLayerInput); @@ -256,13 +274,22 @@ const diagnoseProjectBatch = async ( warnIfAiTrainingEnvironment(); const startTime = globalThis.performance.now(); const { projects, concurrency, config: batchConfig, ...baseOptions } = input; + const oxlintConcurrency = Effect.runSync(OxlintConcurrency); + const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency); // `diagnoseProject` never rejects (failures come back as `ok: false`), // so the pool always drains every project. const projectResults = await mapWithConcurrency( projects, concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY, - (projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig), + (projectDefinition) => + diagnoseProject( + projectDefinition, + baseOptions, + batchConfig, + oxlintConcurrency, + oxlintSpawnSlots, + ), ); const succeededProjects = projectResults.filter((projectResult) => projectResult.ok); diff --git a/packages/core/src/build-diagnostic-pipeline.ts b/packages/core/src/build-diagnostic-pipeline.ts index cebb0ad9db..089c5f3393 100644 --- a/packages/core/src/build-diagnostic-pipeline.ts +++ b/packages/core/src/build-diagnostic-pipeline.ts @@ -1,4 +1,4 @@ -import reactDoctorPlugin from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_RULE_REGISTRY } from "oxlint-plugin-react-doctor/core"; import type { Diagnostic, DiagnosticFileContext, @@ -173,7 +173,7 @@ export const buildDiagnosticPipeline = ( const shouldAutoSuppress = (diagnostic: Diagnostic): boolean => { if (diagnostic.plugin !== "react-doctor") return false; - const rule = reactDoctorPlugin.rules[diagnostic.rule]; + const rule = REACT_DOCTOR_RULE_REGISTRY[diagnostic.rule]; if (!rule?.tags?.includes("test-noise")) return false; if (rule.tags.includes("migration-hint")) return false; return getFileContext(diagnostic.filePath) !== "production"; diff --git a/packages/core/src/check-reduced-motion.ts b/packages/core/src/check-reduced-motion.ts index 35865fe847..9c104030c5 100644 --- a/packages/core/src/check-reduced-motion.ts +++ b/packages/core/src/check-reduced-motion.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor"; +import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor/core"; import ts from "typescript"; import type { Diagnostic } from "./types/index.js"; import { getTypescriptScriptKind } from "./utils/get-typescript-script-kind.js"; diff --git a/packages/core/src/check-security-scan.ts b/packages/core/src/check-security-scan.ts index ae90c97f2e..a7747881cd 100644 --- a/packages/core/src/check-security-scan.ts +++ b/packages/core/src/check-security-scan.ts @@ -1,5 +1,5 @@ -import { REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor"; -import type { FileScan, ScannedFile } from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_SCAN_RULES } from "oxlint-plugin-react-doctor/core"; +import type { FileScan, ScannedFile } from "oxlint-plugin-react-doctor/core"; import { buildSecurityScanDiagnostic } from "./checks/security-scan/build-security-scan-diagnostic.js"; import type { SecurityScanRuleEntry } from "./checks/security-scan/build-security-scan-diagnostic.js"; import { collectSecurityScanFiles } from "./checks/security-scan/collect-security-scan-files.js"; @@ -9,7 +9,7 @@ import type { Diagnostic, ProjectInfo } from "./types/index.js"; import { isPathGitIgnored } from "./utils/is-path-git-ignored.js"; import { shouldEnableRuleByDefaultStatus } from "./utils/should-enable-rule-by-default-status.js"; import { yieldToEventLoop } from "./utils/yield-to-event-loop.js"; -import type { Capability } from "oxlint-plugin-react-doctor"; +import type { Capability } from "oxlint-plugin-react-doctor/core"; export interface CheckSecurityScanOptions { readonly project?: ProjectInfo; @@ -48,7 +48,7 @@ const createSecurityScanSession = ( const ignoredTags = options.ignoredTags ?? new Set(); const includedTags = options.includedTags ?? new Set(); - const enabledScanRules: EnabledScanRule[] = REACT_DOCTOR_RULES.flatMap((entry) => { + const enabledScanRules: EnabledScanRule[] = REACT_DOCTOR_SCAN_RULES.flatMap((entry) => { const rule = entry.rule; const scan = rule.scan; if (typeof scan !== "function") return []; diff --git a/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts b/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts index fe59b80779..8ea5a5c679 100644 --- a/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts +++ b/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts @@ -1,4 +1,4 @@ -import type { ScanFinding, Rule } from "oxlint-plugin-react-doctor"; +import type { ScanFinding, Rule } from "oxlint-plugin-react-doctor/core"; import type { Diagnostic } from "../../types/index.js"; export interface SecurityScanRuleEntry { diff --git a/packages/core/src/checks/security-scan/collect-security-scan-files.ts b/packages/core/src/checks/security-scan/collect-security-scan-files.ts index cba6ada682..9aefb3d7bb 100644 --- a/packages/core/src/checks/security-scan/collect-security-scan-files.ts +++ b/packages/core/src/checks/security-scan/collect-security-scan-files.ts @@ -3,8 +3,8 @@ import * as path from "node:path"; import { classifySecurityScanFile, shouldReadSecurityScanContent, -} from "oxlint-plugin-react-doctor"; -import type { ScannedFile } from "oxlint-plugin-react-doctor"; +} from "oxlint-plugin-react-doctor/core"; +import type { ScannedFile } from "oxlint-plugin-react-doctor/core"; import { readDirectoryEntries } from "../../project-info/fs-utils.js"; import { isLargeMinifiedFile } from "../../utils/is-large-minified-file.js"; import { diff --git a/packages/core/src/dead-code/dead-code-worker-slots.ts b/packages/core/src/dead-code/dead-code-worker-slots.ts index 5ce426cc75..19e912a044 100644 --- a/packages/core/src/dead-code/dead-code-worker-slots.ts +++ b/packages/core/src/dead-code/dead-code-worker-slots.ts @@ -1,67 +1,16 @@ import { resolveDeadCodeConcurrency } from "../utils/resolve-dead-code-concurrency.js"; +import { createWorkerSlots } from "../utils/create-worker-slots.js"; +import type { WorkerSlots } from "../utils/create-worker-slots.js"; -// A process-global counting semaphore bounding how many real deslop dead-code -// child processes run at once, to the memory budget (`resolveDeadCodeConcurrency`). -// -// It's process-global on purpose: the CLI scans the projects of a workspace in -// concurrent `runInspect` fibers within ONE process, and each spawns its own -// dead-code worker — without a shared cap, N concurrent projects could -// oversubscribe memory with N simultaneous children on a small runner. This -// gates only HOW MANY start; each worker still self-terminates via the proven -// one-shot lifecycle (spawn → analyze → exit), so the semaphore adds no -// process-lifecycle surface — it's plain in-process bookkeeping. -// -// `-1` is the un-initialized sentinel; the first acquirer reads the budget once -// (after which the cap is fixed for the process). -let availableSlots = -1; -const waiters: Array<() => void> = []; +let deadCodeWorkerSlots: WorkerSlots | null = null; -const releaseSlot = (): void => { - const nextWaiter = waiters.shift(); - // Hand the slot straight to the next waiter (no increment); only return it to - // the pool when nobody is waiting. Keeps the count balanced either way. - if (nextWaiter !== undefined) nextWaiter(); - else availableSlots += 1; -}; - -/** - * Runs `task` once a dead-code worker slot is free, releasing the slot when the - * task settles (success or failure). With a high cap (roomy machine) every - * caller proceeds immediately; with a low cap (constrained runner) callers - * queue and run as slots free. - * - * `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while - * this caller is queued, the call rejects without acquiring a slot or running - * `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and - * then spawn a child only to tear it down. A queued caller that aborts removes - * its own waiter so a later release never hands a slot to a dead request. - */ export const withDeadCodeWorkerSlot = async ( task: () => Promise, abortSignal?: AbortSignal, ): Promise => { - if (abortSignal?.aborted) throw new Error("Dead-code worker aborted."); - if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency(); - if (availableSlots > 0) { - availableSlots -= 1; - } else { - await new Promise((resolve, reject) => { - const waiter = (): void => { - abortSignal?.removeEventListener("abort", onAbort); - resolve(); - }; - const onAbort = (): void => { - const queuedIndex = waiters.indexOf(waiter); - if (queuedIndex !== -1) waiters.splice(queuedIndex, 1); - reject(new Error("Dead-code worker aborted.")); - }; - waiters.push(waiter); - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); - } - try { - return await task(); - } finally { - releaseSlot(); - } + deadCodeWorkerSlots ??= createWorkerSlots({ + slotCount: resolveDeadCodeConcurrency(), + createAbortError: () => new Error("Dead-code worker aborted."), + }); + return deadCodeWorkerSlots.run(task, abortSignal); }; diff --git a/packages/core/src/get-diagnostic-rule-identity.ts b/packages/core/src/get-diagnostic-rule-identity.ts index 556a38e03a..8dc9f4e604 100644 --- a/packages/core/src/get-diagnostic-rule-identity.ts +++ b/packages/core/src/get-diagnostic-rule-identity.ts @@ -1,4 +1,4 @@ -import reactDoctorPlugin from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_RULE_REGISTRY } from "oxlint-plugin-react-doctor/core"; import type { Diagnostic } from "./types/index.js"; export interface DiagnosticRuleIdentity { @@ -26,6 +26,6 @@ export const getDiagnosticRuleIdentity = (diagnostic: Diagnostic): DiagnosticRul category: diagnostic.category, tags: diagnostic.plugin === "react-doctor" - ? (reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? []) + ? (REACT_DOCTOR_RULE_REGISTRY[diagnostic.rule]?.tags ?? []) : [], }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d8a829745..a6a70b616e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,8 @@ export * from "./utils/assign-fix-groups.js"; export * from "./utils/build-rule-docs-url.js"; export * from "./utils/classify-package-role.js"; export * from "./utils/compute-config-fingerprint.js"; +export * from "./utils/create-oxlint-spawn-slots.js"; +export * from "./utils/create-worker-slots.js"; export * from "./utils/dedupe-diagnostics.js"; export * from "./utils/define-config.js"; export * from "./utils/detect-ai-training-environment.js"; diff --git a/packages/core/src/project-info/capabilities.ts b/packages/core/src/project-info/capabilities.ts index 4cc547c9d9..898b6c5a63 100644 --- a/packages/core/src/project-info/capabilities.ts +++ b/packages/core/src/project-info/capabilities.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import type { Capability } from "oxlint-plugin-react-doctor"; +import type { Capability } from "oxlint-plugin-react-doctor/core"; import type { Framework, ProjectInfo } from "../types/index.js"; import { EARLIEST_GATED_MOBX_MAJOR, @@ -37,6 +37,8 @@ import { parseTailwindMajorMinor, } from "./version.js"; import { detectTargetBlankOpenerProtection } from "./detect-target-blank-opener-protection.js"; +import { findNearestAncestorPackageJson } from "./find-nearest-ancestor-package-json.js"; +import { isFile } from "./fs-utils.js"; import { readPackageJson } from "./package-json.js"; // SPA / mobile frameworks with no server-side form handler at all — @@ -351,9 +353,13 @@ export const getCapabilities = (project: ProjectInfo): ReadonlySet = const cached = capabilitiesByProject.get(project); if (cached !== undefined) return cached; const capabilities = new Set(buildCapabilities(project)); - const packageJson = readPackageJson(path.join(project.rootDirectory, "package.json")); + const packageJsonPath = path.join(project.rootDirectory, "package.json"); + const capabilityRootDirectory = isFile(packageJsonPath) + ? project.rootDirectory + : (findNearestAncestorPackageJson(project.rootDirectory) ?? project.rootDirectory); + const packageJson = readPackageJson(path.join(capabilityRootDirectory, "package.json")); const targetBlankOpenerProtection = detectTargetBlankOpenerProtection( - project.rootDirectory, + capabilityRootDirectory, packageJson, ); if (targetBlankOpenerProtection !== undefined) { diff --git a/packages/core/src/refs.ts b/packages/core/src/refs.ts index ab42473eb9..c01667079b 100644 --- a/packages/core/src/refs.ts +++ b/packages/core/src/refs.ts @@ -12,6 +12,7 @@ import { readPositiveEnvMs } from "./utils/read-positive-env-ms.js"; import { resolveAutoScanConcurrency } from "./utils/resolve-auto-scan-concurrency.js"; import { resolveLintBatchOrdering } from "./utils/resolve-lint-batch-ordering.js"; import { resolveScanConcurrency } from "./utils/resolve-scan-concurrency.js"; +import type { WorkerSlots } from "./utils/create-worker-slots.js"; /** * Per-batch oxlint wall-clock budget. Reads from the env var on @@ -126,6 +127,13 @@ export class OxlintConcurrency extends Context.Reference("react-doctor/O }, }) {} +export class OxlintSpawnSlots extends Context.Reference( + "react-doctor/OxlintSpawnSlots", + { + defaultValue: () => null, + }, +) {} + /** * Three-state control for overlapping the dead-code pass with the lint pass — * forking dead-code as a child fiber that runs DURING lint instead of strictly diff --git a/packages/core/src/rule-metadata.ts b/packages/core/src/rule-metadata.ts index 23acebf132..431b9e9423 100644 --- a/packages/core/src/rule-metadata.ts +++ b/packages/core/src/rule-metadata.ts @@ -1,4 +1,4 @@ -import reactDoctorPlugin from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_RULE_REGISTRY } from "oxlint-plugin-react-doctor/core"; /** * Static, presentation-oriented metadata for a single rule, resolved @@ -26,7 +26,7 @@ const lookupOwnRule = ( defaultEnabled?: boolean; } | undefined => - Object.hasOwn(reactDoctorPlugin.rules, rule) ? reactDoctorPlugin.rules[rule] : undefined; + Object.hasOwn(REACT_DOCTOR_RULE_REGISTRY, rule) ? REACT_DOCTOR_RULE_REGISTRY[rule] : undefined; /** * Returns presentation metadata for `/`, or `null` when the diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts index 0e3a77c4da..1f5d6aa5a8 100644 --- a/packages/core/src/run-oxlint.ts +++ b/packages/core/src/run-oxlint.ts @@ -6,7 +6,7 @@ import { CROSS_FILE_RULE_IDS, collectCrossFileDependencyProbes, resetManifestCaches, -} from "oxlint-plugin-react-doctor"; +} from "oxlint-plugin-react-doctor/core"; import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js"; import { batchIncludePaths } from "./batch-include-paths.js"; import { COOPERATIVE_YIELD_BUDGET_MS } from "./constants.js"; @@ -28,6 +28,7 @@ import type { SidecarDependencyProbe, SidecarLintCache, } from "./runners/oxlint/sidecar-lint-cache.js"; +import type { WorkerSlots } from "./utils/create-worker-slots.js"; import { resolveUserPlugins } from "./runners/oxlint/plugin-resolution.js"; import { resolveOxlintToolchainVersions } from "./runners/oxlint/resolve-toolchain-versions.js"; import { @@ -133,6 +134,7 @@ interface RunOxlintOptions { * exhaustion (see `spawnLintBatches`). */ concurrency?: number; + spawnSlots?: WorkerSlots; /** * Aborted when the orchestrator's lint-phase timeout fires; forwarded to * `spawnLintBatches` so in-flight oxlint subprocesses are torn down instead @@ -405,11 +407,11 @@ export const runOxlint = async (options: RunOxlintOptions): Promise = {}; for (const registryEntry of REACT_DOCTOR_RULES) { - const rule = reactDoctorPlugin.rules[registryEntry.id]; + const rule = REACT_DOCTOR_RULE_REGISTRY[registryEntry.id]; if (!rule) continue; // Per-file-cache partition: the cacheable config drops the cross-file // rules (they run always-fresh in the sidecar); the sidecar config keeps @@ -181,7 +182,7 @@ export const createOxlintConfig = ({ } // Scan rules run via core's check-security-scan environment // check, not oxlint — registering them would only add dead visitors. - if (rule.scan !== undefined) continue; + if (rule.isScanRule) continue; // `customRulesOnly` mirrors the historical behavior of the pre-port // builtin-react / builtin-a11y gate — skip everything ported 1:1 // from upstream OXC plugins. diff --git a/packages/core/src/runners/oxlint/parse-output.ts b/packages/core/src/runners/oxlint/parse-output.ts index c6840c1583..7ae3650dd6 100644 --- a/packages/core/src/runners/oxlint/parse-output.ts +++ b/packages/core/src/runners/oxlint/parse-output.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import reactDoctorPlugin from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_RULE_REGISTRY } from "oxlint-plugin-react-doctor/core"; import type { CleanedDiagnostic, Diagnostic, @@ -117,7 +117,7 @@ const lookupOwnString = (record: Record, key: string): string | // public-env prefix); everything else renders the static `recommendation`. // Core carries no rule-specific prose or rule-name matches here. const getRuleRecommendation = (ruleName: string, project: ProjectInfo): string | undefined => { - const rule = reactDoctorPlugin.rules[ruleName]; + const rule = REACT_DOCTOR_RULE_REGISTRY[ruleName]; if (!rule) return undefined; if (rule.recommendationFor) { const capabilities = getCapabilities(project); @@ -134,13 +134,13 @@ const getRuleRecommendation = (ruleName: string, project: ProjectInfo): string | // scan summary. Used by `resolveDiagnosticCategory` below and by // `validateRuleRegistration` to assert per-rule metadata coverage. export const getRuleCategory = (ruleName: string): string | undefined => - reactDoctorPlugin.rules[ruleName]?.category; + REACT_DOCTOR_RULE_REGISTRY[ruleName]?.category; // Short human headline for a rule (e.g. "Array index used as a key"). // Only react-doctor rules carry one; adopted third-party rules return // undefined and renderers fall back to the `plugin/rule` id. const getRuleTitle = (ruleName: string): string | undefined => - reactDoctorPlugin.rules[ruleName]?.title; + REACT_DOCTOR_RULE_REGISTRY[ruleName]?.title; // react-doctor rules carry their own `title`; adopted React Compiler // diagnostics get a fixed human headline instead of their bare id. @@ -257,7 +257,7 @@ const resolveDiagnosticCategory = (plugin: string, rule: string): string => { // rules in other categories opt in via their `matchByOccurrence` flag. const resolveMatchByOccurrence = (rule: string, category: string): boolean => OCCURRENCE_MATCHED_CATEGORIES.has(category) || - Boolean(reactDoctorPlugin.rules[rule]?.matchByOccurrence); + Boolean(REACT_DOCTOR_RULE_REGISTRY[rule]?.matchByOccurrence); /** * Maps oxlint's non-primary labels (`labels[1..]`) into related source @@ -441,7 +441,7 @@ export const parseOxlintOutput = ( // HACK: Astro's canonical TSX conversion adds wrapper fragments and keeps // HTML attribute names. Only design-tagged rules consume this shadow; // native Astro linting still handles scripts and every other rule. - if (plugin !== "react-doctor" || !reactDoctorPlugin.rules[rule]?.tags?.includes("design")) { + if (plugin !== "react-doctor" || !REACT_DOCTOR_RULE_REGISTRY[rule]?.tags?.includes("design")) { return []; } const labels = mapPreparedSourceLabels(diagnostic.labels, preparedSourceMap); diff --git a/packages/core/src/runners/oxlint/plugin-resolution.ts b/packages/core/src/runners/oxlint/plugin-resolution.ts index 35296a5c36..de76896920 100644 --- a/packages/core/src/runners/oxlint/plugin-resolution.ts +++ b/packages/core/src/runners/oxlint/plugin-resolution.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import * as path from "node:path"; -import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor"; +import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor/core"; import { messageFromUnknown } from "../../utils/message-from-unknown.js"; import { warnConfigIssue } from "../../utils/warn-config-issue.js"; diff --git a/packages/core/src/runners/oxlint/spawn-batches.ts b/packages/core/src/runners/oxlint/spawn-batches.ts index 1ea63b44c5..3653211530 100644 --- a/packages/core/src/runners/oxlint/spawn-batches.ts +++ b/packages/core/src/runners/oxlint/spawn-batches.ts @@ -15,6 +15,7 @@ import { dedupeDiagnostics } from "../../utils/dedupe-diagnostics.js"; import { mapWithConcurrency } from "../../utils/map-with-concurrency.js"; import { remainingDeadlineBudgetMs } from "../../utils/remaining-deadline-budget-ms.js"; import { resolveScanConcurrency } from "../../utils/resolve-scan-concurrency.js"; +import type { WorkerSlots } from "../../utils/create-worker-slots.js"; import { parseOxlintOutput } from "./parse-output.js"; import { spawnOxlint } from "./spawn-oxlint.js"; @@ -93,6 +94,7 @@ export interface SpawnLintBatchesInput { * resource error replays once with a single worker. */ readonly concurrency?: number; + readonly spawnSlots?: WorkerSlots; } interface BatchPassOutcome { @@ -111,6 +113,13 @@ interface BatchPassOutcome { readonly firstNonOomDropReason: string | null; } +interface BatchState { + deadlineMs: number | null; + deadlineSkippedFileCount: number; + didStart: boolean; + initialFileCount: number; +} + /** * Runs every prebuilt file batch through oxlint, with binary-split * retry on the splittable error classes (timeout / output-too-large / @@ -219,7 +228,7 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise => { // Past the --max-duration budget: skip instead of spawning, even inside a // binary-split retry, so a batch that started just before the deadline @@ -231,14 +240,31 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise => { + if (isPastDeadline()) { + deadlineSkippedFiles.push(...batch); + batchState.deadlineSkippedFileCount += batch.length; + return Promise.resolve(null); + } + return spawnOxlint( + batchArgs, + rootDirectory, + nodeBinaryPath, + spawnTimeoutMs, + outputMaxBytes, + signal, + () => { + if (batchState.didStart) return; + batchState.didStart = true; + startedFileCount += batchState.initialFileCount; + }, + ); + }; + const stdout = + input.spawnSlots === undefined + ? await spawnBatch() + : await input.spawnSlots.run(spawnBatch, signal); + if (stdout === null) return []; return parseOxlintOutput( stdout, project, @@ -311,16 +337,17 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise void, ): Promise => new Promise((resolve, reject) => { if (abortSignal?.aborted) { @@ -52,6 +53,7 @@ export const spawnOxlint = ( ); return; } + onSpawn?.(); const child = spawn( nodeBinaryPath, buildProfiledNodeArguments({ diff --git a/packages/core/src/runners/oxlint/validate-rule-registration.ts b/packages/core/src/runners/oxlint/validate-rule-registration.ts index 3026602b52..ed91bedfe9 100644 --- a/packages/core/src/runners/oxlint/validate-rule-registration.ts +++ b/packages/core/src/runners/oxlint/validate-rule-registration.ts @@ -1,7 +1,8 @@ -import reactDoctorPlugin, { +import { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, -} from "oxlint-plugin-react-doctor"; + REACT_DOCTOR_RULE_REGISTRY, +} from "oxlint-plugin-react-doctor/core"; import { getRuleCategory } from "./parse-output.js"; let didValidate = false; @@ -28,10 +29,13 @@ export const validateRuleRegistration = (): void => { if (!getRuleCategory(ruleName)) { missingCategory.push(fullKey); } - if (!reactDoctorPlugin.rules[ruleName]?.recommendation) { + if (!REACT_DOCTOR_RULE_REGISTRY[ruleName]?.recommendation) { missingHelp.push(fullKey); } - if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) { + if ( + FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && + !REACT_DOCTOR_RULE_REGISTRY[ruleName]?.requires + ) { missingMetadata.push(fullKey); } } diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 7e6ad5c39e..31fd48da2c 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { FRAMEWORK_TOKENS } from "oxlint-plugin-react-doctor"; +import { FRAMEWORK_TOKENS } from "oxlint-plugin-react-doctor/core"; import * as Schema from "effect/Schema"; export const Severity = Schema.Literals(["error", "warning"]); diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index e2cec40dcc..6f75912ac5 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -9,6 +9,7 @@ import { LintBatchOrdering, OxlintConcurrency, OxlintOutputMaxBytes, + OxlintSpawnSlots, OxlintSpawnTimeoutMs, PerFileLintCacheEnabled, SidecarLintCacheEnabled, @@ -129,6 +130,7 @@ export class Linter extends Context.Service< const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs; const outputMaxBytes = yield* OxlintOutputMaxBytes; const concurrency = yield* OxlintConcurrency; + const spawnSlots = yield* OxlintSpawnSlots; const lintBatchOrdering = yield* LintBatchOrdering; const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled; const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled; @@ -162,6 +164,7 @@ export class Linter extends Context.Service< onSidecarStats: input.onSidecarStats, spawnTimeoutMs, outputMaxBytes, + spawnSlots: spawnSlots ?? undefined, concurrency, signal, lintBatchOrdering, diff --git a/packages/core/src/types/project-info.ts b/packages/core/src/types/project-info.ts index e70bd2ac02..0b6f0e44a5 100644 --- a/packages/core/src/types/project-info.ts +++ b/packages/core/src/types/project-info.ts @@ -1,4 +1,4 @@ -import type { FrameworkToken } from "oxlint-plugin-react-doctor"; +import type { FrameworkToken } from "oxlint-plugin-react-doctor/core"; // Aliased to the plugin's capability vocabulary: `buildCapabilities` emits // `project.framework` as a capability token, so the two unions must be one. diff --git a/packages/core/src/utils/create-oxlint-spawn-slots.ts b/packages/core/src/utils/create-oxlint-spawn-slots.ts new file mode 100644 index 0000000000..dcb37fdfee --- /dev/null +++ b/packages/core/src/utils/create-oxlint-spawn-slots.ts @@ -0,0 +1,15 @@ +import { OxlintSpawnFailed, ReactDoctorError } from "../errors.js"; +import { createWorkerSlots } from "./create-worker-slots.js"; +import type { WorkerSlots } from "./create-worker-slots.js"; +import { resolveScanConcurrency } from "./resolve-scan-concurrency.js"; + +const createLintPhaseAbortError = (): ReactDoctorError => + new ReactDoctorError({ + reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }), + }); + +export const createOxlintSpawnSlots = (concurrency: number): WorkerSlots => + createWorkerSlots({ + slotCount: resolveScanConcurrency(concurrency), + createAbortError: createLintPhaseAbortError, + }); diff --git a/packages/core/src/utils/create-worker-slots.ts b/packages/core/src/utils/create-worker-slots.ts new file mode 100644 index 0000000000..a2c20262b9 --- /dev/null +++ b/packages/core/src/utils/create-worker-slots.ts @@ -0,0 +1,65 @@ +export interface WorkerSlots { + readonly run: (task: () => Promise, abortSignal?: AbortSignal) => Promise; +} + +interface WorkerSlotWaiter { + readonly resolve: () => void; + readonly abortSignal: AbortSignal | undefined; + readonly onAbort: () => void; +} + +interface CreateWorkerSlotsInput { + readonly slotCount: number; + readonly createAbortError: () => Error; +} + +export const createWorkerSlots = (input: CreateWorkerSlotsInput): WorkerSlots => { + let availableSlotCount = input.slotCount; + const waiters: WorkerSlotWaiter[] = []; + + const releaseSlot = (): void => { + const nextWaiter = waiters.shift(); + if (nextWaiter === undefined) { + availableSlotCount += 1; + return; + } + nextWaiter.abortSignal?.removeEventListener("abort", nextWaiter.onAbort); + nextWaiter.resolve(); + }; + + const acquireSlot = async (abortSignal?: AbortSignal): Promise => { + if (abortSignal?.aborted) throw input.createAbortError(); + if (availableSlotCount > 0) { + availableSlotCount -= 1; + return; + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + const waiterIndex = waiters.indexOf(waiter); + if (waiterIndex !== -1) waiters.splice(waiterIndex, 1); + reject(input.createAbortError()); + }; + const waiter: WorkerSlotWaiter = { + resolve, + abortSignal, + onAbort, + }; + waiters.push(waiter); + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); + }; + + return { + run: async ( + task: () => Promise, + abortSignal?: AbortSignal, + ): Promise => { + await acquireSlot(abortSignal); + try { + return await task(); + } finally { + releaseSlot(); + } + }, + }; +}; diff --git a/packages/core/src/utils/has-published-fix-recipe.ts b/packages/core/src/utils/has-published-fix-recipe.ts index 33b09ee15f..ec55470276 100644 --- a/packages/core/src/utils/has-published-fix-recipe.ts +++ b/packages/core/src/utils/has-published-fix-recipe.ts @@ -1,4 +1,4 @@ -import reactDoctorPlugin from "oxlint-plugin-react-doctor"; +import { REACT_DOCTOR_RULE_REGISTRY } from "oxlint-plugin-react-doctor/core"; import type { Diagnostic } from "../types/index.js"; /** @@ -16,4 +16,5 @@ import type { Diagnostic } from "../types/index.js"; * a 404. Gate the directive on this predicate. */ export const hasPublishedFixRecipe = (diagnostic: Pick): boolean => - diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule); + diagnostic.plugin === "react-doctor" && + Object.hasOwn(REACT_DOCTOR_RULE_REGISTRY, diagnostic.rule); diff --git a/packages/core/tests/create-worker-slots.test.ts b/packages/core/tests/create-worker-slots.test.ts new file mode 100644 index 0000000000..0c9f7b468d --- /dev/null +++ b/packages/core/tests/create-worker-slots.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createWorkerSlots } from "../src/utils/create-worker-slots.js"; + +interface Deferred { + readonly promise: Promise; + readonly resolve: () => void; +} + +const createDeferred = (): Deferred => { + let resolvePromise = (): void => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; + +const flushTasks = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +const createTestWorkerSlots = (slotCount: number) => + createWorkerSlots({ + slotCount, + createAbortError: () => new Error("aborted"), + }); + +describe("createWorkerSlots", () => { + it("enforces the peak slot count and admits queued tasks in FIFO order", async () => { + const workerSlots = createTestWorkerSlots(2); + const firstRelease = createDeferred(); + const secondRelease = createDeferred(); + const thirdRelease = createDeferred(); + const fourthRelease = createDeferred(); + const startedTasks: string[] = []; + let runningTaskCount = 0; + let peakRunningTaskCount = 0; + + const runTask = (name: string, release: Deferred): Promise => + workerSlots.run(async () => { + startedTasks.push(name); + runningTaskCount += 1; + peakRunningTaskCount = Math.max(peakRunningTaskCount, runningTaskCount); + await release.promise; + runningTaskCount -= 1; + return name; + }); + + const results = Promise.all([ + runTask("first", firstRelease), + runTask("second", secondRelease), + runTask("third", thirdRelease), + runTask("fourth", fourthRelease), + ]); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second"]); + + secondRelease.resolve(); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second", "third"]); + + firstRelease.resolve(); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second", "third", "fourth"]); + + thirdRelease.resolve(); + fourthRelease.resolve(); + expect(await results).toEqual(["first", "second", "third", "fourth"]); + expect(peakRunningTaskCount).toBe(2); + }); + + it("releases slots after rejection", async () => { + const workerSlots = createTestWorkerSlots(1); + await expect( + workerSlots.run(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(workerSlots.run(async () => "after")).resolves.toBe("after"); + }); + + it("removes an aborted waiter without running it or leaking a slot", async () => { + const workerSlots = createTestWorkerSlots(1); + const heldRelease = createDeferred(); + const heldTask = workerSlots.run(() => heldRelease.promise); + await flushTasks(); + + const abortController = new AbortController(); + let didRunAbortedTask = false; + const abortedTask = workerSlots.run(async () => { + didRunAbortedTask = true; + }, abortController.signal); + abortController.abort(); + + await expect(abortedTask).rejects.toThrow("aborted"); + expect(didRunAbortedTask).toBe(false); + heldRelease.resolve(); + await heldTask; + await expect(workerSlots.run(async () => "after")).resolves.toBe("after"); + }); +}); diff --git a/packages/core/tests/detect-target-blank-opener-protection.test.ts b/packages/core/tests/detect-target-blank-opener-protection.test.ts index 04e1a9ad1c..bc9477a130 100644 --- a/packages/core/tests/detect-target-blank-opener-protection.test.ts +++ b/packages/core/tests/detect-target-blank-opener-protection.test.ts @@ -189,6 +189,40 @@ describe("detectTargetBlankOpenerProtection", () => { expect(capabilities.has("target-blank-needs-noreferrer")).toBe(true); }); + it.each([ + [ + "nested-browser-target", + { + name: "nested-browser-target", + dependencies: { react: "^18.0.0" }, + browserslist: ["chrome 80"], + }, + false, + ], + [ + "nested-electron-target", + { + name: "nested-electron-target", + dependencies: { react: "^18.0.0" }, + devDependencies: { electron: "^0.36.0" }, + }, + true, + ], + ])( + "inherits target-blank policy from the enclosing package for %s", + (caseName, packageJson, needsNoreferrer) => { + const projectDirectory = setupProject(caseName, packageJson); + const nestedDirectory = path.join(projectDirectory, "src", "components"); + fs.mkdirSync(nestedDirectory, { recursive: true }); + fs.writeFileSync(path.join(nestedDirectory, "button.tsx"), "export const Button = null;\n"); + + const capabilities = getCapabilities(discoverProject(nestedDirectory)); + + expect(capabilities.has("target-blank-needs-explicit-protection")).toBe(true); + expect(capabilities.has("target-blank-needs-noreferrer")).toBe(needsNoreferrer); + }, + ); + it("requires noopener once Electron adopted Chromium 49", () => { const packageJson: PackageJson = { name: "electron-0-37", diff --git a/packages/core/tests/spawn-batches.test.ts b/packages/core/tests/spawn-batches.test.ts index 85039bd482..37d9aa4c05 100644 --- a/packages/core/tests/spawn-batches.test.ts +++ b/packages/core/tests/spawn-batches.test.ts @@ -17,6 +17,7 @@ import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import type { ProjectInfo } from "@react-doctor/core"; import { spawnLintBatches } from "../src/runners/oxlint/spawn-batches.js"; +import { createOxlintSpawnSlots } from "../src/utils/create-oxlint-spawn-slots.js"; const project: ProjectInfo = { rootDirectory: "/tmp/app", @@ -155,6 +156,64 @@ describe("spawnLintBatches concurrency", () => { const peak = await runMarkedBatches(4, 1); expect(peak).toBe(1); }); + + it("shares one subprocess cap across concurrent project batch runners", async () => { + const markDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-parallel-")); + const markFile = path.join(markDirectory, "marks.txt"); + fs.writeFileSync(markFile, ""); + const script = [ + 'const fs = require("fs");', + `const markFile = ${JSON.stringify(markFile)};`, + 'fs.appendFileSync(markFile, "+");', + "const files = process.argv.slice(1);", + "setTimeout(() => {", + ' fs.appendFileSync(markFile, "-");', + " const diagnostics = files.map((filename) => ({", + ' message: "Array index used as a key",', + ' code: "react-doctor(no-array-index-as-key)",', + ' severity: "warning",', + ' causes: [], url: "", help: "",', + " filename,", + ' labels: [{ label: "", span: { offset: 0, length: 1, line: 1, column: 1 } }],', + " related: [],", + " }));", + " process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));", + `}, ${SLEEP_MS});`, + ].join("\n"); + const spawnSlots = createOxlintSpawnSlots(2); + const runProjectBatches = (projectName: string) => + spawnLintBatches({ + baseArgs: ["-e", script], + fileBatches: Array.from({ length: 3 }, (_unused, index) => [ + `src/${projectName}-${index}.tsx`, + ]), + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + concurrency: 3, + spawnSlots, + }); + + try { + const [firstDiagnostics, secondDiagnostics] = await Promise.all([ + runProjectBatches("first"), + runProjectBatches("second"), + ]); + expect(computePeakConcurrency(fs.readFileSync(markFile, "utf8"))).toBe(2); + expect(firstDiagnostics.map((diagnostic) => diagnostic.filePath)).toEqual([ + "src/first-0.tsx", + "src/first-1.tsx", + "src/first-2.tsx", + ]); + expect(secondDiagnostics.map((diagnostic) => diagnostic.filePath)).toEqual([ + "src/second-0.tsx", + "src/second-1.tsx", + "src/second-2.tsx", + ]); + } finally { + fs.rmSync(markDirectory, { recursive: true, force: true }); + } + }); }); /** @@ -178,6 +237,81 @@ const EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT = [ "process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));", ].join("\n"); +describe("spawnLintBatches shared slot timing", () => { + it("starts the subprocess timeout only after a queued slot is acquired", async () => { + const spawnSlots = createOxlintSpawnSlots(1); + const progressUpdates: Array = []; + let releaseHeldSlot = (): void => {}; + const heldSlot = spawnSlots.run( + () => + new Promise((resolve) => { + releaseHeldSlot = resolve; + }), + ); + await Promise.resolve(); + + const diagnosticsPromise = spawnLintBatches({ + baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], + fileBatches: [["src/queued-a.tsx", "src/queued-b.tsx"]], + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + spawnTimeoutMs: 2_000, + spawnSlots, + onFileProgress: (scannedFileCount, totalFileCount) => { + progressUpdates.push([scannedFileCount, totalFileCount]); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2_200)); + expect(progressUpdates).toEqual([]); + releaseHeldSlot(); + await heldSlot; + + await expect(diagnosticsPromise).resolves.toMatchObject([ + { filePath: "src/queued-a.tsx" }, + { filePath: "src/queued-b.tsx" }, + ]); + expect(progressUpdates.at(-1)).toEqual([2, 2]); + }); + + it("rechecks the scan deadline after a queued slot is acquired", async () => { + const spawnSlots = createOxlintSpawnSlots(1); + let releaseHeldSlot = (): void => {}; + const heldSlot = spawnSlots.run( + () => + new Promise((resolve) => { + releaseHeldSlot = resolve; + }), + ); + await Promise.resolve(); + const partialFailures: string[] = []; + const progressUpdates: Array = []; + + const diagnosticsPromise = spawnLintBatches({ + baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], + fileBatches: [["src/deadline-a.tsx", "src/deadline-b.tsx"]], + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + deadlineEpochMs: Date.now() + 50, + spawnSlots, + onPartialFailure: (reason) => partialFailures.push(reason), + onFileProgress: (scannedFileCount, totalFileCount) => { + progressUpdates.push([scannedFileCount, totalFileCount]); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + releaseHeldSlot(); + await heldSlot; + + await expect(diagnosticsPromise).resolves.toEqual([]); + expect(partialFailures).toHaveLength(1); + expect(partialFailures[0]).toContain("2 file(s) skipped"); + expect(partialFailures[0]).toContain("max scan duration reached"); + expect(progressUpdates).toEqual([]); + }); +}); + const lintFileBatches = (fileBatches: string[][]) => spawnLintBatches({ baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index c61d0f5c97..2a68bfdfef 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ find: /^@react-doctor\/core\/schemas$/, replacement: path.join(packageRoot, "src/schemas.ts"), }, + { + find: /^oxlint-plugin-react-doctor\/core$/, + replacement: path.join(packageRoot, "../oxlint-plugin-react-doctor/src/core.ts"), + }, { find: /^oxlint-plugin-react-doctor$/, replacement: path.join(packageRoot, "../oxlint-plugin-react-doctor/src/index.ts"), diff --git a/packages/fuzz/corpus/true-positives/no-mirror-prop-effect--block-return.tsx b/packages/fuzz/corpus/true-positives/no-mirror-prop-effect--block-return.tsx new file mode 100644 index 0000000000..89190fba4c --- /dev/null +++ b/packages/fuzz/corpus/true-positives/no-mirror-prop-effect--block-return.tsx @@ -0,0 +1,12 @@ +// rule: no-mirror-prop-effect +// weakness: callback-return-shape +// source: strict verdict-preserving fuzz +// verdict: fail + +export const Form = ({ value }) => { + const [draft, setDraft] = useState(value); + useEffect(() => { + return setDraft(value); + }, [value]); + return {draft}; +}; diff --git a/packages/oxlint-plugin-react-doctor/README.md b/packages/oxlint-plugin-react-doctor/README.md index 97b9a03cab..1fb72099a2 100644 --- a/packages/oxlint-plugin-react-doctor/README.md +++ b/packages/oxlint-plugin-react-doctor/README.md @@ -52,6 +52,13 @@ Run oxlint as normal: npx oxlint . ``` +## Lightweight engine entry + +`oxlint-plugin-react-doctor/core` exposes rule metadata, cross-file collectors, +and project scan rules without loading the full oxlint visitor registry. It is +intended for engine integrations. Oxlint configurations should continue using +the package's root entry. + ## Available rules The full rule list lives in [`rule-registry.ts`](https://github.com/millionco/react-doctor/blob/main/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts). All rules are namespaced under `react-doctor/*`. diff --git a/packages/oxlint-plugin-react-doctor/package.json b/packages/oxlint-plugin-react-doctor/package.json index c5ad11b336..c6352ae172 100644 --- a/packages/oxlint-plugin-react-doctor/package.json +++ b/packages/oxlint-plugin-react-doctor/package.json @@ -39,13 +39,17 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./core": { + "types": "./dist/core.d.ts", + "default": "./dist/core.js" } }, "scripts": { "dev": "vp pack --watch", "build": "pnpm gen && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack", - "gen": "node ./scripts/generate-rule-registry.mjs", - "gen:check": "node ./scripts/generate-rule-registry.mjs && git diff --exit-code -- src/plugin/rule-registry.ts", + "gen": "tsx ./scripts/generate-rule-registry.mjs && vp fmt src/plugin/core-rule-registry-data.json", + "gen:check": "pnpm gen && git diff --exit-code -- src/plugin/core-rule-registry-data.json src/plugin/rule-registry.ts src/plugin/security-scan-rule-registry.ts", "gen:fixtures": "node ./scripts/extract-oxc-fixtures.mjs /tmp/oxc", "gen:fixture-tests": "node ./scripts/generate-fixture-tests.mjs", "gen:react-hooks-fixtures": "node ./scripts/extract-react-hooks-tests.mjs /tmp/react/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js src/plugin/rules/react-builtins/__upstream-fixtures__/rules-of-hooks.json && node ./scripts/extract-react-hooks-tests.mjs /tmp/react/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js src/plugin/rules/react-builtins/__upstream-fixtures__/exhaustive-deps.json", 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 8cd77bd230..ba5ba65e27 100644 --- a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs +++ b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs @@ -13,12 +13,20 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); const PLUGIN_RULES_ROOT = path.join(PACKAGE_ROOT, "src/plugin/rules"); +const CORE_REGISTRY_DATA_OUTPUT = path.join( + PACKAGE_ROOT, + "src/plugin/core-rule-registry-data.json", +); const REGISTRY_OUTPUT = path.join(PACKAGE_ROOT, "src/plugin/rule-registry.ts"); +const SECURITY_SCAN_REGISTRY_OUTPUT = path.join( + PACKAGE_ROOT, + "src/plugin/security-scan-rule-registry.ts", +); const GENERATED_LINE_WIDTH = 100; // Bucket directory → framework (each rule's `framework` field is derived, @@ -320,6 +328,7 @@ for (const bucket of fs.readdirSync(PLUGIN_RULES_ROOT, { withFileTypes: true })) ruleEntries.push({ ruleId, identifier, + filePath, relativeImport, framework, category, @@ -344,6 +353,17 @@ for (const entry of ruleEntries) { seenRuleIds.add(entry.ruleId); } +await Promise.all( + ruleEntries.map(async (entry) => { + const ruleModule = await import(pathToFileURL(entry.filePath).href); + const sourceRule = ruleModule[entry.identifier]; + if (typeof sourceRule !== "object" || sourceRule === null) { + throw new Error(`Rule export not found: ${entry.identifier} in ${entry.filePath}`); + } + entry.sourceRule = sourceRule; + }), +); + const importLines = ruleEntries .map((entry) => `import { ${entry.identifier} } from "${entry.relativeImport}";`) .join("\n"); @@ -404,25 +424,28 @@ const formatRequiresLine = (entry) => { // `entry.rule.framework` / `.category` / `.severity` so we don't ship // the same value twice per entry. Saves ~3 lines × N rules on the // generated file and on the published bundle. -const ruleLines = ruleEntries - .map( - (entry) => - ` {\n` + - ` key: "react-doctor/${entry.ruleId}",\n` + - ` id: "${entry.ruleId}",\n` + - ` source: "react-doctor",\n` + - ` originallyExternal: ${entry.originallyExternal},\n` + - ` rule: {\n` + - ` ...${entry.identifier},\n` + - ` framework: "${entry.framework}",\n` + - ` category: "${entry.category}",\n` + - (entry.shouldSynthesizeDefaultDisabled ? ` defaultEnabled: false,\n` : "") + - formatAutoTagsLine(entry) + - formatRequiresLine(entry) + - ` },\n` + - ` },`, - ) - .join("\n"); +const formatRuleLines = (entries) => + entries + .map( + (entry) => + ` {\n` + + ` key: "react-doctor/${entry.ruleId}",\n` + + ` id: "${entry.ruleId}",\n` + + ` source: "react-doctor",\n` + + ` originallyExternal: ${entry.originallyExternal},\n` + + ` rule: {\n` + + ` ...${entry.identifier},\n` + + ` framework: "${entry.framework}",\n` + + ` category: "${entry.category}",\n` + + (entry.shouldSynthesizeDefaultDisabled ? ` defaultEnabled: false,\n` : "") + + formatAutoTagsLine(entry) + + formatRequiresLine(entry) + + ` },\n` + + ` },`, + ) + .join("\n"); + +const ruleLines = formatRuleLines(ruleEntries); const generatedSource = `// GENERATED FILE — do not edit by hand. Run \`pnpm gen\` to regenerate. // Source of truth: every \`export const = defineRule({ id: "...", ... })\` @@ -447,4 +470,73 @@ export const ruleRegistry: Record = Object.fromEntries( `; fs.writeFileSync(REGISTRY_OUTPUT, generatedSource); + +const recommendationOverrideByRuleId = { + "nextjs-no-client-side-redirect": "static-export-redirect", + "no-secrets-in-client-code": "client-secret", +}; + +const coreRuleEntries = ruleEntries.map((entry) => { + const sourceRule = entry.sourceRule; + const recommendationOverride = recommendationOverrideByRuleId[entry.ruleId]; + if (typeof sourceRule.recommendationFor === "function" && recommendationOverride === undefined) { + throw new Error(`Missing core recommendation override for rule: ${entry.ruleId}`); + } + const tags = [...new Set([...entry.autoTags, ...(sourceRule.tags ?? [])])]; + const requires = [...new Set([...entry.requiredCapabilities, ...(sourceRule.requires ?? [])])]; + return { + key: `react-doctor/${entry.ruleId}`, + id: entry.ruleId, + source: "react-doctor", + originallyExternal: entry.originallyExternal, + rule: { + id: entry.ruleId, + title: sourceRule.title, + severity: entry.severity, + recommendation: sourceRule.recommendation, + recommendationOverride, + category: entry.category, + framework: entry.framework, + requires: requires.length > 0 ? requires : undefined, + disabledWhen: sourceRule.disabledWhen, + tags: tags.length > 0 ? tags : undefined, + defaultEnabled: + entry.shouldSynthesizeDefaultDisabled || sourceRule.defaultEnabled === false + ? false + : undefined, + matchByOccurrence: sourceRule.matchByOccurrence, + isScanRule: typeof sourceRule.scan === "function", + }, + }; +}); + +fs.writeFileSync(CORE_REGISTRY_DATA_OUTPUT, `${JSON.stringify(coreRuleEntries, null, 2)}\n`); + +const securityScanEntries = ruleEntries.filter( + (entry) => typeof entry.sourceRule.scan === "function", +); +const securityScanImportLines = securityScanEntries + .map((entry) => `import { ${entry.identifier} } from "${entry.relativeImport}";`) + .join("\n"); +const securityScanCapabilityImport = securityScanEntries.some( + (entry) => entry.requiredCapabilities.length > 0, +) + ? `import type { Capability } from "./utils/capability.js";` + : ""; +const securityScanGeneratedSource = `// GENERATED FILE — do not edit by hand. Run \`pnpm gen\` to regenerate. + +${[securityScanCapabilityImport, securityScanImportLines].filter(Boolean).join("\n\n")} + +export const reactDoctorScanRules = [ +${formatRuleLines(securityScanEntries)} +] as const; +`; +fs.writeFileSync(SECURITY_SCAN_REGISTRY_OUTPUT, securityScanGeneratedSource); + console.log(`Wrote ${path.relative(PACKAGE_ROOT, REGISTRY_OUTPUT)} (${ruleEntries.length} rules)`); +console.log( + `Wrote ${path.relative(PACKAGE_ROOT, CORE_REGISTRY_DATA_OUTPUT)} (${coreRuleEntries.length} rules)`, +); +console.log( + `Wrote ${path.relative(PACKAGE_ROOT, SECURITY_SCAN_REGISTRY_OUTPUT)} (${securityScanEntries.length} rules)`, +); diff --git a/packages/oxlint-plugin-react-doctor/src/core.ts b/packages/oxlint-plugin-react-doctor/src/core.ts new file mode 100644 index 0000000000..c3c5f900e9 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/core.ts @@ -0,0 +1,44 @@ +import { CORE_REACT_DOCTOR_RULES, CORE_RULE_REGISTRY } from "./plugin/core-rule-registry.js"; + +export { EXTERNAL_RULES, REACT_COMPILER_RULES } from "./external-rules.js"; + +export const REACT_DOCTOR_RULES = CORE_REACT_DOCTOR_RULES; +export const REACT_DOCTOR_RULE_REGISTRY = CORE_RULE_REGISTRY; +export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet = new Set( + CORE_REACT_DOCTOR_RULES.map((entry) => entry.key), +); +export const FRAMEWORK_SPECIFIC_RULE_KEYS: ReadonlySet = new Set( + CORE_REACT_DOCTOR_RULES.filter((entry) => entry.rule.framework !== "global").map( + (entry) => entry.key, + ), +); + +export { MOTION_LIBRARY_PACKAGES } from "./plugin/constants/style.js"; +export { CROSS_FILE_RULE_IDS } from "./plugin/constants/cross-file-rule-ids.js"; +export { + CROSS_FILE_DEPENDENCY_COLLECTORS, + UNBOUNDED_CROSS_FILE_RULE_IDS, + collectCrossFileDependencyProbes, +} from "./plugin/cross-file-dependencies.js"; +export type { CrossFileProbeTrace } from "./plugin/utils/cross-file-probe-recorder.js"; +export { reactDoctorScanRules as REACT_DOCTOR_SCAN_RULES } from "./plugin/security-scan-rule-registry.js"; +export { resetFilesystemCaches as resetManifestCaches } from "./plugin/utils/reset-filesystem-caches.js"; + +export { + classifySecurityScanFile, + shouldReadSecurityScanContent, +} from "./plugin/rules/security-scan/utils/classify-security-scan-file.js"; + +export { + REACT_NATIVE_DEPENDENCY_NAMES, + REACT_NATIVE_DEPENDENCY_PREFIXES, + isReactNativeDependencyName, +} from "./react-native-dependency-names.js"; + +export { FRAMEWORK_TOKENS } from "./plugin/utils/capability.js"; +export type { Capability, CapabilityQuery, FrameworkToken } from "./plugin/utils/capability.js"; +export type { CoreRuleMetadata } from "./plugin/utils/core-rule-metadata.js"; +export type { EsTreeNode } from "./plugin/utils/es-tree-node.js"; +export type { FileScan, ScanFinding, ScannedFile } from "./plugin/utils/file-scan.js"; +export type { Rule, RuleFramework, RuleSeverity } from "./plugin/utils/rule.js"; +export type { OxlintRuleSeverity } from "./types.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/external-rules.ts b/packages/oxlint-plugin-react-doctor/src/external-rules.ts new file mode 100644 index 0000000000..317953803e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/external-rules.ts @@ -0,0 +1,40 @@ +import type { OxlintRuleSeverity } from "./types.js"; + +export interface ExternalRule { + readonly key: string; + readonly source: "react-compiler"; + readonly severity: OxlintRuleSeverity; +} + +export const EXTERNAL_RULES = [ + { key: "react-hooks-js/set-state-in-render", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/immutability", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/refs", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/purity", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/hooks", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/set-state-in-effect", source: "react-compiler", severity: "warn" }, + { key: "react-hooks-js/globals", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/error-boundaries", source: "react-compiler", severity: "error" }, + { + key: "react-hooks-js/preserve-manual-memoization", + source: "react-compiler", + severity: "error", + }, + { key: "react-hooks-js/unsupported-syntax", source: "react-compiler", severity: "error" }, + { + key: "react-hooks-js/component-hook-factories", + source: "react-compiler", + severity: "error", + }, + { key: "react-hooks-js/static-components", source: "react-compiler", severity: "error" }, + // These stay `error`: each compiler diagnostic marks code React Compiler + // could not optimize. Redundant-memo cleanup belongs to the local rule. + { key: "react-hooks-js/use-memo", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/void-use-memo", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/incompatible-library", source: "react-compiler", severity: "error" }, + { key: "react-hooks-js/todo", source: "react-compiler", severity: "error" }, +] as const satisfies ReadonlyArray; + +export const REACT_COMPILER_RULES: Record = Object.fromEntries( + EXTERNAL_RULES.map((rule) => [rule.key, rule.severity]), +); diff --git a/packages/oxlint-plugin-react-doctor/src/index.ts b/packages/oxlint-plugin-react-doctor/src/index.ts index 569f99104f..8d5639a96c 100644 --- a/packages/oxlint-plugin-react-doctor/src/index.ts +++ b/packages/oxlint-plugin-react-doctor/src/index.ts @@ -28,12 +28,7 @@ export { collectCrossFileDependencyProbes, } from "./plugin/cross-file-dependencies.js"; export type { CrossFileProbeTrace } from "./plugin/utils/cross-file-probe-recorder.js"; - -// Per-scan invalidation for the nearest-package.json memos. The memos are -// sound while a scan's filesystem is frozen, but a long-lived host (the LSP -// server) must drop them between scans — core's `runOxlint` calls this at -// every scan start. -export { resetManifestCaches } from "./plugin/utils/read-nearest-package-manifest.js"; +export { resetFilesystemCaches as resetManifestCaches } from "./plugin/utils/reset-filesystem-caches.js"; export { classifySecurityScanFile, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json new file mode 100644 index 0000000000..aef8e3bc23 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json @@ -0,0 +1,13012 @@ +[ + { + "key": "react-doctor/active-static-asset", + "id": "active-static-asset", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "active-static-asset", + "title": "Executable SVG exposure", + "severity": "warn", + "recommendation": "Prefer `` for SVG images; if SVG must be served directly, use attachment disposition and a CSP that blocks scripts and objects.", + "category": "Security", + "framework": "global", + "tags": ["security-scan"], + "isScanRule": true + } + }, + { + "key": "react-doctor/activity-wraps-effect-heavy-subtree", + "id": "activity-wraps-effect-heavy-subtree", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "activity-wraps-effect-heavy-subtree", + "title": "Activity wraps an effect-heavy subtree", + "severity": "warn", + "recommendation": "Check what's under ``. Every hide and show rebuilds every Effect inside from scratch. Move subscriptions and effect-driven setState out of the Activity, or load the data above it.", + "category": "Bugs", + "framework": "global", + "requires": ["react", "react:19.2"], + "isScanRule": false + } + }, + { + "key": "react-doctor/advanced-event-handler-refs", + "id": "advanced-event-handler-refs", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "advanced-event-handler-refs", + "title": "Listener re-subscribes on every handler change", + "severity": "warn", + "recommendation": "Store the handler in a ref and have the listener read `handlerRef.current()`. The subscription stays put while the latest handler still runs.", + "category": "Performance", + "framework": "global", + "requires": ["react"], + "tags": ["test-noise"], + "isScanRule": false + } + }, + { + "key": "react-doctor/agent-tool-capability-risk", + "id": "agent-tool-capability-risk", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "agent-tool-capability-risk", + "title": "Agent tool exposes dangerous capability", + "severity": "warn", + "recommendation": "Treat tool inputs as prompt-injection controlled. Validate arguments, scope permissions per call, and avoid exposing shell/file/network primitives directly to agents.", + "category": "Security", + "framework": "global", + "tags": ["security-scan"], + "isScanRule": true + } + }, + { + "key": "react-doctor/alt-text", + "id": "alt-text", + "source": "react-doctor", + "originallyExternal": true, + "rule": { + "id": "alt-text", + "title": "Image missing alt text", + "severity": "error", + "recommendation": "Give every meaningful image an `alt`, `aria-label`, or `aria-labelledby`.", + "category": "Accessibility", + "framework": "global", + "requires": ["react"], + "tags": ["react-jsx-only"], + "isScanRule": false + } + }, + { + "key": "react-doctor/anchor-ambiguous-text", + "id": "anchor-ambiguous-text", + "source": "react-doctor", + "originallyExternal": true, + "rule": { + "id": "anchor-ambiguous-text", + "title": "Ambiguous link text", + "severity": "warn", + "recommendation": "Name where a link goes. Avoid 'click here', 'learn more', and 'link'.", + "category": "Accessibility", + "framework": "global", + "requires": ["react"], + "tags": ["react-jsx-only"], + "isScanRule": false + } + }, + { + "key": "react-doctor/anchor-has-content", + "id": "anchor-has-content", + "source": "react-doctor", + "originallyExternal": true, + "rule": { + "id": "anchor-has-content", + "title": "Anchor has no content", + "severity": "warn", + "recommendation": "Put readable text inside every link.", + "category": "Accessibility", + "framework": "global", + "requires": ["react"], + "tags": ["react-jsx-only"], + "isScanRule": false + } + }, + { + "key": "react-doctor/anchor-is-valid", + "id": "anchor-is-valid", + "source": "react-doctor", + "originallyExternal": true, + "rule": { + "id": "anchor-is-valid", + "title": "Anchor used as a button", + "severity": "warn", + "recommendation": "Give links a real destination. Use `