diff --git a/.agents/skills/run-parity/scripts/compare-parity.mjs b/.agents/skills/run-parity/scripts/compare-parity.mjs index 5c85813102..4c20c1258b 100644 --- a/.agents/skills/run-parity/scripts/compare-parity.mjs +++ b/.agents/skills/run-parity/scripts/compare-parity.mjs @@ -36,6 +36,7 @@ const REPORT_FRAMEWORKS = new Set([ "react-native", "tanstack-start", "preact", + "astro", "unknown", ]); const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/; diff --git a/.agents/skills/run-parity/scripts/compare-parity.test.mjs b/.agents/skills/run-parity/scripts/compare-parity.test.mjs index 91c206a068..72bfb9ee9a 100644 --- a/.agents/skills/run-parity/scripts/compare-parity.test.mjs +++ b/.agents/skills/run-parity/scripts/compare-parity.test.mjs @@ -204,6 +204,17 @@ test("keeps same-named files in nested projects as distinct identities", () => { assert.equal(JSON.parse(result.stdout).summary.unchanged, 2); }); +test("accepts complete Astro project reports", () => { + const report = buildReport(); + report.projects[0].framework = "astro"; + const record = buildRecord(buildRepository(), report); + + const result = runComparison([record], [record]); + + assert.equal(result.status, SUCCESS_EXIT_CODE, result.stderr); + assert.equal(JSON.parse(result.stdout).summary.unchanged, 1); +}); + test("does not count duplicate diagnostic identities more than once", () => { const diagnostic = buildV3Diagnostic(); const addedDiagnostic = buildV3Diagnostic({ diff --git a/.agents/skills/run-parity/scripts/validate-parity-input.jq b/.agents/skills/run-parity/scripts/validate-parity-input.jq index 7cd5567b92..0b155519b6 100644 --- a/.agents/skills/run-parity/scripts/validate-parity-input.jq +++ b/.agents/skills/run-parity/scripts/validate-parity-input.jq @@ -44,7 +44,7 @@ def is_project($schema_version): ( if $schema_version == 3 then (.packageRoot | type) == "string" and - (.framework as $framework | ["nextjs", "vite", "cra", "remix", "gatsby", "expo", "react-native", "tanstack-start", "preact", "unknown"] | index($framework)) != null and + (.framework as $framework | ["nextjs", "vite", "cra", "remix", "gatsby", "expo", "react-native", "tanstack-start", "preact", "astro", "unknown"] | index($framework)) != null and .complete == true and (.skippedChecks | length) == 0 and ((.skippedCheckReasons // {}) | length) == 0 and diff --git a/.agents/skills/run-parity/scripts/validate-parity-input.test.mjs b/.agents/skills/run-parity/scripts/validate-parity-input.test.mjs index f108a8e13d..c81d729335 100644 --- a/.agents/skills/run-parity/scripts/validate-parity-input.test.mjs +++ b/.agents/skills/run-parity/scripts/validate-parity-input.test.mjs @@ -92,6 +92,14 @@ test("accepts multiple complete pinned evaluation records", () => { assert.equal(result.status, 0, result.stderr); }); +test("accepts a complete Astro project report", () => { + const record = buildRecord(); + record.report.projects[0].framework = "astro"; + const result = validateRecords([record]); + + assert.equal(result.status, 0, result.stderr); +}); + test("rejects duplicate project records", () => { const result = validateRecords([buildRecord(), buildRecord()]); diff --git a/.changeset/faster-sidecar-probes.md b/.changeset/faster-sidecar-probes.md new file mode 100644 index 0000000000..815549ee38 --- /dev/null +++ b/.changeset/faster-sidecar-probes.md @@ -0,0 +1,12 @@ +--- +"@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, and ignore explicitly +disabled inline CSS animations and transitions in Remotion rules. 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/evals/src/constants.ts b/packages/evals/src/constants.ts index 7e96b819fc..0ecfedf25c 100644 --- a/packages/evals/src/constants.ts +++ b/packages/evals/src/constants.ts @@ -9,9 +9,9 @@ export const DEFAULT_CORPUS_REPOSITORY_COUNT = 2_000; export const DEFAULT_CORPUS_CONCURRENCY = 200; export const DEFAULT_REPOSITORIES_PER_SANDBOX = 10; export const DEFAULT_PROJECT_ROOTS_PER_REPOSITORY = 1; -export const DEFAULT_EVALUATION_MAX_DURATION_MINUTES = 30; +export const DEFAULT_EVALUATION_MAX_DURATION_MINUTES = 45; export const EVALUATION_CLEANUP_RESERVE_MINUTES = 2; -export const EVALUATION_RETRY_CONCURRENCIES: ReadonlyArray = [50, 10]; +export const EVALUATION_RETRY_CONCURRENCIES: ReadonlyArray = [50, 10, 2]; export const EVALUATION_RETRY_ATTEMPT_RESERVE_MINUTES = 5; export const EVALUATION_RETRY_REPOSITORIES_PER_SANDBOX = 1; @@ -42,6 +42,7 @@ export const REACT_DOCTOR_REPORT_MODES: ReadonlySet = new Set([ ]); export const REACT_DOCTOR_REPORT_FRAMEWORKS: ReadonlySet = new Set([ "nextjs", + "astro", "vite", "cra", "remix", diff --git a/packages/evals/tests/parse-evaluation-arguments.test.ts b/packages/evals/tests/parse-evaluation-arguments.test.ts index 384beb4607..8d2ee408f4 100644 --- a/packages/evals/tests/parse-evaluation-arguments.test.ts +++ b/packages/evals/tests/parse-evaluation-arguments.test.ts @@ -10,7 +10,7 @@ describe("parseEvaluationArguments", () => { concurrency: 200, repositoriesPerSandbox: 10, projectRootsPerRepository: 1, - maxDurationMinutes: 30, + maxDurationMinutes: 45, reactDoctorRepository: "https://github.com/millionco/react-doctor.git", reactDoctorRef: "main", }); @@ -32,7 +32,7 @@ describe("parseEvaluationArguments", () => { "--project-roots-per-repository", "3", "--max-duration-minutes", - "15", + "20", "--react-doctor-ref", "feature/eval", ]), @@ -42,7 +42,7 @@ describe("parseEvaluationArguments", () => { concurrency: 25, repositoriesPerSandbox: 5, projectRootsPerRepository: 3, - maxDurationMinutes: 15, + maxDurationMinutes: 20, reactDoctorRef: "feature/eval", }); }); @@ -59,7 +59,7 @@ describe("parseEvaluationArguments", () => { expect(() => parseEvaluationArguments(["--project-roots-per-repository", "0"])).toThrow( "positive integer", ); - expect(() => parseEvaluationArguments(["--max-duration-minutes", "12"])).toThrow( + expect(() => parseEvaluationArguments(["--max-duration-minutes", "17"])).toThrow( "cleanup and retry reserve", ); }); diff --git a/packages/evals/tests/parse-react-doctor-report.test.ts b/packages/evals/tests/parse-react-doctor-report.test.ts index 130023c443..fe3ed8592b 100644 --- a/packages/evals/tests/parse-react-doctor-report.test.ts +++ b/packages/evals/tests/parse-react-doctor-report.test.ts @@ -62,6 +62,13 @@ describe("parseReactDoctorReport", () => { expect(parseReactDoctorReport(JSON.stringify(report))).toEqual(report); }); + it("accepts Astro reports", () => { + const report = buildReport(); + report.projects[0].framework = "astro"; + + expect(parseReactDoctorReport(JSON.stringify(report))).toEqual(report); + }); + it("accepts complete legacy reports", () => { const legacyDiagnostic = { filePath: "src/app.tsx", diff --git a/packages/fuzz/corpus/regressions/remotion-no-css-transition--disabled-inline-style.tsx b/packages/fuzz/corpus/regressions/remotion-no-css-transition--disabled-inline-style.tsx new file mode 100644 index 0000000000..7b103b766f --- /dev/null +++ b/packages/fuzz/corpus/regressions/remotion-no-css-transition--disabled-inline-style.tsx @@ -0,0 +1,10 @@ +// rule: remotion-no-css-transition +// weakness: library-idiom +// source: Daytona parity for PR 1533 +// verdict: pass +import { useCurrentFrame } from "remotion"; + +export const DisabledTransitionScene = () => { + useCurrentFrame(); + return
; +}; 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..895590e1f2 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", + "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", @@ -60,7 +64,8 @@ "oxc-parser": "^0.142.0" }, "devDependencies": { - "@types/node": "^25.6.0" + "@types/node": "^25.6.0", + "oxfmt": "0.46.0" }, "engines": { "node": "^20.19.0 || >=22.13.0" 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..2d2edd2d03 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,21 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { format } from "oxfmt"; 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 +329,7 @@ for (const bucket of fs.readdirSync(PLUGIN_RULES_ROOT, { withFileTypes: true })) ruleEntries.push({ ruleId, identifier, + filePath, relativeImport, framework, category, @@ -344,6 +354,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 +425,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 +471,78 @@ 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", + }, + }; +}); + +const coreRegistryData = await format( + CORE_REGISTRY_DATA_OUTPUT, + `${JSON.stringify(coreRuleEntries, null, 2)}\n`, + { printWidth: GENERATED_LINE_WIDTH }, +); +fs.writeFileSync(CORE_REGISTRY_DATA_OUTPUT, coreRegistryData.code); + +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 `