Skip to content
Draft
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
7 changes: 2 additions & 5 deletions packages/core/src/apply-ignore-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Diagnostic, ReactDoctorConfig, ReactDoctorIgnoreOverride } from "./types/index.js";
import { isPlainObject } from "./project-info/index.js";
import { isSameRuleKey } from "./rule-key-aliases.js";
import { compileGlobPatternsLenient } from "./utils/match-glob-pattern.js";
import { isSameRuleKeyInSet } from "./utils/is-same-rule-key-in-set.js";
import { toRelativePath } from "./utils/to-relative-path.js";
import { warnConfigIssue } from "./utils/warn-config-issue.js";

Expand All @@ -18,10 +18,7 @@ const collectStringList = (value: unknown): string[] =>

const hasMatchingRuleOverride = (ruleIds: ReadonlySet<string>, ruleIdentifier: string): boolean => {
if (ruleIds.size === 0) return true;
for (const ruleId of ruleIds) {
if (isSameRuleKey(ruleId, ruleIdentifier)) return true;
}
return false;
return isSameRuleKeyInSet(ruleIds, ruleIdentifier);
};

const validateOverrideEntry = (entry: unknown, index: number): ReactDoctorIgnoreOverride | null => {
Expand Down
24 changes: 9 additions & 15 deletions packages/core/src/build-diagnostic-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Diagnostic,
DiagnosticFileContext,
ReactDoctorConfig,
RuleSeverityControls,
RuleSeverityOverride,
SuppressedRuleCount,
} from "./types/index.js";
Expand All @@ -17,9 +18,9 @@ import { getDiagnosticRuleIdentity } from "./get-diagnostic-rule-identity.js";
import { compileIgnoredFilePatterns, isFileIgnoredByPatterns } from "./is-ignored-file.js";
import { classifyFileContext } from "./classify-file-context.js";
import { resolveRuleSeverityOverride } from "./resolve-rule-severity-override.js";
import { isSameRuleKey } from "./rule-key-aliases.js";
import { APP_ONLY_RULE_KEYS } from "./constants.js";
import { classifyPackageRole } from "./utils/classify-package-role.js";
import { isSameRuleKeyInSet } from "./utils/is-same-rule-key-in-set.js";
import { resolveCandidateReadPath } from "./utils/resolve-candidate-read-path.js";
import {
isInsideStringOnlyWrapper,
Expand All @@ -31,6 +32,7 @@ interface BuildDiagnosticPipelineInput {
readonly userConfig: ReactDoctorConfig | null;
readonly readFileLinesSync: (filePath: string) => string[] | null;
readonly respectInlineDisables: boolean;
readonly severityControls?: RuleSeverityControls;
/**
* Whether `"warning"`-severity diagnostics are allowed through. When
* `true` (the default), warnings show; when `false`, every warning is
Expand Down Expand Up @@ -109,7 +111,7 @@ export const buildDiagnosticPipeline = (
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables, showWarnings } =
input;

const severityControls = buildRuleSeverityControls(userConfig);
const severityControls = input.severityControls ?? buildRuleSeverityControls(userConfig);
const ignoredRules = new Set(
Array.isArray(userConfig?.ignore?.rules)
? userConfig.ignore.rules.filter((rule): rule is string => typeof rule === "string")
Expand Down Expand Up @@ -179,13 +181,6 @@ export const buildDiagnosticPipeline = (
return getFileContext(diagnostic.filePath) !== "production";
};

const matchesRuleKey = (ruleIdentifier: string, candidates: Iterable<string>): boolean => {
for (const candidate of candidates) {
if (isSameRuleKey(candidate, ruleIdentifier)) return true;
}
return false;
};

const isRnRawTextSuppressedByConfig = (diagnostic: Diagnostic): boolean => {
if (diagnostic.rule !== "rn-no-raw-text") return false;
if (diagnostic.line <= 0) return false;
Expand Down Expand Up @@ -238,13 +233,9 @@ export const buildDiagnosticPipeline = (

let current = diagnostic;
let explicitSeverityOverride: RuleSeverityOverride | undefined;
// A *per-rule* override (vs. a broad `categories` bump) — the only signal
// that should re-enable an app-only rule on a library file.
let explicitRuleOverride: RuleSeverityOverride | undefined;
if (severityControls) {
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
// No `category` → resolves against `rules` (+ aliases) only, ignoring
// any matching `categories` entry.
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
explicitSeverityOverride = resolveRuleSeverityOverride(
{ ruleKey, category },
Expand All @@ -263,7 +254,10 @@ export const buildDiagnosticPipeline = (
// deliberate "I want static-components in my library" and must not leak
// these rules back into published packages.
if (explicitRuleOverride === undefined) {
if (matchesRuleKey(ruleIdentifier, APP_ONLY_RULE_KEYS) && isLibraryFile(current.filePath)) {
if (
isSameRuleKeyInSet(APP_ONLY_RULE_KEYS, ruleIdentifier) &&
isLibraryFile(current.filePath)
) {
return null;
}
}
Expand All @@ -277,7 +271,7 @@ export const buildDiagnosticPipeline = (
}

if (userConfig) {
if (matchesRuleKey(ruleIdentifier, ignoredRules)) return suppress(current, "config");
if (isSameRuleKeyInSet(ignoredRules, ruleIdentifier)) return suppress(current, "config");
if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/filter-for-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const isDiagnosticOnSurface = (
};

export const filterDiagnosticsForSurface = (
diagnostics: Diagnostic[],
diagnostics: ReadonlyArray<Diagnostic>,
surface: DiagnosticSurface,
config: ReactDoctorConfig | null,
): Diagnostic[] =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,3 @@ export const collectPackageImportNames = (content: string): Set<string> => {
}
return packageNames;
};

export const matchesPackageImportReference = (content: string, packageName: string): boolean =>
collectPackageImportNames(content).has(packageName);
61 changes: 30 additions & 31 deletions packages/core/src/run-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ const LINT_NATIVE_BINDING_FAIL_TEXT = (nodeVersion: string): string =>
`Scanning failed — oxlint native binding not found (Node ${nodeVersion}).`;
const MAINTAINABILITY_FAIL_TEXT = "Scanning failed (maintainability analysis, non-fatal).";

const missingGitMetadata = (): string | null => null;

const formatLintFailText = (
reasonTag: ReactDoctorErrorReason["_tag"] | null,
nodeVersion: string,
Expand Down Expand Up @@ -241,13 +243,9 @@ export const runInspect = <HooksR = never>(
}
const [repo, sha, defaultBranch] = yield* Effect.all(
[
gitService
.githubRepo(scanDirectory)
.pipe(Effect.orElseSucceed(() => null as string | null)),
gitService.headSha(scanDirectory).pipe(Effect.orElseSucceed(() => null as string | null)),
gitService
.defaultBranch(scanDirectory)
.pipe(Effect.orElseSucceed(() => null as string | null)),
gitService.githubRepo(scanDirectory).pipe(Effect.orElseSucceed(missingGitMetadata)),
gitService.headSha(scanDirectory).pipe(Effect.orElseSucceed(missingGitMetadata)),
gitService.defaultBranch(scanDirectory).pipe(Effect.orElseSucceed(missingGitMetadata)),
],
{ concurrency: 3 },
);
Expand All @@ -256,7 +254,7 @@ export const runInspect = <HooksR = never>(
input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null
? gitService
.githubViewerPermission({ directory: scanDirectory, repo })
.pipe(Effect.orElseSucceed(() => null as string | null))
.pipe(Effect.orElseSucceed(missingGitMetadata))
: Effect.succeed(null as string | null),
);

Expand Down Expand Up @@ -311,12 +309,14 @@ export const runInspect = <HooksR = never>(
const isDiffMode = input.includePaths.length > 0;

const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? DEFAULT_SHOW_WARNINGS;
const severityControls = buildRuleSeverityControls(resolvedConfig.config);

const transform = buildDiagnosticPipeline({
rootDirectory: scanDirectory,
userConfig: resolvedConfig.config,
readFileLinesSync: fileReader(filesService, scanDirectory),
respectInlineDisables: input.respectInlineDisables,
severityControls,
showWarnings,
});

Expand Down Expand Up @@ -495,26 +495,26 @@ export const runInspect = <HooksR = never>(
const workerCountSuffix =
scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
const projectCapabilities = getCapabilities(project);
const projectRuleSelections = resolveProjectRuleSelections(
buildRuleSeverityControls(resolvedConfig.config),
).filter((selection) => {
const rule = REACT_DOCTOR_RULE_REGISTRY[selection.ruleId];
return (
rule !== undefined &&
shouldEnableRule(
rule.requires,
rule.tags,
projectCapabilities,
input.ignoredTags,
rule.disabledWhen,
input.includedTags,
) &&
(selection.ruleId === MAINTAINABILITY_DUPLICATE_JSX_RULE
? input.runDeadCode
: !isDiffMode) &&
(showWarnings || projectRuleSelectionsMaySurfaceWhenWarningsAreHidden([selection]))
);
});
const projectRuleSelections = resolveProjectRuleSelections(severityControls).filter(
(selection) => {
const rule = REACT_DOCTOR_RULE_REGISTRY[selection.ruleId];
return (
rule !== undefined &&
shouldEnableRule(
rule.requires,
rule.tags,
projectCapabilities,
input.ignoredTags,
rule.disabledWhen,
input.includedTags,
) &&
(selection.ruleId === MAINTAINABILITY_DUPLICATE_JSX_RULE
? input.runDeadCode
: !isDiffMode) &&
(showWarnings || projectRuleSelectionsMaySurfaceWhenWarningsAreHidden([selection]))
);
},
);
const enabledProjectRuleIds = new Set(
projectRuleSelections.map((selection) => selection.ruleId),
);
Expand Down Expand Up @@ -646,14 +646,13 @@ export const runInspect = <HooksR = never>(
),
),
);
const rawLintStream = baseLintStream;

// Lint phase cap (Effect-side, runtime-independent of the per-batch
// spawn timeout and the bounded split cascade): on timeout, fold into
// the existing lint-failure contract (score becomes null) with an
// `OxlintBatchExceeded`-tagged reason so renderers dispatch on it, and
// yield an empty chunk so the rest of the scan still completes.
const collectLintDiagnostics = Stream.runCollect(filterPerElementPipeline(rawLintStream));
const collectLintDiagnostics = Stream.runCollect(filterPerElementPipeline(baseLintStream));
const filteredLintDiagnostics = yield* lintPhaseTimeoutMs === null
? collectLintDiagnostics
: collectLintDiagnostics.pipe(
Expand Down Expand Up @@ -802,7 +801,7 @@ export const runInspect = <HooksR = never>(

const scoreSurface: DiagnosticSurface = input.scoreSurface ?? "score";
const scoreDiagnostics = filterDiagnosticsForSurface(
[...finalDiagnostics],
finalDiagnostics,
scoreSurface,
resolvedConfig.config,
);
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/utils/is-same-rule-key-in-set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isSameRuleKey } from "../rule-key-aliases.js";

export const isSameRuleKeyInSet = (
candidates: Iterable<string>,
ruleIdentifier: string,
): boolean => {
for (const candidate of candidates) {
if (isSameRuleKey(candidate, ruleIdentifier)) return true;
}
return false;
};
2 changes: 1 addition & 1 deletion packages/evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Text entries use each repository's default branch. Output records replace `HEAD`

Candidate runs accept only complete, schema-valid baseline records pinned to full commit hashes. Evaluation concurrency defaults to 200 batches, with a target of 10 repositories per sandbox. Sandbox creation is capped at 20 to avoid overloading Daytona, so a 2,000-repository run uses about 200 sandboxes instead of provisioning 2,000. Batches are balanced by project-root count so large monorepos do not collect on one worker.

The default 30-minute wall-clock budget stops initial-pass commands after 18 minutes, the first retry after 23 minutes, and the final retry after 28 minutes. The last two minutes remain reserved for deleting sandboxes and the snapshot. Override the corpus size, batch size, concurrency, or duration for smaller investigations. After the initial pass, the evaluator retries failed or incomplete projects at concurrency 50, then 10 in isolated sandboxes. Malformed and incomplete reports make the command exit non-zero instead of presenting partial coverage as a completed evaluation.
The default 45-minute wall-clock budget reserves the last two minutes for deleting sandboxes and the snapshot. Within the 43-minute evaluation window, the initial pass stops after 32.25 minutes, then retry attempts stop after 40.3125 and 42.328125 minutes before the final retry uses the full evaluation deadline. Override the corpus size, batch size, concurrency, or duration for smaller investigations. After the initial pass, the evaluator retries failed or incomplete projects at concurrency 50, then 10, then 2 in isolated sandboxes. Malformed and incomplete reports make the command exit non-zero instead of presenting partial coverage as a completed evaluation.

Progress and completion metrics use stderr. Results use stdout. The evaluator deletes every repository sandbox and the build snapshot after the run.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import {
DEFAULT_EVALUATION_MAX_DURATION_MINUTES,
EVALUATION_CLEANUP_RESERVE_MINUTES,
EVALUATION_RETRY_CONCURRENCIES,
MILLISECONDS_PER_MINUTE,
} from "../src/constants.js";
import { getEvaluationAttemptDeadlineMilliseconds } from "../src/utils/get-evaluation-attempt-deadline-milliseconds.js";

describe("getEvaluationAttemptDeadlineMilliseconds", () => {
Expand Down Expand Up @@ -43,6 +49,43 @@ describe("getEvaluationAttemptDeadlineMilliseconds", () => {
).toBe(50 * 60_000);
});

it("pins the default evaluation budget schedule", () => {
const evaluationDeadlineMilliseconds =
(DEFAULT_EVALUATION_MAX_DURATION_MINUTES - EVALUATION_CLEANUP_RESERVE_MINUTES) *
MILLISECONDS_PER_MINUTE;
const totalAttempts = EVALUATION_RETRY_CONCURRENCIES.length + 1;
const firstAttemptDeadlineMilliseconds = getEvaluationAttemptDeadlineMilliseconds({
evaluationDeadlineMilliseconds,
attemptIndex: 0,
totalAttempts,
nowMilliseconds: 0,
});
const secondAttemptDeadlineMilliseconds = getEvaluationAttemptDeadlineMilliseconds({
evaluationDeadlineMilliseconds,
attemptIndex: 1,
totalAttempts,
nowMilliseconds: firstAttemptDeadlineMilliseconds,
});
const thirdAttemptDeadlineMilliseconds = getEvaluationAttemptDeadlineMilliseconds({
evaluationDeadlineMilliseconds,
attemptIndex: 2,
totalAttempts,
nowMilliseconds: secondAttemptDeadlineMilliseconds,
});

expect(firstAttemptDeadlineMilliseconds).toBe(32.25 * MILLISECONDS_PER_MINUTE);
expect(secondAttemptDeadlineMilliseconds).toBe(40.3125 * MILLISECONDS_PER_MINUTE);
expect(thirdAttemptDeadlineMilliseconds).toBe(42.328125 * MILLISECONDS_PER_MINUTE);
expect(
getEvaluationAttemptDeadlineMilliseconds({
evaluationDeadlineMilliseconds,
attemptIndex: 3,
totalAttempts,
nowMilliseconds: thirdAttemptDeadlineMilliseconds,
}),
).toBe(evaluationDeadlineMilliseconds);
});

it("uses the evaluation deadline when no retries remain", () => {
expect(
getEvaluationAttemptDeadlineMilliseconds({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ const resolveTextBoundaryName = (
return resolveJsxElementName(openingElement);
};

const TEXT_COMPONENT_KEYWORDS: ReadonlyArray<string> = [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS];

const isTextHandlingComponent = (elementName: string): boolean => {
if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
return TEXT_COMPONENT_KEYWORDS.some((keyword) => elementName.includes(keyword));
for (const keyword of REACT_NATIVE_TEXT_COMPONENT_KEYWORDS) {
if (elementName.includes(keyword)) return true;
}
return false;
};

const isTransparentTextWrapper = (elementName: string | null): boolean =>
Expand All @@ -90,13 +91,6 @@ export const rnNoRawText = defineRule({
recommendation:
"Text outside a `<Text>` component crashes on React Native. Wrap it like `<Text>{value}</Text>`.",
create: (context: RuleContext) => {
// The package-boundary gate (`isReactNativeFileActive`) lives on the
// rule wrapper applied at registry load — by the time we get here
// the file is confirmed to belong to a React Native / Expo package
// (or to be ambiguous enough that we err on the side of running).
// The only file-level branch we still need is "use dom", which is
// Expo Router's directive that opts a single file into being rendered
// in a WebView as DOM rather than on React Native primitives.
let isDomComponentFile = false;

// In-file components classified by where they forward their children (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
// True for a non-computed `.<name>` member access (covers both the plain
// `.current` and the optional-chained `?.current` forms — the optional
// flag doesn't change the node shape, only `node.optional`).
const isStaticMemberNamed = (node: EsTreeNode, name: string): boolean =>
const isStaticMemberNamed = (
node: EsTreeNode,
name: string,
): node is EsTreeNodeOfType<"MemberExpression"> =>
isNodeOfType(node, "MemberExpression") &&
!node.computed &&
isNodeOfType(node.property, "Identifier") &&
Expand All @@ -31,10 +34,7 @@ export const rnNoSetNativeProps = defineRule({
create: (context: RuleContext) => ({
CallExpression(node: EsTreeNodeOfType<"CallExpression">) {
const callee = node.callee;
// Callee must be `<receiver>.setNativeProps` (static, non-computed).
if (!isStaticMemberNamed(callee, "setNativeProps")) return;
if (!isNodeOfType(callee, "MemberExpression")) return;
// Receiver must be a `*.current` access — the React ref shape.
if (!isStaticMemberNamed(stripParenExpression(callee.object), "current")) return;
context.report({
node,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {
import { readNearestPackageManifest } from "./read-nearest-package-manifest.js";
import type { PackageManifest } from "./read-nearest-package-manifest.js";

export { findNearestPackageDirectory } from "./read-nearest-package-manifest.js";

// Packages that mark the manifest as a web-only React target. If a manifest
// contains one of these AND has no React Native indicator, every React
// Native rule must skip files inside that package. `react-dom` covers
Expand Down
Loading
Loading