Skip to content
64 changes: 22 additions & 42 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ import {
Git,
layerOtlp,
Linter,
LintPartialFailures,
loadConfigWithSource,
Project,
Reporter,
resolveConfigRootDir,
resolveDiagnoseTarget,
resolveScanPlan,
runInspect,
Score,
type InspectOutput,
Expand All @@ -23,16 +20,19 @@ import {
NoReactDependencyError,
ProjectNotFoundError,
} from "@react-doctor/core";
import type { DiagnoseOptions, DiagnoseResult } from "@react-doctor/core";
import type { DiagnoseOptions, DiagnoseResult, ResolvedScanPlan } from "@react-doctor/core";

const buildLayerStack = () =>
const buildLayerStack = (scanPlan: ResolvedScanPlan) =>
Layer.mergeAll(
Project.layerNode,
Config.layerNode,
Config.layerOf({
config: scanPlan.userConfig,
resolvedDirectory: scanPlan.resolvedDirectory ?? scanPlan.directoryAfterRootDir,
configSourceDirectory: scanPlan.configSourceDirectory,
}),
Files.layerNode,
Git.layerNode,
Linter.layerOxlint,
LintPartialFailures.layerLive,
DeadCode.layerNode,
Score.layerHttp,
Reporter.layerNoop,
Expand All @@ -44,45 +44,25 @@ export const diagnose = async (
): Promise<DiagnoseResult> => {
const startTime = globalThis.performance.now();
const requestedDirectory = path.resolve(directory);
const scanPlan = resolveScanPlan({
directory: requestedDirectory,
options,
shouldResolveDiagnoseTarget: true,
});
const resolvedDirectory = scanPlan.resolvedDirectory;

/**
* Pre-resolve the rootDir redirect + auto-fallback to nested React
* subprojects BEFORE handing off to runInspect. These two
* directory-shape concerns predate the project-discovery boundary:
* the rootDir redirect happens against the config (which lives at
* the requested directory), and resolveDiagnoseTarget walks down to
* find a nested React project when the requested directory itself
* lacks a package.json. runInspect itself only knows "go discover
* the project at this directory".
*/
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
const redirectedDirectory = resolveConfigRootDir(
initialLoadedConfig?.config ?? null,
initialLoadedConfig?.sourceDirectory ?? null,
);
const directoryAfterRedirect = redirectedDirectory ?? requestedDirectory;

// resolveDiagnoseTarget throws AmbiguousProjectError when the
// requested directory has multiple React subprojects; let it
// propagate so the legacy public-API contract holds. A `null`
// return means "no React project here" — translate that to the
// ProjectNotFoundError the legacy diagnose() used.
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
if (!resolvedDirectory) {
throw new ProjectNotFoundError(directoryAfterRedirect);
throw new ProjectNotFoundError(scanPlan.directoryAfterRootDir);
}

const includePaths = options.includePaths ?? [];

const program = runInspect({
directory: resolvedDirectory,
includePaths,
customRulesOnly: initialLoadedConfig?.config?.customRulesOnly ?? false,
respectInlineDisables:
options.respectInlineDisables ?? initialLoadedConfig?.config?.respectInlineDisables ?? true,
adoptExistingLintConfig: initialLoadedConfig?.config?.adoptExistingLintConfig ?? true,
ignoredTags: new Set(initialLoadedConfig?.config?.ignore?.tags ?? []),
runDeadCode: options.deadCode ?? initialLoadedConfig?.config?.deadCode ?? true,
includePaths: scanPlan.options.includePaths,
customRulesOnly: scanPlan.options.customRulesOnly,
respectInlineDisables: scanPlan.options.respectInlineDisables,
adoptExistingLintConfig: scanPlan.options.adoptExistingLintConfig,
ignoredTags: scanPlan.options.ignoredTags,
runDeadCode: scanPlan.options.deadCode,
isCi: false,
});

Expand All @@ -96,7 +76,7 @@ export const diagnose = async (
// grep-stderr callers.
const output: InspectOutput = await Effect.runPromise(
program.pipe(
Effect.provide(buildLayerStack()),
Effect.provide(buildLayerStack(scanPlan)),
// Opt-in OTLP exporter. No-op unless REACT_DOCTOR_OTLP_ENDPOINT
// + REACT_DOCTOR_OTLP_AUTH_HEADER are set in the environment;
// see `core/observability.ts` for the env-driven config.
Expand Down
65 changes: 65 additions & 0 deletions packages/api/tests/diagnose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vite-plus/test";
import { diagnose, NoReactDependencyError, ProjectNotFoundError } from "../src/index.js";
import { clearConfigCache } from "@react-doctor/core";

const FIXTURES_DIRECTORY = path.resolve(
import.meta.dirname,
Expand All @@ -19,6 +20,34 @@ fs.writeFileSync(
JSON.stringify({ name: "no-react", dependencies: {} }),
);

const forbiddenWordPlugin = `
const noForbiddenWordRule = {
create: (context) => ({
JSXText(node) {
if (typeof node.value !== "string") return;
if (node.value.includes("FORBIDDEN")) {
context.report({
node,
message: "team policy: 'FORBIDDEN' is not allowed in JSX text",
});
}
},
}),
};

module.exports = {
meta: { name: "team-conventions" },
rules: {
"no-forbidden-word": noForbiddenWordRule,
},
};
`;

const writeJson = (filePath: string, value: unknown): void => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value));
};

afterAll(() => {
fs.rmSync(noReactTempDirectory, { recursive: true, force: true });
});
Expand Down Expand Up @@ -60,4 +89,40 @@ describe("diagnose", () => {
});
expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0);
});

it("resolves config plugins from the config source directory after rootDir redirect", async () => {
clearConfigCache();
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-rootdir-plugin-"));
try {
const webProjectDirectory = path.join(tempDirectory, "apps", "web");
writeJson(path.join(webProjectDirectory, "package.json"), {
name: "web",
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
});
writeJson(path.join(webProjectDirectory, "tsconfig.json"), {
compilerOptions: { jsx: "preserve", strict: false, target: "es2022", module: "esnext" },
});
fs.mkdirSync(path.join(webProjectDirectory, "src"), { recursive: true });
fs.writeFileSync(
path.join(webProjectDirectory, "src/App.tsx"),
`export const App = () => <div>FORBIDDEN content</div>;\n`,
);
fs.mkdirSync(path.join(tempDirectory, "lint"), { recursive: true });
fs.writeFileSync(path.join(tempDirectory, "lint/team-conventions.cjs"), forbiddenWordPlugin);
writeJson(path.join(tempDirectory, "react-doctor.config.json"), {
rootDir: "apps/web",
plugins: ["./lint/team-conventions.cjs"],
rules: { "team-conventions/no-forbidden-word": "error" },
});

const result = await diagnose(tempDirectory, { deadCode: false });

expect(result.project.rootDirectory).toBe(webProjectDirectory);
expect(result.diagnostics.some((diagnostic) => diagnostic.rule === "no-forbidden-word")).toBe(
true,
);
} finally {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
});
});
2 changes: 2 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000;
// vs the hard-cap perf cliffs they prevent.
export const OXLINT_MAX_FILES_PER_BATCH = 100;

