From 83636611c607a6c9fb27f0e098d05525fc033aa5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:18:56 +0000 Subject: [PATCH 01/16] feat(api): ship diagnoseProjects publicly with additive config merging Co-Authored-By: Aiden Bai --- .changeset/expose-diagnose-projects.md | 23 +++++ packages/api/src/diagnose.ts | 33 +++++-- packages/api/tests/diagnose.test.ts | 34 ++++++++ packages/core/src/index.ts | 1 + packages/core/src/types/diagnose.ts | 23 +++-- .../src/utils/merge-react-doctor-configs.ts | 80 +++++++++++++++++ .../tests/merge-react-doctor-configs.test.ts | 86 +++++++++++++++++++ packages/react-doctor/src/index.ts | 14 ++- 8 files changed, 279 insertions(+), 15 deletions(-) create mode 100644 .changeset/expose-diagnose-projects.md create mode 100644 packages/core/src/utils/merge-react-doctor-configs.ts create mode 100644 packages/core/tests/merge-react-doctor-configs.test.ts diff --git a/.changeset/expose-diagnose-projects.md b/.changeset/expose-diagnose-projects.md new file mode 100644 index 000000000..268484194 --- /dev/null +++ b/.changeset/expose-diagnose-projects.md @@ -0,0 +1,23 @@ +--- +"react-doctor": minor +--- + +Expose `diagnoseProjects()` from the published `react-doctor` package for native per-module scoring — scan many projects in parallel and get per-project scores, diagnostics, and a worst-of aggregate score without shelling out to the CLI per module. + +Per-project `config` now **merges additively** onto the base config instead of replacing it: `rules` / `categories` merge per key, `ignore` lists union, and scalar fields override. A new batch-level `config` on `diagnoseProjects({ config })` applies one shared base rule set across every project, with each project's own `config` layered on top. + +```ts +import { diagnoseProjects } from "react-doctor"; + +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" }, + }}, + ], + config: { rules: { "react-doctor/no-prop-drilling": "off" } }, + concurrency: 4, +}); +``` diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index bd59ddeee..aac34b188 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -10,6 +10,7 @@ import { layerOtlp, Linter, LintPartialFailures, + mergeReactDoctorConfigs, Progress, Project, Reporter, @@ -149,22 +150,30 @@ const findWorstScore = (projectResults: ProjectResult[]): ScoreResult | null => const diagnoseProject = async ( projectDefinition: ProjectDefinition, baseOptions: DiagnoseOptions, + batchConfig: ReactDoctorConfig | undefined, ): Promise => { const startTime = globalThis.performance.now(); try { const scanTarget = await resolveScanTarget(projectDefinition.directory); - const { directory: _, config: configOverride, ...perProjectOptions } = projectDefinition; + const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition; const mergedOptions: DiagnoseOptions = { ...baseOptions, ...perProjectOptions }; - const program = buildInspectProgram(scanTarget, mergedOptions, configOverride); + const didOverrideConfig = batchConfig !== undefined || projectConfig !== undefined; + const effectiveConfig = mergeReactDoctorConfigs( + mergeReactDoctorConfigs(scanTarget.userConfig, batchConfig), + projectConfig, + ); + + const program = buildInspectProgram( + scanTarget, + mergedOptions, + didOverrideConfig ? (effectiveConfig ?? undefined) : undefined, + ); - const effectiveConfig = configOverride ?? scanTarget.userConfig; const layer = buildDiagnoseLayer( effectiveConfig, - configOverride !== undefined - ? { resolvedDirectory: scanTarget.resolvedDirectory } - : undefined, + didOverrideConfig ? { resolvedDirectory: scanTarget.resolvedDirectory } : undefined, ); const output: InspectOutput = await Effect.runPromise( @@ -194,6 +203,13 @@ const diagnoseProject = async ( * dead-code analysis, and scoring all work identically to a single * `diagnose()` call. * + * Config layering (least to most specific): the project's on-disk + * `doctor.config.*` ← the batch-level `config` ← the project's own + * `config`. Each layer merges additively via `mergeReactDoctorConfigs` + * (`rules` / `categories` merge per key, `ignore` lists union, scalars + * override), so one base rule set can serve the whole batch with + * narrow per-project exceptions. + * * 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. @@ -207,6 +223,7 @@ const diagnoseProject = async ( * rules: { "react-doctor/no-array-index-as-key": "off" }, * }}, * ], + * config: { rules: { "react-doctor/no-prop-drilling": "off" } }, * concurrency: 4, * }); * @@ -223,7 +240,7 @@ export const diagnoseProjects = async ( input: DiagnoseProjectsInput, ): Promise => { const startTime = globalThis.performance.now(); - const { projects, concurrency: rawConcurrency, ...baseOptions } = input; + const { projects, concurrency: rawConcurrency, config: batchConfig, ...baseOptions } = input; const concurrency = Math.max(1, rawConcurrency ?? projects.length); const projectResults: ProjectResult[] = []; @@ -234,7 +251,7 @@ export const diagnoseProjects = async ( while (pendingProjects.length > 0 && batch.length < concurrency) { const projectDefinition = pendingProjects.shift()!; - batch.push(diagnoseProject(projectDefinition, baseOptions)); + batch.push(diagnoseProject(projectDefinition, baseOptions, batchConfig)); } const batchResults = await Promise.all(batch); diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 4933d9634..69a0edd25 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -231,4 +231,38 @@ describe("diagnoseProjects", () => { expect(result.projects).toHaveLength(1); expect(result.projects[0].ok).toBe(true); }); + + it("accepts a batch-level config shared across every project", async () => { + const result = await diagnoseProjects({ + projects: [ + { directory: path.join(FIXTURES_DIRECTORY, "basic-react") }, + { directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") }, + ], + config: { rules: { "react-doctor/no-array-index-as-key": "off" } }, + deadCode: false, + lint: false, + }); + + expect(result.projects).toHaveLength(2); + for (const projectResult of result.projects) { + expect(projectResult.ok).toBe(true); + } + }); + + it("layers a per-project config on top of the batch-level config", async () => { + const result = await diagnoseProjects({ + projects: [ + { + directory: path.join(FIXTURES_DIRECTORY, "basic-react"), + config: { rules: { "react-doctor/no-prop-drilling": "off" } }, + }, + ], + config: { ignore: { tags: ["design"] } }, + deadCode: false, + lint: false, + }); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0].ok).toBe(true); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dddcc0855..a87996da8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index afd865309..a51497471 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -41,16 +41,18 @@ export interface DiagnoseResult { * A single project to scan as part of a `diagnoseProjects()` batch. * Scan options (`deadCode`, `lint`, etc.) are flat on the entry and * layer on top of the global defaults — omitted fields fall through. - * `config` is a full `ReactDoctorConfig` override that replaces the - * on-disk `doctor.config.*` for this project's scan. + * `config` layers on top of the batch-level `config` and the project's + * on-disk `doctor.config.*` (see `mergeReactDoctorConfigs`). */ export interface ProjectDefinition extends DiagnoseOptions { directory: string; /** - * Full react-doctor config override for this project. When provided, - * replaces the on-disk `doctor.config.*` for this project's - * scan — the scan target resolver still runs (so `rootDir` and - * subproject discovery work), but its loaded config is swapped out. + * Per-project react-doctor config overrides. Merged on top of the + * effective base config (the project's on-disk `doctor.config.*`, + * then the batch-level `DiagnoseProjectsInput.config`) via + * `mergeReactDoctorConfigs`: `rules` / `categories` merge per key, + * `ignore` lists union, and scalar fields are overridden when set — + * so disabling one rule here keeps every base rule intact. */ config?: ReactDoctorConfig; } @@ -70,6 +72,15 @@ export type ProjectResult = ProjectResultOk | ProjectResultError; export interface DiagnoseProjectsInput extends DiagnoseOptions { projects: ProjectDefinition[]; + /** + * Shared react-doctor config overrides applied to every project in + * the batch. Merged on top of each project's on-disk + * `doctor.config.*` (per-key for `rules` / `categories`, unioned for + * `ignore` lists), with each `ProjectDefinition.config` layered on + * top of that. Use this to keep one base rule set across the batch + * and override per project only where needed. + */ + config?: ReactDoctorConfig; /** * Maximum number of projects to scan concurrently. Defaults to the * number of projects (fully parallel). Set to `1` for sequential diff --git a/packages/core/src/utils/merge-react-doctor-configs.ts b/packages/core/src/utils/merge-react-doctor-configs.ts new file mode 100644 index 000000000..abe8fdbdc --- /dev/null +++ b/packages/core/src/utils/merge-react-doctor-configs.ts @@ -0,0 +1,80 @@ +import type { ReactDoctorConfig } from "../types/config.js"; + +type ReactDoctorIgnore = NonNullable; + +const mergeUniqueArrays = ( + baseValues: string[] | undefined, + overrideValues: string[] | undefined, +): string[] | undefined => { + if (baseValues === undefined) return overrideValues; + if (overrideValues === undefined) return baseValues; + return [...new Set([...baseValues, ...overrideValues])]; +}; + +const mergeRecords = ( + baseRecord: Record | undefined, + overrideRecord: Record | undefined, +): Record | undefined => { + if (baseRecord === undefined) return overrideRecord; + if (overrideRecord === undefined) return baseRecord; + return { ...baseRecord, ...overrideRecord }; +}; + +const mergeIgnoreConfigs = ( + baseIgnore: ReactDoctorIgnore | undefined, + overrideIgnore: ReactDoctorIgnore | undefined, +): ReactDoctorIgnore | undefined => { + if (baseIgnore === undefined) return overrideIgnore; + if (overrideIgnore === undefined) return baseIgnore; + + const mergedIgnore: ReactDoctorIgnore = {}; + const rules = mergeUniqueArrays(baseIgnore.rules, overrideIgnore.rules); + const files = mergeUniqueArrays(baseIgnore.files, overrideIgnore.files); + const tags = mergeUniqueArrays(baseIgnore.tags, overrideIgnore.tags); + if (rules !== undefined) mergedIgnore.rules = rules; + if (files !== undefined) mergedIgnore.files = files; + if (tags !== undefined) mergedIgnore.tags = tags; + if (baseIgnore.overrides !== undefined || overrideIgnore.overrides !== undefined) { + mergedIgnore.overrides = [...(baseIgnore.overrides ?? []), ...(overrideIgnore.overrides ?? [])]; + } + return mergedIgnore; +}; + +/** + * Layer one `ReactDoctorConfig` on top of another, additively: + * + * - `rules` / `categories` merge per key — the override restamps or + * disables individual rules without discarding the base map. + * - `ignore.rules` / `ignore.files` / `ignore.tags` union (deduplicated); + * `ignore.overrides` concatenate. + * - `supplyChain` merges per field. + * - Every other field is a scalar (or positional value) where layering + * has no additive meaning, so the override simply wins when set. + * + * Returns the base unchanged when there is no override, and vice versa — + * so callers can thread `null`/`undefined` through without special-casing. + */ +export const mergeReactDoctorConfigs = ( + baseConfig: ReactDoctorConfig | null, + overrideConfig: ReactDoctorConfig | undefined, +): ReactDoctorConfig | null => { + if (overrideConfig === undefined) return baseConfig; + if (baseConfig === null) return overrideConfig; + + const mergedConfig: ReactDoctorConfig = { ...baseConfig, ...overrideConfig }; + + const ignore = mergeIgnoreConfigs(baseConfig.ignore, overrideConfig.ignore); + if (ignore !== undefined) mergedConfig.ignore = ignore; + + const rules = mergeRecords(baseConfig.rules, overrideConfig.rules); + if (rules !== undefined) mergedConfig.rules = rules; + + const categories = mergeRecords(baseConfig.categories, overrideConfig.categories); + if (categories !== undefined) mergedConfig.categories = categories; + + if (baseConfig.supplyChain !== undefined && overrideConfig.supplyChain !== undefined) { + mergedConfig.supplyChain = { ...baseConfig.supplyChain, ...overrideConfig.supplyChain }; + } + + return mergedConfig; +}; diff --git a/packages/core/tests/merge-react-doctor-configs.test.ts b/packages/core/tests/merge-react-doctor-configs.test.ts new file mode 100644 index 000000000..c05a98c1c --- /dev/null +++ b/packages/core/tests/merge-react-doctor-configs.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ReactDoctorConfig } from "@react-doctor/core"; +import { mergeReactDoctorConfigs } from "@react-doctor/core"; + +describe("mergeReactDoctorConfigs", () => { + it("returns the base unchanged when there is no override", () => { + const baseConfig: ReactDoctorConfig = { rules: { "react-doctor/no-prop-drilling": "off" } }; + expect(mergeReactDoctorConfigs(baseConfig, undefined)).toBe(baseConfig); + }); + + it("returns the override when the base is null", () => { + const overrideConfig: ReactDoctorConfig = { verbose: true }; + expect(mergeReactDoctorConfigs(null, overrideConfig)).toBe(overrideConfig); + }); + + it("returns null when both sides are empty", () => { + expect(mergeReactDoctorConfigs(null, undefined)).toBeNull(); + }); + + it("merges `rules` per key with the override winning on conflicts", () => { + const merged = mergeReactDoctorConfigs( + { + rules: { + "react-doctor/no-prop-drilling": "error", + "react-doctor/no-array-index-as-key": "error", + }, + }, + { rules: { "react-doctor/no-array-index-as-key": "off" } }, + ); + expect(merged?.rules).toEqual({ + "react-doctor/no-prop-drilling": "error", + "react-doctor/no-array-index-as-key": "off", + }); + }); + + it("merges `categories` per key", () => { + const merged = mergeReactDoctorConfigs( + { categories: { Performance: "warn" } }, + { categories: { Maintainability: "off" } }, + ); + expect(merged?.categories).toEqual({ Performance: "warn", Maintainability: "off" }); + }); + + it("unions `ignore` lists and concatenates `ignore.overrides`", () => { + const merged = mergeReactDoctorConfigs( + { + ignore: { + rules: ["react-doctor/no-prop-drilling"], + files: ["src/legacy/**"], + tags: ["design"], + overrides: [{ files: ["src/old/**"] }], + }, + }, + { + ignore: { + rules: ["react-doctor/no-prop-drilling", "react-doctor/no-array-index-as-key"], + tags: ["test-noise"], + overrides: [{ files: ["src/generated/**"] }], + }, + }, + ); + expect(merged?.ignore).toEqual({ + rules: ["react-doctor/no-prop-drilling", "react-doctor/no-array-index-as-key"], + files: ["src/legacy/**"], + tags: ["design", "test-noise"], + overrides: [{ files: ["src/old/**"] }, { files: ["src/generated/**"] }], + }); + }); + + it("merges `supplyChain` per field", () => { + const merged = mergeReactDoctorConfigs( + { supplyChain: { enabled: true } }, + { supplyChain: { severity: "warning" } }, + ); + expect(merged?.supplyChain).toEqual({ enabled: true, severity: "warning" }); + }); + + it("overrides scalar fields when set and keeps base scalars otherwise", () => { + const merged = mergeReactDoctorConfigs( + { deadCode: true, verbose: true }, + { deadCode: false }, + ); + expect(merged?.deadCode).toBe(false); + expect(merged?.verbose).toBe(true); + }); +}); diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index ca8be7585..88809f655 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -11,6 +11,8 @@ import { import type { Diagnostic, DiagnoseOptions, + DiagnoseProjectsInput, + DiagnoseProjectsResult, DiagnoseResult, DiffInfo, JsonReport, @@ -19,7 +21,11 @@ import type { JsonReportMode, JsonReportProjectEntry, JsonReportSummary, + ProjectDefinition, ProjectInfo, + ProjectResult, + ProjectResultError, + ProjectResultOk, ReactDoctorConfig, ScoreResult, } from "@react-doctor/core"; @@ -27,6 +33,8 @@ import type { export type { Diagnostic, DiagnoseOptions, + DiagnoseProjectsInput, + DiagnoseProjectsResult, DiagnoseResult, DiffInfo, JsonReport, @@ -35,7 +43,11 @@ export type { JsonReportMode, JsonReportProjectEntry, JsonReportSummary, + ProjectDefinition, ProjectInfo, + ProjectResult, + ProjectResultError, + ProjectResultOk, ReactDoctorConfig, ScoreResult, }; @@ -109,4 +121,4 @@ export const toJsonReport = (result: DiagnoseResult, options: ToJsonReportOption totalElapsedMilliseconds: result.elapsedMilliseconds, }); -export { diagnose } from "@react-doctor/api"; +export { diagnose, diagnoseProjects } from "@react-doctor/api"; From 0010e2ae28a73faffe3da79f263c433ed97e12a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:25:27 +0000 Subject: [PATCH 02/16] chore: format changeset and merge config test Co-Authored-By: Aiden Bai --- .changeset/expose-diagnose-projects.md | 9 ++++++--- packages/core/tests/merge-react-doctor-configs.test.ts | 5 +---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/expose-diagnose-projects.md b/.changeset/expose-diagnose-projects.md index 268484194..92f07df36 100644 --- a/.changeset/expose-diagnose-projects.md +++ b/.changeset/expose-diagnose-projects.md @@ -13,9 +13,12 @@ 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" }, - }}, + { + directory: "packages/admin", + config: { + rules: { "react-doctor/no-array-index-as-key": "off" }, + }, + }, ], config: { rules: { "react-doctor/no-prop-drilling": "off" } }, concurrency: 4, diff --git a/packages/core/tests/merge-react-doctor-configs.test.ts b/packages/core/tests/merge-react-doctor-configs.test.ts index c05a98c1c..79bd7b252 100644 --- a/packages/core/tests/merge-react-doctor-configs.test.ts +++ b/packages/core/tests/merge-react-doctor-configs.test.ts @@ -76,10 +76,7 @@ describe("mergeReactDoctorConfigs", () => { }); it("overrides scalar fields when set and keeps base scalars otherwise", () => { - const merged = mergeReactDoctorConfigs( - { deadCode: true, verbose: true }, - { deadCode: false }, - ); + const merged = mergeReactDoctorConfigs({ deadCode: true, verbose: true }, { deadCode: false }); expect(merged?.deadCode).toBe(false); expect(merged?.verbose).toBe(true); }); From df13fe7074c05f355312c606bcb75a2150a94dbc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:50:24 +0000 Subject: [PATCH 03/16] chore: remove changeset Co-Authored-By: Aiden Bai --- .changeset/expose-diagnose-projects.md | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 .changeset/expose-diagnose-projects.md diff --git a/.changeset/expose-diagnose-projects.md b/.changeset/expose-diagnose-projects.md deleted file mode 100644 index 92f07df36..000000000 --- a/.changeset/expose-diagnose-projects.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"react-doctor": minor ---- - -Expose `diagnoseProjects()` from the published `react-doctor` package for native per-module scoring — scan many projects in parallel and get per-project scores, diagnostics, and a worst-of aggregate score without shelling out to the CLI per module. - -Per-project `config` now **merges additively** onto the base config instead of replacing it: `rules` / `categories` merge per key, `ignore` lists union, and scalar fields override. A new batch-level `config` on `diagnoseProjects({ config })` applies one shared base rule set across every project, with each project's own `config` layered on top. - -```ts -import { diagnoseProjects } from "react-doctor"; - -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" }, - }, - }, - ], - config: { rules: { "react-doctor/no-prop-drilling": "off" } }, - concurrency: 4, -}); -``` From f5564a5c1122bf6c3ca818e55f98b90771104118 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:37:35 +0000 Subject: [PATCH 04/16] fix(api): preserve config source directory and use true worker-pool concurrency in diagnoseProjects Co-Authored-By: Aiden Bai --- packages/api/src/diagnose.ts | 44 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index aac34b188..a5786f7ed 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -42,7 +42,10 @@ import type { // stack is built once here rather than duplicated per variant. const buildDiagnoseLayer = ( config: ReactDoctorConfig | null, - configOverride?: { readonly resolvedDirectory: string }, + configOverride?: { + readonly resolvedDirectory: string; + readonly configSourceDirectory: string | null; + }, ) => { const configLayer = configOverride === undefined @@ -50,7 +53,7 @@ const buildDiagnoseLayer = ( : Config.layerOf({ config, resolvedDirectory: configOverride.resolvedDirectory, - configSourceDirectory: null, + configSourceDirectory: configOverride.configSourceDirectory, }); return Layer.mergeAll( Project.layerNode, @@ -173,7 +176,12 @@ const diagnoseProject = async ( const layer = buildDiagnoseLayer( effectiveConfig, - didOverrideConfig ? { resolvedDirectory: scanTarget.resolvedDirectory } : undefined, + didOverrideConfig + ? { + resolvedDirectory: scanTarget.resolvedDirectory, + configSourceDirectory: scanTarget.configSourceDirectory, + } + : undefined, ); const output: InspectOutput = await Effect.runPromise( @@ -243,26 +251,24 @@ export const diagnoseProjects = async ( const { projects, concurrency: rawConcurrency, config: batchConfig, ...baseOptions } = input; const concurrency = Math.max(1, rawConcurrency ?? projects.length); - const projectResults: ProjectResult[] = []; - const pendingProjects = [...projects]; + const projectResults: ProjectResult[] = new Array(projects.length); + let nextProjectIndex = 0; - const runBatch = async (): Promise => { - const batch: Promise[] = []; - - while (pendingProjects.length > 0 && batch.length < concurrency) { - const projectDefinition = pendingProjects.shift()!; - batch.push(diagnoseProject(projectDefinition, baseOptions, batchConfig)); - } - - const batchResults = await Promise.all(batch); - projectResults.push(...batchResults); - - if (pendingProjects.length > 0) { - await runBatch(); + const runWorker = async (): Promise => { + while (nextProjectIndex < projects.length) { + const projectIndex = nextProjectIndex; + nextProjectIndex += 1; + projectResults[projectIndex] = await diagnoseProject( + projects[projectIndex]!, + baseOptions, + batchConfig, + ); } }; - await runBatch(); + await Promise.all( + Array.from({ length: Math.min(concurrency, projects.length) }, () => runWorker()), + ); const allDiagnostics = projectResults.flatMap((projectResult) => projectResult.ok ? projectResult.diagnostics : [], From 3c684d221f5e27b82da50f0ae258ec92bc1e3672 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:50:47 +0000 Subject: [PATCH 05/16] refactor(api): fold multi-project scoring into diagnose() overload Co-Authored-By: Aiden Bai --- packages/api/src/diagnose.ts | 108 ++++++++++++++++------------ packages/api/src/index.ts | 2 +- packages/api/tests/diagnose.test.ts | 25 ++++--- packages/core/src/types/diagnose.ts | 2 +- packages/react-doctor/src/index.ts | 2 +- 5 files changed, 78 insertions(+), 61 deletions(-) diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index a5786f7ed..1bc39a1ba 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -117,9 +117,9 @@ const outputToDiagnoseResult = ( }; }; -export const diagnose = async ( +const diagnoseDirectory = async ( directory: string, - options: DiagnoseOptions = {}, + options: DiagnoseOptions, ): Promise => { const startTime = globalThis.performance.now(); const scanTarget = await resolveScanTarget(directory); @@ -202,49 +202,7 @@ 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. - * - * Config layering (least to most specific): the project's on-disk - * `doctor.config.*` ← the batch-level `config` ← the project's own - * `config`. Each layer merges additively via `mergeReactDoctorConfigs` - * (`rules` / `categories` merge per key, `ignore` lists union, scalars - * override), so one base rule set can serve the whole batch with - * narrow per-project exceptions. - * - * 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" }, - * }}, - * ], - * config: { rules: { "react-doctor/no-prop-drilling": "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 => { const startTime = globalThis.performance.now(); @@ -281,3 +239,63 @@ export const diagnoseProjects = async ( elapsedMilliseconds: globalThis.performance.now() - startTime, }; }; + +interface Diagnose { + /** Scan a single project directory and return diagnostics + score. */ + (directory: string, options?: DiagnoseOptions): Promise; + /** + * 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 the single-directory form uses — so per-project + * config overrides, dead-code analysis, and scoring all work + * identically to a single-directory call. + * + * Config layering (least to most specific): the project's on-disk + * `doctor.config.*` ← the batch-level `config` ← the project's own + * `config`. Each layer merges additively via `mergeReactDoctorConfigs` + * (`rules` / `categories` merge per key, `ignore` lists union, scalars + * override), so one base rule set can serve the whole batch with + * narrow per-project exceptions. + * + * 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 diagnose({ + * projects: [ + * { directory: "packages/app" }, + * { directory: "packages/shared", deadCode: false }, + * { directory: "packages/admin", config: { + * rules: { "react-doctor/no-array-index-as-key": "off" }, + * }}, + * ], + * config: { rules: { "react-doctor/no-prop-drilling": "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); + * } + * } + * ``` + */ + (input: DiagnoseProjectsInput): Promise; +} + +// HACK: the cast is the standard escape hatch for assigning an 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 => + typeof directoryOrInput === "string" + ? diagnoseDirectory(directoryOrInput, options) + : diagnoseProjectBatch(directoryOrInput)) as Diagnose; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d5a3c3c83..3a5e2eed9 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,4 @@ -export { diagnose, diagnoseProjects } from "./diagnose.js"; +export { diagnose } from "./diagnose.js"; export { defineConfig } from "@react-doctor/core"; export type { diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 69a0edd25..41b1da94a 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -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, @@ -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") }, @@ -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") }, @@ -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 }, @@ -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") }, @@ -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, @@ -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 }, @@ -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); @@ -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, @@ -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"), @@ -233,7 +232,7 @@ describe("diagnoseProjects", () => { }); it("accepts a batch-level config shared across every project", async () => { - const result = await diagnoseProjects({ + const result = await diagnose({ projects: [ { directory: path.join(FIXTURES_DIRECTORY, "basic-react") }, { directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") }, @@ -250,7 +249,7 @@ describe("diagnoseProjects", () => { }); it("layers a per-project config on top of the batch-level config", async () => { - const result = await diagnoseProjects({ + const result = await diagnose({ projects: [ { directory: path.join(FIXTURES_DIRECTORY, "basic-react"), diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index a51497471..703ef4799 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -38,7 +38,7 @@ export interface DiagnoseResult { } /** - * A single project to scan as part of a `diagnoseProjects()` batch. + * A single project to scan as part of a `diagnose({ projects })` batch. * Scan options (`deadCode`, `lint`, etc.) are flat on the entry and * layer on top of the global defaults — omitted fields fall through. * `config` layers on top of the batch-level `config` and the project's diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 88809f655..442e89823 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -121,4 +121,4 @@ export const toJsonReport = (result: DiagnoseResult, options: ToJsonReportOption totalElapsedMilliseconds: result.elapsedMilliseconds, }); -export { diagnose, diagnoseProjects } from "@react-doctor/api"; +export { diagnose } from "@react-doctor/api"; From f6e6e33d493a7703d8bd291f7c1a22f6c5c67903 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:12:21 +0000 Subject: [PATCH 06/16] feat(cli): --project accepts directory paths for per-module scoring Co-Authored-By: Aiden Bai --- .../react-doctor/src/cli/commands/inspect.ts | 12 +++- packages/react-doctor/src/cli/index.ts | 11 ++- .../react-doctor/src/cli/utils/constants.ts | 1 + .../src/cli/utils/select-projects.ts | 45 +++++++++--- .../tests/select-projects.test.ts | 69 +++++++++++++++++++ packages/website/public/llms.txt | 2 +- 6 files changed, 127 insertions(+), 13 deletions(-) diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index b6d989601..8ee3ab101 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -11,6 +11,8 @@ import { getChangedLineRanges, getDiffInfo, highlighter, + loadConfigWithSource, + mergeReactDoctorConfigs, resolveScanTarget, toRelativePath, } from "@react-doctor/core"; @@ -544,10 +546,18 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro if (!isQuiet && !isMultiProject) { logger.dim(" "); } + // Each project's own doctor.config layers additively onto the root + // config (rules merge per key, ignore lists union) — same semantics as + // `diagnose({ projects })` — so per-module overrides apply without + // discarding the shared base rules. + const projectConfig = + projectDirectory === resolvedDirectory + ? undefined + : (await loadConfigWithSource(projectDirectory))?.config; const scanResult = await inspect(projectDirectory, { ...scanOptions, includePaths, - configOverride: userConfig, + configOverride: mergeReactDoctorConfigs(userConfig, projectConfig), suppressRendering: isMultiProject, baseline: baselineRef ? { ref: baselineRef } : undefined, changedLineRanges: diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index af35f32eb..cfa19acc1 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -60,6 +60,7 @@ ${formatExampleLines([ ["react-doctor", "scan the current project"], ["react-doctor ./apps/web", "scan a specific directory"], ["react-doctor --diff main", "scan only files changed vs. main"], + ["react-doctor --project modules/a,modules/b", "score multiple modules (per-module scores)"], ["react-doctor --staged", "scan staged files (pre-commit hook)"], ["react-doctor --category Security", "show only one diagnostic category"], ["react-doctor --blocking warning", "fail CI on warnings too (default: error)"], @@ -118,7 +119,10 @@ const program = new Command() "--no-parallel", "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)", ) - .option("--project ", "select workspace project (comma-separated for multiple)") + .option( + "--project ", + "select projects: workspace names or directory paths (comma-separated for multiple)", + ) .option( "--scope ", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)", @@ -183,7 +187,10 @@ program.action(inspectAction); program .command("why ") .description("Explain why a rule fired (or why a suppression didn't apply) at a file:line") - .option("--project ", "select workspace project (comma-separated for multiple)") + .option( + "--project ", + "select projects: workspace names or directory paths (comma-separated for multiple)", + ) .option("-c, --cwd ", "working directory", process.cwd()) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index a53543e70..047d47e63 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -132,6 +132,7 @@ export const METRIC = { cliInvoked: "cli.invoked", cliError: "cli.error", projectDetected: "project.detected", + projectPathSelected: "project.path_selected", scanCompleted: "scan.completed", scanDuration: "scan.duration", scanPhaseDuration: "scan.phase_duration", diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index 854abe102..0d0b8fffa 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -3,13 +3,16 @@ import type { WorkspacePackage } from "@react-doctor/core"; import { discoverReactSubprojects, highlighter, + isDirectory, isFile, isMonorepoRoot, listWorkspacePackages, } from "@react-doctor/core"; import { cliLogger as logger } from "./cli-logger.js"; import { CliInputError } from "./cli-input-error.js"; +import { METRIC } from "./constants.js"; import { prompts } from "./prompts.js"; +import { recordCount } from "./record-metric.js"; export const selectProjects = async ( rootDirectory: string, @@ -22,6 +25,12 @@ export const selectProjects = async ( packages = discoverReactSubprojects(rootDirectory); } + // The flag wins over workspace discovery: it can name packages OR point at + // arbitrary directories (per-module scoring in repos whose modules aren't + // workspace packages), so it must resolve even when discovery finds 0 or 1 + // packages — previously it was silently ignored in those cases. + if (projectFlag) return resolveProjectFlag(projectFlag, packages, rootDirectory); + if (packages.length === 0) return [rootDirectory]; if (packages.length === 1) { logger.log( @@ -30,8 +39,6 @@ export const selectProjects = async ( return [packages[0].directory]; } - if (projectFlag) return resolveProjectFlag(projectFlag, packages); - if (skipPrompts) { printDiscoveredProjects(packages); return packages.map((workspacePackage) => workspacePackage.directory); @@ -45,6 +52,7 @@ const ALL_PROJECTS_SENTINEL = "*"; const resolveProjectFlag = ( projectFlag: string, workspacePackages: WorkspacePackage[], + rootDirectory: string, ): string[] => { const requestedNames = projectFlag .split(",") @@ -63,10 +71,13 @@ const resolveProjectFlag = ( // making "scan all workspace projects" explicit instead of relying on // the empty-flag prompt-skip fallback. if (requestedNames.includes(ALL_PROJECTS_SENTINEL)) { - return workspacePackages.map((workspacePackage) => workspacePackage.directory); + return workspacePackages.length > 0 + ? workspacePackages.map((workspacePackage) => workspacePackage.directory) + : [rootDirectory]; } const resolvedDirectories: string[] = []; + let pathSelectionCount = 0; for (const requestedName of requestedNames) { const matched = workspacePackages.find( @@ -75,14 +86,30 @@ const resolveProjectFlag = ( path.basename(workspacePackage.directory) === requestedName, ); - if (!matched) { - const availableNames = workspacePackages - .map((workspacePackage) => workspacePackage.name) - .join(", "); - throw new CliInputError(`Project "${requestedName}" not found. Available: ${availableNames}`); + if (matched) { + resolvedDirectories.push(matched.directory); + continue; + } + + const candidateDirectory = path.resolve(rootDirectory, requestedName); + if (isDirectory(candidateDirectory)) { + resolvedDirectories.push(candidateDirectory); + pathSelectionCount += 1; + continue; } - resolvedDirectories.push(matched.directory); + const availableNames = workspacePackages + .map((workspacePackage) => workspacePackage.name) + .join(", "); + throw new CliInputError( + workspacePackages.length > 0 + ? `Project "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` + : `Project "${requestedName}" is not a directory under ${rootDirectory}.`, + ); + } + + if (pathSelectionCount > 0) { + recordCount(METRIC.projectPathSelected, pathSelectionCount); } return resolvedDirectories; diff --git a/packages/react-doctor/tests/select-projects.test.ts b/packages/react-doctor/tests/select-projects.test.ts index b39bcb5f8..bc5a3775d 100644 --- a/packages/react-doctor/tests/select-projects.test.ts +++ b/packages/react-doctor/tests/select-projects.test.ts @@ -108,6 +108,75 @@ describe("selectProjects", () => { ); }); + it("resolves --project directory paths when modules are not workspace packages", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + fs.mkdirSync(path.join(tempDirectory, "modules", "billing"), { recursive: true }); + fs.mkdirSync(path.join(tempDirectory, "modules", "payroll"), { recursive: true }); + + const selectedDirectories = await selectProjects( + tempDirectory, + "modules/billing,modules/payroll", + true, + ); + + expect(selectedDirectories).toEqual([ + path.join(tempDirectory, "modules", "billing"), + path.join(tempDirectory, "modules", "payroll"), + ]); + expect(prompts).not.toHaveBeenCalled(); + }); + + it("mixes workspace package names and directory paths in --project", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { + name: "workspace", + workspaces: ["apps/*"], + }); + const webDirectory = setupReactProject(path.join(tempDirectory, "apps"), "web"); + setupReactProject(path.join(tempDirectory, "apps"), "docs"); + fs.mkdirSync(path.join(tempDirectory, "modules", "billing"), { recursive: true }); + + const selectedDirectories = await selectProjects(tempDirectory, "web,modules/billing", true); + + expect(selectedDirectories).toEqual([ + webDirectory, + path.join(tempDirectory, "modules", "billing"), + ]); + }); + + it("rejects a --project entry that is neither a workspace project nor a directory", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + + await expect(selectProjects(tempDirectory, "modules/missing", true)).rejects.toThrow( + /is not a directory under/, + ); + }); + + it("resolves the --project flag even when discovery finds a single workspace package", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { + name: "workspace", + workspaces: ["apps/*"], + }); + setupReactProject(path.join(tempDirectory, "apps"), "web"); + fs.mkdirSync(path.join(tempDirectory, "modules", "billing"), { recursive: true }); + + const selectedDirectories = await selectProjects(tempDirectory, "modules/billing", true); + + expect(selectedDirectories).toEqual([path.join(tempDirectory, "modules", "billing")]); + }); + + it("falls back to the root directory for --project '*' when no packages are discovered", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + + const selectedDirectories = await selectProjects(tempDirectory, "*", true); + + expect(selectedDirectories).toEqual([tempDirectory]); + }); + it("rejects a --project flag that names no project (e.g. just commas)", async () => { const tempDirectory = createTempDirectory(); writeJson(path.join(tempDirectory, "package.json"), { diff --git a/packages/website/public/llms.txt b/packages/website/public/llms.txt index 3a363402f..78e23068c 100644 --- a/packages/website/public/llms.txt +++ b/packages/website/public/llms.txt @@ -98,7 +98,7 @@ Options: --score output only the score --json output a single structured JSON report (suppresses other output) -y, --yes skip prompts, scan all workspace projects - --project select workspace project (comma-separated for multiple) + --project select projects: workspace names or directory paths (comma-separated for multiple) --diff [base] scan only files changed vs base branch (pass `false` to force a full scan) --no-score skip the score API and share URL --staged scan only staged (git index) files for pre-commit hooks From b2d9c2b9167c3b1d576b149e417b5d93810db36a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:31:56 +0000 Subject: [PATCH 07/16] feat(cli): support per-module project selection via config `projects` field Co-Authored-By: Aiden Bai --- packages/core/src/types/config.ts | 16 +++++ .../react-doctor/src/cli/commands/inspect.ts | 7 ++- packages/react-doctor/src/cli/index.ts | 2 +- .../react-doctor/src/cli/utils/constants.ts | 1 + .../src/cli/utils/select-projects.ts | 32 +++++++++- .../tests/select-projects.test.ts | 49 +++++++++++++++ packages/website/public/llms.txt | 2 +- packages/website/public/schema/config.json | 59 ++++++++++++++++++- 8 files changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index ad1db1242..f9683a635 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -277,6 +277,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 diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 8ee3ab101..a85475838 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -416,7 +416,12 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro return; } - const projectDirectories = await selectProjects(resolvedDirectory, flags.project, skipPrompts); + const projectDirectories = await selectProjects( + resolvedDirectory, + flags.project, + skipPrompts, + userConfig?.projects, + ); const changedFilesDiffInfo = flags.changedFilesFrom ? buildChangedFilesDiffInfo(readChangedFilesFrom(path.resolve(flags.changedFilesFrom))) diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index cfa19acc1..c51901fe6 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -121,7 +121,7 @@ const program = new Command() ) .option( "--project ", - "select projects: workspace names or directory paths (comma-separated for multiple)", + "select projects: workspace names or directory paths (comma-separated for multiple); overrides the `projects` config field", ) .option( "--scope ", diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index 047d47e63..e9754126e 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -133,6 +133,7 @@ export const METRIC = { cliError: "cli.error", projectDetected: "project.detected", projectPathSelected: "project.path_selected", + projectConfigSelected: "project.config_selected", scanCompleted: "scan.completed", scanDuration: "scan.duration", scanPhaseDuration: "scan.phase_duration", diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index 0d0b8fffa..c611002d9 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -18,6 +18,7 @@ export const selectProjects = async ( rootDirectory: string, projectFlag: string | undefined, skipPrompts: boolean, + configProjects?: readonly string[], ): Promise => { const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); let packages = listWorkspacePackages(rootDirectory); @@ -31,6 +32,23 @@ export const selectProjects = async ( // packages — previously it was silently ignored in those cases. if (projectFlag) return resolveProjectFlag(projectFlag, packages, rootDirectory); + // The config's `projects` field is the flag's persistent form: same + // resolution, same errors, but declared once in doctor.config.* instead + // of on every invocation. The flag (handled above) overrides it. + const configRequestedNames = (configProjects ?? []) + .map((requestedName) => requestedName.trim()) + .filter((requestedName) => requestedName.length > 0); + if (configRequestedNames.length > 0) { + const resolvedDirectories = resolveRequestedProjects( + configRequestedNames, + packages, + rootDirectory, + "config", + ); + recordCount(METRIC.projectConfigSelected, resolvedDirectories.length); + return resolvedDirectories; + } + if (packages.length === 0) return [rootDirectory]; if (packages.length === 1) { logger.log( @@ -67,6 +85,15 @@ const resolveProjectFlag = ( ); } + return resolveRequestedProjects(requestedNames, workspacePackages, rootDirectory, "flag"); +}; + +const resolveRequestedProjects = ( + requestedNames: string[], + workspacePackages: WorkspacePackage[], + rootDirectory: string, + source: "flag" | "config", +): string[] => { // `*` (the GitHub Action's default) selects every discovered project, // making "scan all workspace projects" explicit instead of relying on // the empty-flag prompt-skip fallback. @@ -76,6 +103,7 @@ const resolveProjectFlag = ( : [rootDirectory]; } + const sourceLabel = source === "flag" ? "Project" : 'Config "projects" entry'; const resolvedDirectories: string[] = []; let pathSelectionCount = 0; @@ -103,8 +131,8 @@ const resolveProjectFlag = ( .join(", "); throw new CliInputError( workspacePackages.length > 0 - ? `Project "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` - : `Project "${requestedName}" is not a directory under ${rootDirectory}.`, + ? `${sourceLabel} "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` + : `${sourceLabel} "${requestedName}" is not a directory under ${rootDirectory}.`, ); } diff --git a/packages/react-doctor/tests/select-projects.test.ts b/packages/react-doctor/tests/select-projects.test.ts index bc5a3775d..ca22ce8df 100644 --- a/packages/react-doctor/tests/select-projects.test.ts +++ b/packages/react-doctor/tests/select-projects.test.ts @@ -191,6 +191,55 @@ describe("selectProjects", () => { ); }); + it("resolves config `projects` directory paths when no --project flag is passed", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + fs.mkdirSync(path.join(tempDirectory, "modules", "billing"), { recursive: true }); + fs.mkdirSync(path.join(tempDirectory, "modules", "payroll"), { recursive: true }); + + const selectedDirectories = await selectProjects(tempDirectory, undefined, true, [ + "modules/billing", + "modules/payroll", + ]); + + expect(selectedDirectories).toEqual([ + path.join(tempDirectory, "modules", "billing"), + path.join(tempDirectory, "modules", "payroll"), + ]); + expect(prompts).not.toHaveBeenCalled(); + }); + + it("lets the --project flag override config `projects`", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + fs.mkdirSync(path.join(tempDirectory, "modules", "billing"), { recursive: true }); + fs.mkdirSync(path.join(tempDirectory, "modules", "payroll"), { recursive: true }); + + const selectedDirectories = await selectProjects(tempDirectory, "modules/payroll", true, [ + "modules/billing", + ]); + + expect(selectedDirectories).toEqual([path.join(tempDirectory, "modules", "payroll")]); + }); + + it("rejects a config `projects` entry that is neither a workspace project nor a directory", async () => { + const tempDirectory = createTempDirectory(); + writeJson(path.join(tempDirectory, "package.json"), { name: "monolith" }); + + await expect( + selectProjects(tempDirectory, undefined, true, ["modules/missing"]), + ).rejects.toThrow(/Config "projects" entry "modules\/missing" is not a directory under/); + }); + + it("ignores an empty or whitespace-only config `projects` list", async () => { + const tempDirectory = createTempDirectory(); + const projectDirectory = setupReactProject(tempDirectory, "app"); + + const selectedDirectories = await selectProjects(projectDirectory, undefined, true, [" "]); + + expect(selectedDirectories).toEqual([projectDirectory]); + }); + it("discovers nested React projects when a wrapper directory has no package.json", async () => { const tempDirectory = createTempDirectory(); const frontendDirectory = setupReactProject(tempDirectory, "frontend"); diff --git a/packages/website/public/llms.txt b/packages/website/public/llms.txt index 78e23068c..1cb4f648d 100644 --- a/packages/website/public/llms.txt +++ b/packages/website/public/llms.txt @@ -98,7 +98,7 @@ Options: --score output only the score --json output a single structured JSON report (suppresses other output) -y, --yes skip prompts, scan all workspace projects - --project select projects: workspace names or directory paths (comma-separated for multiple) + --project select projects: workspace names or directory paths (comma-separated for multiple); set `projects` in doctor.config.* to make the selection persistent (the flag wins) --diff [base] scan only files changed vs base branch (pass `false` to force a full scan) --no-score skip the score API and share URL --staged scan only staged (git index) files for pre-commit hooks diff --git a/packages/website/public/schema/config.json b/packages/website/public/schema/config.json index ccdb71398..9be059b55 100644 --- a/packages/website/public/schema/config.json +++ b/packages/website/public/schema/config.json @@ -48,6 +48,10 @@ "lint": { "type": "boolean" }, + "supplyChain": { + "$ref": "#/definitions/SupplyChainConfig", + "description": "Socket.dev supply-chain score gate. Runs by default; set `supplyChain: { enabled: false }` to opt out. See {@link SupplyChainConfig } . Every direct dependency is scored against Socket's free PURL endpoint and a low score fails the scan (at the default `severity: \"error\"`)." + }, "deadCode": { "type": "boolean", "description": "Whether to run dead-code analysis (via `deslop-js`) alongside lint. Reports unused files, unused exports, unused dependencies, and circular imports under the \"Maintainability\" category. Default: `true`. Always skipped in `--diff` / `--staged` modes because reachability is a whole-project property." @@ -59,11 +63,20 @@ "type": "boolean", "description": "Whether to surface `\"warning\"`-severity diagnostics. Default: `true` — every warning reaches every surface (CLI, PR comment, score, the CI gate). Warnings only flip the exit code when `blocking` is set to `\"warning\"`; at the default `\"error\"` threshold they stay advisory.\n\nSet to `false` to surface only `\"error\"`-severity findings. This is the master toggle and runs after per-rule / per-category severity overrides: a rule the user explicitly restamps to `\"warn\"` (via `rules` / `categories`) still shows even when `warnings` is `false`." }, + "scope": { + "$ref": "#/definitions/ScopeValue", + "description": "Scan scope. Defaults to `\"full\"`. See `ScopeValue` for the four values. The CLI `--scope` flag and the GitHub Action `scope` input set the same thing; flags win over config." + }, + "base": { + "type": "string", + "description": "Base git ref the `\"files\"` / `\"changed\"` / `\"lines\"` scopes compare against. Auto-detected from the default branch / merge-base when unset." + }, "diff": { "type": [ "boolean", "string" - ] + ], + "deprecated": "Use `scope` instead. Still honored as an alias when `scope`\nis unset: `diff: \"\"` / `diff: true` → `scope: \"changed\"`,\n`diff: false` → `scope: \"full\"`. Using it emits a one-time deprecation\nwarning. Prefer `scope` (+ `base`)." }, "blocking": { "$ref": "#/definitions/BlockingLevel", @@ -86,6 +99,13 @@ "type": "string", "description": "Redirect react-doctor at a different project directory than the one it was invoked against. Resolved relative to the location of the config file that declared this field (NOT relative to the CWD), so the redirect is stable no matter where the CLI / `diagnose()` is run from. Absolute paths are used as-is.\n\nTypical use: a monorepo root holds the only `doctor.config.*` (so editor tooling and child commands all find it), but the React app lives in `apps/web`. Setting `\"rootDir\": \"apps/web\"` makes every invocation that loads this config scan that subproject without anyone needing to `cd` first or pass an explicit path.\n\nIgnored if the resolved path does not exist or is not a directory (a warning is emitted and react-doctor falls back to the originally requested directory)." }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "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.\n\nEntries 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.\n\nPrecedence: 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)." + }, "textComponents": { "type": "array", "items": { @@ -184,6 +204,43 @@ ], "additionalProperties": false }, + "SupplyChainConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to run the Socket supply-chain score check. Default: `true`. Set to `false` to opt out — the check performs one network request per direct dependency. It is always skipped in `--diff` / `--staged` mode and in editor scans regardless of this setting." + }, + "minScore": { + "type": "number", + "description": "Minimum acceptable Socket score on a 0–100 scale. A direct dependency whose Socket `overall` score is below this is flagged. Default: `50`. Values outside `0..100` are clamped." + }, + "severity": { + "type": "string", + "enum": [ + "error", + "warning" + ], + "description": "Severity for a below-threshold dependency. `\"error\"` (default) fails the scan at the standard `blocking: \"error\"` gate; `\"warning\"` keeps the finding advisory." + }, + "includeDevDependencies": { + "type": "boolean", + "description": "Whether to score `devDependencies` in addition to `dependencies`. Default: `true`." + } + }, + "additionalProperties": false, + "description": "Configuration for the Socket.dev supply-chain score check (the `SupplyChain` service). Runs by default; set `enabled: false` to opt out (it performs one network request per direct dependency).\n\nMirrors how Socket Firewall's free tier (`sfw`) works: each direct dependency's PURL is looked up against Socket's keyless `firewall-api.socket.dev/purl/` endpoint, which returns a supply-chain score (0–100 once normalized). A dependency scoring below `minScore` produces a diagnostic; at the default `severity: \"error\"` it fails the scan (non-zero CI exit), the same way an error-severity lint finding does." + }, + "ScopeValue": { + "type": "string", + "enum": [ + "full", + "files", + "changed", + "lines" + ], + "description": "How much of the project a scan looks at and reports on — the single knob the CLI `--scope` flag, the GitHub Action `scope` input, and this config field all share. Ordered widest to narrowest:\n\n- `\"full\"` — whole project, every issue (the default). Whole-project checks (dead-code, environment, supply-chain) run only at this scope.\n- `\"files\"` — only the files changed vs the base, with ALL issues in them (no compare-to-main). What `--staged` and an uncommitted `--diff` do today.\n- `\"changed\"` — only issues the change INTRODUCED vs the base (the baseline delta). What `--diff ` and the action's `scope: changed` do today.\n- `\"lines\"` — only issues on the lines the change actually touched." + }, "BlockingLevel": { "type": "string", "enum": [ From 7abd79a97d2c4cb0fa293fbe924fcf7c61270f65 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Wed, 10 Jun 2026 22:43:40 -0700 Subject: [PATCH 08/16] =?UTF-8?q?refactor:=20deslop=20per-module=20scoring?= =?UTF-8?q?=20=E2=80=94=20reuse=20mapWithConcurrency,=20slim=20config=20me?= =?UTF-8?q?rge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diagnoseProjectBatch reuses core's existing mapWithConcurrency instead of a hand-rolled worker pool; buildDiagnoseLayer takes the scan target directly - mergeReactDoctorConfigs: spread resolves single-sided keys, only both-defined keys are re-merged additively (drops two generic helpers) - single source of truth for merge semantics docs; trimmed restated comments - resolveProjectFlag flattened to a map; consolidated redundant tests Co-authored-by: Cursor --- packages/api/src/diagnose.ts | 121 +++++------------- packages/api/tests/diagnose.test.ts | 26 +--- packages/core/src/types/diagnose.ts | 22 ++-- .../src/utils/merge-react-doctor-configs.ts | 97 ++++++-------- .../tests/merge-react-doctor-configs.test.ts | 10 +- .../react-doctor/src/cli/commands/inspect.ts | 5 +- packages/react-doctor/src/cli/index.ts | 2 +- .../src/cli/utils/select-projects.ts | 30 ++--- 8 files changed, 98 insertions(+), 215 deletions(-) diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index 1bc39a1ba..8f2c12d83 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -10,6 +10,7 @@ import { layerOtlp, Linter, LintPartialFailures, + mapWithConcurrency, mergeReactDoctorConfigs, Progress, Project, @@ -42,18 +43,15 @@ import type { // stack is built once here rather than duplicated per variant. const buildDiagnoseLayer = ( config: ReactDoctorConfig | null, - configOverride?: { - readonly resolvedDirectory: string; - readonly configSourceDirectory: string | null; - }, + configOverrideTarget?: Pick, ) => { const configLayer = - configOverride === undefined + configOverrideTarget === undefined ? Config.layerNode : Config.layerOf({ config, - resolvedDirectory: configOverride.resolvedDirectory, - configSourceDirectory: configOverride.configSourceDirectory, + resolvedDirectory: configOverrideTarget.resolvedDirectory, + configSourceDirectory: configOverrideTarget.configSourceDirectory, }); return Layer.mergeAll( Project.layerNode, @@ -160,8 +158,10 @@ const diagnoseProject = async ( try { const scanTarget = await resolveScanTarget(projectDefinition.directory); const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition; - const mergedOptions: DiagnoseOptions = { ...baseOptions, ...perProjectOptions }; + // 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), @@ -170,19 +170,10 @@ const diagnoseProject = async ( const program = buildInspectProgram( scanTarget, - mergedOptions, - didOverrideConfig ? (effectiveConfig ?? undefined) : undefined, - ); - - const layer = buildDiagnoseLayer( - effectiveConfig, - didOverrideConfig - ? { - resolvedDirectory: scanTarget.resolvedDirectory, - configSourceDirectory: scanTarget.configSourceDirectory, - } - : undefined, + { ...baseOptions, ...perProjectOptions }, + effectiveConfig ?? undefined, ); + const layer = buildDiagnoseLayer(effectiveConfig, didOverrideConfig ? scanTarget : undefined); const output: InspectOutput = await Effect.runPromise( restoreLegacyThrow(program.pipe(Effect.provide(layer), Effect.provide(layerOtlp))), @@ -206,35 +197,21 @@ const diagnoseProjectBatch = async ( input: DiagnoseProjectsInput, ): Promise => { const startTime = globalThis.performance.now(); - const { projects, concurrency: rawConcurrency, config: batchConfig, ...baseOptions } = input; - const concurrency = Math.max(1, rawConcurrency ?? projects.length); - - const projectResults: ProjectResult[] = new Array(projects.length); - let nextProjectIndex = 0; - - const runWorker = async (): Promise => { - while (nextProjectIndex < projects.length) { - const projectIndex = nextProjectIndex; - nextProjectIndex += 1; - projectResults[projectIndex] = await diagnoseProject( - projects[projectIndex]!, - baseOptions, - batchConfig, - ); - } - }; - - await Promise.all( - Array.from({ length: Math.min(concurrency, projects.length) }, () => runWorker()), - ); - - const allDiagnostics = projectResults.flatMap((projectResult) => - projectResult.ok ? projectResult.diagnostics : [], + const { projects, concurrency, config: batchConfig, ...baseOptions } = input; + + // `diagnoseProject` never rejects (failures come back as `ok: false`), + // so the pool always drains every project. + const projectResults = await mapWithConcurrency( + projects, + concurrency ?? projects.length, + (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, }; @@ -244,54 +221,20 @@ interface Diagnose { /** Scan a single project directory and return diagnostics + score. */ (directory: string, options?: DiagnoseOptions): Promise; /** - * 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 the single-directory form uses — so per-project - * config overrides, dead-code analysis, and scoring all work - * identically to a single-directory call. - * - * Config layering (least to most specific): the project's on-disk - * `doctor.config.*` ← the batch-level `config` ← the project's own - * `config`. Each layer merges additively via `mergeReactDoctorConfigs` - * (`rules` / `categories` merge per key, `ignore` lists union, scalars - * override), so one base rule set can serve the whole batch with - * narrow per-project exceptions. - * - * 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 diagnose({ - * projects: [ - * { directory: "packages/app" }, - * { directory: "packages/shared", deadCode: false }, - * { directory: "packages/admin", config: { - * rules: { "react-doctor/no-array-index-as-key": "off" }, - * }}, - * ], - * config: { rules: { "react-doctor/no-prop-drilling": "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); - * } - * } - * ``` + * 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; } -// HACK: the cast is the standard escape hatch for assigning an 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. +// 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 = {}, diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 41b1da94a..61d2d9193 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -231,28 +231,12 @@ describe("diagnose({ projects })", () => { expect(result.projects[0].ok).toBe(true); }); - it("accepts a batch-level config shared across every project", async () => { + 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-array-index-as-key": "off" } }, - deadCode: false, - lint: false, - }); - - expect(result.projects).toHaveLength(2); - for (const projectResult of result.projects) { - expect(projectResult.ok).toBe(true); - } - }); - - it("layers a per-project config on top of the 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" } }, }, ], @@ -261,7 +245,9 @@ describe("diagnose({ projects })", () => { lint: false, }); - expect(result.projects).toHaveLength(1); - expect(result.projects[0].ok).toBe(true); + expect(result.projects).toHaveLength(2); + for (const projectResult of result.projects) { + expect(projectResult.ok).toBe(true); + } }); }); diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index 703ef4799..39f44f63b 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -41,18 +41,14 @@ export interface DiagnoseResult { * A single project to scan as part of a `diagnose({ projects })` batch. * Scan options (`deadCode`, `lint`, etc.) are flat on the entry and * layer on top of the global defaults — omitted fields fall through. - * `config` layers on top of the batch-level `config` and the project's - * on-disk `doctor.config.*` (see `mergeReactDoctorConfigs`). */ export interface ProjectDefinition extends DiagnoseOptions { directory: string; /** - * Per-project react-doctor config overrides. Merged on top of the - * effective base config (the project's on-disk `doctor.config.*`, - * then the batch-level `DiagnoseProjectsInput.config`) via - * `mergeReactDoctorConfigs`: `rules` / `categories` merge per key, - * `ignore` lists union, and scalar fields are overridden when set — - * so disabling one rule here keeps every base rule intact. + * Per-project config overrides, layered additively (see + * `mergeReactDoctorConfigs`) on top of the project's on-disk + * `doctor.config.*` and the batch-level `DiagnoseProjectsInput.config` + * — so disabling one rule here keeps every base rule intact. */ config?: ReactDoctorConfig; } @@ -73,12 +69,10 @@ export type ProjectResult = ProjectResultOk | ProjectResultError; export interface DiagnoseProjectsInput extends DiagnoseOptions { projects: ProjectDefinition[]; /** - * Shared react-doctor config overrides applied to every project in - * the batch. Merged on top of each project's on-disk - * `doctor.config.*` (per-key for `rules` / `categories`, unioned for - * `ignore` lists), with each `ProjectDefinition.config` layered on - * top of that. Use this to keep one base rule set across the batch - * and override per project only where needed. + * Config overrides applied to every project in the batch, layered + * additively (see `mergeReactDoctorConfigs`) between each project's + * on-disk `doctor.config.*` and its `ProjectDefinition.config` — one + * base rule set for the batch, overridden per project only where needed. */ config?: ReactDoctorConfig; /** diff --git a/packages/core/src/utils/merge-react-doctor-configs.ts b/packages/core/src/utils/merge-react-doctor-configs.ts index abe8fdbdc..9c5766721 100644 --- a/packages/core/src/utils/merge-react-doctor-configs.ts +++ b/packages/core/src/utils/merge-react-doctor-configs.ts @@ -2,57 +2,40 @@ import type { ReactDoctorConfig } from "../types/config.js"; type ReactDoctorIgnore = NonNullable; -const mergeUniqueArrays = ( - baseValues: string[] | undefined, - overrideValues: string[] | undefined, -): string[] | undefined => { - if (baseValues === undefined) return overrideValues; - if (overrideValues === undefined) return baseValues; - return [...new Set([...baseValues, ...overrideValues])]; -}; - -const mergeRecords = ( - baseRecord: Record | undefined, - overrideRecord: Record | undefined, -): Record | undefined => { - if (baseRecord === undefined) return overrideRecord; - if (overrideRecord === undefined) return baseRecord; - return { ...baseRecord, ...overrideRecord }; -}; - -const mergeIgnoreConfigs = ( - baseIgnore: ReactDoctorIgnore | undefined, - overrideIgnore: ReactDoctorIgnore | undefined, -): ReactDoctorIgnore | undefined => { - if (baseIgnore === undefined) return overrideIgnore; - if (overrideIgnore === undefined) return baseIgnore; - - const mergedIgnore: ReactDoctorIgnore = {}; - const rules = mergeUniqueArrays(baseIgnore.rules, overrideIgnore.rules); - const files = mergeUniqueArrays(baseIgnore.files, overrideIgnore.files); - const tags = mergeUniqueArrays(baseIgnore.tags, overrideIgnore.tags); - if (rules !== undefined) mergedIgnore.rules = rules; - if (files !== undefined) mergedIgnore.files = files; - if (tags !== undefined) mergedIgnore.tags = tags; - if (baseIgnore.overrides !== undefined || overrideIgnore.overrides !== undefined) { - mergedIgnore.overrides = [...(baseIgnore.overrides ?? []), ...(overrideIgnore.overrides ?? [])]; +const unionValues = (baseValues: string[], overrideValues: string[]): string[] => [ + ...new Set([...baseValues, ...overrideValues]), +]; + +// `{ ...base, ...override }` already resolves every key only one side +// defines; the merge helpers below only fix up keys BOTH sides define, +// where layering must be additive instead of override-wins. +const mergeIgnores = ( + baseIgnore: ReactDoctorIgnore, + overrideIgnore: ReactDoctorIgnore, +): ReactDoctorIgnore => { + const mergedIgnore: ReactDoctorIgnore = { ...baseIgnore, ...overrideIgnore }; + if (baseIgnore.rules && overrideIgnore.rules) { + mergedIgnore.rules = unionValues(baseIgnore.rules, overrideIgnore.rules); + } + if (baseIgnore.files && overrideIgnore.files) { + mergedIgnore.files = unionValues(baseIgnore.files, overrideIgnore.files); + } + if (baseIgnore.tags && overrideIgnore.tags) { + mergedIgnore.tags = unionValues(baseIgnore.tags, overrideIgnore.tags); + } + if (baseIgnore.overrides && overrideIgnore.overrides) { + mergedIgnore.overrides = [...baseIgnore.overrides, ...overrideIgnore.overrides]; } return mergedIgnore; }; /** - * Layer one `ReactDoctorConfig` on top of another, additively: - * - * - `rules` / `categories` merge per key — the override restamps or - * disables individual rules without discarding the base map. - * - `ignore.rules` / `ignore.files` / `ignore.tags` union (deduplicated); - * `ignore.overrides` concatenate. - * - `supplyChain` merges per field. - * - Every other field is a scalar (or positional value) where layering - * has no additive meaning, so the override simply wins when set. - * - * Returns the base unchanged when there is no override, and vice versa — - * so callers can thread `null`/`undefined` through without special-casing. + * Layer one `ReactDoctorConfig` on top of another, additively: `rules` / + * `categories` / `supplyChain` merge per key, `ignore` lists union + * (`ignore.overrides` concatenate), and every other field is a scalar the + * override simply wins on when set. Returns the base unchanged when there + * is no override, and vice versa — so callers can thread `null` / + * `undefined` through without special-casing. */ export const mergeReactDoctorConfigs = ( baseConfig: ReactDoctorConfig | null, @@ -62,19 +45,17 @@ export const mergeReactDoctorConfigs = ( if (baseConfig === null) return overrideConfig; const mergedConfig: ReactDoctorConfig = { ...baseConfig, ...overrideConfig }; - - const ignore = mergeIgnoreConfigs(baseConfig.ignore, overrideConfig.ignore); - if (ignore !== undefined) mergedConfig.ignore = ignore; - - const rules = mergeRecords(baseConfig.rules, overrideConfig.rules); - if (rules !== undefined) mergedConfig.rules = rules; - - const categories = mergeRecords(baseConfig.categories, overrideConfig.categories); - if (categories !== undefined) mergedConfig.categories = categories; - - if (baseConfig.supplyChain !== undefined && overrideConfig.supplyChain !== undefined) { + if (baseConfig.rules && overrideConfig.rules) { + mergedConfig.rules = { ...baseConfig.rules, ...overrideConfig.rules }; + } + if (baseConfig.categories && overrideConfig.categories) { + mergedConfig.categories = { ...baseConfig.categories, ...overrideConfig.categories }; + } + if (baseConfig.supplyChain && overrideConfig.supplyChain) { mergedConfig.supplyChain = { ...baseConfig.supplyChain, ...overrideConfig.supplyChain }; } - + if (baseConfig.ignore && overrideConfig.ignore) { + mergedConfig.ignore = mergeIgnores(baseConfig.ignore, overrideConfig.ignore); + } return mergedConfig; }; diff --git a/packages/core/tests/merge-react-doctor-configs.test.ts b/packages/core/tests/merge-react-doctor-configs.test.ts index 79bd7b252..4d42b8509 100644 --- a/packages/core/tests/merge-react-doctor-configs.test.ts +++ b/packages/core/tests/merge-react-doctor-configs.test.ts @@ -3,17 +3,11 @@ import type { ReactDoctorConfig } from "@react-doctor/core"; import { mergeReactDoctorConfigs } from "@react-doctor/core"; describe("mergeReactDoctorConfigs", () => { - it("returns the base unchanged when there is no override", () => { + it("passes either side through unchanged when the other is empty", () => { const baseConfig: ReactDoctorConfig = { rules: { "react-doctor/no-prop-drilling": "off" } }; - expect(mergeReactDoctorConfigs(baseConfig, undefined)).toBe(baseConfig); - }); - - it("returns the override when the base is null", () => { const overrideConfig: ReactDoctorConfig = { verbose: true }; + expect(mergeReactDoctorConfigs(baseConfig, undefined)).toBe(baseConfig); expect(mergeReactDoctorConfigs(null, overrideConfig)).toBe(overrideConfig); - }); - - it("returns null when both sides are empty", () => { expect(mergeReactDoctorConfigs(null, undefined)).toBeNull(); }); diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index a85475838..73a0e924d 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -552,9 +552,8 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro logger.dim(" "); } // Each project's own doctor.config layers additively onto the root - // config (rules merge per key, ignore lists union) — same semantics as - // `diagnose({ projects })` — so per-module overrides apply without - // discarding the shared base rules. + // config (same `mergeReactDoctorConfigs` semantics as + // `diagnose({ projects })`), keeping the shared base rules intact. const projectConfig = projectDirectory === resolvedDirectory ? undefined diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index c51901fe6..108f0855d 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -60,7 +60,7 @@ ${formatExampleLines([ ["react-doctor", "scan the current project"], ["react-doctor ./apps/web", "scan a specific directory"], ["react-doctor --diff main", "scan only files changed vs. main"], - ["react-doctor --project modules/a,modules/b", "score multiple modules (per-module scores)"], + ["react-doctor --project modules/a,modules/b", "score each module separately (names or paths)"], ["react-doctor --staged", "scan staged files (pre-commit hook)"], ["react-doctor --category Security", "show only one diagnostic category"], ["react-doctor --blocking warning", "fail CI on warnings too (default: error)"], diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index c611002d9..6b3015143 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -26,10 +26,9 @@ export const selectProjects = async ( packages = discoverReactSubprojects(rootDirectory); } - // The flag wins over workspace discovery: it can name packages OR point at - // arbitrary directories (per-module scoring in repos whose modules aren't - // workspace packages), so it must resolve even when discovery finds 0 or 1 - // packages — previously it was silently ignored in those cases. + // The flag wins over workspace discovery: entries can name packages OR + // point at arbitrary directories, so it must resolve even when discovery + // finds 0 or 1 packages (where it was previously silently ignored). if (projectFlag) return resolveProjectFlag(projectFlag, packages, rootDirectory); // The config's `projects` field is the flag's persistent form: same @@ -104,26 +103,19 @@ const resolveRequestedProjects = ( } const sourceLabel = source === "flag" ? "Project" : 'Config "projects" entry'; - const resolvedDirectories: string[] = []; - let pathSelectionCount = 0; - for (const requestedName of requestedNames) { + return requestedNames.map((requestedName) => { const matched = workspacePackages.find( (workspacePackage) => workspacePackage.name === requestedName || path.basename(workspacePackage.directory) === requestedName, ); - - if (matched) { - resolvedDirectories.push(matched.directory); - continue; - } + if (matched) return matched.directory; const candidateDirectory = path.resolve(rootDirectory, requestedName); if (isDirectory(candidateDirectory)) { - resolvedDirectories.push(candidateDirectory); - pathSelectionCount += 1; - continue; + recordCount(METRIC.projectPathSelected); + return candidateDirectory; } const availableNames = workspacePackages @@ -134,13 +126,7 @@ const resolveRequestedProjects = ( ? `${sourceLabel} "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` : `${sourceLabel} "${requestedName}" is not a directory under ${rootDirectory}.`, ); - } - - if (pathSelectionCount > 0) { - recordCount(METRIC.projectPathSelected, pathSelectionCount); - } - - return resolvedDirectories; + }); }; const printDiscoveredProjects = (packages: WorkspacePackage[]): void => { From 517f97724c56f2f2aaac84d8226d29bfc4491506 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:53:28 +0000 Subject: [PATCH 09/16] fix(cli): resolve each project's scan target like diagnose({ projects }) Multi-project CLI scans now run resolveScanTarget per selected folder, so a module's own rootDir redirect, nested React discovery, and config source directory (for relative plugin resolution) match the batch API. The diff-mode supply-chain inclusion decision now reads the per-project merged config instead of the root config. Co-Authored-By: Aiden Bai --- packages/core/src/types/inspect.ts | 7 +++ .../react-doctor/src/cli/commands/inspect.ts | 50 ++++++++++--------- packages/react-doctor/src/inspect.ts | 7 +-- .../tests/inspect-action-setup-prompt.test.ts | 10 ++-- 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/packages/core/src/types/inspect.ts b/packages/core/src/types/inspect.ts index 7468878db..22f686c45 100644 --- a/packages/core/src/types/inspect.ts +++ b/packages/core/src/types/inspect.ts @@ -80,6 +80,13 @@ export interface InspectOptions { deadCode?: boolean; includePaths?: string[]; configOverride?: ReactDoctorConfig | null; + /** + * Directory of the config file that supplied `configOverride`, when it was + * loaded from disk. Anchors relative `configOverride.plugins` resolution at + * the config file's location instead of the scan root. Ignored without + * `configOverride`. + */ + configSourceDirectory?: string; respectInlineDisables?: boolean; /** * Whether the scanned project's `package.json` changed in this diff / diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 73a0e924d..5a6008d18 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -11,7 +11,6 @@ import { getChangedLineRanges, getDiffInfo, highlighter, - loadConfigWithSource, mergeReactDoctorConfigs, resolveScanTarget, toRelativePath, @@ -512,29 +511,44 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro const allDiagnostics: Diagnostic[] = []; const completedScans: Array<{ directory: string; result: InspectResult }> = []; const isMultiProject = projectDirectories.length > 1; - // The Socket supply-chain check runs by default; opted out per project - // config. Off ⇒ a manifest-only diff change shouldn't pull a project into - // the scan (there'd be nothing to report). - const supplyChainEnabled = userConfig?.supplyChain?.enabled !== false; for (const projectDirectory of projectDirectories) { + // Each selected folder goes through the same scan-target resolution as + // `diagnose({ projects })` — its own `rootDir`, nested React discovery, + // and on-disk config (layered additively onto the root config via + // `mergeReactDoctorConfigs`) — so the CLI and the API agree on what + // scanning a module means. + const projectScanTarget = + projectDirectory === resolvedDirectory + ? scanTarget + : await resolveScanTarget(projectDirectory, { allowAmbiguous: true }); + const scanDirectory = projectScanTarget.resolvedDirectory; + const projectConfig = + projectDirectory === resolvedDirectory + ? userConfig + : mergeReactDoctorConfigs(userConfig, projectScanTarget.userConfig ?? undefined); + // The Socket supply-chain check runs by default; opted out per project + // config. Off ⇒ a manifest-only diff change shouldn't pull a project into + // the scan (there'd be nothing to report). + const supplyChainEnabled = projectConfig?.supplyChain?.enabled !== false; + let includePaths: string[] | undefined; let supplyChainManifestChanged = false; if (isDiffMode) { const changedSourceFiles = diffInfo === null ? [] - : resolveProjectDiffIncludePaths(resolvedDirectory, projectDirectory, diffInfo); + : resolveProjectDiffIncludePaths(resolvedDirectory, scanDirectory, diffInfo); // A PR that edits this project's package.json should still have its // dependencies scored, even with no changed source files — dependency // health is a manifest property, not a per-file one. supplyChainManifestChanged = supplyChainEnabled && diffInfo !== null && - projectManifestChanged(resolvedDirectory, projectDirectory, diffInfo); + projectManifestChanged(resolvedDirectory, scanDirectory, diffInfo); if (changedSourceFiles.length === 0 && !supplyChainManifestChanged) { if (!isQuiet) { - logger.dim(`No changed source files in ${projectDirectory}, skipping.`); + logger.dim(`No changed source files in ${scanDirectory}, skipping.`); logger.break(); } continue; @@ -551,31 +565,21 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro if (!isQuiet && !isMultiProject) { logger.dim(" "); } - // Each project's own doctor.config layers additively onto the root - // config (same `mergeReactDoctorConfigs` semantics as - // `diagnose({ projects })`), keeping the shared base rules intact. - const projectConfig = - projectDirectory === resolvedDirectory - ? undefined - : (await loadConfigWithSource(projectDirectory))?.config; - const scanResult = await inspect(projectDirectory, { + const scanResult = await inspect(scanDirectory, { ...scanOptions, includePaths, - configOverride: mergeReactDoctorConfigs(userConfig, projectConfig), + configOverride: projectConfig, + configSourceDirectory: projectScanTarget.configSourceDirectory ?? undefined, suppressRendering: isMultiProject, baseline: baselineRef ? { ref: baselineRef } : undefined, changedLineRanges: scope === "lines" && changedLineRanges !== null - ? resolveProjectChangedLineRanges( - resolvedDirectory, - projectDirectory, - changedLineRanges, - ) + ? resolveProjectChangedLineRanges(resolvedDirectory, scanDirectory, changedLineRanges) : undefined, supplyChainManifestChanged, }); allDiagnostics.push(...scanResult.diagnostics); - completedScans.push({ directory: projectDirectory, result: scanResult }); + completedScans.push({ directory: scanDirectory, result: scanResult }); if (!isQuiet && !isMultiProject) { logger.break(); } diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index c7d80a2e1..65e34207c 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -267,13 +267,14 @@ export const inspect = async ( // `config.plugins` entries — relative paths and npm packages // resolve from here (the config file's location), NOT from the // post-`rootDir` scan root. `null` when the caller passed - // `configOverride` programmatically, in which case the runner - // falls back to the scan root for plugin resolution. + // `configOverride` programmatically without a corresponding + // `configSourceDirectory`, in which case the runner falls back + // to the scan root for plugin resolution. let configSourceDirectory: string | null; if (hasConfigOverride) { scanDirectory = directory; userConfig = inputOptions.configOverride ?? null; - configSourceDirectory = null; + configSourceDirectory = inputOptions.configSourceDirectory ?? null; } else { const scanTarget = await resolveScanTarget(directory); scanDirectory = scanTarget.resolvedDirectory; diff --git a/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts b/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts index 8324e5e63..362737b1a 100644 --- a/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts +++ b/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts @@ -8,7 +8,6 @@ import { inspectAction } from "../src/cli/commands/inspect.js"; import { inspect } from "../src/inspect.js"; const mockState = vi.hoisted(() => ({ - rootDirectory: "", projectDirectories: [] as string[], })); @@ -30,9 +29,9 @@ vi.mock("@react-doctor/core", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - resolveScanTarget: vi.fn(async () => ({ - resolvedDirectory: mockState.rootDirectory, - requestedDirectory: mockState.rootDirectory, + resolveScanTarget: vi.fn(async (requestedDirectory: string) => ({ + resolvedDirectory: requestedDirectory, + requestedDirectory, userConfig: null, configSourceDirectory: null, didRedirectViaRootDir: false, @@ -115,7 +114,6 @@ describe("inspectAction setup prompt", () => { afterEach(() => { vi.clearAllMocks(); - mockState.rootDirectory = ""; mockState.projectDirectories = []; for (const tempDirectory of tempDirectories.splice(0)) { fs.rmSync(tempDirectory, { recursive: true, force: true }); @@ -135,7 +133,6 @@ describe("inspectAction setup prompt", () => { writePackageJson(webDirectory, { name: "web", scripts: {} }); writePackageJson(adminDirectory, { name: "admin", scripts: {} }); - mockState.rootDirectory = rootDirectory; mockState.projectDirectories = [webDirectory, adminDirectory]; await inspectAction(rootDirectory, { diff: true, lint: false }); @@ -170,7 +167,6 @@ describe("inspectAction setup prompt", () => { ), ); - mockState.rootDirectory = rootDirectory; mockState.projectDirectories = [webDirectory, adminDirectory]; await inspectAction(rootDirectory, { changedFilesFrom: changedFilesPath, lint: false }); From 6637087ce5a52f4bc7318bfabb50c20a347cc8f0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:04:37 +0000 Subject: [PATCH 10/16] chore: retrigger CI (sentry-tracer clock flake on node 20) Co-Authored-By: Aiden Bai From a9eadd28286a1376293fb1531b2bc631b15f9654 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Wed, 10 Jun 2026 23:32:51 -0700 Subject: [PATCH 11/16] fix(api): bound diagnose({ projects }) batch concurrency by default Default was fully parallel (projects.length): each project scan already fans out its own oxlint workers, so an 80-module batch would spawn hundreds of subprocesses. Default to DEFAULT_PROJECT_SCAN_CONCURRENCY (4, in core constants) as the PR intent stated; callers opt into more via `concurrency`. Co-authored-by: Cursor --- packages/api/src/diagnose.ts | 3 ++- packages/core/src/constants.ts | 7 +++++++ packages/core/src/types/diagnose.ts | 8 +++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index 8f2c12d83..ff8e04083 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; import { buildSkippedChecks, Config, + DEFAULT_PROJECT_SCAN_CONCURRENCY, DEFAULT_SHOW_WARNINGS, DeadCode, Files, @@ -203,7 +204,7 @@ const diagnoseProjectBatch = async ( // so the pool always drains every project. const projectResults = await mapWithConcurrency( projects, - concurrency ?? projects.length, + concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY, (projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig), ); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index e668e52b0..ae41d7745 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index 39f44f63b..a2e4b3c68 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -76,9 +76,11 @@ export interface DiagnoseProjectsInput extends DiagnoseOptions { */ config?: ReactDoctorConfig; /** - * Maximum number of projects to scan concurrently. Defaults to the - * number of projects (fully parallel). Set to `1` for sequential - * execution. Values below 1 are clamped to 1. + * Maximum number of projects to scan concurrently. Defaults to + * `DEFAULT_PROJECT_SCAN_CONCURRENCY` (4) — each project scan fans out + * its own lint workers, so the batch is bounded rather than fully + * parallel. Set to `1` for sequential execution. Values below 1 are + * clamped to 1. */ concurrency?: number; } From 464e547f2a09d690f0fbb2a7697e1be2c04807af Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Wed, 10 Jun 2026 23:42:05 -0700 Subject: [PATCH 12/16] fix: resolve merged relative plugins against the config that supplied them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugins` is override-wins in mergeReactDoctorConfigs, but the resolution base was always the module config's directory (CLI) or the on-disk config's directory (API) — root-inherited relative plugins loaded from the wrong place. Base now follows the supplying layer: the module config when it declares `plugins`, the root config otherwise; for caller-supplied API overrides, the scan root. Co-authored-by: Cursor --- packages/api/src/diagnose.ts | 15 ++++++++++++++- packages/react-doctor/src/cli/commands/inspect.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index ff8e04083..905448acf 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -174,7 +174,20 @@ const diagnoseProject = async ( { ...baseOptions, ...perProjectOptions }, effectiveConfig ?? undefined, ); - const layer = buildDiagnoseLayer(effectiveConfig, didOverrideConfig ? scanTarget : 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, + didOverrideConfig + ? { + resolvedDirectory: scanTarget.resolvedDirectory, + configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory, + } + : undefined, + ); const output: InspectOutput = await Effect.runPromise( restoreLegacyThrow(program.pipe(Effect.provide(layer), Effect.provide(layerOtlp))), diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 5a6008d18..7d9629da8 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -527,6 +527,13 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro projectDirectory === resolvedDirectory ? userConfig : mergeReactDoctorConfigs(userConfig, projectScanTarget.userConfig ?? undefined); + // `plugins` is override-wins in the merge, so relative entries must + // resolve against the config file that supplied them: the module's own + // config when it declares `plugins`, the root config otherwise. + const projectConfigSourceDirectory = + projectScanTarget.userConfig?.plugins === undefined + ? scanTarget.configSourceDirectory + : projectScanTarget.configSourceDirectory; // The Socket supply-chain check runs by default; opted out per project // config. Off ⇒ a manifest-only diff change shouldn't pull a project into // the scan (there'd be nothing to report). @@ -569,7 +576,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro ...scanOptions, includePaths, configOverride: projectConfig, - configSourceDirectory: projectScanTarget.configSourceDirectory ?? undefined, + configSourceDirectory: projectConfigSourceDirectory ?? undefined, suppressRendering: isMultiProject, baseline: baselineRef ? { ref: baselineRef } : undefined, changedLineRanges: From a1ec050e60125c9c9e03cd46e2ad2fa0b622d330 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:55:45 +0000 Subject: [PATCH 13/16] perf(cli): run multi-project scans through a bounded concurrent pool; route no-score message to stderr Co-Authored-By: Aiden Bai --- .../react-doctor/src/cli/commands/inspect.ts | 40 ++++++++++++++++--- .../cli/utils/render-multi-project-summary.ts | 24 +++++------ packages/react-doctor/src/inspect.ts | 13 ++++-- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 7d9629da8..22eaa0868 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -6,11 +6,13 @@ import * as fs from "node:fs"; import { buildJsonReport, collectSupplyChainScores, + DEFAULT_PROJECT_SCAN_CONCURRENCY, filterDiagnosticsForSurface, findLegacyConfig, getChangedLineRanges, getDiffInfo, highlighter, + mapWithConcurrency, mergeReactDoctorConfigs, resolveScanTarget, toRelativePath, @@ -509,10 +511,10 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro } const allDiagnostics: Diagnostic[] = []; - const completedScans: Array<{ directory: string; result: InspectResult }> = []; + const completedScans: CompletedScan[] = []; const isMultiProject = projectDirectories.length > 1; - for (const projectDirectory of projectDirectories) { + const scanProject = async (projectDirectory: string): Promise => { // Each selected folder goes through the same scan-target resolution as // `diagnose({ projects })` — its own `rootDir`, nested React discovery, // and on-disk config (layered additively onto the root config via @@ -558,7 +560,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro logger.dim(`No changed source files in ${scanDirectory}, skipping.`); logger.break(); } - continue; + return null; } // A changed package.json enters the scan as an include so the run // stays in diff mode (lint ignores it — it's not a source file) while @@ -585,11 +587,38 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro : undefined, supplyChainManifestChanged, }); - allDiagnostics.push(...scanResult.diagnostics); - completedScans.push({ directory: scanDirectory, result: scanResult }); if (!isQuiet && !isMultiProject) { logger.break(); } + return { directory: scanDirectory, result: scanResult }; + }; + + // Multi-project scans run through the same bounded pool as + // `diagnose({ projects })` — per-project rendering is suppressed in favor + // of the aggregate summary, so concurrent scans don't garble output. + // Single-project runs keep their inline rendering on the same path. + const scanLoopStartTime = performance.now(); + const projectCount = projectDirectories.length; + const batchSpinner = + isMultiProject && !isQuiet ? spinner(`Scanning ${projectCount} projects…`).start() : null; + let finishedProjectCount = 0; + const scanOutcomes = await mapWithConcurrency( + projectDirectories, + isMultiProject ? DEFAULT_PROJECT_SCAN_CONCURRENCY : 1, + async (projectDirectory) => { + const scanOutcome = await scanProject(projectDirectory); + finishedProjectCount += 1; + batchSpinner?.update( + `Scanning ${projectCount} projects… (${finishedProjectCount}/${projectCount})`, + ); + return scanOutcome; + }, + ); + batchSpinner?.stop(); + for (const scanOutcome of scanOutcomes) { + if (scanOutcome === null) continue; + allDiagnostics.push(...scanOutcome.result.diagnostics); + completedScans.push(scanOutcome); } if (!isQuiet && isMultiProject && completedScans.length > 0) { @@ -603,6 +632,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro verbose: Boolean(flags.verbose), isOffline: !shouldShowShareLink, projectName: path.basename(resolvedDirectory), + totalElapsedMilliseconds: performance.now() - scanLoopStartTime, }), ); } diff --git a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts index a0ee9f7aa..0ffa283f0 100644 --- a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts @@ -83,11 +83,19 @@ export interface MultiProjectSummaryInput { readonly verbose: boolean; readonly isOffline: boolean; readonly projectName: string; + readonly totalElapsedMilliseconds: number; } export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effect.Effect => Effect.gen(function* () { - const { completedScans, userConfig, verbose, isOffline, projectName } = input; + const { + completedScans, + userConfig, + verbose, + isOffline, + projectName, + totalElapsedMilliseconds, + } = input; const categoryFilters = input.categoryFilters ?? new Set(); // Report animations (category count-up + score-projection ghost gain) play @@ -113,8 +121,8 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec // Single aggregate scan line in place of the per-project spinner // success lines (suppressed via `suppressScanSummary`). Scans run - // sequentially, so summing each project's scan duration matches the - // wall-clock total. + // through a bounded concurrent pool, so the caller passes the + // wall-clock total rather than summing per-project durations. // // Count UNIQUE scanned files by absolute path: nested workspace // packages (a parent whose tree contains a child package) scan the @@ -135,12 +143,8 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec } } const totalScannedFileCount = uniqueScannedFilePaths.size + fileCountFromScansWithoutPaths; - const totalScanElapsedMilliseconds = completedScans.reduce( - (sum, scan) => sum + (scan.result.scanElapsedMilliseconds ?? scan.result.elapsedMilliseconds), - 0, - ); yield* Console.log( - `${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`, + `${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalElapsedMilliseconds)}`, ); if (displayDiagnostics.length > 0) { @@ -161,10 +165,6 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec (sum, scan) => sum + scan.result.project.sourceFileCount, 0, ); - const totalElapsedMilliseconds = completedScans.reduce( - (sum, scan) => sum + scan.result.elapsedMilliseconds, - 0, - ); // Project the worst project's score: the displayed top errors are // picked across all projects, but only removing them from the worst diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 65e34207c..1368b2e76 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -498,13 +498,16 @@ const runInspectWithRuntime = async ( } // Suppress the orchestrator-owned lint + dead-code spinners when - // the CLI is in score-only / silent mode (or when lint is - // skipped entirely). `Progress.layerNoop` makes the lifecycle a - // no-op; the rest of the pipeline is unchanged. + // the CLI is in score-only / silent / suppressed-rendering mode (or + // when lint is skipped entirely) — suppressed-rendering scans run + // concurrently in multi-project batches, where interleaved spinners + // would garble the terminal. `Progress.layerNoop` makes the lifecycle + // a no-op; the rest of the pipeline is unchanged. const shouldShowProgressSpinners = !options.isCiOrCodingAgentEnvironment && !options.silent && !options.scoreOnly && + !options.suppressRendering && options.lint && Boolean(resolvedNodeBinaryPath); @@ -848,7 +851,9 @@ const finalizeAndRender = (input: FinalizeInput): Effect.Effect = if (score) { yield* Console.log(`${score.score}`); } else { - yield* Console.log(highlighter.gray(noScoreMessage)); + // stderr, so scripts that parse `--score` stdout (expecting a bare + // number) read an empty stream instead of prose when no score exists. + yield* Console.error(highlighter.gray(noScoreMessage)); } return buildResult(); } From 10f28bb32425b4c52c6938eeeb316fc6e3461d71 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Thu, 11 Jun 2026 00:22:48 -0700 Subject: [PATCH 14/16] fix(cli): keep concurrent multi-project scans out of the global Sentry run state The module-level run state (scanned project, active run trace) has single-scan semantics; pool members overlapped each other's resets and writes, mislabeling events and crash links. Concurrent scans now emit span-scoped telemetry only: wide events keep full per-scan attribution (they ride the root span), metrics omit the project shape during a batch, and no batch member records the active run trace. Co-authored-by: Cursor --- packages/core/src/types/inspect.ts | 8 ++ .../react-doctor/src/cli/commands/inspect.ts | 3 + .../src/cli/utils/with-sentry-run-span.ts | 46 +++++++---- packages/react-doctor/src/inspect.ts | 80 +++++++++++-------- 4 files changed, 90 insertions(+), 47 deletions(-) diff --git a/packages/core/src/types/inspect.ts b/packages/core/src/types/inspect.ts index 22f686c45..38b0ddfab 100644 --- a/packages/core/src/types/inspect.ts +++ b/packages/core/src/types/inspect.ts @@ -166,6 +166,14 @@ export interface InspectOptions { * instead of N individual ones. */ suppressRendering?: boolean; + /** + * Set when multiple `inspect()` calls run concurrently in one process + * (the CLI's multi-project pool). The scan keeps its telemetry + * span-scoped — it neither resets nor writes the process-global Sentry + * run state (scanned project, active run trace), so overlapping scans + * can't clear or mislabel each other's attribution. Defaults to `false`. + */ + concurrentScan?: boolean; } /** diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 22eaa0868..ad72dbefb 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -580,6 +580,9 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro configOverride: projectConfig, configSourceDirectory: projectConfigSourceDirectory ?? undefined, suppressRendering: isMultiProject, + // Pool members overlap; they must not own the process-global Sentry + // run state (see `InspectOptions.concurrentScan`). + concurrentScan: isMultiProject, baseline: baselineRef ? { ref: baselineRef } : undefined, changedLineRanges: scope === "lines" && changedLineRanges !== null diff --git a/packages/react-doctor/src/cli/utils/with-sentry-run-span.ts b/packages/react-doctor/src/cli/utils/with-sentry-run-span.ts index 1995d184b..8132431d8 100644 --- a/packages/react-doctor/src/cli/utils/with-sentry-run-span.ts +++ b/packages/react-doctor/src/cli/utils/with-sentry-run-span.ts @@ -12,12 +12,13 @@ export type SentryRootSpan = ReturnType | undef /** * Clears the module-level run-scoped Sentry state — the current scanned project * and the active run trace. `inspect()` calls this at the start of every run and - * again after a clean one (it's invoked once per project in a workspace scan), - * so a prior or just-finished scan can't attach its project tags / trace to a - * later run or to a non-scan error (e.g. inspectAction's post-loop - * finalize/handoff steps). A thrown scan error skips the post-run reset, leaving - * the state for the command catch to attribute and link the crash. Safe to call - * when Sentry is off (the refs are read only when an event is built). + * again after a clean one, so a prior or just-finished scan can't attach its + * project tags / trace to a later run or to a non-scan error (e.g. + * inspectAction's post-loop finalize/handoff steps). A thrown scan error skips + * the post-run reset, leaving the state for the command catch to attribute and + * link the crash. Concurrent batch members (`concurrentScan`) never touch this + * state — they neither write nor reset it. Safe to call when Sentry is off (the + * refs are read only when an event is built). */ export const resetSentryRunState = (): void => { setSentryProjectInfo(null); @@ -41,20 +42,30 @@ export const resetSentryRunState = (): void => { * state right after a clean run and at the start of the next one, so the trace * is never attached to a non-scan error; on a thrown error the state is left in * place for the command catch, then the process exits. + * + * A `concurrentScan` (one member of the CLI's multi-project pool) still gets + * its own root span, but skips recording the active run trace — the module- + * level handle has single-scan semantics, and overlapping writers would link a + * crash to an arbitrary sibling's trace. */ -export const withSentryRunSpan = (run: (rootSpan: SentryRootSpan) => Promise): Promise => { +export const withSentryRunSpan = ( + run: (rootSpan: SentryRootSpan) => Promise, + options: { concurrentScan?: boolean } = {}, +): Promise => { if (!isSentryTracingEnabled()) return run(undefined); const { tags } = buildSentryScope(); const command = typeof tags.command === "string" ? tags.command : "inspect"; return Sentry.startSpan( { name: `react-doctor ${command}`, op: "cli.inspect", attributes: toSpanAttributes(tags) }, (rootSpan) => { - const spanContext = rootSpan.spanContext(); - setActiveRunTrace({ - traceId: spanContext.traceId, - spanId: spanContext.spanId, - sampled: (spanContext.traceFlags & TRACE_FLAG_SAMPLED) === TRACE_FLAG_SAMPLED, - }); + if (options.concurrentScan !== true) { + const spanContext = rootSpan.spanContext(); + setActiveRunTrace({ + traceId: spanContext.traceId, + spanId: spanContext.spanId, + sampled: (spanContext.traceFlags & TRACE_FLAG_SAMPLED) === TRACE_FLAG_SAMPLED, + }); + } return run(rootSpan); }, ); @@ -67,12 +78,19 @@ export const withSentryRunSpan = (run: (rootSpan: SentryRootSpan) => Promise< * run's root span so the transaction/trace carries the project shape too. * Always cheap — the span attribute set is skipped when `rootSpan` is absent * (tracing off), and storing the info is a plain assignment. + * + * A `concurrentScan` only sets the span attributes: the module-level project + * ref has single-scan semantics, and overlapping writers would stamp events + * and metrics with an arbitrary sibling's project. Wide events keep full + * attribution (they ride the span); per-emit metrics simply omit the project + * shape during a concurrent batch (absent, never wrong). */ export const recordSentryProjectContext = ( projectInfo: ProjectInfo, rootSpan: SentryRootSpan, + options: { concurrentScan?: boolean } = {}, ): void => { - setSentryProjectInfo(projectInfo); + if (options.concurrentScan !== true) setSentryProjectInfo(projectInfo); rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags)); // Metrics emitted after discovery (`project.detected`, `scan.completed`, // `rule.fired`, ...) pick the project shape up via `getSentryProjectInfo()` diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 1368b2e76..dff8d3542 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -156,6 +156,8 @@ export interface ResolvedInspectOptions { ignoredTags: ReadonlySet; outputSurface: DiagnosticSurface; suppressRendering: boolean; + /** See `InspectOptions.concurrentScan`. */ + concurrentScan: boolean; /** Resolved oxlint worker count, or `undefined` to keep the ambient default. */ concurrency: number | undefined; /** Baseline ref to subtract (new-only mode), or `null` for a plain scan. */ @@ -202,6 +204,7 @@ const mergeInspectOptions = ( ignoredTags: buildIgnoredTags(userConfig), outputSurface: inputOptions.outputSurface ?? "cli", suppressRendering: inputOptions.suppressRendering ?? false, + concurrentScan: inputOptions.concurrentScan ?? false, concurrency: inputOptions.concurrency, baseline: inputOptions.baseline ?? null, changedLineRanges: inputOptions.changedLineRanges ?? null, @@ -248,10 +251,13 @@ export const inspect = async ( ): Promise => { const startTime = performance.now(); - // Clear any run-scoped Sentry state from a prior inspect() (workspace scans - // call this once per project) so a stale project/trace can't leak onto this - // run's events — including errors thrown before the project is discovered. - resetSentryRunState(); + // Clear any run-scoped Sentry state from a prior inspect() so a stale + // project/trace can't leak onto this run's events — including errors thrown + // before the project is discovered. Concurrent batch members skip this (and + // every other write to the module-level run state): overlapping scans would + // clear or overwrite each other's attribution mid-flight. + const isConcurrentScan = inputOptions.concurrentScan === true; + if (!isConcurrentScan) resetSentryRunState(); const hasConfigOverride = inputOptions.configOverride !== undefined; // When the caller pre-loaded a config (CLI's `inspectAction` does @@ -294,38 +300,42 @@ export const inspect = async ( if (options.silent) setSpinnerSilent(true); try { - const result = await withSentryRunSpan(async (rootSentrySpan) => { - try { - return await runInspectWithRuntime( - scanDirectory, - options, - userConfig, - hasConfigOverride, - configSourceDirectory, - startTime, - rootSentrySpan, - ); - } catch (error) { - // Emit the canonical wide event on the failure path too: the scan threw - // before finalizing, so there's no `result` — just the error taxonomy - // plus the config it ran with. The lint/dead-code outcome isn't known - // here, so it's omitted rather than asserted as a benign default. - // Rethrow so error handling is unchanged. - recordRunEvent(rootSentrySpan, { - ...buildRunEventConfig(options, userConfig, userConfig !== null), - mode: options.includePaths.length > 0 ? "diff" : "full", - error, - }); - throw error; - } - }); + const result = await withSentryRunSpan( + async (rootSentrySpan) => { + try { + return await runInspectWithRuntime( + scanDirectory, + options, + userConfig, + hasConfigOverride, + configSourceDirectory, + startTime, + rootSentrySpan, + ); + } catch (error) { + // Emit the canonical wide event on the failure path too: the scan threw + // before finalizing, so there's no `result` — just the error taxonomy + // plus the config it ran with. The lint/dead-code outcome isn't known + // here, so it's omitted rather than asserted as a benign default. + // Rethrow so error handling is unchanged. + recordRunEvent(rootSentrySpan, { + ...buildRunEventConfig(options, userConfig, userConfig !== null), + mode: options.includePaths.length > 0 ? "diff" : "full", + error, + }); + throw error; + } + }, + { concurrentScan: isConcurrentScan }, + ); // Scan finished cleanly — clear run-scoped Sentry state so a later non-scan // error (inspectAction's finalize/handoff/install steps, or the next // project in a workspace loop) isn't mislabeled with this scan's project or // mislinked to its already-sent transaction. On a thrown error this line is // skipped, so the state persists for the command catch to attribute and - // link the crash before the process exits. - resetSentryRunState(); + // link the crash before the process exits. Concurrent batch members never + // wrote this state, so they have nothing to clear. + if (!isConcurrentScan) resetSentryRunState(); return result; } finally { if (options.silent) setSpinnerSilent(wasSpinnerSilent); @@ -473,7 +483,9 @@ const runInspectWithRuntime = async ( const scanResultCache = cacheKey === null ? null : createScanResultCache(directory); const cachedPayload = cacheKey === null ? null : (scanResultCache?.lookup(cacheKey) ?? null); if (cachedPayload) { - recordSentryProjectContext(cachedPayload.project, rootSentrySpan); + recordSentryProjectContext(cachedPayload.project, rootSentrySpan, { + concurrentScan: options.concurrentScan, + }); recordCount(METRIC.projectDetected, 1); await renderCachedProjectDetection({ payload: cachedPayload, @@ -548,7 +560,9 @@ const runInspectWithRuntime = async ( // (this hook fires right after project discovery) so crashes, the run // transaction, and every subsequent metric carry it. No-op when // Sentry/tracing is off. - recordSentryProjectContext(projectInfo, rootSentrySpan); + recordSentryProjectContext(projectInfo, rootSentrySpan, { + concurrentScan: options.concurrentScan, + }); recordCount(METRIC.projectDetected, 1); if (options.scoreOnly || options.suppressRendering) return; const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount; From 8af01e6ecc7eb154bb7e89f87cec4dca75d6de5d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:47:41 +0000 Subject: [PATCH 15/16] fix(cli): silence spinners once around the multi-project pool instead of per-scan Co-Authored-By: Aiden Bai --- .../react-doctor/src/cli/commands/inspect.ts | 39 ++++++++++++------- packages/react-doctor/src/inspect.ts | 9 +++-- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index ad72dbefb..74871d89c 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -69,7 +69,7 @@ import { runExplain } from "../utils/run-explain.js"; import { projectManifestChanged } from "../utils/project-manifest-changed.js"; import { renderSupplyChainScores } from "../utils/render-supply-chain-scores.js"; import { selectProjects } from "../utils/select-projects.js"; -import { spinner } from "../utils/spinner.js"; +import { isSpinnerSilent, setSpinnerSilent, spinner } from "../utils/spinner.js"; import { shouldBlockCi } from "../utils/should-block-ci.js"; import { shouldSkipPrompts } from "../utils/should-skip-prompts.js"; import { warnDeprecatedFailOn } from "../utils/warn-deprecated-fail-on.js"; @@ -604,20 +604,31 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro const projectCount = projectDirectories.length; const batchSpinner = isMultiProject && !isQuiet ? spinner(`Scanning ${projectCount} projects…`).start() : null; + // Concurrent pool members skip the per-scan toggle of the module-level + // spinner-silent flag (overlapping save/restore pairs would race), so + // the pool owner silences spinners once around the whole batch. + const ownsBatchSpinnerSilence = isMultiProject && scanOptions.silent === true; + const wasSpinnerSilent = isSpinnerSilent(); + if (ownsBatchSpinnerSilence) setSpinnerSilent(true); let finishedProjectCount = 0; - const scanOutcomes = await mapWithConcurrency( - projectDirectories, - isMultiProject ? DEFAULT_PROJECT_SCAN_CONCURRENCY : 1, - async (projectDirectory) => { - const scanOutcome = await scanProject(projectDirectory); - finishedProjectCount += 1; - batchSpinner?.update( - `Scanning ${projectCount} projects… (${finishedProjectCount}/${projectCount})`, - ); - return scanOutcome; - }, - ); - batchSpinner?.stop(); + let scanOutcomes: ReadonlyArray; + try { + scanOutcomes = await mapWithConcurrency( + projectDirectories, + isMultiProject ? DEFAULT_PROJECT_SCAN_CONCURRENCY : 1, + async (projectDirectory) => { + const scanOutcome = await scanProject(projectDirectory); + finishedProjectCount += 1; + batchSpinner?.update( + `Scanning ${projectCount} projects… (${finishedProjectCount}/${projectCount})`, + ); + return scanOutcome; + }, + ); + } finally { + if (ownsBatchSpinnerSilence) setSpinnerSilent(wasSpinnerSilent); + batchSpinner?.stop(); + } for (const scanOutcome of scanOutcomes) { if (scanOutcome === null) continue; allDiagnostics.push(...scanOutcome.result.diagnostics); diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index dff8d3542..69c1ac3af 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -295,9 +295,12 @@ export const inspect = async ( // silent flag here until that file moves to a Progress service in // a follow-up PR. Console-side silent is handled by swapping the // global Console reference for `silentConsole` inside the program - // (see `runInspectWithRuntime`). + // (see `runInspectWithRuntime`). Concurrent batch members never touch + // the shared flag — overlapping save/restore pairs would race — so the + // pool owner (the CLI) silences spinners once around the whole batch. + const ownsSpinnerSilence = options.silent && !isConcurrentScan; const wasSpinnerSilent = isSpinnerSilent(); - if (options.silent) setSpinnerSilent(true); + if (ownsSpinnerSilence) setSpinnerSilent(true); try { const result = await withSentryRunSpan( @@ -338,7 +341,7 @@ export const inspect = async ( if (!isConcurrentScan) resetSentryRunState(); return result; } finally { - if (options.silent) setSpinnerSilent(wasSpinnerSilent); + if (ownsSpinnerSilence) setSpinnerSilent(wasSpinnerSilent); } }; From 46d3a219923a12915068f1614965178e10ba9b1b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:22:38 +0000 Subject: [PATCH 16/16] fix(cli): filter multi-project diagnostics with each scan's merged config Co-Authored-By: Aiden Bai --- .../react-doctor/src/cli/commands/inspect.ts | 29 +++++-------- .../src/cli/utils/filter-scans-for-surface.ts | 26 ++++++++++++ .../cli/utils/render-multi-project-summary.ts | 41 ++++++++----------- 3 files changed, 53 insertions(+), 43 deletions(-) create mode 100644 packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 74871d89c..aa6dcb134 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -7,7 +7,6 @@ import { buildJsonReport, collectSupplyChainScores, DEFAULT_PROJECT_SCAN_CONCURRENCY, - filterDiagnosticsForSurface, findLegacyConfig, getChangedLineRanges, getDiffInfo, @@ -19,7 +18,6 @@ import { } from "@react-doctor/core"; import { inspect } from "../../inspect.js"; import type { - Diagnostic, DiffInfo, InspectResult, JsonReportMode, @@ -68,6 +66,7 @@ import { import { runExplain } from "../utils/run-explain.js"; import { projectManifestChanged } from "../utils/project-manifest-changed.js"; import { renderSupplyChainScores } from "../utils/render-supply-chain-scores.js"; +import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; import { selectProjects } from "../utils/select-projects.js"; import { isSpinnerSilent, setSpinnerSilent, spinner } from "../utils/spinner.js"; import { shouldBlockCi } from "../utils/should-block-ci.js"; @@ -79,6 +78,9 @@ import { VERSION } from "../utils/version.js"; interface CompletedScan { directory: string; result: InspectResult; + // The merged (root + module) config the scan ran under — surface + // filtering of its diagnostics must use this, not the root config. + config: ReactDoctorConfig | null; } const filterCompletedScansByCategories = ( @@ -97,7 +99,6 @@ const filterCompletedScansByCategories = ( }; interface FinalizeScansInput { - readonly diagnostics: Diagnostic[]; readonly completedScans: CompletedScan[]; readonly mode: JsonReportMode; readonly diff: DiffInfo | null; @@ -183,11 +184,7 @@ const finalizeScans = (input: FinalizeScansInput): void => { if (input.isScoreOnly || baselineDegraded) return; - const ciFailureDiagnostics = filterDiagnosticsForSurface( - input.diagnostics, - "ciFailure", - input.userConfig, - ); + const ciFailureDiagnostics = filterScansForSurface(input.completedScans, "ciFailure"); if (shouldBlockCi(ciFailureDiagnostics, resolveBlockingLevel(input.flags, input.userConfig))) { process.exitCode = 1; } @@ -398,8 +395,9 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro }; finalizeScans({ - diagnostics: remappedDiagnostics, - completedScans: [{ directory: resolvedDirectory, result: remappedInspectResult }], + completedScans: [ + { directory: resolvedDirectory, result: remappedInspectResult, config: userConfig }, + ], mode: "staged", diff: null, baselineIntended: false, @@ -510,7 +508,6 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro logger.break(); } - const allDiagnostics: Diagnostic[] = []; const completedScans: CompletedScan[] = []; const isMultiProject = projectDirectories.length > 1; @@ -593,7 +590,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro if (!isQuiet && !isMultiProject) { logger.break(); } - return { directory: scanDirectory, result: scanResult }; + return { directory: scanDirectory, result: scanResult, config: projectConfig }; }; // Multi-project scans run through the same bounded pool as @@ -631,7 +628,6 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro } for (const scanOutcome of scanOutcomes) { if (scanOutcome === null) continue; - allDiagnostics.push(...scanOutcome.result.diagnostics); completedScans.push(scanOutcome); } @@ -642,7 +638,6 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro printMultiProjectSummary({ completedScans, categoryFilters, - userConfig, verbose: Boolean(flags.verbose), isOffline: !shouldShowShareLink, projectName: path.basename(resolvedDirectory), @@ -652,7 +647,6 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro } finalizeScans({ - diagnostics: allDiagnostics, completedScans, // A resolved base ref means a baseline run; finalizeScans downgrades this // to `diff` if no delta was produced (degraded run). @@ -671,10 +665,9 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro startTime, }); - const surfaceDiagnostics = filterDiagnosticsForSurface( - allDiagnostics, + const surfaceDiagnostics = filterScansForSurface( + completedScans, scanOptions.outputSurface ?? "cli", - userConfig, ); const selectedSurfaceDiagnostics = filterDiagnosticsByCategories( surfaceDiagnostics, diff --git a/packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts b/packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts new file mode 100644 index 000000000..898f05237 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts @@ -0,0 +1,26 @@ +import { filterDiagnosticsForSurface } from "@react-doctor/core"; +import type { + Diagnostic, + DiagnosticSurface, + InspectResult, + ReactDoctorConfig, +} from "@react-doctor/core"; + +export interface SurfaceFilterableScan { + readonly result: InspectResult; + /** + * The merged (root + module) config the scan ran under. Surface + * filtering must use it — not the invocation-root config — so a + * module's own `surfaces` controls apply to the aggregate output + * exactly as they would to a standalone scan of that module. + */ + readonly config: ReactDoctorConfig | null; +} + +export const filterScansForSurface = ( + completedScans: ReadonlyArray, + surface: DiagnosticSurface, +): Diagnostic[] => + completedScans.flatMap((scan) => + filterDiagnosticsForSurface([...scan.result.diagnostics], surface, scan.config), + ); diff --git a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts index 0ffa283f0..bc2991fd6 100644 --- a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts @@ -1,13 +1,10 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { - filterDiagnosticsForSurface, - highlighter, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, -} from "@react-doctor/core"; -import type { Diagnostic, InspectResult, ReactDoctorConfig, ScoreResult } from "@react-doctor/core"; +import { highlighter, SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from "@react-doctor/core"; +import type { Diagnostic, InspectResult, ScoreResult } from "@react-doctor/core"; import { colorizeByScore } from "./colorize-by-score.js"; +import { filterScansForSurface } from "./filter-scans-for-surface.js"; +import type { SurfaceFilterableScan } from "./filter-scans-for-surface.js"; import { computeProjectedScore } from "./compute-score-projection.js"; import { buildRulePriorityMap } from "./diagnostic-grouping.js"; import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; @@ -63,11 +60,14 @@ const buildSummaryLine = (entry: ProjectScanEntry, longestProjectNameLength: num // (a chain is only as strong as its weakest link), so the score // projection is computed against that same project. const findLowestScoredScan = ( - completedScans: ReadonlyArray<{ readonly result: InspectResult }>, -): { readonly result: InspectResult & { score: ScoreResult } } | null => { + completedScans: ReadonlyArray, +): (SurfaceFilterableScan & { readonly result: InspectResult & { score: ScoreResult } }) | null => { const scoredScans = completedScans.filter( - (scan): scan is { readonly result: InspectResult & { score: ScoreResult } } => - scan.result.score !== null, + ( + scan, + ): scan is SurfaceFilterableScan & { + readonly result: InspectResult & { score: ScoreResult }; + } => scan.result.score !== null, ); if (scoredScans.length === 0) return null; @@ -77,8 +77,7 @@ const findLowestScoredScan = ( }; export interface MultiProjectSummaryInput { - readonly completedScans: ReadonlyArray<{ readonly result: InspectResult }>; - readonly userConfig: ReactDoctorConfig | null; + readonly completedScans: ReadonlyArray; readonly categoryFilters?: ReadonlySet; readonly verbose: boolean; readonly isOffline: boolean; @@ -88,14 +87,7 @@ export interface MultiProjectSummaryInput { export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effect.Effect => Effect.gen(function* () { - const { - completedScans, - userConfig, - verbose, - isOffline, - projectName, - totalElapsedMilliseconds, - } = input; + const { completedScans, verbose, isOffline, projectName, totalElapsedMilliseconds } = input; const categoryFilters = input.categoryFilters ?? new Set(); // Report animations (category count-up + score-projection ghost gain) play @@ -103,8 +95,7 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec // in `inspect.ts`. The first-run section pacing stays single-project-only. const animateRender = !verbose && canAnimateOnboarding(process.stdout); - const allDiagnostics: Diagnostic[] = completedScans.flatMap((scan) => scan.result.diagnostics); - const surfaceDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "cli", userConfig); + const surfaceDiagnostics = filterScansForSurface(completedScans, "cli"); const displayDiagnostics = filterDiagnosticsByCategories(surfaceDiagnostics, categoryFilters); // Each diagnostic's `filePath` is relative to its own project root, @@ -173,7 +164,7 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec ? yield* Effect.promise(() => computeProjectedScore( displayDiagnostics, - filterDiagnosticsForSurface(lowestScoredScan.result.diagnostics, "cli", userConfig), + filterScansForSurface([lowestScoredScan], "cli"), lowestScoredScan.result.score, ), ) @@ -192,7 +183,7 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec const entries: ProjectScanEntry[] = completedScans.map((scan) => { const projectDiagnostics = filterDiagnosticsByCategories( - filterDiagnosticsForSurface([...scan.result.diagnostics], "cli", userConfig), + filterScansForSurface([scan], "cli"), categoryFilters, ); const errorCount = projectDiagnostics.filter(