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
6 changes: 6 additions & 0 deletions .changeset/adopt-tailwind-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-doctor/core": patch
"react-doctor": patch
---

Preserve plugin settings when React Doctor adopts an existing lint config.
58 changes: 2 additions & 56 deletions packages/core/src/can-oxlint-extend-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from "node:fs";
import { parseJSONC } from "confbox";
import { isPlainObject } from "./project-info/index.js";

const EXTENDS_LOCAL_PATH_PREFIXES = ["./", "../", "/"];
Expand All @@ -10,61 +11,6 @@ const isLocalPathExtend = (entry: string): boolean => {
return false;
};

// HACK: ESLint's JSON config files in the wild are routinely JSONC —
// `//` line comments and `/* */` block comments. Strict `JSON.parse`
// throws on them. Strip both forms (avoiding matches inside string
// literals) so the extends pre-screen still works on real Next.js /
// CRA / TypeScript scaffolds.
const stripJsoncComments = (raw: string): string => {
let result = "";
let cursor = 0;
let inString = false;
let stringQuote = "";
while (cursor < raw.length) {
const character = raw[cursor];
const nextCharacter = raw[cursor + 1];
if (inString) {
result += character;
if (character === "\\" && cursor + 1 < raw.length) {
result += nextCharacter;
cursor += 2;
continue;
}
if (character === stringQuote) inString = false;
cursor += 1;
continue;
}
if (character === '"' || character === "'") {
inString = true;
stringQuote = character;
result += character;
cursor += 1;
continue;
}
if (character === "/" && nextCharacter === "/") {
const lineEndIndex = raw.indexOf("\n", cursor);
cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
continue;
}
if (character === "/" && nextCharacter === "*") {
const blockEndIndex = raw.indexOf("*/", cursor + 2);
cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
continue;
}
result += character;
cursor += 1;
}
return result;
};

const parseJsonOrJsonc = (raw: string): unknown => {
try {
return JSON.parse(raw);
} catch {
return JSON.parse(stripJsoncComments(raw));
}
};

