diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index bd59ddeee..905448acf 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, @@ -10,6 +11,8 @@ import { layerOtlp, Linter, LintPartialFailures, + mapWithConcurrency, + mergeReactDoctorConfigs, Progress, Project, Reporter, @@ -41,15 +44,15 @@ import type { // stack is built once here rather than duplicated per variant. const buildDiagnoseLayer = ( config: ReactDoctorConfig | null, - configOverride?: { readonly resolvedDirectory: string }, + configOverrideTarget?: Pick, ) => { const configLayer = - configOverride === undefined + configOverrideTarget === undefined ? Config.layerNode : Config.layerOf({ config, - resolvedDirectory: configOverride.resolvedDirectory, - configSourceDirectory: null, + resolvedDirectory: configOverrideTarget.resolvedDirectory, + configSourceDirectory: configOverrideTarget.configSourceDirectory, }); return Layer.mergeAll( Project.layerNode, @@ -113,9 +116,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); @@ -149,21 +152,40 @@ 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 mergedOptions: DiagnoseOptions = { ...baseOptions, ...perProjectOptions }; + const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition; - const program = buildInspectProgram(scanTarget, mergedOptions, configOverride); + // Config layers, least to most specific: on-disk `doctor.config.*` ← + // batch `config` ← per-project `config`. With no overrides the merge is + // the identity and the orchestrator loads from disk (`Config.layerNode`). + const didOverrideConfig = batchConfig !== undefined || projectConfig !== undefined; + const effectiveConfig = mergeReactDoctorConfigs( + mergeReactDoctorConfigs(scanTarget.userConfig, batchConfig), + projectConfig, + ); - const effectiveConfig = configOverride ?? scanTarget.userConfig; + const program = buildInspectProgram( + scanTarget, + { ...baseOptions, ...perProjectOptions }, + effectiveConfig ?? undefined, + ); + // `plugins` is override-wins in the merge: when a caller layer supplies + // it, relative entries resolve against the scan root (caller configs + // have no file location); otherwise the on-disk config's directory. + const didOverridePlugins = + batchConfig?.plugins !== undefined || projectConfig?.plugins !== undefined; const layer = buildDiagnoseLayer( effectiveConfig, - configOverride !== undefined - ? { resolvedDirectory: scanTarget.resolvedDirectory } + didOverrideConfig + ? { + resolvedDirectory: scanTarget.resolvedDirectory, + configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory, + } : undefined, ); @@ -185,76 +207,52 @@ const diagnoseProject = async ( } }; -/** - * Scan multiple projects in parallel and return per-project scores, - * diagnostics, and an aggregate score (worst-of across all projects). - * - * Each project runs its own independent `runInspect` pipeline — the - * same pipeline `diagnose()` uses — so per-project config overrides, - * dead-code analysis, and scoring all work identically to a single - * `diagnose()` call. - * - * Projects that fail (e.g. missing `package.json`, no React dependency) - * are included in the result with `ok: false` rather than aborting the - * entire batch, so callers always receive partial results. - * - * ```ts - * const result = await diagnoseProjects({ - * projects: [ - * { directory: "packages/app" }, - * { directory: "packages/shared", deadCode: false }, - * { directory: "packages/admin", config: { - * rules: { "react-doctor/no-array-index-as-key": "off" }, - * }}, - * ], - * concurrency: 4, - * }); - * - * for (const project of result.projects) { - * if (project.ok) { - * console.log(project.directory, project.score); - * } else { - * console.error(project.directory, project.error); - * } - * } - * ``` - */ -export const diagnoseProjects = async ( +const diagnoseProjectBatch = async ( input: DiagnoseProjectsInput, ): Promise => { const startTime = globalThis.performance.now(); - const { projects, concurrency: rawConcurrency, ...baseOptions } = input; - const concurrency = Math.max(1, rawConcurrency ?? projects.length); - - const projectResults: ProjectResult[] = []; - const pendingProjects = [...projects]; - - const runBatch = async (): Promise => { - const batch: Promise[] = []; - - while (pendingProjects.length > 0 && batch.length < concurrency) { - const projectDefinition = pendingProjects.shift()!; - batch.push(diagnoseProject(projectDefinition, baseOptions)); - } - - const batchResults = await Promise.all(batch); - projectResults.push(...batchResults); + const { projects, concurrency, config: batchConfig, ...baseOptions } = input; - if (pendingProjects.length > 0) { - await runBatch(); - } - }; - - await runBatch(); - - const allDiagnostics = projectResults.flatMap((projectResult) => - projectResult.ok ? projectResult.diagnostics : [], + // `diagnoseProject` never rejects (failures come back as `ok: false`), + // so the pool always drains every project. + const projectResults = await mapWithConcurrency( + projects, + concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY, + (projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig), ); return { projects: projectResults, - diagnostics: allDiagnostics, + diagnostics: projectResults.flatMap((projectResult) => + projectResult.ok ? projectResult.diagnostics : [], + ), score: findWorstScore(projectResults), elapsedMilliseconds: globalThis.performance.now() - startTime, }; }; + +interface Diagnose { + /** Scan a single project directory and return diagnostics + score. */ + (directory: string, options?: DiagnoseOptions): Promise; + /** + * 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 required to assign the overload implementation (whose +// return type is the union of both signatures) to the overloaded interface +// — TypeScript can't verify that narrowing on the first argument selects +// the matching return type. +export const diagnose = (async ( + directoryOrInput: string | DiagnoseProjectsInput, + options: DiagnoseOptions = {}, +): Promise => + 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 4933d9634..61d2d9193 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"), @@ -231,4 +230,24 @@ describe("diagnoseProjects", () => { expect(result.projects).toHaveLength(1); expect(result.projects[0].ok).toBe(true); }); + + it("layers per-project configs on top of a batch-level config", async () => { + const result = await diagnose({ + projects: [ + { directory: path.join(FIXTURES_DIRECTORY, "basic-react") }, + { + directory: path.join(FIXTURES_DIRECTORY, "nextjs-app"), + config: { rules: { "react-doctor/no-prop-drilling": "off" } }, + }, + ], + config: { ignore: { tags: ["design"] } }, + deadCode: false, + lint: false, + }); + + expect(result.projects).toHaveLength(2); + for (const projectResult of result.projects) { + expect(projectResult.ok).toBe(true); + } + }); }); 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/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/config.ts b/packages/core/src/types/config.ts index 283beb319..4a056ad9a 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -278,6 +278,22 @@ export interface ReactDoctorConfig { * requested directory). */ rootDir?: string; + /** + * Projects to scan and score separately — the config-file equivalent of + * the CLI `--project` flag, for repos that always want per-module scoring + * (e.g. a monorepo dashboard tracking each module's score daily) without + * passing the flag on every run. + * + * Entries resolve exactly like `--project` values: workspace package + * names (or directory basenames) first, then directory paths relative to + * the scanned root. `"*"` selects every discovered workspace project. + * Invalid entries fail the run with the same error as the flag. + * + * Precedence: an explicit `--project` flag overrides this list. Only the + * config at the invocation root is consulted — `projects` inside a + * module's own config is ignored (modules can't redirect the scan). + */ + projects?: string[]; textComponents?: string[]; /** * Names of components that safely route string-only children through a diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index afd865309..a2e4b3c68 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -38,19 +38,17 @@ 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` is a full `ReactDoctorConfig` override that replaces the - * on-disk `doctor.config.*` for this project's scan. */ 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 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; } @@ -71,9 +69,18 @@ export type ProjectResult = ProjectResultOk | ProjectResultError; export interface DiagnoseProjectsInput extends DiagnoseOptions { projects: ProjectDefinition[]; /** - * 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. + * 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; + /** + * 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; } diff --git a/packages/core/src/types/inspect.ts b/packages/core/src/types/inspect.ts index 4af2f5090..8f3b6b655 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 / @@ -165,6 +172,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/core/src/utils/merge-react-doctor-configs.ts b/packages/core/src/utils/merge-react-doctor-configs.ts new file mode 100644 index 000000000..9c5766721 --- /dev/null +++ b/packages/core/src/utils/merge-react-doctor-configs.ts @@ -0,0 +1,61 @@ +import type { ReactDoctorConfig } from "../types/config.js"; + +type ReactDoctorIgnore = NonNullable; + +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` / `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, + overrideConfig: ReactDoctorConfig | undefined, +): ReactDoctorConfig | null => { + if (overrideConfig === undefined) return baseConfig; + if (baseConfig === null) return overrideConfig; + + const mergedConfig: ReactDoctorConfig = { ...baseConfig, ...overrideConfig }; + 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 new file mode 100644 index 000000000..4d42b8509 --- /dev/null +++ b/packages/core/tests/merge-react-doctor-configs.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ReactDoctorConfig } from "@react-doctor/core"; +import { mergeReactDoctorConfigs } from "@react-doctor/core"; + +describe("mergeReactDoctorConfigs", () => { + it("passes either side through unchanged when the other is empty", () => { + const baseConfig: ReactDoctorConfig = { rules: { "react-doctor/no-prop-drilling": "off" } }; + const overrideConfig: ReactDoctorConfig = { verbose: true }; + expect(mergeReactDoctorConfigs(baseConfig, undefined)).toBe(baseConfig); + expect(mergeReactDoctorConfigs(null, overrideConfig)).toBe(overrideConfig); + 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/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 08967bcc4..216dc1a7d 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -6,17 +6,18 @@ import * as fs from "node:fs"; import { buildJsonReport, collectSupplyChainScores, - filterDiagnosticsForSurface, + DEFAULT_PROJECT_SCAN_CONCURRENCY, findLegacyConfig, getChangedLineRanges, getDiffInfo, highlighter, + mapWithConcurrency, + mergeReactDoctorConfigs, resolveScanTarget, toRelativePath, } from "@react-doctor/core"; import { inspect } from "../../inspect.js"; import type { - Diagnostic, DiffInfo, InspectResult, JsonReportMode, @@ -66,8 +67,9 @@ 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 { 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"; @@ -77,6 +79,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 = ( @@ -95,7 +100,6 @@ const filterCompletedScansByCategories = ( }; interface FinalizeScansInput { - readonly diagnostics: Diagnostic[]; readonly completedScans: CompletedScan[]; readonly mode: JsonReportMode; readonly diff: DiffInfo | null; @@ -181,11 +185,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; } @@ -396,8 +396,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, @@ -415,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))) @@ -503,35 +509,56 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro logger.break(); } - const allDiagnostics: Diagnostic[] = []; - const completedScans: Array<{ directory: string; result: InspectResult }> = []; + const completedScans: CompletedScan[] = []; 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) { + 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 + // `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); + // `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). + 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; + 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 @@ -545,27 +572,64 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro if (!isQuiet && !isMultiProject) { logger.dim(" "); } - const scanResult = await inspect(projectDirectory, { + const scanResult = await inspect(scanDirectory, { ...scanOptions, includePaths, - configOverride: userConfig, + 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 - ? resolveProjectChangedLineRanges( - resolvedDirectory, - projectDirectory, - changedLineRanges, - ) + ? resolveProjectChangedLineRanges(resolvedDirectory, scanDirectory, changedLineRanges) : undefined, supplyChainManifestChanged, }); - allDiagnostics.push(...scanResult.diagnostics); - completedScans.push({ directory: projectDirectory, result: scanResult }); if (!isQuiet && !isMultiProject) { logger.break(); } + return { directory: scanDirectory, result: scanResult, config: projectConfig }; + }; + + // 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; + // 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; + 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; + completedScans.push(scanOutcome); } if (!isQuiet && isMultiProject && completedScans.length > 0) { @@ -575,19 +639,18 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro printMultiProjectSummary({ completedScans, categoryFilters, - userConfig, verbose: Boolean(flags.verbose), outputDirectory: flags.outputDir, isOffline: !shouldShowShareLink, projectName: path.basename(resolvedDirectory), + totalElapsedMilliseconds: performance.now() - scanLoopStartTime, }), ); } - const surfaceDiagnostics = filterDiagnosticsForSurface( - allDiagnostics, + const surfaceDiagnostics = filterScansForSurface( + completedScans, scanOptions.outputSurface ?? "cli", - userConfig, ); const selectedSurfaceDiagnostics = filterDiagnosticsByCategories( surfaceDiagnostics, @@ -614,7 +677,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). diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index 63f3aca0e..ffd4f26fc 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 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)"], @@ -119,7 +120,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); overrides the `projects` config field", + ) .option( "--scope ", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)", @@ -184,7 +188,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..e9754126e 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -132,6 +132,8 @@ export const METRIC = { cliInvoked: "cli.invoked", 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/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 30f063ddd..d9ee70f0e 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,18 +77,18 @@ 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 outputDirectory?: string | null; 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, verbose, isOffline, projectName, totalElapsedMilliseconds } = input; const categoryFilters = input.categoryFilters ?? new Set(); // Report animations (category count-up + score-projection ghost gain) play @@ -96,8 +96,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, @@ -114,8 +113,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 @@ -136,12 +135,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) { @@ -162,10 +157,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 @@ -174,7 +165,7 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec ? yield* Effect.promise(() => computeProjectedScore( displayDiagnostics, - filterDiagnosticsForSurface(lowestScoredScan.result.diagnostics, "cli", userConfig), + filterScansForSurface([lowestScoredScan], "cli"), lowestScoredScan.result.score, ), ) @@ -194,7 +185,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( diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index 854abe102..6b3015143 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -3,18 +3,22 @@ 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, projectFlag: string | undefined, skipPrompts: boolean, + configProjects?: readonly string[], ): Promise => { const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); let packages = listWorkspacePackages(rootDirectory); @@ -22,6 +26,28 @@ export const selectProjects = async ( packages = discoverReactSubprojects(rootDirectory); } + // 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 + // 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( @@ -30,8 +56,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 +69,7 @@ const ALL_PROJECTS_SENTINEL = "*"; const resolveProjectFlag = ( projectFlag: string, workspacePackages: WorkspacePackage[], + rootDirectory: string, ): string[] => { const requestedNames = projectFlag .split(",") @@ -59,33 +84,49 @@ 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. 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[] = []; + const sourceLabel = source === "flag" ? "Project" : 'Config "projects" entry'; - for (const requestedName of requestedNames) { + return requestedNames.map((requestedName) => { const matched = workspacePackages.find( (workspacePackage) => workspacePackage.name === requestedName || path.basename(workspacePackage.directory) === requestedName, ); + if (matched) return matched.directory; - if (!matched) { - const availableNames = workspacePackages - .map((workspacePackage) => workspacePackage.name) - .join(", "); - throw new CliInputError(`Project "${requestedName}" not found. Available: ${availableNames}`); + const candidateDirectory = path.resolve(rootDirectory, requestedName); + if (isDirectory(candidateDirectory)) { + recordCount(METRIC.projectPathSelected); + return candidateDirectory; } - resolvedDirectories.push(matched.directory); - } - - return resolvedDirectories; + const availableNames = workspacePackages + .map((workspacePackage) => workspacePackage.name) + .join(", "); + throw new CliInputError( + workspacePackages.length > 0 + ? `${sourceLabel} "${requestedName}" is not a workspace project or a directory. Available projects: ${availableNames}` + : `${sourceLabel} "${requestedName}" is not a directory under ${rootDirectory}.`, + ); + }); }; const printDiscoveredProjects = (packages: WorkspacePackage[]): void => { 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/index.ts b/packages/react-doctor/src/index.ts index ca8be7585..442e89823 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, }; diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 4b3c74b7a..43755f59d 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -158,6 +158,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. */ @@ -205,6 +207,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, @@ -252,10 +255,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 @@ -271,13 +277,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; @@ -292,46 +299,53 @@ 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(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); + if (ownsSpinnerSilence) setSpinnerSilent(wasSpinnerSilent); } }; @@ -476,7 +490,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, @@ -501,13 +517,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); @@ -548,7 +567,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; @@ -865,7 +886,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(); } 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 }); diff --git a/packages/react-doctor/tests/select-projects.test.ts b/packages/react-doctor/tests/select-projects.test.ts index b39bcb5f8..ca22ce8df 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"), { @@ -122,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 42f138d82..c55b6ed4c 100644 --- a/packages/website/public/llms.txt +++ b/packages/website/public/llms.txt @@ -99,7 +99,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); 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 6592d5bfb..9e83ff7b5 100644 --- a/packages/website/public/schema/config.json +++ b/packages/website/public/schema/config.json @@ -63,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", @@ -90,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": { @@ -215,6 +231,16 @@ "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 per-axis scores (0–100 once normalized). A dependency whose worst security axis (supply chain or vulnerability) scores 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": [