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
5 changes: 5 additions & 0 deletions .changeset/api-lint-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Honor the programmatic API `lint: false` option by skipping the oxlint layer during `diagnose()` scans.
19 changes: 12 additions & 7 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const warnIfAiTrainingEnvironment = (): void => {
const buildDiagnoseLayer = (
config: ReactDoctorConfig | null,
configOverrideTarget?: Pick<ResolvedScanTarget, "resolvedDirectory" | "configSourceDirectory">,
shouldRunLint = true,
) => {
const configLayer =
configOverrideTarget === undefined
Expand All @@ -73,7 +74,7 @@ const buildDiagnoseLayer = (
DeadCode.layerNode,
Files.layerNode,
Git.layerNode,
Linter.layerOxlint,
shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]),
LintPartialFailures.layerLive,
Progress.layerNoop,
Reporter.layerNoop,
Expand All @@ -82,6 +83,11 @@ const buildDiagnoseLayer = (
);
};

const resolveShouldRunLint = (
options: DiagnoseOptions,
config: ReactDoctorConfig | null | undefined,
): boolean => options.lint ?? config?.lint ?? true;

const buildInspectProgram = (
scanTarget: ResolvedScanTarget,
options: DiagnoseOptions,
Expand Down Expand Up @@ -137,11 +143,12 @@ const diagnoseDirectory = async (
const startTime = globalThis.performance.now();
const scanTarget = await resolveScanTarget(directory);
const program = buildInspectProgram(scanTarget, options);
const shouldRunLint = resolveShouldRunLint(options, scanTarget.userConfig);

const output: InspectOutput = await Effect.runPromise(
restoreLegacyThrow(
program.pipe(
Effect.provide(buildDiagnoseLayer(scanTarget.userConfig)),
Effect.provide(buildDiagnoseLayer(scanTarget.userConfig, undefined, shouldRunLint)),
Effect.provide(layerOtlp),
),
),
Expand Down Expand Up @@ -173,6 +180,7 @@ const diagnoseProject = async (
try {
const scanTarget = await resolveScanTarget(projectDefinition.directory);
const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition;
const options = { ...baseOptions, ...perProjectOptions };

// Config layers, least to most specific: on-disk `doctor.config.*` ←
// batch `config` ← per-project `config`. With no overrides the merge is
Expand All @@ -183,11 +191,7 @@ const diagnoseProject = async (
projectConfig,
);

const program = buildInspectProgram(
scanTarget,
{ ...baseOptions, ...perProjectOptions },
effectiveConfig ?? undefined,
);
const program = buildInspectProgram(scanTarget, options, effectiveConfig ?? undefined);
// `plugins` is override-wins in the merge: when a caller layer supplies
// it, relative entries resolve against the scan root (caller configs
// have no file location); otherwise the on-disk config's directory.
Expand All @@ -201,6 +205,7 @@ const diagnoseProject = async (
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
}
: undefined,
resolveShouldRunLint(options, effectiveConfig),
);

const output: InspectOutput = await Effect.runPromise(
Expand Down
3 changes: 3 additions & 0 deletions packages/api/tests/diagnose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ describe("diagnose", () => {
expect(result).toHaveProperty("elapsedMilliseconds");
expect(result.project.reactMajorVersion).toBe(19);
expect(Array.isArray(result.diagnostics)).toBe(true);
expect(result.diagnostics.some((diagnostic) => diagnostic.filePath.endsWith(".tsx"))).toBe(
false,
);
});

it("throws NoReactDependencyError when the directory has package.json without react", async () => {
Expand Down
9 changes: 0 additions & 9 deletions packages/deslop-js/src/collect/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
SCRIPT_FILE_PATTERN,
SCRIPT_EXTENSIONLESS_FILE_PATTERN,
SCRIPT_CONFIG_FILE_PATTERN,
SCRIPT_ENTRY_PATTERNS,
SHALLOW_WORKSPACE_MAX_DEPTH,
SOURCE_EXTENSIONS as IMPORTABLE_SOURCE_EXTENSIONS,
} from "../constants.js";
Expand Down Expand Up @@ -894,14 +893,6 @@ const extractScriptEntries = (directory: string): string[] => {
}
} catch {}

const scriptDirectoryFiles = fg.sync(SCRIPT_ENTRY_PATTERNS, {
cwd: directory,
absolute: true,
onlyFiles: true,
ignore: ["**/node_modules/**"],
});
entries.push(...scriptDirectoryFiles);

return entries;
};

Expand Down
2 changes: 0 additions & 2 deletions packages/deslop-js/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ export const SCRIPT_EXTENSIONLESS_FILE_PATTERN =
export const SCRIPT_CONFIG_FILE_PATTERN =
/--config\s+([\w./@-]+\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))/;

export const SCRIPT_ENTRY_PATTERNS: string[] = [];

export const DEFAULT_ENTRY_GLOBS = [
"src/index.{ts,tsx,js,jsx}",
"src/main.{ts,tsx,js,jsx}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const dispatchTreeWalk = (root: EsTreeNode, visitors: RuleVisitors): void => {
const visit = (node: EsTreeNode): void => {
const handler = visitors[node.type];
if (typeof handler === "function") handler(node);

const nodeRecord = node as unknown as Record<string, unknown>;
for (const key of Object.keys(nodeRecord)) {
if (key === "parent") continue;
Expand All @@ -45,15 +46,16 @@ const dispatchTreeWalk = (root: EsTreeNode, visitors: RuleVisitors): void => {
visit(child);
}
}

const exitHandler = visitors[`${node.type}:exit`];
if (typeof exitHandler === "function") exitHandler(node);
};
visit(root);
const programExitHandler = visitors["Program:exit"];
if (typeof programExitHandler === "function") programExitHandler(root);
};

// Pure-TS rule runner mirroring what oxlint does at runtime: parse code,
// attach `parent` references, build a fake `RuleContext`, dispatch each
// `node.type` / `Program:exit` to the matching visitor, and collect every
// `node.type` / `node.type:exit` to the matching visitor, and collect every
// `report({...})` call as a `RuleDiagnostic`. Used by every `<rule>.test.ts`
// to assert pass/fail semantics ported from OXC's `Tester::new(...).pass / .fail`.
export const runRule = (rule: Rule, code: string, options: RunRuleOptions = {}): RunRuleResult => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-doctor/tests/github-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("GitHub Action contract", () => {
"comment",
"review-comments",
"commit-status",
"scope",
"node-version",
"version",
]) {
Expand Down
11 changes: 9 additions & 2 deletions packages/react-doctor/tests/run-oxlint/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const USER_OXLINT_CONFIG_BROKEN_DIRECTORY = path.join(
const findDiagnosticsByRule = (diagnostics: Diagnostic[], rule: string): Diagnostic[] =>
diagnostics.filter((diagnostic) => diagnostic.rule === rule);

const normalizePathForAssertion = (filePath: string): string => filePath.replaceAll("\\", "/");

export interface RuleTestCase {
fixture: string;
ruleSource: string;
Expand All @@ -42,9 +44,14 @@ export const describeRules = (
for (const [ruleName, testCase] of Object.entries(rules)) {
it(`${ruleName} (${testCase.fixture} → ${testCase.ruleSource})`, () => {
const issues = findDiagnosticsByRule(getDiagnostics(), ruleName);
const expectedFixture = normalizePathForAssertion(testCase.fixture);
const matchingIssue = issues.find((diagnostic) =>
normalizePathForAssertion(diagnostic.filePath).endsWith(expectedFixture),
);
expect(issues.length).toBeGreaterThan(0);
if (testCase.severity) expect(issues[0].severity).toBe(testCase.severity);
if (testCase.category) expect(issues[0].category).toBe(testCase.category);
expect(matchingIssue).toBeDefined();
if (testCase.severity) expect(matchingIssue?.severity).toBe(testCase.severity);
if (testCase.category) expect(matchingIssue?.category).toBe(testCase.category);
});
}
});
Expand Down
Loading