Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8363661
feat(api): ship diagnoseProjects publicly with additive config merging
devin-ai-integration[bot] Jun 10, 2026
0010e2a
chore: format changeset and merge config test
devin-ai-integration[bot] Jun 10, 2026
df13fe7
chore: remove changeset
devin-ai-integration[bot] Jun 10, 2026
f5564a5
fix(api): preserve config source directory and use true worker-pool c…
devin-ai-integration[bot] Jun 10, 2026
3c684d2
refactor(api): fold multi-project scoring into diagnose() overload
devin-ai-integration[bot] Jun 11, 2026
f6e6e33
feat(cli): --project accepts directory paths for per-module scoring
devin-ai-integration[bot] Jun 11, 2026
b2d9c2b
feat(cli): support per-module project selection via config `projects`…
devin-ai-integration[bot] Jun 11, 2026
b45727d
Merge origin/main (regenerate config schema)
devin-ai-integration[bot] Jun 11, 2026
7abd79a
refactor: deslop per-module scoring — reuse mapWithConcurrency, slim …
rayhanadev Jun 11, 2026
517f977
fix(cli): resolve each project's scan target like diagnose({ projects })
devin-ai-integration[bot] Jun 11, 2026
6637087
chore: retrigger CI (sentry-tracer clock flake on node 20)
devin-ai-integration[bot] Jun 11, 2026
a9eadd2
fix(api): bound diagnose({ projects }) batch concurrency by default
rayhanadev Jun 11, 2026
464e547
fix: resolve merged relative plugins against the config that supplied…
rayhanadev Jun 11, 2026
a1ec050
perf(cli): run multi-project scans through a bounded concurrent pool;…
devin-ai-integration[bot] Jun 11, 2026
10f28bb
fix(cli): keep concurrent multi-project scans out of the global Sentr…
rayhanadev Jun 11, 2026
8af01e6
fix(cli): silence spinners once around the multi-project pool instead…
devin-ai-integration[bot] Jun 11, 2026
46d3a21
fix(cli): filter multi-project diagnostics with each scan's merged co…
devin-ai-integration[bot] Jun 11, 2026
dc04cf2
Merge remote-tracking branch 'origin/main' into devin/1781083136-diag…
rayhanadev Jun 11, 2026
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
144 changes: 71 additions & 73 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import * as Layer from "effect/Layer";
import {
buildSkippedChecks,
Config,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
Files,
Git,
layerOtlp,
Linter,
LintPartialFailures,
mapWithConcurrency,
mergeReactDoctorConfigs,
Progress,
Project,
Reporter,
Expand Down Expand Up @@ -41,15 +44,15 @@ import type {
// stack is built once here rather than duplicated per variant.
const buildDiagnoseLayer = (
config: ReactDoctorConfig | null,
configOverride?: { readonly resolvedDirectory: string },
configOverrideTarget?: Pick<ResolvedScanTarget, "resolvedDirectory" | "configSourceDirectory">,
) => {
const configLayer =
configOverride === undefined
configOverrideTarget === undefined
? Config.layerNode
: Config.layerOf({
config,
resolvedDirectory: configOverride.resolvedDirectory,
configSourceDirectory: null,
resolvedDirectory: configOverrideTarget.resolvedDirectory,
configSourceDirectory: configOverrideTarget.configSourceDirectory,
});
return Layer.mergeAll(
Project.layerNode,
Expand Down Expand Up @@ -113,9 +116,9 @@ const outputToDiagnoseResult = (
};
};

export const diagnose = async (
const diagnoseDirectory = async (
directory: string,
options: DiagnoseOptions = {},
options: DiagnoseOptions,
): Promise<DiagnoseResult> => {
const startTime = globalThis.performance.now();
const scanTarget = await resolveScanTarget(directory);
Expand Down Expand Up @@ -149,21 +152,40 @@ const findWorstScore = (projectResults: ProjectResult[]): ScoreResult | null =>
const diagnoseProject = async (
projectDefinition: ProjectDefinition,
baseOptions: DiagnoseOptions,
batchConfig: ReactDoctorConfig | undefined,
): Promise<ProjectResult> => {
const startTime = globalThis.performance.now();

try {
const scanTarget = await resolveScanTarget(projectDefinition.directory);
const { directory: _, config: configOverride, ...perProjectOptions } = projectDefinition;
const mergedOptions: DiagnoseOptions = { ...baseOptions, ...perProjectOptions };
const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition;

const program = buildInspectProgram(scanTarget, mergedOptions, configOverride);
// Config layers, least to most specific: on-disk `doctor.config.*` ←
// batch `config` ← per-project `config`. With no overrides the merge is
// the identity and the orchestrator loads from disk (`Config.layerNode`).
const didOverrideConfig = batchConfig !== undefined || projectConfig !== undefined;
const effectiveConfig = mergeReactDoctorConfigs(
mergeReactDoctorConfigs(scanTarget.userConfig, batchConfig),
projectConfig,
);
Comment on lines +166 to +170

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Semantic change: per-project config now merges instead of replacing

Before this PR, a ProjectDefinition.config fully replaced the on-disk doctor.config.* for that project's scan. Now it merges additively via mergeReactDoctorConfigs (packages/api/src/diagnose.ts:163-166). This is an intentional behavioral change documented in the changeset and types, but worth noting as a potential breaking change for any existing callers that relied on the replacement semantics — e.g., a caller passing config: { rules: { "react-doctor/no-prop-drilling": "off" } } previously got ONLY that rule config, but now gets the on-disk rules merged with that override.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and intentional: replace semantics was the bug from the user's perspective — Akshay's use case (same base rules across the repo + small per-module overrides) is broken by full replacement, since a one-rule override silently dropped the entire base config. The change ships before diagnoseProjects is ever publicly exported (it was only reachable via the private, unpublished @react-doctor/api), so there are no external callers relying on the old semantics; the changeset and JSDoc document the new layering. Verified end-to-end in this comment (test 3).


const effectiveConfig = configOverride ?? scanTarget.userConfig;
const program = buildInspectProgram(
scanTarget,
{ ...baseOptions, ...perProjectOptions },
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.
const didOverridePlugins =
batchConfig?.plugins !== undefined || projectConfig?.plugins !== undefined;
const layer = buildDiagnoseLayer(
effectiveConfig,
configOverride !== undefined
? { resolvedDirectory: scanTarget.resolvedDirectory }
didOverrideConfig
? {
resolvedDirectory: scanTarget.resolvedDirectory,
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
}
: undefined,
);

Expand All @@ -185,76 +207,52 @@ const diagnoseProject = async (
}
};

/**
* Scan multiple projects in parallel and return per-project scores,
* diagnostics, and an aggregate score (worst-of across all projects).
*
* Each project runs its own independent `runInspect` pipeline — the
* same pipeline `diagnose()` uses — so per-project config overrides,
* dead-code analysis, and scoring all work identically to a single
* `diagnose()` call.
*
* Projects that fail (e.g. missing `package.json`, no React dependency)
* are included in the result with `ok: false` rather than aborting the
* entire batch, so callers always receive partial results.
*
* ```ts
* const result = await diagnoseProjects({
* projects: [
* { directory: "packages/app" },
* { directory: "packages/shared", deadCode: false },
* { directory: "packages/admin", config: {
* rules: { "react-doctor/no-array-index-as-key": "off" },
* }},
* ],
* concurrency: 4,
* });
*
* for (const project of result.projects) {
* if (project.ok) {
* console.log(project.directory, project.score);
* } else {
* console.error(project.directory, project.error);
* }
* }
* ```
*/
export const diagnoseProjects = async (
const diagnoseProjectBatch = async (
input: DiagnoseProjectsInput,
): Promise<DiagnoseProjectsResult> => {
const startTime = globalThis.performance.now();
const { projects, concurrency: rawConcurrency, ...baseOptions } = input;
const concurrency = Math.max(1, rawConcurrency ?? projects.length);

const projectResults: ProjectResult[] = [];
const pendingProjects = [...projects];

const runBatch = async (): Promise<void> => {
const batch: Promise<ProjectResult>[] = [];

while (pendingProjects.length > 0 && batch.length < concurrency) {
const projectDefinition = pendingProjects.shift()!;
batch.push(diagnoseProject(projectDefinition, baseOptions));
}

const batchResults = await Promise.all(batch);
projectResults.push(...batchResults);
const { projects, concurrency, config: batchConfig, ...baseOptions } = input;

if (pendingProjects.length > 0) {
await runBatch();
}
};

await runBatch();

const allDiagnostics = projectResults.flatMap((projectResult) =>
projectResult.ok ? projectResult.diagnostics : [],
// `diagnoseProject` never rejects (failures come back as `ok: false`),
// so the pool always drains every project.
const projectResults = await mapWithConcurrency(
projects,
concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY,
(projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig),
);

return {
projects: projectResults,
diagnostics: allDiagnostics,
diagnostics: projectResults.flatMap((projectResult) =>
projectResult.ok ? projectResult.diagnostics : [],
),
score: findWorstScore(projectResults),
elapsedMilliseconds: globalThis.performance.now() - startTime,
};
};

interface Diagnose {
/** Scan a single project directory and return diagnostics + score. */
(directory: string, options?: DiagnoseOptions): Promise<DiagnoseResult>;
/**
* Scan multiple projects in parallel — each through the same pipeline as
* the single-directory form — and return per-project results plus an
* aggregate worst-of score. A failing project (e.g. no `package.json`)
* comes back with `ok: false` instead of aborting the batch. Per-project
* `config` layers on the batch `config`, which layers on each project's
* on-disk config (see `mergeReactDoctorConfigs`).
*/
(input: DiagnoseProjectsInput): Promise<DiagnoseProjectsResult>;
}

// HACK: the cast is required to assign the overload implementation (whose
// return type is the union of both signatures) to the overloaded interface
// — TypeScript can't verify that narrowing on the first argument selects
// the matching return type.
export const diagnose = (async (
directoryOrInput: string | DiagnoseProjectsInput,
options: DiagnoseOptions = {},
): Promise<DiagnoseResult | DiagnoseProjectsResult> =>
typeof directoryOrInput === "string"
? diagnoseDirectory(directoryOrInput, options)
: diagnoseProjectBatch(directoryOrInput)) as Diagnose;
2 changes: 1 addition & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { diagnose, diagnoseProjects } from "./diagnose.js";
export { diagnose } from "./diagnose.js";
export { defineConfig } from "@react-doctor/core";

export type {
Expand Down
41 changes: 30 additions & 11 deletions packages/api/tests/diagnose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import * as path from "node:path";
import { afterAll, describe, expect, it } from "vite-plus/test";
import {
diagnose,
diagnoseProjects,
NoReactDependencyError,
NotADirectoryError,
ProjectNotFoundError,
Expand Down Expand Up @@ -79,9 +78,9 @@ describe("diagnose", () => {
});
});

describe("diagnoseProjects", () => {
describe("diagnose({ projects })", () => {
it("returns per-project results for multiple directories", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") },
Expand Down Expand Up @@ -109,7 +108,7 @@ describe("diagnoseProjects", () => {
});

it("flattens diagnostics across all succeeded projects", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") },
Expand All @@ -126,7 +125,7 @@ describe("diagnoseProjects", () => {
});

it("supports per-project scan option overrides", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react"), deadCode: false },
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app"), deadCode: false },
Expand All @@ -143,7 +142,7 @@ describe("diagnoseProjects", () => {
});

it("respects concurrency: 1 for sequential execution", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") },
Expand All @@ -158,7 +157,7 @@ describe("diagnoseProjects", () => {
});

it("handles a single project identically to diagnose()", async () => {
const multiResult = await diagnoseProjects({
const multiResult = await diagnose({
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
deadCode: false,
lint: false,
Expand All @@ -177,7 +176,7 @@ describe("diagnoseProjects", () => {
});

it("collects failing projects with ok: false without aborting the batch", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
{ directory: noReactTempDirectory },
Expand All @@ -197,7 +196,7 @@ describe("diagnoseProjects", () => {
});

it("returns empty results for an empty projects array", async () => {
const result = await diagnoseProjects({ projects: [], deadCode: false, lint: false });
const result = await diagnose({ projects: [], deadCode: false, lint: false });

expect(result.projects).toHaveLength(0);
expect(result.diagnostics).toHaveLength(0);
Expand All @@ -206,7 +205,7 @@ describe("diagnoseProjects", () => {
});

it("clamps concurrency: 0 to 1 without hanging", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
deadCode: false,
lint: false,
Expand All @@ -217,7 +216,7 @@ describe("diagnoseProjects", () => {
});

it("accepts per-project ReactDoctorConfig override", async () => {
const result = await diagnoseProjects({
const result = await diagnose({
projects: [
{
directory: path.join(FIXTURES_DIRECTORY, "basic-react"),
Expand All @@ -231,4 +230,24 @@ describe("diagnoseProjects", () => {
expect(result.projects).toHaveLength(1);
expect(result.projects[0].ok).toBe(true);
});

it("layers per-project configs on top of a batch-level config", async () => {
const result = await diagnose({
projects: [
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
{
directory: path.join(FIXTURES_DIRECTORY, "nextjs-app"),
config: { rules: { "react-doctor/no-prop-drilling": "off" } },
},
],
config: { ignore: { tags: ["design"] } },
deadCode: false,
lint: false,
});

expect(result.projects).toHaveLength(2);
for (const projectResult of result.projects) {
expect(projectResult.ok).toBe(true);
}
});
});
7 changes: 7 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ export const MIN_SCAN_CONCURRENCY = 1;

export const MAX_SCAN_CONCURRENCY = 16;

// Default worker count for a `diagnose({ projects })` batch. Each project
// scan already fans out its own oxlint workers (bounded by the constants
// above), so batch concurrency multiplies process count — a small bound
// keeps an 80-module monorepo from spawning hundreds of subprocesses by
// default. Callers opt into more via `DiagnoseProjectsInput.concurrency`.
export const DEFAULT_PROJECT_SCAN_CONCURRENCY = 4;

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 @@ -87,6 +87,7 @@ export * from "./utils/has-published-fix-recipe.js";
export * from "./utils/list-source-files.js";
export * from "./utils/map-with-concurrency.js";
export * from "./utils/match-glob-pattern.js";
export * from "./utils/merge-react-doctor-configs.js";
export * from "./utils/redact-sensitive-text.js";
export * from "./utils/resolve-github-actions-score-metadata.js";
export * from "./utils/resolve-scan-concurrency.js";
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,22 @@ export interface ReactDoctorConfig {
* requested directory).
*/
rootDir?: string;
/**
* Projects to scan and score separately — the config-file equivalent of
* the CLI `--project` flag, for repos that always want per-module scoring
* (e.g. a monorepo dashboard tracking each module's score daily) without
* passing the flag on every run.
*
* Entries resolve exactly like `--project` values: workspace package
* names (or directory basenames) first, then directory paths relative to
* the scanned root. `"*"` selects every discovered workspace project.
* Invalid entries fail the run with the same error as the flag.
*
* Precedence: an explicit `--project` flag overrides this list. Only the
* config at the invocation root is consulted — `projects` inside a
* module's own config is ignored (modules can't redirect the scan).
*/
projects?: string[];
textComponents?: string[];
/**
* Names of components that safely route string-only children through a
Expand Down
Loading
Loading