// HACK: oxlint's `extends` resolver only handles local file paths and
// other oxlint configs — bare-package extends (`"next"`, `"airbnb"`,
// `"plugin:@typescript-eslint/recommended"`) crash the parser with
Expand All @@ -83,7 +29,7 @@ export const canOxlintExtendConfig = (configPath: string): boolean => {
let parsed: unknown;
try {
const raw = fs.readFileSync(configPath, "utf-8");
parsed = parseJsonOrJsonc(raw);
parsed = parseJSONC<unknown>(raw, { allowTrailingComma: true });
} catch {
return true;
}
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/read-adopted-lint-config-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as fs from "node:fs";
import { parseJSONC } from "confbox";
import { isPlainObject } from "./project-info/index.js";

export interface AdoptedLintConfigSettings {
readonly [pluginName: string]: unknown;
}

export const readAdoptedLintConfigSettings = (
configPaths: ReadonlyArray<string>,
): AdoptedLintConfigSettings => {
const mergedSettings = new Map<string, unknown>();

for (const configPath of configPaths) {
let parsed: unknown;
try {
const raw = fs.readFileSync(configPath, "utf-8");
parsed = parseJSONC<unknown>(raw, { allowTrailingComma: true });
} catch {
continue;
}

if (!isPlainObject(parsed)) continue;

const settings = parsed.settings;
if (!isPlainObject(settings)) continue;

for (const [pluginName, pluginSettings] of Object.entries(settings)) {
if (pluginName === "react-doctor") continue;
mergedSettings.set(pluginName, pluginSettings);
}
}

return Object.fromEntries(mergedSettings);
};
4 changes: 4 additions & 0 deletions packages/core/src/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { buildRuleSeverityControls } from "./build-rule-severity-controls.js";
import { canOxlintExtendConfig } from "./can-oxlint-extend-config.js";
import { collectIgnorePatterns } from "./collect-ignore-patterns.js";
import { detectUserLintConfigPaths } from "./detect-user-lint-config.js";
import { readAdoptedLintConfigSettings } from "./read-adopted-lint-config-settings.js";
import { ReactDoctorError } from "./errors.js";
import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js";
import { computeRulesetHash } from "./runners/oxlint/compute-ruleset-hash.js";
Expand Down Expand Up @@ -328,6 +329,8 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
// the parser crash + misleading warning. Drop them up front so the
// scan starts in the same state the fallback would land in.
const extendsPaths = detectedConfigPaths.filter(canOxlintExtendConfig);
const adoptedSettings =
detectedConfigPaths.length > 0 ? readAdoptedLintConfigSettings(detectedConfigPaths) : {};
const userPlugins =
includedTags.size > 0 ? [] : resolveUserPlugins(userConfig?.plugins, configSourceDirectory);

Expand Down Expand Up @@ -452,6 +455,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
serverAuthFunctionNames,
projectIndexModuleSources,
severityControls,
adoptedSettings,
userPlugins,
disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
ruleSelection: overrides.ruleSelection,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runners/oxlint/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "oxlint-plugin-react-doctor/core";
import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor/core";
import type { ProjectInfo, RuleSeverityControls } from "../../types/index.js";
import type { AdoptedLintConfigSettings } from "../../read-adopted-lint-config-settings.js";
import { resolveRuleSeverityOverride } from "../../resolve-rule-severity-override.js";
import { COMPILER_CLEANUP_BUCKET, COMPILER_CLEANUP_RULE_KEYS } from "../../constants.js";
import { getCapabilities, shouldEnableRule } from "../../project-info/capabilities.js";
Expand All @@ -28,6 +29,7 @@ export interface OxlintConfigOptions {
serverAuthFunctionNames?: ReadonlyArray<string>;
projectIndexModuleSources?: ReadonlyArray<string>;
severityControls?: RuleSeverityControls;
adoptedSettings?: AdoptedLintConfigSettings;
/**
* User-declared plugins from `react-doctor.config.json`'s
* `plugins: [...]`, already resolved + introspected via
Expand Down Expand Up @@ -134,6 +136,7 @@ export const createOxlintConfig = ({
serverAuthFunctionNames,
projectIndexModuleSources,
severityControls,
adoptedSettings = {},
userPlugins = [],
disableReactHooksJsPlugin = false,
ruleSelection,
Expand Down Expand Up @@ -281,6 +284,7 @@ export const createOxlintConfig = ({
plugins: [],
jsPlugins: [...jsPlugins, pluginPath],
settings: {
...adoptedSettings,
"react-doctor": {
portedRuleMode: "curated",
framework: project.framework,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/tests/can-oxlint-extend-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe("canOxlintExtendConfig", () => {
"extends": ["next", "plugin:@typescript-eslint/recommended"],
"rules": {
// "off-for-now": "off"
}
},
}
`,
);
Expand Down
14 changes: 14 additions & 0 deletions packages/core/tests/fixtures/user-tailwind-config/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"plugins": ["oxlint-plugin-tailwindcss"],
"rules": {
"tailwindcss/enforce-canonical": "error",
"tailwindcss/enforce-sort-order": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/no-unknown-classes": "error"
},
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css"
}
}
}
13 changes: 13 additions & 0 deletions packages/core/tests/fixtures/user-tailwind-config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "user-tailwind-config",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"oxlint-plugin-tailwindcss": "^1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from "react";

export const Button = () => {
return <button className="bg-blue-500 hover:bg-blue-700 text-white">Click me</button>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
25 changes: 25 additions & 0 deletions packages/core/tests/oxlint-config-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,31 @@ describe("createOxlintConfig settings", () => {
});
});

it("merges adopted settings without replacing react-doctor settings", () => {
const config = createOxlintConfig({
pluginPath: "/tmp/plugin.js",
project: tailwindViteWebProject,
runtimeGlobals: ["DatePicker"],
adoptedSettings: {
tailwindcss: {
entryPoint: "src/styles.css",
},
"other-plugin": {
option: "value",
},
},
});

expect(config.settings.tailwindcss).toEqual({
entryPoint: "src/styles.css",
});
expect(config.settings["other-plugin"]).toEqual({
option: "value",
});
expect(config.settings["react-doctor"].framework).toBe("vite");
expect(config.settings["react-doctor"].runtimeGlobals).toEqual(["DatePicker"]);
});

it("never registers security scan rules (they run as a core environment check)", () => {
const config = createOxlintConfig({
pluginPath: "/tmp/plugin.js",
Expand Down
106 changes: 106 additions & 0 deletions packages/core/tests/read-adopted-lint-config-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { canOxlintExtendConfig } from "../src/can-oxlint-extend-config.js";
import { readAdoptedLintConfigSettings } from "../src/read-adopted-lint-config-settings.js";

describe("readAdoptedLintConfigSettings", () => {
let temporaryDirectory: string;

beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-settings-"));
});

afterEach(() => {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});

const writeConfig = (filename: string, content: string): string => {
const configPath = path.join(temporaryDirectory, filename);
fs.writeFileSync(configPath, content);
return configPath;
};

it("reads JSONC settings and skips react-doctor settings", () => {
const configPath = writeConfig(
".oxlintrc.json",
`{
// Keep the Tailwind source path.
"settings": {
"react-doctor": { "framework": "next" },
"tailwindcss": { "entryPoint": "src/styles.css" },
},
}`,
);

expect(readAdoptedLintConfigSettings([configPath])).toEqual({
tailwindcss: { entryPoint: "src/styles.css" },
});
});

it("reads settings from an ESLint config that Oxlint cannot extend", () => {
const configPath = writeConfig(
".eslintrc.json",
JSON.stringify({
extends: ["next/core-web-vitals"],
settings: { tailwindcss: { entryPoint: "src/styles.css" } },
}),
);

expect(canOxlintExtendConfig(configPath)).toBe(false);
expect(readAdoptedLintConfigSettings([configPath])).toEqual({
tailwindcss: { entryPoint: "src/styles.css" },
});
});

it("uses the last value when configs contain the same setting", () => {
const firstConfigPath = writeConfig(
"first.json",
JSON.stringify({ settings: { tailwindcss: { entryPoint: "first.css" } } }),
);
const secondConfigPath = writeConfig(
"second.json",
JSON.stringify({
settings: {
tailwindcss: { entryPoint: "second.css" },
"other-plugin": { option: true },
},
}),
);

expect(readAdoptedLintConfigSettings([firstConfigPath, secondConfigPath])).toEqual({
tailwindcss: { entryPoint: "second.css" },
"other-plugin": { option: true },
});
});

it("skips missing, invalid, and non-settings configs", () => {
const invalidConfigPath = writeConfig("invalid.json", "{ invalid json");
const rulesOnlyConfigPath = writeConfig(
"rules-only.json",
JSON.stringify({ rules: { "no-debugger": "error" } }),
);

expect(
readAdoptedLintConfigSettings([
path.join(temporaryDirectory, "missing.json"),
invalidConfigPath,
rulesOnlyConfigPath,
]),
).toEqual({});
});

it("does not allow special keys to change the result prototype", () => {
const configPath = writeConfig(
".oxlintrc.json",
`{"settings":{"__proto__":{"polluted":true}}}`,
);

const settings = readAdoptedLintConfigSettings([configPath]);

expect(Object.getPrototypeOf(settings)).toBe(Object.prototype);
expect(Object.hasOwn(settings, "__proto__")).toBe(false);
expect(Object.prototype).not.toHaveProperty("polluted");
});
});
1 change: 1 addition & 0 deletions packages/react-doctor/tests/run-oxlint/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const USER_OXLINT_CONFIG_BROKEN_DIRECTORY = path.join(
FIXTURES_DIRECTORY,
"user-oxlint-config-broken",
);
export const USER_TAILWIND_CONFIG_DIRECTORY = path.join(FIXTURES_DIRECTORY, "user-tailwind-config");

const findDiagnosticsByRule = (diagnostics: Diagnostic[], rule: string): Diagnostic[] =>
diagnostics.filter((diagnostic) => diagnostic.rule === rule);
Expand Down
Loading
Loading