export const OXLINT_MAX_CONCURRENT_BATCHES_COUNT = 3;

export const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];

// JSON-format oxlint / eslint configs react-doctor can fold into the
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./errors.js";
export * from "./observability.js";
export * from "./paths.js";
export * from "./refs.js";
export * from "./resolve-scan-plan.js";
export * from "./run-inspect.js";
// Selective re-exports from `./schemas.js` only — most class names
// (Diagnostic, JsonReport, JsonReportSummary, …) collide with the
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/resolve-scan-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { resolveConfigRootDir } from "./resolve-config-root-dir.js";
import { loadConfigWithSource } from "./load-config.js";
import { resolveDiagnoseTarget } from "./resolve-diagnose-target.js";
import type { DiagnosticSurface, InspectOptions, ReactDoctorConfig } from "./types/index.js";

export interface ResolvedScanOptions {
readonly lint: boolean;
readonly deadCode: boolean;
readonly verbose: boolean;
readonly scoreOnly: boolean;
readonly noScore: boolean;
readonly isCi: boolean;
readonly silent: boolean;
readonly includePaths: string[];
readonly customRulesOnly: boolean;
readonly share: boolean;
readonly respectInlineDisables: boolean;
readonly adoptExistingLintConfig: boolean;
readonly ignoredTags: ReadonlySet<string>;
readonly outputSurface: DiagnosticSurface;
}

