From c228ef2641e78980948f033b8034ef0d1319f891 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 29 Jul 2026 11:47:29 +0000 Subject: [PATCH] refactor(cli): decompose inspect workflow and rendering --- .../react-doctor/src/cli/commands/inspect.ts | 116 +- .../react-doctor/src/cli/ink/run-scan-app.tsx | 39 +- .../src/cli/utils/anonymize-text.ts | 6 +- .../src/cli/utils/build-inspect-result.ts | 57 + .../src/cli/utils/build-project-scan-plan.ts | 76 ++ .../src/cli/utils/build-run-event.ts | 17 +- .../src/cli/utils/build-runtime-layers.ts | 31 +- .../filter-diagnostics-by-changed-lines.ts | 29 + .../src/cli/utils/filter-scans-for-surface.ts | 10 +- .../src/cli/utils/find-owning-project.ts | 33 +- .../src/cli/utils/handle-error.ts | 9 +- .../src/cli/utils/prompt-install-setup.ts | 8 +- .../src/cli/utils/record-scan-metrics.ts | 13 +- .../src/cli/utils/render-diagnostics.ts | 11 +- .../src/cli/utils/render-inspect-result.ts | 436 +++++++ .../src/cli/utils/render-summary.ts | 12 +- .../cli/utils/resolve-baseline-comparison.ts | 204 ++++ .../src/cli/utils/resolve-inspect-options.ts | 70 ++ .../src/cli/utils/rule-config-file.ts | 11 +- .../cli/utils/scan-result-cache-lifecycle.ts | 144 +++ .../src/cli/utils/scan-result-cache-policy.ts | 46 + .../src/cli/utils/scan-result-cache.ts | 46 +- .../src/cli/utils/select-projects.ts | 26 +- packages/react-doctor/src/index.ts | 81 +- packages/react-doctor/src/inspect-options.ts | 60 + packages/react-doctor/src/inspect.ts | 1034 ++--------------- .../tests/build-inspect-result.test.ts | 163 +++ .../tests/build-project-scan-plan.test.ts | 166 +++ .../react-doctor/tests/clear-caches.test.ts | 22 + .../tests/core-configuration-boundary.test.ts | 134 +++ ...core-diagnostic-semantics-boundary.test.ts | 102 ++ .../tests/core-errors-boundary.test.ts | 122 ++ .../tests/core-presentation-boundary.test.ts | 125 ++ .../core-project-discovery-boundary.test.ts | 108 ++ .../tests/core-reporting-boundary.test.ts | 104 ++ .../tests/core-runtime-boundary.test.ts | 143 +++ .../tests/core-score-boundary.test.ts | 96 ++ .../tests/core-shared-boundaries.test.ts | 125 ++ .../tests/core-source-boundaries.test.ts | 126 ++ .../tests/core-type-boundary.test.ts | 158 +++ ...ilter-diagnostics-by-changed-lines.test.ts | 107 ++ .../tests/find-owning-project.test.ts | 70 ++ .../tests/ink/run-scan-app.test.ts | 16 +- .../tests/inspect-action-exit-code.test.ts | 12 +- .../tests/inspect-action-setup-prompt.test.ts | 12 +- .../tests/inspect-action-staged-guard.test.ts | 12 +- .../tests/inspect-cache-output-parity.test.ts | 113 ++ .../tests/onboarding-state.test.ts | 11 +- .../tests/performance-harness.test.ts | 83 +- .../tests/prompt-install-setup.test.ts | 12 +- .../regressions/architecture-rules.test.ts | 94 -- .../tests/render-inspect-result.test.ts | 359 ++++++ .../tests/resolve-baseline-comparison.test.ts | 262 +++++ .../tests/resolve-inspect-options.test.ts | 131 +++ .../run-oxlint/sidecar-lint-cache.test.ts | 267 +++++ .../tests/scan-result-cache-lifecycle.test.ts | 388 +++++++ .../tests/scan-result-cache-policy.test.ts | 57 + .../tests/scan-result-cache.test.ts | 52 +- .../tests/select-projects.test.ts | 21 +- 59 files changed, 5030 insertions(+), 1368 deletions(-) create mode 100644 packages/react-doctor/src/cli/utils/build-inspect-result.ts create mode 100644 packages/react-doctor/src/cli/utils/build-project-scan-plan.ts create mode 100644 packages/react-doctor/src/cli/utils/filter-diagnostics-by-changed-lines.ts create mode 100644 packages/react-doctor/src/cli/utils/render-inspect-result.ts create mode 100644 packages/react-doctor/src/cli/utils/resolve-baseline-comparison.ts create mode 100644 packages/react-doctor/src/cli/utils/resolve-inspect-options.ts create mode 100644 packages/react-doctor/src/cli/utils/scan-result-cache-lifecycle.ts create mode 100644 packages/react-doctor/src/cli/utils/scan-result-cache-policy.ts create mode 100644 packages/react-doctor/src/inspect-options.ts create mode 100644 packages/react-doctor/tests/build-inspect-result.test.ts create mode 100644 packages/react-doctor/tests/build-project-scan-plan.test.ts create mode 100644 packages/react-doctor/tests/core-configuration-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-diagnostic-semantics-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-errors-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-presentation-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-project-discovery-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-reporting-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-runtime-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-score-boundary.test.ts create mode 100644 packages/react-doctor/tests/core-shared-boundaries.test.ts create mode 100644 packages/react-doctor/tests/core-source-boundaries.test.ts create mode 100644 packages/react-doctor/tests/core-type-boundary.test.ts create mode 100644 packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts create mode 100644 packages/react-doctor/tests/inspect-cache-output-parity.test.ts create mode 100644 packages/react-doctor/tests/render-inspect-result.test.ts create mode 100644 packages/react-doctor/tests/resolve-baseline-comparison.test.ts create mode 100644 packages/react-doctor/tests/resolve-inspect-options.test.ts create mode 100644 packages/react-doctor/tests/scan-result-cache-lifecycle.test.ts create mode 100644 packages/react-doctor/tests/scan-result-cache-policy.test.ts diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 65127c37a8..8d2bd0f2af 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -3,27 +3,22 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import * as Effect from "effect/Effect"; import * as fs from "node:fs"; +import { mergeReactDoctorConfigs } from "../../core/core-configuration.js"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { highlighter } from "../../core/core-presentation.js"; +import { toRelativePath } from "../../core/core-primitives.js"; +import { hasReactRuntime, resolveScanTarget } from "../../core/core-project-discovery.js"; +import { buildJsonReport } from "../../core/core-reporting.js"; +import type { JsonReportMode } from "../../core/core-reporting.js"; +import { DEFAULT_PROJECT_SCAN_CONCURRENCY, mapWithConcurrency } from "../../core/core-runtime.js"; +import type { DiffInfo, InspectResult } from "../../core/core-types.js"; import { - buildJsonReport, - DEFAULT_PROJECT_SCAN_CONCURRENCY, getBaselineDiffPlan, getChangedLineRanges, getDiffInfo, - hasReactRuntime, - highlighter, - mapWithConcurrency, - mergeReactDoctorConfigs, - resolveScanTarget, - toRelativePath, -} from "@react-doctor/core"; -import { inspect } from "../../inspect.js"; +} from "../../core/core-version-control.js"; +import { createInvocationInspect } from "../../inspect.js"; import { flushSentry } from "../../instrument.js"; -import type { - DiffInfo, - InspectResult, - JsonReportMode, - ReactDoctorConfig, -} from "@react-doctor/core"; import type { RequestedScope } from "../utils/resolve-scope.js"; import { cliLogger as logger } from "../utils/cli-logger.js"; import { METRIC, STAGED_FILES_TEMP_DIR_PREFIX } from "../utils/constants.js"; @@ -64,13 +59,8 @@ import type { CliInspectOptions } from "../utils/resolve-cli-inspect-options.js" import { finalizeScope, resolveScope, warnDeprecatedDiff } from "../utils/resolve-scope.js"; import { resolveMergeBaseRef } from "../utils/materialize-baseline-files.js"; import { resolveBlockingLevel } from "../utils/resolve-blocking-level.js"; -import { - resolveProjectChangedLineRanges, - resolveProjectDiffIncludePaths, -} from "../utils/resolve-project-diff-include-paths.js"; -import { resolveProjectSourceFilePaths } from "../utils/resolve-project-source-file-paths.js"; +import { resolveProjectChangedLineRanges } from "../utils/resolve-project-diff-include-paths.js"; import { runExplain } from "../utils/run-explain.js"; -import { projectManifestChanged } from "../utils/project-manifest-changed.js"; import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; import { selectProjects } from "../utils/select-projects.js"; import { resolveProjectRelativeDirectory } from "../utils/resolve-project-relative-directory.js"; @@ -83,6 +73,7 @@ import { validateIncludeUntrackedScope, validateModeFlags } from "../utils/valid import { VERSION } from "../utils/version.js"; import { findStagedSnapshotDivergences } from "../utils/find-staged-snapshot-divergences.js"; import { CliInputError } from "../utils/cli-input-error.js"; +import { buildProjectScanPlan } from "../utils/build-project-scan-plan.js"; interface CompletedScan { directory: string; @@ -359,6 +350,7 @@ export const inspectAction = async ( } const scanOptions: CliInspectOptions = resolveCliInspectOptions(flags, userConfig); + const inspectProject = createInvocationInspect(scanOptions.concurrency); // One `--max-duration` budget per invocation, shared by every project of a // workspace scan: fix the absolute deadline once here and hand it to each // project's `inspect()` (rather than restarting the budget per project). @@ -425,7 +417,7 @@ export const inspectAction = async ( logger.break(); } try { - const scanResult = await inspect(snapshot.tempDirectory, { + const scanResult = await inspectProject(snapshot.tempDirectory, { ...scanOptions, deadlineEpochMs: scanDeadlineEpochMs, includePaths: snapshot.stagedFiles, @@ -641,67 +633,29 @@ export const inspectAction = async ( // diff change shouldn't pull a project into the scan (nothing to report). const supplyChainEnabled = flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false; - let includePaths: string[] | undefined; - let supplyChainManifestChanged = false; - const projectBaselineBaseFiles = - baselineDiffPlan === null - ? null - : resolveProjectSourceFilePaths( - resolvedDirectory, - scanDirectory, - baselineDiffPlan.baseFiles, - ); - const projectBaselineHeadFiles = - baselineDiffPlan === null - ? null - : resolveProjectSourceFilePaths( - resolvedDirectory, - scanDirectory, - baselineDiffPlan.headFiles, - ); - if (isDiffMode) { - const changedSourceFiles = - diffInfo === null - ? [] - : 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, scanDirectory, diffInfo); - const hasBaselineOnlyFiles = (projectBaselineBaseFiles?.length ?? 0) > 0; - if ( - changedSourceFiles.length === 0 && - !supplyChainManifestChanged && - !hasBaselineOnlyFiles - ) { - if (!isQuiet) { - logger.dim(`No changed source files in ${scanDirectory}, skipping.`); - logger.break(); - } - 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 - // the supply-chain pass runs. Including it also makes the baseline pass - // materialize the base manifest, so the delta filters out pre-existing - // low-score dependencies instead of reporting them as newly introduced. - includePaths = [...changedSourceFiles]; - if (includePaths.length === 0 && hasBaselineOnlyFiles) { - includePaths.push(...(projectBaselineBaseFiles ?? [])); + const projectScanPlan = buildProjectScanPlan({ + rootDirectory: resolvedDirectory, + projectDirectory: scanDirectory, + baselineDiffPlan, + diffInfo, + isDiffMode, + supplyChainEnabled, + }); + if (projectScanPlan.shouldSkipProject) { + if (!isQuiet) { + logger.dim(`No changed source files in ${scanDirectory}, skipping.`); + logger.break(); } - if (supplyChainManifestChanged) includePaths.push("package.json"); + return null; } if (!isQuiet && !isMultiProject) { logger.dim(" "); } - const scanResult = await inspect(scanDirectory, { + const scanResult = await inspectProject(scanDirectory, { ...scanOptions, deadlineEpochMs: scanDeadlineEpochMs, - includePaths, + includePaths: projectScanPlan.includePaths, configOverride: projectConfig, configSourceDirectory: projectConfigSourceDirectory ?? undefined, suppressRendering: isMultiProject, @@ -710,19 +664,19 @@ export const inspectAction = async ( concurrentScan: isMultiProject, baseline: baselineRef !== null && - projectBaselineBaseFiles !== null && - projectBaselineHeadFiles !== null + projectScanPlan.projectBaselineBaseFiles !== null && + projectScanPlan.projectBaselineHeadFiles !== null ? { ref: baselineRef, - baseFiles: projectBaselineBaseFiles, - headFiles: projectBaselineHeadFiles, + baseFiles: projectScanPlan.projectBaselineBaseFiles, + headFiles: projectScanPlan.projectBaselineHeadFiles, } : undefined, changedLineRanges: scope === "lines" && changedLineRanges !== null ? resolveProjectChangedLineRanges(resolvedDirectory, scanDirectory, changedLineRanges) : undefined, - supplyChainManifestChanged, + supplyChainManifestChanged: projectScanPlan.supplyChainManifestChanged, }); if (!isQuiet && !isMultiProject) { logger.break(); diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index 0b4eabef27..dec3c7b66d 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -2,25 +2,25 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { render } from "ink"; import * as Effect from "effect/Effect"; +import { mergeReactDoctorConfigs } from "../../core/core-configuration.js"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { highlighter } from "../../core/core-presentation.js"; +import { resolveScanTarget } from "../../core/core-project-discovery.js"; import { DEFAULT_PROJECT_SCAN_CONCURRENCY, - highlighter, mapWithConcurrency, - mergeReactDoctorConfigs, Reporter, - resolveScanTarget, -} from "@react-doctor/core"; +} from "../../core/core-runtime.js"; import type { BlockingLevel, Diagnostic, InspectResult, - ReactDoctorConfig, ResolvedScanTarget, ScoreResult, WorkspacePackage, -} from "@react-doctor/core"; -import { inspect } from "../../inspect.js"; -import type { ReactDoctorInspectOptions } from "../../inspect.js"; +} from "../../core/core-types.js"; +import { createInvocationInspect } from "../../inspect.js"; +import type { ReactDoctorInspectOptions } from "../../inspect-options.js"; import { buildNoScoreMessage } from "../utils/build-no-score-message.js"; import { computeProjectedScore } from "../utils/compute-score-projection.js"; import { countUniqueScannedFiles } from "../utils/count-unique-scanned-files.js"; @@ -379,11 +379,12 @@ const runSingleProjectScan = async ( projectDirectory: string, input: RunScanAppInput, blockingLevel: BlockingLevel, + inspectProject: ReturnType, ): Promise => { const projectScan = await resolveProjectScan(rootScanTarget, projectDirectory); const presentation = resolveScanPresentation(input, [projectScan]); return runMountedScan(projectScan.directory, presentation, blockingLevel, async (context) => { - const result = await inspect(projectScan.directory, { + const result = await inspectProject(projectScan.directory, { ...resolveTuiInspectOptions(input, projectScan.config), isCi: isCiEnvironment(), configOverride: projectScan.config, @@ -421,6 +422,7 @@ const runMultiProjectScan = async ( directories: ReadonlyArray, input: RunScanAppInput, blockingLevel: BlockingLevel, + inspectProject: ReturnType, ): Promise => { const rootDirectory = rootScanTarget.resolvedDirectory; const projectScans = await mapWithConcurrency( @@ -437,7 +439,7 @@ const runMultiProjectScan = async ( projectScans, DEFAULT_PROJECT_SCAN_CONCURRENCY, async (projectScan) => { - const result = await inspect(projectScan.directory, { + const result = await inspectProject(projectScan.directory, { ...resolveTuiInspectOptions(input, projectScan.config), isCi: isCiEnvironment(), configOverride: projectScan.config, @@ -514,6 +516,7 @@ export const runScanApp = async (input: RunScanAppInput): Promise; + readonly score: ScoreResult | null; + readonly skippedChecks: string[]; + readonly skippedCheckReasons: Record; + readonly project: ProjectInfo; + readonly elapsedMilliseconds: number; + readonly scannedFileCount: number; + readonly scannedFilePaths: ReadonlyArray; + readonly analyzedFiles: ReadonlyArray; + readonly scanElapsedMilliseconds: number; + readonly lintCacheHitFileCount: number | null; + readonly lintCacheTotalFileCount: number | null; + readonly lintSidecarReplayedFileCount: number | null; + readonly lintSidecarTotalFileCount: number | null; + readonly deadCodeCacheHit: boolean | null; + readonly deadCodeSummaryCacheHits: number | null; + readonly deadCodeSummaryCacheMisses: number | null; + readonly baselineDelta: InspectResult["baselineDelta"]; +} + +export const buildInspectResult = (input: BuildInspectResultInput): InspectResult => ({ + diagnostics: [...input.diagnostics], + score: input.score, + skippedChecks: input.skippedChecks, + ...(Object.keys(input.skippedCheckReasons).length > 0 + ? { skippedCheckReasons: input.skippedCheckReasons } + : {}), + project: input.project, + elapsedMilliseconds: input.elapsedMilliseconds, + scannedFileCount: input.scannedFileCount, + scannedFilePaths: input.scannedFilePaths, + analyzedFiles: input.analyzedFiles, + scanElapsedMilliseconds: input.scanElapsedMilliseconds, + ...(input.lintCacheTotalFileCount !== null + ? { + lintCacheHitFileCount: input.lintCacheHitFileCount, + lintCacheTotalFileCount: input.lintCacheTotalFileCount, + } + : {}), + ...(input.lintSidecarTotalFileCount !== null + ? { + lintSidecarReplayedFileCount: input.lintSidecarReplayedFileCount, + lintSidecarTotalFileCount: input.lintSidecarTotalFileCount, + } + : {}), + ...(input.deadCodeCacheHit !== null ? { deadCodeCacheHit: input.deadCodeCacheHit } : {}), + ...(input.deadCodeSummaryCacheHits !== null && input.deadCodeSummaryCacheMisses !== null + ? { + deadCodeSummaryCacheHits: input.deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses: input.deadCodeSummaryCacheMisses, + } + : {}), + ...(input.baselineDelta ? { baselineDelta: input.baselineDelta } : {}), +}); diff --git a/packages/react-doctor/src/cli/utils/build-project-scan-plan.ts b/packages/react-doctor/src/cli/utils/build-project-scan-plan.ts new file mode 100644 index 0000000000..03ba81a1cd --- /dev/null +++ b/packages/react-doctor/src/cli/utils/build-project-scan-plan.ts @@ -0,0 +1,76 @@ +import type { DiffInfo, GitBaselineDiffPlan } from "../../core/core-types.js"; +import { projectManifestChanged } from "./project-manifest-changed.js"; +import { resolveProjectDiffIncludePaths } from "./resolve-project-diff-include-paths.js"; +import { resolveProjectSourceFilePaths } from "./resolve-project-source-file-paths.js"; + +interface BuildProjectScanPlanInput { + readonly rootDirectory: string; + readonly projectDirectory: string; + readonly baselineDiffPlan: GitBaselineDiffPlan | null; + readonly diffInfo: DiffInfo | null; + readonly isDiffMode: boolean; + readonly supplyChainEnabled: boolean; +} + +export interface ProjectScanPlan { + readonly includePaths: string[] | undefined; + readonly projectBaselineBaseFiles: string[] | null; + readonly projectBaselineHeadFiles: string[] | null; + readonly shouldSkipProject: boolean; + readonly supplyChainManifestChanged: boolean; +} + +export const buildProjectScanPlan = (input: BuildProjectScanPlanInput): ProjectScanPlan => { + const projectBaselineBaseFiles = + input.baselineDiffPlan === null + ? null + : resolveProjectSourceFilePaths( + input.rootDirectory, + input.projectDirectory, + input.baselineDiffPlan.baseFiles, + ); + const projectBaselineHeadFiles = + input.baselineDiffPlan === null + ? null + : resolveProjectSourceFilePaths( + input.rootDirectory, + input.projectDirectory, + input.baselineDiffPlan.headFiles, + ); + + if (!input.isDiffMode) { + return { + includePaths: undefined, + projectBaselineBaseFiles, + projectBaselineHeadFiles, + shouldSkipProject: false, + supplyChainManifestChanged: false, + }; + } + + const changedSourceFiles = + input.diffInfo === null + ? [] + : resolveProjectDiffIncludePaths(input.rootDirectory, input.projectDirectory, input.diffInfo); + const supplyChainManifestChanged = + input.supplyChainEnabled && + input.diffInfo !== null && + projectManifestChanged(input.rootDirectory, input.projectDirectory, input.diffInfo); + const hasProjectBaselineBaseFiles = (projectBaselineBaseFiles?.length ?? 0) > 0; + const shouldSkipProject = + changedSourceFiles.length === 0 && !supplyChainManifestChanged && !hasProjectBaselineBaseFiles; + + const includePaths = [...changedSourceFiles]; + if (includePaths.length === 0 && hasProjectBaselineBaseFiles) { + includePaths.push(...(projectBaselineBaseFiles ?? [])); + } + if (supplyChainManifestChanged) includePaths.push("package.json"); + + return { + includePaths, + projectBaselineBaseFiles, + projectBaselineHeadFiles, + shouldSkipProject, + supplyChainManifestChanged, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/build-run-event.ts b/packages/react-doctor/src/cli/utils/build-run-event.ts index c34a53e479..41e1abb4ef 100644 --- a/packages/react-doctor/src/cli/utils/build-run-event.ts +++ b/packages/react-doctor/src/cli/utils/build-run-event.ts @@ -1,17 +1,12 @@ +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { isReactDoctorError } from "../../core/core-errors.js"; import { filterDiagnosticsForSurface, - HTML_FILE_PATTERN, - isReactDoctorError, - JSX_FILE_PATTERN, - resolveGithubActionsScoreMetadata, summarizeDiagnostics, -} from "@react-doctor/core"; -import type { - BlockingLevel, - InspectResult, - ReactDoctorConfig, - SuppressedRuleCount, -} from "@react-doctor/core"; +} from "../../core/core-diagnostic-semantics.js"; +import { HTML_FILE_PATTERN, JSX_FILE_PATTERN } from "../../core/core-project-discovery.js"; +import { resolveGithubActionsScoreMetadata } from "../../core/core-score.js"; +import type { BlockingLevel, InspectResult, SuppressedRuleCount } from "../../core/core-types.js"; import { buildRuleBlastRadii } from "./diagnostic-grouping.js"; import { hasLintHardFailure } from "./has-lint-hard-failure.js"; import { isInspectResultComplete } from "./is-inspect-result-complete.js"; diff --git a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts index 95a32b4a1d..5ee8be59b1 100644 --- a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts +++ b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; import { Config, DeadCode, @@ -8,14 +9,15 @@ import { Linter, LintPartialFailures, OxlintConcurrency, + OxlintSpawnSlots, Progress, Project, ProjectChecks, Reporter, - Score, SupplyChain, -} from "@react-doctor/core"; -import type { ProgressHandle, ProjectInfo, ReactDoctorConfig } from "@react-doctor/core"; +} from "../../core/core-runtime.js"; +import { Score } from "../../core/core-score.js"; +import type { ProgressHandle, ProjectInfo, WorkerSlots } from "../../core/core-types.js"; import { spinner } from "./spinner.js"; export interface BuildRuntimeLayersInput { @@ -59,14 +61,11 @@ export interface BuildRuntimeLayersInput { */ readonly shouldShowProgressSpinners: boolean; /** - * Resolved oxlint worker count from the CLI's `--no-parallel` flag - * (today the only value it produces is `1` — serial). When provided, it - * overrides the `OxlintConcurrency` Reference for this run via - * `Layer.succeed`; `undefined` leaves the env-seeded ambient default - * (parallel: auto-detect cores unless `REACT_DOCTOR_PARALLEL` pins a - * count) in place. + * Invocation-wide oxlint subprocess count, resolved once from the explicit + * concurrency pin or the env-seeded automatic default. */ - readonly oxlintConcurrency?: number; + readonly oxlintConcurrency: number; + readonly oxlintSpawnSlots: WorkerSlots; readonly reporterLayer?: Layer.Layer; readonly progressLayer?: Layer.Layer; } @@ -159,11 +158,9 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { supplyChainLayer, ); - // Only override the ambient `OxlintConcurrency` Reference when the CLI - // resolved a concrete worker count (today: `--no-parallel` → serial); - // otherwise leave the env-seeded default (parallel) so - // `REACT_DOCTOR_PARALLEL` still applies to flag-less runs. - return input.oxlintConcurrency === undefined - ? baseLayers - : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency)); + return Layer.mergeAll( + baseLayers, + Layer.succeed(OxlintConcurrency, input.oxlintConcurrency), + Layer.succeed(OxlintSpawnSlots, input.oxlintSpawnSlots), + ); }; diff --git a/packages/react-doctor/src/cli/utils/filter-diagnostics-by-changed-lines.ts b/packages/react-doctor/src/cli/utils/filter-diagnostics-by-changed-lines.ts new file mode 100644 index 0000000000..c5836f4f92 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/filter-diagnostics-by-changed-lines.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import type { ChangedFileLineRanges, Diagnostic } from "../../core/core-types.js"; +import { diagnosticIntersectsLineRanges } from "./diagnostic-intersects-line-ranges.js"; +import { toForwardSlashes } from "./path-format.js"; + +interface FilterDiagnosticsByChangedLinesInput { + readonly directory: string; + readonly diagnostics: ReadonlyArray; + readonly changedLineRanges: ReadonlyArray; +} + +export const filterDiagnosticsByChangedLines = ( + input: FilterDiagnosticsByChangedLinesInput, +): ReadonlyArray => { + const rangesByFile = new Map>(); + for (const entry of input.changedLineRanges) { + rangesByFile.set(toForwardSlashes(entry.file), entry.ranges); + } + + return input.diagnostics.filter((diagnostic) => { + const relativePath = toForwardSlashes( + path.isAbsolute(diagnostic.filePath) + ? path.relative(input.directory, diagnostic.filePath) + : diagnostic.filePath, + ); + const ranges = rangesByFile.get(relativePath); + return ranges !== undefined && diagnosticIntersectsLineRanges(diagnostic, ranges); + }); +}; 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 index 898f052379..3f27823ff8 100644 --- a/packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts +++ b/packages/react-doctor/src/cli/utils/filter-scans-for-surface.ts @@ -1,10 +1,6 @@ -import { filterDiagnosticsForSurface } from "@react-doctor/core"; -import type { - Diagnostic, - DiagnosticSurface, - InspectResult, - ReactDoctorConfig, -} from "@react-doctor/core"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import type { Diagnostic, DiagnosticSurface, InspectResult } from "../../core/core-types.js"; +import { filterDiagnosticsForSurface } from "../../core/core-diagnostic-semantics.js"; export interface SurfaceFilterableScan { readonly result: InspectResult; diff --git a/packages/react-doctor/src/cli/utils/find-owning-project.ts b/packages/react-doctor/src/cli/utils/find-owning-project.ts index 186c9e8a3b..a3db87fac6 100644 --- a/packages/react-doctor/src/cli/utils/find-owning-project.ts +++ b/packages/react-doctor/src/cli/utils/find-owning-project.ts @@ -1,18 +1,39 @@ import * as path from "node:path"; -import { discoverReactSubprojects, listWorkspacePackages } from "@react-doctor/core"; +import { + buildPackageGraph, + discoverReactSubprojects, + isFile, + readPackageJson, +} from "../../core/core-project-discovery.js"; export const findOwningProjectDirectory = (rootDirectory: string, filePath: string): string => { - const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath); + const absoluteFilePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(rootDirectory, filePath); + const packageJsonPath = path.join(rootDirectory, "package.json"); + if (isFile(packageJsonPath)) { + const packageGraph = buildPackageGraph(rootDirectory, readPackageJson(packageJsonPath)); + if (packageGraph.workspacePatterns.length > 0) { + const owningPackage = packageGraph.findOwningPackage( + absoluteFilePath, + (packageNode) => packageNode.hasReactDependency, + ); + if (owningPackage) { + return owningPackage.isRoot ? rootDirectory : owningPackage.directory; + } + if (packageGraph.packages.some((packageNode) => packageNode.hasReactDependency)) { + return rootDirectory; + } + } + } - const workspacePackages = listWorkspacePackages(rootDirectory); - const candidates = - workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory); + const candidates = discoverReactSubprojects(rootDirectory); if (candidates.length === 0) return rootDirectory; let bestMatch: { directory: string; depth: number } | null = null; for (const candidate of candidates) { const candidateDirectory = path.resolve(candidate.directory); - const relativeFromCandidate = path.relative(candidateDirectory, absoluteFile); + const relativeFromCandidate = path.relative(candidateDirectory, absoluteFilePath); if (relativeFromCandidate.startsWith("..") || path.isAbsolute(relativeFromCandidate)) continue; const depth = candidateDirectory.length; if (!bestMatch || depth > bestMatch.depth) { diff --git a/packages/react-doctor/src/cli/utils/handle-error.ts b/packages/react-doctor/src/cli/utils/handle-error.ts index 3c94edf4a5..d71ba080c9 100644 --- a/packages/react-doctor/src/cli/utils/handle-error.ts +++ b/packages/react-doctor/src/cli/utils/handle-error.ts @@ -1,15 +1,14 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { - CANONICAL_DISCORD_URL, - CANONICAL_GITHUB_URL, formatErrorChain, formatReactDoctorError, - highlighter, isErrnoException, isReactDoctorError, -} from "@react-doctor/core"; -import type { HandleErrorOptions } from "@react-doctor/core"; +} from "../../core/core-errors.js"; +import { highlighter } from "../../core/core-presentation.js"; +import { CANONICAL_DISCORD_URL, CANONICAL_GITHUB_URL } from "../../core/core-product.js"; +import type { HandleErrorOptions } from "../../core/core-types.js"; import { VERSION } from "./version.js"; import { METRIC } from "./constants.js"; import { formatEnvironmentError, isEnvironmentError } from "./is-environment-error.js"; diff --git a/packages/react-doctor/src/cli/utils/prompt-install-setup.ts b/packages/react-doctor/src/cli/utils/prompt-install-setup.ts index 149ef1d1b3..c84f72618c 100644 --- a/packages/react-doctor/src/cli/utils/prompt-install-setup.ts +++ b/packages/react-doctor/src/cli/utils/prompt-install-setup.ts @@ -1,6 +1,5 @@ -import { type CliStateOptions, SETUP_HINT_EVENT, getCliStatePath } from "./cli-state-store.js"; +import { type CliStateOptions, SETUP_HINT_EVENT } from "./cli-state-store.js"; import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; -import { hashProjectRoot } from "./hash-project-root.js"; import { findNearestPackageDirectory, hasDoctorScript } from "./install-doctor-script.js"; import { isCodingAgentEnvironment } from "./is-ci-environment.js"; @@ -18,11 +17,6 @@ export interface ResolveInstallSetupProjectRootOptions { // "show again" (better than crashing a scan), matching the prior behavior. const SETUP_HINT_GATE: Gate = { id: SETUP_HINT_EVENT, scope: "project", fireWhenUnknown: true }; -export const getSetupPromptConfigPath = getCliStatePath; - -export const getSetupPromptProjectKey = (projectRoot: string): string => - hashProjectRoot(projectRoot); - export const hasDisabledSetupPrompt = ( projectRoot: string, options: CliStateOptions = {}, diff --git a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts index aecf19829d..bbe2560cbb 100644 --- a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts +++ b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts @@ -1,10 +1,9 @@ -import { canonicalizeUserRuleKey, getDiagnosticRuleIdentity } from "@react-doctor/core"; -import type { - Diagnostic, - InspectResult, - ReactDoctorConfig, - SuppressedRuleCount, -} from "@react-doctor/core"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import type { Diagnostic, InspectResult, SuppressedRuleCount } from "../../core/core-types.js"; +import { + canonicalizeUserRuleKey, + getDiagnosticRuleIdentity, +} from "../../core/core-diagnostic-semantics.js"; import { METRIC } from "./constants.js"; import { recordCount, recordDistribution } from "./record-metric.js"; diff --git a/packages/react-doctor/src/cli/utils/render-diagnostics.ts b/packages/react-doctor/src/cli/utils/render-diagnostics.ts index 0c8f95f9e6..ce2fc3c469 100644 --- a/packages/react-doctor/src/cli/utils/render-diagnostics.ts +++ b/packages/react-doctor/src/cli/utils/render-diagnostics.ts @@ -1,17 +1,16 @@ import isUnicodeSupported from "is-unicode-supported"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import { DIAGNOSTIC_CATEGORY_BUCKETS, groupBy } from "../../core/core-diagnostic-semantics.js"; import { CODE_FRAME_BATCH_MAX_SPAN_LINES, CODE_FRAME_LINES_ABOVE, CODE_FRAME_LINES_BELOW, - DIAGNOSTIC_CATEGORY_BUCKETS, - groupBy, highlighter, - MILLISECONDS_PER_SECOND, - TOP_ERRORS_DISPLAY_COUNT, -} from "@react-doctor/core"; -import type { Diagnostic } from "@react-doctor/core"; +} from "../../core/core-presentation.js"; +import { MILLISECONDS_PER_SECOND } from "../../core/core-runtime.js"; +import { TOP_ERRORS_DISPLAY_COUNT } from "../../core/core-score.js"; +import type { Diagnostic } from "../../core/core-types.js"; import { pathToFileURL } from "node:url"; import { boxText } from "./box-text.js"; import { buildCodeFrame } from "./build-code-frame.js"; diff --git a/packages/react-doctor/src/cli/utils/render-inspect-result.ts b/packages/react-doctor/src/cli/utils/render-inspect-result.ts new file mode 100644 index 0000000000..6c428e2128 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/render-inspect-result.ts @@ -0,0 +1,436 @@ +import { performance } from "node:perf_hooks"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { filterDiagnosticsForSurface } from "../../core/core-diagnostic-semantics.js"; +import { highlighter } from "../../core/core-presentation.js"; +import { buildSkippedChecks } from "../../core/core-reporting.js"; +import type { Diagnostic, InspectResult, ScoreResult } from "../../core/core-types.js"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { buildInspectResult } from "./build-inspect-result.js"; +import { buildNoScoreMessage } from "./build-no-score-message.js"; +import { recordRunEvent } from "./build-run-event.js"; +import { computeProjectedScore } from "./compute-score-projection.js"; +import { countDeadlineSkippedFiles } from "./count-deadline-skipped-files.js"; +import { countDroppedLintFiles } from "./count-dropped-lint-files.js"; +import { buildRulePriorityMap } from "./diagnostic-grouping.js"; +import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; +import { isCodingAgentEnvironment } from "./is-ci-environment.js"; +import { makeNoopConsole } from "./noop-console.js"; +import { canAnimateOnboarding, onboardingSectionPause } from "./onboarding-pacing.js"; +import { recordScanMetrics } from "./record-scan-metrics.js"; +import { printAgentGuidance } from "./render-agent-guidance.js"; +import { printDiagnostics } from "./render-diagnostics.js"; +import { printProjectDetection } from "./render-project-detection.js"; +import { + printBrandingOnlyHeader, + printNoScoreHeader, + printScoreHeader, +} from "./render-score-header.js"; +import { printDiagnosticsDump, printFooter, printSummary } from "./render-summary.js"; +import type { CachedScanPayload } from "./scan-result-cache.js"; +import { resolveWorkerTelemetry } from "./resolve-worker-telemetry.js"; +import { shouldRenderHyperlinks } from "./should-render-hyperlinks.js"; +import { shouldShowShareLink } from "./should-show-share-link.js"; +import type { SentryRootSpan } from "./with-sentry-run-span.js"; + +interface FinalizeInput { + readonly options: ResolvedInspectOptions; + readonly elapsedMilliseconds: number; + readonly diagnostics: ReadonlyArray; + readonly score: ScoreResult | null; + readonly project: InspectResult["project"]; + readonly userConfig: ReactDoctorConfig | null; + readonly didLintFail: boolean; + readonly lintFailureReason: string | null; + readonly lintPartialFailures: ReadonlyArray; + readonly didDeadCodeFail: boolean; + readonly deadCodeFailureReason: string | null; + readonly supplyChainOverlapTimedOut: boolean; + readonly securityScanFailed: boolean; + readonly directory: string; + readonly scannedFileCount: number; + readonly scannedFilePaths: ReadonlyArray; + readonly analyzedFiles: ReadonlyArray; + readonly scanElapsedMilliseconds: number; + readonly lintCacheHitFileCount: number | null; + readonly lintCacheTotalFileCount: number | null; + readonly lintSidecarReplayedFileCount: number | null; + readonly lintSidecarTotalFileCount: number | null; + readonly deadCodeCacheHit: boolean | null; + readonly deadCodeSummaryCacheHits: number | null; + readonly deadCodeSummaryCacheMisses: number | null; + readonly baselineDelta: InspectResult["baselineDelta"]; +} + +export interface RenderCachedProjectDetectionInput { + readonly payload: CachedScanPayload; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly isDiffMode: boolean; +} + +export interface RenderAndRecordScanInput { + readonly payload: CachedScanPayload; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly hasCustomConfig: boolean; + readonly startTime: number; + readonly rootSentrySpan: SentryRootSpan; + readonly scanMode: "full" | "diff" | "baseline"; + readonly baselineDegraded: boolean; + readonly wholeRepoCacheHit: boolean; + readonly lintCacheHitFileCount?: number | null; + readonly lintCacheTotalFileCount?: number | null; + readonly lintSidecarReplayedFileCount?: number | null; + readonly lintSidecarTotalFileCount?: number | null; + readonly deadCodeCacheHit?: boolean | null; + readonly deadCodeSummaryCacheHits?: number | null; + readonly deadCodeSummaryCacheMisses?: number | null; +} + +const formatCategorySelection = (categoryFilters: ReadonlySet): string => + [...categoryFilters].join(", "); + +const deriveScope = (options: ResolvedInspectOptions): string => { + if (options.baseline) return "changed"; + if (options.changedLineRanges !== null) return "lines"; + return options.includePaths.length > 0 ? "files" : "full"; +}; + +export const buildRunEventConfig = ( + options: ResolvedInspectOptions, + userConfig: ReactDoctorConfig | null, + hasCustomConfig: boolean, + resolvedWorkerCount?: number, +) => { + const { workerCount, parallel } = resolveWorkerTelemetry( + resolvedWorkerCount, + options.concurrency, + ); + return { + scope: deriveScope(options), + parallel, + workerCount, + maxDurationMs: options.maxDurationMs, + lint: options.lint, + deadCode: options.deadCode, + supplyChain: options.supplyChain, + scoreOnly: options.scoreOnly, + noScore: options.noScore, + respectInlineDisables: options.respectInlineDisables, + showWarnings: options.warnings, + usedOutputDir: options.outputDirectory !== null, + ignoredTagCount: options.ignoredTags.size, + hasCustomConfig, + userConfig, + }; +}; + +export const silentConsole = makeNoopConsole(); + +const runMaybeSilent = ( + effect: Effect.Effect, + silent: boolean, +): Effect.Effect => + silent ? effect.pipe(Effect.provideService(Console.Console, silentConsole)) : effect; + +export const renderCachedProjectDetection = async ( + input: RenderCachedProjectDetectionInput, +): Promise => { + if (input.options.scoreOnly || input.options.suppressRendering) return; + await Effect.runPromise( + runMaybeSilent( + printProjectDetection({ + projectInfo: input.payload.project, + userConfig: input.userConfig, + isDiffMode: input.isDiffMode, + includePaths: input.options.includePaths, + lintSourceFileCount: input.payload.scannedFileCount, + }), + input.options.silent, + ), + ); +}; + +export const renderAndRecordScan = async ( + input: RenderAndRecordScanInput, +): Promise => { + const finalizeInput: FinalizeInput = { + options: input.options, + elapsedMilliseconds: performance.now() - input.startTime, + diagnostics: input.payload.diagnostics, + score: input.payload.score, + project: input.payload.project, + userConfig: input.payload.userConfig, + didLintFail: input.payload.didLintFail, + lintFailureReason: input.payload.lintFailureReason, + lintPartialFailures: input.payload.lintPartialFailures, + didDeadCodeFail: input.payload.didDeadCodeFail, + deadCodeFailureReason: input.payload.deadCodeFailureReason, + supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut, + securityScanFailed: input.payload.securityScanFailed ?? false, + directory: input.payload.directory, + scannedFileCount: input.payload.scannedFileCount, + scannedFilePaths: input.payload.scannedFilePaths, + analyzedFiles: input.payload.analyzedFiles ?? [], + scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds, + lintCacheHitFileCount: input.lintCacheHitFileCount ?? null, + lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null, + lintSidecarReplayedFileCount: input.lintSidecarReplayedFileCount ?? null, + lintSidecarTotalFileCount: input.lintSidecarTotalFileCount ?? null, + deadCodeCacheHit: input.deadCodeCacheHit ?? null, + deadCodeSummaryCacheHits: input.deadCodeSummaryCacheHits ?? null, + deadCodeSummaryCacheMisses: input.deadCodeSummaryCacheMisses ?? null, + baselineDelta: input.payload.baselineDelta, + }; + const result = await Effect.runPromise( + runMaybeSilent(finalizeAndRender(finalizeInput), input.options.silent), + ); + const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry( + input.payload.scanConcurrency, + input.options.concurrency, + ); + recordScanMetrics({ + result, + mode: input.scanMode, + baselineDegraded: input.baselineDegraded, + parallel, + workerCount: resolvedWorkerCount, + lint: input.options.lint, + deadCode: input.options.deadCode, + scoreOnly: input.options.scoreOnly, + noScore: input.options.noScore, + didLintFail: input.payload.didLintFail, + lintFailureReasonKind: input.payload.lintFailureReasonKind, + didDeadCodeFail: input.payload.didDeadCodeFail, + userConfig: input.userConfig, + suppressedRuleCounts: input.payload.suppressedRuleCounts, + }); + recordRunEvent(input.rootSentrySpan, { + ...buildRunEventConfig( + input.options, + input.userConfig, + input.hasCustomConfig, + resolvedWorkerCount, + ), + result, + mode: input.scanMode, + gateExempt: input.baselineDegraded, + wholeRepoCacheHit: input.wholeRepoCacheHit, + didLintFail: input.payload.didLintFail, + lintFailureReasonKind: input.payload.lintFailureReasonKind, + lintPartialFailureCount: input.payload.lintPartialFailures.length, + lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures), + lintDeadlineSkippedFileCount: countDeadlineSkippedFiles(input.payload.lintPartialFailures), + didDeadCodeFail: input.payload.didDeadCodeFail, + supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut, + securityScanFailed: input.payload.securityScanFailed, + deadCodeOverlapped: input.payload.deadCodeOverlapped, + suppressedRuleCounts: input.payload.suppressedRuleCounts, + }); + return result; +}; + +const finalizeAndRender = (input: FinalizeInput): Effect.Effect => + Effect.gen(function* () { + const { + options, + elapsedMilliseconds, + diagnostics, + score, + project, + userConfig, + didLintFail, + lintFailureReason, + lintPartialFailures, + didDeadCodeFail, + deadCodeFailureReason, + supplyChainOverlapTimedOut, + securityScanFailed, + directory, + scannedFileCount, + scannedFilePaths, + analyzedFiles, + scanElapsedMilliseconds, + lintCacheHitFileCount, + lintCacheTotalFileCount, + lintSidecarReplayedFileCount, + lintSidecarTotalFileCount, + deadCodeCacheHit, + deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses, + baselineDelta, + } = input; + + const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ + didLintFail, + lintFailureReason, + lintPartialFailures, + didDeadCodeFail, + deadCodeFailureReason, + supplyChainOverlapTimedOut, + securityScanFailed, + }); + const hasSkippedChecks = skippedChecks.length > 0; + const noScoreMessage = buildNoScoreMessage(options.noScore, options.scoreDisabledMessage); + + const buildResult = (): InspectResult => + buildInspectResult({ + diagnostics, + score, + skippedChecks, + skippedCheckReasons, + project, + elapsedMilliseconds, + scannedFileCount, + scannedFilePaths, + analyzedFiles, + scanElapsedMilliseconds, + lintCacheHitFileCount, + lintCacheTotalFileCount, + lintSidecarReplayedFileCount, + lintSidecarTotalFileCount, + deadCodeCacheHit, + deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses, + baselineDelta, + }); + + if (options.suppressRendering) return buildResult(); + + const surfaceDiagnostics = filterDiagnosticsForSurface( + [...diagnostics], + options.outputSurface, + userConfig, + ); + const printedDiagnostics = filterDiagnosticsByCategories( + surfaceDiagnostics, + options.categoryFilters, + ); + + if (options.scoreOnly) { + if (options.outputDirectory !== null) { + yield* printDiagnosticsDump(printedDiagnostics, options.outputDirectory, false, "stderr"); + } + if (score) yield* Console.log(`${score.score}`); + else yield* Console.error(highlighter.gray(noScoreMessage)); + return buildResult(); + } + + const animateRender = + !options.silent && !options.verbose && canAnimateOnboarding(process.stdout); + const pause = onboardingSectionPause(animateRender); + const useHyperlinks = shouldRenderHyperlinks(process.stdout); + const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length; + const isDiffMode = options.includePaths.length > 0; + const lintSourceFileCount = isDiffMode ? options.includePaths.length : project.sourceFileCount; + + if (printedDiagnostics.length === 0) { + yield* pause; + if (hasSkippedChecks) { + const skippedLabel = skippedChecks.join(" and "); + yield* Console.warn( + highlighter.warn( + `No issues detected, but ${skippedLabel} checks failed — results are incomplete.`, + ), + ); + } else if (options.categoryFilters.size > 0) { + yield* Console.log( + highlighter.success( + `No issues found in category ${formatCategorySelection(options.categoryFilters)}!`, + ), + ); + } else if (demotedDiagnosticCount > 0) { + yield* Console.log( + highlighter.success( + `No issues found! (${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface — see config.surfaces.)`, + ), + ); + } else { + yield* Console.log(highlighter.success("No issues found!")); + } + yield* Console.log(""); + yield* pause; + if (hasSkippedChecks) { + yield* printBrandingOnlyHeader; + yield* Console.log(highlighter.gray(" Score not shown — some checks could not complete.")); + } else if (score) { + yield* printScoreHeader(score); + } else { + yield* printNoScoreHeader(noScoreMessage); + } + if (options.outputDirectory !== null) { + yield* printDiagnosticsDump(printedDiagnostics, options.outputDirectory); + } + return buildResult(); + } + + yield* pause; + yield* Console.log(""); + yield* printDiagnostics( + [...printedDiagnostics], + options.verbose, + directory, + buildRulePriorityMap([score]), + isCodingAgentEnvironment(), + { sectionPause: pause, animateCountUp: animateRender }, + useHyperlinks, + ); + if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") { + yield* printAgentGuidance(); + } + + if (options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) { + yield* Console.log( + highlighter.gray( + ` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`, + ), + ); + yield* Console.log(""); + } + + const scoreDiagnostics = filterDiagnosticsForSurface([...diagnostics], "score", userConfig); + const displayedScoreDiagnostics = filterDiagnosticsForSurface( + [...printedDiagnostics], + "score", + userConfig, + ); + const potentialScore = score + ? yield* Effect.promise(() => + computeProjectedScore(displayedScoreDiagnostics, scoreDiagnostics, score), + ) + : null; + + const showShareLink = shouldShowShareLink(options); + yield* pause; + yield* printSummary({ + diagnostics: [...printedDiagnostics], + elapsedMilliseconds, + scoreResult: score, + potentialScore, + totalSourceFileCount: lintSourceFileCount, + noScoreMessage, + verbose: options.verbose, + outputDirectory: options.outputDirectory, + animateProjection: animateRender, + }); + + if (hasSkippedChecks) { + const skippedLabel = skippedChecks.join(" and "); + yield* Console.log(""); + yield* Console.warn( + highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`), + ); + } + + yield* pause; + yield* printFooter({ + diagnostics: [...printedDiagnostics], + scoreResult: score, + projectName: project.projectName, + isOffline: !showShareLink, + }); + + return buildResult(); + }); diff --git a/packages/react-doctor/src/cli/utils/render-summary.ts b/packages/react-doctor/src/cli/utils/render-summary.ts index 25ba815d97..43583b6b1b 100644 --- a/packages/react-doctor/src/cli/utils/render-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-summary.ts @@ -1,13 +1,9 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { - CANONICAL_GITHUB_URL, - DOCS_URL, - highlighter, - SHARE_BASE_URL, - TOP_ERRORS_DISPLAY_COUNT, -} from "@react-doctor/core"; -import type { Diagnostic, ScoreResult } from "@react-doctor/core"; +import { highlighter } from "../../core/core-presentation.js"; +import { CANONICAL_GITHUB_URL, DOCS_URL, SHARE_BASE_URL } from "../../core/core-product.js"; +import { TOP_ERRORS_DISPLAY_COUNT } from "../../core/core-score.js"; +import type { Diagnostic, ScoreResult } from "../../core/core-types.js"; import { buildSectionDivider } from "./build-section-divider.js"; import { colorizeByScore } from "./colorize-by-score.js"; import { SCORE_PROJECTION_BAR_ROWS_ABOVE_CURSOR } from "./constants.js"; diff --git a/packages/react-doctor/src/cli/utils/resolve-baseline-comparison.ts b/packages/react-doctor/src/cli/utils/resolve-baseline-comparison.ts new file mode 100644 index 0000000000..37fba20b62 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/resolve-baseline-comparison.ts @@ -0,0 +1,204 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { + PerFileLintCacheEnabled, + runInspect as runInspectEffect, + SidecarLintCacheEnabled, +} from "../../core/core-runtime.js"; +import { computeDiagnosticDelta } from "../../core/core-diagnostic-semantics.js"; +import { restoreLegacyThrow } from "../../core/core-errors.js"; +import { filterSourceFiles } from "../../core/core-project-discovery.js"; +import type { Diagnostic, InspectResult, ProjectInfo, WorkerSlots } from "../../core/core-types.js"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { buildRuntimeLayers } from "./build-runtime-layers.js"; +import { BASELINE_FILES_TEMP_DIR_PREFIX } from "./constants.js"; +import { countDeadlineSkippedFiles } from "./count-deadline-skipped-files.js"; +import { countDroppedLintFiles } from "./count-dropped-lint-files.js"; +import { filterDiagnosticsByChangedLines } from "./filter-diagnostics-by-changed-lines.js"; +import { materializeBaselineFiles } from "./materialize-baseline-files.js"; +import { toForwardSlashes } from "./path-format.js"; +import { createDiagnosticEvidenceReader } from "./read-diagnostic-evidence.js"; +import { createSourceLineReader } from "./read-source-line.js"; +import { getRunId } from "./run-id.js"; +import { VERSION } from "./version.js"; + +interface BaselineComparison { + readonly displayDiagnostics: ReadonlyArray; + readonly baselineDelta: NonNullable; +} + +export interface OxlintInvocationRuntime { + readonly concurrency: number; + readonly spawnSlots: WorkerSlots; +} + +export interface ResolveBaselineComparisonInput { + readonly directory: string; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly configSourceDirectory: string | null; + readonly headProjectInfo: ProjectInfo; + readonly headDiagnostics: ReadonlyArray; + readonly headAnalyzedFiles: ReadonlyArray; + readonly didLintFail: boolean; + readonly lintPartialFailures: ReadonlyArray; + readonly resolvedNodeBinaryPath: string | null; + readonly deadlineEpochMs: number | null; + readonly oxlintRuntime: OxlintInvocationRuntime; + readonly silentConsole: Console.Console; +} + +interface ResolvedBaselineComparison { + readonly displayDiagnostics: ReadonlyArray; + readonly baselineDelta: InspectResult["baselineDelta"]; +} + +const countIncompleteLintFiles = (lintPartialFailures: ReadonlyArray): number => + countDroppedLintFiles(lintPartialFailures) + countDeadlineSkippedFiles(lintPartialFailures); + +const runBaselineComparison = async ( + input: ResolveBaselineComparisonInput, +): Promise => { + const baseline = input.options.baseline; + if (baseline === null) return null; + + const tempDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX)); + const snapshot = await materializeBaselineFiles({ + directory: input.directory, + ref: baseline.ref, + files: input.options.includePaths, + baseFiles: baseline.baseFiles, + headFiles: baseline.headFiles, + tempDirectory, + }).catch((error: unknown) => { + rmSync(tempDirectory, { recursive: true, force: true }); + throw error; + }); + if (snapshot === null) { + rmSync(tempDirectory, { recursive: true, force: true }); + return null; + } + try { + if (!snapshot.isComplete) return null; + const analyzedHeadFiles = new Set(input.headAnalyzedFiles.map(toForwardSlashes)); + const baseFiles = new Set(snapshot.baseFiles.map(toForwardSlashes)); + const trackedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes)); + const expectedHeadFiles = new Set(trackedHeadFiles); + for (const filePath of input.options.includePaths) { + const normalizedFilePath = toForwardSlashes(filePath); + if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath); + } + if ( + filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath)) + ) { + return null; + } + const baseLayers = buildRuntimeLayers({ + directory: snapshot.tempDirectory, + hasConfigOverride: true, + userConfig: input.userConfig, + configSourceDirectory: input.configSourceDirectory, + projectInfoOverride: input.headProjectInfo, + shouldSkipLint: !input.options.lint || !input.resolvedNodeBinaryPath, + shouldRunDeadCode: false, + shouldRunSupplyChain: input.options.supplyChain, + shouldComputeScore: false, + shouldShowProgressSpinners: false, + oxlintConcurrency: input.oxlintRuntime.concurrency, + oxlintSpawnSlots: input.oxlintRuntime.spawnSlots, + }); + const baseProgram = runInspectEffect( + { + directory: snapshot.tempDirectory, + includePaths: snapshot.materializedFiles, + customRulesOnly: input.options.customRulesOnly, + respectInlineDisables: input.options.respectInlineDisables, + warnings: input.options.warnings, + adoptExistingLintConfig: input.options.adoptExistingLintConfig, + ignoredTags: input.options.ignoredTags, + includedTags: input.options.includedTags, + includeTagDefaults: input.options.includeTagDefaults, + nodeBinaryPath: input.resolvedNodeBinaryPath ?? undefined, + runDeadCode: false, + isCi: input.options.isCi, + doctorVersion: VERSION, + runId: getRunId(), + resolveLocalGithubViewerPermission: false, + suppressScanSummary: true, + supplyChainManifestChanged: input.options.supplyChainManifestChanged, + deadlineEpochMs: input.deadlineEpochMs ?? undefined, + }, + {}, + ); + const baseOutput = await Effect.runPromise( + restoreLegacyThrow( + baseProgram.pipe( + Effect.provide(baseLayers), + Effect.provideService(PerFileLintCacheEnabled, false), + Effect.provideService(SidecarLintCacheEnabled, false), + Effect.provideService(Console.Console, input.silentConsole), + ), + ), + ); + if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) { + return null; + } + const hasUnscannedUntrackedSourceFiles = filterSourceFiles( + snapshot.untrackedFiles.map(toForwardSlashes), + ).some((filePath) => !analyzedHeadFiles.has(filePath)); + const delta = computeDiagnosticDelta({ + headDiagnostics: input.headDiagnostics, + baseDiagnostics: baseOutput.diagnostics, + readHeadLine: createSourceLineReader(input.directory), + readBaseLine: createSourceLineReader(snapshot.tempDirectory), + readHeadEvidence: createDiagnosticEvidenceReader(input.directory, { + resolveForwardedHandlers: true, + }), + readBaseEvidence: createDiagnosticEvidenceReader(snapshot.tempDirectory), + }); + return { + displayDiagnostics: delta.newDiagnostics, + baselineDelta: { + baseRef: baseline.ref, + fixedCount: hasUnscannedUntrackedSourceFiles ? 0 : delta.fixedCount, + baseTotalCount: baseOutput.diagnostics.length, + crossFileMatchCount: delta.crossFileMatchCount, + }, + }; + } finally { + snapshot.cleanup(); + } +}; + +export const resolveBaselineComparison = async ( + input: ResolveBaselineComparisonInput, +): Promise => { + const isDiffMode = input.options.includePaths.length > 0; + if ( + input.options.baseline !== null && + isDiffMode && + !input.didLintFail && + countIncompleteLintFiles(input.lintPartialFailures) === 0 + ) { + const comparison = await runBaselineComparison(input); + if (comparison !== null) return comparison; + } else if (input.options.changedLineRanges !== null && isDiffMode) { + return { + displayDiagnostics: filterDiagnosticsByChangedLines({ + directory: input.directory, + diagnostics: input.headDiagnostics, + changedLineRanges: input.options.changedLineRanges, + }), + baselineDelta: undefined, + }; + } + + return { + displayDiagnostics: input.headDiagnostics, + baselineDelta: undefined, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts b/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts new file mode 100644 index 0000000000..667d729622 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts @@ -0,0 +1,70 @@ +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "../../inspect-options.js"; +import { DEFAULT_SHOW_WARNINGS } from "../../core/core-configuration.js"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { resolveCliCategories } from "./resolve-cli-categories.js"; + +export interface InspectEnvironment { + readonly isCiOrCodingAgentEnvironment: boolean; + readonly isNonInteractiveEnvironment: boolean; +} + +interface ResolveInspectOptionsInput { + readonly inputOptions: ReactDoctorInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly environment: InspectEnvironment; +} + +const buildIgnoredTags = ( + userConfig: ReactDoctorConfig | null, + includedTags: ReadonlySet, +): ReadonlySet => { + const ignoredTags = new Set(); + if (userConfig?.ignore?.tags) { + for (const tag of userConfig.ignore.tags) ignoredTags.add(tag); + } + for (const tag of includedTags) ignoredTags.delete(tag); + return ignoredTags; +}; + +export const resolveInspectOptions = ({ + inputOptions, + userConfig, + environment, +}: ResolveInspectOptionsInput): ResolvedInspectOptions => { + const includedTags = inputOptions.includedTags ?? new Set(); + return { + lint: inputOptions.lint ?? userConfig?.lint ?? true, + deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, + supplyChain: inputOptions.supplyChain ?? userConfig?.supplyChain?.enabled ?? true, + verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, + outputDirectory: inputOptions.outputDirectory || null, + scoreOnly: inputOptions.scoreOnly ?? false, + noScore: inputOptions.noScore ?? userConfig?.noScore ?? false, + isCi: inputOptions.isCi ?? false, + isCiOrCodingAgentEnvironment: environment.isCiOrCodingAgentEnvironment, + isNonInteractiveEnvironment: environment.isNonInteractiveEnvironment, + silent: inputOptions.silent ?? false, + includePaths: inputOptions.includePaths ?? [], + customRulesOnly: includedTags.size > 0 ? false : (userConfig?.customRulesOnly ?? false), + share: userConfig?.share ?? true, + respectInlineDisables: + inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true, + warnings: inputOptions.warnings ?? userConfig?.warnings ?? DEFAULT_SHOW_WARNINGS, + categoryFilters: new Set(resolveCliCategories(inputOptions.categoryFilters) ?? []), + adoptExistingLintConfig: + includedTags.size > 0 ? false : (userConfig?.adoptExistingLintConfig ?? true), + ignoredTags: buildIgnoredTags(userConfig, includedTags), + includedTags, + includeTagDefaults: inputOptions.includeTagDefaults ?? false, + scoreDisabledMessage: inputOptions.scoreDisabledMessage, + outputSurface: inputOptions.outputSurface ?? "cli", + suppressRendering: (inputOptions.suppressRendering ?? false) || inputOptions.uiLayers != null, + uiLayers: inputOptions.uiLayers ?? null, + concurrentScan: inputOptions.concurrentScan ?? false, + concurrency: inputOptions.concurrency, + maxDurationMs: inputOptions.maxDurationMs ?? null, + baseline: inputOptions.baseline ?? null, + changedLineRanges: inputOptions.changedLineRanges ?? null, + supplyChainManifestChanged: inputOptions.supplyChainManifestChanged ?? false, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/rule-config-file.ts b/packages/react-doctor/src/cli/utils/rule-config-file.ts index 7f15f0562c..8dc54117b0 100644 --- a/packages/react-doctor/src/cli/utils/rule-config-file.ts +++ b/packages/react-doctor/src/cli/utils/rule-config-file.ts @@ -3,13 +3,14 @@ import { generateCode, loadFile, writeFile } from "magicast"; import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers"; import * as fs from "node:fs"; import { - CONFIG_SCHEMA_URL, - LEGACY_CONFIG_FILENAME, clearConfigCache, - isPlainObject, + LEGACY_CONFIG_FILENAME, loadConfigWithSource, -} from "@react-doctor/core"; -import type { ReactDoctorConfig, ReactDoctorConfigFormat } from "@react-doctor/core"; +} from "../../core/core-configuration.js"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import { isPlainObject } from "../../core/core-primitives.js"; +import { CONFIG_SCHEMA_URL } from "../../core/core-product.js"; +import type { ReactDoctorConfigFormat } from "../../core/core-types.js"; import { readObjectFile } from "./read-object-file.js"; const NEW_CONFIG_FILENAME = "doctor.config.json"; diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache-lifecycle.ts b/packages/react-doctor/src/cli/utils/scan-result-cache-lifecycle.ts new file mode 100644 index 0000000000..d8dd804db6 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/scan-result-cache-lifecycle.ts @@ -0,0 +1,144 @@ +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; +import type { InspectResult } from "../../core/core-types.js"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { METRIC } from "./constants.js"; +import { recordCount } from "./record-metric.js"; +import { + buildScanResultCacheKey, + createScanResultCache, + shouldStoreScanPayload, + type CachedScanPayload, +} from "./scan-result-cache.js"; +import { buildScanResultCachePolicy } from "./scan-result-cache-policy.js"; +import type { + RenderAndRecordScanInput, + RenderCachedProjectDetectionInput, +} from "./render-inspect-result.js"; +export type { + RenderAndRecordScanInput, + RenderCachedProjectDetectionInput, +} from "./render-inspect-result.js"; +import type { SentryRootSpan } from "./with-sentry-run-span.js"; +import { recordSentryProjectContext } from "./with-sentry-run-span.js"; +import { VERSION } from "./version.js"; + +export interface CreateScanResultCacheLifecycleInput { + readonly directory: string; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly hasConfigOverride: boolean; + readonly configSourceDirectory: string | null; + readonly resolvedNodeBinaryPath: string | null; + readonly startTime: number; + readonly rootSentrySpan: SentryRootSpan; + readonly renderCachedProjectDetection: ( + input: RenderCachedProjectDetectionInput, + ) => Promise; + readonly renderAndRecordScan: (input: RenderAndRecordScanInput) => Promise; + readonly recordOnboardingCompletion: (options: ResolvedInspectOptions) => void; +} + +export interface CompleteScanResultCacheInput { + readonly payload: CachedScanPayload; + readonly scanMode: RenderAndRecordScanInput["scanMode"]; + readonly baselineDegraded: boolean; + readonly lintCacheHitFileCount: number | null; + readonly lintCacheTotalFileCount: number | null; + readonly lintSidecarReplayedFileCount: number | null; + readonly lintSidecarTotalFileCount: number | null; + readonly deadCodeCacheHit: boolean | null; + readonly deadCodeSummaryCacheHits: number | null; + readonly deadCodeSummaryCacheMisses: number | null; +} + +export interface ScanResultCacheLifecycle { + // A miss is synchronous so cold scans do not yield before runtime construction. + readonly replay: () => Promise | null; + readonly complete: (input: CompleteScanResultCacheInput) => Promise; +} + +export const createScanResultCacheLifecycle = ( + input: CreateScanResultCacheLifecycleInput, +): ScanResultCacheLifecycle => { + const cacheKey = buildScanResultCacheKey({ + projectDirectory: input.directory, + version: VERSION, + nodeBinaryPath: input.resolvedNodeBinaryPath, + policy: buildScanResultCachePolicy(input.options), + userConfig: input.userConfig, + hasConfigOverride: input.hasConfigOverride, + configSourceDirectory: input.configSourceDirectory, + }); + const scanResultCache = cacheKey === null ? null : createScanResultCache(input.directory); + const cachedPayload = cacheKey === null ? null : (scanResultCache?.lookup(cacheKey) ?? null); + + return { + replay: () => { + if (cachedPayload === null) return null; + + return (async () => { + const isDiffMode = input.options.includePaths.length > 0; + recordSentryProjectContext(cachedPayload.project, input.rootSentrySpan, { + concurrentScan: input.options.concurrentScan, + }); + recordCount(METRIC.projectDetected, 1); + await input.renderCachedProjectDetection({ + payload: cachedPayload, + options: input.options, + userConfig: input.userConfig, + isDiffMode, + }); + const baselineDegraded = + Boolean(input.options.baseline) && + isDiffMode && + cachedPayload.baselineDelta === undefined; + let scanMode: RenderAndRecordScanInput["scanMode"] = "full"; + if (cachedPayload.baselineDelta) scanMode = "baseline"; + else if (isDiffMode) scanMode = "diff"; + const result = await input.renderAndRecordScan({ + payload: cachedPayload, + options: input.options, + userConfig: input.userConfig, + hasCustomConfig: input.userConfig !== null, + startTime: input.startTime, + rootSentrySpan: input.rootSentrySpan, + scanMode, + baselineDegraded, + wholeRepoCacheHit: true, + }); + input.recordOnboardingCompletion(input.options); + return result; + })(); + }, + complete: async (completion) => { + if ( + cacheKey !== null && + scanResultCache !== null && + shouldStoreScanPayload(completion.payload) && + !completion.baselineDegraded + ) { + scanResultCache.store(cacheKey, completion.payload); + } + const result = await input.renderAndRecordScan({ + payload: completion.payload, + options: input.options, + userConfig: input.userConfig, + hasCustomConfig: input.userConfig !== null, + startTime: input.startTime, + rootSentrySpan: input.rootSentrySpan, + scanMode: completion.scanMode, + baselineDegraded: completion.baselineDegraded, + wholeRepoCacheHit: false, + lintCacheHitFileCount: completion.lintCacheHitFileCount, + lintCacheTotalFileCount: completion.lintCacheTotalFileCount, + lintSidecarReplayedFileCount: completion.lintSidecarReplayedFileCount, + lintSidecarTotalFileCount: completion.lintSidecarTotalFileCount, + deadCodeCacheHit: completion.deadCodeCacheHit, + deadCodeSummaryCacheHits: completion.deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses: completion.deadCodeSummaryCacheMisses, + }); + input.recordOnboardingCompletion(input.options); + return result; + }, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache-policy.ts b/packages/react-doctor/src/cli/utils/scan-result-cache-policy.ts new file mode 100644 index 0000000000..cca45caeb9 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/scan-result-cache-policy.ts @@ -0,0 +1,46 @@ +import type { ChangedFileLineRanges } from "../../core/core-types.js"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; + +export interface ScanResultCachePolicy { + readonly lint: boolean; + readonly deadCode: boolean; + readonly supplyChain: boolean; + readonly includePaths: ReadonlyArray; + readonly customRulesOnly: boolean; + readonly respectInlineDisables: boolean; + readonly warnings: boolean; + readonly adoptExistingLintConfig: boolean; + readonly ignoredTags: ReadonlySet; + readonly includedTags: ReadonlySet; + readonly includeTagDefaults: boolean; + readonly concurrency: number | undefined; + readonly baselineRef: string | undefined; + readonly changedLineRanges: ReadonlyArray | null; + readonly noScore: boolean; + readonly isCi: boolean; + readonly suppressRendering: boolean; + readonly supplyChainManifestChanged: boolean; +} + +export const buildScanResultCachePolicy = ( + options: ResolvedInspectOptions, +): ScanResultCachePolicy => ({ + lint: options.lint, + deadCode: options.deadCode, + supplyChain: options.supplyChain, + includePaths: options.includePaths, + customRulesOnly: options.customRulesOnly, + respectInlineDisables: options.respectInlineDisables, + warnings: options.warnings, + adoptExistingLintConfig: options.adoptExistingLintConfig, + ignoredTags: options.ignoredTags, + includedTags: options.includedTags, + includeTagDefaults: options.includeTagDefaults, + concurrency: options.concurrency, + baselineRef: options.baseline?.ref, + changedLineRanges: options.changedLineRanges, + noScore: options.noScore, + isCi: options.isCi, + suppressRendering: options.suppressRendering, + supplyChainManifestChanged: options.supplyChainManifestChanged, +}); diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache.ts b/packages/react-doctor/src/cli/utils/scan-result-cache.ts index 21d45747d2..6e9251e6d2 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache.ts @@ -7,15 +7,15 @@ import { hashFileContents, resolveLintBatchOrdering, resolveReactDoctorCacheDir, -} from "@react-doctor/core"; +} from "../../core/core-scan-cache.js"; +import type { ReactDoctorConfig } from "../../core/core-configuration.js"; import type { Diagnostic, InspectOutput, InspectResult, - ReactDoctorConfig, ScoreResult, SuppressedRuleCount, -} from "@react-doctor/core"; +} from "../../core/core-types.js"; import { SCAN_RESULT_CACHE_FILENAME, SCAN_RESULT_CACHE_MAX_DIRTY_STATUS_ENTRY_COUNT, @@ -24,7 +24,7 @@ import { SCAN_RESULT_CACHE_SCHEMA_VERSION, } from "./constants.js"; import { getPackageJsonPath, isRecord, runGit } from "./git-hook-shared.js"; -import type { ResolvedInspectOptions } from "../../inspect.js"; +import type { ScanResultCachePolicy } from "./scan-result-cache-policy.js"; export interface CachedScanPayload { readonly diagnostics: ReadonlyArray; @@ -94,7 +94,7 @@ interface ScanResultCacheKeyInput { readonly projectDirectory: string; readonly version: string; readonly nodeBinaryPath: string | null; - readonly options: ResolvedInspectOptions; + readonly policy: ScanResultCachePolicy; readonly userConfig: ReactDoctorConfig | null; readonly hasConfigOverride: boolean; readonly configSourceDirectory: string | null; @@ -434,35 +434,35 @@ export const buildScanResultCacheKey = (input: ScanResultCacheKeyInput): string configSourceDirectory: input.configSourceDirectory, userConfig: input.userConfig, engineOptions: { - lint: input.options.lint, - deadCode: input.options.deadCode, + lint: input.policy.lint, + deadCode: input.policy.deadCode, // Resolved supply-chain enablement (the `--supply-chain` flag over // config). Keyed here — not just via `userConfig` — because the flag can // flip it without touching the config blob, so a `--no-supply-chain` // lookup must not serve a supply-chain-on payload at the same commit. - supplyChain: input.options.supplyChain, - includePaths: [...input.options.includePaths].sort(), - customRulesOnly: input.options.customRulesOnly, - respectInlineDisables: input.options.respectInlineDisables, - warnings: input.options.warnings, - adoptExistingLintConfig: input.options.adoptExistingLintConfig, - ignoredTags: [...input.options.ignoredTags].sort(), - includedTags: [...input.options.includedTags].sort(), - includeTagDefaults: input.options.includeTagDefaults, - concurrency: input.options.concurrency, + supplyChain: input.policy.supplyChain, + includePaths: [...input.policy.includePaths].sort(), + customRulesOnly: input.policy.customRulesOnly, + respectInlineDisables: input.policy.respectInlineDisables, + warnings: input.policy.warnings, + adoptExistingLintConfig: input.policy.adoptExistingLintConfig, + ignoredTags: [...input.policy.ignoredTags].sort(), + includedTags: [...input.policy.includedTags].sort(), + includeTagDefaults: input.policy.includeTagDefaults, + concurrency: input.policy.concurrency, // Full-scan batch ordering can change which files trip the spawn // timeout and get dropped, so — like `concurrency` above — it must key // the cache: a `cost` run must not serve its payload to an `arrival` // lookup at the same commit. lintBatchOrdering: resolveLintBatchOrdering(), - baselineRef: input.options.baseline?.ref, + baselineRef: input.policy.baselineRef, // `null` (not a `lines` scope) and an omitted field hash identically, so a // non-lines lookup matches a non-lines store; only real ranges shift the key. - changedLineRanges: input.options.changedLineRanges ?? undefined, - noScore: input.options.noScore, - isCi: input.options.isCi, - suppressRendering: input.options.suppressRendering, - supplyChainManifestChanged: input.options.supplyChainManifestChanged, + changedLineRanges: input.policy.changedLineRanges ?? undefined, + noScore: input.policy.noScore, + isCi: input.policy.isCi, + suppressRendering: input.policy.suppressRendering, + supplyChainManifestChanged: input.policy.supplyChainManifestChanged, // `maxDurationMs` is deliberately NOT keyed. It only changes the RESULT // when the budget is hit, and every such truncated run (lint partial or // dead-code skipped) is barred from the cache by `shouldStoreScanPayload` diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index f6981c8a77..80e9652be0 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -1,13 +1,14 @@ import * as path from "node:path"; -import type { WorkspacePackage } from "@react-doctor/core"; +import type { WorkspacePackage } from "../../core/core-types.js"; import { + buildPackageGraph, discoverReactSubprojects, - highlighter, isDirectory, isFile, isMonorepoRoot, - listWorkspacePackages, -} from "@react-doctor/core"; + readPackageJson, +} from "../../core/core-project-discovery.js"; +import { highlighter } from "../../core/core-presentation.js"; import { cliLogger as logger } from "./cli-logger.js"; import { CliInputError } from "./cli-input-error.js"; import { METRIC } from "./constants.js"; @@ -15,12 +16,21 @@ import { prompts } from "./prompts.js"; import { recordCount } from "./record-metric.js"; export const discoverWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { - const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); - const packages = listWorkspacePackages(rootDirectory); - if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) { + const packageJsonPath = path.join(rootDirectory, "package.json"); + if (!isFile(packageJsonPath)) return discoverReactSubprojects(rootDirectory); + if (!isMonorepoRoot(rootDirectory)) return []; + + const packageGraph = buildPackageGraph(rootDirectory, readPackageJson(packageJsonPath)); + if (packageGraph.workspacePatterns.length === 0) { return discoverReactSubprojects(rootDirectory); } - return packages; + const workspacePackages = packageGraph.packages + .filter((packageNode) => packageNode.hasReactDependency) + .map((packageNode) => ({ + name: packageNode.name ?? path.basename(packageNode.directory), + directory: packageNode.isRoot ? rootDirectory : packageNode.directory, + })); + return workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory); }; export const selectProjects = async ( diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 3a26289f94..f6e52d891d 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -1,35 +1,42 @@ +import { defineConfig } from "./core/core-configuration.js"; +import type { ReactDoctorConfig } from "./core/core-configuration.js"; +import { summarizeDiagnostics } from "./core/core-diagnostic-semantics.js"; import { - buildJsonReport, - buildJsonReportError, - clearAutoSuppressionCaches, - clearConfigCache, - clearIgnorePatternsCache, - clearMinifiedFileCache, - clearPackageJsonCache, - clearPackageRoleCache, - clearProjectCache, -} from "@react-doctor/core"; + AmbiguousProjectError, + isProjectDiscoveryError, + isReactDoctorError, + NoReactDependencyError, + NotADirectoryError, + PackageJsonNotFoundError, + ProjectNotFoundError, + ReactDoctorError, +} from "./core/core-errors.js"; +import { filterSourceFiles, hasReactRuntime } from "./core/core-project-discovery.js"; +import { buildJsonReport, buildJsonReportError } from "./core/core-reporting.js"; +import { clearCoreCaches } from "./core/core-scan-cache.js"; +import { getDiffInfo } from "./core/core-version-control.js"; import type { - Diagnostic, - DiagnoseOptions, - DiagnoseProjectsInput, - DiagnoseProjectsResult, - DiagnoseResult, - DiffInfo, JsonReport, JsonReportDiffInfo, JsonReportError, JsonReportMode, JsonReportProjectEntry, JsonReportSummary, +} from "./core/core-reporting.js"; +import type { + Diagnostic, + DiagnoseOptions, + DiagnoseProjectsInput, + DiagnoseProjectsResult, + DiagnoseResult, + DiffInfo, ProjectDefinition, ProjectInfo, ProjectResult, ProjectResultError, ProjectResultOk, - ReactDoctorConfig, ScoreResult, -} from "@react-doctor/core"; +} from "./core/core-types.js"; export type { Diagnostic, @@ -53,45 +60,37 @@ export type { ScoreResult, }; export { - getDiffInfo, + AmbiguousProjectError, + buildJsonReport, + buildJsonReportError, filterSourceFiles, + getDiffInfo, + hasReactRuntime, + isProjectDiscoveryError, + isReactDoctorError, + NoReactDependencyError, + NotADirectoryError, + PackageJsonNotFoundError, + ProjectNotFoundError, + ReactDoctorError, summarizeDiagnostics, defineConfig, - hasReactRuntime, -} from "@react-doctor/core"; -export { buildJsonReport, buildJsonReportError }; +}; // `ReactDoctorError` is the tagged Schema class from // `@react-doctor/core`, used by the new Effect pipeline. // `isReactDoctorError` narrows to that tagged class. -// The four narrow errors below are still plain JS Error subclasses — +// The five narrow errors below are still plain JS Error subclasses — // they're thrown synchronously by `discoverProject` / // `resolveDiagnoseTarget` / `readPackageJson` BEFORE the Effect // runtime takes over, so callers can `try/catch` them without // Effect-aware machinery. -export { - ReactDoctorError, - ProjectNotFoundError, - NoReactDependencyError, - PackageJsonNotFoundError, - NotADirectoryError, - AmbiguousProjectError, - isReactDoctorError, - isProjectDiscoveryError, -} from "@react-doctor/core"; - // HACK: programmatic API consumers (watch-mode tools, test runners, // agentic CLI flows) call diagnose() repeatedly on the same directory. // project / config / package.json results are memoized at module scope // to keep CLI scans fast — this hook lets long-running consumers // invalidate when the underlying files change between calls. export const clearCaches = (): void => { - clearProjectCache(); - clearConfigCache(); - clearPackageJsonCache(); - clearIgnorePatternsCache(); - clearPackageRoleCache(); - clearAutoSuppressionCaches(); - clearMinifiedFileCache(); + clearCoreCaches(); }; interface ToJsonReportOptions { diff --git a/packages/react-doctor/src/inspect-options.ts b/packages/react-doctor/src/inspect-options.ts new file mode 100644 index 0000000000..13b3d84339 --- /dev/null +++ b/packages/react-doctor/src/inspect-options.ts @@ -0,0 +1,60 @@ +import type { + ChangedFileLineRanges, + DiagnosticSurface, + InspectOptions, + Progress, + Reporter, +} from "./core/core-types.js"; +import type * as Layer from "effect/Layer"; + +export interface InspectUiLayers { + readonly reporter: Layer.Layer; + readonly progress?: Layer.Layer; +} + +export interface ReactDoctorInspectOptions extends InspectOptions { + categoryFilters?: string[]; + includedTags?: ReadonlySet; + includeTagDefaults?: boolean; + scoreDisabledMessage?: string; + deadlineEpochMs?: number; + uiLayers?: InspectUiLayers; +} + +export interface ResolvedInspectOptions { + lint: boolean; + deadCode: boolean; + supplyChain: boolean; + verbose: boolean; + outputDirectory: string | null; + scoreOnly: boolean; + noScore: boolean; + isCi: boolean; + isCiOrCodingAgentEnvironment: boolean; + isNonInteractiveEnvironment: boolean; + silent: boolean; + includePaths: string[]; + customRulesOnly: boolean; + share: boolean; + respectInlineDisables: boolean; + warnings: boolean; + categoryFilters: ReadonlySet; + adoptExistingLintConfig: boolean; + ignoredTags: ReadonlySet; + includedTags: ReadonlySet; + includeTagDefaults: boolean; + scoreDisabledMessage: string | undefined; + outputSurface: DiagnosticSurface; + suppressRendering: boolean; + concurrentScan: boolean; + concurrency: number | undefined; + maxDurationMs: number | null; + baseline: { + ref: string; + baseFiles?: ReadonlyArray; + headFiles?: ReadonlyArray; + } | null; + changedLineRanges: ReadonlyArray | null; + supplyChainManifestChanged: boolean; + uiLayers: InspectUiLayers | null; +} diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 5db7537795..f8de2f354f 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -1,25 +1,18 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { performance } from "node:perf_hooks"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import type { ReactDoctorConfig } from "./core/core-configuration.js"; import { - buildSkippedChecks, - computeDiagnosticDelta, - DEFAULT_SHOW_WARNINGS, - filterDiagnosticsForSurface, - filterSourceFiles, - highlighter, + createOxlintSpawnSlots, + OxlintConcurrency, OXLINT_NODE_REQUIREMENT, - PerFileLintCacheEnabled, - resolveScanTarget, - restoreLegacyThrow, + resolveScanConcurrency, runInspect as runInspectEffect, - SidecarLintCacheEnabled, -} from "@react-doctor/core"; -import type * as Layer from "effect/Layer"; -import type { Progress, Reporter } from "@react-doctor/core"; +} from "./core/core-runtime.js"; +import { restoreLegacyThrow } from "./core/core-errors.js"; +import { highlighter } from "./core/core-presentation.js"; +import { resolveScanTarget } from "./core/core-project-discovery.js"; +import type { InspectResult } from "./core/core-types.js"; import { applyObservability } from "./cli/utils/apply-observability.js"; import { buildRuntimeLayers } from "./cli/utils/build-runtime-layers.js"; import { @@ -28,69 +21,40 @@ import { withSentryRunSpan, } from "./cli/utils/with-sentry-run-span.js"; import type { SentryRootSpan } from "./cli/utils/with-sentry-run-span.js"; -import { BASELINE_FILES_TEMP_DIR_PREFIX, METRIC } from "./cli/utils/constants.js"; +import { METRIC } from "./cli/utils/constants.js"; import { recordCount } from "./cli/utils/record-metric.js"; -import { recordScanMetrics } from "./cli/utils/record-scan-metrics.js"; import { recordRunEvent } from "./cli/utils/build-run-event.js"; -import { resolveWorkerTelemetry } from "./cli/utils/resolve-worker-telemetry.js"; -import { countDeadlineSkippedFiles } from "./cli/utils/count-deadline-skipped-files.js"; -import { countDroppedLintFiles } from "./cli/utils/count-dropped-lint-files.js"; -import type { - ChangedFileLineRanges, - Diagnostic, - DiagnosticSurface, - InspectOptions, - InspectResult, - ProjectInfo, - ReactDoctorConfig, - ScoreResult, -} from "@react-doctor/core"; -import { toForwardSlashes } from "./cli/utils/path-format.js"; -import { diagnosticIntersectsLineRanges } from "./cli/utils/diagnostic-intersects-line-ranges.js"; -import { makeNoopConsole } from "./cli/utils/noop-console.js"; -import { materializeBaselineFiles } from "./cli/utils/materialize-baseline-files.js"; -import { createSourceLineReader } from "./cli/utils/read-source-line.js"; -import { createDiagnosticEvidenceReader } from "./cli/utils/read-diagnostic-evidence.js"; -import { buildNoScoreMessage } from "./cli/utils/build-no-score-message.js"; -import { printAgentGuidance } from "./cli/utils/render-agent-guidance.js"; -import { - isCiOrCodingAgentEnvironment, - isCodingAgentEnvironment, -} from "./cli/utils/is-ci-environment.js"; -import { computeProjectedScore } from "./cli/utils/compute-score-projection.js"; -import { buildRulePriorityMap } from "./cli/utils/diagnostic-grouping.js"; -import { filterDiagnosticsByCategories } from "./cli/utils/filter-diagnostics-by-categories.js"; -import { printDiagnostics } from "./cli/utils/render-diagnostics.js"; -import { shouldRenderHyperlinks } from "./cli/utils/should-render-hyperlinks.js"; -import { shouldShowShareLink } from "./cli/utils/should-show-share-link.js"; -import { isNonInteractiveEnvironment } from "./cli/utils/is-non-interactive-environment.js"; +import { isCiOrCodingAgentEnvironment } from "./cli/utils/is-ci-environment.js"; import { canAnimateOnboarding, isOnboardingForced, - onboardingSectionPause, shouldRecordOnboarding, } from "./cli/utils/onboarding-pacing.js"; import { hasCompletedOnboarding, markOnboardingComplete } from "./cli/utils/onboarding-state.js"; +import { isNonInteractiveEnvironment } from "./cli/utils/is-non-interactive-environment.js"; import { printProjectDetection } from "./cli/utils/render-project-detection.js"; import { - printBrandingOnlyHeader, - printNoScoreHeader, - printScoreHeader, -} from "./cli/utils/render-score-header.js"; -import { printDiagnosticsDump, printFooter, printSummary } from "./cli/utils/render-summary.js"; + buildRunEventConfig, + renderAndRecordScan, + renderCachedProjectDetection, + silentConsole, +} from "./cli/utils/render-inspect-result.js"; import { resolveOxlintNode } from "./cli/utils/resolve-oxlint-node.js"; -import { resolveCliCategories } from "./cli/utils/resolve-cli-categories.js"; import { getRunId } from "./cli/utils/run-id.js"; -import { - buildScanResultCacheKey, - createScanResultCache, - shouldStoreScanPayload, - type CachedScanPayload, -} from "./cli/utils/scan-result-cache.js"; +import type { CachedScanPayload } from "./cli/utils/scan-result-cache.js"; +import { createScanResultCacheLifecycle } from "./cli/utils/scan-result-cache-lifecycle.js"; +import { resolveInspectOptions } from "./cli/utils/resolve-inspect-options.js"; +import { resolveBaselineComparison } from "./cli/utils/resolve-baseline-comparison.js"; +import type { OxlintInvocationRuntime } from "./cli/utils/resolve-baseline-comparison.js"; import { isSpinnerSilent, setSpinnerSilent } from "./cli/utils/spinner.js"; import { VERSION } from "./cli/utils/version.js"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "./inspect-options.js"; -const silentConsole = makeNoopConsole(); +export type { + InspectUiLayers, + ReactDoctorInspectOptions, + ResolvedInspectOptions, +} from "./inspect-options.js"; const runConsole = (effect: Effect.Effect): void => { Effect.runSync(effect); @@ -117,217 +81,10 @@ const recordOnboardingCompletion = (options: ResolvedInspectOptions): void => { } }; -const formatCategorySelection = (categoryFilters: ReadonlySet): string => - [...categoryFilters].join(", "); - -// Builds the `--scope lines` predicate: a diagnostic survives when its source -// span intersects a changed range of its file. `changedLineRanges` is keyed by paths -// relative to `directory`; diagnostic paths are normalized the same way so -// absolute and relative forms both match. -const buildChangedLineMatcher = ( +const inspectWithOxlintRuntime = async ( directory: string, - changedLineRanges: ReadonlyArray, -): ((diagnostic: Diagnostic) => boolean) => { - const rangesByFile = new Map>(); - for (const entry of changedLineRanges) { - rangesByFile.set(toForwardSlashes(entry.file), entry.ranges); - } - return (diagnostic) => { - const relativePath = toForwardSlashes( - path.isAbsolute(diagnostic.filePath) - ? path.relative(directory, diagnostic.filePath) - : diagnostic.filePath, - ); - const ranges = rangesByFile.get(relativePath); - if (ranges === undefined) return false; - return diagnosticIntersectsLineRanges(diagnostic, ranges); - }; -}; - -/** - * CLI-only: layer overrides an interactive UI supplies so the scan streams - * live diagnostics (and optionally progress) into it instead of the console. - * When present, all console rendering is suppressed — the UI owns the screen - * and reads the returned result. The scan engine never learns the UI's - * concrete store type; it only sees these generic service layers. - */ -export interface InspectUiLayers { - readonly reporter: Layer.Layer; - readonly progress?: Layer.Layer; -} - -export interface ReactDoctorInspectOptions extends InspectOptions { - categoryFilters?: string[]; - includedTags?: ReadonlySet; - includeTagDefaults?: boolean; - scoreDisabledMessage?: string; - /** - * Internal: an absolute epoch-ms deadline shared across a workspace scan's - * projects. The CLI sets it so every project honors ONE `--max-duration` - * budget without restarting it per project, while `maxDurationMs` stays the - * user's configured value (so telemetry reports what they set). When unset, - * the deadline is derived from `maxDurationMs` at call start. - */ - deadlineEpochMs?: number; - /** See {@link InspectUiLayers}. */ - uiLayers?: InspectUiLayers; -} - -export interface ResolvedInspectOptions { - lint: boolean; - deadCode: boolean; - supplyChain: boolean; - verbose: boolean; - /** See `InspectOptions.outputDirectory`. `null` keeps the temp-dir default. */ - outputDirectory: string | null; - scoreOnly: boolean; - noScore: boolean; - isCi: boolean; - isCiOrCodingAgentEnvironment: boolean; - isNonInteractiveEnvironment: boolean; - silent: boolean; - includePaths: string[]; - customRulesOnly: boolean; - share: boolean; - respectInlineDisables: boolean; - warnings: boolean; - categoryFilters: ReadonlySet; - adoptExistingLintConfig: boolean; - ignoredTags: ReadonlySet; - includedTags: ReadonlySet; - includeTagDefaults: boolean; - scoreDisabledMessage: string | undefined; - outputSurface: DiagnosticSurface; - suppressRendering: boolean; - /** See `InspectOptions.concurrentScan`. */ - concurrentScan: boolean; - /** Resolved oxlint worker count, or `undefined` to keep the ambient default. */ - concurrency: number | undefined; - /** Scan time budget in milliseconds, or `null` for no budget. */ - maxDurationMs: number | null; - /** Baseline ref to subtract (new-only mode), or `null` for a plain scan. */ - baseline: { - ref: string; - baseFiles?: ReadonlyArray; - headFiles?: ReadonlyArray; - } | null; - /** - * `--scope lines`: changed line ranges to restrict reported diagnostics to, - * or `null` for any other scope. An empty array still filters (a `lines` - * scope whose files added no lines reports nothing). - */ - changedLineRanges: ReadonlyArray | null; - /** See `InspectOptions.supplyChainManifestChanged`. */ - supplyChainManifestChanged: boolean; - /** Interactive UI layer overrides, or `null` for the static console path. */ - uiLayers: InspectUiLayers | null; -} - -const buildIgnoredTags = ( - userConfig: ReactDoctorConfig | null, - includedTags: ReadonlySet, -): ReadonlySet => { - const tags = new Set(); - if (userConfig?.ignore?.tags) { - for (const tag of userConfig.ignore.tags) tags.add(tag); - } - for (const tag of includedTags) tags.delete(tag); - return tags; -}; - -const mergeInspectOptions = ( inputOptions: ReactDoctorInspectOptions, - userConfig: ReactDoctorConfig | null, -): ResolvedInspectOptions => { - const includedTags = inputOptions.includedTags ?? new Set(); - return { - lint: inputOptions.lint ?? userConfig?.lint ?? true, - deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, - supplyChain: inputOptions.supplyChain ?? userConfig?.supplyChain?.enabled ?? true, - verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, - outputDirectory: inputOptions.outputDirectory || null, - scoreOnly: inputOptions.scoreOnly ?? false, - noScore: inputOptions.noScore ?? userConfig?.noScore ?? false, - isCi: inputOptions.isCi ?? false, - isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(), - isNonInteractiveEnvironment: isNonInteractiveEnvironment(), - silent: inputOptions.silent ?? false, - includePaths: inputOptions.includePaths ?? [], - customRulesOnly: includedTags.size > 0 ? false : (userConfig?.customRulesOnly ?? false), - share: userConfig?.share ?? true, - respectInlineDisables: - inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true, - warnings: inputOptions.warnings ?? userConfig?.warnings ?? DEFAULT_SHOW_WARNINGS, - categoryFilters: new Set(resolveCliCategories(inputOptions.categoryFilters) ?? []), - adoptExistingLintConfig: - includedTags.size > 0 ? false : (userConfig?.adoptExistingLintConfig ?? true), - ignoredTags: buildIgnoredTags(userConfig, includedTags), - includedTags, - includeTagDefaults: inputOptions.includeTagDefaults ?? false, - scoreDisabledMessage: inputOptions.scoreDisabledMessage, - outputSurface: inputOptions.outputSurface ?? "cli", - suppressRendering: (inputOptions.suppressRendering ?? false) || inputOptions.uiLayers != null, - uiLayers: inputOptions.uiLayers ?? null, - concurrentScan: inputOptions.concurrentScan ?? false, - concurrency: inputOptions.concurrency, - maxDurationMs: inputOptions.maxDurationMs ?? null, - baseline: inputOptions.baseline ?? null, - changedLineRanges: inputOptions.changedLineRanges ?? null, - supplyChainManifestChanged: inputOptions.supplyChainManifestChanged ?? false, - }; -}; - -// The scan-config slice of the wide event, shared by the success and failure -// emit paths (the failure path has no `result`, so it can only supply config). -// The return type is inferred and checked at the call sites, which spread it -// into the full `RunEventInput` — a missing field surfaces there. -// Reconstruct the resolved scope from the engine inputs (the CLI resolved it -// from `--scope`, but `inspect()` only sees its effects): a baseline ref means -// `changed`, line ranges mean `lines`, any other diff means `files`, else `full`. -// A degraded `lines` / `changed` run carries neither, so it reads as `files` — -// matching what actually ran. -const deriveScope = (options: ResolvedInspectOptions): string => { - if (options.baseline) return "changed"; - if (options.changedLineRanges !== null) return "lines"; - return options.includePaths.length > 0 ? "files" : "full"; -}; - -const buildRunEventConfig = ( - options: ResolvedInspectOptions, - userConfig: ReactDoctorConfig | null, - hasCustomConfig: boolean, - // The worker count the scan actually resolved to (`output.scanConcurrency`), - // which is the real value on the auto path where `options.concurrency` is - // `undefined`. Omitted on the pre-scan failure path (no scan ran), where it - // falls back to the caller's pin. - resolvedWorkerCount?: number, -) => { - const { workerCount, parallel } = resolveWorkerTelemetry( - resolvedWorkerCount, - options.concurrency, - ); - return { - scope: deriveScope(options), - parallel, - workerCount, - maxDurationMs: options.maxDurationMs, - lint: options.lint, - deadCode: options.deadCode, - supplyChain: options.supplyChain, - scoreOnly: options.scoreOnly, - noScore: options.noScore, - respectInlineDisables: options.respectInlineDisables, - showWarnings: options.warnings, - usedOutputDir: options.outputDirectory !== null, - ignoredTagCount: options.ignoredTags.size, - hasCustomConfig, - userConfig, - }; -}; - -export const inspect = async ( - directory: string, - inputOptions: ReactDoctorInspectOptions = {}, + oxlintRuntime: OxlintInvocationRuntime, ): Promise => { const startTime = performance.now(); // The CLI passes an absolute `deadlineEpochMs` shared across a workspace @@ -376,7 +133,14 @@ export const inspect = async ( configSourceDirectory = scanTarget.configSourceDirectory; } - const options = mergeInspectOptions(inputOptions, userConfig); + const options = resolveInspectOptions({ + inputOptions, + userConfig, + environment: { + isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(), + isNonInteractiveEnvironment: isNonInteractiveEnvironment(), + }, + }); // HACK: spinner.ts still has module-level silent state (used by // printProjectDetection's internal spinner() calls). Mirror the @@ -403,6 +167,7 @@ export const inspect = async ( startTime, deadlineEpochMs, rootSentrySpan, + oxlintRuntime, ); } catch (error) { // Emit the canonical wide event on the failure path too: the scan threw @@ -434,175 +199,26 @@ export const inspect = async ( } }; -interface BaselineComparison { - displayDiagnostics: ReadonlyArray; - baselineDelta: NonNullable; -} - -// Files the lint pass failed to cover — dropped (pathological batches) plus -// deadline-skipped. Distinct from `lintPartialFailures.length`, which also -// counts informational notes (e.g. the react-hooks-js plugin-drop) that leave -// the lint COMPLETE. Baseline comparison is only unreliable when coverage is -// actually incomplete, so it degrades on this count, not on any partial string. -const countIncompleteLintFiles = (lintPartialFailures: ReadonlyArray): number => - countDroppedLintFiles(lintPartialFailures) + countDeadlineSkippedFiles(lintPartialFailures); - -interface RunBaselineComparisonInput { - directory: string; - options: ResolvedInspectOptions; - userConfig: ReactDoctorConfig | null; - /** - * Where `userConfig` was loaded from, so the base scan resolves - * `config.plugins` specifiers from the real config directory — anchoring - * them at the temp snapshot (which has no `node_modules` or plugin files) - * silently drops every custom plugin from the base side and mislabels its - * pre-existing findings as newly introduced. - */ - configSourceDirectory: string | null; - headProjectInfo: ProjectInfo; - headDiagnostics: ReadonlyArray; - resolvedNodeBinaryPath: string | null; - baselineRef: string; - baseFiles?: ReadonlyArray; - headFiles?: ReadonlyArray; - headAnalyzedFiles: ReadonlyArray; - /** Shared invocation deadline; bounds the base-ref lint like the head scan. */ - deadlineEpochMs: number | null; -} - -/** - * Runs a second, lint-only scan over the changed files as they existed at the - * baseline ref (materialized into a temp tree with head's config) and diffs it - * against the head diagnostics, returning only the findings the change - * introduced plus the fixed / base counts. No score, dead-code, progress, or - * telemetry — it's a pure comparison pass. The temp tree is always cleaned up. - */ -const runBaselineComparison = async ( - params: RunBaselineComparisonInput, -): Promise => { - const tempDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX)); - // If materialization throws before the snapshot (and its cleanup) exists, - // remove the temp dir we just created so it can't leak. - const snapshot = await materializeBaselineFiles({ - directory: params.directory, - ref: params.baselineRef, - files: params.options.includePaths, - baseFiles: params.baseFiles, - headFiles: params.headFiles, - tempDirectory, - }).catch((error: unknown) => { - rmSync(tempDirectory, { recursive: true, force: true }); - throw error; - }); - if (snapshot === null) { - rmSync(tempDirectory, { recursive: true, force: true }); - return null; - } - try { - if (!snapshot.isComplete) return null; - const analyzedHeadFiles = new Set(params.headAnalyzedFiles.map(toForwardSlashes)); - const baseFiles = new Set(snapshot.baseFiles.map(toForwardSlashes)); - const trackedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes)); - const expectedHeadFiles = new Set(trackedHeadFiles); - for (const filePath of params.options.includePaths) { - const normalizedFilePath = toForwardSlashes(filePath); - if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath); - } - if ( - filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath)) - ) { - return null; - } - const baseLayers = buildRuntimeLayers({ - directory: snapshot.tempDirectory, - hasConfigOverride: true, - userConfig: params.userConfig, - configSourceDirectory: params.configSourceDirectory, - projectInfoOverride: params.headProjectInfo, - shouldSkipLint: !params.options.lint || !params.resolvedNodeBinaryPath, - shouldRunDeadCode: false, - shouldRunSupplyChain: params.options.supplyChain, - shouldComputeScore: false, - shouldShowProgressSpinners: false, - oxlintConcurrency: params.options.concurrency, - }); - const baseProgram = runInspectEffect( - { - directory: snapshot.tempDirectory, - includePaths: snapshot.materializedFiles, - customRulesOnly: params.options.customRulesOnly, - respectInlineDisables: params.options.respectInlineDisables, - warnings: params.options.warnings, - adoptExistingLintConfig: params.options.adoptExistingLintConfig, - ignoredTags: params.options.ignoredTags, - includedTags: params.options.includedTags, - includeTagDefaults: params.options.includeTagDefaults, - nodeBinaryPath: params.resolvedNodeBinaryPath ?? undefined, - runDeadCode: false, - isCi: params.options.isCi, - doctorVersion: VERSION, - runId: getRunId(), - resolveLocalGithubViewerPermission: false, - suppressScanSummary: true, - // Score the base manifest too so `computeDiagnosticDelta` filters out - // pre-existing low-score dependencies instead of reporting them as new. - supplyChainManifestChanged: params.options.supplyChainManifestChanged, - // The base-ref lint shares the invocation deadline, so a --max-duration - // budget bounds the whole run, not just the head scan. - deadlineEpochMs: params.deadlineEpochMs ?? undefined, - }, - {}, - ); - const baseOutput = await Effect.runPromise( - restoreLegacyThrow( - baseProgram.pipe( - Effect.provide(baseLayers), - // The base snapshot lints in a per-run-unique temp dir, so its - // on-disk cache identity can never hit — writing would only mint an - // orphan per-run subdir inside the CI-persisted cache directory - // (unbounded growth across the action's restore→save cycles). - Effect.provideService(PerFileLintCacheEnabled, false), - Effect.provideService(SidecarLintCacheEnabled, false), - Effect.provideService(Console.Console, silentConsole), - ), - ), - ); - // A failed OR budget-truncated base lint leaves base findings - // unreliable/incomplete, which would mislabel pre-existing head issues as - // newly introduced. Signal "no delta" (null) so the caller degrades to a - // plain diff — full head findings stay visible, but the run won't claim - // they're new or gate on them. A genuinely empty but *successful* base lint - // is fine — every head finding is new. - if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) { - return null; - } - const hasUnscannedUntrackedSourceFiles = filterSourceFiles( - snapshot.untrackedFiles.map(toForwardSlashes), - ).some((filePath) => !analyzedHeadFiles.has(filePath)); - const delta = computeDiagnosticDelta({ - headDiagnostics: params.headDiagnostics, - baseDiagnostics: baseOutput.diagnostics, - readHeadLine: createSourceLineReader(params.directory), - readBaseLine: createSourceLineReader(snapshot.tempDirectory), - readHeadEvidence: createDiagnosticEvidenceReader(params.directory, { - resolveForwardedHandlers: true, - }), - readBaseEvidence: createDiagnosticEvidenceReader(snapshot.tempDirectory), - }); - return { - displayDiagnostics: delta.newDiagnostics, - baselineDelta: { - baseRef: params.baselineRef, - fixedCount: hasUnscannedUntrackedSourceFiles ? 0 : delta.fixedCount, - baseTotalCount: baseOutput.diagnostics.length, - crossFileMatchCount: delta.crossFileMatchCount, - }, - }; - } finally { - snapshot.cleanup(); - } +export const createInvocationInspect = ( + requestedOxlintConcurrency?: number, +): ((directory: string, inputOptions?: ReactDoctorInspectOptions) => Promise) => { + const concurrency = resolveScanConcurrency( + requestedOxlintConcurrency ?? Effect.runSync(OxlintConcurrency), + ); + const oxlintRuntime: OxlintInvocationRuntime = { + concurrency, + spawnSlots: createOxlintSpawnSlots(concurrency), + }; + return (directory, inputOptions = {}) => + inspectWithOxlintRuntime(directory, inputOptions, oxlintRuntime); }; +export const inspect = async ( + directory: string, + inputOptions: ReactDoctorInspectOptions = {}, +): Promise => + createInvocationInspect(inputOptions.concurrency)(directory, inputOptions); + const runInspectWithRuntime = async ( directory: string, options: ResolvedInspectOptions, @@ -612,6 +228,7 @@ const runInspectWithRuntime = async ( startTime: number, deadlineEpochMs: number | null, rootSentrySpan: SentryRootSpan, + oxlintRuntime: OxlintInvocationRuntime, ): Promise => { const isDiffMode = options.includePaths.length > 0; // Pre-check oxlint native binding the same way the legacy entry @@ -626,44 +243,21 @@ const runInspectWithRuntime = async ( options.scoreOnly || options.silent, ); const lintBindingMissing = options.lint && !resolvedNodeBinaryPath; - const cacheKey = buildScanResultCacheKey({ - projectDirectory: directory, - version: VERSION, - nodeBinaryPath: resolvedNodeBinaryPath, + const cacheLifecycle = createScanResultCacheLifecycle({ + directory, options, userConfig, hasConfigOverride, configSourceDirectory, + resolvedNodeBinaryPath, + startTime, + rootSentrySpan, + renderCachedProjectDetection, + renderAndRecordScan, + recordOnboardingCompletion, }); - const scanResultCache = cacheKey === null ? null : createScanResultCache(directory); - const cachedPayload = cacheKey === null ? null : (scanResultCache?.lookup(cacheKey) ?? null); - if (cachedPayload) { - recordSentryProjectContext(cachedPayload.project, rootSentrySpan, { - concurrentScan: options.concurrentScan, - }); - recordCount(METRIC.projectDetected, 1); - await renderCachedProjectDetection({ - payload: cachedPayload, - options, - userConfig, - isDiffMode, - }); - const baselineDegraded = - Boolean(options.baseline) && isDiffMode && cachedPayload.baselineDelta === undefined; - const result = await renderAndRecordScan({ - payload: cachedPayload, - options, - userConfig, - hasCustomConfig: userConfig !== null, - startTime, - rootSentrySpan, - scanMode: cachedPayload.baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", - baselineDegraded, - wholeRepoCacheHit: true, - }); - recordOnboardingCompletion(options); - return result; - } + const cachedResult = cacheLifecycle.replay(); + if (cachedResult !== null) return await cachedResult; // Suppress the orchestrator-owned lint + dead-code spinners when // the CLI is in score-only / silent / suppressed-rendering mode (or @@ -689,7 +283,8 @@ const runInspectWithRuntime = async ( shouldRunSupplyChain: options.supplyChain, shouldComputeScore: !options.noScore, shouldShowProgressSpinners, - oxlintConcurrency: options.concurrency, + oxlintConcurrency: oxlintRuntime.concurrency, + oxlintSpawnSlots: oxlintRuntime.spawnSlots, reporterLayer: options.uiLayers?.reporter, progressLayer: options.uiLayers?.progress, }); @@ -788,51 +383,23 @@ const runInspectWithRuntime = async ( } } - // Baseline mode: subtract the diagnostics that already existed at the base - // ref so we surface only what this change introduced. The reported score - // stays head's. - // When the delta can't be computed — the head lint failed, or the base lint - // failed (runBaselineComparison returns null) — degrade to a plain diff: keep - // the full head findings visible and emit no delta. The CLI then reports - // `mode: "diff"` and skips the gate rather than hiding real findings or - // blaming the PR for pre-existing ones. - let inspectDiagnostics: ReadonlyArray = output.diagnostics; - let baselineDelta: InspectResult["baselineDelta"]; - // A head lint that dropped or deadline-skipped files is incomplete, so the - // delta would silently miss findings in the unlinted files — degrade to a - // plain diff exactly like a failed head lint. - if ( - options.baseline && - isDiffMode && - !didLintFail && - countIncompleteLintFiles(output.lintPartialFailures) === 0 - ) { - const comparison = await runBaselineComparison({ - directory, - options, - userConfig, - configSourceDirectory, - headProjectInfo: output.project, - headDiagnostics: output.diagnostics, - resolvedNodeBinaryPath, - baselineRef: options.baseline.ref, - baseFiles: options.baseline.baseFiles, - headFiles: options.baseline.headFiles, - headAnalyzedFiles: output.analyzedFiles, - deadlineEpochMs, - }); - if (comparison) { - inspectDiagnostics = comparison.displayDiagnostics; - baselineDelta = comparison.baselineDelta; - } - } else if (options.changedLineRanges !== null && isDiffMode) { - // `--scope lines`: keep diagnostics whose source spans touch the change. - // Runs at the same post-lint seam as baseline (the score is already - // computed on the full head set), so the gate, summary, and inline - // comments all narrow together. - const isOnChangedLine = buildChangedLineMatcher(directory, options.changedLineRanges); - inspectDiagnostics = output.diagnostics.filter(isOnChangedLine); - } + const comparison = await resolveBaselineComparison({ + directory, + options, + userConfig, + configSourceDirectory, + headProjectInfo: output.project, + headDiagnostics: output.diagnostics, + headAnalyzedFiles: output.analyzedFiles, + didLintFail, + lintPartialFailures: output.lintPartialFailures, + resolvedNodeBinaryPath, + deadlineEpochMs, + oxlintRuntime, + silentConsole, + }); + const inspectDiagnostics = comparison.displayDiagnostics; + const baselineDelta = comparison.baselineDelta; // Baseline was requested but no delta was produced (head/base lint failed) — // the run degrades to a plain diff and must not gate on the full head set. const baselineDegraded = Boolean(options.baseline) && isDiffMode && baselineDelta === undefined; @@ -873,24 +440,10 @@ const runInspectWithRuntime = async ( // so a stored degraded payload would replay at this HEAD/base pair until // the commit changes, skipping the gate instead of re-attempting the // comparison. - if ( - cacheKey !== null && - scanResultCache !== null && - shouldStoreScanPayload(payload) && - !baselineDegraded - ) { - scanResultCache.store(cacheKey, payload); - } - const result = await renderAndRecordScan({ + return await cacheLifecycle.complete({ payload, - options, - userConfig, - hasCustomConfig: userConfig !== null, - startTime, - rootSentrySpan, scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", baselineDegraded, - wholeRepoCacheHit: false, lintCacheHitFileCount: output.lintCacheHitFileCount, lintCacheTotalFileCount: output.lintCacheTotalFileCount, lintSidecarReplayedFileCount: output.lintSidecarReplayedFileCount, @@ -899,415 +452,4 @@ const runInspectWithRuntime = async ( deadCodeSummaryCacheHits: output.deadCodeSummaryCacheHits, deadCodeSummaryCacheMisses: output.deadCodeSummaryCacheMisses, }); - recordOnboardingCompletion(options); - return result; }; - -interface FinalizeInput { - options: ResolvedInspectOptions; - elapsedMilliseconds: number; - diagnostics: ReadonlyArray; - score: ScoreResult | null; - project: InspectResult["project"]; - userConfig: ReactDoctorConfig | null; - didLintFail: boolean; - lintFailureReason: string | null; - lintPartialFailures: ReadonlyArray; - didDeadCodeFail: boolean; - deadCodeFailureReason: string | null; - supplyChainOverlapTimedOut: boolean; - securityScanFailed: boolean; - directory: string; - scannedFileCount: number; - scannedFilePaths: ReadonlyArray; - analyzedFiles: ReadonlyArray; - scanElapsedMilliseconds: number; - lintCacheHitFileCount: number | null; - lintCacheTotalFileCount: number | null; - lintSidecarReplayedFileCount: number | null; - lintSidecarTotalFileCount: number | null; - deadCodeCacheHit: boolean | null; - deadCodeSummaryCacheHits: number | null; - deadCodeSummaryCacheMisses: number | null; - baselineDelta: InspectResult["baselineDelta"]; -} - -interface RenderCachedProjectDetectionInput { - readonly payload: CachedScanPayload; - readonly options: ResolvedInspectOptions; - readonly userConfig: ReactDoctorConfig | null; - readonly isDiffMode: boolean; -} - -interface RenderAndRecordScanInput { - readonly payload: CachedScanPayload; - readonly options: ResolvedInspectOptions; - readonly userConfig: ReactDoctorConfig | null; - readonly hasCustomConfig: boolean; - readonly startTime: number; - readonly rootSentrySpan: SentryRootSpan; - readonly scanMode: "full" | "diff" | "baseline"; - readonly baselineDegraded: boolean; - /** - * `true` only on the whole-repo scan-result replay path (the exact-key - * `cachedPayload` branch, where no lint / dead-code / score work ran). - * Required so both call sites state it explicitly — the wide event's - * `cache.temperature = "turbo"` derives from this flag, never from the - * execution dims below happening to be null. - */ - readonly wholeRepoCacheHit: boolean; - /** - * Per-file lint cache outcome for THIS scan's lint pass. Threaded outside - * `CachedScanPayload` on purpose — it's telemetry about the lint that ran in - * this process, not part of the cacheable result, so a whole-repo cache - * replay (where no lint ran) correctly leaves it absent. - */ - readonly lintCacheHitFileCount?: number | null; - readonly lintCacheTotalFileCount?: number | null; - /** - * Sidecar lint cache outcome for THIS scan's lint pass. Threaded outside - * `CachedScanPayload` for the same reason as the lint cache stats above. - */ - readonly lintSidecarReplayedFileCount?: number | null; - readonly lintSidecarTotalFileCount?: number | null; - /** - * Dead-code result cache outcome for THIS scan's dead-code pass. Threaded - * outside `CachedScanPayload` for the same reason as the lint cache stats - * above: a whole-repo cache replay (where no analysis ran) correctly - * leaves it absent. - */ - readonly deadCodeCacheHit?: boolean | null; - /** - * deslop's incremental summary-cache outcome for THIS scan's dead-code - * analysis (files served from cached parse summaries vs freshly parsed). - * Same outside-the-payload contract as the fields above. - */ - readonly deadCodeSummaryCacheHits?: number | null; - readonly deadCodeSummaryCacheMisses?: number | null; -} - -const runMaybeSilent = ( - effect: Effect.Effect, - silent: boolean, -): Effect.Effect => - silent ? effect.pipe(Effect.provideService(Console.Console, silentConsole)) : effect; - -const renderCachedProjectDetection = async ( - input: RenderCachedProjectDetectionInput, -): Promise => { - if (input.options.scoreOnly || input.options.suppressRendering) return; - await Effect.runPromise( - runMaybeSilent( - printProjectDetection({ - projectInfo: input.payload.project, - userConfig: input.userConfig, - isDiffMode: input.isDiffMode, - includePaths: input.options.includePaths, - lintSourceFileCount: input.payload.scannedFileCount, - }), - input.options.silent, - ), - ); -}; - -const renderAndRecordScan = async (input: RenderAndRecordScanInput): Promise => { - const finalizeInput: FinalizeInput = { - options: input.options, - elapsedMilliseconds: performance.now() - input.startTime, - diagnostics: input.payload.diagnostics, - score: input.payload.score, - project: input.payload.project, - userConfig: input.payload.userConfig, - didLintFail: input.payload.didLintFail, - lintFailureReason: input.payload.lintFailureReason, - lintPartialFailures: input.payload.lintPartialFailures, - didDeadCodeFail: input.payload.didDeadCodeFail, - deadCodeFailureReason: input.payload.deadCodeFailureReason, - supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut, - securityScanFailed: input.payload.securityScanFailed ?? false, - directory: input.payload.directory, - scannedFileCount: input.payload.scannedFileCount, - scannedFilePaths: input.payload.scannedFilePaths, - analyzedFiles: input.payload.analyzedFiles ?? [], - scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds, - lintCacheHitFileCount: input.lintCacheHitFileCount ?? null, - lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null, - lintSidecarReplayedFileCount: input.lintSidecarReplayedFileCount ?? null, - lintSidecarTotalFileCount: input.lintSidecarTotalFileCount ?? null, - deadCodeCacheHit: input.deadCodeCacheHit ?? null, - deadCodeSummaryCacheHits: input.deadCodeSummaryCacheHits ?? null, - deadCodeSummaryCacheMisses: input.deadCodeSummaryCacheMisses ?? null, - baselineDelta: input.payload.baselineDelta, - }; - const result = await Effect.runPromise( - runMaybeSilent(finalizeAndRender(finalizeInput), input.options.silent), - ); - // The real worker count the scan fanned out to (resolved auto count on the - // common parallel path, where the caller pinned no `concurrency`). A stale - // cache hit predating the field falls back to the caller's pin. - const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry( - input.payload.scanConcurrency, - input.options.concurrency, - ); - recordScanMetrics({ - result, - mode: input.scanMode, - baselineDegraded: input.baselineDegraded, - parallel, - workerCount: resolvedWorkerCount, - lint: input.options.lint, - deadCode: input.options.deadCode, - scoreOnly: input.options.scoreOnly, - noScore: input.options.noScore, - didLintFail: input.payload.didLintFail, - lintFailureReasonKind: input.payload.lintFailureReasonKind, - didDeadCodeFail: input.payload.didDeadCodeFail, - userConfig: input.userConfig, - suppressedRuleCounts: input.payload.suppressedRuleCounts, - }); - recordRunEvent(input.rootSentrySpan, { - ...buildRunEventConfig( - input.options, - input.userConfig, - input.hasCustomConfig, - resolvedWorkerCount, - ), - result, - mode: input.scanMode, - gateExempt: input.baselineDegraded, - wholeRepoCacheHit: input.wholeRepoCacheHit, - didLintFail: input.payload.didLintFail, - lintFailureReasonKind: input.payload.lintFailureReasonKind, - lintPartialFailureCount: input.payload.lintPartialFailures.length, - lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures), - lintDeadlineSkippedFileCount: countDeadlineSkippedFiles(input.payload.lintPartialFailures), - didDeadCodeFail: input.payload.didDeadCodeFail, - supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut, - securityScanFailed: input.payload.securityScanFailed, - deadCodeOverlapped: input.payload.deadCodeOverlapped, - suppressedRuleCounts: input.payload.suppressedRuleCounts, - }); - return result; -}; - -const finalizeAndRender = (input: FinalizeInput): Effect.Effect => - Effect.gen(function* () { - const { - options, - elapsedMilliseconds, - diagnostics, - score, - project, - userConfig, - didLintFail, - lintFailureReason, - lintPartialFailures, - didDeadCodeFail, - deadCodeFailureReason, - supplyChainOverlapTimedOut, - securityScanFailed, - directory, - scannedFileCount, - scannedFilePaths, - analyzedFiles, - scanElapsedMilliseconds, - lintCacheHitFileCount, - lintCacheTotalFileCount, - lintSidecarReplayedFileCount, - lintSidecarTotalFileCount, - deadCodeCacheHit, - deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses, - baselineDelta, - } = input; - - const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ - didLintFail, - lintFailureReason, - lintPartialFailures, - didDeadCodeFail, - deadCodeFailureReason, - supplyChainOverlapTimedOut, - securityScanFailed, - }); - const hasSkippedChecks = skippedChecks.length > 0; - - const noScoreMessage = buildNoScoreMessage(options.noScore, options.scoreDisabledMessage); - - const buildResult = (): InspectResult => ({ - diagnostics: [...diagnostics], - score, - skippedChecks, - ...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}), - project, - elapsedMilliseconds, - scannedFileCount, - scannedFilePaths, - analyzedFiles, - scanElapsedMilliseconds, - ...(lintCacheTotalFileCount !== null - ? { lintCacheHitFileCount, lintCacheTotalFileCount } - : {}), - ...(lintSidecarTotalFileCount !== null - ? { lintSidecarReplayedFileCount, lintSidecarTotalFileCount } - : {}), - ...(deadCodeCacheHit !== null ? { deadCodeCacheHit } : {}), - ...(deadCodeSummaryCacheHits !== null && deadCodeSummaryCacheMisses !== null - ? { deadCodeSummaryCacheHits, deadCodeSummaryCacheMisses } - : {}), - ...(baselineDelta ? { baselineDelta } : {}), - }); - - if (options.suppressRendering) { - return buildResult(); - } - - const surfaceDiagnostics = filterDiagnosticsForSurface( - [...diagnostics], - options.outputSurface, - userConfig, - ); - const printedDiagnostics = filterDiagnosticsByCategories( - surfaceDiagnostics, - options.categoryFilters, - ); - - if (options.scoreOnly) { - // The path line goes to stderr so `--score` stdout stays machine-clean. - if (options.outputDirectory !== null) { - yield* printDiagnosticsDump(printedDiagnostics, options.outputDirectory, false, "stderr"); - } - if (score) { - yield* Console.log(`${score.score}`); - } else { - // 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(); - } - - // Report animations — the staggered section reveal, the category count-up, - // and the eased score-projection "ghost gain" — play on every interactive - // render, like the animated score bar, not just the first-run onboarding. - // `!silent` keeps the raw cursor writes out of JSON / piped output. - const animateRender = - !options.silent && !options.verbose && canAnimateOnboarding(process.stdout); - const pause = onboardingSectionPause(animateRender); - const useHyperlinks = shouldRenderHyperlinks(process.stdout); - const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length; - const isDiffMode = options.includePaths.length > 0; - const lintSourceFileCount = isDiffMode ? options.includePaths.length : project.sourceFileCount; - - if (printedDiagnostics.length === 0) { - yield* pause; - if (hasSkippedChecks) { - const skippedLabel = skippedChecks.join(" and "); - yield* Console.warn( - highlighter.warn( - `No issues detected, but ${skippedLabel} checks failed — results are incomplete.`, - ), - ); - } else if (options.categoryFilters.size > 0) { - yield* Console.log( - highlighter.success( - `No issues found in category ${formatCategorySelection(options.categoryFilters)}!`, - ), - ); - } else if (demotedDiagnosticCount > 0) { - yield* Console.log( - highlighter.success( - `No issues found! (${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface — see config.surfaces.)`, - ), - ); - } else { - yield* Console.log(highlighter.success("No issues found!")); - } - yield* Console.log(""); - yield* pause; - if (hasSkippedChecks) { - yield* printBrandingOnlyHeader; - yield* Console.log(highlighter.gray(" Score not shown — some checks could not complete.")); - } else if (score) { - yield* printScoreHeader(score); - } else { - yield* printNoScoreHeader(noScoreMessage); - } - // `--output-dir` still gets its dump (and stale-file cleanup) when - // nothing printed — e.g. every issue was fixed since the last run. - if (options.outputDirectory !== null) { - yield* printDiagnosticsDump(printedDiagnostics, options.outputDirectory); - } - return buildResult(); - } - - yield* pause; - yield* Console.log(""); - yield* printDiagnostics( - [...printedDiagnostics], - options.verbose, - directory, - buildRulePriorityMap([score]), - isCodingAgentEnvironment(), - { sectionPause: pause, animateCountUp: animateRender }, - useHyperlinks, - ); - if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") { - yield* printAgentGuidance(); - } - - if (options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) { - yield* Console.log( - highlighter.gray( - ` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`, - ), - ); - yield* Console.log(""); - } - - // Re-score with the displayed top errors removed so the score bar can - // show the payoff as a ghost gain segment. - const scoreDiagnostics = filterDiagnosticsForSurface([...diagnostics], "score", userConfig); - const displayedScoreDiagnostics = filterDiagnosticsForSurface( - [...printedDiagnostics], - "score", - userConfig, - ); - const potentialScore = score - ? yield* Effect.promise(() => - computeProjectedScore(displayedScoreDiagnostics, scoreDiagnostics, score), - ) - : null; - - const showShareLink = shouldShowShareLink(options); - yield* pause; - yield* printSummary({ - diagnostics: [...printedDiagnostics], - elapsedMilliseconds, - scoreResult: score, - potentialScore, - totalSourceFileCount: lintSourceFileCount, - noScoreMessage, - verbose: options.verbose, - outputDirectory: options.outputDirectory, - animateProjection: animateRender, - }); - - if (hasSkippedChecks) { - const skippedLabel = skippedChecks.join(" and "); - yield* Console.log(""); - yield* Console.warn( - highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`), - ); - } - - yield* pause; - yield* printFooter({ - diagnostics: [...printedDiagnostics], - scoreResult: score, - projectName: project.projectName, - isOffline: !showShareLink, - }); - - return buildResult(); - }); diff --git a/packages/react-doctor/tests/build-inspect-result.test.ts b/packages/react-doctor/tests/build-inspect-result.test.ts new file mode 100644 index 0000000000..e79a62fea8 --- /dev/null +++ b/packages/react-doctor/tests/build-inspect-result.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { Diagnostic, ProjectInfo, ScoreResult } from "@react-doctor/core"; +import { buildInspectResult } from "../src/cli/utils/build-inspect-result.js"; +import type { BuildInspectResultInput } from "../src/cli/utils/build-inspect-result.js"; + +const diagnostic: Diagnostic = { + filePath: "/repo/src/app.tsx", + plugin: "react-doctor", + rule: "example", + severity: "warning", + message: "Example diagnostic", + help: "Fix the example", + line: 1, + column: 1, + category: "Correctness", +}; + +const score: ScoreResult = { + score: 92, + label: "Excellent", +}; + +const project: ProjectInfo = { + rootDirectory: "/repo", + projectName: "example", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "unknown", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + preactVersion: null, + preactMajorVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + sourceFileCount: 1, +}; + +const baseInput = (overrides: Partial = {}): BuildInspectResultInput => ({ + diagnostics: [diagnostic], + score, + skippedChecks: [], + skippedCheckReasons: {}, + project, + elapsedMilliseconds: 120, + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 100, + lintCacheHitFileCount: null, + lintCacheTotalFileCount: null, + lintSidecarReplayedFileCount: null, + lintSidecarTotalFileCount: null, + deadCodeCacheHit: null, + deadCodeSummaryCacheHits: null, + deadCodeSummaryCacheMisses: null, + baselineDelta: undefined, + ...overrides, +}); + +describe("buildInspectResult", () => { + it("builds the stable required result shape and omits absent optional fields", () => { + const input = baseInput(); + const result = buildInspectResult(input); + + expect(result).toEqual({ + diagnostics: [diagnostic], + score, + skippedChecks: [], + project, + elapsedMilliseconds: 120, + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 100, + }); + expect(result.diagnostics).not.toBe(input.diagnostics); + expect(result.project).toBe(project); + expect(result.score).toBe(score); + expect(result.scannedFilePaths).toBe(input.scannedFilePaths); + expect(result.analyzedFiles).toBe(input.analyzedFiles); + }); + + it("includes every complete optional result group without changing values", () => { + expect( + buildInspectResult( + baseInput({ + skippedChecks: ["lint"], + skippedCheckReasons: { lint: "Oxlint failed." }, + lintCacheHitFileCount: 0, + lintCacheTotalFileCount: 4, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 2, + deadCodeCacheHit: false, + deadCodeSummaryCacheHits: 3, + deadCodeSummaryCacheMisses: 5, + baselineDelta: { + baseRef: "base", + fixedCount: 2, + baseTotalCount: 9, + crossFileMatchCount: 1, + }, + }), + ), + ).toEqual({ + diagnostics: [diagnostic], + score, + skippedChecks: ["lint"], + skippedCheckReasons: { lint: "Oxlint failed." }, + project, + elapsedMilliseconds: 120, + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 100, + lintCacheHitFileCount: 0, + lintCacheTotalFileCount: 4, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 2, + deadCodeCacheHit: false, + deadCodeSummaryCacheHits: 3, + deadCodeSummaryCacheMisses: 5, + baselineDelta: { + baseRef: "base", + fixedCount: 2, + baseTotalCount: 9, + crossFileMatchCount: 1, + }, + }); + }); + + it("omits incomplete cache-stat groups exactly", () => { + const result = buildInspectResult( + baseInput({ + lintCacheHitFileCount: 3, + lintCacheTotalFileCount: null, + lintSidecarReplayedFileCount: 2, + lintSidecarTotalFileCount: null, + deadCodeSummaryCacheHits: 4, + deadCodeSummaryCacheMisses: null, + }), + ); + + expect(result).not.toHaveProperty("lintCacheHitFileCount"); + expect(result).not.toHaveProperty("lintCacheTotalFileCount"); + expect(result).not.toHaveProperty("lintSidecarReplayedFileCount"); + expect(result).not.toHaveProperty("lintSidecarTotalFileCount"); + expect(result).not.toHaveProperty("deadCodeSummaryCacheHits"); + expect(result).not.toHaveProperty("deadCodeSummaryCacheMisses"); + }); +}); diff --git a/packages/react-doctor/tests/build-project-scan-plan.test.ts b/packages/react-doctor/tests/build-project-scan-plan.test.ts new file mode 100644 index 0000000000..699cf3636e --- /dev/null +++ b/packages/react-doctor/tests/build-project-scan-plan.test.ts @@ -0,0 +1,166 @@ +import * as path from "node:path"; +import type { GitBaselineDiffPlan } from "@react-doctor/core"; +import { describe, expect, it } from "vite-plus/test"; +import type { DiffInfo } from "../src/index.js"; +import { buildProjectScanPlan } from "../src/cli/utils/build-project-scan-plan.js"; + +const buildDiffInfo = (changedFiles: string[]): DiffInfo => ({ + currentBranch: "feature", + baseBranch: "main", + changedFiles, + isCurrentChanges: false, +}); + +const buildBaselineDiffPlan = (baseFiles: string[], headFiles: string[]): GitBaselineDiffPlan => ({ + baseFiles, + headFiles, + untrackedFiles: [], +}); + +describe("buildProjectScanPlan", () => { + const rootDirectory = path.join("/repo"); + const projectDirectory = path.join(rootDirectory, "apps", "web"); + + it("keeps full scans unfiltered while projecting baseline files", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: buildBaselineDiffPlan( + ["apps/web/src/removed.tsx", "apps/admin/src/admin.tsx"], + ["apps/web/src/current.tsx"], + ), + diffInfo: buildDiffInfo(["apps/web/package.json"]), + isDiffMode: false, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: undefined, + projectBaselineBaseFiles: ["src/removed.tsx"], + projectBaselineHeadFiles: ["src/current.tsx"], + shouldSkipProject: false, + supplyChainManifestChanged: false, + }); + }); + + it("skips a diff scan with no changed or baseline source files", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: null, + diffInfo: null, + isDiffMode: true, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: [], + projectBaselineBaseFiles: null, + projectBaselineHeadFiles: null, + shouldSkipProject: true, + supplyChainManifestChanged: false, + }); + }); + + it("maps changed source files into the selected project", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: null, + diffInfo: buildDiffInfo([ + "apps/admin/src/admin.tsx", + "apps/web/src/app.tsx", + "apps/web/README.md", + ]), + isDiffMode: true, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: ["src/app.tsx"], + projectBaselineBaseFiles: null, + projectBaselineHeadFiles: null, + shouldSkipProject: false, + supplyChainManifestChanged: false, + }); + }); + + it("includes a changed project manifest when supply-chain checks are enabled", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: null, + diffInfo: buildDiffInfo(["apps/web/package.json"]), + isDiffMode: true, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: ["package.json"], + projectBaselineBaseFiles: null, + projectBaselineHeadFiles: null, + shouldSkipProject: false, + supplyChainManifestChanged: true, + }); + }); + + it("skips a manifest-only diff when supply-chain checks are disabled", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: null, + diffInfo: buildDiffInfo(["apps/web/package.json"]), + isDiffMode: true, + supplyChainEnabled: false, + }), + ).toEqual({ + includePaths: [], + projectBaselineBaseFiles: null, + projectBaselineHeadFiles: null, + shouldSkipProject: true, + supplyChainManifestChanged: false, + }); + }); + + it("scans baseline-only source files and preserves base and head projections", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: buildBaselineDiffPlan( + ["apps/web/src/removed.tsx"], + ["apps/admin/src/current.tsx"], + ), + diffInfo: buildDiffInfo([]), + isDiffMode: true, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: ["src/removed.tsx"], + projectBaselineBaseFiles: ["src/removed.tsx"], + projectBaselineHeadFiles: [], + shouldSkipProject: false, + supplyChainManifestChanged: false, + }); + }); + + it("orders changed sources before the manifest include", () => { + expect( + buildProjectScanPlan({ + rootDirectory, + projectDirectory, + baselineDiffPlan: null, + diffInfo: buildDiffInfo(["apps/web/src/app.tsx", "apps/web/package.json"]), + isDiffMode: true, + supplyChainEnabled: true, + }), + ).toEqual({ + includePaths: ["src/app.tsx", "package.json"], + projectBaselineBaseFiles: null, + projectBaselineHeadFiles: null, + shouldSkipProject: false, + supplyChainManifestChanged: true, + }); + }); +}); diff --git a/packages/react-doctor/tests/clear-caches.test.ts b/packages/react-doctor/tests/clear-caches.test.ts index 234aabb733..f96e243771 100644 --- a/packages/react-doctor/tests/clear-caches.test.ts +++ b/packages/react-doctor/tests/clear-caches.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { + classifyPackageRole, clearMinifiedFileCache, isLargeMinifiedFile, MINIFIED_MAX_LINE_LENGTH_CHARS, @@ -40,4 +41,25 @@ describe("clearCaches", () => { // re-sniffs the now-small file. A surviving `true` means it was not wired. expect(isLargeMinifiedFile(bundlePath)).toBe(false); }); + + it("clears the memoized package role", () => { + const packageDirectory = path.join(temporaryDirectory, "package"); + const sourcePath = path.join(packageDirectory, "src", "button.tsx"); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, "export const button = null;\n"); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "@scope/ui", exports: { ".": "./index.js" } }), + ); + expect(classifyPackageRole(sourcePath)).toBe("library"); + + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "@scope/ui", private: true }), + ); + expect(classifyPackageRole(sourcePath)).toBe("library"); + + clearCaches(); + expect(classifyPackageRole(sourcePath)).toBe("unknown"); + }); }); diff --git a/packages/react-doctor/tests/core-configuration-boundary.test.ts b/packages/react-doctor/tests/core-configuration-boundary.test.ts new file mode 100644 index 0000000000..5fa29c40d4 --- /dev/null +++ b/packages/react-doctor/tests/core-configuration-boundary.test.ts @@ -0,0 +1,134 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreConfiguration from "../src/core/core-configuration.js"; +import * as reactDoctorApi from "../src/index.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_CONFIGURATION_RELATIVE_PATH = "core/core-configuration.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const CONFIGURATION_RUNTIME_CAPABILITIES = [ + "clearConfigCache", + "COMPILER_CLEANUP_BUCKET", + "COMPILER_CLEANUP_RULE_KEYS", + "DEFAULT_SHOW_WARNINGS", + "defineConfig", + "findLegacyConfig", + "LEGACY_CONFIG_FILENAME", + "loadConfigWithSource", + "mergeReactDoctorConfigs", + "validateConfigTypes", +] as const; +const CONFIGURATION_TYPE_CAPABILITIES = ["ReactDoctorConfig", "RuleSeverityOverride"] as const; +const CONFIGURATION_CAPABILITIES = [ + ...CONFIGURATION_RUNTIME_CAPABILITIES, + ...CONFIGURATION_TYPE_CAPABILITIES, +] as const; +const CONFIGURATION_CAPABILITY_SET = new Set(CONFIGURATION_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectConfigurationBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...CONFIGURATION_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return CONFIGURATION_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core configuration boundary", () => { + it("routes every production configuration capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectConfigurationBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_CONFIGURATION_RELATIVE_PATH}: ${CONFIGURATION_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreConfiguration).sort()).toEqual( + [...CONFIGURATION_RUNTIME_CAPABILITIES].sort(), + ); + expect(coreConfiguration).toMatchObject({ + clearConfigCache: core.clearConfigCache, + COMPILER_CLEANUP_BUCKET: core.COMPILER_CLEANUP_BUCKET, + COMPILER_CLEANUP_RULE_KEYS: core.COMPILER_CLEANUP_RULE_KEYS, + DEFAULT_SHOW_WARNINGS: core.DEFAULT_SHOW_WARNINGS, + defineConfig: core.defineConfig, + findLegacyConfig: core.findLegacyConfig, + LEGACY_CONFIG_FILENAME: core.LEGACY_CONFIG_FILENAME, + loadConfigWithSource: core.loadConfigWithSource, + mergeReactDoctorConfigs: core.mergeReactDoctorConfigs, + validateConfigTypes: core.validateConfigTypes, + }); + }); + + it("freezes the adapter's type capability set", () => { + const adapterPath = path.join(SOURCE_DIRECTORY, CORE_CONFIGURATION_RELATIVE_PATH); + const sourceFile = ts.createSourceFile( + adapterPath, + fs.readFileSync(adapterPath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const exportedTypes = sourceFile.statements.flatMap((statement) => + ts.isExportDeclaration(statement) && + statement.isTypeOnly && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ? statement.exportClause.elements.map((element) => element.name.text) + : [], + ); + + expect(exportedTypes.sort()).toEqual([...CONFIGURATION_TYPE_CAPABILITIES].sort()); + }); + + it("preserves the public defineConfig facade identity", () => { + expect(reactDoctorApi.defineConfig).toBe(core.defineConfig); + }); +}); diff --git a/packages/react-doctor/tests/core-diagnostic-semantics-boundary.test.ts b/packages/react-doctor/tests/core-diagnostic-semantics-boundary.test.ts new file mode 100644 index 0000000000..d8dc0edcd5 --- /dev/null +++ b/packages/react-doctor/tests/core-diagnostic-semantics-boundary.test.ts @@ -0,0 +1,102 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreDiagnosticSemantics from "../src/core/core-diagnostic-semantics.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_DIAGNOSTIC_SEMANTICS_RELATIVE_PATH = "core/core-diagnostic-semantics.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const DIAGNOSTIC_SEMANTICS_CAPABILITIES = [ + "canonicalizeUserRuleKey", + "computeDiagnosticDelta", + "DIAGNOSTIC_CATEGORY_BUCKETS", + "filterDiagnosticsForSurface", + "getDiagnosticRuleIdentity", + "getEquivalentRuleKeys", + "groupBy", + "isSameRuleKey", + "summarizeDiagnostics", +] as const; +const DIAGNOSTIC_SEMANTICS_CAPABILITY_SET = new Set(DIAGNOSTIC_SEMANTICS_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectDiagnosticSemanticsBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...DIAGNOSTIC_SEMANTICS_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return DIAGNOSTIC_SEMANTICS_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core diagnostic semantics boundary", () => { + it("routes every production diagnostic semantics capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectDiagnosticSemanticsBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_DIAGNOSTIC_SEMANTICS_RELATIVE_PATH}: ${DIAGNOSTIC_SEMANTICS_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreDiagnosticSemantics).sort()).toEqual( + [...DIAGNOSTIC_SEMANTICS_CAPABILITIES].sort(), + ); + expect(coreDiagnosticSemantics).toMatchObject({ + canonicalizeUserRuleKey: core.canonicalizeUserRuleKey, + computeDiagnosticDelta: core.computeDiagnosticDelta, + DIAGNOSTIC_CATEGORY_BUCKETS: core.DIAGNOSTIC_CATEGORY_BUCKETS, + filterDiagnosticsForSurface: core.filterDiagnosticsForSurface, + getDiagnosticRuleIdentity: core.getDiagnosticRuleIdentity, + getEquivalentRuleKeys: core.getEquivalentRuleKeys, + groupBy: core.groupBy, + isSameRuleKey: core.isSameRuleKey, + summarizeDiagnostics: core.summarizeDiagnostics, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-errors-boundary.test.ts b/packages/react-doctor/tests/core-errors-boundary.test.ts new file mode 100644 index 0000000000..663504030b --- /dev/null +++ b/packages/react-doctor/tests/core-errors-boundary.test.ts @@ -0,0 +1,122 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreErrors from "../src/core/core-errors.js"; +import * as reactDoctorApi from "../src/index.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_ERRORS_RELATIVE_PATH = "core/core-errors.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const ERROR_CAPABILITIES = [ + "AmbiguousProjectError", + "formatErrorChain", + "formatReactDoctorError", + "isErrnoException", + "isProjectDiscoveryError", + "isReactDoctorError", + "messageFromUnknown", + "NoReactDependencyError", + "NotADirectoryError", + "PackageJsonNotFoundError", + "ProjectNotFoundError", + "ReactDoctorError", + "restoreLegacyThrow", +] as const; +const ERROR_CAPABILITY_SET = new Set(ERROR_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectErrorBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...ERROR_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return ERROR_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core errors boundary", () => { + it("routes every production error capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectErrorBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_ERRORS_RELATIVE_PATH}: ${ERROR_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreErrors).sort()).toEqual([...ERROR_CAPABILITIES].sort()); + expect(coreErrors).toMatchObject({ + AmbiguousProjectError: core.AmbiguousProjectError, + formatErrorChain: core.formatErrorChain, + formatReactDoctorError: core.formatReactDoctorError, + isErrnoException: core.isErrnoException, + isProjectDiscoveryError: core.isProjectDiscoveryError, + isReactDoctorError: core.isReactDoctorError, + messageFromUnknown: core.messageFromUnknown, + NoReactDependencyError: core.NoReactDependencyError, + NotADirectoryError: core.NotADirectoryError, + PackageJsonNotFoundError: core.PackageJsonNotFoundError, + ProjectNotFoundError: core.ProjectNotFoundError, + ReactDoctorError: core.ReactDoctorError, + restoreLegacyThrow: core.restoreLegacyThrow, + }); + }); + + it("preserves the public error facade identities", () => { + expect(reactDoctorApi).toMatchObject({ + AmbiguousProjectError: core.AmbiguousProjectError, + isProjectDiscoveryError: core.isProjectDiscoveryError, + isReactDoctorError: core.isReactDoctorError, + NoReactDependencyError: core.NoReactDependencyError, + NotADirectoryError: core.NotADirectoryError, + PackageJsonNotFoundError: core.PackageJsonNotFoundError, + ProjectNotFoundError: core.ProjectNotFoundError, + ReactDoctorError: core.ReactDoctorError, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-presentation-boundary.test.ts b/packages/react-doctor/tests/core-presentation-boundary.test.ts new file mode 100644 index 0000000000..c845793976 --- /dev/null +++ b/packages/react-doctor/tests/core-presentation-boundary.test.ts @@ -0,0 +1,125 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CODE_FRAME_BATCH_MAX_SPAN_LINES, + CODE_FRAME_LINES_ABOVE, + CODE_FRAME_LINES_BELOW, + CODE_FRAME_MAX_LINE_LENGTH_CHARS, + createNodeReadFileLinesSync, + getCategoryImpact, + hasPublishedFixRecipe, + highlighter, + MIGRATION_SCALE_RULE_FILE_COUNT, + MIN_SHARED_FIX_SITE_COUNT, + OUTPUT_MEASURE_WIDTH_CHARS, + SCORE_BAR_WIDTH_CHARS, + setColorEnabled, + SPINNER_INDENT_CHARS, +} from "@react-doctor/core"; +import * as corePresentation from "../src/core/core-presentation.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_PRESENTATION_RELATIVE_PATH = "core/core-presentation.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const PRESENTATION_CAPABILITIES = [ + "CODE_FRAME_BATCH_MAX_SPAN_LINES", + "CODE_FRAME_LINES_ABOVE", + "CODE_FRAME_LINES_BELOW", + "CODE_FRAME_MAX_LINE_LENGTH_CHARS", + "createNodeReadFileLinesSync", + "getCategoryImpact", + "hasPublishedFixRecipe", + "highlighter", + "MIGRATION_SCALE_RULE_FILE_COUNT", + "MIN_SHARED_FIX_SITE_COUNT", + "OUTPUT_MEASURE_WIDTH_CHARS", + "SCORE_BAR_WIDTH_CHARS", + "setColorEnabled", + "SPINNER_INDENT_CHARS", +] as const; +const PRESENTATION_CAPABILITY_SET = new Set(PRESENTATION_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectPresentationBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...PRESENTATION_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return PRESENTATION_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core presentation boundary", () => { + it("routes every production presentation capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectPresentationBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_PRESENTATION_RELATIVE_PATH}: ${PRESENTATION_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(corePresentation).sort()).toEqual([...PRESENTATION_CAPABILITIES].sort()); + expect(corePresentation.CODE_FRAME_BATCH_MAX_SPAN_LINES).toBe(CODE_FRAME_BATCH_MAX_SPAN_LINES); + expect(corePresentation.CODE_FRAME_LINES_ABOVE).toBe(CODE_FRAME_LINES_ABOVE); + expect(corePresentation.CODE_FRAME_LINES_BELOW).toBe(CODE_FRAME_LINES_BELOW); + expect(corePresentation.CODE_FRAME_MAX_LINE_LENGTH_CHARS).toBe( + CODE_FRAME_MAX_LINE_LENGTH_CHARS, + ); + expect(corePresentation.createNodeReadFileLinesSync).toBe(createNodeReadFileLinesSync); + expect(corePresentation.getCategoryImpact).toBe(getCategoryImpact); + expect(corePresentation.hasPublishedFixRecipe).toBe(hasPublishedFixRecipe); + expect(corePresentation.highlighter).toBe(highlighter); + expect(corePresentation.MIGRATION_SCALE_RULE_FILE_COUNT).toBe(MIGRATION_SCALE_RULE_FILE_COUNT); + expect(corePresentation.MIN_SHARED_FIX_SITE_COUNT).toBe(MIN_SHARED_FIX_SITE_COUNT); + expect(corePresentation.OUTPUT_MEASURE_WIDTH_CHARS).toBe(OUTPUT_MEASURE_WIDTH_CHARS); + expect(corePresentation.SCORE_BAR_WIDTH_CHARS).toBe(SCORE_BAR_WIDTH_CHARS); + expect(corePresentation.setColorEnabled).toBe(setColorEnabled); + expect(corePresentation.SPINNER_INDENT_CHARS).toBe(SPINNER_INDENT_CHARS); + }); +}); diff --git a/packages/react-doctor/tests/core-project-discovery-boundary.test.ts b/packages/react-doctor/tests/core-project-discovery-boundary.test.ts new file mode 100644 index 0000000000..c97c79e9b3 --- /dev/null +++ b/packages/react-doctor/tests/core-project-discovery-boundary.test.ts @@ -0,0 +1,108 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreProjectDiscovery from "../src/core/core-project-discovery.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_PROJECT_DISCOVERY_RELATIVE_PATH = "core/core-project-discovery.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const PROJECT_DISCOVERY_CAPABILITIES = [ + "buildPackageGraph", + "discoverReactSubprojects", + "filterSourceFiles", + "hasReactRuntime", + "HTML_FILE_PATTERN", + "isDirectory", + "isFile", + "isMonorepoRoot", + "JSX_FILE_PATTERN", + "listSourceFiles", + "readPackageJson", + "resolveScanTarget", +] as const; +const PROJECT_DISCOVERY_CAPABILITY_SET = new Set(PROJECT_DISCOVERY_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectProjectDiscoveryBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...PROJECT_DISCOVERY_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return PROJECT_DISCOVERY_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core project discovery boundary", () => { + it("routes every production project discovery capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectProjectDiscoveryBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_PROJECT_DISCOVERY_RELATIVE_PATH}: ${PROJECT_DISCOVERY_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreProjectDiscovery).sort()).toEqual( + [...PROJECT_DISCOVERY_CAPABILITIES].sort(), + ); + expect(coreProjectDiscovery).toMatchObject({ + buildPackageGraph: core.buildPackageGraph, + discoverReactSubprojects: core.discoverReactSubprojects, + filterSourceFiles: core.filterSourceFiles, + hasReactRuntime: core.hasReactRuntime, + HTML_FILE_PATTERN: core.HTML_FILE_PATTERN, + isDirectory: core.isDirectory, + isFile: core.isFile, + isMonorepoRoot: core.isMonorepoRoot, + JSX_FILE_PATTERN: core.JSX_FILE_PATTERN, + listSourceFiles: core.listSourceFiles, + readPackageJson: core.readPackageJson, + resolveScanTarget: core.resolveScanTarget, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-reporting-boundary.test.ts b/packages/react-doctor/tests/core-reporting-boundary.test.ts new file mode 100644 index 0000000000..80a9ff8241 --- /dev/null +++ b/packages/react-doctor/tests/core-reporting-boundary.test.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import type { Diagnostic as CoreSchemaDiagnostic } from "@react-doctor/core/schemas"; +import * as coreReporting from "../src/core/core-reporting.js"; +import type { LiveDiagnostic } from "../src/core/core-reporting.js"; +import * as reactDoctorApi from "../src/index.js"; +import * as ts from "typescript"; +import { describe, expect, expectTypeOf, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_REPORTING_RELATIVE_PATH = "core/core-reporting.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const REPORTING_CAPABILITIES: ReadonlyArray = [ + "buildJsonReport", + "buildJsonReportError", + "buildSkippedChecks", + "isScanComplete", +]; +const REPORTING_CAPABILITY_SET = new Set(REPORTING_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectReportingBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...REPORTING_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return REPORTING_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core reporting boundary", () => { + it("routes every production reporting capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectReportingBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_REPORTING_RELATIVE_PATH}: ${REPORTING_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreReporting).sort()).toEqual([...REPORTING_CAPABILITIES].sort()); + expect(coreReporting).toMatchObject({ + buildJsonReport: core.buildJsonReport, + buildJsonReportError: core.buildJsonReportError, + buildSkippedChecks: core.buildSkippedChecks, + isScanComplete: core.isScanComplete, + }); + }); + + it("preserves the public reporting facade identities", () => { + expect(reactDoctorApi).toMatchObject({ + buildJsonReport: core.buildJsonReport, + buildJsonReportError: core.buildJsonReportError, + }); + }); + + it("preserves the schema-derived live diagnostic type exactly", () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/react-doctor/tests/core-runtime-boundary.test.ts b/packages/react-doctor/tests/core-runtime-boundary.test.ts new file mode 100644 index 0000000000..8de964b2b5 --- /dev/null +++ b/packages/react-doctor/tests/core-runtime-boundary.test.ts @@ -0,0 +1,143 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreRuntime from "../src/core/core-runtime.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_RUNTIME_RELATIVE_PATH = "core/core-runtime.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const RUNTIME_CAPABILITIES = [ + "Config", + "createOxlintSpawnSlots", + "DeadCode", + "DEFAULT_PROJECT_SCAN_CONCURRENCY", + "detectAiTrainingEnvironment", + "Files", + "Git", + "layerOtlp", + "Linter", + "LintPartialFailures", + "mapWithConcurrency", + "MILLISECONDS_PER_SECOND", + "MIN_SCAN_CONCURRENCY", + "NodeResolver", + "OxlintConcurrency", + "OXLINT_NODE_REQUIREMENT", + "OXLINT_RECOMMENDED_NODE_MAJOR", + "OxlintSpawnSlots", + "PerFileLintCacheEnabled", + "Progress", + "Project", + "ProjectChecks", + "Reporter", + "resolveScanConcurrency", + "runInspect", + "SidecarLintCacheEnabled", + "StagedFiles", + "SupplyChain", +]; +const RUNTIME_CAPABILITY_SET = new Set(RUNTIME_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectRuntimeBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + const isTypeOnlyDeclaration = ts.isImportDeclaration(statement) + ? statement.importClause?.isTypeOnly === true + : statement.isTypeOnly; + if (isTypeOnlyDeclaration) return []; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...RUNTIME_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + if (element.isTypeOnly) return []; + const importedName = (element.propertyName ?? element.name).text; + return RUNTIME_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core runtime boundary", () => { + it("routes every production runtime capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectRuntimeBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_RUNTIME_RELATIVE_PATH}: ${RUNTIME_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreRuntime).sort()).toEqual([...RUNTIME_CAPABILITIES].sort()); + expect(coreRuntime).toMatchObject({ + Config: core.Config, + createOxlintSpawnSlots: core.createOxlintSpawnSlots, + DeadCode: core.DeadCode, + DEFAULT_PROJECT_SCAN_CONCURRENCY: core.DEFAULT_PROJECT_SCAN_CONCURRENCY, + detectAiTrainingEnvironment: core.detectAiTrainingEnvironment, + Files: core.Files, + Git: core.Git, + layerOtlp: core.layerOtlp, + Linter: core.Linter, + LintPartialFailures: core.LintPartialFailures, + mapWithConcurrency: core.mapWithConcurrency, + MILLISECONDS_PER_SECOND: core.MILLISECONDS_PER_SECOND, + MIN_SCAN_CONCURRENCY: core.MIN_SCAN_CONCURRENCY, + NodeResolver: core.NodeResolver, + OxlintConcurrency: core.OxlintConcurrency, + OXLINT_NODE_REQUIREMENT: core.OXLINT_NODE_REQUIREMENT, + OXLINT_RECOMMENDED_NODE_MAJOR: core.OXLINT_RECOMMENDED_NODE_MAJOR, + OxlintSpawnSlots: core.OxlintSpawnSlots, + PerFileLintCacheEnabled: core.PerFileLintCacheEnabled, + Progress: core.Progress, + Project: core.Project, + ProjectChecks: core.ProjectChecks, + Reporter: core.Reporter, + resolveScanConcurrency: core.resolveScanConcurrency, + runInspect: core.runInspect, + SidecarLintCacheEnabled: core.SidecarLintCacheEnabled, + StagedFiles: core.StagedFiles, + SupplyChain: core.SupplyChain, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-score-boundary.test.ts b/packages/react-doctor/tests/core-score-boundary.test.ts new file mode 100644 index 0000000000..f5fce2d792 --- /dev/null +++ b/packages/react-doctor/tests/core-score-boundary.test.ts @@ -0,0 +1,96 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreScore from "../src/core/core-score.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_SCORE_RELATIVE_PATH = "core/core-score.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const SCORE_CAPABILITIES = [ + "calculateScore", + "PERFECT_SCORE", + "resolveGithubActionsScoreMetadata", + "SCORE_GOOD_THRESHOLD", + "SCORE_OK_THRESHOLD", + "Score", + "TOP_ERRORS_DISPLAY_COUNT", +] as const; +const SCORE_CAPABILITY_SET = new Set(SCORE_CAPABILITIES); + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectScoreBindings = (filePath: string): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...SCORE_CAPABILITIES]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return SCORE_CAPABILITY_SET.has(importedName) ? [importedName] : []; + }); + }); +}; + +describe("React Doctor core score boundary", () => { + it("routes every production score capability through one adapter", () => { + const directDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectScoreBindings(filePath); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + + expect(directDependents).toEqual([ + `${CORE_SCORE_RELATIVE_PATH}: ${SCORE_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the adapter's runtime capability set and identities", () => { + expect(Object.keys(coreScore).sort()).toEqual([...SCORE_CAPABILITIES].sort()); + expect(coreScore).toMatchObject({ + calculateScore: core.calculateScore, + PERFECT_SCORE: core.PERFECT_SCORE, + resolveGithubActionsScoreMetadata: core.resolveGithubActionsScoreMetadata, + SCORE_GOOD_THRESHOLD: core.SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD: core.SCORE_OK_THRESHOLD, + Score: core.Score, + TOP_ERRORS_DISPLAY_COUNT: core.TOP_ERRORS_DISPLAY_COUNT, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-shared-boundaries.test.ts b/packages/react-doctor/tests/core-shared-boundaries.test.ts new file mode 100644 index 0000000000..a8cf19003f --- /dev/null +++ b/packages/react-doctor/tests/core-shared-boundaries.test.ts @@ -0,0 +1,125 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as corePrimitives from "../src/core/core-primitives.js"; +import * as coreProduct from "../src/core/core-product.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const PRIMITIVE_CAPABILITIES: ReadonlyArray = [ + "isPlainObject", + "redactSensitiveText", + "scrubSensitivePaths", + "toRelativePath", +]; +const PRODUCT_CAPABILITIES: ReadonlyArray = [ + "buildRuleDocsUrl", + "CANONICAL_DISCORD_URL", + "CANONICAL_GITHUB_URL", + "CI_URL", + "CONFIG_SCHEMA_URL", + "DOCS_URL", + "ENTERPRISE_CONTACT_URL", + "GITHUB_ACTIONS_SETUP_URL", + "SHARE_BASE_URL", + "SKILL_NAME", +]; + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectBindings = (filePath: string, capabilities: ReadonlyArray): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const capabilitySet = new Set(capabilities); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...capabilities]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return capabilitySet.has(importedName) ? [importedName] : []; + }); + }); +}; + +const collectDirectDependents = (capabilities: ReadonlyArray): string[] => + collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectBindings(filePath, capabilities); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + +describe("React Doctor core shared boundaries", () => { + it("routes product metadata through its private adapter", () => { + expect(collectDirectDependents(PRODUCT_CAPABILITIES)).toEqual([ + `core/core-product.ts: ${PRODUCT_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the product exports and identities", () => { + expect(Object.keys(coreProduct).sort()).toEqual([...PRODUCT_CAPABILITIES].sort()); + expect(coreProduct).toMatchObject({ + buildRuleDocsUrl: core.buildRuleDocsUrl, + CANONICAL_DISCORD_URL: core.CANONICAL_DISCORD_URL, + CANONICAL_GITHUB_URL: core.CANONICAL_GITHUB_URL, + CI_URL: core.CI_URL, + CONFIG_SCHEMA_URL: core.CONFIG_SCHEMA_URL, + DOCS_URL: core.DOCS_URL, + ENTERPRISE_CONTACT_URL: core.ENTERPRISE_CONTACT_URL, + GITHUB_ACTIONS_SETUP_URL: core.GITHUB_ACTIONS_SETUP_URL, + SHARE_BASE_URL: core.SHARE_BASE_URL, + SKILL_NAME: core.SKILL_NAME, + }); + }); + + it("routes generic primitives through their private adapter", () => { + expect(collectDirectDependents(PRIMITIVE_CAPABILITIES)).toEqual([ + `core/core-primitives.ts: ${PRIMITIVE_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes the primitive exports and identities", () => { + expect(Object.keys(corePrimitives).sort()).toEqual([...PRIMITIVE_CAPABILITIES].sort()); + expect(corePrimitives).toMatchObject({ + isPlainObject: core.isPlainObject, + redactSensitiveText: core.redactSensitiveText, + scrubSensitivePaths: core.scrubSensitivePaths, + toRelativePath: core.toRelativePath, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-source-boundaries.test.ts b/packages/react-doctor/tests/core-source-boundaries.test.ts new file mode 100644 index 0000000000..efdd904dd8 --- /dev/null +++ b/packages/react-doctor/tests/core-source-boundaries.test.ts @@ -0,0 +1,126 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "@react-doctor/core"; +import * as coreScanCache from "../src/core/core-scan-cache.js"; +import * as coreVersionControl from "../src/core/core-version-control.js"; +import * as reactDoctorApi from "../src/index.js"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const VERSION_CONTROL_CAPABILITIES = [ + "getBaselineDiffPlan", + "getChangedLineRanges", + "getDiffInfo", + "GIT_SHOW_MAX_BUFFER_BYTES", + "materializeSourceTree", + "STAGED_FILES_PROJECT_CONFIG_FILENAMES", +] as const; +const SCAN_CACHE_CAPABILITIES = [ + "clearCoreCaches", + "computeConfigFingerprint", + "hashFileContents", + "resolveLintBatchOrdering", + "resolveReactDoctorCacheDir", +] as const; + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const collectDirectBindings = (filePath: string, capabilities: ReadonlyArray): string[] => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const capabilitySet = new Set(capabilities); + + return sourceFile.statements.flatMap((statement) => { + if ( + (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || + statement.moduleSpecifier === undefined || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== CORE_PACKAGE_SPECIFIER + ) { + return []; + } + + const namedBindings = ts.isImportDeclaration(statement) + ? statement.importClause?.namedBindings + : statement.exportClause; + if ( + namedBindings === undefined || + ts.isNamespaceImport(namedBindings) || + ts.isNamespaceExport(namedBindings) + ) { + return [...capabilities]; + } + + return namedBindings.elements.flatMap((element) => { + const importedName = (element.propertyName ?? element.name).text; + return capabilitySet.has(importedName) ? [importedName] : []; + }); + }); +}; + +const collectDirectDependents = (capabilities: ReadonlyArray): string[] => + collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const importedCapabilities = collectDirectBindings(filePath, capabilities); + if (importedCapabilities.length === 0) return []; + + return [ + `${path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")}: ${importedCapabilities.join(", ")}`, + ]; + }); + +describe("React Doctor core source boundaries", () => { + it("routes version-control capabilities through their private adapter", () => { + expect(collectDirectDependents(VERSION_CONTROL_CAPABILITIES)).toEqual([ + `core/core-version-control.ts: ${VERSION_CONTROL_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes version-control exports and identities", () => { + expect(Object.keys(coreVersionControl).sort()).toEqual( + [...VERSION_CONTROL_CAPABILITIES].sort(), + ); + expect(coreVersionControl).toMatchObject({ + getBaselineDiffPlan: core.getBaselineDiffPlan, + getChangedLineRanges: core.getChangedLineRanges, + getDiffInfo: core.getDiffInfo, + GIT_SHOW_MAX_BUFFER_BYTES: core.GIT_SHOW_MAX_BUFFER_BYTES, + materializeSourceTree: core.materializeSourceTree, + STAGED_FILES_PROJECT_CONFIG_FILENAMES: core.STAGED_FILES_PROJECT_CONFIG_FILENAMES, + }); + }); + + it("preserves the public getDiffInfo facade identity", () => { + expect(reactDoctorApi.getDiffInfo).toBe(core.getDiffInfo); + }); + + it("routes scan-cache capabilities through their private adapter", () => { + expect(collectDirectDependents(SCAN_CACHE_CAPABILITIES)).toEqual([ + `core/core-scan-cache.ts: ${SCAN_CACHE_CAPABILITIES.join(", ")}`, + ]); + }); + + it("freezes scan-cache exports and identities", () => { + expect(Object.keys(coreScanCache).sort()).toEqual([...SCAN_CACHE_CAPABILITIES].sort()); + expect(coreScanCache).toMatchObject({ + clearCoreCaches: core.clearCoreCaches, + computeConfigFingerprint: core.computeConfigFingerprint, + hashFileContents: core.hashFileContents, + resolveLintBatchOrdering: core.resolveLintBatchOrdering, + resolveReactDoctorCacheDir: core.resolveReactDoctorCacheDir, + }); + }); +}); diff --git a/packages/react-doctor/tests/core-type-boundary.test.ts b/packages/react-doctor/tests/core-type-boundary.test.ts new file mode 100644 index 0000000000..b458ce7037 --- /dev/null +++ b/packages/react-doctor/tests/core-type-boundary.test.ts @@ -0,0 +1,158 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url)); +const CORE_TYPES_RELATIVE_PATH = "core/core-types.ts"; +const CORE_PACKAGE_SPECIFIER = "@react-doctor/core"; +const CORE_ADAPTER_DIRECTORY = "core"; +const CORE_ADAPTER_FILE_PATTERN = /^core-[a-z0-9-]+\.ts$/; + +const collectTypeScriptFiles = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) + ? [entryPath] + : []; + }); + +const isTypeOnlyDeclaration = ( + declaration: ts.ImportDeclaration | ts.ExportDeclaration, +): boolean => { + if (ts.isImportDeclaration(declaration)) { + const importClause = declaration.importClause; + if (importClause?.isTypeOnly) return true; + return ( + importClause?.namedBindings !== undefined && + ts.isNamedImports(importClause.namedBindings) && + importClause.namedBindings.elements.every((element) => element.isTypeOnly) + ); + } + + if (declaration.isTypeOnly) return true; + return ( + declaration.exportClause !== undefined && + ts.isNamedExports(declaration.exportClause) && + declaration.exportClause.elements.every((element) => element.isTypeOnly) + ); +}; + +const collectCoreDeclarations = ( + filePath: string, +): ReadonlyArray => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + return sourceFile.statements.filter( + (statement): statement is ts.ImportDeclaration | ts.ExportDeclaration => + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === CORE_PACKAGE_SPECIFIER, + ); +}; + +const containsCorePackageSpecifier = (filePath: string): boolean => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + let containsSpecifier = false; + const visitNode = (node: ts.Node): void => { + if ( + ts.isStringLiteral(node) && + (node.text === CORE_PACKAGE_SPECIFIER || node.text.startsWith(`${CORE_PACKAGE_SPECIFIER}/`)) + ) { + containsSpecifier = true; + return; + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return containsSpecifier; +}; + +describe("React Doctor core type boundary", () => { + it("routes every production core package dependency through an explicit adapter", () => { + const directCoreDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + if (!containsCorePackageSpecifier(filePath)) return []; + + const relativePath = path.relative(SOURCE_DIRECTORY, filePath); + const isCoreAdapter = + path.dirname(relativePath) === CORE_ADAPTER_DIRECTORY && + CORE_ADAPTER_FILE_PATTERN.test(path.basename(relativePath)); + return isCoreAdapter ? [] : [relativePath.replaceAll(path.sep, "/")]; + }); + + expect(directCoreDependents).toEqual([]); + }); + + it("routes every pure type-only core dependency through one adapter", () => { + const pureTypeCoreDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => { + const declarations = collectCoreDeclarations(filePath); + return declarations.length > 0 && declarations.every(isTypeOnlyDeclaration) + ? [path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")] + : []; + }); + + expect(pureTypeCoreDependents).toEqual([CORE_TYPES_RELATIVE_PATH]); + }); + + it("freezes the adapter's type capability set", () => { + const adapterPath = path.join(SOURCE_DIRECTORY, CORE_TYPES_RELATIVE_PATH); + const exportedNames = collectCoreDeclarations(adapterPath).flatMap((declaration) => + ts.isExportDeclaration(declaration) && + declaration.exportClause !== undefined && + ts.isNamedExports(declaration.exportClause) + ? declaration.exportClause.elements.map((element) => element.name.text) + : [], + ); + + expect(exportedNames.sort()).toEqual( + [ + "BlockingLevel", + "ChangedFileLineRanges", + "Diagnostic", + "DiagnosticSurface", + "DiagnoseOptions", + "DiagnoseProjectsInput", + "DiagnoseProjectsResult", + "DiagnoseResult", + "DiffInfo", + "GitBaselineDiffPlan", + "HandleErrorOptions", + "InspectOptions", + "InspectOutput", + "InspectResult", + "LegacyConfigLocation", + "MaterializedTree", + "Progress", + "ProgressHandle", + "ProjectDefinition", + "ProjectInfo", + "ProjectResult", + "ProjectResultError", + "ProjectResultOk", + "PromptMultiselectChoiceState", + "PromptMultiselectContext", + "ReactDoctorConfigFormat", + "Reporter", + "ResolvedScanTarget", + "ScopeValue", + "ScoreResult", + "StagedSnapshot", + "SuppressedRuleCount", + "WorkerSlots", + "WorkspacePackage", + ].sort(), + ); + }); +}); diff --git a/packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts b/packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts new file mode 100644 index 0000000000..42f659ddac --- /dev/null +++ b/packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts @@ -0,0 +1,107 @@ +import path from "node:path"; +import type { Diagnostic } from "@react-doctor/core"; +import { describe, expect, it } from "vite-plus/test"; +import { filterDiagnosticsByChangedLines } from "../src/cli/utils/filter-diagnostics-by-changed-lines.js"; + +const buildDiagnostic = ( + filePath: string, + line: number, + overrides: Partial = {}, +): Diagnostic => ({ + filePath, + plugin: "react-doctor", + rule: "button-has-type", + severity: "error", + message: "Button is missing an explicit type", + help: 'Add type="button"', + line, + column: 1, + category: "Accessibility", + ...overrides, +}); + +describe("filterDiagnosticsByChangedLines", () => { + it("preserves diagnostic order while matching relative and absolute paths", () => { + const directory = path.resolve("/workspace/project"); + const firstDiagnostic = buildDiagnostic("src/first.tsx", 3); + const droppedDiagnostic = buildDiagnostic("src/dropped.tsx", 7); + const secondDiagnostic = buildDiagnostic(path.join(directory, "src/second.tsx"), 9); + + expect( + filterDiagnosticsByChangedLines({ + directory, + diagnostics: [firstDiagnostic, droppedDiagnostic, secondDiagnostic], + changedLineRanges: [ + { file: "src/second.tsx", ranges: [[9, 9]] }, + { file: "src/first.tsx", ranges: [[3, 3]] }, + ], + }), + ).toEqual([firstDiagnostic, secondDiagnostic]); + }); + + it("normalizes backslashes in changed file paths", () => { + const diagnostic = buildDiagnostic("src/App.tsx", 4); + + expect( + filterDiagnosticsByChangedLines({ + directory: "/workspace/project", + diagnostics: [diagnostic], + changedLineRanges: [{ file: "src\\App.tsx", ranges: [[4, 4]] }], + }), + ).toEqual([diagnostic]); + }); + + it("uses the final entry when a changed file is listed more than once", () => { + const diagnostic = buildDiagnostic("src/App.tsx", 4); + + expect( + filterDiagnosticsByChangedLines({ + directory: "/workspace/project", + diagnostics: [diagnostic], + changedLineRanges: [ + { file: "src/App.tsx", ranges: [[4, 4]] }, + { file: "src/App.tsx", ranges: [[8, 8]] }, + ], + }), + ).toEqual([]); + }); + + it("keeps a multiline diagnostic when a changed continuation line intersects", () => { + const diagnostic = buildDiagnostic("src/App.tsx", 2, { endLine: 6 }); + + expect( + filterDiagnosticsByChangedLines({ + directory: "/workspace/project", + diagnostics: [diagnostic], + changedLineRanges: [{ file: "src/App.tsx", ranges: [[5, 5]] }], + }), + ).toEqual([diagnostic]); + }); + + it("uses the anchor line when the diagnostic end line is absent", () => { + const diagnostic = buildDiagnostic("src/App.tsx", 2); + + expect( + filterDiagnosticsByChangedLines({ + directory: "/workspace/project", + diagnostics: [diagnostic], + changedLineRanges: [{ file: "src/App.tsx", ranges: [[3, 3]] }], + }), + ).toEqual([]); + }); + + it("returns no diagnostics for empty ranges or files without a range entry", () => { + const diagnostics = [ + buildDiagnostic("src/empty.tsx", 2), + buildDiagnostic("src/missing.tsx", 2), + ]; + + expect( + filterDiagnosticsByChangedLines({ + directory: "/workspace/project", + diagnostics, + changedLineRanges: [{ file: "src/empty.tsx", ranges: [] }], + }), + ).toEqual([]); + }); +}); diff --git a/packages/react-doctor/tests/find-owning-project.test.ts b/packages/react-doctor/tests/find-owning-project.test.ts index 8c73bd2b4c..1f1bcdfc08 100644 --- a/packages/react-doctor/tests/find-owning-project.test.ts +++ b/packages/react-doctor/tests/find-owning-project.test.ts @@ -19,6 +19,38 @@ describe("findOwningProjectDirectory", () => { expect(findOwningProjectDirectory(projectDir, "src/index.tsx")).toBe(projectDir); }); + it("finds a nested React package under a standalone React root", () => { + const projectRoot = setupReactProject(tempRoot, "standalone-with-nested"); + const nestedProjectDirectory = setupReactProject( + path.join(projectRoot, "examples"), + "playground", + ); + + expect( + findOwningProjectDirectory(projectRoot, path.join(nestedProjectDirectory, "src", "App.tsx")), + ).toBe(nestedProjectDirectory); + }); + + it("keeps package-less pnpm workspaces on fallback discovery", () => { + const workspaceRoot = path.join(tempRoot, "package-less-pnpm"); + const projectDirectory = setupReactProject(path.join(workspaceRoot, "apps"), "web"); + fs.writeFileSync(path.join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - apps/*\n"); + + expect( + findOwningProjectDirectory(workspaceRoot, path.join(projectDirectory, "src", "App.tsx")), + ).toBe(projectDirectory); + }); + + it("keeps package-less Nx workspaces on fallback discovery", () => { + const workspaceRoot = path.join(tempRoot, "package-less-nx"); + const projectDirectory = setupReactProject(path.join(workspaceRoot, "apps"), "web"); + writeJson(path.join(workspaceRoot, "nx.json"), {}); + + expect( + findOwningProjectDirectory(workspaceRoot, path.join(projectDirectory, "src", "App.tsx")), + ).toBe(projectDirectory); + }); + it("returns the workspace package whose directory contains the file", () => { const monorepoRoot = path.join(tempRoot, "monorepo"); fs.mkdirSync(monorepoRoot, { recursive: true }); @@ -77,4 +109,42 @@ describe("findOwningProjectDirectory", () => { path.join(monorepoRoot, "packages/web"), ); }); + + it("returns the deepest nested React workspace that contains the file", () => { + const monorepoRoot = path.join(tempRoot, "nested-monorepo"); + const shellDirectory = path.join(monorepoRoot, "packages", "shell"); + const featureDirectory = path.join(shellDirectory, "features", "billing"); + fs.mkdirSync(monorepoRoot, { recursive: true }); + writeJson(path.join(monorepoRoot, "package.json"), { + name: "nested-monorepo", + private: true, + workspaces: ["packages/shell", "packages/shell/features/billing"], + }); + setupReactProject(path.join(monorepoRoot, "packages"), "shell"); + setupReactProject(path.join(shellDirectory, "features"), "billing"); + + expect( + findOwningProjectDirectory(monorepoRoot, path.join(featureDirectory, "src", "invoice.tsx")), + ).toBe(featureDirectory); + }); + + it("skips a nested non-React workspace when finding the owning React project", () => { + const monorepoRoot = path.join(tempRoot, "nested-tool-monorepo"); + const webDirectory = path.join(monorepoRoot, "packages", "web"); + const generatorDirectory = path.join(webDirectory, "tools", "generator"); + fs.mkdirSync(monorepoRoot, { recursive: true }); + writeJson(path.join(monorepoRoot, "package.json"), { + name: "nested-tool-monorepo", + private: true, + workspaces: ["packages/web", "packages/web/tools/generator"], + }); + setupReactProject(path.join(monorepoRoot, "packages"), "web"); + writeJson(path.join(generatorDirectory, "package.json"), { + name: "generator", + }); + + expect( + findOwningProjectDirectory(monorepoRoot, path.join(generatorDirectory, "src", "index.ts")), + ).toBe(webDirectory); + }); }); diff --git a/packages/react-doctor/tests/ink/run-scan-app.test.ts b/packages/react-doctor/tests/ink/run-scan-app.test.ts index c66ada8fe9..8d46394374 100644 --- a/packages/react-doctor/tests/ink/run-scan-app.test.ts +++ b/packages/react-doctor/tests/ink/run-scan-app.test.ts @@ -5,7 +5,7 @@ import type { InspectResult, ResolvedScanTarget } from "@react-doctor/core"; import { Reporter, resolveScanTarget } from "@react-doctor/core"; import { runScanApp } from "../../src/cli/ink/run-scan-app.js"; import type { ScanStore, TuiHandoffRequest } from "../../src/cli/ink/scan-store.js"; -import { inspect } from "../../src/inspect.js"; +import { createInvocationInspect, inspect } from "../../src/inspect.js"; import { buildDiagnostic, buildTestProject } from "../regressions/_helpers.js"; interface MockScanAppProps { @@ -62,13 +62,17 @@ vi.mock("@react-doctor/core", async (importOriginal) => { }; }); -vi.mock("../../src/inspect.js", () => ({ - inspect: vi.fn(async (directory: string): Promise => { +vi.mock("../../src/inspect.js", () => { + const inspect = vi.fn(async (directory: string): Promise => { const result = mockState.inspectResults.get(directory); if (!result) throw new Error(`Missing inspect result for ${directory}`); return result; - }), -})); + }); + return { + inspect, + createInvocationInspect: vi.fn(() => inspect), + }; +}); vi.mock("../../src/cli/utils/select-projects.js", () => ({ discoverWorkspacePackages: vi.fn(() => []), @@ -199,6 +203,8 @@ describe("runScanApp", () => { expect(resolveScanTarget).toHaveBeenCalledWith(requestedAdminDirectory, { allowAmbiguous: true, }); + expect(createInvocationInspect).toHaveBeenCalledTimes(1); + expect(createInvocationInspect).toHaveBeenCalledWith(undefined); expect(inspect).toHaveBeenCalledTimes(2); expect(inspect).toHaveBeenNthCalledWith( 1, diff --git a/packages/react-doctor/tests/inspect-action-exit-code.test.ts b/packages/react-doctor/tests/inspect-action-exit-code.test.ts index 45c7464e62..e61a0fd2ef 100644 --- a/packages/react-doctor/tests/inspect-action-exit-code.test.ts +++ b/packages/react-doctor/tests/inspect-action-exit-code.test.ts @@ -42,12 +42,16 @@ vi.mock("@react-doctor/core", async (importOriginal) => { }; }); -vi.mock("../src/inspect.js", () => ({ - inspect: vi.fn(async (): Promise => { +vi.mock("../src/inspect.js", () => { + const inspect = vi.fn(async (): Promise => { if (mockState.result === undefined) throw new Error("mockState.result not set"); return mockState.result; - }), -})); + }); + return { + inspect, + createInvocationInspect: vi.fn(() => inspect), + }; +}); vi.mock("../src/cli/utils/select-projects.js", () => ({ selectProjects: vi.fn(async () => mockState.projectDirectories), 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 a335c3441d..8723a40d06 100644 --- a/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts +++ b/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts @@ -54,8 +54,8 @@ vi.mock("@react-doctor/core", async (importOriginal) => { }; }); -vi.mock("../src/inspect.js", () => ({ - inspect: vi.fn( +vi.mock("../src/inspect.js", () => { + const inspect = vi.fn( async (directory: string): Promise => ({ diagnostics: [], score: null, @@ -89,8 +89,12 @@ vi.mock("../src/inspect.js", () => ({ }, elapsedMilliseconds: 1, }), - ), -})); + ); + return { + inspect, + createInvocationInspect: vi.fn(() => inspect), + }; +}); vi.mock("../src/cli/utils/select-projects.js", () => ({ selectProjects: vi.fn(async () => mockState.projectDirectories), diff --git a/packages/react-doctor/tests/inspect-action-staged-guard.test.ts b/packages/react-doctor/tests/inspect-action-staged-guard.test.ts index 5bddc0df3e..b2b294a5bd 100644 --- a/packages/react-doctor/tests/inspect-action-staged-guard.test.ts +++ b/packages/react-doctor/tests/inspect-action-staged-guard.test.ts @@ -15,8 +15,8 @@ vi.mock("../src/cli/utils/handle-error.js", () => ({ handleUserError: vi.fn(), })); -vi.mock("../src/inspect.js", () => ({ - inspect: vi.fn( +vi.mock("../src/inspect.js", () => { + const inspect = vi.fn( async (directory: string): Promise => ({ diagnostics: [], score: null, @@ -47,8 +47,12 @@ vi.mock("../src/inspect.js", () => ({ }, elapsedMilliseconds: 1, }), - ), -})); + ); + return { + inspect, + createInvocationInspect: vi.fn(() => inspect), + }; +}); const temporaryDirectories: string[] = []; diff --git a/packages/react-doctor/tests/inspect-cache-output-parity.test.ts b/packages/react-doctor/tests/inspect-cache-output-parity.test.ts new file mode 100644 index 0000000000..eafe5d4424 --- /dev/null +++ b/packages/react-doctor/tests/inspect-cache-output-parity.test.ts @@ -0,0 +1,113 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { InspectResult } from "../src/core/core-types.js"; +import { recordRunEvent } from "../src/cli/utils/build-run-event.js"; +import { inspect } from "../src/inspect.js"; +import { commitAll, initGitRepo, setupReactProject } from "./regressions/_helpers.js"; + +interface MockSpinner { + text: string; + readonly start: () => MockSpinner; + readonly stop: () => void; + readonly succeed: () => void; + readonly fail: () => void; +} + +interface CapturedConsoleEvent { + readonly method: "debug" | "error" | "info" | "log" | "warn"; + readonly argumentsList: ReadonlyArray; +} + +interface CapturedInspectRun { + readonly result: InspectResult; + readonly events: ReadonlyArray; +} + +const FIXED_PERFORMANCE_TIME_MS = 1_000; +const temporaryDirectories: string[] = []; + +vi.mock("../src/cli/utils/build-run-event.js", () => ({ + recordRunEvent: vi.fn(), +})); + +vi.mock("ora", () => ({ + default: (): MockSpinner => { + let spinner: MockSpinner; + spinner = { + text: "", + start: () => spinner, + stop: () => {}, + succeed: () => {}, + fail: () => {}, + }; + return spinner; + }, +})); + +const captureInspectRun = async (projectDirectory: string): Promise => { + const events: CapturedConsoleEvent[] = []; + const recordEvent = + (method: CapturedConsoleEvent["method"]) => + (...argumentsList: unknown[]): void => { + events.push({ + method, + argumentsList: argumentsList.map(String), + }); + }; + const debugSpy = vi.spyOn(console, "debug").mockImplementation(recordEvent("debug")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(recordEvent("error")); + const infoSpy = vi.spyOn(console, "info").mockImplementation(recordEvent("info")); + const logSpy = vi.spyOn(console, "log").mockImplementation(recordEvent("log")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(recordEvent("warn")); + + try { + const result = await inspect(projectDirectory, { + lint: false, + deadCode: false, + supplyChain: false, + noScore: true, + }); + return { result, events }; + } finally { + debugSpy.mockRestore(); + errorSpy.mockRestore(); + infoSpy.mockRestore(); + logSpy.mockRestore(); + warnSpy.mockRestore(); + } +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +describe("inspect whole-repository cache output parity", () => { + it("returns and renders byte-identical cold and replayed results", async () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-cache-output-parity-"), + ); + temporaryDirectories.push(temporaryDirectory); + const projectDirectory = setupReactProject(temporaryDirectory, "app"); + initGitRepo(projectDirectory); + commitAll(projectDirectory, "initial project"); + vi.stubEnv("REACT_DOCTOR_CACHE_DIR", path.join(temporaryDirectory, "react-doctor-cache")); + vi.spyOn(performance, "now").mockReturnValue(FIXED_PERFORMANCE_TIME_MS); + const mockedRecordRunEvent = vi.mocked(recordRunEvent); + + const coldRun = await captureInspectRun(projectDirectory); + expect(mockedRecordRunEvent.mock.calls.at(-1)?.[1].wholeRepoCacheHit).toBe(false); + const replayedRun = await captureInspectRun(projectDirectory); + + expect(mockedRecordRunEvent.mock.calls.at(-1)?.[1].wholeRepoCacheHit).toBe(true); + expect(replayedRun.result).toEqual(coldRun.result); + expect(replayedRun.events).toEqual(coldRun.events); + expect(coldRun.events.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react-doctor/tests/onboarding-state.test.ts b/packages/react-doctor/tests/onboarding-state.test.ts index 7502fc42d5..36e961d9cd 100644 --- a/packages/react-doctor/tests/onboarding-state.test.ts +++ b/packages/react-doctor/tests/onboarding-state.test.ts @@ -2,14 +2,13 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import * as fs from "node:fs"; +import { getCliStatePath } from "../src/cli/utils/cli-state-store.js"; import { - getOnboardingConfigPath, hasCompletedOnboarding, markOnboardingComplete, } from "../src/cli/utils/onboarding-state.js"; import { disableSetupPrompt, - getSetupPromptConfigPath, hasDisabledSetupPrompt, } from "../src/cli/utils/prompt-install-setup.js"; @@ -38,8 +37,8 @@ describe("onboarding state", () => { it("keeps the first-run timestamp stable across repeated marks", () => { const readOnboardingFiredAt = (): unknown => - JSON.parse(fs.readFileSync(getOnboardingConfigPath({ cwd: configRoot }), "utf8")).global - ?.events?.onboarding?.firedAt; + JSON.parse(fs.readFileSync(getCliStatePath({ cwd: configRoot }), "utf8")).global?.events + ?.onboarding?.firedAt; markOnboardingComplete({ cwd: configRoot }); const firstStamp = readOnboardingFiredAt(); @@ -56,9 +55,7 @@ describe("onboarding state", () => { disableSetupPrompt(projectRoot, { cwd: configRoot }); markOnboardingComplete({ cwd: configRoot }); - expect(getOnboardingConfigPath({ cwd: configRoot })).toBe( - getSetupPromptConfigPath({ cwd: configRoot }), - ); + expect(fs.existsSync(getCliStatePath({ cwd: configRoot }))).toBe(true); expect(hasDisabledSetupPrompt(projectRoot, { cwd: configRoot })).toBe(true); expect(hasCompletedOnboarding({ cwd: configRoot })).toBe(true); }); diff --git a/packages/react-doctor/tests/performance-harness.test.ts b/packages/react-doctor/tests/performance-harness.test.ts index ddb9cd1659..0572c16ff4 100644 --- a/packages/react-doctor/tests/performance-harness.test.ts +++ b/packages/react-doctor/tests/performance-harness.test.ts @@ -9,6 +9,7 @@ import { buildBenchmarkComparisons } from "../../../scripts/performance/build-be import { buildBenchmarkEnvironment } from "../../../scripts/performance/build-benchmark-environment.ts"; import type { BuildBenchmarkEnvironmentInput } from "../../../scripts/performance/build-benchmark-environment.ts"; import { clearBenchmarkRunArtifacts } from "../../../scripts/performance/clear-benchmark-run-artifacts.ts"; +import { PERFORMANCE_PROFILE_TEST_TIMEOUT_MS } from "../../../scripts/performance/constants.ts"; import { createStressProject } from "../../../scripts/performance/create-stress-project.ts"; import { parsePerformanceArguments } from "../../../scripts/performance/parse-performance-arguments.ts"; import { parseProcessResourceUsage } from "../../../scripts/performance/parse-process-resource-usage.ts"; @@ -424,45 +425,49 @@ describe("performance harness", () => { ); }); - it("captures and aggregates profiles across the benchmark process tree", () => { - const directory = createTemporaryDirectory(); - const projectDirectory = path.join(directory, "project"); - const outputDirectory = path.join(directory, "profile results"); - createStressProject({ - directory: projectDirectory, - fileCount: 1, - componentsPerFileCount: 1, - }); - runPerformance({ - directories: [projectDirectory], - samples: 1, - warmups: 0, - workerCounts: [1], - modes: ["full"], - cacheCohorts: ["no-cache"], - outputDirectory, - comparePath: null, - cliPath: builtCliPath, - profile: true, - heapProfile: true, - }); - - const cpuAnalysis = analyzeCpuProfiles(outputDirectory); - const heapAnalysis = analyzeHeapProfiles(outputDirectory); - const cpuProcessRoles = new Set( - cpuAnalysis.processes.map((processSummary) => processSummary.role), - ); - expect(cpuProcessRoles).toContain("react-doctor"); - expect(cpuProcessRoles).toContain("oxlint"); - if (process.allowedNodeEnvironmentFlags.has("--cpu-prof")) { - expect(cpuProcessRoles).toContain("dead-code"); - } else { - expect(cpuProcessRoles.size).toBeGreaterThanOrEqual(2); - } - expect(heapAnalysis.processes.length).toBeGreaterThanOrEqual( - process.allowedNodeEnvironmentFlags.has("--heap-prof") ? 3 : 2, - ); - }); + it( + "captures and aggregates profiles across the benchmark process tree", + () => { + const directory = createTemporaryDirectory(); + const projectDirectory = path.join(directory, "project"); + const outputDirectory = path.join(directory, "profile results"); + createStressProject({ + directory: projectDirectory, + fileCount: 1, + componentsPerFileCount: 1, + }); + runPerformance({ + directories: [projectDirectory], + samples: 1, + warmups: 0, + workerCounts: [1], + modes: ["full"], + cacheCohorts: ["no-cache"], + outputDirectory, + comparePath: null, + cliPath: builtCliPath, + profile: true, + heapProfile: true, + }); + + const cpuAnalysis = analyzeCpuProfiles(outputDirectory); + const heapAnalysis = analyzeHeapProfiles(outputDirectory); + const cpuProcessRoles = new Set( + cpuAnalysis.processes.map((processSummary) => processSummary.role), + ); + expect(cpuProcessRoles).toContain("react-doctor"); + expect(cpuProcessRoles).toContain("oxlint"); + if (process.allowedNodeEnvironmentFlags.has("--cpu-prof")) { + expect(cpuProcessRoles).toContain("dead-code"); + } else { + expect(cpuProcessRoles.size).toBeGreaterThanOrEqual(2); + } + expect(heapAnalysis.processes.length).toBeGreaterThanOrEqual( + process.allowedNodeEnvironmentFlags.has("--heap-prof") ? 3 : 2, + ); + }, + PERFORMANCE_PROFILE_TEST_TIMEOUT_MS, + ); }); it("summarizes distributions with a robust median and MAD", () => { diff --git a/packages/react-doctor/tests/prompt-install-setup.test.ts b/packages/react-doctor/tests/prompt-install-setup.test.ts index e6d43faffb..6fd4bd7919 100644 --- a/packages/react-doctor/tests/prompt-install-setup.test.ts +++ b/packages/react-doctor/tests/prompt-install-setup.test.ts @@ -2,11 +2,11 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import * as fs from "node:fs"; +import { getCliStatePath } from "../src/cli/utils/cli-state-store.js"; +import { hashProjectRoot } from "../src/cli/utils/hash-project-root.js"; import { AGENT_INSTALL_HINT_LINES, disableSetupPrompt, - getSetupPromptConfigPath, - getSetupPromptProjectKey, hasDisabledSetupPrompt, printAgentInstallHint, resolveInstallSetupProjectRoot, @@ -39,7 +39,7 @@ const readPackageJson = (projectRoot: string): Record => JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")); const readSetupPromptConfig = (configRoot: string): Record => - JSON.parse(fs.readFileSync(getSetupPromptConfigPath({ cwd: configRoot }), "utf8")); + JSON.parse(fs.readFileSync(getCliStatePath({ cwd: configRoot }), "utf8")); describe("resolveInstallSetupProjectRoot", () => { let fixture: PromptInstallSetupFixture; @@ -144,12 +144,12 @@ describe("disableSetupPrompt", () => { it("migrates a legacy opt-out forward and preserves it when disabling another", () => { writePackageJson(fixture.projectRoot, { scripts: {} }); - const otherProjectKey = getSetupPromptProjectKey("/other/project"); + const otherProjectKey = hashProjectRoot("/other/project"); // Seed a pre-v2, legacy-shaped opt-out (`setupPrompt: false`) for a // different project; opening the store should migrate it forward to a // setup-hint event without losing the opt-out. fs.writeFileSync( - getSetupPromptConfigPath({ cwd: fixture.configRoot }), + getCliStatePath({ cwd: fixture.configRoot }), `${JSON.stringify( { projects: { [otherProjectKey]: { rootDirectory: "/other/project", setupPrompt: false } }, @@ -165,7 +165,7 @@ describe("disableSetupPrompt", () => { expect(hasDisabledSetupPrompt("/other/project", { cwd: fixture.configRoot })).toBe(true); expect(hasDisabledSetupPrompt(fixture.projectRoot, { cwd: fixture.configRoot })).toBe(true); - const projectKey = getSetupPromptProjectKey(fixture.projectRoot); + const projectKey = hashProjectRoot(fixture.projectRoot); const projects = readSetupPromptConfig(fixture.configRoot).projects; expect(projects[otherProjectKey].rootDirectory).toBe("/other/project"); expect(projects[otherProjectKey].events["setup-hint"].outcome).toBe("declined"); diff --git a/packages/react-doctor/tests/regressions/architecture-rules.test.ts b/packages/react-doctor/tests/regressions/architecture-rules.test.ts index 879134fb46..641736a48e 100644 --- a/packages/react-doctor/tests/regressions/architecture-rules.test.ts +++ b/packages/react-doctor/tests/regressions/architecture-rules.test.ts @@ -11,100 +11,6 @@ afterAll(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); -// `react-compiler-destructure-method` is currently un-registered in -// `rule-registry.ts` — the React-Compiler memoization premise didn't -// hold up for the canonical hooks it targeted (`useRouter`, -// `useSearchParams`, `useNavigation`), all of which return stable -// references. The rule implementation and these regression suites are -// kept intact so re-registering the rule re-enables coverage in one -// diff; switch this back to `describe(...)` when that happens. -describe.skip("react-compiler-destructure-method", () => { - it("does not flag React Navigation methods", async () => { - const projectDir = setupReactProject(tempRoot, "react-navigation-methods", { - files: { - "src/Screen.tsx": `import { useNavigation } from "@react-navigation/native"; - -declare function useRouter(): { - push: (path: string) => void; -}; - -declare module "@react-navigation/native" { - export function useNavigation(): { - navigate: (screen: string, params?: { sessionId: string }) => void; - }; -} - -export const WebRouteButton = () => { - const router = useRouter(); - return ; -}; - -export const NativeRouteButton = () => { - const navigation = useNavigation(); - return ( - - ); -}; -`, - }, - }); - - const hits = await collectRuleHits(projectDir, "react-compiler-destructure-method"); - expect(hits).toHaveLength(1); - expect(hits[0].message).toContain("useRouter"); - expect(hits[0].message).not.toContain("useNavigation"); - }); - - it("does not flag React Navigation core methods", async () => { - const projectDir = setupReactProject(tempRoot, "react-navigation-core-methods", { - files: { - "src/Screen.tsx": `import { useNavigation } from "@react-navigation/core"; - -declare module "@react-navigation/core" { - export function useNavigation(): { - dispatch: (action: { type: string }) => void; - }; -} - -export const NativeRouteButton = () => { - const navigation = useNavigation(); - return ; -}; -`, - }, - }); - - const hits = await collectRuleHits(projectDir, "react-compiler-destructure-method"); - expect(hits).toHaveLength(0); - }); - - it("still flags non-React-Navigation useNavigation hooks", async () => { - const projectDir = setupReactProject(tempRoot, "custom-use-navigation-methods", { - files: { - "src/Screen.tsx": `declare function useNavigation(): { - navigate: (screen: string, params?: { sessionId: string }) => void; -}; - -export const RouteButton = () => { - const navigation = useNavigation(); - return ( - - ); -}; -`, - }, - }); - - const hits = await collectRuleHits(projectDir, "react-compiler-destructure-method"); - expect(hits).toHaveLength(1); - expect(hits[0].message).toContain("useNavigation"); - }); -}); - describe("react-compiler-no-manual-memoization", () => { it("flags useMemo, useCallback, and memo when React Compiler is enabled", async () => { const projectDir = setupReactProject(tempRoot, "manual-memoization-with-compiler", { diff --git a/packages/react-doctor/tests/render-inspect-result.test.ts b/packages/react-doctor/tests/render-inspect-result.test.ts new file mode 100644 index 0000000000..8b004e5846 --- /dev/null +++ b/packages/react-doctor/tests/render-inspect-result.test.ts @@ -0,0 +1,359 @@ +import * as Effect from "effect/Effect"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Diagnostic, ProjectInfo, ScoreResult } from "@react-doctor/core"; +import { recordRunEvent } from "../src/cli/utils/build-run-event.js"; +import { computeProjectedScore } from "../src/cli/utils/compute-score-projection.js"; +import { recordScanMetrics } from "../src/cli/utils/record-scan-metrics.js"; +import { printDiagnostics } from "../src/cli/utils/render-diagnostics.js"; +import { + renderAndRecordScan, + renderCachedProjectDetection, + type RenderAndRecordScanInput, +} from "../src/cli/utils/render-inspect-result.js"; +import { printProjectDetection } from "../src/cli/utils/render-project-detection.js"; +import { + printDiagnosticsDump, + printFooter, + printSummary, +} from "../src/cli/utils/render-summary.js"; +import type { CachedScanPayload } from "../src/cli/utils/scan-result-cache.js"; +import { resolveInspectOptions } from "../src/cli/utils/resolve-inspect-options.js"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "../src/inspect-options.js"; + +vi.mock("../src/cli/utils/build-run-event.js", () => ({ + recordRunEvent: vi.fn(), +})); + +vi.mock("../src/cli/utils/compute-score-projection.js", () => ({ + computeProjectedScore: vi.fn(async () => null), +})); + +vi.mock("../src/cli/utils/onboarding-pacing.js", () => ({ + canAnimateOnboarding: vi.fn(() => false), + onboardingSectionPause: vi.fn(() => Effect.void), +})); + +vi.mock("../src/cli/utils/record-scan-metrics.js", () => ({ + recordScanMetrics: vi.fn(), +})); + +vi.mock("../src/cli/utils/render-diagnostics.js", () => ({ + printDiagnostics: vi.fn(() => Effect.void), +})); + +vi.mock("../src/cli/utils/render-project-detection.js", () => ({ + printProjectDetection: vi.fn(() => Effect.void), +})); + +vi.mock("../src/cli/utils/render-summary.js", () => ({ + printDiagnosticsDump: vi.fn(() => Effect.void), + printFooter: vi.fn(() => Effect.void), + printSummary: vi.fn(() => Effect.void), +})); + +const mockedComputeProjectedScore = vi.mocked(computeProjectedScore); +const mockedPrintDiagnostics = vi.mocked(printDiagnostics); +const mockedPrintDiagnosticsDump = vi.mocked(printDiagnosticsDump); +const mockedPrintFooter = vi.mocked(printFooter); +const mockedPrintProjectDetection = vi.mocked(printProjectDetection); +const mockedPrintSummary = vi.mocked(printSummary); +const mockedRecordRunEvent = vi.mocked(recordRunEvent); +const mockedRecordScanMetrics = vi.mocked(recordScanMetrics); + +const project: ProjectInfo = { + rootDirectory: "/repo", + projectName: "example", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "unknown", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + preactVersion: null, + preactMajorVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + sourceFileCount: 1, +}; + +const diagnostic: Diagnostic = { + filePath: "/repo/src/app.tsx", + plugin: "react-doctor", + rule: "no-array-index-as-key", + severity: "warning", + message: "Avoid array indexes as keys", + help: "Use a stable key", + line: 1, + column: 1, + category: "Correctness", +}; + +const designDiagnostic: Diagnostic = { + ...diagnostic, + rule: "no-gradient-text", + category: "Design", +}; + +const score: ScoreResult = { score: 88, label: "Great" }; + +const buildPayload = (overrides: Partial = {}): CachedScanPayload => ({ + diagnostics: [diagnostic], + score, + project, + userConfig: null, + didLintFail: false, + lintFailureReason: null, + lintPartialFailures: [], + didDeadCodeFail: false, + deadCodeFailureReason: null, + deadCodeOverlapped: false, + directory: "/repo", + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 10, + scanConcurrency: 2, + baselineDelta: undefined, + lintFailureReasonKind: null, + supplyChainOverlapTimedOut: false, + securityScanFailed: false, + suppressedRuleCounts: [], + ...overrides, +}); + +const resolveOptions = (inputOptions: ReactDoctorInspectOptions): ResolvedInspectOptions => + resolveInspectOptions({ + inputOptions, + userConfig: null, + environment: { + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: false, + }, + }); + +const buildInput = ( + options: ResolvedInspectOptions, + payload: CachedScanPayload = buildPayload(), +): RenderAndRecordScanInput => ({ + payload, + options, + userConfig: null, + hasCustomConfig: false, + startTime: performance.now(), + rootSentrySpan: undefined, + scanMode: "full", + baselineDegraded: false, + wholeRepoCacheHit: false, +}); + +describe("render inspect result lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedComputeProjectedScore.mockResolvedValue(null); + }); + + it("returns the same payload references without rendering when rendering is suppressed", async () => { + const payload = buildPayload(); + const events: string[] = []; + mockedRecordScanMetrics.mockImplementation(() => { + events.push("metrics"); + }); + mockedRecordRunEvent.mockImplementation(() => { + events.push("run-event"); + }); + + const result = await renderAndRecordScan( + buildInput(resolveOptions({ suppressRendering: true }), payload), + ); + + expect(result.diagnostics).toEqual(payload.diagnostics); + expect(result.diagnostics).not.toBe(payload.diagnostics); + expect(result.score).toBe(payload.score); + expect(result.project).toBe(payload.project); + expect(result.scannedFilePaths).toBe(payload.scannedFilePaths); + expect(result.analyzedFiles).toBe(payload.analyzedFiles); + expect(mockedPrintDiagnostics).not.toHaveBeenCalled(); + expect(mockedPrintSummary).not.toHaveBeenCalled(); + expect(mockedPrintFooter).not.toHaveBeenCalled(); + expect(events).toEqual(["metrics", "run-event"]); + expect(mockedRecordRunEvent.mock.calls[0][1].result).toBe(result); + }); + + it("keeps score-only stdout machine-clean and writes no-score prose to stderr", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await renderAndRecordScan( + buildInput(resolveOptions({ scoreOnly: true, outputDirectory: "/dump" })), + ); + + expect(mockedPrintDiagnosticsDump).toHaveBeenCalledWith([diagnostic], "/dump", false, "stderr"); + expect(log).toHaveBeenCalledWith("88"); + expect(error).not.toHaveBeenCalled(); + + log.mockClear(); + mockedPrintDiagnosticsDump.mockClear(); + await renderAndRecordScan( + buildInput(resolveOptions({ scoreOnly: true }), buildPayload({ score: null })), + ); + + expect(log).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Score unavailable")); + }); + + it.each([ + { + name: "ordinary", + payload: buildPayload({ diagnostics: [], score: null }), + options: resolveOptions({}), + expected: "No issues found!", + }, + { + name: "category-filtered", + payload: buildPayload({ score: null }), + options: resolveOptions({ categoryFilters: ["Security"] }), + expected: "No issues found in category Security!", + }, + { + name: "surface-demoted", + payload: buildPayload({ diagnostics: [designDiagnostic], score: null }), + options: resolveOptions({ outputSurface: "prComment" }), + expected: "1 demoted from the prComment surface", + }, + { + name: "skipped", + payload: buildPayload({ + diagnostics: [], + score: null, + didLintFail: true, + lintFailureReason: "lint crashed", + }), + options: resolveOptions({}), + expected: "results are incomplete", + }, + ])("renders the $name no-findings state", async ({ payload, options, expected }) => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await renderAndRecordScan(buildInput(options, payload)); + + const output = [...log.mock.calls, ...warn.mock.calls].flat().join("\n"); + expect(output).toContain(expected); + if (payload.didLintFail) { + expect(result.skippedChecks).toContain("lint"); + expect(output).toContain("Score not shown"); + } + }); + + it("renders findings, summary, and footer in order before recording telemetry", async () => { + const events: string[] = []; + mockedPrintDiagnostics.mockImplementation(() => + Effect.sync(() => { + events.push("diagnostics"); + }), + ); + mockedPrintSummary.mockImplementation(() => + Effect.sync(() => { + events.push("summary"); + }), + ); + mockedPrintFooter.mockImplementation(() => + Effect.sync(() => { + events.push("footer"); + }), + ); + mockedRecordScanMetrics.mockImplementation(() => { + events.push("metrics"); + }); + mockedRecordRunEvent.mockImplementation(() => { + events.push("run-event"); + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await renderAndRecordScan(buildInput(resolveOptions({}))); + + expect(events).toEqual(["diagnostics", "summary", "footer", "metrics", "run-event"]); + }); + + it("does not wrap metric failures and does not record a run event afterward", async () => { + const failure = new Error("metric failure"); + mockedRecordScanMetrics.mockImplementation(() => { + throw failure; + }); + + await expect( + renderAndRecordScan(buildInput(resolveOptions({ suppressRendering: true }))), + ).rejects.toBe(failure); + expect(mockedRecordRunEvent).not.toHaveBeenCalled(); + }); + + it("preserves run-event failure identity after recording metrics", async () => { + const failure = new Error("run-event failure"); + mockedRecordScanMetrics.mockImplementation(() => {}); + mockedRecordRunEvent.mockImplementation(() => { + throw failure; + }); + + await expect( + renderAndRecordScan(buildInput(resolveOptions({ suppressRendering: true }))), + ).rejects.toBe(failure); + expect(mockedRecordScanMetrics).toHaveBeenCalledOnce(); + }); + + it("preserves render failure identity and skips telemetry", async () => { + const failure = new Error("render failure"); + mockedPrintDiagnostics.mockImplementation(() => Effect.fail(failure)); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await expect(renderAndRecordScan(buildInput(resolveOptions({})))).rejects.toBe(failure); + expect(mockedRecordScanMetrics).not.toHaveBeenCalled(); + expect(mockedRecordRunEvent).not.toHaveBeenCalled(); + }); + + it("renders cached project detection only for visible non-score output", async () => { + const payload = buildPayload(); + const options = resolveOptions({}); + + await renderCachedProjectDetection({ + payload, + options, + userConfig: null, + isDiffMode: true, + }); + + expect(mockedPrintProjectDetection).toHaveBeenCalledWith({ + projectInfo: payload.project, + userConfig: null, + isDiffMode: true, + includePaths: options.includePaths, + lintSourceFileCount: payload.scannedFileCount, + }); + + mockedPrintProjectDetection.mockClear(); + await renderCachedProjectDetection({ + payload, + options: resolveOptions({ scoreOnly: true }), + userConfig: null, + isDiffMode: false, + }); + await renderCachedProjectDetection({ + payload, + options: resolveOptions({ suppressRendering: true }), + userConfig: null, + isDiffMode: false, + }); + expect(mockedPrintProjectDetection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-doctor/tests/resolve-baseline-comparison.test.ts b/packages/react-doctor/tests/resolve-baseline-comparison.test.ts new file mode 100644 index 0000000000..a652e898fa --- /dev/null +++ b/packages/react-doctor/tests/resolve-baseline-comparison.test.ts @@ -0,0 +1,262 @@ +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { createOxlintSpawnSlots } from "@react-doctor/core"; +import type { Diagnostic, ProjectInfo } from "@react-doctor/core"; +import { BASELINE_FILES_TEMP_DIR_PREFIX } from "../src/cli/utils/constants.js"; +import { materializeBaselineFiles } from "../src/cli/utils/materialize-baseline-files.js"; +import { makeNoopConsole } from "../src/cli/utils/noop-console.js"; +import { + resolveBaselineComparison, + type ResolveBaselineComparisonInput, +} from "../src/cli/utils/resolve-baseline-comparison.js"; +import { resolveInspectOptions } from "../src/cli/utils/resolve-inspect-options.js"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "../src/inspect-options.js"; + +vi.mock("../src/cli/utils/materialize-baseline-files.js", () => ({ + materializeBaselineFiles: vi.fn(), +})); + +const mockedMaterializeBaselineFiles = vi.mocked(materializeBaselineFiles); + +const projectInfo: ProjectInfo = { + rootDirectory: "/repo", + projectName: "example", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "unknown", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + preactVersion: null, + preactMajorVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + sourceFileCount: 1, +}; + +const diagnostic = (filePath: string, line: number): Diagnostic => ({ + filePath, + plugin: "react-doctor", + rule: "example", + severity: "warning", + message: "Example diagnostic", + help: "Fix the example", + line, + column: 1, + category: "Correctness", +}); + +const resolveOptions = (inputOptions: ReactDoctorInspectOptions): ResolvedInspectOptions => + resolveInspectOptions({ + inputOptions, + userConfig: null, + environment: { + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: true, + }, + }); + +const buildInput = ( + inputOptions: ReactDoctorInspectOptions, + overrides: Partial = {}, +): ResolveBaselineComparisonInput => ({ + directory: "/repo", + options: resolveOptions(inputOptions), + userConfig: null, + configSourceDirectory: null, + headProjectInfo: projectInfo, + headDiagnostics: [diagnostic("src/app.tsx", 3)], + headAnalyzedFiles: ["src/app.tsx"], + didLintFail: false, + lintPartialFailures: [], + resolvedNodeBinaryPath: "/node", + deadlineEpochMs: null, + oxlintRuntime: { + concurrency: 2, + spawnSlots: createOxlintSpawnSlots(2), + }, + silentConsole: makeNoopConsole(), + ...overrides, +}); + +describe("resolveBaselineComparison", () => { + afterEach(() => { + mockedMaterializeBaselineFiles.mockReset(); + }); + + it("filters diagnostics by changed lines without entering baseline materialization", async () => { + const diagnostics = [diagnostic("src/app.tsx", 3), diagnostic("/repo/src/app.tsx", 8)]; + const result = await resolveBaselineComparison( + buildInput( + { + includePaths: ["src/app.tsx"], + changedLineRanges: [{ file: "src/app.tsx", ranges: [[7, 9]] }], + }, + { headDiagnostics: diagnostics }, + ), + ); + + expect(result).toEqual({ + displayDiagnostics: [diagnostics[1]], + baselineDelta: undefined, + }); + expect(mockedMaterializeBaselineFiles).not.toHaveBeenCalled(); + }); + + it("preserves the head diagnostic identity when its lint coverage is incomplete", async () => { + const diagnostics = [diagnostic("src/app.tsx", 3)]; + const result = await resolveBaselineComparison( + buildInput( + { + includePaths: ["src/app.tsx"], + baseline: { ref: "origin/main" }, + }, + { + headDiagnostics: diagnostics, + lintPartialFailures: ["1 file(s) skipped — max scan duration reached"], + }, + ), + ); + + expect(result.displayDiagnostics).toBe(diagnostics); + expect(result.baselineDelta).toBeUndefined(); + expect(mockedMaterializeBaselineFiles).not.toHaveBeenCalled(); + }); + + it("preserves materialization inputs and removes the allocated temp directory on null", async () => { + const baseFiles = ["src/base.tsx"]; + const headFiles = ["src/head.tsx"]; + const includePaths = ["src/app.tsx"]; + const baseline = { ref: "origin/main", baseFiles, headFiles }; + mockedMaterializeBaselineFiles.mockResolvedValue(null); + + const result = await resolveBaselineComparison( + buildInput({ + includePaths, + baseline, + }), + ); + + expect(result.baselineDelta).toBeUndefined(); + expect(mockedMaterializeBaselineFiles).toHaveBeenCalledTimes(1); + const materializationInput = mockedMaterializeBaselineFiles.mock.calls[0][0]; + expect(materializationInput).toMatchObject({ + directory: "/repo", + ref: baseline.ref, + }); + expect(materializationInput.files).toBe(includePaths); + expect(materializationInput.baseFiles).toBe(baseFiles); + expect(materializationInput.headFiles).toBe(headFiles); + expect(path.dirname(materializationInput.tempDirectory)).toBe(tmpdir()); + expect(path.basename(materializationInput.tempDirectory)).toMatch( + new RegExp(`^${BASELINE_FILES_TEMP_DIR_PREFIX}`), + ); + expect(fs.existsSync(materializationInput.tempDirectory)).toBe(false); + }); + + it("cleans an incomplete snapshot through its owned cleanup callback", async () => { + const cleanup = vi.fn(); + mockedMaterializeBaselineFiles.mockImplementation(async (input) => { + cleanup.mockImplementation(() => { + fs.rmSync(input.tempDirectory, { recursive: true, force: true }); + }); + return { + tempDirectory: input.tempDirectory, + materializedFiles: [], + unmaterializedFiles: ["src/app.tsx"], + cleanup, + baseFiles: ["src/app.tsx"], + headFiles: ["src/app.tsx"], + isComplete: false, + untrackedFiles: [], + }; + }); + + const result = await resolveBaselineComparison( + buildInput({ + includePaths: ["src/app.tsx"], + baseline: { ref: "origin/main" }, + }), + ); + + expect(result.baselineDelta).toBeUndefined(); + expect(cleanup).toHaveBeenCalledTimes(1); + const materializationInput = mockedMaterializeBaselineFiles.mock.calls[0][0]; + expect(fs.existsSync(materializationInput.tempDirectory)).toBe(false); + }); + + it("returns the diagnostic delta and cleans the snapshot after a successful base scan", async () => { + const cleanup = vi.fn(); + mockedMaterializeBaselineFiles.mockImplementation(async (input) => { + cleanup.mockImplementation(() => { + fs.rmSync(input.tempDirectory, { recursive: true, force: true }); + }); + return { + tempDirectory: input.tempDirectory, + materializedFiles: ["src/app.tsx"], + unmaterializedFiles: [], + cleanup, + baseFiles: ["src/app.tsx"], + headFiles: ["src/app.tsx"], + isComplete: true, + untrackedFiles: [], + }; + }); + const diagnostics = [diagnostic("src/app.tsx", 3)]; + + const result = await resolveBaselineComparison( + buildInput( + { + includePaths: ["src/app.tsx"], + baseline: { ref: "origin/main" }, + supplyChain: false, + }, + { + headDiagnostics: diagnostics, + resolvedNodeBinaryPath: null, + }, + ), + ); + + expect(result).toEqual({ + displayDiagnostics: diagnostics, + baselineDelta: { + baseRef: "origin/main", + fixedCount: 0, + baseTotalCount: 0, + crossFileMatchCount: 0, + }, + }); + expect(cleanup).toHaveBeenCalledTimes(1); + const materializationInput = mockedMaterializeBaselineFiles.mock.calls[0][0]; + expect(fs.existsSync(materializationInput.tempDirectory)).toBe(false); + }); + + it("removes the allocated temp directory and rethrows the original materialization error", async () => { + const materializationError = new Error("materialization failed"); + mockedMaterializeBaselineFiles.mockRejectedValue(materializationError); + const input = buildInput({ + includePaths: ["src/app.tsx"], + baseline: { ref: "origin/main" }, + }); + + await expect(resolveBaselineComparison(input)).rejects.toBe(materializationError); + + const materializationInput = mockedMaterializeBaselineFiles.mock.calls[0][0]; + expect(fs.existsSync(materializationInput.tempDirectory)).toBe(false); + }); +}); diff --git a/packages/react-doctor/tests/resolve-inspect-options.test.ts b/packages/react-doctor/tests/resolve-inspect-options.test.ts new file mode 100644 index 0000000000..ee3cbe8090 --- /dev/null +++ b/packages/react-doctor/tests/resolve-inspect-options.test.ts @@ -0,0 +1,131 @@ +import type { ChangedFileLineRanges } from "@react-doctor/core"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveInspectOptions } from "../src/cli/utils/resolve-inspect-options.js"; + +describe("resolveInspectOptions", () => { + it("preserves the complete default option contract", () => { + expect( + resolveInspectOptions({ + inputOptions: {}, + userConfig: null, + environment: { + isCiOrCodingAgentEnvironment: true, + isNonInteractiveEnvironment: false, + }, + }), + ).toEqual({ + lint: true, + deadCode: true, + supplyChain: true, + verbose: false, + outputDirectory: null, + scoreOnly: false, + noScore: false, + isCi: false, + isCiOrCodingAgentEnvironment: true, + isNonInteractiveEnvironment: false, + silent: false, + includePaths: [], + customRulesOnly: false, + share: true, + respectInlineDisables: true, + warnings: true, + categoryFilters: new Set(), + adoptExistingLintConfig: true, + ignoredTags: new Set(), + includedTags: new Set(), + includeTagDefaults: false, + scoreDisabledMessage: undefined, + outputSurface: "cli", + suppressRendering: false, + uiLayers: null, + concurrentScan: false, + concurrency: undefined, + maxDurationMs: null, + baseline: null, + changedLineRanges: null, + supplyChainManifestChanged: false, + }); + }); + + it("preserves input precedence and included-tag activation policy", () => { + const includedTags = new Set(["design"]); + const includePaths = ["src/app.tsx"]; + const baseline = { ref: "origin/main" }; + const changedLineRanges: ReadonlyArray = [ + { file: "src/app.tsx", ranges: [[2, 4]] }, + ]; + + const resolvedOptions = resolveInspectOptions({ + inputOptions: { + lint: true, + warnings: true, + noScore: false, + respectInlineDisables: true, + outputDirectory: "", + includePaths, + includedTags, + includeTagDefaults: true, + categoryFilters: ["performance"], + outputSurface: "json", + suppressRendering: true, + concurrentScan: true, + concurrency: 3, + maxDurationMs: 2_000, + baseline, + changedLineRanges, + supplyChainManifestChanged: true, + scoreDisabledMessage: "Scoring disabled.", + }, + userConfig: { + lint: false, + deadCode: false, + supplyChain: { enabled: false }, + verbose: true, + noScore: true, + customRulesOnly: true, + share: false, + respectInlineDisables: false, + warnings: false, + adoptExistingLintConfig: false, + ignore: { tags: ["design", "security"] }, + }, + environment: { + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: true, + }, + }); + + expect(resolvedOptions).toMatchObject({ + lint: true, + deadCode: false, + supplyChain: false, + verbose: true, + outputDirectory: null, + noScore: false, + customRulesOnly: false, + share: false, + respectInlineDisables: true, + warnings: true, + categoryFilters: new Set(["Performance"]), + adoptExistingLintConfig: false, + ignoredTags: new Set(["security"]), + includedTags, + includeTagDefaults: true, + scoreDisabledMessage: "Scoring disabled.", + outputSurface: "json", + suppressRendering: true, + concurrentScan: true, + concurrency: 3, + maxDurationMs: 2_000, + baseline, + changedLineRanges, + supplyChainManifestChanged: true, + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: true, + }); + expect(resolvedOptions.includePaths).toBe(includePaths); + expect(resolvedOptions.baseline).toBe(baseline); + expect(resolvedOptions.changedLineRanges).toBe(changedLineRanges); + }); +}); diff --git a/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts b/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts index 0f72ec8325..2853ba8fcd 100644 --- a/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts +++ b/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; import type { Diagnostic } from "@react-doctor/core"; import { buildDiagnosticIdentity, runOxlint } from "@react-doctor/core"; +import { CROSS_FILE_RULE_IDS } from "oxlint-plugin-react-doctor"; import { buildTestProject, setupReactProject, writeFile } from "../regressions/_helpers.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sidecar-cache-e2e-")); @@ -30,6 +31,34 @@ const INK_USER_CONFIG = { }, } as const; +const HYDRATION_RULE_ID = "no-unguarded-browser-global-in-render-or-hook-init"; +const HYDRATION_USER_CONFIG = { + rules: Object.fromEntries( + [...CROSS_FILE_RULE_IDS].map((ruleId): [string, "error" | "off"] => [ + `react-doctor/${ruleId}`, + ruleId === HYDRATION_RULE_ID ? "error" : "off", + ]), + ), +}; +const NEXT_DYNAMIC_API_RULE_ID = "nextjs-async-dynamic-api-not-awaited"; +const NEXT_DYNAMIC_API_USER_CONFIG = { + rules: Object.fromEntries( + [...CROSS_FILE_RULE_IDS].map((ruleId): [string, "error" | "off"] => [ + `react-doctor/${ruleId}`, + ruleId === NEXT_DYNAMIC_API_RULE_ID ? "error" : "off", + ]), + ), +}; +const NEXT_NO_IMG_RULE_ID = "nextjs-no-img-element"; +const NEXT_NO_IMG_USER_CONFIG = { + rules: Object.fromEntries( + [...CROSS_FILE_RULE_IDS].map((ruleId): [string, "error" | "off"] => [ + `react-doctor/${ruleId}`, + ruleId === NEXT_NO_IMG_RULE_ID ? "error" : "off", + ]), + ), +}; + const APP_SOURCE = `import { Button } from "./components"; export const App = () =>
; `; @@ -44,6 +73,12 @@ const MUTATING_REDUCER = `export const listReducer = (state, action) => { `; const PURE_REDUCER = `export const listReducer = (state, action) => ({ count: state.count + 1 }); `; +const FALSE_SERVER_SNAPSHOT_HOOK = `import { useSyncExternalStore } from "react"; +const subscribe = () => () => {}; +export const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false); +`; +// Preserve byte length so the dependency's content, not its size, must invalidate the replay. +const TRUE_SERVER_SNAPSHOT_HOOK = FALSE_SERVER_SNAPSHOT_HOOK.replace("() => false", "() => true"); interface ScanOptions { perFileLintCacheEnabled?: boolean; @@ -112,6 +147,119 @@ const scanInk = (projectDir: string, sidecarLintCacheEnabled: boolean): Promise< sidecarLintCacheEnabled, }); +const setupHydrationFixture = (caseId: string): string => { + const projectDir = setupReactProject(tempRoot, caseId, { + files: { + "src/App.tsx": `import { useClientReady as useHydrated } from "./hooks"; +export const App = () => { + const hydrated = useHydrated(); + return hydrated && {document.title}; +}; +`, + "src/hooks/index.ts": `export { useHydrated as useClientReady } from "../use-hydrated";\n`, + "src/use-hydrated.ts": FALSE_SERVER_SNAPSHOT_HOOK, + "src/clean.tsx": "export const Clean = () =>
ok
;\n", + }, + packageJsonExtras: { + dependencies: { + next: "^15.0.0", + react: "^19.0.0", + "react-dom": "^19.0.0", + }, + }, + }); + fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true }); + return projectDir; +}; + +const scanHydration = (projectDir: string, options: ScanOptions = {}): Promise => + runOxlint({ + rootDirectory: projectDir, + project: buildTestProject({ rootDirectory: projectDir, framework: "nextjs" }), + userConfig: HYDRATION_USER_CONFIG, + perFileLintCacheEnabled: options.perFileLintCacheEnabled ?? true, + sidecarLintCacheEnabled: options.sidecarLintCacheEnabled ?? true, + onSidecarStats: options.onSidecarStats, + }); + +const scanHydrationFull = (projectDir: string): Promise => + scanHydration(projectDir, { + perFileLintCacheEnabled: false, + sidecarLintCacheEnabled: false, + }); + +const setupNextDynamicApiFixture = (caseId: string): string => { + const projectDir = setupReactProject(tempRoot, caseId, { + files: { + "packages/app/app/page.tsx": `import { cookies } from "next/headers"; +export default function Page() { + return cookies().get("session"); +} +`, + "src/clean.tsx": "export const Clean = () =>
ok
;\n", + }, + }); + writeFile( + path.join(projectDir, "packages/app/package.json"), + `{ "name": "app", "dependencies": { "react": "19.0.0", "next": "15.0.0" } }\n`, + ); + fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true }); + return projectDir; +}; + +const scanNextDynamicApi = (projectDir: string, options: ScanOptions = {}): Promise => + runOxlint({ + rootDirectory: projectDir, + project: buildTestProject({ rootDirectory: projectDir, framework: "nextjs" }), + userConfig: NEXT_DYNAMIC_API_USER_CONFIG, + perFileLintCacheEnabled: options.perFileLintCacheEnabled ?? true, + sidecarLintCacheEnabled: options.sidecarLintCacheEnabled ?? true, + onSidecarStats: options.onSidecarStats, + }); + +const scanNextDynamicApiFull = (projectDir: string): Promise => + scanNextDynamicApi(projectDir, { + perFileLintCacheEnabled: false, + sidecarLintCacheEnabled: false, + }); + +const setupNextImageFixture = (caseId: string): string => { + const projectDir = setupReactProject(tempRoot, caseId, { + files: { + "lib/card.tsx": `export const Card = () => ;\n`, + "app/api/card/route.tsx": `import { ImageResponse } from "next/og"; +import { Card } from "../../../lib/card"; +export const GET = () => new ImageResponse(); +`, + }, + packageJsonExtras: { + dependencies: { + next: "15.0.0", + react: "19.0.0", + "react-dom": "19.0.0", + }, + }, + }); + fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true }); + return projectDir; +}; + +const scanNextImage = (projectDir: string, options: ScanOptions = {}): Promise => + runOxlint({ + rootDirectory: projectDir, + project: buildTestProject({ rootDirectory: projectDir, framework: "nextjs" }), + userConfig: NEXT_NO_IMG_USER_CONFIG, + perFileLintCacheEnabled: options.perFileLintCacheEnabled ?? true, + sidecarLintCacheEnabled: options.sidecarLintCacheEnabled ?? true, + onSidecarStats: options.onSidecarStats, + }); + +const scanNextImageFull = (projectDir: string): Promise => + scanNextImage(projectDir, { + perFileLintCacheEnabled: false, + sidecarLintCacheEnabled: false, + }); + const serialize = (diagnostics: ReadonlyArray): string => JSON.stringify( [...diagnostics] @@ -265,6 +413,125 @@ export const Panel = ({ children }) => {children}; expect(ruleHitsOn(incremental, "no-mutating-reducer-state", "src/Store.tsx")).toHaveLength(0); }); + it("invalidates an unchanged browser read when an imported server snapshot changes", async () => { + const projectDir = setupHydrationFixture("imported-server-snapshot-flip"); + const before = await scanHydration(projectDir); + let warmReplayed: number | null = null; + let warmConsidered: number | null = null; + await scanHydration(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + warmReplayed = replayedFileCount; + warmConsidered = consideredFileCount; + }, + }); + expect(ruleHitsOn(before, HYDRATION_RULE_ID, "src/App.tsx")).toHaveLength(0); + expect(warmConsidered).toBeGreaterThan(0); + expect(warmReplayed).toBe(warmConsidered); + + writeFile(path.join(projectDir, "src/use-hydrated.ts"), TRUE_SERVER_SNAPSHOT_HOOK); + let incrementalReplayed: number | null = null; + let incrementalConsidered: number | null = null; + const incremental = await scanHydration(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + incrementalReplayed = replayedFileCount; + incrementalConsidered = consideredFileCount; + }, + }); + const full = await scanHydrationFull(projectDir); + const hits = ruleHitsOn(incremental, HYDRATION_RULE_ID, "src/App.tsx"); + + expect(serialize(incremental)).toBe(serialize(full)); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ + line: 4, + column: 29, + message: + "`document` is read while React is rendering on the server, where browser globals are unavailable. Move the read into an effect or event, or provide a stable server snapshot.", + }); + expect(incrementalConsidered).toBeGreaterThan(0); + expect(incrementalReplayed).toBeLessThan(incrementalConsidered); + }); + + it("invalidates an unchanged Next.js file when its owning package drops Next", async () => { + const projectDir = setupNextDynamicApiFixture("next-owning-package-flip"); + const targetFilePath = "packages/app/app/page.tsx"; + const before = await scanNextDynamicApi(projectDir); + let warmReplayed: number | null = null; + let warmConsidered: number | null = null; + await scanNextDynamicApi(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + warmReplayed = replayedFileCount; + warmConsidered = consideredFileCount; + }, + }); + expect(ruleHitsOn(before, NEXT_DYNAMIC_API_RULE_ID, targetFilePath)).toHaveLength(1); + expect(warmConsidered).toBeGreaterThan(0); + expect(warmReplayed).toBe(warmConsidered); + + writeFile( + path.join(projectDir, "packages/app/package.json"), + `{ "name": "app", "dependencies": { "react": "19.0.0", "vite": "15.0.0" } }\n`, + ); + let incrementalReplayed: number | null = null; + let incrementalConsidered: number | null = null; + const incremental = await scanNextDynamicApi(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + incrementalReplayed = replayedFileCount; + incrementalConsidered = consideredFileCount; + }, + }); + const full = await scanNextDynamicApiFull(projectDir); + + expect(serialize(incremental)).toBe(serialize(full)); + expect(ruleHitsOn(incremental, NEXT_DYNAMIC_API_RULE_ID, targetFilePath)).toEqual([]); + expect(incrementalConsidered).toBeGreaterThan(0); + expect(incrementalReplayed).toBeLessThan(incrementalConsidered); + }); + + it("keeps project-wide image ownership fresh when a consumer changes", async () => { + const projectDir = setupNextImageFixture("next-image-consumer-flip"); + const targetFilePath = "lib/card.tsx"; + const before = await scanNextImage(projectDir); + let warmReplayed: number | null = null; + let warmConsidered: number | null = null; + await scanNextImage(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + warmReplayed = replayedFileCount; + warmConsidered = consideredFileCount; + }, + }); + expect(ruleHitsOn(before, NEXT_NO_IMG_RULE_ID, targetFilePath)).toEqual([]); + expect(warmConsidered).toBeNull(); + expect(warmReplayed).toBeNull(); + + writeFile( + path.join(projectDir, "app/api/card/route.tsx"), + `import { Card } from "../../../lib/card"; +export const Page = () => ; +`, + ); + let incrementalReplayed: number | null = null; + let incrementalConsidered: number | null = null; + const incremental = await scanNextImage(projectDir, { + onSidecarStats: (replayedFileCount, consideredFileCount) => { + incrementalReplayed = replayedFileCount; + incrementalConsidered = consideredFileCount; + }, + }); + const full = await scanNextImageFull(projectDir); + const hits = ruleHitsOn(incremental, NEXT_NO_IMG_RULE_ID, targetFilePath); + + expect(serialize(incremental)).toBe(serialize(full)); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ + line: 1, + column: 27, + message: "Plain ships unoptimized, oversized images.", + }); + expect(incrementalConsidered).toBeNull(); + expect(incrementalReplayed).toBeNull(); + }); + it("keeps replaying unaffected files when an unrelated file changes", async () => { const projectDir = setupFixture("unrelated-edit"); await scan(projectDir); diff --git a/packages/react-doctor/tests/scan-result-cache-lifecycle.test.ts b/packages/react-doctor/tests/scan-result-cache-lifecycle.test.ts new file mode 100644 index 0000000000..b37876cbd3 --- /dev/null +++ b/packages/react-doctor/tests/scan-result-cache-lifecycle.test.ts @@ -0,0 +1,388 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Diagnostic, InspectResult, ProjectInfo, ReactDoctorConfig } from "@react-doctor/core"; +import { METRIC } from "../src/cli/utils/constants.js"; +import { recordCount } from "../src/cli/utils/record-metric.js"; +import { + buildScanResultCacheKey, + createScanResultCache, + shouldStoreScanPayload, + type CachedScanPayload, +} from "../src/cli/utils/scan-result-cache.js"; +import { + createScanResultCacheLifecycle, + type CompleteScanResultCacheInput, + type CreateScanResultCacheLifecycleInput, + type RenderAndRecordScanInput, + type RenderCachedProjectDetectionInput, +} from "../src/cli/utils/scan-result-cache-lifecycle.js"; +import { resolveInspectOptions } from "../src/cli/utils/resolve-inspect-options.js"; +import { VERSION } from "../src/cli/utils/version.js"; +import { recordSentryProjectContext } from "../src/cli/utils/with-sentry-run-span.js"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "../src/inspect-options.js"; + +vi.mock("../src/cli/utils/record-metric.js", () => ({ + recordCount: vi.fn(), +})); + +vi.mock("../src/cli/utils/scan-result-cache.js", () => ({ + buildScanResultCacheKey: vi.fn(), + createScanResultCache: vi.fn(), + shouldStoreScanPayload: vi.fn(), +})); + +vi.mock("../src/cli/utils/with-sentry-run-span.js", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + recordSentryProjectContext: vi.fn(), + }; +}); + +const mockedBuildScanResultCacheKey = vi.mocked(buildScanResultCacheKey); +const mockedCreateScanResultCache = vi.mocked(createScanResultCache); +const mockedShouldStoreScanPayload = vi.mocked(shouldStoreScanPayload); +const mockedRecordCount = vi.mocked(recordCount); +const mockedRecordSentryProjectContext = vi.mocked(recordSentryProjectContext); + +const projectInfo: ProjectInfo = { + rootDirectory: "/repo", + projectName: "example", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "unknown", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + preactVersion: null, + preactMajorVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + sourceFileCount: 1, +}; + +const diagnostic: Diagnostic = { + filePath: "/repo/src/app.tsx", + plugin: "react-doctor", + rule: "example", + severity: "warning", + message: "Example diagnostic", + help: "Fix the example", + line: 1, + column: 1, + category: "Correctness", +}; + +const payload: CachedScanPayload = { + diagnostics: [diagnostic], + score: null, + project: projectInfo, + userConfig: null, + didLintFail: false, + lintFailureReason: null, + lintPartialFailures: [], + didDeadCodeFail: false, + deadCodeFailureReason: null, + deadCodeOverlapped: false, + directory: "/repo", + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 10, + scanConcurrency: 2, + baselineDelta: undefined, + lintFailureReasonKind: null, + supplyChainOverlapTimedOut: false, + securityScanFailed: false, + suppressedRuleCounts: [], +}; + +const replayedResult: InspectResult = { + diagnostics: [diagnostic], + score: null, + skippedChecks: [], + project: projectInfo, + elapsedMilliseconds: 20, + scannedFileCount: 1, + scannedFilePaths: ["/repo/src/app.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 10, +}; + +const completionInput: CompleteScanResultCacheInput = { + payload, + scanMode: "full", + baselineDegraded: false, + lintCacheHitFileCount: 1, + lintCacheTotalFileCount: 1, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 1, + deadCodeCacheHit: true, + deadCodeSummaryCacheHits: 1, + deadCodeSummaryCacheMisses: 0, +}; + +const resolveOptions = (inputOptions: ReactDoctorInspectOptions): ResolvedInspectOptions => + resolveInspectOptions({ + inputOptions, + userConfig: null, + environment: { + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: true, + }, + }); + +const buildInput = ( + overrides: Partial = {}, +): CreateScanResultCacheLifecycleInput => ({ + directory: "/repo", + options: resolveOptions({ silent: true }), + userConfig: null, + hasConfigOverride: false, + configSourceDirectory: null, + resolvedNodeBinaryPath: "/node", + startTime: 100, + rootSentrySpan: undefined, + renderCachedProjectDetection: vi.fn( + async (_input: RenderCachedProjectDetectionInput): Promise => {}, + ), + renderAndRecordScan: vi.fn( + async (_input: RenderAndRecordScanInput): Promise => replayedResult, + ), + recordOnboardingCompletion: vi.fn(), + ...overrides, +}); + +describe("createScanResultCacheLifecycle", () => { + const lookup = vi.fn(); + const store = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedBuildScanResultCacheKey.mockReturnValue("cache-key"); + mockedCreateScanResultCache.mockReturnValue({ lookup, store }); + mockedShouldStoreScanPayload.mockReturnValue(true); + lookup.mockReturnValue(null); + }); + + it("builds and looks up the exact key eagerly without changing option references", () => { + const includePaths = ["src/app.tsx"]; + const includedTags = new Set(["design"]); + const userConfig: ReactDoctorConfig = { warnings: false }; + const options = resolveOptions({ + includePaths, + includedTags, + suppressRendering: true, + }); + const input = buildInput({ + options, + userConfig, + hasConfigOverride: true, + configSourceDirectory: "/repo/config", + }); + + const lifecycle = createScanResultCacheLifecycle(input); + + expect(mockedBuildScanResultCacheKey).toHaveBeenCalledTimes(1); + const keyInput = mockedBuildScanResultCacheKey.mock.calls[0][0]; + expect(keyInput).toMatchObject({ + projectDirectory: input.directory, + version: VERSION, + nodeBinaryPath: input.resolvedNodeBinaryPath, + hasConfigOverride: true, + configSourceDirectory: "/repo/config", + }); + expect(keyInput.userConfig).toBe(userConfig); + expect(keyInput.policy.includePaths).toBe(includePaths); + expect(keyInput.policy.ignoredTags).toBe(options.ignoredTags); + expect(keyInput.policy.includedTags).toBe(includedTags); + expect(mockedCreateScanResultCache).toHaveBeenCalledWith(input.directory); + expect(lookup).toHaveBeenCalledWith("cache-key"); + expect(lifecycle.replay()).toBeNull(); + }); + + it("replays a hit through project telemetry, rendering, scan telemetry, and onboarding in order", async () => { + const events: string[] = []; + lookup.mockImplementation(() => { + events.push("lookup"); + return payload; + }); + mockedRecordSentryProjectContext.mockImplementation(() => { + events.push("project-context"); + }); + mockedRecordCount.mockImplementation(() => { + events.push("project-metric"); + }); + const options = resolveOptions({ + includePaths: ["src/app.tsx"], + baseline: { ref: "origin/main" }, + silent: true, + }); + const renderCachedProjectDetection = vi.fn( + async (_input: RenderCachedProjectDetectionInput): Promise => { + events.push("project-render"); + }, + ); + const renderAndRecordScan = vi.fn( + async (_input: RenderAndRecordScanInput): Promise => { + events.push("scan-render-record"); + return replayedResult; + }, + ); + const recordOnboardingCompletion = vi.fn(() => { + events.push("onboarding"); + }); + const input = buildInput({ + options, + renderCachedProjectDetection, + renderAndRecordScan, + recordOnboardingCompletion, + }); + + const lifecycle = createScanResultCacheLifecycle(input); + expect(events).toEqual(["lookup"]); + const replayPromise = lifecycle.replay(); + expect(replayPromise).not.toBeNull(); + if (replayPromise === null) return; + await expect(replayPromise).resolves.toBe(replayedResult); + + expect(events).toEqual([ + "lookup", + "project-context", + "project-metric", + "project-render", + "scan-render-record", + "onboarding", + ]); + expect(mockedRecordSentryProjectContext).toHaveBeenCalledWith( + projectInfo, + input.rootSentrySpan, + { concurrentScan: options.concurrentScan }, + ); + expect(mockedRecordCount).toHaveBeenCalledWith(METRIC.projectDetected, 1); + expect(renderCachedProjectDetection).toHaveBeenCalledWith({ + payload, + options, + userConfig: input.userConfig, + isDiffMode: true, + }); + expect(renderAndRecordScan).toHaveBeenCalledWith({ + payload, + options, + userConfig: input.userConfig, + hasCustomConfig: false, + startTime: input.startTime, + rootSentrySpan: input.rootSentrySpan, + scanMode: "diff", + baselineDegraded: true, + wholeRepoCacheHit: true, + }); + expect(recordOnboardingCompletion).toHaveBeenCalledWith(options); + }); + + it("keeps cache-hit rendering errors identical and does not run later stages", async () => { + const renderError = new Error("render failed"); + lookup.mockReturnValue(payload); + const renderCachedProjectDetection = vi.fn( + async (_input: RenderCachedProjectDetectionInput): Promise => { + throw renderError; + }, + ); + const renderAndRecordScan = vi.fn( + async (_input: RenderAndRecordScanInput): Promise => replayedResult, + ); + const recordOnboardingCompletion = vi.fn(); + const lifecycle = createScanResultCacheLifecycle( + buildInput({ + renderCachedProjectDetection, + renderAndRecordScan, + recordOnboardingCompletion, + }), + ); + + const replayPromise = lifecycle.replay(); + expect(replayPromise).not.toBeNull(); + if (replayPromise === null) return; + await expect(replayPromise).rejects.toBe(renderError); + expect(renderAndRecordScan).not.toHaveBeenCalled(); + expect(recordOnboardingCompletion).not.toHaveBeenCalled(); + }); + + it("stores eligible payloads before cold rendering and onboarding", async () => { + const events: string[] = []; + mockedShouldStoreScanPayload.mockImplementation(() => { + events.push("eligibility"); + return true; + }); + store.mockImplementation(() => { + events.push("store"); + }); + const renderAndRecordScan = vi.fn( + async (_input: RenderAndRecordScanInput): Promise => { + events.push("scan-render-record"); + return replayedResult; + }, + ); + const recordOnboardingCompletion = vi.fn(() => { + events.push("onboarding"); + }); + const input = buildInput({ renderAndRecordScan, recordOnboardingCompletion }); + const lifecycle = createScanResultCacheLifecycle(input); + + await expect(lifecycle.complete(completionInput)).resolves.toBe(replayedResult); + expect(mockedShouldStoreScanPayload).toHaveBeenCalledWith(payload); + expect(store).toHaveBeenCalledWith("cache-key", payload); + expect(events).toEqual(["eligibility", "store", "scan-render-record", "onboarding"]); + expect(renderAndRecordScan).toHaveBeenCalledWith({ + payload, + options: input.options, + userConfig: null, + hasCustomConfig: false, + startTime: 100, + rootSentrySpan: undefined, + scanMode: "full", + baselineDegraded: false, + wholeRepoCacheHit: false, + lintCacheHitFileCount: 1, + lintCacheTotalFileCount: 1, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 1, + deadCodeCacheHit: true, + deadCodeSummaryCacheHits: 1, + deadCodeSummaryCacheMisses: 0, + }); + + store.mockClear(); + await lifecycle.complete({ ...completionInput, baselineDegraded: true }); + expect(mockedShouldStoreScanPayload).toHaveBeenCalledWith(payload); + expect(store).not.toHaveBeenCalled(); + + mockedShouldStoreScanPayload.mockReturnValue(false); + await lifecycle.complete(completionInput); + expect(store).not.toHaveBeenCalled(); + }); + + it("bypasses cache creation, lookup, eligibility, and writes when keying returns null", async () => { + mockedBuildScanResultCacheKey.mockReturnValue(null); + const lifecycle = createScanResultCacheLifecycle(buildInput()); + + expect(mockedCreateScanResultCache).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(lifecycle.replay()).toBeNull(); + await expect(lifecycle.complete(completionInput)).resolves.toBe(replayedResult); + expect(mockedShouldStoreScanPayload).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-doctor/tests/scan-result-cache-policy.test.ts b/packages/react-doctor/tests/scan-result-cache-policy.test.ts new file mode 100644 index 0000000000..8ba59c0a50 --- /dev/null +++ b/packages/react-doctor/tests/scan-result-cache-policy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildScanResultCachePolicy } from "../src/cli/utils/scan-result-cache-policy.js"; +import { resolveInspectOptions } from "../src/cli/utils/resolve-inspect-options.js"; + +describe("buildScanResultCachePolicy", () => { + it("projects only cache-relevant fields with exact nullish behavior", () => { + const resolvedOptions = resolveInspectOptions({ + inputOptions: { + lint: false, + deadCode: false, + supplyChain: false, + includePaths: ["src/app.tsx"], + customRulesOnly: true, + respectInlineDisables: false, + warnings: false, + adoptExistingLintConfig: false, + includedTags: new Set(["design"]), + includeTagDefaults: true, + concurrency: 2, + baseline: { ref: "" }, + changedLineRanges: [], + noScore: true, + isCi: true, + suppressRendering: true, + supplyChainManifestChanged: true, + }, + userConfig: { + ignore: { tags: ["security"] }, + }, + environment: { + isCiOrCodingAgentEnvironment: false, + isNonInteractiveEnvironment: false, + }, + }); + + expect(buildScanResultCachePolicy(resolvedOptions)).toEqual({ + lint: false, + deadCode: false, + supplyChain: false, + includePaths: ["src/app.tsx"], + customRulesOnly: false, + respectInlineDisables: false, + warnings: false, + adoptExistingLintConfig: false, + ignoredTags: new Set(["security"]), + includedTags: new Set(["design"]), + includeTagDefaults: true, + concurrency: 2, + baselineRef: "", + changedLineRanges: [], + noScore: true, + isCi: true, + suppressRendering: true, + supplyChainManifestChanged: true, + }); + }); +}); diff --git a/packages/react-doctor/tests/scan-result-cache.test.ts b/packages/react-doctor/tests/scan-result-cache.test.ts index 3e33eec994..22fa833b34 100644 --- a/packages/react-doctor/tests/scan-result-cache.test.ts +++ b/packages/react-doctor/tests/scan-result-cache.test.ts @@ -4,13 +4,14 @@ import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { clearConfigCache, type Diagnostic } from "@react-doctor/core"; -import { inspect, type ResolvedInspectOptions } from "../src/inspect.js"; +import { inspect } from "../src/inspect.js"; import { buildScanResultCacheKey, createScanResultCache, shouldStoreScanPayload, type CachedScanPayload, } from "../src/cli/utils/scan-result-cache.js"; +import type { ScanResultCachePolicy } from "../src/cli/utils/scan-result-cache-policy.js"; import { SCAN_RESULT_CACHE_MAX_DIRTY_STATUS_ENTRY_COUNT, SCAN_RESULT_CACHE_MAX_HASHED_FILE_SIZE_BYTES, @@ -21,38 +22,31 @@ import { commitAll, initGitRepo, setupReactProject } from "./regressions/_helper let tempDirectory: string; -const baseOptions = (overrides: Partial = {}): ResolvedInspectOptions => ({ +const baseOptions = (overrides: Partial = {}): ScanResultCachePolicy => ({ lint: false, deadCode: false, supplyChain: true, - verbose: false, - scoreOnly: false, noScore: true, isCi: false, - isCiOrCodingAgentEnvironment: false, - isNonInteractiveEnvironment: false, - silent: true, includePaths: [], customRulesOnly: false, - share: true, respectInlineDisables: true, warnings: true, adoptExistingLintConfig: true, ignoredTags: new Set(), includedTags: new Set(), includeTagDefaults: false, - scoreDisabledMessage: undefined, - outputSurface: "cli", suppressRendering: false, concurrency: undefined, - baseline: null, + baselineRef: undefined, + changedLineRanges: null, supplyChainManifestChanged: false, ...overrides, }); const cacheKey = ( projectDirectory: string, - options: ResolvedInspectOptions, + policy: ScanResultCachePolicy, version = VERSION, nodeBinaryPath: string | null = null, ): string | null => @@ -60,7 +54,7 @@ const cacheKey = ( projectDirectory, version, nodeBinaryPath, - options, + policy, userConfig: null, hasConfigOverride: false, configSourceDirectory: null, @@ -179,6 +173,38 @@ describe("scan result cache", () => { } }); + it("fails open on corrupt persisted state and replaces it on the next store", () => { + const projectDirectory = setupReactProject(tempDirectory, "corrupt-cache", { + files: { "src/App.tsx": "export const App = () =>
;\n" }, + }); + initGitRepo(projectDirectory, { commit: true }); + const overrideDirectory = path.join(tempDirectory, "corrupt-cache-dir"); + const previousValue = process.env.REACT_DOCTOR_CACHE_DIR; + try { + process.env.REACT_DOCTOR_CACHE_DIR = overrideDirectory; + const key = cacheKey(projectDirectory, baseOptions()); + expect(key).not.toBeNull(); + if (key === null) return; + createScanResultCache(projectDirectory).store(key, basePayload(projectDirectory)); + const cacheFileEntry = fs + .readdirSync(overrideDirectory, { recursive: true }) + .find((entry) => String(entry).endsWith("scan-cache.json")); + expect(cacheFileEntry).toBeDefined(); + if (cacheFileEntry === undefined) return; + const cacheFilePath = path.join(overrideDirectory, String(cacheFileEntry)); + fs.writeFileSync(cacheFilePath, "{"); + + expect(createScanResultCache(projectDirectory).lookup(key)).toBeNull(); + const replacementCache = createScanResultCache(projectDirectory); + replacementCache.store(key, basePayload(projectDirectory)); + expect(replacementCache.lookup(key)?.diagnostics).toEqual([diagnostic(projectDirectory)]); + expect(() => JSON.parse(fs.readFileSync(cacheFilePath, "utf8"))).not.toThrow(); + } finally { + if (previousValue === undefined) delete process.env.REACT_DOCTOR_CACHE_DIR; + else process.env.REACT_DOCTOR_CACHE_DIR = previousValue; + } + }); + it("keys identically across a fresh-checkout mtime bump of config and dotenv files", () => { const projectDirectory = setupReactProject(tempDirectory, "fresh-checkout", { files: { diff --git a/packages/react-doctor/tests/select-projects.test.ts b/packages/react-doctor/tests/select-projects.test.ts index e1a388a09b..5c8a115bcd 100644 --- a/packages/react-doctor/tests/select-projects.test.ts +++ b/packages/react-doctor/tests/select-projects.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { selectProjects } from "../src/cli/utils/select-projects.js"; +import { discoverWorkspacePackages, selectProjects } from "../src/cli/utils/select-projects.js"; import { cliLogger } from "../src/cli/utils/cli-logger.js"; import { prompts } from "../src/cli/utils/prompts.js"; import { setupReactProject, writeJson } from "./regressions/_helpers.js"; @@ -68,6 +68,25 @@ describe("selectProjects", () => { ); }); + it("preserves root-first workspace-pattern order", () => { + const rootDirectory = createTempDirectory(); + writeJson(path.join(rootDirectory, "package.json"), { + name: "workspace-root", + dependencies: { react: "^19.0.0" }, + workspaces: ["packages/zeta", "apps/beta", "packages/alpha"], + }); + const zetaDirectory = setupReactProject(path.join(rootDirectory, "packages"), "zeta"); + const betaDirectory = setupReactProject(path.join(rootDirectory, "apps"), "beta"); + const alphaDirectory = setupReactProject(path.join(rootDirectory, "packages"), "alpha"); + + expect(discoverWorkspacePackages(rootDirectory)).toEqual([ + { name: "workspace-root", directory: rootDirectory }, + { name: "zeta", directory: zetaDirectory }, + { name: "beta", directory: betaDirectory }, + { name: "alpha", directory: alphaDirectory }, + ]); + }); + it("falls through to subproject discovery for a monorepo with no workspace React packages", async () => { const tempDirectory = createTempDirectory(); writeJson(path.join(tempDirectory, "package.json"), {