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
5 changes: 5 additions & 0 deletions .changeset/fix-function-resolution-stack-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"oxlint-plugin-react-doctor": patch
---

Prevent stack overflows while resolving deeply nested local function references. React Doctor now stops following a reference chain after a bounded number of steps instead of aborting the lint scan.
5 changes: 5 additions & 0 deletions .changeset/fix-implicit-subproject-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-doctor/core": patch
---

Use entry points from discovered implicit subprojects outside declared workspace globs, preventing legitimate files from being reported as unused.
5 changes: 5 additions & 0 deletions .changeset/fix-type-only-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"oxlint-plugin-react-doctor": patch
---

Ignore browser-global names in TypeScript-only positions so interface and type property keys are not reported as unsafe module-scope runtime access.
5 changes: 1 addition & 4 deletions packages/core/src/project-analysis/collect/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,7 @@ export const resolveEntries = async (config: ProjectAnalysisConfig): Promise<Res
}
}

const shouldExtractEntries =
isEligible &&
(workspacePackage.isDeclaredWorkspace || !workspaceDiscovery.hasRootLevelWorkspacePatterns);
if (shouldExtractEntries) {
if (isEligible) {
const workspacePackageJsonPath = resolve(workspacePackage.directory, "package.json");
const workspacePackageJsonEntries = await extractPackageJsonEntries(workspacePackageJsonPath);
const hasValidEntries = workspacePackageJsonEntries.some((entryPath) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,28 @@ describe("unused-file graph completeness", () => {
},
);

it("uses entry points from implicit sub-projects outside workspace patterns", async () => {
const rootDirectory = createProject(
{
"index.js": "console.log('root');",
"packages/real/package.json": JSON.stringify({ name: "real", main: "index.js" }),
"packages/real/index.js": "export const value = 1;",
"sub/package.json": JSON.stringify({ name: "sub", main: "cli.js" }),
"sub/cli.js": "const { helper } = require('./helper'); console.log(helper());",
"sub/helper.js": "module.exports.helper = () => 'hi';",
},
{
name: "root",
workspaces: ["packages/*"],
main: "index.js",
},
);

const result = await analyzeProject({ rootDirectory });

expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual([]);
});

it("isolates uncertainty to its owning workspace package", async () => {
const rootDirectory = createProject(
{
Expand Down
5 changes: 3 additions & 2 deletions packages/core/tests/spawn-batches-oom-rescue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* partial result; a file that STILL aborts alone stays dropped and reported.
*
* The oxlint binary is stood in for by a `node -e` stub that aborts itself
* via `process.abort()` on each file's first attempt (tracked via per-file
* on each file's first attempt (tracked via per-file
* marker files) and emits one diagnostic per file on later attempts.
* `process.abort()` raises a real SIGABRT on POSIX; on Windows — which has
* no POSIX signals, so a self-aborting child can never surface a signal to
Expand Down Expand Up @@ -83,7 +83,8 @@ const buildAbortOnceScript = (abortStatement = "process.abort();"): string =>
"process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));",
].join("\n");

const ALWAYS_ABORT_SCRIPT = "process.abort();";
// HACK: Exit 134 preserves abort classification without generating repeated core dumps under CI load.
const ALWAYS_ABORT_SCRIPT = "process.exit(134);";

// "poison" files abort on their first attempt (so they enter the rescue),
// then print non-JSON stdout — a non-splittable `OxlintOutputUnparseable`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// rule: no-unguarded-browser-global-at-module-scope
// weakness: type-position
// source: GitHub issue #1667
// verdict: pass

interface TweetSearchCoverageStrategyMetadata {
readonly window:
| {
readonly sinceTime: string;
readonly untilTime: string;
}
| undefined;
}

export const metadata: TweetSearchCoverageStrategyMetadata = {
window: undefined,
};
49 changes: 47 additions & 2 deletions packages/oxlint-plugin-react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,54 @@ 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/*`.
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). Rules exported by this package use the `react-doctor/*` namespace.

Rules in the `security-scan` bucket are project-level project-wide file scans (leaked artifact secrets, permissive Firebase/Supabase rules, committed key material, …). They register metadata here but are no-ops under plain oxlint or ESLint — the [React Doctor CLI](https://npmjs.com/package/react-doctor) executes them over a whole-tree file walk during its scan.
### Choose a runner for each diagnostic

Standalone Oxlint runs rules that inspect one source file. The React Doctor CLI also runs whole-project and package analyzers.

| Diagnostic source | Standalone Oxlint | Runner |
| --------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------- |
| `react-doctor/*` rules with per-file visitors | Yes | Configure them under `rules` as shown above |
| Project rules such as `react-doctor/circular-dependency` and `react-doctor/unused-dependency` | No | Run the React Doctor CLI |
| `deslop/*` diagnostics | No | Run the React Doctor CLI |
| `socket/*` diagnostics | No | Run the React Doctor CLI |
| `react-hooks-js/*` React Compiler diagnostics | Separate plugin | Register `eslint-plugin-react-hooks` or run the React Doctor CLI |

The `deslop/*` names are CLI output aliases for project rules. These rules need the complete dependency graph, so their plugin entries have no per-file visitors. The `socket/*` diagnostics need package and supply-chain data. Neither prefix names an Oxlint plugin exported by this package.

Rules in the `security-scan` bucket also require a project-wide file scan. They register metadata here but do nothing under standalone Oxlint or ESLint. The [React Doctor CLI](https://npmjs.com/package/react-doctor) executes them during its whole-tree scan.

### Run React Compiler diagnostics in standalone Oxlint

React Doctor loads React Compiler diagnostics from `eslint-plugin-react-hooks` when it detects the compiler. To use them in standalone Oxlint, install and register that plugin:

```bash
npm install --save-dev eslint-plugin-react-hooks
```

Add both plugins to `.oxlintrc.json`. The `react-compiler` capability enables compiler-specific React Doctor cleanup rules:

```jsonc
{
"jsPlugins": [
{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" },
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" },
],
"settings": {
"react-doctor": {
"capabilities": ["react-compiler"],
},
},
"rules": {
"react-doctor/react-compiler-no-manual-memoization": "warn",
"react-hooks-js/immutability": "error",
"react-hooks-js/refs": "error",
},
}
```

The `react-hooks-js` namespace is the alias React Doctor uses for the external plugin. It is not exported by `oxlint-plugin-react-doctor`.

Each rule can be set to `"error"`, `"warn"`, or `"off"`:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3;
export const BOOLEAN_PROP_VARIANT_BRANCH_THRESHOLD = 2;
export const GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3;
export const SYNCHRONOUS_THROW_RESOLUTION_DEPTH = 3;
export const FUNCTION_RESOLUTION_MAX_DEPTH = 15;
// How many identifier→initializer hops jsx-key follows when proving a
// `{...spread}` after an explicit `key` cannot carry a `key` of its own
// (`const tokenProps = { ... }` chains). Bounded so a pathological
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -771,4 +771,75 @@ describe("no-unguarded-browser-global-at-module-scope", () => {
);
expect(result.diagnostics).toHaveLength(0);
});

it("does not flag type-only property names in interfaces", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`interface TweetSearchCoverageStrategyMetadata {
readonly window: { readonly sinceTime: string; readonly untilTime: string } | undefined;
}`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(0);
});

it("does not flag type-only property names in type aliases", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`type Config = {
window: number;
navigator: string;
};`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(0);
});

it("still flags runtime window property access", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`interface Config {
window: number;
}
const w = window.innerWidth;`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("does not flag type parameters that happen to be named window", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`type MapState<window> = (state: window) => window;`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(0);
});

it("does not flag type-only intersection/union member keys", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`type Config = { window: number } | { navigator: string } & { localStorage: boolean };`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(0);
});

it("does not flag method signature return types", () => {
const result = runRule(
noUnguardedBrowserGlobalAtModuleScope,
`interface API {
getWindow(): window;
}
type window = { width: number };`,
prod,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isFunctionLike } from "../../utils/is-function-like.js";
import { isNonSourceFilename } from "../../utils/is-non-source-filename.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import { isTestlikeFilename } from "../../utils/is-testlike-filename.js";
import { isTypeScriptTypePosition } from "../../utils/is-typescript-type-position.js";
import { readBrowserGlobalAvailability } from "../../utils/read-browser-global-availability.js";
import { resolveCrossFileExport } from "../../utils/resolve-cross-file-export.js";
import { findTransparentExpressionRoot } from "../../utils/find-transparent-expression-root.js";
Expand Down Expand Up @@ -628,6 +629,7 @@ export const noUnguardedBrowserGlobalAtModuleScope = defineRule({
Identifier(node: EsTreeNodeOfType<"Identifier">) {
if (!BROWSER_GLOBAL_NAMES.has(node.name)) return;
if (!context.scopes.isGlobalReference(node)) return;
if (isTypeScriptTypePosition(node)) return;
const expressionRoot = findTransparentExpressionRoot(node);
if (
expressionRoot.parent &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const buildNestedJsxSource = (nestingDepth: number): string => {
return `export const DeepTree = () => (${openingElements}{value}${closingElements});`;
};

const measureDuration = (nestingDepth: number): number => {
const measureFastestDuration = (nestingDepth: number): number => {
const source = buildNestedJsxSource(nestingDepth);
const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => {
const startedAt = process.hrtime.bigint();
Expand All @@ -22,16 +22,15 @@ const measureDuration = (nestingDepth: number): number => {
expect(result.diagnostics).toEqual([]);
return Number(process.hrtime.bigint() - startedAt);
});
sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration);
return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY;
return Math.min(...sampleDurations);
};

describe("no-tiny-text performance", () => {
it("scales near-linearly across deeply nested JSX", () => {
measureDuration(SMALL_NESTING_DEPTH);
measureDuration(LARGE_NESTING_DEPTH);
const smallDuration = measureDuration(SMALL_NESTING_DEPTH);
const largeDuration = measureDuration(LARGE_NESTING_DEPTH);
measureFastestDuration(SMALL_NESTING_DEPTH);
measureFastestDuration(LARGE_NESTING_DEPTH);
const smallDuration = measureFastestDuration(SMALL_NESTING_DEPTH);
const largeDuration = measureFastestDuration(LARGE_NESTING_DEPTH);
expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const buildEffectWithLocalDerivations = (localCount: number): string => {
`;
};

const measureDuration = (localCount: number): number => {
const measureFastestDuration = (localCount: number): number => {
const source = buildEffectWithLocalDerivations(localCount);
const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => {
const startedAt = process.hrtime.bigint();
Expand All @@ -38,16 +38,15 @@ const measureDuration = (localCount: number): number => {
expect(result.diagnostics).toHaveLength(1);
return Number(process.hrtime.bigint() - startedAt);
});
sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration);
return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY;
return Math.min(...sampleDurations);
};

describe("no-derived-state performance", () => {
it("scales near-linearly with effect-local derivations", () => {
measureDuration(SMALL_LOCAL_COUNT);
measureDuration(LARGE_LOCAL_COUNT);
const smallDuration = measureDuration(SMALL_LOCAL_COUNT);
const largeDuration = measureDuration(LARGE_LOCAL_COUNT);
measureFastestDuration(SMALL_LOCAL_COUNT);
measureFastestDuration(LARGE_LOCAL_COUNT);
const smallDuration = measureFastestDuration(SMALL_LOCAL_COUNT);
const largeDuration = measureFastestDuration(LARGE_LOCAL_COUNT);
expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { noMutateThenSetOrReturnSameReference } from "./no-mutate-then-set-or-return-same-reference.js";

const MEASUREMENT_SAMPLE_COUNT = 3;

describe("no-mutate-then-set-or-return-same-reference", () => {
it("flags setX(state.add(index)) on a state Set", () => {
const result = runRule(
Expand Down Expand Up @@ -582,10 +584,10 @@ describe("no-mutate-then-set-or-return-same-reference", () => {
runRule(noMutateThenSetOrReturnSameReference, buildSource(200));
const measureFastestDuration = (pairCount: number): number => {
let fastestDuration = Number.POSITIVE_INFINITY;
for (let repetition = 0; repetition < 2; repetition += 1) {
const start = performance.now();
for (let repetition = 0; repetition < MEASUREMENT_SAMPLE_COUNT; repetition += 1) {
const startedAt = performance.now();
const result = runRule(noMutateThenSetOrReturnSameReference, buildSource(pairCount));
fastestDuration = Math.min(fastestDuration, performance.now() - start);
fastestDuration = Math.min(fastestDuration, performance.now() - startedAt);
expect(result.diagnostics).toHaveLength(0);
}
return fastestDuration;
Expand Down
Loading
Loading