export interface ResolveScanPlanInput {
readonly directory: string;
readonly options?: InspectOptions;
/**
* `diagnose()` accepts a directory above the actual React project and
* resolves it to the only React subproject when unambiguous. The CLI and
* public `inspect()` do their own project selection, so they keep the
* post-rootDir directory as-is.
*/
readonly shouldResolveDiagnoseTarget?: boolean;
}

export interface ResolvedScanPlan {
readonly requestedDirectory: string;
readonly directoryAfterRootDir: string;
readonly resolvedDirectory: string | null;
readonly userConfig: ReactDoctorConfig | null;
readonly configSourceDirectory: string | null;
readonly hasConfigOverride: boolean;
readonly options: ResolvedScanOptions;
}

const buildIgnoredTags = (userConfig: ReactDoctorConfig | null): ReadonlySet<string> => {
const tags = new Set<string>();
if (userConfig?.ignore?.tags) {
for (const tag of userConfig.ignore.tags) tags.add(tag);
}
return tags;
};

const resolveUserConfig = (
directory: string,
options: InspectOptions,
): {
readonly userConfig: ReactDoctorConfig | null;
readonly configSourceDirectory: string | null;
readonly directoryAfterRootDir: string;
readonly hasConfigOverride: boolean;
} => {
const hasConfigOverride = options.configOverride !== undefined;
if (hasConfigOverride) {
return {
userConfig: options.configOverride ?? null,
configSourceDirectory: null,
directoryAfterRootDir: directory,
hasConfigOverride,
};
}

const loadedConfig = loadConfigWithSource(directory);
const redirectedDirectory = resolveConfigRootDir(
loadedConfig?.config ?? null,
loadedConfig?.sourceDirectory ?? null,
);
return {
userConfig: loadedConfig?.config ?? null,
configSourceDirectory: loadedConfig?.sourceDirectory ?? null,
directoryAfterRootDir: redirectedDirectory ?? directory,
hasConfigOverride,
};
};

export const resolveScanOptions = (
options: InspectOptions,
userConfig: ReactDoctorConfig | null,
): ResolvedScanOptions => ({
lint: options.lint ?? userConfig?.lint ?? true,
deadCode: options.deadCode ?? userConfig?.deadCode ?? true,
verbose: options.verbose ?? userConfig?.verbose ?? false,
scoreOnly: options.scoreOnly ?? false,
noScore: options.noScore ?? userConfig?.noScore ?? false,
isCi: options.isCi ?? false,
silent: options.silent ?? false,
includePaths: options.includePaths ?? [],
customRulesOnly: userConfig?.customRulesOnly ?? false,
share: userConfig?.share ?? true,
respectInlineDisables: options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
ignoredTags: buildIgnoredTags(userConfig),
outputSurface: options.outputSurface ?? "cli",
});

export const resolveScanPlan = (input: ResolveScanPlanInput): ResolvedScanPlan => {
const options = input.options ?? {};
const requestedDirectory = input.directory;
const resolvedConfig = resolveUserConfig(requestedDirectory, options);
const resolvedDirectory = input.shouldResolveDiagnoseTarget
? resolveDiagnoseTarget(resolvedConfig.directoryAfterRootDir)
: resolvedConfig.directoryAfterRootDir;

return {
requestedDirectory,
directoryAfterRootDir: resolvedConfig.directoryAfterRootDir,
resolvedDirectory,
userConfig: resolvedConfig.userConfig,
configSourceDirectory: resolvedConfig.configSourceDirectory,
hasConfigOverride: resolvedConfig.hasConfigOverride,
options: resolveScanOptions(options, resolvedConfig.userConfig),
};
};
Loading