Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/run-parity/scripts/compare-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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])?$/;
Expand Down
11 changes: 11 additions & 0 deletions .agents/skills/run-parity/scripts/compare-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/run-parity/scripts/validate-parity-input.jq
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);

Expand Down
12 changes: 12 additions & 0 deletions .changeset/faster-sidecar-probes.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 28 additions & 1 deletion packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";
import {
buildSkippedChecks,
Config,
createOxlintSpawnSlots,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
Expand All @@ -15,6 +16,8 @@ import {
LintPartialFailures,
mapWithConcurrency,
mergeReactDoctorConfigs,
OxlintConcurrency,
OxlintSpawnSlots,
Progress,
Project,
Reporter,
Expand All @@ -25,6 +28,7 @@ import {
SupplyChain,
type InspectOutput,
type ResolvedScanTarget,
type WorkerSlots,
} from "@react-doctor/core";
import type {
DiagnoseOptions,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -163,6 +173,8 @@ const diagnoseDirectory = async (
config: scanTarget.userConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
}),
),
Effect.provide(layerOtlp),
Expand Down Expand Up @@ -190,6 +202,8 @@ const diagnoseProject = async (
projectDefinition: ProjectDefinition,
baseOptions: DiagnoseOptions,
batchConfig: ReactDoctorConfig | undefined,
oxlintConcurrency: number,
oxlintSpawnSlots: WorkerSlots,
): Promise<ProjectResult> => {
const startTime = globalThis.performance.now();

Expand Down Expand Up @@ -220,6 +234,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
configOverrideTarget: {
resolvedDirectory: scanTarget.resolvedDirectory,
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
Expand All @@ -229,6 +245,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
};
const layer = buildDiagnoseLayer(diagnoseLayerInput);

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/build-diagnostic-pipeline.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/check-reduced-motion.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/check-security-scan.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -48,7 +48,7 @@ const createSecurityScanSession = (
const ignoredTags = options.ignoredTags ?? new Set<string>();
const includedTags = options.includedTags ?? new Set<string>();

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 [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
67 changes: 8 additions & 59 deletions packages/core/src/dead-code/dead-code-worker-slots.ts
Original file line number Diff line number Diff line change
@@ -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 <Result>(
task: () => Promise<Result>,
abortSignal?: AbortSignal,
): Promise<Result> => {
if (abortSignal?.aborted) throw new Error("Dead-code worker aborted.");
if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency();
if (availableSlots > 0) {
availableSlots -= 1;
} else {
await new Promise<void>((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);
};
4 changes: 2 additions & 2 deletions packages/core/src/get-diagnostic-rule-identity.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 ?? [])
: [],
});
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/project-info/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -351,9 +353,13 @@ export const getCapabilities = (project: ProjectInfo): ReadonlySet<Capability> =
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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +127,13 @@ export class OxlintConcurrency extends Context.Reference<number>("react-doctor/O
},
}) {}

export class OxlintSpawnSlots extends Context.Reference<WorkerSlots | null>(
"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
Expand Down
Loading
Loading