From f249e3aa9496a99d11a27c42cd2fdfdf50fcd1b7 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 02:09:43 +0000 Subject: [PATCH 01/17] refactor(cli): extract inspect result finalization --- .../src/cli/utils/finalize-inspect-result.ts | 177 ++++++++++ .../src/cli/utils/scan-result-cache.ts | 2 +- packages/react-doctor/src/inspect-options.ts | 66 ++++ packages/react-doctor/src/inspect.ts | 303 +----------------- 4 files changed, 261 insertions(+), 287 deletions(-) create mode 100644 packages/react-doctor/src/cli/utils/finalize-inspect-result.ts create mode 100644 packages/react-doctor/src/inspect-options.ts diff --git a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts new file mode 100644 index 000000000..e4df52564 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts @@ -0,0 +1,177 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import { + buildSkippedChecks, + type Diagnostic, + filterDiagnosticsForSurface, + highlighter, + type InspectResult, + type ReactDoctorConfig, + type ScoreResult, +} from "@react-doctor/core"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { buildEmptyReportMessage } from "./build-empty-report-message.js"; +import { buildNoScoreMessage } from "./build-no-score-message.js"; +import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; +import { hasIncompleteScoreAnalysis } from "./has-incomplete-score-analysis.js"; +import { printDiagnosticsDump } from "./print-diagnostics-dump.js"; +import { printFooter } from "./print-footer.js"; +import { printHeadlessReport } from "./print-headless-report.js"; +import { printAgentGuidance } from "./render-agent-guidance.js"; + +interface FinalizeInspectResultInput { + 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 securityScanFailureReason: string | null; + 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 finalizeInspectResult = ( + input: FinalizeInspectResultInput, +): Effect.Effect => + Effect.gen(function* () { + const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ + didLintFail: input.didLintFail, + lintFailureReason: input.lintFailureReason, + lintPartialFailures: input.lintPartialFailures, + didDeadCodeFail: input.didDeadCodeFail, + deadCodeFailureReason: input.deadCodeFailureReason, + supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut, + securityScanFailed: input.securityScanFailed, + securityScanFailureReason: input.securityScanFailureReason, + }); + const hasSkippedChecks = skippedChecks.length > 0; + const noScoreMessage = buildNoScoreMessage({ + isScoreDisabled: input.options.noScore, + isAnalysisIncomplete: hasIncompleteScoreAnalysis(skippedChecks), + disabledMessage: input.options.scoreDisabledMessage, + }); + const result: InspectResult = { + diagnostics: [...input.diagnostics], + score: input.score, + skippedChecks, + ...(Object.keys(skippedCheckReasons).length > 0 ? { 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 } : {}), + }; + + if (input.options.suppressRendering) return result; + + const surfaceDiagnostics = filterDiagnosticsForSurface( + [...input.diagnostics], + input.options.outputSurface, + input.userConfig, + ); + const printedDiagnostics = filterDiagnosticsByCategories( + surfaceDiagnostics, + input.options.categoryFilters, + ); + + if (input.options.scoreOnly) { + if (input.options.outputDirectory !== null) { + yield* printDiagnosticsDump( + printedDiagnostics, + input.options.outputDirectory, + false, + "stderr", + ); + } + if (input.score) { + yield* Console.log(`${input.score.score}`); + } else { + yield* Console.error(highlighter.gray(noScoreMessage)); + } + return result; + } + + const demotedDiagnosticCount = input.diagnostics.length - surfaceDiagnostics.length; + if (input.options.isNonInteractiveEnvironment && input.options.outputSurface !== "prComment") { + yield* printAgentGuidance(); + } + + yield* printHeadlessReport({ + diagnostics: printedDiagnostics, + elapsedMilliseconds: input.elapsedMilliseconds, + emptyStateMessage: buildEmptyReportMessage({ + categoryFilters: input.options.categoryFilters, + demotedDiagnosticCount, + outputSurface: input.options.outputSurface, + }), + noScoreMessage, + projectName: input.project.projectName, + scannedFileCount: input.scannedFileCount, + scoreResult: hasSkippedChecks ? null : input.score, + skippedChecks, + }); + + if (input.options.outputDirectory !== null || input.options.verbose) { + yield* printDiagnosticsDump( + printedDiagnostics, + input.options.outputDirectory, + input.options.verbose, + ); + } + if (input.options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) { + yield* Console.log( + highlighter.gray( + ` ${demotedDiagnosticCount} demoted from the ${input.options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`, + ), + ); + yield* Console.log(""); + } + + yield* printFooter({ + diagnostics: printedDiagnostics, + scoreResult: input.score, + projectName: input.project.projectName, + isOffline: input.options.isCi || !input.options.share || input.score === null, + }); + + return result; + }); 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 3225aff8f..57b59f93b 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache.ts @@ -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 { ResolvedInspectOptions } from "../../inspect-options.js"; export interface CachedScanPayload { readonly diagnostics: ReadonlyArray; diff --git a/packages/react-doctor/src/inspect-options.ts b/packages/react-doctor/src/inspect-options.ts new file mode 100644 index 000000000..06be25107 --- /dev/null +++ b/packages/react-doctor/src/inspect-options.ts @@ -0,0 +1,66 @@ +import type { + ChangedFileLineRanges, + DiagnosticSurface, + InspectOptions, + Progress, + Reporter, +} from "@react-doctor/core"; +import type * as Layer from "effect/Layer"; + +export interface InspectUiLayers { + readonly reporter: Layer.Layer; + readonly progress?: Layer.Layer; +} + +export interface ReactDoctorInspectOptions extends InspectOptions { + precomputedSourceFileCount?: number; + categoryFilters?: string[]; + includedTags?: ReadonlySet; + includeTagDefaults?: boolean; + scoreDisabledMessage?: string; + deadlineEpochMs?: number; + excludedProjectDirectories?: ReadonlyArray; + retainExcludedProjectDeadCodeDiagnostics?: boolean; + 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; + uiLayers: InspectUiLayers | null; + concurrentScan: boolean; + concurrency: number | undefined; + maxDurationMs: number | null; + baseline: { + ref: string; + baseFiles?: ReadonlyArray; + headFiles?: ReadonlyArray; + } | null; + changedLineRanges: ReadonlyArray | null; + supplyChainManifestChanged: boolean; + excludedProjectDirectories: ReadonlyArray; + retainExcludedProjectDeadCodeDiagnostics: boolean; + precomputedSourceFileCount: number | undefined; +} diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index cbd125f78..491c422d9 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -5,18 +5,14 @@ import { performance } from "node:perf_hooks"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { - buildSkippedChecks, type ChangedFileLineRanges, computeDiagnosticDelta, createOxlintSpawnSlots, DEFAULT_SHOW_WARNINGS, type Diagnostic, - type DiagnosticSurface, - filterDiagnosticsForSurface, filterPathsOutsideDirectories, filterSourceFiles, highlighter, - type InspectOptions, type InspectResult, OXLINT_NODE_REQUIREMENT, OxlintConcurrency, @@ -28,13 +24,10 @@ import { resolveScanConcurrency, restoreLegacyThrow, runInspect as runInspectEffect, - type ScoreResult, - type Reporter, SidecarLintCacheEnabled, type WorkerSlots, yieldToEventLoop, } from "@react-doctor/core"; -import type * as Layer from "effect/Layer"; import { activeScanAbortRegistry } from "./cli/utils/active-scan-abort-registry.js"; import { applyObservability } from "./cli/utils/apply-observability.js"; import { buildRuntimeLayers } from "./cli/utils/build-runtime-layers.js"; @@ -57,16 +50,9 @@ 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 { hasIncompleteScoreAnalysis } from "./cli/utils/has-incomplete-score-analysis.js"; -import { buildEmptyReportMessage } from "./cli/utils/build-empty-report-message.js"; -import { printAgentGuidance } from "./cli/utils/render-agent-guidance.js"; import { isCiOrCodingAgentEnvironment } from "./cli/utils/is-ci-environment.js"; -import { filterDiagnosticsByCategories } from "./cli/utils/filter-diagnostics-by-categories.js"; import { isNonInteractiveEnvironment } from "./cli/utils/is-non-interactive-environment.js"; -import { printDiagnosticsDump } from "./cli/utils/print-diagnostics-dump.js"; -import { printFooter } from "./cli/utils/print-footer.js"; -import { printHeadlessReport } from "./cli/utils/print-headless-report.js"; +import { finalizeInspectResult } from "./cli/utils/finalize-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"; @@ -80,6 +66,17 @@ import { } from "./cli/utils/scan-result-cache.js"; import { isSpinnerSilent, setSpinnerSilent } from "./cli/utils/spinner.js"; import { VERSION } from "./cli/utils/version.js"; +import type { + InspectUiLayers, + ReactDoctorInspectOptions, + ResolvedInspectOptions, +} from "./inspect-options.js"; + +export type { + InspectUiLayers, + ReactDoctorInspectOptions, + ResolvedInspectOptions, +} from "./inspect-options.js"; const silentConsole = makeNoopConsole(); @@ -114,97 +111,6 @@ const buildChangedLineMatcher = ( }; }; -/** - * 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 { - /** Internal: source-file count collected once for a workspace batch. */ - precomputedSourceFileCount?: number; - 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; - /** Internal: descendant projects covered by sibling scans in the same workspace batch. */ - excludedProjectDirectories?: ReadonlyArray; - /** Internal: this scan owns dead-code findings for its excluded descendants. */ - retainExcludedProjectDeadCodeDiagnostics?: boolean; - /** 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 headless console path. */ - uiLayers: InspectUiLayers | null; - /** Descendant projects covered by sibling scans in the same workspace batch. */ - excludedProjectDirectories: ReadonlyArray; - /** Whether this scan owns dead-code findings for excluded descendants. */ - retainExcludedProjectDeadCodeDiagnostics: boolean; - /** Source-file count collected once for a workspace batch. */ - precomputedSourceFileCount: number | undefined; -} - const buildIgnoredTags = ( userConfig: ReactDoctorConfig | null, includedTags: ReadonlySet, @@ -933,35 +839,6 @@ const runInspectWithRuntime = async ( 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; - securityScanFailureReason: string | null; - 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 RenderAndRecordScanInput { readonly payload: CachedScanPayload; readonly options: ResolvedInspectOptions; @@ -1009,14 +886,8 @@ interface RenderAndRecordScanInput { readonly deadCodeSummaryCacheMisses?: number | null; } -const runMaybeSilent = ( - effect: Effect.Effect, - silent: boolean, -): Effect.Effect => - silent ? effect.pipe(Effect.provideService(Console.Console, silentConsole)) : effect; - const renderAndRecordScan = async (input: RenderAndRecordScanInput): Promise => { - const finalizeInput: FinalizeInput = { + const finalizeInput = { options: input.options, elapsedMilliseconds: performance.now() - input.startTime, diagnostics: input.payload.diagnostics, @@ -1044,8 +915,11 @@ const renderAndRecordScan = async (input: RenderAndRecordScanInput): Promise => - Effect.gen(function* () { - const { - options, - elapsedMilliseconds, - diagnostics, - score, - project, - userConfig, - didLintFail, - lintFailureReason, - lintPartialFailures, - didDeadCodeFail, - deadCodeFailureReason, - supplyChainOverlapTimedOut, - securityScanFailed, - securityScanFailureReason, - scannedFileCount, - scannedFilePaths, - analyzedFiles, - scanElapsedMilliseconds, - lintCacheHitFileCount, - lintCacheTotalFileCount, - lintSidecarReplayedFileCount, - lintSidecarTotalFileCount, - deadCodeCacheHit, - deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses, - baselineDelta, - } = input; - - const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ - didLintFail, - lintFailureReason, - lintPartialFailures, - didDeadCodeFail, - deadCodeFailureReason, - supplyChainOverlapTimedOut, - securityScanFailed, - securityScanFailureReason, - }); - const hasSkippedChecks = skippedChecks.length > 0; - const noScoreMessage = buildNoScoreMessage({ - isScoreDisabled: options.noScore, - isAnalysisIncomplete: hasIncompleteScoreAnalysis(skippedChecks), - disabledMessage: 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(); - } - - const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length; - if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") { - yield* printAgentGuidance(); - } - - yield* printHeadlessReport({ - diagnostics: printedDiagnostics, - elapsedMilliseconds, - emptyStateMessage: buildEmptyReportMessage({ - categoryFilters: options.categoryFilters, - demotedDiagnosticCount, - outputSurface: options.outputSurface, - }), - noScoreMessage, - projectName: project.projectName, - scannedFileCount, - scoreResult: hasSkippedChecks ? null : score, - skippedChecks, - }); - - if (options.outputDirectory !== null || options.verbose) { - yield* printDiagnosticsDump(printedDiagnostics, options.outputDirectory, options.verbose); - } - 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(""); - } - - yield* printFooter({ - diagnostics: printedDiagnostics, - scoreResult: score, - projectName: project.projectName, - isOffline: options.isCi || !options.share || score === null, - }); - - return buildResult(); - }); From dce5541c48cd4637e68ce74966b890403284df74 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 08:39:50 +0000 Subject: [PATCH 02/17] refactor: harden scan orchestration and caches --- packages/core/src/index.ts | 1 + packages/core/src/utils/atomic-write-file.ts | 22 + packages/core/src/utils/atomic-write-json.ts | 12 +- packages/deslop-js/src/summary-cache.ts | 22 +- .../deslop-js/src/utils/atomic-write-file.ts | 22 + packages/react-doctor/package.json | 1 - .../react-doctor/src/cli/ink/run-scan-app.tsx | 2 +- .../src/cli/utils/finalize-inspect-result.ts | 110 ++-- .../cli/utils/open-workflow-pull-request.ts | 28 +- .../src/cli/utils/render-and-record-scan.ts | 145 +++++ .../src/cli/utils/resolve-inspect-options.ts | 60 +++ .../utils/resolve-project-tui-scan-scope.ts | 2 +- .../src/cli/utils/run-baseline-comparison.ts | 191 +++++++ .../cli/utils/scan-result-cache-payload.ts | 127 +++++ .../src/cli/utils/scan-result-cache.ts | 101 +--- packages/react-doctor/src/inspect-runtime.ts | 9 + packages/react-doctor/src/inspect.ts | 495 +----------------- .../tests/helpers/render-in-terminal.ts | 78 --- .../tests/open-workflow-pull-request.test.ts | 28 +- .../tests/scan-result-cache.test.ts | 41 +- pnpm-lock.yaml | 8 - 21 files changed, 754 insertions(+), 751 deletions(-) create mode 100644 packages/core/src/utils/atomic-write-file.ts create mode 100644 packages/deslop-js/src/utils/atomic-write-file.ts create mode 100644 packages/react-doctor/src/cli/utils/render-and-record-scan.ts create mode 100644 packages/react-doctor/src/cli/utils/resolve-inspect-options.ts create mode 100644 packages/react-doctor/src/cli/utils/run-baseline-comparison.ts create mode 100644 packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts create mode 100644 packages/react-doctor/src/inspect-runtime.ts delete mode 100644 packages/react-doctor/tests/helpers/render-in-terminal.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 14964fd08..e62be578a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,6 +86,7 @@ export * from "./run-oxlint.js"; export * from "./summarize-diagnostics.js"; export * from "./validate-config-types.js"; export * from "./utils/assign-fix-groups.js"; +export * from "./utils/atomic-write-json.js"; export * from "./utils/build-rule-docs-url.js"; export * from "./utils/classify-package-role.js"; export * from "./utils/collect-source-file-counts-by-directory.js"; diff --git a/packages/core/src/utils/atomic-write-file.ts b/packages/core/src/utils/atomic-write-file.ts new file mode 100644 index 000000000..fbb6f9190 --- /dev/null +++ b/packages/core/src/utils/atomic-write-file.ts @@ -0,0 +1,22 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const atomicWriteFile = (filePath: string, contents: string): void => { + let temporaryPath: string | null = null; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, contents); + fs.renameSync(temporaryPath, filePath); + temporaryPath = null; + } catch { + return; + } finally { + if (temporaryPath !== null) { + try { + fs.rmSync(temporaryPath, { force: true }); + } catch {} + } + } +}; diff --git a/packages/core/src/utils/atomic-write-json.ts b/packages/core/src/utils/atomic-write-json.ts index 7c47e73d4..85d664665 100644 --- a/packages/core/src/utils/atomic-write-json.ts +++ b/packages/core/src/utils/atomic-write-json.ts @@ -1,16 +1,12 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; +import { atomicWriteFile } from "./atomic-write-file.js"; -// Writes `value` as JSON to `filePath` atomically: serialize to a -// pid-suffixed temp file in the same directory, then rename over the target so +// Writes `value` as JSON to `filePath` atomically: serialize to a unique temp +// file in the same directory, then rename over the target so // a concurrent reader sees either the old or the new file, never a half-written // one. Swallows every error — a cache that can't persist must not break a scan. export const atomicWriteJson = (filePath: string, value: unknown): void => { try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const temporaryPath = `${filePath}.${process.pid}.tmp`; - fs.writeFileSync(temporaryPath, JSON.stringify(value)); - fs.renameSync(temporaryPath, filePath); + atomicWriteFile(filePath, JSON.stringify(value)); } catch { return; } diff --git a/packages/deslop-js/src/summary-cache.ts b/packages/deslop-js/src/summary-cache.ts index da26fe6f0..3c544f5c9 100644 --- a/packages/deslop-js/src/summary-cache.ts +++ b/packages/deslop-js/src/summary-cache.ts @@ -28,15 +28,7 @@ // rooted at `rootDir` (matching core's whole-result dead-code cache), so // manifest edits ABOVE the scanned root share that cache's accepted gap. import crypto from "node:crypto"; -import { - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - renameSync, - statSync, - writeFileSync, -} from "node:fs"; +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { Minimatch } from "minimatch"; @@ -49,6 +41,7 @@ import { SUMMARY_CACHE_MAX_BYTES, SUMMARY_CACHE_SCHEMA_VERSION, } from "./constants.js"; +import { atomicWriteFile } from "./utils/atomic-write-file.js"; import { toPosixPath } from "./utils/to-posix-path.js"; export type PackageFactKind = "substring" | "importReference"; @@ -500,17 +493,6 @@ const reviveParsedSource = (persisted: unknown): ParsedSource | null => { }; }; -const atomicWriteFile = (filePath: string, contents: string): void => { - try { - mkdirSync(dirname(filePath), { recursive: true }); - const temporaryPath = `${filePath}.${process.pid}.tmp`; - writeFileSync(temporaryPath, contents); - renameSync(temporaryPath, filePath); - } catch { - // A cache that cannot persist must never break the analysis. - } -}; - const createSummaryCache = (cachePath: string, config: DeslopConfig): SummaryCache => { const scopeHash = computeScopeHash(config); const store = readPersistedStore(cachePath, scopeHash); diff --git a/packages/deslop-js/src/utils/atomic-write-file.ts b/packages/deslop-js/src/utils/atomic-write-file.ts new file mode 100644 index 000000000..fbb6f9190 --- /dev/null +++ b/packages/deslop-js/src/utils/atomic-write-file.ts @@ -0,0 +1,22 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const atomicWriteFile = (filePath: string, contents: string): void => { + let temporaryPath: string | null = null; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, contents); + fs.renameSync(temporaryPath, filePath); + temporaryPath = null; + } catch { + return; + } finally { + if (temporaryPath !== null) { + try { + fs.rmSync(temporaryPath, { force: true }); + } catch {} + } + } +}; diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 9cdbf3818..691325103 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -85,7 +85,6 @@ "@types/babel__code-frame": "^7.27.0", "@types/prompts": "^2.4.9", "@types/react": "^19.2.14", - "@xterm/headless": "^6.0.0", "commander": "^14.0.3", "ink": "^7.1.0", "ink-spinner": "^5.0.0", 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 78470131b..674edb9eb 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -23,7 +23,7 @@ import type { WorkspacePackage, } from "@react-doctor/core"; import { createInvocationInspect } from "../../inspect.js"; -import type { ReactDoctorInspectOptions } from "../../inspect.js"; +import type { ReactDoctorInspectOptions } from "../../inspect-options.js"; import { buildNoScoreMessage } from "../utils/build-no-score-message.js"; import { hasIncompleteScoreAnalysis } from "../utils/has-incomplete-score-analysis.js"; import type { InspectFlags } from "../utils/inspect-flags.js"; diff --git a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts index e4df52564..91b060a1b 100644 --- a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts +++ b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts @@ -2,12 +2,9 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { buildSkippedChecks, - type Diagnostic, filterDiagnosticsForSurface, highlighter, type InspectResult, - type ReactDoctorConfig, - type ScoreResult, } from "@react-doctor/core"; import type { ResolvedInspectOptions } from "../../inspect-options.js"; import { buildEmptyReportMessage } from "./build-empty-report-message.js"; @@ -18,26 +15,9 @@ import { printDiagnosticsDump } from "./print-diagnostics-dump.js"; import { printFooter } from "./print-footer.js"; import { printHeadlessReport } from "./print-headless-report.js"; import { printAgentGuidance } from "./render-agent-guidance.js"; +import type { CachedScanPayload } from "./scan-result-cache-payload.js"; -interface FinalizeInspectResultInput { - 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 securityScanFailureReason: string | null; - readonly scannedFileCount: number; - readonly scannedFilePaths: ReadonlyArray; - readonly analyzedFiles: ReadonlyArray; - readonly scanElapsedMilliseconds: number; +export interface InspectExecutionCacheStats { readonly lintCacheHitFileCount: number | null; readonly lintCacheTotalFileCount: number | null; readonly lintSidecarReplayedFileCount: number | null; @@ -45,22 +25,29 @@ interface FinalizeInspectResultInput { readonly deadCodeCacheHit: boolean | null; readonly deadCodeSummaryCacheHits: number | null; readonly deadCodeSummaryCacheMisses: number | null; - readonly baselineDelta: InspectResult["baselineDelta"]; +} + +interface FinalizeInspectResultInput { + readonly options: ResolvedInspectOptions; + readonly elapsedMilliseconds: number; + readonly payload: CachedScanPayload; + readonly cacheStats: InspectExecutionCacheStats; } export const finalizeInspectResult = ( input: FinalizeInspectResultInput, ): Effect.Effect => Effect.gen(function* () { + const { payload, cacheStats } = input; const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ - didLintFail: input.didLintFail, - lintFailureReason: input.lintFailureReason, - lintPartialFailures: input.lintPartialFailures, - didDeadCodeFail: input.didDeadCodeFail, - deadCodeFailureReason: input.deadCodeFailureReason, - supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut, - securityScanFailed: input.securityScanFailed, - securityScanFailureReason: input.securityScanFailureReason, + didLintFail: payload.didLintFail, + lintFailureReason: payload.lintFailureReason, + lintPartialFailures: payload.lintPartialFailures, + didDeadCodeFail: payload.didDeadCodeFail, + deadCodeFailureReason: payload.deadCodeFailureReason, + supplyChainOverlapTimedOut: payload.supplyChainOverlapTimedOut, + securityScanFailed: payload.securityScanFailed ?? false, + securityScanFailureReason: payload.securityScanFailureReason ?? null, }); const hasSkippedChecks = skippedChecks.length > 0; const noScoreMessage = buildNoScoreMessage({ @@ -69,44 +56,47 @@ export const finalizeInspectResult = ( disabledMessage: input.options.scoreDisabledMessage, }); const result: InspectResult = { - diagnostics: [...input.diagnostics], - score: input.score, + diagnostics: [...payload.diagnostics], + score: payload.score, skippedChecks, ...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}), - project: input.project, + project: payload.project, elapsedMilliseconds: input.elapsedMilliseconds, - scannedFileCount: input.scannedFileCount, - scannedFilePaths: input.scannedFilePaths, - analyzedFiles: input.analyzedFiles, - scanElapsedMilliseconds: input.scanElapsedMilliseconds, - ...(input.lintCacheTotalFileCount !== null + scannedFileCount: payload.scannedFileCount, + scannedFilePaths: payload.scannedFilePaths, + analyzedFiles: payload.analyzedFiles ?? [], + scanElapsedMilliseconds: payload.scanElapsedMilliseconds, + ...(cacheStats.lintCacheTotalFileCount !== null ? { - lintCacheHitFileCount: input.lintCacheHitFileCount, - lintCacheTotalFileCount: input.lintCacheTotalFileCount, + lintCacheHitFileCount: cacheStats.lintCacheHitFileCount, + lintCacheTotalFileCount: cacheStats.lintCacheTotalFileCount, } : {}), - ...(input.lintSidecarTotalFileCount !== null + ...(cacheStats.lintSidecarTotalFileCount !== null ? { - lintSidecarReplayedFileCount: input.lintSidecarReplayedFileCount, - lintSidecarTotalFileCount: input.lintSidecarTotalFileCount, + lintSidecarReplayedFileCount: cacheStats.lintSidecarReplayedFileCount, + lintSidecarTotalFileCount: cacheStats.lintSidecarTotalFileCount, } : {}), - ...(input.deadCodeCacheHit !== null ? { deadCodeCacheHit: input.deadCodeCacheHit } : {}), - ...(input.deadCodeSummaryCacheHits !== null && input.deadCodeSummaryCacheMisses !== null + ...(cacheStats.deadCodeCacheHit !== null + ? { deadCodeCacheHit: cacheStats.deadCodeCacheHit } + : {}), + ...(cacheStats.deadCodeSummaryCacheHits !== null && + cacheStats.deadCodeSummaryCacheMisses !== null ? { - deadCodeSummaryCacheHits: input.deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses: input.deadCodeSummaryCacheMisses, + deadCodeSummaryCacheHits: cacheStats.deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses: cacheStats.deadCodeSummaryCacheMisses, } : {}), - ...(input.baselineDelta ? { baselineDelta: input.baselineDelta } : {}), + ...(payload.baselineDelta ? { baselineDelta: payload.baselineDelta } : {}), }; if (input.options.suppressRendering) return result; const surfaceDiagnostics = filterDiagnosticsForSurface( - [...input.diagnostics], + [...payload.diagnostics], input.options.outputSurface, - input.userConfig, + payload.userConfig, ); const printedDiagnostics = filterDiagnosticsByCategories( surfaceDiagnostics, @@ -122,15 +112,15 @@ export const finalizeInspectResult = ( "stderr", ); } - if (input.score) { - yield* Console.log(`${input.score.score}`); + if (payload.score) { + yield* Console.log(`${payload.score.score}`); } else { yield* Console.error(highlighter.gray(noScoreMessage)); } return result; } - const demotedDiagnosticCount = input.diagnostics.length - surfaceDiagnostics.length; + const demotedDiagnosticCount = payload.diagnostics.length - surfaceDiagnostics.length; if (input.options.isNonInteractiveEnvironment && input.options.outputSurface !== "prComment") { yield* printAgentGuidance(); } @@ -144,9 +134,9 @@ export const finalizeInspectResult = ( outputSurface: input.options.outputSurface, }), noScoreMessage, - projectName: input.project.projectName, - scannedFileCount: input.scannedFileCount, - scoreResult: hasSkippedChecks ? null : input.score, + projectName: payload.project.projectName, + scannedFileCount: payload.scannedFileCount, + scoreResult: hasSkippedChecks ? null : payload.score, skippedChecks, }); @@ -168,9 +158,9 @@ export const finalizeInspectResult = ( yield* printFooter({ diagnostics: printedDiagnostics, - scoreResult: input.score, - projectName: input.project.projectName, - isOffline: input.options.isCi || !input.options.share || input.score === null, + scoreResult: payload.score, + projectName: payload.project.projectName, + isOffline: input.options.isCi || !input.options.share || payload.score === null, }); return result; diff --git a/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts b/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts index 8ee01526f..4cebadc29 100644 --- a/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts +++ b/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts @@ -1,4 +1,5 @@ import * as path from "node:path"; +import { isPathInsideDirectory } from "@react-doctor/core"; import { GH_PR_LIST_MAX } from "./constants.js"; import { detectDefaultBranch } from "./detect-default-branch.js"; import { isCommandAvailable } from "./is-command-available.js"; @@ -41,6 +42,7 @@ export type NotAttemptedReason = | "gh-not-installed" | "gh-not-authenticated" | "not-a-git-repo" + | "workflow-outside-repository" | "no-default-branch" | "detached-head" // The working tree has tracked (staged or unstaged) modifications, which @@ -141,7 +143,7 @@ const hasUnrelatedTrackedChanges = async ( // Async so the chain no longer blocks the event loop and the caller's `ora` // spinner keeps animating through the slow network steps. Each step still // runs sequentially via `await` because it depends on the previous one. -export const openWorkflowPullRequest = async (params: { +export const openWorkflowPullRequest = async (input: { workflowPath: string; // Override the commit message / PR title + body. Defaults describe a fresh // install; the v1→v2 upgrade flow passes its own copy. The git/`gh` steps, @@ -160,12 +162,12 @@ export const openWorkflowPullRequest = async (params: { run?: CommandRunner; checkCommandAvailable?: (command: string) => boolean; }): Promise => { - const workflowPath = path.resolve(params.workflowPath); - const commitMessage = params.commitMessage ?? DEFAULT_COMMIT_MESSAGE; - const prTitle = params.prTitle ?? DEFAULT_PR_TITLE; - const prBody = params.prBody ?? DEFAULT_PR_BODY; - const run = params.run ?? runCommand; - const checkCommandAvailable = params.checkCommandAvailable ?? isCommandAvailable; + const workflowPath = path.resolve(input.workflowPath); + const commitMessage = input.commitMessage ?? DEFAULT_COMMIT_MESSAGE; + const prTitle = input.prTitle ?? DEFAULT_PR_TITLE; + const prBody = input.prBody ?? DEFAULT_PR_BODY; + const run = input.run ?? runCommand; + const checkCommandAvailable = input.checkCommandAvailable ?? isCommandAvailable; // Probe from the workflow file's directory so we resolve the repo root // even when the CLI was invoked from a sub-package in a monorepo. @@ -176,6 +178,9 @@ export const openWorkflowPullRequest = async (params: { ); if (!repoRootProbe.success) return { status: "not-attempted", reason: "not-a-git-repo" }; const cwd = repoRootProbe.stdout; + if (!isPathInsideDirectory(workflowPath, cwd)) { + return { status: "not-attempted", reason: "workflow-outside-repository" }; + } // Forward slashes so the `:!` exclude pathspec and `git add` match git's // forward-slash-normalized repo paths on Windows (where `path.relative` // yields backslashes, which git's magic pathspec won't treat as separators). @@ -205,7 +210,7 @@ export const openWorkflowPullRequest = async (params: { return { status: "not-attempted", reason: "working-tree-dirty" }; } - const defaultBranch = params.baseBranch ?? (await detectDefaultBranch(cwd, run)); + const defaultBranch = input.baseBranch ?? (await detectDefaultBranch(cwd, run)); if (!defaultBranch) return { status: "not-attempted", reason: "no-default-branch" }; const previousBranchProbe = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd); @@ -283,18 +288,19 @@ export const openWorkflowPullRequest = async (params: { // `"not-attempted"` and the file should still land in their next commit // instead of sitting as an orphan untracked path. Returns whether the stage // actually happened. -export const stageWorkflowFile = async (params: { +export const stageWorkflowFile = async (input: { workflowPath: string; run?: CommandRunner; }): Promise => { - const workflowPath = path.resolve(params.workflowPath); - const run = params.run ?? runCommand; + const workflowPath = path.resolve(input.workflowPath); + const run = input.run ?? runCommand; const repoRootProbe = await run( "git", ["rev-parse", "--show-toplevel"], path.dirname(workflowPath), ); if (!repoRootProbe.success) return false; + if (!isPathInsideDirectory(workflowPath, repoRootProbe.stdout)) return false; const workflowRelative = toForwardSlashes(path.relative(repoRootProbe.stdout, workflowPath)); return (await run("git", ["add", "--", workflowRelative], repoRootProbe.stdout)).success; }; diff --git a/packages/react-doctor/src/cli/utils/render-and-record-scan.ts b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts new file mode 100644 index 000000000..ff5589c30 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts @@ -0,0 +1,145 @@ +import { performance } from "node:perf_hooks"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import type { InspectResult, ReactDoctorConfig } from "@react-doctor/core"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { recordRunEvent } from "./build-run-event.js"; +import { countDeadlineSkippedFiles } from "./count-deadline-skipped-files.js"; +import { countDroppedLintFiles } from "./count-dropped-lint-files.js"; +import { + finalizeInspectResult, + type InspectExecutionCacheStats, +} from "./finalize-inspect-result.js"; +import { makeNoopConsole } from "./noop-console.js"; +import { recordScanMetrics } from "./record-scan-metrics.js"; +import { resolveWorkerTelemetry } from "./resolve-worker-telemetry.js"; +import type { CachedScanPayload } from "./scan-result-cache-payload.js"; +import type { SentryRootSpan } from "./with-sentry-run-span.js"; + +export interface RenderAndRecordScanInput { + readonly payload: CachedScanPayload; + readonly options: ResolvedInspectOptions; + readonly startTime: number; + readonly rootSentrySpan: SentryRootSpan; + readonly scanMode: "full" | "diff" | "baseline"; + readonly baselineDegraded: boolean; + readonly wholeRepoCacheHit: boolean; + readonly cacheStats?: Partial; +} + +export interface RunEventConfig { + readonly scope: string; + readonly parallel: boolean; + readonly workerCount: number | undefined; + readonly maxDurationMs: number | null; + readonly lint: boolean; + readonly deadCode: boolean; + readonly supplyChain: boolean; + readonly scoreOnly: boolean; + readonly noScore: boolean; + readonly respectInlineDisables: boolean; + readonly showWarnings: boolean; + readonly usedOutputDir: boolean; + readonly ignoredTagCount: number; + readonly hasCustomConfig: boolean; + readonly userConfig: ReactDoctorConfig | null; +} + +const silentConsole = makeNoopConsole(); + +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, + resolvedWorkerCount?: number, +): RunEventConfig => { + 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 !== null, + userConfig, + }; +}; + +export const renderAndRecordScan = async ( + input: RenderAndRecordScanInput, +): Promise => { + const cacheStats: InspectExecutionCacheStats = { + lintCacheHitFileCount: input.cacheStats?.lintCacheHitFileCount ?? null, + lintCacheTotalFileCount: input.cacheStats?.lintCacheTotalFileCount ?? null, + lintSidecarReplayedFileCount: input.cacheStats?.lintSidecarReplayedFileCount ?? null, + lintSidecarTotalFileCount: input.cacheStats?.lintSidecarTotalFileCount ?? null, + deadCodeCacheHit: input.cacheStats?.deadCodeCacheHit ?? null, + deadCodeSummaryCacheHits: input.cacheStats?.deadCodeSummaryCacheHits ?? null, + deadCodeSummaryCacheMisses: input.cacheStats?.deadCodeSummaryCacheMisses ?? null, + }; + const finalizeEffect = finalizeInspectResult({ + options: input.options, + elapsedMilliseconds: performance.now() - input.startTime, + payload: input.payload, + cacheStats, + }); + const result = await Effect.runPromise( + input.options.silent + ? finalizeEffect.pipe(Effect.provideService(Console.Console, silentConsole)) + : finalizeEffect, + ); + 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.payload.userConfig, + suppressedRuleCounts: input.payload.suppressedRuleCounts ?? [], + }); + recordRunEvent(input.rootSentrySpan, { + ...buildRunEventConfig(input.options, input.payload.userConfig, 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; +}; 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 000000000..3e09f9956 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts @@ -0,0 +1,60 @@ +import { DEFAULT_SHOW_WARNINGS, type ReactDoctorConfig } from "@react-doctor/core"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "../../inspect-options.js"; +import { isCiOrCodingAgentEnvironment } from "./is-ci-environment.js"; +import { isNonInteractiveEnvironment } from "./is-non-interactive-environment.js"; +import { resolveCliCategories } from "./resolve-cli-categories.js"; + +const resolveIgnoredTags = ( + userConfig: ReactDoctorConfig | null, + includedTags: ReadonlySet, +): ReadonlySet => { + const ignoredTags = new Set(userConfig?.ignore?.tags ?? []); + for (const includedTag of includedTags) ignoredTags.delete(includedTag); + return ignoredTags; +}; + +export const resolveInspectOptions = ( + 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: resolveIgnoredTags(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, + excludedProjectDirectories: inputOptions.excludedProjectDirectories ?? [], + retainExcludedProjectDeadCodeDiagnostics: + inputOptions.retainExcludedProjectDeadCodeDiagnostics ?? false, + precomputedSourceFileCount: inputOptions.precomputedSourceFileCount, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/resolve-project-tui-scan-scope.ts b/packages/react-doctor/src/cli/utils/resolve-project-tui-scan-scope.ts index 774b7d695..846e47f5f 100644 --- a/packages/react-doctor/src/cli/utils/resolve-project-tui-scan-scope.ts +++ b/packages/react-doctor/src/cli/utils/resolve-project-tui-scan-scope.ts @@ -1,5 +1,5 @@ import type { ChangedFileLineRanges } from "@react-doctor/core"; -import type { ReactDoctorInspectOptions } from "../../inspect.js"; +import type { ReactDoctorInspectOptions } from "../../inspect-options.js"; import { projectManifestChanged } from "./project-manifest-changed.js"; import { resolveProjectChangedLineRanges, diff --git a/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts b/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts new file mode 100644 index 000000000..a587a4d73 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts @@ -0,0 +1,191 @@ +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 { + computeDiagnosticDelta, + filterPathsOutsideDirectories, + filterSourceFiles, + type Diagnostic, + type InspectResult, + PerFileLintCacheEnabled, + type ProjectInfo, + type ReactDoctorConfig, + restoreLegacyThrow, + runInspect as runInspectEffect, + SidecarLintCacheEnabled, +} from "@react-doctor/core"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import type { OxlintInvocationRuntime } from "../../inspect-runtime.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 { createDiagnosticEvidenceReader } from "./read-diagnostic-evidence.js"; +import { createSourceLineReader } from "./read-source-line.js"; +import { materializeBaselineFiles } from "./materialize-baseline-files.js"; +import { makeNoopConsole } from "./noop-console.js"; +import { toForwardSlashes } from "./path-format.js"; +import { getRunId } from "./run-id.js"; +import { VERSION } from "./version.js"; + +export interface BaselineComparison { + readonly displayDiagnostics: ReadonlyArray; + readonly baselineDelta: NonNullable; +} + +export interface RunBaselineComparisonInput { + readonly directory: string; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly configSourceDirectory: string | null; + readonly headProjectInfo: ProjectInfo; + readonly headDiagnostics: ReadonlyArray; + readonly resolvedNodeBinaryPath: string | null; + readonly baselineRef: string; + readonly baseFiles?: ReadonlyArray; + readonly headFiles?: ReadonlyArray; + readonly headAnalyzedFiles: ReadonlyArray; + readonly deadlineEpochMs: number | null; + readonly oxlintRuntime: OxlintInvocationRuntime; +} + +const silentConsole = makeNoopConsole(); + +export const countIncompleteLintFiles = (lintPartialFailures: ReadonlyArray): number => + countDroppedLintFiles(lintPartialFailures) + countDeadlineSkippedFiles(lintPartialFailures); + +export const runBaselineComparison = async ( + input: RunBaselineComparisonInput, +): Promise => { + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX)); + const baselineIncludePaths = filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: input.options.includePaths, + excludedDirectories: input.options.excludedProjectDirectories, + }); + const baselineBaseFiles = input.baseFiles + ? filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: input.baseFiles, + excludedDirectories: input.options.excludedProjectDirectories, + }) + : undefined; + const baselineHeadFiles = input.headFiles + ? filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: input.headFiles, + excludedDirectories: input.options.excludedProjectDirectories, + }) + : undefined; + const snapshot = await materializeBaselineFiles({ + directory: input.directory, + ref: input.baselineRef, + files: baselineIncludePaths, + baseFiles: baselineBaseFiles, + headFiles: baselineHeadFiles, + tempDirectory: temporaryDirectory, + }).catch((error: unknown) => { + rmSync(temporaryDirectory, { recursive: true, force: true }); + throw error; + }); + if (snapshot === null) { + rmSync(temporaryDirectory, { 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 expectedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes)); + for (const filePath of baselineIncludePaths) { + const normalizedFilePath = toForwardSlashes(filePath); + if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath); + } + if ( + filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath)) + ) { + return null; + } + + const runtimeLayers = 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, + signal: input.oxlintRuntime.abortSignal, + }, + {}, + ); + const baseOutput = await Effect.runPromise( + restoreLegacyThrow( + baseProgram.pipe( + Effect.provide(runtimeLayers), + Effect.provideService(PerFileLintCacheEnabled, false), + Effect.provideService(SidecarLintCacheEnabled, false), + Effect.provideService(Console.Console, silentConsole), + ), + ), + { signal: input.oxlintRuntime.abortSignal }, + ); + if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) { + return null; + } + + const hasUnscannedUntrackedSourceFiles = filterSourceFiles( + snapshot.untrackedFiles.map(toForwardSlashes), + ).some((filePath) => !analyzedHeadFiles.has(filePath)); + const diagnosticDelta = 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: diagnosticDelta.newDiagnostics, + baselineDelta: { + baseRef: input.baselineRef, + fixedCount: hasUnscannedUntrackedSourceFiles ? 0 : diagnosticDelta.fixedCount, + baseTotalCount: baseOutput.diagnostics.length, + crossFileMatchCount: diagnosticDelta.crossFileMatchCount, + }, + }; + } finally { + snapshot.cleanup(); + } +}; diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts b/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts new file mode 100644 index 000000000..30c456dd4 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts @@ -0,0 +1,127 @@ +import * as Schema from "effect/Schema"; +import { Diagnostic as DiagnosticSchema } from "@react-doctor/core/schemas"; +import type { + Diagnostic, + InspectOutput, + InspectResult, + ReactDoctorConfig, + ScoreResult, + SuppressedRuleCount, +} from "@react-doctor/core"; +import { isRecord } from "./git-hook-shared.js"; + +export interface CachedScanPayload { + 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 deadCodeOverlapped: boolean; + readonly directory: string; + readonly scannedFileCount: number; + readonly scannedFilePaths: ReadonlyArray; + readonly analyzedFiles?: ReadonlyArray; + readonly scanElapsedMilliseconds: number; + readonly baselineDelta: InspectResult["baselineDelta"]; + readonly lintFailureReasonKind: InspectOutput["lintFailureReasonKind"]; + readonly scanConcurrency?: number; + readonly supplyChainOverlapTimedOut: boolean; + readonly securityScanFailed?: boolean; + readonly securityScanFailureReason?: string | null; + readonly suppressedRuleCounts?: ReadonlyArray; + readonly manifestContentHash?: string | null; +} + +const decodeDiagnostic = Schema.decodeUnknownSync(DiagnosticSchema); + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string"); + +const isNullableString = (value: unknown): value is string | null => + value === null || typeof value === "string"; + +const isDiagnosticArray = (value: unknown): value is Diagnostic[] => { + if (!Array.isArray(value)) return false; + try { + for (const entry of value) decodeDiagnostic(entry); + return true; + } catch { + return false; + } +}; + +const isScoreResult = (value: unknown): value is ScoreResult | null => + value === null || + (isRecord(value) && typeof value.score === "number" && typeof value.label === "string"); + +const isProjectInfo = (value: unknown): value is InspectResult["project"] => + isRecord(value) && + typeof value.rootDirectory === "string" && + typeof value.projectName === "string" && + typeof value.framework === "string" && + typeof value.sourceFileCount === "number"; + +const isBaselineDelta = (value: unknown): value is NonNullable => + isRecord(value) && + typeof value.baseRef === "string" && + typeof value.fixedCount === "number" && + typeof value.baseTotalCount === "number" && + (value.crossFileMatchCount === undefined || typeof value.crossFileMatchCount === "number"); + +const isSuppressedRuleCountArray = (value: unknown): value is SuppressedRuleCount[] => + Array.isArray(value) && + value.every( + (entry) => + isRecord(entry) && + typeof entry.rule === "string" && + typeof entry.source === "string" && + typeof entry.count === "number", + ); + +const isCachedScanPayload = (value: unknown): value is CachedScanPayload => { + if ( + !isRecord(value) || + !isDiagnosticArray(value.diagnostics) || + !isScoreResult(value.score) || + !isProjectInfo(value.project) || + !(value.userConfig === null || isRecord(value.userConfig)) || + typeof value.didLintFail !== "boolean" || + !isNullableString(value.lintFailureReason) || + !isStringArray(value.lintPartialFailures) || + typeof value.didDeadCodeFail !== "boolean" || + !isNullableString(value.deadCodeFailureReason) || + typeof value.deadCodeOverlapped !== "boolean" || + typeof value.directory !== "string" || + typeof value.scannedFileCount !== "number" || + !isStringArray(value.scannedFilePaths) || + (value.analyzedFiles !== undefined && !isStringArray(value.analyzedFiles)) || + typeof value.scanElapsedMilliseconds !== "number" || + (value.baselineDelta !== undefined && !isBaselineDelta(value.baselineDelta)) || + !(value.lintFailureReasonKind === null || typeof value.lintFailureReasonKind === "string") || + (value.scanConcurrency !== undefined && typeof value.scanConcurrency !== "number") || + typeof value.supplyChainOverlapTimedOut !== "boolean" || + (value.securityScanFailed !== undefined && typeof value.securityScanFailed !== "boolean") || + (value.securityScanFailureReason !== undefined && + !isNullableString(value.securityScanFailureReason)) || + (value.suppressedRuleCounts !== undefined && + !isSuppressedRuleCountArray(value.suppressedRuleCounts)) || + (value.manifestContentHash !== undefined && !isNullableString(value.manifestContentHash)) + ) { + return false; + } + return true; +}; + +export const decodeCachedScanPayload = (value: unknown): CachedScanPayload | null => + isCachedScanPayload(value) ? value : null; + +export const shouldStoreScanPayload = (payload: CachedScanPayload): boolean => + !payload.didLintFail && + !payload.didDeadCodeFail && + payload.lintPartialFailures.length === 0 && + !payload.supplyChainOverlapTimedOut && + payload.securityScanFailed !== true; 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 57b59f93b..2271d072d 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache.ts @@ -3,19 +3,13 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { + atomicWriteJson, computeConfigFingerprint, hashFileContents, resolveLintBatchOrdering, resolveReactDoctorCacheDir, } from "@react-doctor/core"; -import type { - Diagnostic, - InspectOutput, - InspectResult, - ReactDoctorConfig, - ScoreResult, - SuppressedRuleCount, -} from "@react-doctor/core"; +import type { ReactDoctorConfig } from "@react-doctor/core"; import { SCAN_RESULT_CACHE_FILENAME, SCAN_RESULT_CACHE_MAX_DIRTY_STATUS_ENTRY_COUNT, @@ -24,56 +18,11 @@ import { SCAN_RESULT_CACHE_SCHEMA_VERSION, } from "./constants.js"; import { getPackageJsonPath, isRecord, runGit } from "./git-hook-shared.js"; +import { decodeCachedScanPayload, type CachedScanPayload } from "./scan-result-cache-payload.js"; import type { ResolvedInspectOptions } from "../../inspect-options.js"; -export interface CachedScanPayload { - 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 deadCodeOverlapped: boolean; - readonly directory: string; - readonly scannedFileCount: number; - readonly scannedFilePaths: ReadonlyArray; - readonly analyzedFiles?: ReadonlyArray; - readonly scanElapsedMilliseconds: number; - readonly baselineDelta: InspectResult["baselineDelta"]; - readonly lintFailureReasonKind: InspectOutput["lintFailureReasonKind"]; - /** - * Resolved lint worker count (`InspectOutput["scanConcurrency"]`), surfaced - * for telemetry. Optional so cache entries persisted before this field - * existed still load — a stale hit falls back to the caller's `concurrency`. - */ - readonly scanConcurrency?: number; - readonly supplyChainOverlapTimedOut: boolean; - /** - * `InspectOutput["securityScanFailed"]`, surfaced for telemetry. Optional so - * cache entries persisted before this field existed still load; a failed - * pass is never cached (`shouldStoreScanPayload`), so a stale hit's - * `undefined` reads as the healthy `false`. - */ - readonly securityScanFailed?: boolean; - readonly securityScanFailureReason?: string | null; - /** - * `InspectOutput["suppressedRuleCounts"]` — deterministic for a given - * commit + config (part of the cache key), so a cache hit replays the same - * suppression telemetry the fresh scan emitted. - */ - readonly suppressedRuleCounts: ReadonlyArray; - /** - * Content hash of the project's `package.json` when the payload was stored - * (`null` when the project has none). Stamped by `store` and re-checked by - * `lookup` independently of the cache key, so any keying bug of the - * same-path-different-project class surfaces as a miss instead of silently - * replaying another project's diagnostics. - */ - readonly manifestContentHash?: string | null; -} +export { shouldStoreScanPayload } from "./scan-result-cache-payload.js"; +export type { CachedScanPayload } from "./scan-result-cache-payload.js"; interface PersistedScanResultCacheEntry { readonly key: string; @@ -363,8 +312,9 @@ const readPersistedCache = (cacheFilePath: string): PersistedScanResultCache => ) { continue; } - if (!isRecord(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue; - entries.push(entry as unknown as PersistedScanResultCacheEntry); + const payload = decodeCachedScanPayload(entry.payload); + if (payload === null) continue; + entries.push({ key: entry.key, createdAtMs: entry.createdAtMs, payload }); } return { version: SCAN_RESULT_CACHE_SCHEMA_VERSION, entries }; } catch { @@ -372,17 +322,6 @@ const readPersistedCache = (cacheFilePath: string): PersistedScanResultCache => } }; -const writePersistedCache = (cacheFilePath: string, cache: PersistedScanResultCache): void => { - try { - fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true }); - const tempPath = `${cacheFilePath}.${process.pid}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(cache)); - fs.renameSync(tempPath, cacheFilePath); - } catch { - return; - } -}; - /** * The global cache off-switch (`REACT_DOCTOR_NO_CACHE`), which disables every * cache subsystem: this whole-repo scan cache plus core's per-file lint, @@ -517,10 +456,15 @@ export const createScanResultCache = (projectDirectory: string): ScanResultCache for (const entry of persistedCache.entries) entries.set(entry.key, entry); const persist = (): void => { - const prunedEntries = [...entries.values()] + const mergedEntries = new Map(); + for (const entry of readPersistedCache(cacheFilePath).entries) { + mergedEntries.set(entry.key, entry); + } + for (const entry of entries.values()) mergedEntries.set(entry.key, entry); + const prunedEntries = [...mergedEntries.values()] .sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs) .slice(0, SCAN_RESULT_CACHE_MAX_ENTRY_COUNT); - writePersistedCache(cacheFilePath, { + atomicWriteJson(cacheFilePath, { version: SCAN_RESULT_CACHE_SCHEMA_VERSION, entries: prunedEntries, }); @@ -549,18 +493,3 @@ export const createScanResultCache = (projectDirectory: string): ScanResultCache }, }; }; - -export const shouldStoreScanPayload = (payload: CachedScanPayload): boolean => - !payload.didLintFail && - !payload.didDeadCodeFail && - payload.lintPartialFailures.length === 0 && - // A supply-chain overlap timeout means the cached diagnostics are missing - // their supply-chain findings; don't persist a degraded result — re-attempt - // the check on the next run instead. This also keeps the timeout kill metric - // clean: a stored payload therefore always carries - // `supplyChainOverlapTimedOut: false`, so a cache hit never replays a stale - // `true`. - !payload.supplyChainOverlapTimedOut && - // Same reasoning for a failed (fail-open) security scan: its diagnostics - // are missing the whole pass, so the result must not be replayed. - payload.securityScanFailed !== true; diff --git a/packages/react-doctor/src/inspect-runtime.ts b/packages/react-doctor/src/inspect-runtime.ts new file mode 100644 index 000000000..cc19ea105 --- /dev/null +++ b/packages/react-doctor/src/inspect-runtime.ts @@ -0,0 +1,9 @@ +import type { WorkerSlots } from "@react-doctor/core"; +import type { ScanResultCacheInvocationState } from "./cli/utils/scan-result-cache.js"; + +export interface OxlintInvocationRuntime { + readonly concurrency: number; + readonly spawnSlots: WorkerSlots; + readonly abortSignal: AbortSignal; + readonly scanResultCacheInvocationState: ScanResultCacheInvocationState; +} diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 491c422d9..5848705aa 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -1,31 +1,20 @@ -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 ChangedFileLineRanges, - computeDiagnosticDelta, createOxlintSpawnSlots, - DEFAULT_SHOW_WARNINGS, type Diagnostic, - filterPathsOutsideDirectories, - filterSourceFiles, highlighter, type InspectResult, OXLINT_NODE_REQUIREMENT, OxlintConcurrency, - PerFileLintCacheEnabled, - type Progress, - type ProjectInfo, type ReactDoctorConfig, resolveScanTarget, resolveScanConcurrency, restoreLegacyThrow, runInspect as runInspectEffect, - SidecarLintCacheEnabled, - type WorkerSlots, yieldToEventLoop, } from "@react-doctor/core"; import { activeScanAbortRegistry } from "./cli/utils/active-scan-abort-registry.js"; @@ -37,24 +26,19 @@ 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 { 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 { isCiOrCodingAgentEnvironment } from "./cli/utils/is-ci-environment.js"; -import { isNonInteractiveEnvironment } from "./cli/utils/is-non-interactive-environment.js"; -import { finalizeInspectResult } from "./cli/utils/finalize-inspect-result.js"; import { resolveOxlintNode } from "./cli/utils/resolve-oxlint-node.js"; -import { resolveCliCategories } from "./cli/utils/resolve-cli-categories.js"; +import { resolveInspectOptions } from "./cli/utils/resolve-inspect-options.js"; +import { buildRunEventConfig, renderAndRecordScan } from "./cli/utils/render-and-record-scan.js"; +import { + countIncompleteLintFiles, + runBaselineComparison, +} from "./cli/utils/run-baseline-comparison.js"; import { getRunId } from "./cli/utils/run-id.js"; import { buildScanResultCacheKey, @@ -62,15 +46,11 @@ import { createScanResultCache, shouldStoreScanPayload, type CachedScanPayload, - type ScanResultCacheInvocationState, } from "./cli/utils/scan-result-cache.js"; import { isSpinnerSilent, setSpinnerSilent } from "./cli/utils/spinner.js"; import { VERSION } from "./cli/utils/version.js"; -import type { - InspectUiLayers, - ReactDoctorInspectOptions, - ResolvedInspectOptions, -} from "./inspect-options.js"; +import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "./inspect-options.js"; +import type { OxlintInvocationRuntime } from "./inspect-runtime.js"; export type { InspectUiLayers, @@ -80,13 +60,6 @@ export type { const silentConsole = makeNoopConsole(); -interface OxlintInvocationRuntime { - readonly concurrency: number; - readonly spawnSlots: WorkerSlots; - readonly abortSignal: AbortSignal; - readonly scanResultCacheInvocationState: ScanResultCacheInvocationState; -} - // 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 @@ -111,110 +84,6 @@ const buildChangedLineMatcher = ( }; }; -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, - excludedProjectDirectories: inputOptions.excludedProjectDirectories ?? [], - retainExcludedProjectDeadCodeDiagnostics: - inputOptions.retainExcludedProjectDeadCodeDiagnostics ?? false, - precomputedSourceFileCount: inputOptions.precomputedSourceFileCount, - }; -}; - -// 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). -// 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` run carries no ranges, so it reads as `files`; degraded -// baseline runs keep `changed` and rely on the baseline-degraded fields. -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, - }; -}; - const inspectWithOxlintRuntime = async ( directory: string, inputOptions: ReactDoctorInspectOptions, @@ -268,7 +137,7 @@ const inspectWithOxlintRuntime = async ( configSourceDirectory = scanTarget.configSourceDirectory; } - const options = mergeInspectOptions(inputOptions, userConfig); + const options = resolveInspectOptions(inputOptions, userConfig); // HACK: spinner.ts still has module-level silent state for imperative CLI // helpers. Concurrent batch members never touch the shared flag — overlapping @@ -300,7 +169,7 @@ const inspectWithOxlintRuntime = async ( // here, so it's omitted rather than asserted as a benign default. // Rethrow so error handling is unchanged. recordRunEvent(rootSentrySpan, { - ...buildRunEventConfig(options, userConfig, userConfig !== null), + ...buildRunEventConfig(options, userConfig), mode: options.includePaths.length > 0 ? "diff" : "full", error, }); @@ -354,198 +223,6 @@ export const inspect = async ( ): Promise => createInvocationInspect(inputOptions.concurrency)(directory, inputOptions); -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; - oxlintRuntime: OxlintInvocationRuntime; -} - -/** - * 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)); - const baselineIncludePaths = filterPathsOutsideDirectories({ - rootDirectory: params.directory, - relativePaths: params.options.includePaths, - excludedDirectories: params.options.excludedProjectDirectories, - }); - const baselineBaseFiles = params.baseFiles - ? filterPathsOutsideDirectories({ - rootDirectory: params.directory, - relativePaths: params.baseFiles, - excludedDirectories: params.options.excludedProjectDirectories, - }) - : undefined; - const baselineHeadFiles = params.headFiles - ? filterPathsOutsideDirectories({ - rootDirectory: params.directory, - relativePaths: params.headFiles, - excludedDirectories: params.options.excludedProjectDirectories, - }) - : undefined; - // 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: baselineIncludePaths, - baseFiles: baselineBaseFiles, - headFiles: baselineHeadFiles, - 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 baselineIncludePaths) { - 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.oxlintRuntime.concurrency, - oxlintSpawnSlots: params.oxlintRuntime.spawnSlots, - }); - 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, - signal: params.oxlintRuntime.abortSignal, - }, - {}, - ); - 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), - ), - ), - { signal: params.oxlintRuntime.abortSignal }, - ); - // 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(); - } -}; - const runInspectWithRuntime = async ( directory: string, options: ResolvedInspectOptions, @@ -592,8 +269,6 @@ const runInspectWithRuntime = async ( const result = await renderAndRecordScan({ payload: cachedPayload, options, - userConfig, - hasCustomConfig: userConfig !== null, startTime, rootSentrySpan, scanMode: cachedPayload.baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", @@ -821,150 +496,20 @@ const runInspectWithRuntime = async ( const result = await renderAndRecordScan({ 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, - lintSidecarTotalFileCount: output.lintSidecarTotalFileCount, - deadCodeCacheHit: output.deadCodeCacheHit, - deadCodeSummaryCacheHits: output.deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses: output.deadCodeSummaryCacheMisses, - }); - return result; -}; - -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 renderAndRecordScan = async (input: RenderAndRecordScanInput): Promise => { - const 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, - securityScanFailureReason: input.payload.securityScanFailureReason ?? null, - 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 finalizeEffect = finalizeInspectResult(finalizeInput); - const result = await Effect.runPromise( - input.options.silent - ? finalizeEffect.pipe(Effect.provideService(Console.Console, silentConsole)) - : finalizeEffect, - ); - // 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, + cacheStats: { + lintCacheHitFileCount: output.lintCacheHitFileCount, + lintCacheTotalFileCount: output.lintCacheTotalFileCount, + lintSidecarReplayedFileCount: output.lintSidecarReplayedFileCount, + lintSidecarTotalFileCount: output.lintSidecarTotalFileCount, + deadCodeCacheHit: output.deadCodeCacheHit, + deadCodeSummaryCacheHits: output.deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses: output.deadCodeSummaryCacheMisses, + }, }); return result; }; diff --git a/packages/react-doctor/tests/helpers/render-in-terminal.ts b/packages/react-doctor/tests/helpers/render-in-terminal.ts deleted file mode 100644 index dd720f9f8..000000000 --- a/packages/react-doctor/tests/helpers/render-in-terminal.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Terminal } from "@xterm/headless"; - -export interface TerminalRenderOptions { - readonly cols: number; - readonly rows?: number; -} - -export interface TerminalRenderResult { - /** Every non-empty visible row, exactly as the emulator laid it out. */ - readonly rows: string[]; - /** Logical lines with soft-wrapped continuation rows stitched back together. */ - readonly logicalLines: string[]; - /** Rows that exist only because a logical line was too wide and wrapped. */ - readonly wrappedRowCount: number; - /** True when any logical line exceeded the column width and wrapped. */ - readonly overflowed: boolean; - /** The full visible buffer joined with newlines (trailing blanks trimmed). */ - readonly text: string; -} - -const DEFAULT_ROWS = 200; -const SCROLLBACK_LINES = 5000; - -/** - * Feeds a raw terminal byte stream (ANSI escapes, cursor moves, box-drawing, - * unicode, etc.) through a headless xterm emulator sized to `cols` × `rows` - * and reports how it actually renders. This is the source of truth for visual - * regressions — `string.length` can't see ANSI codes or double-width glyphs, - * but the emulator lays the grid out exactly like a real terminal would. - */ -export const renderInTerminal = ( - data: string, - options: TerminalRenderOptions, -): Promise => { - const terminal = new Terminal({ - cols: options.cols, - rows: options.rows ?? DEFAULT_ROWS, - scrollback: SCROLLBACK_LINES, - allowProposedApi: true, - }); - - return new Promise((resolve) => { - terminal.write(data, () => { - const buffer = terminal.buffer.active; - const rows: string[] = []; - const logicalLines: string[] = []; - let wrappedRowCount = 0; - - for (let lineIndex = 0; lineIndex < buffer.length; lineIndex += 1) { - const bufferLine = buffer.getLine(lineIndex); - if (!bufferLine) continue; - const rowText = bufferLine.translateToString(true); - rows.push(rowText); - - if (bufferLine.isWrapped) { - wrappedRowCount += 1; - logicalLines[logicalLines.length - 1] += rowText; - } else { - logicalLines.push(rowText); - } - } - - while (rows.length > 0 && rows[rows.length - 1] === "") rows.pop(); - while (logicalLines.length > 0 && logicalLines[logicalLines.length - 1] === "") { - logicalLines.pop(); - } - - terminal.dispose(); - resolve({ - rows, - logicalLines, - wrappedRowCount, - overflowed: wrappedRowCount > 0, - text: rows.join("\n"), - }); - }); - }); -}; diff --git a/packages/react-doctor/tests/open-workflow-pull-request.test.ts b/packages/react-doctor/tests/open-workflow-pull-request.test.ts index 8f3bd1a7d..7aad2e743 100644 --- a/packages/react-doctor/tests/open-workflow-pull-request.test.ts +++ b/packages/react-doctor/tests/open-workflow-pull-request.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { openWorkflowPullRequest } from "../src/cli/utils/open-workflow-pull-request.js"; +import { + openWorkflowPullRequest, + stageWorkflowFile, +} from "../src/cli/utils/open-workflow-pull-request.js"; import type { CommandRunner, RunCommandResult } from "../src/cli/utils/run-command.js"; const succeed = (stdout = ""): RunCommandResult => ({ success: true, stdout, stderr: "" }); @@ -207,4 +210,27 @@ describe("openWorkflowPullRequest", () => { expect(result).toEqual({ status: "not-attempted", reason: "gh-not-installed" }); expect(invocations).not.toContain(GH_PR_LIST); }); + + it("rejects a workflow path outside the detected repository", async () => { + const { run, invocations } = recordingRunner(cleanRepoResponses()); + const result = await openWorkflowPullRequest({ + workflowPath: "/outside/react-doctor.yml", + baseBranch: "main", + run, + checkCommandAvailable: () => true, + }); + + expect(result).toEqual({ + status: "not-attempted", + reason: "workflow-outside-repository", + }); + expect(invocations).toEqual([TOPLEVEL]); + }); + + it("does not stage a workflow path outside the detected repository", async () => { + const { run, invocations } = recordingRunner(cleanRepoResponses()); + + expect(await stageWorkflowFile({ workflowPath: "/outside/react-doctor.yml", run })).toBe(false); + expect(invocations).toEqual([TOPLEVEL]); + }); }); diff --git a/packages/react-doctor/tests/scan-result-cache.test.ts b/packages/react-doctor/tests/scan-result-cache.test.ts index 2043f4bfe..6467654b7 100644 --- a/packages/react-doctor/tests/scan-result-cache.test.ts +++ b/packages/react-doctor/tests/scan-result-cache.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; 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 { clearConfigCache, resolveReactDoctorCacheDir, type Diagnostic } from "@react-doctor/core"; import { inspect, type ResolvedInspectOptions } from "../src/inspect.js"; import { buildScanResultCacheKey, @@ -13,8 +13,10 @@ import { type CachedScanPayload, } from "../src/cli/utils/scan-result-cache.js"; import { + SCAN_RESULT_CACHE_FILENAME, SCAN_RESULT_CACHE_MAX_DIRTY_STATUS_ENTRY_COUNT, SCAN_RESULT_CACHE_MAX_HASHED_FILE_SIZE_BYTES, + SCAN_RESULT_CACHE_SCHEMA_VERSION, } from "../src/cli/utils/constants.js"; import { runGit } from "../src/cli/utils/git-hook-shared.js"; import { VERSION } from "../src/cli/utils/version.js"; @@ -180,6 +182,43 @@ describe("scan result cache", () => { expect(cache.lookup(key)?.diagnostics).toEqual([diagnostic(projectDirectory)]); }); + it("merges entries stored by independently loaded cache instances", () => { + const projectDirectory = setupReactProject(tempDirectory, "merged-writers", { + files: { "src/App.tsx": "export const App = () =>
;\n" }, + }); + initGitRepo(projectDirectory, { commit: true }); + const firstCache = createScanResultCache(projectDirectory); + const secondCache = createScanResultCache(projectDirectory); + + firstCache.store("first", basePayload(projectDirectory)); + secondCache.store("second", basePayload(projectDirectory)); + + const reloadedCache = createScanResultCache(projectDirectory); + expect(reloadedCache.lookup("first")).not.toBeNull(); + expect(reloadedCache.lookup("second")).not.toBeNull(); + }); + + it("drops malformed persisted payloads instead of throwing during lookup", () => { + const projectDirectory = setupReactProject(tempDirectory, "malformed-payload", { + files: { "src/App.tsx": "export const App = () =>
;\n" }, + }); + initGitRepo(projectDirectory, { commit: true }); + const cacheFilePath = path.join( + resolveReactDoctorCacheDir(projectDirectory), + SCAN_RESULT_CACHE_FILENAME, + ); + fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true }); + fs.writeFileSync( + cacheFilePath, + JSON.stringify({ + version: SCAN_RESULT_CACHE_SCHEMA_VERSION, + entries: [{ key: "malformed", createdAtMs: Date.now(), payload: { diagnostics: [] } }], + }), + ); + + expect(createScanResultCache(projectDirectory).lookup("malformed")).toBeNull(); + }); + it("honors REACT_DOCTOR_CACHE_DIR so the action-persisted dir carries the scan cache", () => { const projectDirectory = setupReactProject(tempDirectory, "cache-dir-override", { files: { "src/App.tsx": "export const App = () =>
;\n" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a5f8e459..707eecffd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,9 +340,6 @@ importers: '@types/react': specifier: ^19.2.14 version: 19.2.14 - '@xterm/headless': - specifier: ^6.0.0 - version: 6.0.0 commander: specifier: ^14.0.3 version: 14.0.3 @@ -2715,9 +2712,6 @@ packages: cpu: [x64] os: [win32] - '@xterm/headless@6.0.0': - resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -6575,8 +6569,6 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.20': optional: true - '@xterm/headless@6.0.0': {} - acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 From e20ce0ab748ef2829f73936ee214e18eae690b84 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 08:42:51 +0000 Subject: [PATCH 03/17] chore: add refactor changeset --- .changeset/tall-adults-refuse.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tall-adults-refuse.md diff --git a/.changeset/tall-adults-refuse.md b/.changeset/tall-adults-refuse.md new file mode 100644 index 000000000..c35dddcf2 --- /dev/null +++ b/.changeset/tall-adults-refuse.md @@ -0,0 +1,6 @@ +--- +"react-doctor": patch +"deslop-js": patch +--- + +Harden scan orchestration and cache persistence by validating stored payloads, preserving independently written entries, and keeping workflow paths inside the repository. From 8e0dfb8fea2de8560de009c0e4f33604504beb3b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 09:15:15 +0000 Subject: [PATCH 04/17] refactor: consolidate scan and graph internals --- .changeset/tall-adults-refuse.md | 3 +- packages/core/src/index.ts | 1 + packages/deslop-js/src/constants.ts | 4 - packages/deslop-js/src/report/cycles.ts | 106 +---------- .../deslop-js/src/report/re-export-cycles.ts | 74 +------- .../find-strongly-connected-components.ts | 80 +++++++++ .../src/plugin/constants/react.ts | 22 --- .../bundle-size/no-dynamic-import-path.ts | 4 - .../has-visible-tailwind-fill-or-edge.ts | 5 - .../no-mutating-reducer-state.ts | 10 +- .../utils/effect/constants.ts | 1 - .../state-and-effects/utils/effect/react.ts | 3 - .../utils/is-controlled-prop-mirror.ts | 62 ------- .../utils/resolve-tanstack-query-hook-name.ts | 5 - .../utils/find-exported-function-body.ts | 29 --- .../plugin/utils/reads-post-mount-value.ts | 15 -- .../react-doctor/src/cli/commands/inspect.ts | 83 +++------ .../src/cli/utils/build-project-scan-plan.ts | 76 ++++++++ .../filter-diagnostics-by-changed-lines.ts | 29 +++ .../cli/utils/open-workflow-pull-request.ts | 41 ++++- .../cli/utils/scan-result-cache-lifecycle.ts | 100 +++++++++++ packages/react-doctor/src/inspect.ts | 100 ++--------- .../tests/build-project-scan-plan.test.ts | 165 ++++++++++++++++++ ...ilter-diagnostics-by-changed-lines.test.ts | 107 ++++++++++++ .../tests/open-workflow-pull-request.test.ts | 39 +++++ 25 files changed, 680 insertions(+), 484 deletions(-) create mode 100644 packages/deslop-js/src/utils/find-strongly-connected-components.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/scan-result-cache-lifecycle.ts create mode 100644 packages/react-doctor/tests/build-project-scan-plan.test.ts create mode 100644 packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts diff --git a/.changeset/tall-adults-refuse.md b/.changeset/tall-adults-refuse.md index c35dddcf2..e648a031c 100644 --- a/.changeset/tall-adults-refuse.md +++ b/.changeset/tall-adults-refuse.md @@ -1,6 +1,7 @@ --- "react-doctor": patch "deslop-js": patch +"oxlint-plugin-react-doctor": patch --- -Harden scan orchestration and cache persistence by validating stored payloads, preserving independently written entries, and keeping workflow paths inside the repository. +Harden scan orchestration and cache persistence, share cycle analysis, keep workflow paths inside the repository, and remove unused rule internals. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e62be578a..25aa00749 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -119,6 +119,7 @@ export * from "./utils/resolve-react-doctor-cache-dir.js"; export * from "./utils/resolve-scan-concurrency.js"; export * from "./utils/scrub-sensitive-paths.js"; export * from "./utils/sort-diagnostics-stable.js"; +export * from "./utils/to-canonical-path.js"; export * from "./utils/to-relative-path.js"; export * from "./utils/warn-config-issue.js"; export * from "./utils/yield-to-event-loop.js"; diff --git a/packages/deslop-js/src/constants.ts b/packages/deslop-js/src/constants.ts index a44f8834b..527d00f62 100644 --- a/packages/deslop-js/src/constants.ts +++ b/packages/deslop-js/src/constants.ts @@ -375,12 +375,8 @@ export const INLINE_TYPE_PREVIEW_KEYS = 4; export const SIMPLIFIABLE_EXPRESSION_MEMBER_ACCESS_DEPTH = 6; -export const ANALYSIS_ERROR_PRINT_LIMIT = 20; - export const DUPLICATE_INLINE_TYPE_HIGH_MEMBER_COUNT = 5; -export const SEMANTIC_PROGRAM_BUDGET_MS = 30_000; - export const SEMANTIC_TRACE_MAX_ENTRIES = 5; export const DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS = 50; diff --git a/packages/deslop-js/src/report/cycles.ts b/packages/deslop-js/src/report/cycles.ts index a725cb9ed..c70e9db10 100644 --- a/packages/deslop-js/src/report/cycles.ts +++ b/packages/deslop-js/src/report/cycles.ts @@ -4,21 +4,7 @@ import { MAX_TOTAL_CYCLES, MAX_SCC_SIZE_FOR_ENUMERATION, } from "../constants.js"; - -const UNDEFINED_INDEX = -1; - -interface TarjanState { - indexCounter: number; - indices: number[]; - lowlinks: number[]; - onStack: boolean[]; - stack: number[]; -} - -interface DfsFrame { - node: number; - successorPosition: number; -} +import { findStronglyConnectedComponents } from "../utils/find-strongly-connected-components.js"; // A value-form import (`import { Props } from "./barrel"`) whose every // symbol resolves to a type-only export (interface / type alias) in the @@ -100,92 +86,6 @@ const cycleHasModuleInitAccess = (cycle: number[], initAccessEdges: Set) return false; }; -const findStronglyConnectedComponents = (adjacencyList: number[][]): number[][] => { - const nodeCount = adjacencyList.length; - if (nodeCount === 0) { - return []; - } - - const state: TarjanState = { - indexCounter: 0, - indices: Array(nodeCount).fill(UNDEFINED_INDEX), - lowlinks: Array(nodeCount).fill(0), - onStack: Array(nodeCount).fill(false), - stack: [], - }; - - const components: number[][] = []; - const dfsStack: DfsFrame[] = []; - - for (let startNode = 0; startNode < nodeCount; startNode++) { - if (state.indices[startNode] !== UNDEFINED_INDEX) { - continue; - } - - state.indices[startNode] = state.indexCounter; - state.lowlinks[startNode] = state.indexCounter; - state.indexCounter++; - state.onStack[startNode] = true; - state.stack.push(startNode); - - dfsStack.push({ node: startNode, successorPosition: 0 }); - - while (dfsStack.length > 0) { - const frame = dfsStack[dfsStack.length - 1]; - const successors = adjacencyList[frame.node]; - - if (frame.successorPosition < successors.length) { - const successor = successors[frame.successorPosition]; - frame.successorPosition++; - - if (state.indices[successor] === UNDEFINED_INDEX) { - state.indices[successor] = state.indexCounter; - state.lowlinks[successor] = state.indexCounter; - state.indexCounter++; - state.onStack[successor] = true; - state.stack.push(successor); - - dfsStack.push({ node: successor, successorPosition: 0 }); - } else if (state.onStack[successor]) { - state.lowlinks[frame.node] = Math.min( - state.lowlinks[frame.node], - state.indices[successor], - ); - } - } else { - const currentNode = frame.node; - const currentLowlink = state.lowlinks[currentNode]; - const currentIndex = state.indices[currentNode]; - dfsStack.pop(); - - if (dfsStack.length > 0) { - const parentFrame = dfsStack[dfsStack.length - 1]; - state.lowlinks[parentFrame.node] = Math.min( - state.lowlinks[parentFrame.node], - currentLowlink, - ); - } - - if (currentLowlink === currentIndex) { - const component: number[] = []; - let poppedNode: number; - do { - poppedNode = state.stack.pop()!; - state.onStack[poppedNode] = false; - component.push(poppedNode); - } while (poppedNode !== currentNode); - - if (component.length >= 2) { - components.push(component); - } - } - } - } - } - - return components; -}; - const canonicalizeCycle = (cycle: number[], graph: DependencyGraph): number[] => { if (cycle.length === 0) { return []; @@ -270,7 +170,9 @@ const enumerateElementaryCycles = ( export const detectCycles = (graph: DependencyGraph): CircularDependency[] => { const adjacencyList = buildAdjacencyList(graph); const initAccessEdges = buildModuleInitAccessEdgeSet(graph); - const components = findStronglyConnectedComponents(adjacencyList); + const components = findStronglyConnectedComponents(adjacencyList).filter( + (component) => component.length >= 2, + ); const allCycles: number[][] = []; const seenKeys = new Set(); diff --git a/packages/deslop-js/src/report/re-export-cycles.ts b/packages/deslop-js/src/report/re-export-cycles.ts index 879b8a001..6eb5bf0e9 100644 --- a/packages/deslop-js/src/report/re-export-cycles.ts +++ b/packages/deslop-js/src/report/re-export-cycles.ts @@ -1,4 +1,5 @@ import type { DependencyGraph, ReExportCycle } from "../types.js"; +import { findStronglyConnectedComponents } from "../utils/find-strongly-connected-components.js"; /** * Reports cycles in the subgraph of `isReExportEdge` edges only. These are @@ -22,7 +23,7 @@ export const detectReExportCycles = (graph: DependencyGraph): ReExportCycle[] => adjacency[edge.source].push(edge.target); } - const sccComponents = computeStronglyConnectedComponents(adjacency); + const sccComponents = findStronglyConnectedComponents(adjacency); const findings: ReExportCycle[] = []; for (const component of sccComponents) { @@ -56,74 +57,3 @@ export const detectReExportCycles = (graph: DependencyGraph): ReExportCycle[] => ); return findings; }; - -/** - * Iterative Tarjan's SCC. Singleton components are returned too so the - * caller can distinguish a real self-loop from a node with no edges. - */ -const computeStronglyConnectedComponents = (adjacency: number[][]): number[][] => { - const nodeCount = adjacency.length; - if (nodeCount === 0) return []; - - const indices: number[] = new Array(nodeCount).fill(-1); - const lowLinks: number[] = new Array(nodeCount).fill(0); - const onStack: boolean[] = new Array(nodeCount).fill(false); - const tarjanStack: number[] = []; - const components: number[][] = []; - let nextIndex = 0; - - for (let startNode = 0; startNode < nodeCount; startNode++) { - if (indices[startNode] !== -1) continue; - - const dfsStack: { node: number; successorPosition: number }[] = [ - { node: startNode, successorPosition: 0 }, - ]; - indices[startNode] = nextIndex; - lowLinks[startNode] = nextIndex; - nextIndex++; - onStack[startNode] = true; - tarjanStack.push(startNode); - - while (dfsStack.length > 0) { - const frame = dfsStack[dfsStack.length - 1]; - const successors = adjacency[frame.node]; - - if (frame.successorPosition < successors.length) { - const successorNode = successors[frame.successorPosition]; - frame.successorPosition++; - if (indices[successorNode] === -1) { - indices[successorNode] = nextIndex; - lowLinks[successorNode] = nextIndex; - nextIndex++; - onStack[successorNode] = true; - tarjanStack.push(successorNode); - dfsStack.push({ node: successorNode, successorPosition: 0 }); - } else if (onStack[successorNode]) { - if (indices[successorNode] < lowLinks[frame.node]) { - lowLinks[frame.node] = indices[successorNode]; - } - } - } else { - if (lowLinks[frame.node] === indices[frame.node]) { - const component: number[] = []; - let popped: number; - do { - popped = tarjanStack.pop()!; - onStack[popped] = false; - component.push(popped); - } while (popped !== frame.node); - components.push(component); - } - dfsStack.pop(); - if (dfsStack.length > 0) { - const parent = dfsStack[dfsStack.length - 1]; - if (lowLinks[frame.node] < lowLinks[parent.node]) { - lowLinks[parent.node] = lowLinks[frame.node]; - } - } - } - } - } - - return components; -}; diff --git a/packages/deslop-js/src/utils/find-strongly-connected-components.ts b/packages/deslop-js/src/utils/find-strongly-connected-components.ts new file mode 100644 index 000000000..304ee4bbf --- /dev/null +++ b/packages/deslop-js/src/utils/find-strongly-connected-components.ts @@ -0,0 +1,80 @@ +interface StronglyConnectedComponentFrame { + nodeIndex: number; + successorIndex: number; +} + +export const findStronglyConnectedComponents = ( + adjacencyList: ReadonlyArray>, +): number[][] => { + const nodeIndices: Array = new Array(adjacencyList.length); + const lowLinks: number[] = new Array(adjacencyList.length).fill(0); + const nodesOnStack: boolean[] = new Array(adjacencyList.length).fill(false); + const componentStack: number[] = []; + const components: number[][] = []; + let nextNodeIndex = 0; + + for (let startNodeIndex = 0; startNodeIndex < adjacencyList.length; startNodeIndex++) { + if (nodeIndices[startNodeIndex] !== undefined) continue; + + nodeIndices[startNodeIndex] = nextNodeIndex; + lowLinks[startNodeIndex] = nextNodeIndex; + nextNodeIndex++; + nodesOnStack[startNodeIndex] = true; + componentStack.push(startNodeIndex); + + const traversalStack: StronglyConnectedComponentFrame[] = [ + { nodeIndex: startNodeIndex, successorIndex: 0 }, + ]; + + while (traversalStack.length > 0) { + const frame = traversalStack[traversalStack.length - 1]; + const successors = adjacencyList[frame.nodeIndex]; + + if (frame.successorIndex < successors.length) { + const successorNodeIndex = successors[frame.successorIndex]; + frame.successorIndex++; + const successorTraversalIndex = nodeIndices[successorNodeIndex]; + + if (successorTraversalIndex === undefined) { + nodeIndices[successorNodeIndex] = nextNodeIndex; + lowLinks[successorNodeIndex] = nextNodeIndex; + nextNodeIndex++; + nodesOnStack[successorNodeIndex] = true; + componentStack.push(successorNodeIndex); + traversalStack.push({ nodeIndex: successorNodeIndex, successorIndex: 0 }); + } else if (nodesOnStack[successorNodeIndex]) { + lowLinks[frame.nodeIndex] = Math.min(lowLinks[frame.nodeIndex], successorTraversalIndex); + } + continue; + } + + const currentNodeIndex = frame.nodeIndex; + const currentTraversalIndex = nodeIndices[currentNodeIndex]; + traversalStack.pop(); + + if (traversalStack.length > 0) { + const parentFrame = traversalStack[traversalStack.length - 1]; + lowLinks[parentFrame.nodeIndex] = Math.min( + lowLinks[parentFrame.nodeIndex], + lowLinks[currentNodeIndex], + ); + } + + if (currentTraversalIndex !== lowLinks[currentNodeIndex]) continue; + + const component: number[] = []; + let componentNodeIndex: number | undefined; + do { + componentNodeIndex = componentStack.pop(); + if (componentNodeIndex === undefined) { + throw new Error("Strongly connected component stack was unexpectedly empty."); + } + nodesOnStack[componentNodeIndex] = false; + component.push(componentNodeIndex); + } while (componentNodeIndex !== currentNodeIndex); + components.push(component); + } + } + + return components; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts index fde4cab92..9a448f24e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -65,28 +65,6 @@ export const TRIVIAL_INITIALIZER_NAMES = new Set([ "parseFloat", ]); -// Used by `noDerivedStateEffect` to decide whether a derived-state -// expression is "expensive enough" to recommend `useMemo` over plain -// inline computation. Coercion / parsing / boundary helpers are cheap -// and should still get the "compute during render" message. -// MemberExpression callees (e.g. `Math.floor`, `Date.now`) are -// recognized via BUILTIN_GLOBAL_NAMESPACE_NAMES (the chain root), not -// here — putting "Math" or "Date" in this set wouldn't match because -// the expensive-derivation walker reads the *property* name. -export const TRIVIAL_DERIVATION_CALLEE_NAMES = new Set([ - "Boolean", - "String", - "Number", - "Array", - "Object", - "parseInt", - "parseFloat", - "isNaN", - "isFinite", - "BigInt", - "Symbol", -]); - export const SETTER_PATTERN = /^set[A-Z]/; export const RENDER_FUNCTION_PATTERN = /^render[A-Z]/; export const UPPERCASE_PATTERN = /^[A-Z]/; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-dynamic-import-path.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-dynamic-import-path.ts index 63a431129..11fd3f548 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-dynamic-import-path.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/bundle-size/no-dynamic-import-path.ts @@ -89,10 +89,6 @@ const hasBundlerIgnoreAnnotation = (node: EsTreeNode, filename: string | undefin return BUNDLER_IGNORE_ANNOTATION_PATTERN.test(fileText.slice(range[0], range[1])); }; -export const clearBundlerIgnoreAnnotationCache = (): void => { - annotatedFileTextCache.clear(); -}; - const isUrlCreateObjectUrlCall = (expression: EsTreeNode): boolean => isNodeOfType(expression, "CallExpression") && isNodeOfType(expression.callee, "MemberExpression") && diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/has-visible-tailwind-fill-or-edge.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/has-visible-tailwind-fill-or-edge.ts index 9de5195ca..be8dcf543 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/has-visible-tailwind-fill-or-edge.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/has-visible-tailwind-fill-or-edge.ts @@ -263,11 +263,6 @@ export const hasVisibleTailwindFillOrEdge = (tokens: string[]): boolean => hasVisibleTailwindRing(tokens) || hasVisibleTailwindBackground(tokens); -export const hasVisibleTailwindClosedSurface = (tokens: string[]): boolean => - hasVisibleTailwindClosedBorder(tokens) || - hasVisibleTailwindRing(tokens) || - hasVisibleTailwindBackground(tokens); - export const hasVisibleTailwindBoundary = (tokens: string[]): boolean => hasVisibleTailwindBorder(tokens) || hasVisibleTailwindRing(tokens) || diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.ts index fe9484b58..b0236f3c9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.ts @@ -43,14 +43,8 @@ const SAME_REFERENCE_ARRAY_RETURN_METHODS = new Set(["copyWithin", "fill", "reve // same-reference return such as `return state`, `return alias`, // `return state.sort(...)`, or `return Object.assign(state, patch)`. // -// Cross-file resolution: when the reducer is imported from a -// sibling file, the rule resolves the import via -// `resolveRelativeImportPath` (which handles `.ts` / `.tsx` / -// extension probing / package `exports` maps), then follows barrel -// re-exports via `resolveBarrelExportFilePath`. Imported reducer -// bodies are parsed with the cached `parseSourceFile` and the -// exported function is located by `findExportedFunctionBody`. The -// same path analysis then runs on the resolved function. +// Cross-file resolution is delegated to `resolveReducerFunction`; the same +// path analysis then runs on the resolved function. // // Out of scope for cross-file: // - Non-relative imports (`from "@/store/reducer"`) until TS path diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/constants.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/constants.ts index fdc590dc7..c6f9276d4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/constants.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/constants.ts @@ -1,3 +1,2 @@ export const FIRST_ARGUMENT_INDEX = 0; -export const MAX_EXPRESSION_SNIPPET_ITEMS_COUNT = 3; export const SECOND_ARGUMENT_INDEX = 1; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts index 92878f1be..09905d39b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts @@ -507,9 +507,6 @@ export const isSyncStateSetterCall = ( isSynchronous(ref.identifier as unknown as EsTreeNode, effectFn) && !resolvesToAsyncFunction(ref); -export const isPropCall = (analysis: ProgramAnalysis, ref: Reference): boolean => - isEventualCallTo(analysis, ref, (innerRef) => isPropAlias(analysis, innerRef)); - const HANDLER_NAMED_METHOD_PATTERN = /^(on|handle)[A-Z]/; const SYNCHRONOUS_CALLBACK_ARGUMENT_INDEX_BY_METHOD: ReadonlyMap = new Map([ ["every", FIRST_ARGUMENT_INDEX], diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts index 8d1585fec..6c9eb2787 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts @@ -1,44 +1,11 @@ import { collectPatternNames } from "../../../utils/collect-pattern-names.js"; import type { EsTreeNode } from "../../../utils/es-tree-node.js"; -import { findEnclosingFunction } from "../../../utils/find-enclosing-function.js"; import { getJsxAttributeName } from "../../../utils/get-jsx-attribute-name.js"; import { isFunctionLike } from "../../../utils/is-function-like.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; import { walkAst } from "../../../utils/walk-ast.js"; import { isEventHandlerName } from "./event-handler-reference.js"; -// Memoized per component function node — the prop-name set is a pure -// function of the (immutable) subtree, and every isControlledPropMirror -// query for the same component recomputes it otherwise. -const componentPropNamesCache = new WeakMap>(); - -const collectComponentPropNames = (componentFunction: EsTreeNode): ReadonlySet => { - const cached = componentPropNamesCache.get(componentFunction); - if (cached) return cached; - const propNames = new Set(); - if (!isFunctionLike(componentFunction)) return propNames; - const propsObjectParamNames = new Set(); - for (const param of componentFunction.params ?? []) { - collectPatternNames(param, propNames); - if (isNodeOfType(param, "Identifier")) propsObjectParamNames.add(param.name); - } - const componentBody: EsTreeNode | null | undefined = componentFunction.body; - if (!componentBody) return propNames; - walkAst(componentBody, (child: EsTreeNode): boolean | void => { - if (child !== componentBody && isFunctionLike(child)) return false; - if ( - isNodeOfType(child, "VariableDeclarator") && - isNodeOfType(child.id, "ObjectPattern") && - isNodeOfType(child.init, "Identifier") && - propsObjectParamNames.has(child.init.name) - ) { - collectPatternNames(child.id, propNames); - } - }); - componentPropNamesCache.set(componentFunction, propNames); - return propNames; -}; - // Own-scope bound names (params + non-nested declarators) per function node, // memoized so the repeated "does this nested function declare X" checks are // a Set lookup instead of a fresh subtree walk each time. @@ -120,32 +87,3 @@ export const isSetterWiredToJsxHandler = ( }); return isWired; }; - -// Controlled/uncontrolled value mirror: `useState(value)` + -// `useEffect(() => setDraft(value), [value])` where the SAME setter is wired -// into a JSX event-handler attribute — passed directly -// (`onChange={setDraft}`) or called from an inline attribute handler -// (`onChange={(e) => setDraft(e.target.value)}`). The state holds the user's -// live edits and merely re-syncs to the controlled prop, so it is NOT a -// value derivable while rendering — a `useMemo` would erase the user's -// input. A setter that only reaches JSX through a body-defined handler -// (`onChange={onChangeHandler}`) does NOT count: that indirection is the -// mirror shape the derived-state rules must keep detecting. The mirrored -// argument must be a bare prop identifier; body destructures -// (`const { value: color } = props`) count as props. Callers verify the -// callee is a useState setter before calling this. -export const isControlledPropMirror = (effectNode: EsTreeNode, setterCall: EsTreeNode): boolean => { - if (!isNodeOfType(setterCall, "CallExpression")) return false; - if (!isNodeOfType(setterCall.callee, "Identifier")) return false; - const setterArguments = setterCall.arguments ?? []; - if (setterArguments.length !== 1) return false; - const mirroredArgument = setterArguments[0]; - if (!isNodeOfType(mirroredArgument, "Identifier")) return false; - - const componentFunction = findEnclosingFunction(effectNode); - if (!componentFunction) return false; - - if (!collectComponentPropNames(componentFunction).has(mirroredArgument.name)) return false; - - return isSetterWiredToJsxHandler(componentFunction, setterCall.callee.name); -}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/tanstack-query/utils/resolve-tanstack-query-hook-name.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/tanstack-query/utils/resolve-tanstack-query-hook-name.ts index 816b4cd1f..4755a290e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/tanstack-query/utils/resolve-tanstack-query-hook-name.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/tanstack-query/utils/resolve-tanstack-query-hook-name.ts @@ -73,11 +73,6 @@ const resolveTanstackHookNameFromInitializer = ( return resolveTanstackHookName(resolvedInitializer, scopes, hookNames); }; -export const resolveTanstackQueryHookName = ( - callExpression: EsTreeNodeOfType<"CallExpression">, - scopes: ScopeAnalysis, -): string | null => resolveTanstackHookName(callExpression, scopes, TANSTACK_QUERY_HOOKS); - export const resolveTanstackQueryHookNameFromInitializer = ( initializer: EsTreeNode, scopes: ScopeAnalysis, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts index 8b0f225de..c92e5dcfc 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts @@ -1,6 +1,4 @@ import type { EsTreeNode } from "./es-tree-node.js"; -import { findExportedValue } from "./find-exported-value.js"; -import { isFunctionLike } from "./is-function-like.js"; import { isNodeOfType } from "./is-node-of-type.js"; export interface ReExportTarget { @@ -8,33 +6,6 @@ export interface ReExportTarget { source: string; } -// Given a parsed Program AST and an exported name, returns the -// function/arrow node bound to that export, or null if the export -// doesn't resolve to a function in this file. Handles: -// -// export function reducer(state, action) {...} -// export const reducer = (state, action) => {...} -// export const reducer = function (state, action) {...} -// export default function reducer(state, action) {...} -// export default function (state, action) {...} (exportedName === "default") -// export default (state, action) => {...} (exportedName === "default") -// function reducer(state, action) {...}; export { reducer }; -// const reducer = (...) => {...}; export { reducer }; -// export { reducer as default }; (exportedName === "default") -// -// Re-exports (`export { reducer } from "./other"`, -// `export * from "./other"`) are NOT followed here — that's the -// barrel-following layer's job (see `resolve-barrel-export-file-path`). -// If a re-export is encountered the function returns null and the -// caller is expected to resolve the barrel separately. -export const findExportedFunctionBody = ( - programRoot: EsTreeNode, - exportedName: string, -): EsTreeNode | null => { - const exportedValue = findExportedValue(programRoot, exportedName); - return isFunctionLike(exportedValue) ? exportedValue : null; -}; - // Convenience: returns the source-side identifier name for an // import specifier. Handles both `import { foo } from "..."` and // `import { foo as localBar } from "..."` — returning "foo" in both diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts index 27cb39712..5edc0e66e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts @@ -178,21 +178,6 @@ export const isPostMountMemberRead = (node: EsTreeNode): boolean => { return isRefLikeReceiver(node.object as EsTreeNode); }; -// A member read that yields a live measurement VALUE. Layout members measure -// as plain property reads (`ref.current.scrollHeight`), but DOM query members -// are METHODS — they only measure when invoked (`window.matchMedia("...")`). -// A bare method reference (`!!window.matchMedia`) is render-time-knowable, so -// it does not justify deferring state init to a mount effect. -export const isMeasurementMemberRead = (node: EsTreeNode): boolean => { - if (!isPostMountMemberRead(node)) return false; - if (!isNodeOfType(node, "MemberExpression") || !isNodeOfType(node.property, "Identifier")) { - return false; - } - if (!DOM_QUERY_MEMBER_NAMES.has(node.property.name)) return true; - const parent = node.parent; - return Boolean(parent && isNodeOfType(parent, "CallExpression") && parent.callee === node); -}; - const isPropertyNamePosition = (identifier: EsTreeNode): boolean => { const parent = identifier.parent; if (!parent) return false; diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 57e4112bc..29562f97a 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -64,15 +64,12 @@ import { resolveMergeBaseRef } from "../utils/materialize-baseline-files.js"; import { resolveBlockingLevel } from "../utils/resolve-blocking-level.js"; import { resolveWorkspaceDeadCodeOwner } from "../utils/resolve-workspace-dead-code-owner.js"; import { retryMissingProjectScores } from "../utils/retry-missing-project-scores.js"; -import { - resolveProjectChangedLineRanges, - resolveProjectDiffIncludePaths, -} from "../utils/resolve-project-diff-include-paths.js"; +import { resolveProjectChangedLineRanges } from "../utils/resolve-project-diff-include-paths.js"; import { resolveProjectSourceFilePaths } from "../utils/resolve-project-source-file-paths.js"; import { resolveProjectScan, type ResolvedProjectScan } from "../utils/resolve-project-scan.js"; import { runExplain } from "../utils/run-explain.js"; import { runProjectScanBatch } from "../utils/run-project-scan-batch.js"; -import { projectManifestChanged } from "../utils/project-manifest-changed.js"; +import { buildProjectScanPlan } from "../utils/build-project-scan-plan.js"; import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; import { selectProjects } from "../utils/select-projects.js"; import { @@ -953,58 +950,20 @@ 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) { @@ -1015,7 +974,7 @@ export const inspectAction = async ( deadCode: workspaceDeadCodeOwner === null ? scanOptions.deadCode : ownsWorkspaceDeadCode, precomputedSourceFileCount: precomputedSourceFileCounts?.get(scanDirectory), deadlineEpochMs: scanDeadlineEpochMs, - includePaths, + includePaths: projectScanPlan.includePaths, configOverride: projectConfig, configSourceDirectory: projectScan.configSourceDirectory ?? undefined, suppressRendering: isMultiProject, @@ -1030,19 +989,19 @@ export const inspectAction = async ( retainExcludedProjectDeadCodeDiagnostics: ownsWorkspaceDeadCode, 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/utils/build-project-scan-plan.ts b/packages/react-doctor/src/cli/utils/build-project-scan-plan.ts new file mode 100644 index 000000000..0f4b4fcc4 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/build-project-scan-plan.ts @@ -0,0 +1,76 @@ +import type { DiffInfo, GitBaselineDiffPlan } from "@react-doctor/core"; +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/filter-diagnostics-by-changed-lines.ts b/packages/react-doctor/src/cli/utils/filter-diagnostics-by-changed-lines.ts new file mode 100644 index 000000000..5dc303baf --- /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 "@react-doctor/core"; +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/open-workflow-pull-request.ts b/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts index 4cebadc29..c5c5b8800 100644 --- a/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts +++ b/packages/react-doctor/src/cli/utils/open-workflow-pull-request.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import { isPathInsideDirectory } from "@react-doctor/core"; +import { isPathInsideDirectory, toCanonicalPath } from "@react-doctor/core"; import { GH_PR_LIST_MAX } from "./constants.js"; import { detectDefaultBranch } from "./detect-default-branch.js"; import { isCommandAvailable } from "./is-command-available.js"; @@ -54,6 +54,26 @@ export type NotAttemptedReason = | "git-commit-failed" | "git-push-failed"; +interface WorkflowRepositoryLocation { + readonly repositoryRoot: string; + readonly workflowRelativePath: string; +} + +const resolveWorkflowRepositoryLocation = ( + workflowPath: string, + repositoryRoot: string, +): WorkflowRepositoryLocation | null => { + const canonicalWorkflowPath = toCanonicalPath(workflowPath); + const canonicalRepositoryRoot = toCanonicalPath(repositoryRoot); + if (!isPathInsideDirectory(canonicalWorkflowPath, canonicalRepositoryRoot)) return null; + return { + repositoryRoot: canonicalRepositoryRoot, + workflowRelativePath: toForwardSlashes( + path.relative(canonicalRepositoryRoot, canonicalWorkflowPath), + ), + }; +}; + // Tries `react-doctor/add-github-actions` first and appends a compact // timestamp suffix if a local branch already exists with that name (avoids // clobbering a previous attempt's branch). @@ -177,14 +197,15 @@ export const openWorkflowPullRequest = async (input: { path.dirname(workflowPath), ); if (!repoRootProbe.success) return { status: "not-attempted", reason: "not-a-git-repo" }; - const cwd = repoRootProbe.stdout; - if (!isPathInsideDirectory(workflowPath, cwd)) { + const repositoryLocation = resolveWorkflowRepositoryLocation(workflowPath, repoRootProbe.stdout); + if (repositoryLocation === null) { return { status: "not-attempted", reason: "workflow-outside-repository" }; } + const cwd = repositoryLocation.repositoryRoot; // Forward slashes so the `:!` exclude pathspec and `git add` match git's // forward-slash-normalized repo paths on Windows (where `path.relative` // yields backslashes, which git's magic pathspec won't treat as separators). - const workflowRelative = toForwardSlashes(path.relative(cwd, workflowPath)); + const workflowRelative = repositoryLocation.workflowRelativePath; if (!checkCommandAvailable("gh")) return { status: "not-attempted", reason: "gh-not-installed" }; if (!(await run("gh", ["auth", "status"], cwd)).success) { @@ -300,7 +321,13 @@ export const stageWorkflowFile = async (input: { path.dirname(workflowPath), ); if (!repoRootProbe.success) return false; - if (!isPathInsideDirectory(workflowPath, repoRootProbe.stdout)) return false; - const workflowRelative = toForwardSlashes(path.relative(repoRootProbe.stdout, workflowPath)); - return (await run("git", ["add", "--", workflowRelative], repoRootProbe.stdout)).success; + const repositoryLocation = resolveWorkflowRepositoryLocation(workflowPath, repoRootProbe.stdout); + if (repositoryLocation === null) return false; + return ( + await run( + "git", + ["add", "--", repositoryLocation.workflowRelativePath], + repositoryLocation.repositoryRoot, + ) + ).success; }; 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 000000000..9b5a7f96b --- /dev/null +++ b/packages/react-doctor/src/cli/utils/scan-result-cache-lifecycle.ts @@ -0,0 +1,100 @@ +import type { InspectResult, ReactDoctorConfig } from "@react-doctor/core"; +import type { ResolvedInspectOptions } from "../../inspect-options.js"; +import { METRIC } from "./constants.js"; +import type { InspectExecutionCacheStats } from "./finalize-inspect-result.js"; +import { recordCount } from "./record-metric.js"; +import { renderAndRecordScan, type RenderAndRecordScanInput } from "./render-and-record-scan.js"; +import { + buildScanResultCacheKey, + createScanResultCache, + shouldStoreScanPayload, + type ScanResultCacheInvocationState, +} from "./scan-result-cache.js"; +import type { CachedScanPayload } from "./scan-result-cache-payload.js"; +import { VERSION } from "./version.js"; +import { recordSentryProjectContext, type SentryRootSpan } from "./with-sentry-run-span.js"; + +interface CreateScanResultCacheLifecycleInput { + readonly directory: string; + readonly options: ResolvedInspectOptions; + readonly userConfig: ReactDoctorConfig | null; + readonly hasConfigOverride: boolean; + readonly configSourceDirectory: string | null; + readonly resolvedNodeBinaryPath: string | null; + readonly invocationState: ScanResultCacheInvocationState; + readonly startTime: number; + readonly rootSentrySpan: SentryRootSpan; +} + +interface CompleteScanResultCacheInput { + readonly payload: CachedScanPayload; + readonly scanMode: RenderAndRecordScanInput["scanMode"]; + readonly baselineDegraded: boolean; + readonly cacheStats: Partial; +} + +interface ScanResultCacheLifecycle { + 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, + options: input.options, + userConfig: input.userConfig, + hasConfigOverride: input.hasConfigOverride, + configSourceDirectory: input.configSourceDirectory, + invocationState: input.invocationState, + }); + const scanResultCache = cacheKey === null ? null : createScanResultCache(input.directory); + const cachedPayload = + cacheKey === null || scanResultCache === null ? null : scanResultCache.lookup(cacheKey); + + return { + replay: () => { + if (cachedPayload === null) return null; + + recordSentryProjectContext(cachedPayload.project, input.rootSentrySpan, { + concurrentScan: input.options.concurrentScan, + }); + recordCount(METRIC.projectDetected, 1); + const isDiffMode = input.options.includePaths.length > 0; + const baselineDegraded = + Boolean(input.options.baseline) && isDiffMode && cachedPayload.baselineDelta === undefined; + return renderAndRecordScan({ + payload: cachedPayload, + options: input.options, + startTime: input.startTime, + rootSentrySpan: input.rootSentrySpan, + scanMode: cachedPayload.baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", + baselineDegraded, + wholeRepoCacheHit: true, + }); + }, + complete: (completion) => { + if ( + cacheKey !== null && + scanResultCache !== null && + shouldStoreScanPayload(completion.payload) && + !completion.baselineDegraded + ) { + scanResultCache.store(cacheKey, completion.payload); + } + return renderAndRecordScan({ + payload: completion.payload, + options: input.options, + startTime: input.startTime, + rootSentrySpan: input.rootSentrySpan, + scanMode: completion.scanMode, + baselineDegraded: completion.baselineDegraded, + wholeRepoCacheHit: false, + cacheStats: completion.cacheStats, + }); + }, + }; +}; diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 5848705aa..040cc0970 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -1,9 +1,7 @@ -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 ChangedFileLineRanges, createOxlintSpawnSlots, type Diagnostic, highlighter, @@ -29,24 +27,19 @@ import type { SentryRootSpan } from "./cli/utils/with-sentry-run-span.js"; import { METRIC } from "./cli/utils/constants.js"; import { recordCount } from "./cli/utils/record-metric.js"; import { recordRunEvent } from "./cli/utils/build-run-event.js"; -import { toForwardSlashes } from "./cli/utils/path-format.js"; -import { diagnosticIntersectsLineRanges } from "./cli/utils/diagnostic-intersects-line-ranges.js"; +import { filterDiagnosticsByChangedLines } from "./cli/utils/filter-diagnostics-by-changed-lines.js"; import { makeNoopConsole } from "./cli/utils/noop-console.js"; import { resolveOxlintNode } from "./cli/utils/resolve-oxlint-node.js"; import { resolveInspectOptions } from "./cli/utils/resolve-inspect-options.js"; -import { buildRunEventConfig, renderAndRecordScan } from "./cli/utils/render-and-record-scan.js"; +import { buildRunEventConfig } from "./cli/utils/render-and-record-scan.js"; import { countIncompleteLintFiles, runBaselineComparison, } from "./cli/utils/run-baseline-comparison.js"; import { getRunId } from "./cli/utils/run-id.js"; -import { - buildScanResultCacheKey, - createScanResultCacheInvocationState, - createScanResultCache, - shouldStoreScanPayload, - type CachedScanPayload, -} from "./cli/utils/scan-result-cache.js"; +import { createScanResultCacheInvocationState } from "./cli/utils/scan-result-cache.js"; +import { createScanResultCacheLifecycle } from "./cli/utils/scan-result-cache-lifecycle.js"; +import type { CachedScanPayload } from "./cli/utils/scan-result-cache-payload.js"; import { isSpinnerSilent, setSpinnerSilent } from "./cli/utils/spinner.js"; import { VERSION } from "./cli/utils/version.js"; import type { ReactDoctorInspectOptions, ResolvedInspectOptions } from "./inspect-options.js"; @@ -60,30 +53,6 @@ export type { const silentConsole = makeNoopConsole(); -// 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 = ( - 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); - }; -}; - const inspectWithOxlintRuntime = async ( directory: string, inputOptions: ReactDoctorInspectOptions, @@ -246,37 +215,19 @@ const runInspectWithRuntime = async ( ); const lintBindingMissing = options.lint && !resolvedNodeBinaryPath; await yieldToEventLoop(); - const cacheKey = buildScanResultCacheKey({ - projectDirectory: directory, - version: VERSION, - nodeBinaryPath: resolvedNodeBinaryPath, + const scanResultCacheLifecycle = createScanResultCacheLifecycle({ + directory, options, userConfig, hasConfigOverride, configSourceDirectory, + resolvedNodeBinaryPath, invocationState: oxlintRuntime.scanResultCacheInvocationState, + startTime, + rootSentrySpan, }); - const scanResultCache = cacheKey === null ? null : createScanResultCache(directory); - const cachedPayload = - cacheKey === null || scanResultCache === null ? null : scanResultCache.lookup(cacheKey); - if (cachedPayload) { - recordSentryProjectContext(cachedPayload.project, rootSentrySpan, { - concurrentScan: options.concurrentScan, - }); - recordCount(METRIC.projectDetected, 1); - const baselineDegraded = - Boolean(options.baseline) && isDiffMode && cachedPayload.baselineDelta === undefined; - const result = await renderAndRecordScan({ - payload: cachedPayload, - options, - startTime, - rootSentrySpan, - scanMode: cachedPayload.baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", - baselineDegraded, - wholeRepoCacheHit: true, - }); - return result; - } + const cachedResult = scanResultCacheLifecycle.replay(); + if (cachedResult !== null) return cachedResult; // Suppress the orchestrator-owned lint + dead-code spinners when // the CLI is in score-only / silent / suppressed-rendering mode (or @@ -441,8 +392,11 @@ const runInspectWithRuntime = async ( // 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); + inspectDiagnostics = filterDiagnosticsByChangedLines({ + directory, + diagnostics: output.diagnostics, + changedLineRanges: options.changedLineRanges, + }); } // 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. @@ -480,27 +434,10 @@ const runInspectWithRuntime = async ( securityScanFailureReason: output.securityScanFailureReason, suppressedRuleCounts: output.suppressedRuleCounts, }; - // A degraded baseline (requested but no delta — e.g. a transient base-lint - // failure) must not be persisted: the cache key includes the baseline ref, - // 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 scanResultCacheLifecycle.complete({ payload, - options, - startTime, - rootSentrySpan, scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full", baselineDegraded, - wholeRepoCacheHit: false, cacheStats: { lintCacheHitFileCount: output.lintCacheHitFileCount, lintCacheTotalFileCount: output.lintCacheTotalFileCount, @@ -511,5 +448,4 @@ const runInspectWithRuntime = async ( deadCodeSummaryCacheMisses: output.deadCodeSummaryCacheMisses, }, }); - return result; }; 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 000000000..39e356b65 --- /dev/null +++ b/packages/react-doctor/tests/build-project-scan-plan.test.ts @@ -0,0 +1,165 @@ +import * as path from "node:path"; +import type { DiffInfo, GitBaselineDiffPlan } from "@react-doctor/core"; +import { describe, expect, it } from "vite-plus/test"; +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/filter-diagnostics-by-changed-lines.test.ts b/packages/react-doctor/tests/filter-diagnostics-by-changed-lines.test.ts new file mode 100644 index 000000000..42f659dda --- /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/open-workflow-pull-request.test.ts b/packages/react-doctor/tests/open-workflow-pull-request.test.ts index 7aad2e743..602c9383a 100644 --- a/packages/react-doctor/tests/open-workflow-pull-request.test.ts +++ b/packages/react-doctor/tests/open-workflow-pull-request.test.ts @@ -1,3 +1,6 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { openWorkflowPullRequest, @@ -233,4 +236,40 @@ describe("openWorkflowPullRequest", () => { expect(await stageWorkflowFile({ workflowPath: "/outside/react-doctor.yml", run })).toBe(false); expect(invocations).toEqual([TOPLEVEL]); }); + + it("accepts a workflow path through a symlinked checkout", async () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-workflow-path-"), + ); + const physicalRepository = path.join(temporaryDirectory, "repository"); + const repositoryAlias = path.join(temporaryDirectory, "repository-alias"); + const physicalWorkflowPath = path.join(physicalRepository, WORKFLOW_RELATIVE); + const aliasedWorkflowPath = path.join(repositoryAlias, WORKFLOW_RELATIVE); + fs.mkdirSync(path.dirname(physicalWorkflowPath), { recursive: true }); + fs.writeFileSync(physicalWorkflowPath, ""); + fs.symlinkSync( + physicalRepository, + repositoryAlias, + process.platform === "win32" ? "junction" : "dir", + ); + + try { + const openRunner = recordingRunner({ [TOPLEVEL]: succeed(physicalRepository) }); + expect( + await openWorkflowPullRequest({ + workflowPath: aliasedWorkflowPath, + run: openRunner.run, + checkCommandAvailable: () => false, + }), + ).toEqual({ status: "not-attempted", reason: "gh-not-installed" }); + + const stageRunner = recordingRunner({ [TOPLEVEL]: succeed(physicalRepository) }); + expect( + await stageWorkflowFile({ workflowPath: aliasedWorkflowPath, run: stageRunner.run }), + ).toBe(true); + expect(stageRunner.invocations).toContain(`git add -- ${WORKFLOW_RELATIVE}`); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); }); From 4bd5408e56db29eed4212378e06a5b21dec13bc6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 10:00:46 +0000 Subject: [PATCH 05/17] refactor: remove analyzer-confirmed dead internals --- .changeset/tall-adults-refuse.md | 2 +- .../src/is-compiler-rule-foreign-disabled.ts | 2 +- .../core/src/project-info/dependencies.ts | 2 +- packages/core/src/project-info/version.ts | 8 +- .../deslop-js/src/duplicate-blocks/index.ts | 4 +- packages/deslop-js/src/errors.ts | 30 +---- packages/deslop-js/src/utils/oxc-ast-node.ts | 2 +- packages/deslop-js/tests/errors.test.ts | 13 +- .../src/utils/get-evaluator-source-hash.ts | 2 +- .../src/external-rules.ts | 2 +- .../src/plugin/constants/event-handlers.ts | 2 +- .../src/plugin/constants/react-native.ts | 2 + .../src/plugin/constants/react-router.ts | 9 ++ ...ull-assertion-on-maybe-undefined-result.ts | 7 +- .../rules/correctness/no-prevent-default.ts | 6 +- .../rules/correctness/no-unsafe-json-parse.ts | 5 +- .../react-router-internal-route-anchor.ts | 5 +- .../react-router-no-empty-leaf-route.ts | 15 ++- .../react-router-no-redirect-in-try-catch.ts | 5 +- ...ct-router-resource-link-requires-reload.ts | 11 +- .../design/no-shape-assembled-illustration.ts | 7 +- .../no-transitioned-composite-widget-state.ts | 6 +- .../design/utils/match-static-css-selector.ts | 2 +- .../r3f/utils/is-inside-r3f-webgpu-canvas.ts | 2 +- .../exhaustive-deps-suppression.ts | 102 +-------------- .../react-builtins/no-did-update-set-state.ts | 2 +- .../rules-of-hooks-suppression.ts | 102 +-------------- .../utils/create-rule-suppression.ts | 122 ++++++++++++++++++ .../rn-bottom-sheet-no-ignored-scroll-prop.ts | 4 +- .../rn-bottom-sheet-no-state-in-on-animate.ts | 4 +- ...-bottom-sheet-use-integrated-scrollable.ts | 4 +- .../rn-list-recyclable-without-types.ts | 7 +- .../state-and-effects/utils/effect/ast.ts | 7 +- .../utils/is-cleanup-return.ts | 6 +- .../zustand-no-mutating-state.ts | 4 +- .../get-jsx-prop-static-string-values.ts | 2 +- .../utils/has-static-property-write-before.ts | 2 +- .../utils/mutable-state-reference-analysis.ts | 6 +- .../plugin/utils/reads-post-mount-value.ts | 2 +- .../src/plugin/utils/strip-grouping-parens.ts | 2 +- .../unwrap-object-integrity-expression.ts | 2 +- .../src/react-native-dependency-names.ts | 2 +- .../src/cli/utils/action-upgrade-prompt.ts | 2 +- .../src/cli/utils/ci-prompt-decision.ts | 2 +- .../src/cli/utils/cli-lifecycle.ts | 2 +- .../src/cli/utils/cli-migrations.ts | 2 +- .../src/cli/utils/detect-agents.ts | 2 +- .../src/cli/utils/diagnostic-grouping.ts | 2 +- .../cli/utils/handoff-target-preference.ts | 2 +- .../cli/utils/install-agents-preference.ts | 2 +- .../src/cli/utils/install-react-doctor.ts | 2 +- .../src/cli/utils/onboarding-state.ts | 2 +- .../src/cli/utils/resolve-measure-width.ts | 2 +- scripts/performance/constants.ts | 2 +- 54 files changed, 230 insertions(+), 327 deletions(-) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/utils/create-rule-suppression.ts diff --git a/.changeset/tall-adults-refuse.md b/.changeset/tall-adults-refuse.md index e648a031c..81bae6055 100644 --- a/.changeset/tall-adults-refuse.md +++ b/.changeset/tall-adults-refuse.md @@ -4,4 +4,4 @@ "oxlint-plugin-react-doctor": patch --- -Harden scan orchestration and cache persistence, share cycle analysis, keep workflow paths inside the repository, and remove unused rule internals. +Harden scan orchestration and cache persistence, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals. diff --git a/packages/core/src/is-compiler-rule-foreign-disabled.ts b/packages/core/src/is-compiler-rule-foreign-disabled.ts index acbf0b0e1..6bc8a7b3d 100644 --- a/packages/core/src/is-compiler-rule-foreign-disabled.ts +++ b/packages/core/src/is-compiler-rule-foreign-disabled.ts @@ -13,7 +13,7 @@ import { tokenizeRuleList } from "./tokenize-rule-list.js"; // `// eslint-disable-next-line react-hooks/refs` silently fails to bind and // the finding refires. Honor both spellings here (react-doctor's own // `react-doctor-disable-*` family is handled in evaluate-suppression). -export const REACT_COMPILER_PLUGIN_PREFIX = "react-hooks-js/"; +const REACT_COMPILER_PLUGIN_PREFIX = "react-hooks-js/"; const ESLINT_REACT_HOOKS_PLUGIN_PREFIX = "react-hooks/"; const buildAcceptedTokens = (ruleId: string): ReadonlySet => { diff --git a/packages/core/src/project-info/dependencies.ts b/packages/core/src/project-info/dependencies.ts index 2c97d77d9..f112c6ed6 100644 --- a/packages/core/src/project-info/dependencies.ts +++ b/packages/core/src/project-info/dependencies.ts @@ -10,7 +10,7 @@ import { isConcreteDependencyVersion, isTailwindPostcss7CompatAlias } from "./ve export const isCatalogReference = (version: unknown): version is string => typeof version === "string" && version.startsWith("catalog:"); -export const extractCatalogName = (version: unknown): string | null => { +const extractCatalogName = (version: unknown): string | null => { if (!isCatalogReference(version)) return null; const name = version.slice("catalog:".length).trim(); return name.length > 0 ? name : null; diff --git a/packages/core/src/project-info/version.ts b/packages/core/src/project-info/version.ts index 736d7db74..f97a733ac 100644 --- a/packages/core/src/project-info/version.ts +++ b/packages/core/src/project-info/version.ts @@ -161,20 +161,20 @@ export const normalizeDependencyVersion = (version: string): string | null => { return normalizedVersion; }; -export const splitDependencyVersionBranches = (version: string): string[] => +const splitDependencyVersionBranches = (version: string): string[] => version .split("||") .map((branch) => branch.trim()) .filter(Boolean); -export const hasUpperBoundComparator = (version: string): boolean => { +const hasUpperBoundComparator = (version: string): boolean => { for (let index = 0; index < version.length; index += 1) { if (getUpperBoundComparatorEnd(version, index) !== null) return true; } return false; }; -export const getBranchLowestMajor = (branch: string): number | null => { +const getBranchLowestMajor = (branch: string): number | null => { if (hasNonLowerBoundComparator(branch)) return null; const lowerBoundComparators = stripUpperBoundComparators(branch).trim(); @@ -387,7 +387,7 @@ export const parseTailwindMajorMinor = ( // range. Used to compute the effective React version for libraries: // a library with `"react": "^17 || ^18 || ^19"` has an effective major // of 17, so version-gated rules that require React 19+ are suppressed. -export const hasUpperBoundOnlyPeerRange = (range: string | null | undefined): boolean => { +const hasUpperBoundOnlyPeerRange = (range: string | null | undefined): boolean => { if (typeof range !== "string") return false; const normalizedRange = normalizeDependencyVersion(range); if (normalizedRange === null) return false; diff --git a/packages/deslop-js/src/duplicate-blocks/index.ts b/packages/deslop-js/src/duplicate-blocks/index.ts index cd0c49a87..ae6ae63d6 100644 --- a/packages/deslop-js/src/duplicate-blocks/index.ts +++ b/packages/deslop-js/src/duplicate-blocks/index.ts @@ -109,8 +109,6 @@ const buildCloneInstance = ( }; }; -const directoryOf = (filePath: string): string => dirname(filePath); - const filterRawBlocksToReportableDuplicates = ( rawBlocks: RawDuplicateBlock[], tokenizedFiles: TokenizedFile[], @@ -131,7 +129,7 @@ const filterRawBlocksToReportableDuplicates = ( if (instances.length < config.minOccurrences) continue; if (config.skipLocal) { - const distinctDirectories = new Set(instances.map((instance) => directoryOf(instance.path))); + const distinctDirectories = new Set(instances.map((instance) => dirname(instance.path))); if (distinctDirectories.size < 2) continue; } diff --git a/packages/deslop-js/src/errors.ts b/packages/deslop-js/src/errors.ts index 7f3be3d25..25527ed01 100644 --- a/packages/deslop-js/src/errors.ts +++ b/packages/deslop-js/src/errors.ts @@ -59,7 +59,7 @@ export interface DeslopErrorJson { detail?: string; } -import { MAX_ANALYSIS_ERRORS, MAX_ERROR_DETAIL_LENGTH } from "./constants.js"; +import { MAX_ERROR_DETAIL_LENGTH } from "./constants.js"; const truncateDetail = (text: string): string => { if (text.length <= MAX_ERROR_DETAIL_LENGTH) return text; @@ -224,31 +224,3 @@ export class DetectorError extends DeslopError { this.name = "DetectorError"; } } - -export const createDeslopError = (input: DeslopErrorInput): DeslopError => new DeslopError(input); - -export class DeslopErrorCollector { - private readonly entries: DeslopError[] = []; - private readonly maxEntries: number; - - constructor(maxEntries: number = MAX_ANALYSIS_ERRORS) { - this.maxEntries = maxEntries; - } - - push(error: DeslopError): void { - if (this.entries.length >= this.maxEntries) return; - this.entries.push(error); - } - - pushCaught(input: DeslopErrorFromCaughtInput): void { - this.push(DeslopError.fromCaught(input)); - } - - snapshot(): DeslopError[] { - return [...this.entries]; - } - - size(): number { - return this.entries.length; - } -} diff --git a/packages/deslop-js/src/utils/oxc-ast-node.ts b/packages/deslop-js/src/utils/oxc-ast-node.ts index b54dcdcda..1cc1f6f1e 100644 --- a/packages/deslop-js/src/utils/oxc-ast-node.ts +++ b/packages/deslop-js/src/utils/oxc-ast-node.ts @@ -8,7 +8,7 @@ export interface OxcAstNode { export const isOxcAstNode = (value: unknown): value is OxcAstNode => Boolean(value) && typeof value === "object" && typeof (value as OxcAstNode).type === "string"; -export const getNodeStringField = (node: OxcAstNode, key: string): string | undefined => { +const getNodeStringField = (node: OxcAstNode, key: string): string | undefined => { const value = node[key]; return typeof value === "string" ? value : undefined; }; diff --git a/packages/deslop-js/tests/errors.test.ts b/packages/deslop-js/tests/errors.test.ts index d3c6ec3b7..bb1993834 100644 --- a/packages/deslop-js/tests/errors.test.ts +++ b/packages/deslop-js/tests/errors.test.ts @@ -11,14 +11,12 @@ import { ResolverError, TypeScriptError, WorkspaceError, - createDeslopError, - DeslopErrorCollector, } from "../src/errors.js"; import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; describe("errors / DeslopError class hierarchy", () => { it("DeslopError is an Error subclass with structured fields", () => { - const error = createDeslopError({ + const error = new DeslopError({ code: "file-read-failed", module: "parse", message: "boom", @@ -84,15 +82,6 @@ describe("errors / DeslopError class hierarchy", () => { assert.equal(serialized.message, "boom"); assert.equal(serialized.path, "/p"); }); - - it("DeslopErrorCollector caps entries and exposes a snapshot", () => { - const collector = new DeslopErrorCollector(3); - for (let index = 0; index < 5; index++) { - collector.push(new ParseError({ code: "parse-failed", message: `m${index}` })); - } - assert.equal(collector.size(), 3); - assert.equal(collector.snapshot().length, 3); - }); }); describe("errors / analyze() returns DeslopErrors instead of throwing", () => { diff --git a/packages/evals/src/utils/get-evaluator-source-hash.ts b/packages/evals/src/utils/get-evaluator-source-hash.ts index 1240f09df..e3881c57e 100644 --- a/packages/evals/src/utils/get-evaluator-source-hash.ts +++ b/packages/evals/src/utils/get-evaluator-source-hash.ts @@ -17,7 +17,7 @@ const collectSourceFilePaths = (directory: string): ReadonlyArray => }); const defaultInput = (): GetEvaluatorSourceHashInput => { - const sourceDirectory = dirname(fileURLToPath(new URL("../cli.ts", import.meta.url))); + const sourceDirectory = fileURLToPath(new URL("..", import.meta.url)); const packageDirectory = dirname(sourceDirectory); return { sourceDirectory, diff --git a/packages/oxlint-plugin-react-doctor/src/external-rules.ts b/packages/oxlint-plugin-react-doctor/src/external-rules.ts index 317953803..f65096519 100644 --- a/packages/oxlint-plugin-react-doctor/src/external-rules.ts +++ b/packages/oxlint-plugin-react-doctor/src/external-rules.ts @@ -1,6 +1,6 @@ import type { OxlintRuleSeverity } from "./types.js"; -export interface ExternalRule { +interface ExternalRule { readonly key: string; readonly source: "react-compiler"; readonly severity: OxlintRuleSeverity; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/event-handlers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/event-handlers.ts index af865b4e1..0a7f9cd5b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/event-handlers.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/event-handlers.ts @@ -26,7 +26,7 @@ const MOUSE_EVENT_HANDLERS: ReadonlyArray = [ const KEYBOARD_EVENT_HANDLERS: ReadonlyArray = ["onKeyDown", "onKeyPress", "onKeyUp"]; -export const ALL_EVENT_HANDLERS: ReadonlyArray = [ +const ALL_EVENT_HANDLERS: ReadonlyArray = [ ...MOUSE_EVENT_HANDLERS, ...KEYBOARD_EVENT_HANDLERS, ]; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-native.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-native.ts index 627bc44d4..eeab08194 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-native.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-native.ts @@ -1,5 +1,7 @@ export const RAW_TEXT_PREVIEW_MAX_CHARS = 30; +export const GORHOM_BOTTOM_SHEET_MODULE_NAME = "@gorhom/bottom-sheet"; + export const REACT_NATIVE_TEXT_COMPONENTS = new Set([ "Text", "TextInput", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-router.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-router.ts index 99a3df905..6295ec4f7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-router.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react-router.ts @@ -4,6 +4,15 @@ export const REACT_ROUTER_PACKAGE_NAMES: readonly string[] = [ "react-router", ]; +export const REACT_ROUTER_RENDER_PROPERTY_NAMES = ["Component", "element", "lazy"]; + +export const REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES = [ + "action", + "clientAction", + "clientLoader", + "loader", +]; + export const REACT_ROUTER_RULE_IDS: readonly string[] = [ "react-router-csp-nonce-consistency", "react-router-descendant-routes-require-splat", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-non-null-assertion-on-maybe-undefined-result.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-non-null-assertion-on-maybe-undefined-result.ts index a10859768..648b4fcf9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-non-null-assertion-on-maybe-undefined-result.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-non-null-assertion-on-maybe-undefined-result.ts @@ -509,7 +509,7 @@ const isGuardedAnchoredCharacterMatch = ( const sliceCallee = stripParenExpression(slicedValue.callee as EsTreeNode); if ( !isNodeOfType(sliceCallee, "MemberExpression") || - getPropertyName(sliceCallee) !== "slice" || + getStaticPropertyName(sliceCallee) !== "slice" || slicedValue.arguments.length !== 1 || !slicedValue.arguments[0] ) { @@ -909,7 +909,7 @@ const isMatchProvenByFindUpUntilPredicate = ( const callee = stripParenExpression(child.callee as EsTreeNode); if ( !isNodeOfType(callee, "MemberExpression") || - getPropertyName(callee) !== "match" || + getStaticPropertyName(callee) !== "match" || !child.arguments[0] || !areRegexPatternsEquivalent(child.arguments[0] as EsTreeNode, assertedPattern, context) || !doesPredicateTruthRequireMatch(child, predicateFunction) @@ -1691,9 +1691,6 @@ const isEnsureThenMapGet = ( return false; }; -const getPropertyName = (memberExpression: EsTreeNodeOfType<"MemberExpression">): string | null => - getStaticPropertyName(memberExpression); - const isPredicateArgument = ( node: EsTreeNode | null | undefined, context: RuleContext, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-prevent-default.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-prevent-default.ts index 0b503bc65..c787d01b9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-prevent-default.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-prevent-default.ts @@ -3,8 +3,10 @@ import { collectPatternNames } from "../../utils/collect-pattern-names.js"; import { defineRule } from "../../utils/define-rule.js"; import { findJsxAttribute } from "../../utils/find-jsx-attribute.js"; import { findProgramRoot } from "../../utils/find-program-root.js"; -import { hasCapability } from "../../utils/get-react-doctor-setting.js"; -import { getReactDoctorStringSetting } from "../../utils/get-react-doctor-setting.js"; +import { + getReactDoctorStringSetting, + hasCapability, +} from "../../utils/get-react-doctor-setting.js"; import { hasDirective } from "../../utils/has-directive.js"; import { hasJsxSpreadAttribute } from "../../utils/has-jsx-spread-attribute.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unsafe-json-parse.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unsafe-json-parse.ts index 4db27ca28..20b43d668 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unsafe-json-parse.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unsafe-json-parse.ts @@ -58,9 +58,6 @@ const isStaticallyValidJsonLiteral = (argument: EsTreeNode): boolean => { } }; -const skipParenthesizedParents = (node: EsTreeNode): EsTreeNode => - findTransparentExpressionRoot(node); - // Destructuring reads properties straight off the parse result: // `const { foo } = JSON.parse(raw)` / `const [first] = JSON.parse(raw)`. const isDestructuredDeclaratorInit = (node: EsTreeNode): boolean => { @@ -76,7 +73,7 @@ const isDestructuredDeclaratorInit = (node: EsTreeNode): boolean => { // True when a property is read directly off the call result, including through // transparent TypeScript and parenthesis wrappers. const isResultImmediatelyRead = (call: EsTreeNode): boolean => { - const unwrapped = skipParenthesizedParents(call); + const unwrapped = findTransparentExpressionRoot(call); return isObjectOfMemberAccess(unwrapped) || isDestructuredDeclaratorInit(unwrapped); }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-internal-route-anchor.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-internal-route-anchor.ts index d76204080..88a51fe71 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-internal-route-anchor.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-internal-route-anchor.ts @@ -1,3 +1,4 @@ +import { REACT_ROUTER_RENDER_PROPERTY_NAMES } from "../../constants/react-router.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; @@ -16,8 +17,6 @@ interface InternalAnchorCandidate { node: EsTreeNode; } -const UI_ROUTE_PROPERTY_NAMES = ["Component", "element", "lazy"]; - export const reactRouterInternalRouteAnchor = wrapReactRouterRule( defineRule({ id: "react-router-internal-route-anchor", @@ -33,7 +32,7 @@ export const reactRouterInternalRouteAnchor = wrapReactRouterRule( ObjectExpression(node: EsTreeNodeOfType<"ObjectExpression">) { if (!isStaticReactRouterRouteObject(context, node)) return; if ( - !UI_ROUTE_PROPERTY_NAMES.some((propertyName) => + !REACT_ROUTER_RENDER_PROPERTY_NAMES.some((propertyName) => hasActiveRouteProperty(context, node, propertyName), ) ) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-empty-leaf-route.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-empty-leaf-route.ts index d4770f4bb..7d32f9501 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-empty-leaf-route.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-empty-leaf-route.ts @@ -1,3 +1,7 @@ +import { + REACT_ROUTER_RENDER_PROPERTY_NAMES, + REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES, +} from "../../constants/react-router.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { getStaticRouteProperty } from "../../utils/get-static-route-property.js"; @@ -6,9 +10,6 @@ import { isStaticReactRouterRouteObject } from "../../utils/is-static-react-rout import type { RuleContext } from "../../utils/rule-context.js"; import { wrapReactRouterRule } from "../../utils/wrap-react-router-rule.js"; -const LEAF_CONTENT_PROPERTY_NAMES = ["Component", "element", "lazy"]; -const RESOURCE_ROUTE_PROPERTY_NAMES = ["action", "clientAction", "clientLoader", "loader"]; - export const reactRouterNoEmptyLeafRoute = wrapReactRouterRule( defineRule({ id: "react-router-no-empty-leaf-route", @@ -29,12 +30,16 @@ export const reactRouterNoEmptyLeafRoute = wrapReactRouterRule( return; } if ( - LEAF_CONTENT_PROPERTY_NAMES.some((name) => hasActiveRouteProperty(context, node, name)) + REACT_ROUTER_RENDER_PROPERTY_NAMES.some((name) => + hasActiveRouteProperty(context, node, name), + ) ) { return; } if ( - RESOURCE_ROUTE_PROPERTY_NAMES.some((name) => hasActiveRouteProperty(context, node, name)) + REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES.some((name) => + hasActiveRouteProperty(context, node, name), + ) ) { return; } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-redirect-in-try-catch.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-redirect-in-try-catch.ts index faeb7f759..67ef30496 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-redirect-in-try-catch.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-no-redirect-in-try-catch.ts @@ -1,3 +1,4 @@ +import { REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES } from "../../constants/react-router.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { findEnclosingFunction } from "../../utils/find-enclosing-function.js"; @@ -8,8 +9,6 @@ import { isReactRouterRouteFunction } from "../../utils/is-react-router-route-fu import type { RuleContext } from "../../utils/rule-context.js"; import { wrapReactRouterRule } from "../../utils/wrap-react-router-rule.js"; -const REDIRECT_ROUTE_FUNCTION_NAMES = ["action", "clientAction", "clientLoader", "loader"]; - export const reactRouterNoRedirectInTryCatch = wrapReactRouterRule( defineRule({ id: "react-router-no-redirect-in-try-catch", @@ -32,7 +31,7 @@ export const reactRouterNoRedirectInTryCatch = wrapReactRouterRule( const routeFunction = findEnclosingFunction(node); if ( routeFunction === null || - !REDIRECT_ROUTE_FUNCTION_NAMES.some((name) => + !REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES.some((name) => isReactRouterRouteFunction(context, routeFunction, name), ) ) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-resource-link-requires-reload.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-resource-link-requires-reload.ts index fa2dc3928..16a571e03 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-resource-link-requires-reload.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/react-router-resource-link-requires-reload.ts @@ -1,3 +1,7 @@ +import { + REACT_ROUTER_RENDER_PROPERTY_NAMES, + REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES, +} from "../../constants/react-router.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { getImportedNameFromReactRouter } from "../../utils/get-imported-name-from-react-router.js"; @@ -11,9 +15,6 @@ import { isStaticReactRouterRouteObject } from "../../utils/is-static-react-rout import type { RuleContext } from "../../utils/rule-context.js"; import { wrapReactRouterRule } from "../../utils/wrap-react-router-rule.js"; -const RESOURCE_HANDLER_PROPERTY_NAMES = ["action", "clientAction", "clientLoader", "loader"]; -const RENDER_PROPERTY_NAMES = ["Component", "element", "lazy"]; - interface ResourceLinkCandidate { destination: string; importedName: string; @@ -36,14 +37,14 @@ export const reactRouterResourceLinkRequiresReload = wrapReactRouterRule( ObjectExpression(node: EsTreeNodeOfType<"ObjectExpression">) { if (!isStaticReactRouterRouteObject(context, node)) return; if ( - !RESOURCE_HANDLER_PROPERTY_NAMES.some((propertyName) => + !REACT_ROUTER_RESOURCE_HANDLER_PROPERTY_NAMES.some((propertyName) => hasActiveRouteProperty(context, node, propertyName), ) ) { return; } if ( - RENDER_PROPERTY_NAMES.some((propertyName) => + REACT_ROUTER_RENDER_PROPERTY_NAMES.some((propertyName) => hasActiveRouteProperty(context, node, propertyName), ) || hasActiveRouteProperty(context, node, "children") diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-shape-assembled-illustration.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-shape-assembled-illustration.ts index 3f270a136..227bff51b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-shape-assembled-illustration.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-shape-assembled-illustration.ts @@ -171,14 +171,11 @@ const getStaticBooleanAttributeState = ( if (!attribute) return false; if (!attribute.value) return true; const value = attribute.value; - if (isNodeOfType(value, "Literal")) - return value.value === false || value.value === null ? false : true; + if (isNodeOfType(value, "Literal")) return value.value !== false && value.value !== null; if (!isNodeOfType(value, "JSXExpressionContainer")) return null; const expression = stripParenExpression(value.expression); return isNodeOfType(expression, "Literal") - ? expression.value === false || expression.value === null - ? false - : true + ? expression.value !== false && expression.value !== null : null; }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-transitioned-composite-widget-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-transitioned-composite-widget-state.ts index 6c2c93f95..77dc53434 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-transitioned-composite-widget-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-transitioned-composite-widget-state.ts @@ -7,8 +7,10 @@ import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { getAuthoritativeJsxAttribute } from "../../utils/get-authoritative-jsx-attribute.js"; import { getElementType } from "../../utils/get-element-type.js"; import { getHighestPriorityTailwindClassNameTokens } from "../../utils/get-highest-priority-tailwind-class-name-tokens.js"; -import { getJsxPropExhaustiveStaticStringValues } from "../../utils/get-jsx-prop-static-string-values.js"; -import { getJsxPropStaticStringValues } from "../../utils/get-jsx-prop-static-string-values.js"; +import { + getJsxPropExhaustiveStaticStringValues, + getJsxPropStaticStringValues, +} from "../../utils/get-jsx-prop-static-string-values.js"; import { getTailwindTopLevelCharacterIndices } from "../../utils/get-tailwind-top-level-character-indices.js"; import { getTailwindTransitionPropertyEffect } from "../../utils/get-tailwind-transition-property-effect.js"; import { hasJsxSpreadAttribute } from "../../utils/has-jsx-spread-attribute.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/match-static-css-selector.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/match-static-css-selector.ts index 1a0117234..a93d3617f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/match-static-css-selector.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/utils/match-static-css-selector.ts @@ -96,7 +96,7 @@ export const selectorMatches = ( node: EsTreeNodeOfType<"JSXOpeningElement">, ): StaticSelectorMatch => selectorMatchesAt(selector, selector.length - 1, node); -export const selectorListMatches = ( +const selectorListMatches = ( selectors: ReadonlyArray, node: EsTreeNodeOfType<"JSXOpeningElement">, ): StaticSelectorMatch => diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/is-inside-r3f-webgpu-canvas.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/is-inside-r3f-webgpu-canvas.ts index 98e922438..d21edd2fe 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/is-inside-r3f-webgpu-canvas.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/is-inside-r3f-webgpu-canvas.ts @@ -46,7 +46,7 @@ const canvasCreatesWebGpuRenderer = (canvas: EsTreeNode, context: RuleContext): ); }; -export const isR3fWebgpuCanvasElement = (node: EsTreeNode, context: RuleContext): boolean => { +const isR3fWebgpuCanvasElement = (node: EsTreeNode, context: RuleContext): boolean => { if (!isNodeOfType(node, "JSXElement")) return false; const moduleSource = getApiReferenceModuleSource( node.openingElement.name, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps-suppression.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps-suppression.ts index 5f3620b18..c67019af0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps-suppression.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps-suppression.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { createRuleSuppression } from "./utils/create-rule-suppression.js"; // Codebases that migrated from eslint-plugin-react-hooks carry // `eslint-disable-next-line react-hooks/exhaustive-deps` comments on @@ -7,101 +7,7 @@ import { readFileSync } from "node:fs"; // upstream rule name (which oxlint's own disable-comment handling does // NOT match against our `react-doctor/exhaustive-deps` id) keeps those // documented opt-outs working instead of re-reporting them. -const DISABLE_COMMENT_RULE_NAME_PATTERN = /(?:^|[\s,/])exhaustive-deps(?:$|[\s,:])/; -const DISABLE_NEXT_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-next-line\b([^\n]*)/; -const DISABLE_SAME_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-line\b([^\n]*)/; +const exhaustiveDepsSuppression = createRuleSuppression("exhaustive-deps"); -interface SuppressionIndex { - suppressedLines: ReadonlySet; - utf16NewlineOffsets: ReadonlyArray; - utf8NewlineOffsets: ReadonlyArray; -} - -const suppressionIndexCache = new Map(); - -const namesExhaustiveDeps = (ruleList: string | undefined): boolean => - typeof ruleList === "string" && DISABLE_COMMENT_RULE_NAME_PATTERN.test(ruleList); - -const buildSuppressionIndex = (sourceText: string): SuppressionIndex | null => { - const suppressedLines = new Set(); - const lines = sourceText.split("\n"); - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const line = lines[lineIndex]!; - if (!line.includes("-disable")) continue; - const nextLineMatch = DISABLE_NEXT_LINE_PATTERN.exec(line); - if (nextLineMatch && namesExhaustiveDeps(nextLineMatch[1])) { - suppressedLines.add(lineIndex + 2); - continue; - } - const sameLineMatch = DISABLE_SAME_LINE_PATTERN.exec(line); - if (sameLineMatch && namesExhaustiveDeps(sameLineMatch[1])) { - suppressedLines.add(lineIndex + 1); - } - } - if (suppressedLines.size === 0) return null; - // Host ASTs disagree on span units (oxlint raw-transfer spans are - // UTF-8 byte offsets; the ESLint adapter and test harness use UTF-16 - // string indices), so record newline positions in BOTH units and let - // the lookup accept either interpretation. Suppression additionally - // requires the explicit rule-name comment, so a dual match can only - // widen an author-requested opt-out, never hide an unrelated report. - const utf16NewlineOffsets: number[] = []; - const utf8NewlineOffsets: number[] = []; - let utf8Offset = 0; - let sliceStart = 0; - for (let charIndex = 0; charIndex < sourceText.length; charIndex++) { - if (sourceText[charIndex] !== "\n") continue; - utf16NewlineOffsets.push(charIndex); - utf8Offset += Buffer.byteLength(sourceText.slice(sliceStart, charIndex + 1), "utf8"); - utf8NewlineOffsets.push(utf8Offset - 1); - sliceStart = charIndex + 1; - } - return { suppressedLines, utf16NewlineOffsets, utf8NewlineOffsets }; -}; - -const lineForOffset = (offset: number, newlineOffsets: ReadonlyArray): number => { - let lowIndex = 0; - let highIndex = newlineOffsets.length - 1; - let newlinesBefore = 0; - while (lowIndex <= highIndex) { - const middleIndex = Math.floor((lowIndex + highIndex) / 2); - if (newlineOffsets[middleIndex]! < offset) { - newlinesBefore = middleIndex + 1; - lowIndex = middleIndex + 1; - } else { - highIndex = middleIndex - 1; - } - } - return newlinesBefore + 1; -}; - -const getSuppressionIndex = (filename: string | undefined): SuppressionIndex | null => { - if (!filename) return null; - const cached = suppressionIndexCache.get(filename); - if (cached !== undefined) return cached; - let index: SuppressionIndex | null = null; - try { - index = buildSuppressionIndex(readFileSync(filename, "utf8")); - } catch { - index = null; - } - suppressionIndexCache.set(filename, index); - return index; -}; - -export const isExhaustiveDepsSuppressedAt = ( - filename: string | undefined, - nodeStartOffset: number | null, -): boolean => { - if (nodeStartOffset === null) return false; - const index = getSuppressionIndex(filename); - if (!index) return false; - return ( - index.suppressedLines.has(lineForOffset(nodeStartOffset, index.utf16NewlineOffsets)) || - index.suppressedLines.has(lineForOffset(nodeStartOffset, index.utf8NewlineOffsets)) - ); -}; - -export const clearExhaustiveDepsSuppressionCache = (): void => { - suppressionIndexCache.clear(); -}; +export const isExhaustiveDepsSuppressedAt = exhaustiveDepsSuppression.isSuppressedAt; +export const clearExhaustiveDepsSuppressionCache = exhaustiveDepsSuppression.clearCache; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-did-update-set-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-did-update-set-state.ts index c3ee1a142..237c67700 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-did-update-set-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-did-update-set-state.ts @@ -401,7 +401,7 @@ const isUndefinedIdentifier = (node: EsTreeNode): boolean => { return isNodeOfType(unwrappedNode, "Identifier") && unwrappedNode.name === "undefined"; }; -export const getThisFieldName = (node: EsTreeNode): string | null => { +const getThisFieldName = (node: EsTreeNode): string | null => { const unwrappedNode = stripParenExpression(node); if ( !isNodeOfType(unwrappedNode, "MemberExpression") || diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/rules-of-hooks-suppression.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/rules-of-hooks-suppression.ts index e20a85c57..1e3e605e2 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/rules-of-hooks-suppression.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/rules-of-hooks-suppression.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { createRuleSuppression } from "./utils/create-rule-suppression.js"; // Codebases that migrated from eslint-plugin-react-hooks carry // `eslint-disable-next-line react-hooks/rules-of-hooks` comments on @@ -7,101 +7,7 @@ import { readFileSync } from "node:fs"; // of a given build). oxlint's own disable-comment handling only matches // our `react-doctor/rules-of-hooks` id, so the upstream rule name must be // honored here to keep those documented opt-outs working. -const DISABLE_COMMENT_RULE_NAME_PATTERN = /(?:^|[\s,/])rules-of-hooks(?:$|[\s,:])/; -const DISABLE_NEXT_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-next-line\b([^\n]*)/; -const DISABLE_SAME_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-line\b([^\n]*)/; +const rulesOfHooksSuppression = createRuleSuppression("rules-of-hooks"); -interface SuppressionIndex { - suppressedLines: ReadonlySet; - utf16NewlineOffsets: ReadonlyArray; - utf8NewlineOffsets: ReadonlyArray; -} - -const suppressionIndexCache = new Map(); - -const namesRulesOfHooks = (ruleList: string | undefined): boolean => - typeof ruleList === "string" && DISABLE_COMMENT_RULE_NAME_PATTERN.test(ruleList); - -const buildSuppressionIndex = (sourceText: string): SuppressionIndex | null => { - const suppressedLines = new Set(); - const lines = sourceText.split("\n"); - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const line = lines[lineIndex]!; - if (!line.includes("-disable")) continue; - const nextLineMatch = DISABLE_NEXT_LINE_PATTERN.exec(line); - if (nextLineMatch && namesRulesOfHooks(nextLineMatch[1])) { - suppressedLines.add(lineIndex + 2); - continue; - } - const sameLineMatch = DISABLE_SAME_LINE_PATTERN.exec(line); - if (sameLineMatch && namesRulesOfHooks(sameLineMatch[1])) { - suppressedLines.add(lineIndex + 1); - } - } - if (suppressedLines.size === 0) return null; - // Host ASTs disagree on span units (oxlint raw-transfer spans are - // UTF-8 byte offsets; the ESLint adapter and test harness use UTF-16 - // string indices), so record newline positions in BOTH units and let - // the lookup accept either interpretation. Suppression additionally - // requires the explicit rule-name comment, so a dual match can only - // widen an author-requested opt-out, never hide an unrelated report. - const utf16NewlineOffsets: number[] = []; - const utf8NewlineOffsets: number[] = []; - let utf8Offset = 0; - let sliceStart = 0; - for (let charIndex = 0; charIndex < sourceText.length; charIndex++) { - if (sourceText[charIndex] !== "\n") continue; - utf16NewlineOffsets.push(charIndex); - utf8Offset += Buffer.byteLength(sourceText.slice(sliceStart, charIndex + 1), "utf8"); - utf8NewlineOffsets.push(utf8Offset - 1); - sliceStart = charIndex + 1; - } - return { suppressedLines, utf16NewlineOffsets, utf8NewlineOffsets }; -}; - -const lineForOffset = (offset: number, newlineOffsets: ReadonlyArray): number => { - let lowIndex = 0; - let highIndex = newlineOffsets.length - 1; - let newlinesBefore = 0; - while (lowIndex <= highIndex) { - const middleIndex = Math.floor((lowIndex + highIndex) / 2); - if (newlineOffsets[middleIndex]! < offset) { - newlinesBefore = middleIndex + 1; - lowIndex = middleIndex + 1; - } else { - highIndex = middleIndex - 1; - } - } - return newlinesBefore + 1; -}; - -const getSuppressionIndex = (filename: string | undefined): SuppressionIndex | null => { - if (!filename) return null; - const cached = suppressionIndexCache.get(filename); - if (cached !== undefined) return cached; - let index: SuppressionIndex | null = null; - try { - index = buildSuppressionIndex(readFileSync(filename, "utf8")); - } catch { - index = null; - } - suppressionIndexCache.set(filename, index); - return index; -}; - -export const isRulesOfHooksSuppressedAt = ( - filename: string | undefined, - nodeStartOffset: number | null, -): boolean => { - if (nodeStartOffset === null) return false; - const index = getSuppressionIndex(filename); - if (!index) return false; - return ( - index.suppressedLines.has(lineForOffset(nodeStartOffset, index.utf16NewlineOffsets)) || - index.suppressedLines.has(lineForOffset(nodeStartOffset, index.utf8NewlineOffsets)) - ); -}; - -export const clearRulesOfHooksSuppressionCache = (): void => { - suppressionIndexCache.clear(); -}; +export const isRulesOfHooksSuppressedAt = rulesOfHooksSuppression.isSuppressedAt; +export const clearRulesOfHooksSuppressionCache = rulesOfHooksSuppression.clearCache; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/utils/create-rule-suppression.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/utils/create-rule-suppression.ts new file mode 100644 index 000000000..249425218 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/utils/create-rule-suppression.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; + +const DISABLE_NEXT_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-next-line\b([^\n]*)/; +const DISABLE_SAME_LINE_PATTERN = /\b(?:eslint|oxlint)-disable-line\b([^\n]*)/; + +interface SuppressionIndex { + suppressedLines: ReadonlySet; + utf16NewlineOffsets: ReadonlyArray; + utf8NewlineOffsets: ReadonlyArray; +} + +interface RuleSuppression { + isSuppressedAt: (filename: string | undefined, nodeStartOffset: number | null) => boolean; + clearCache: () => void; +} + +interface NewlineOffsets { + utf16: ReadonlyArray; + utf8: ReadonlyArray; +} + +const collectSuppressedLines = (sourceText: string, ruleNamePattern: RegExp): Set => { + const suppressedLines = new Set(); + const lines = sourceText.split("\n"); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]!; + if (!line.includes("-disable")) continue; + const nextLineMatch = DISABLE_NEXT_LINE_PATTERN.exec(line); + if (nextLineMatch && ruleNamePattern.test(nextLineMatch[1] ?? "")) { + suppressedLines.add(lineIndex + 2); + continue; + } + const sameLineMatch = DISABLE_SAME_LINE_PATTERN.exec(line); + if (sameLineMatch && ruleNamePattern.test(sameLineMatch[1] ?? "")) { + suppressedLines.add(lineIndex + 1); + } + } + return suppressedLines; +}; + +const collectNewlineOffsets = (sourceText: string): NewlineOffsets => { + const utf16NewlineOffsets: number[] = []; + const utf8NewlineOffsets: number[] = []; + let utf8Offset = 0; + let sliceStart = 0; + for (let characterIndex = 0; characterIndex < sourceText.length; characterIndex++) { + if (sourceText[characterIndex] !== "\n") continue; + utf16NewlineOffsets.push(characterIndex); + utf8Offset += Buffer.byteLength(sourceText.slice(sliceStart, characterIndex + 1), "utf8"); + utf8NewlineOffsets.push(utf8Offset - 1); + sliceStart = characterIndex + 1; + } + return { utf16: utf16NewlineOffsets, utf8: utf8NewlineOffsets }; +}; + +const buildSuppressionIndex = ( + sourceText: string, + ruleNamePattern: RegExp, +): SuppressionIndex | null => { + const suppressedLines = collectSuppressedLines(sourceText, ruleNamePattern); + if (suppressedLines.size === 0) return null; + + const newlineOffsets = collectNewlineOffsets(sourceText); + return { + suppressedLines, + utf16NewlineOffsets: newlineOffsets.utf16, + utf8NewlineOffsets: newlineOffsets.utf8, + }; +}; + +const lineForOffset = (offset: number, newlineOffsets: ReadonlyArray): number => { + let lowIndex = 0; + let highIndex = newlineOffsets.length - 1; + let newlinesBefore = 0; + while (lowIndex <= highIndex) { + const middleIndex = Math.floor((lowIndex + highIndex) / 2); + if (newlineOffsets[middleIndex]! < offset) { + newlinesBefore = middleIndex + 1; + lowIndex = middleIndex + 1; + } else { + highIndex = middleIndex - 1; + } + } + return newlinesBefore + 1; +}; + +export const createRuleSuppression = (ruleName: string): RuleSuppression => { + const ruleNamePattern = new RegExp(`(?:^|[\\s,/])${ruleName}(?:$|[\\s,:])`); + const suppressionIndexCache = new Map(); + + const getSuppressionIndex = (filename: string | undefined): SuppressionIndex | null => { + if (!filename) return null; + const cachedIndex = suppressionIndexCache.get(filename); + if (cachedIndex !== undefined) return cachedIndex; + + let suppressionIndex: SuppressionIndex | null = null; + try { + suppressionIndex = buildSuppressionIndex(readFileSync(filename, "utf8"), ruleNamePattern); + } catch { + suppressionIndex = null; + } + suppressionIndexCache.set(filename, suppressionIndex); + return suppressionIndex; + }; + + return { + clearCache: () => suppressionIndexCache.clear(), + isSuppressedAt: (filename, nodeStartOffset) => { + if (nodeStartOffset === null) return false; + const suppressionIndex = getSuppressionIndex(filename); + if (!suppressionIndex) return false; + return ( + suppressionIndex.suppressedLines.has( + lineForOffset(nodeStartOffset, suppressionIndex.utf16NewlineOffsets), + ) || + suppressionIndex.suppressedLines.has( + lineForOffset(nodeStartOffset, suppressionIndex.utf8NewlineOffsets), + ) + ); + }, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-ignored-scroll-prop.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-ignored-scroll-prop.ts index 3e350e555..7445e4758 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-ignored-scroll-prop.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-ignored-scroll-prop.ts @@ -1,10 +1,10 @@ +import { GORHOM_BOTTOM_SHEET_MODULE_NAME } from "../../constants/react-native.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { getJsxAttributeName } from "../../utils/get-jsx-attribute-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { resolveImportedJsxComponentName } from "../../utils/resolve-imported-jsx-component-name.js"; -const GORHOM_BOTTOM_SHEET_MODULE = "@gorhom/bottom-sheet"; const IGNORED_SCROLL_PROPERTY_NAMES: ReadonlySet = new Set([ "decelerationRate", "onScrollBeginDrag", @@ -22,7 +22,7 @@ export const rnBottomSheetNoIgnoredScrollProp = defineRule({ JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { const componentName = resolveImportedJsxComponentName( node, - GORHOM_BOTTOM_SHEET_MODULE, + GORHOM_BOTTOM_SHEET_MODULE_NAME, context.scopes, ); if (componentName !== "BottomSheetScrollView") return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-state-in-on-animate.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-state-in-on-animate.ts index 37e0bbe7d..f713784e5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-state-in-on-animate.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-no-state-in-on-animate.ts @@ -1,3 +1,4 @@ +import { GORHOM_BOTTOM_SHEET_MODULE_NAME } from "../../constants/react-native.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; @@ -8,7 +9,6 @@ import { resolveImportedJsxComponentName } from "../../utils/resolve-imported-js import { resolveReactUseStatePair } from "../../utils/resolve-react-use-state-pair.js"; import { walkOwnFunctionScope } from "../../utils/walk-own-function-scope.js"; -const GORHOM_BOTTOM_SHEET_MODULE = "@gorhom/bottom-sheet"; const BOTTOM_SHEET_CONTAINER_NAMES: ReadonlySet = new Set([ "BottomSheet", "BottomSheetModal", @@ -26,7 +26,7 @@ export const rnBottomSheetNoStateInOnAnimate = defineRule({ JSXOpeningElement(node: EsTreeNodeOfType<"JSXOpeningElement">) { const componentName = resolveImportedJsxComponentName( node, - GORHOM_BOTTOM_SHEET_MODULE, + GORHOM_BOTTOM_SHEET_MODULE_NAME, context.scopes, ); if (!componentName || !BOTTOM_SHEET_CONTAINER_NAMES.has(componentName)) return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-use-integrated-scrollable.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-use-integrated-scrollable.ts index 5a4eab1aa..56a5fcb4b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-use-integrated-scrollable.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-bottom-sheet-use-integrated-scrollable.ts @@ -1,10 +1,10 @@ +import { GORHOM_BOTTOM_SHEET_MODULE_NAME } from "../../constants/react-native.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { getStaticJsxDescendantOpeningElements } from "../../utils/get-static-jsx-descendant-opening-elements.js"; import { resolveImportedJsxComponentName } from "../../utils/resolve-imported-jsx-component-name.js"; -const GORHOM_BOTTOM_SHEET_MODULE = "@gorhom/bottom-sheet"; const REACT_NATIVE_MODULE = "react-native"; const BOTTOM_SHEET_CONTAINER_NAMES: ReadonlySet = new Set([ "BottomSheet", @@ -31,7 +31,7 @@ export const rnBottomSheetUseIntegratedScrollable = defineRule({ JSXElement(node: EsTreeNodeOfType<"JSXElement">) { const containerName = resolveImportedJsxComponentName( node.openingElement, - GORHOM_BOTTOM_SHEET_MODULE, + GORHOM_BOTTOM_SHEET_MODULE_NAME, context.scopes, ); if (!containerName || !BOTTOM_SHEET_CONTAINER_NAMES.has(containerName)) return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-list-recyclable-without-types.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-list-recyclable-without-types.ts index 3d3cf84ed..8011c26f0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-list-recyclable-without-types.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-list-recyclable-without-types.ts @@ -19,8 +19,11 @@ import { getStaticPropertyName } from "../../utils/get-static-property-name.js"; import { getTransparentReactCallbackWrapperArgument } from "../../utils/get-transparent-react-callback-wrapper-argument.js"; import { hasSymbolWriteBefore } from "../../utils/has-symbol-write-before.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; -import { isImportedFromReact, isReactApiCall } from "../../utils/is-react-api-call.js"; -import { isReactNamespaceImport } from "../../utils/is-react-api-call.js"; +import { + isImportedFromReact, + isReactApiCall, + isReactNamespaceImport, +} from "../../utils/is-react-api-call.js"; import { isJsxFragmentElement } from "../../utils/is-jsx-fragment-element.js"; import type { RuleContext } from "../../utils/rule-context.js"; import { resolveConstIdentifierAlias } from "../../utils/resolve-const-identifier-alias.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/ast.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/ast.ts index 3af3ffcc2..f291b9668 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/ast.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/ast.ts @@ -21,10 +21,7 @@ import type { ProgramAnalysis } from "./get-program-analysis.js"; // Bare identifier arguments (`const debounced = debounce(setN)`) still do. const HOOK_NAME_PATTERN = /^use[A-Z0-9]/; -export const isInsideCallbackArgumentOf = ( - identifier: EsTreeNode, - initializer: EsTreeNode, -): boolean => { +const isInsideCallbackArgumentOf = (identifier: EsTreeNode, initializer: EsTreeNode): boolean => { if (!isNodeOfType(initializer, "CallExpression") && !isNodeOfType(initializer, "NewExpression")) { return false; } @@ -124,7 +121,7 @@ export const getUpstreamRefs = (analysis: ProgramAnalysis, ref: Reference): Refe return refs; }; -export const findDownstreamNodes = (topNode: EsTreeNode, type: string): EsTreeNode[] => { +const findDownstreamNodes = (topNode: EsTreeNode, type: string): EsTreeNode[] => { const nodes: EsTreeNode[] = []; descend(topNode, (node) => { if (node.type === type) nodes.push(node); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-cleanup-return.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-cleanup-return.ts index 8c210002b..865e919e4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-cleanup-return.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-cleanup-return.ts @@ -78,7 +78,7 @@ const isNoOpInlineHandlerRemoval = ( ); }; -export const isReleaseLikeCall = ( +const isReleaseLikeCall = ( node: EsTreeNode, knownCleanupFunctionNames: ReadonlySet, knownBoundSubscriptionNames: ReadonlySet, @@ -131,7 +131,7 @@ const isIteratorCallbackArgument = (node: EsTreeNode): boolean => { ); }; -export const containsReleaseLikeCall = ( +const containsReleaseLikeCall = ( node: EsTreeNode, knownCleanupFunctionNames: ReadonlySet, knownBoundSubscriptionNames: ReadonlySet, @@ -150,7 +150,7 @@ export const containsReleaseLikeCall = ( return didFindRelease; }; -export const isCleanupFunctionLike = ( +const isCleanupFunctionLike = ( node: EsTreeNode, knownCleanupFunctionNames: ReadonlySet, knownBoundSubscriptionNames: ReadonlySet, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts index c74685e16..34a7bd88d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts @@ -548,7 +548,7 @@ const objectTargetReplacementDisposition = ( ): boolean | null => { const propertyName = targetPath[0]; if (!propertyName) return true; - let disposition: boolean | null = isPartialUpdateRoot ? false : true; + let disposition: boolean | null = !isPartialUpdateRoot; for (const property of objectExpression.properties) { if (isNodeOfType(property, "SpreadElement")) { const spreadKey = resolveExpressionKey(property.argument, context); @@ -613,7 +613,7 @@ const objectTargetPathReplacementDisposition = ( ): boolean | null => { const propertyName = targetPath[0]; if (!propertyName) return true; - let disposition: boolean | null = isPartialUpdateRoot ? false : true; + let disposition: boolean | null = !isPartialUpdateRoot; for (const property of objectExpression.properties) { if (isNodeOfType(property, "SpreadElement")) { disposition = null; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.ts index 58bc3141c..8e2eee470 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.ts @@ -131,7 +131,7 @@ const resolveStaticStringExpressionValues = ( return staticStringValues.length > 0 ? staticStringValues : null; }; -export const getStaticStringExpressionValues = ( +const getStaticStringExpressionValues = ( rawExpression: EsTreeNode, scopes: ScopeAnalysis, options: StaticStringResolutionOptions = {}, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-static-property-write-before.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-static-property-write-before.ts index 66c3050d7..78f382455 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-static-property-write-before.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-static-property-write-before.ts @@ -240,7 +240,7 @@ export const getFunctionSynchronousInvocationPathsBefore = ( }); }; -export const isFunctionSynchronouslyInvokedBefore = ( +const isFunctionSynchronouslyInvokedBefore = ( functionNode: EsTreeNode, referenceNode: EsTreeNode, scopes: ScopeAnalysis, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/mutable-state-reference-analysis.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/mutable-state-reference-analysis.ts index 9f535a604..5d21a29d0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/mutable-state-reference-analysis.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/mutable-state-reference-analysis.ts @@ -42,7 +42,7 @@ const isStaticMethodCallOnNamedObject = ( return !findVariableInitializer(calleeObject, calleeObject.name); }; -export const isExpressionRootedInMutableStateSource = ( +const isExpressionRootedInMutableStateSource = ( node: EsTreeNode, state: MutableStateReferenceState, ): boolean => { @@ -56,7 +56,7 @@ export const isExpressionRootedInMutableStateSource = ( ); }; -export const isExpressionReachableFromMutableState = ( +const isExpressionReachableFromMutableState = ( node: EsTreeNode | null | undefined, state: MutableStateReferenceState, ): boolean => { @@ -71,7 +71,7 @@ export const isExpressionReachableFromMutableState = ( ); }; -export const addMutableStateReferenceBindings = ( +const addMutableStateReferenceBindings = ( pattern: EsTreeNode, state: MutableStateReferenceState, ): void => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts index 5edc0e66e..a69f0e15a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts @@ -14,7 +14,7 @@ import { walkAst } from "./walk-ast.js"; // // Unambiguous DOM API method names: these never appear on plain data objects, // so a bare name match is safe. -export const DOM_QUERY_MEMBER_NAMES: ReadonlySet = new Set([ +const DOM_QUERY_MEMBER_NAMES: ReadonlySet = new Set([ "getBoundingClientRect", "getComputedStyle", "getElementById", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/strip-grouping-parens.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/strip-grouping-parens.ts index ec2075c17..4aab3358d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/strip-grouping-parens.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/strip-grouping-parens.ts @@ -2,7 +2,7 @@ import type { EsTreeNode } from "./es-tree-node.js"; // oxc-parser surfaces `(...)` as a `ParenthesizedExpression`, a node kind // outside the TSESTree union, so it is matched by string here. -export const PARENTHESIZED_EXPRESSION_TYPE: string = "ParenthesizedExpression"; +const PARENTHESIZED_EXPRESSION_TYPE: string = "ParenthesizedExpression"; // Peels ONLY grouping parentheses, leaving TS assertion (`as` / `satisfies` // / `!`) and optional-chaining wrappers intact — unlike diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/unwrap-object-integrity-expression.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/unwrap-object-integrity-expression.ts index 1ec473739..25cbe24e5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/unwrap-object-integrity-expression.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/unwrap-object-integrity-expression.ts @@ -3,7 +3,7 @@ import type { EsTreeNode } from "./es-tree-node.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { stripParenExpression } from "./strip-paren-expression.js"; -export const OBJECT_INTEGRITY_METHOD_NAMES = new Set(["freeze", "seal", "preventExtensions"]); +const OBJECT_INTEGRITY_METHOD_NAMES = new Set(["freeze", "seal", "preventExtensions"]); export const OBJECT_FREEZE_OR_SEAL_METHOD_NAMES = new Set(["freeze", "seal"]); diff --git a/packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts b/packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts index ed89b9e49..941ce2c5e 100644 --- a/packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts +++ b/packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts @@ -9,7 +9,7 @@ // target. // Closed set of canonical Expo-managed dependency names. -export const EXPO_MANAGED_DEPENDENCY_NAMES: ReadonlySet = new Set([ +const EXPO_MANAGED_DEPENDENCY_NAMES: ReadonlySet = new Set([ "expo", "expo-router", "@expo/cli", diff --git a/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts b/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts index b88a8d43c..06e293f62 100644 --- a/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts +++ b/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts @@ -5,7 +5,7 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // closes it (an accepted-but-unmerged PR shouldn't re-prompt). When a future // major ships, register a new gate id (e.g. `action-upgrade-v3`) rather than // bumping this one, so the v2 answer stays remembered. -export const ACTION_UPGRADE_GATE: Gate = { id: ACTION_UPGRADE_EVENT, scope: "project" }; +const ACTION_UPGRADE_GATE: Gate = { id: ACTION_UPGRADE_EVENT, scope: "project" }; export const getActionUpgradePromptConfigPath = getCliStatePath; diff --git a/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts b/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts index 5daddc636..65b10a77e 100644 --- a/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts +++ b/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts @@ -6,7 +6,7 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // closes the gate, so a decline doesn't re-nag and an accept whose workflow // write didn't land doesn't re-pitch (the user can re-run `react-doctor // install`). Bump `version` to re-pitch everyone after a reworked campaign. -export const CI_PITCH_GATE: Gate = { id: CI_PITCH_EVENT, scope: "project" }; +const CI_PITCH_GATE: Gate = { id: CI_PITCH_EVENT, scope: "project" }; export const getCiPromptConfigPath = getCliStatePath; diff --git a/packages/react-doctor/src/cli/utils/cli-lifecycle.ts b/packages/react-doctor/src/cli/utils/cli-lifecycle.ts index 7130f61bc..d76e88fb0 100644 --- a/packages/react-doctor/src/cli/utils/cli-lifecycle.ts +++ b/packages/react-doctor/src/cli/utils/cli-lifecycle.ts @@ -28,7 +28,7 @@ import { nowIso } from "./now-iso.js"; // // Scope is "global" (once per machine/user) or "project" (once per repo). -export type LifecycleScope = "global" | "project"; +type LifecycleScope = "global" | "project"; // A `scope`-bearing thing the scope helpers below can resolve. Both `Gate` and // `Migration` satisfy it. diff --git a/packages/react-doctor/src/cli/utils/cli-migrations.ts b/packages/react-doctor/src/cli/utils/cli-migrations.ts index ceaf78b06..0e924f393 100644 --- a/packages/react-doctor/src/cli/utils/cli-migrations.ts +++ b/packages/react-doctor/src/cli/utils/cli-migrations.ts @@ -94,7 +94,7 @@ const agentHooksShellToNode: Migration = { }, }; -export const PROJECT_MIGRATIONS: ReadonlyArray = [ +const PROJECT_MIGRATIONS: ReadonlyArray = [ legacyConfigToTypescript, actionPinMainToMajor, agentHooksShellToNode, diff --git a/packages/react-doctor/src/cli/utils/detect-agents.ts b/packages/react-doctor/src/cli/utils/detect-agents.ts index 6c3ab262b..e5d6a6410 100644 --- a/packages/react-doctor/src/cli/utils/detect-agents.ts +++ b/packages/react-doctor/src/cli/utils/detect-agents.ts @@ -50,7 +50,7 @@ export const detectAvailableAgents = async (): Promise => { // here. Niche tools the user merely has installed somewhere in $HOME stay // shown-but-unselected, so a machine full of AI tools doesn't get the skill // copied into a dozen project-local directories just by pressing Enter. -export const DEFAULT_INSTALL_AGENTS: readonly SkillAgentType[] = [ +const DEFAULT_INSTALL_AGENTS: readonly SkillAgentType[] = [ "claude-code", "cursor", "codex", diff --git a/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts b/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts index 233af3f94..c35f18a2d 100644 --- a/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts +++ b/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts @@ -38,7 +38,7 @@ export const buildRulePriorityMap = ( // A rule the API didn't rank sorts after a ranked one; two unranked rules // (or every rule when the score is unavailable) compare equal and keep // their original order via `toSorted`'s stability. -export const compareByRulePriority = ( +const compareByRulePriority = ( ruleKeyA: string, ruleKeyB: string, rulePriority: ReadonlyMap | undefined, diff --git a/packages/react-doctor/src/cli/utils/handoff-target-preference.ts b/packages/react-doctor/src/cli/utils/handoff-target-preference.ts index a1b25d03a..64bb05bd8 100644 --- a/packages/react-doctor/src/cli/utils/handoff-target-preference.ts +++ b/packages/react-doctor/src/cli/utils/handoff-target-preference.ts @@ -5,7 +5,7 @@ import { type Preference, readPreference, writePreference } from "./cli-lifecycl // clipboard", or "skip"). Remembered globally — the preferred handoff is a // personal habit, not a per-repo setting — so every scan defaults to whatever // the user chose last, anywhere. A new value just overwrites the old one. -export const HANDOFF_TARGET_PREFERENCE: Preference = { +const HANDOFF_TARGET_PREFERENCE: Preference = { id: HANDOFF_TARGET_PREFERENCE_ID, scope: "global", }; diff --git a/packages/react-doctor/src/cli/utils/install-agents-preference.ts b/packages/react-doctor/src/cli/utils/install-agents-preference.ts index 839fd4eb7..bdbc9b132 100644 --- a/packages/react-doctor/src/cli/utils/install-agents-preference.ts +++ b/packages/react-doctor/src/cli/utils/install-agents-preference.ts @@ -7,7 +7,7 @@ import { type Preference, readPreference, writePreference } from "./cli-lifecycl // per-repo setting — so the next install pre-selects the same picks anywhere. // Mirrors the Vercel `skills` CLI's `lastSelectedAgents` lock. The Preference // primitive stores one string, so the list is comma-encoded. -export const INSTALL_AGENTS_PREFERENCE: Preference = { +const INSTALL_AGENTS_PREFERENCE: Preference = { id: INSTALL_AGENTS_PREFERENCE_ID, scope: "global", }; diff --git a/packages/react-doctor/src/cli/utils/install-react-doctor.ts b/packages/react-doctor/src/cli/utils/install-react-doctor.ts index fa9693ec2..507728570 100644 --- a/packages/react-doctor/src/cli/utils/install-react-doctor.ts +++ b/packages/react-doctor/src/cli/utils/install-react-doctor.ts @@ -287,7 +287,7 @@ const buildDependencyFollowUp = ( return ` React Doctor still works via \`npx react-doctor\`. To install locally: ${installCommand}`; }; -export const installReactDoctorPackageSetup = async ( +const installReactDoctorPackageSetup = async ( projectRoot: string, dependencyRunner?: (input: InstallReactDoctorDependencyRunnerInput) => void | Promise, ): Promise => { diff --git a/packages/react-doctor/src/cli/utils/onboarding-state.ts b/packages/react-doctor/src/cli/utils/onboarding-state.ts index 51fe2305c..a6eed18ec 100644 --- a/packages/react-doctor/src/cli/utils/onboarding-state.ts +++ b/packages/react-doctor/src/cli/utils/onboarding-state.ts @@ -5,7 +5,7 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // per machine/user. To make the guided reveal re-appear once per repo instead, // flip `scope` to "project" (and pass `{ projectRoot }` through) — the gate // machinery supports both with no other change. -export const ONBOARDING_GATE: Gate = { id: ONBOARDING_EVENT, scope: "global" }; +const ONBOARDING_GATE: Gate = { id: ONBOARDING_EVENT, scope: "global" }; export const getOnboardingConfigPath = getCliStatePath; diff --git a/packages/react-doctor/src/cli/utils/resolve-measure-width.ts b/packages/react-doctor/src/cli/utils/resolve-measure-width.ts index f042ca2b3..5dd2e90b1 100644 --- a/packages/react-doctor/src/cli/utils/resolve-measure-width.ts +++ b/packages/react-doctor/src/cli/utils/resolve-measure-width.ts @@ -14,7 +14,7 @@ interface ResolveClampedWidthInput { // `reservedColumns`, capped at `fullWidth` and floored at `minWidth`. Returns // `fullWidth` untouched when the column count is unknown. The single source for // every terminal-aware width in the CLI renderers. -export const resolveClampedWidth = (input: ResolveClampedWidthInput): number => { +const resolveClampedWidth = (input: ResolveClampedWidthInput): number => { const terminalColumns = process.stdout.columns; if (!terminalColumns || terminalColumns <= 0) return input.fullWidth; const availableColumns = terminalColumns - input.reservedColumns; diff --git a/scripts/performance/constants.ts b/scripts/performance/constants.ts index 49f02fc3e..c8817b609 100644 --- a/scripts/performance/constants.ts +++ b/scripts/performance/constants.ts @@ -27,7 +27,7 @@ export const COMMAND_MAX_BUFFER_BYTES = 100_000_000; export const BYTES_PER_KIBIBYTE = 1_024; export const BYTES_PER_MEBIBYTE = BYTES_PER_KIBIBYTE * BYTES_PER_KIBIBYTE; export const MILLISECONDS_PER_SECOND = 1_000; -export const MICROSECONDS_PER_MILLISECOND = 1_000; +const MICROSECONDS_PER_MILLISECOND = 1_000; export const MICROSECONDS_PER_SECOND = MICROSECONDS_PER_MILLISECOND * MILLISECONDS_PER_SECOND; export const PERCENT_MULTIPLIER = 100; export const PROFILE_TOP_FRAME_COUNT = 30; From b42acf6ba39db53c917a10a00045ff5039c5d860 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 11:12:01 +0000 Subject: [PATCH 06/17] refactor: simplify package internals --- .changeset/tall-adults-refuse.md | 4 +- packages/core/src/check-dead-code.ts | 6 +- .../src/project-info/collect-project-facts.ts | 2 +- .../core/src/project-info/dependencies.ts | 2 +- .../core/src/project-info/detect-framework.ts | 66 ++ .../project-info/detect-pre-es2023-target.ts | 212 +++++++ packages/core/src/project-info/detectors.ts | 288 +-------- .../core/src/project-info/discover-project.ts | 4 +- .../project-info/is-local-module-specifier.ts | 8 + packages/core/src/run-inspect.ts | 228 +------ packages/core/src/run-oxlint.ts | 117 +--- packages/core/src/services/dead-code.ts | 8 +- packages/core/src/services/git.ts | 140 +---- packages/core/src/services/linter.ts | 7 +- packages/core/src/types/dead-code.ts | 4 + packages/core/src/types/git.ts | 127 ++++ packages/core/src/types/run-inspect.ts | 225 +++++++ packages/core/src/types/run-oxlint.ts | 116 ++++ .../tests/detect-pre-es2023-target.test.ts | 2 +- packages/deslop-cli/src/cli.ts | 57 +- .../src/utils/parse-path-mappings.ts | 33 + packages/deslop-cli/tests/cli.test.ts | 17 + packages/deslop-js/src/collect/entries.ts | 294 +-------- .../src/collect/package-json-entries.ts | 296 +++++++++ packages/deslop-js/src/collect/parse.ts | 551 ++++++++--------- packages/deslop-js/src/config.ts | 89 +++ packages/deslop-js/src/index.ts | 371 +---------- .../src/linker/build-module-link-inputs.ts | 207 +++++++ .../linker/mark-filename-registry-entries.ts | 81 +++ .../deslop-js/src/report/typescript-smells.ts | 2 +- packages/deslop-js/src/resolver/resolve.ts | 5 +- packages/deslop-js/src/summary-cache.ts | 74 ++- .../eslint-plugin-react-doctor/src/index.ts | 2 +- packages/evals/src/matrix-artifact.ts | 33 +- packages/evals/src/utils/hash-file-sha256.ts | 8 + .../src/utils/matrix-base-artifact-binding.ts | 16 +- .../evals/src/verify-matrix-baseline-cache.ts | 5 +- packages/language-server/src/server.ts | 29 +- .../src/utils/read-diagnostic-data.ts | 46 +- .../tests/unit/read-diagnostic-data.test.ts | 38 ++ .../plugin/cross-file-dependencies.test.ts | 4 +- .../src/plugin/cross-file-dependencies.ts | 1 - .../design/no-cramped-container-padding.ts | 9 +- ...params-without-suspense.cross-file.test.ts | 4 +- .../rules/performance/async-defer-await.ts | 10 +- ...in-render-or-hook-init.regressions.test.ts | 6 +- ...f-in-function-component.cross-file.test.ts | 4 +- .../no-derived-state.cross-file.test.ts | 4 +- ...-mutating-reducer-state.cross-file.test.ts | 4 +- ...-state-updater-function.cross-file.test.ts | 4 +- .../utils/build-local-dependency-graph.ts | 11 +- .../utils/collect-handler-binding-names.ts | 5 +- .../utils/is-inside-event-handler.ts | 35 +- .../utils/export-all-adds-runtime-values.ts | 10 +- .../utils/find-exported-function-body.ts | 15 +- .../plugin/utils/get-function-export-names.ts | 21 +- .../plugin/utils/get-module-specifier-name.ts | 7 + ...-jsx-owned-by-generated-image-renderers.ts | 32 +- .../utils/resolve-tsconfig-alias.test.ts | 8 +- .../plugin/utils/resolve-tsconfig-alias.ts | 2 - .../react-doctor/src/cli/commands/inspect.ts | 574 +----------------- .../src/cli/utils/build-inspect-result.ts | 68 +++ .../src/cli/utils/build-run-event.ts | 19 + .../src/cli/utils/ci/manage-ci.ts | 77 +-- .../src/cli/utils/finalize-cli-scans.ts | 130 ++++ .../src/cli/utils/finalize-inspect-result.ts | 72 +-- .../src/cli/utils/render-and-record-scan.ts | 20 +- .../src/cli/utils/run-staged-inspect.ts | 408 +++++++++++++ packages/vscode-react-doctor/src/extension.ts | 11 +- 69 files changed, 2734 insertions(+), 2661 deletions(-) create mode 100644 packages/core/src/project-info/detect-framework.ts create mode 100644 packages/core/src/project-info/detect-pre-es2023-target.ts create mode 100644 packages/core/src/project-info/is-local-module-specifier.ts create mode 100644 packages/core/src/types/dead-code.ts create mode 100644 packages/core/src/types/git.ts create mode 100644 packages/core/src/types/run-inspect.ts create mode 100644 packages/core/src/types/run-oxlint.ts create mode 100644 packages/deslop-cli/src/utils/parse-path-mappings.ts create mode 100644 packages/deslop-js/src/collect/package-json-entries.ts create mode 100644 packages/deslop-js/src/config.ts create mode 100644 packages/deslop-js/src/linker/build-module-link-inputs.ts create mode 100644 packages/deslop-js/src/linker/mark-filename-registry-entries.ts create mode 100644 packages/evals/src/utils/hash-file-sha256.ts create mode 100644 packages/language-server/tests/unit/read-diagnostic-data.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/get-module-specifier-name.ts create mode 100644 packages/react-doctor/src/cli/utils/build-inspect-result.ts create mode 100644 packages/react-doctor/src/cli/utils/finalize-cli-scans.ts create mode 100644 packages/react-doctor/src/cli/utils/run-staged-inspect.ts diff --git a/.changeset/tall-adults-refuse.md b/.changeset/tall-adults-refuse.md index 81bae6055..c3c3adb5e 100644 --- a/.changeset/tall-adults-refuse.md +++ b/.changeset/tall-adults-refuse.md @@ -1,7 +1,9 @@ --- "react-doctor": patch +"deslop-cli": patch "deslop-js": patch +"eslint-plugin-react-doctor": patch "oxlint-plugin-react-doctor": patch --- -Harden scan orchestration and cache persistence, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals. +Harden scan orchestration and cache persistence, simplify package boundaries and analyzers, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals. diff --git a/packages/core/src/check-dead-code.ts b/packages/core/src/check-dead-code.ts index 279a68c6c..e461e73b8 100644 --- a/packages/core/src/check-dead-code.ts +++ b/packages/core/src/check-dead-code.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import type { Diagnostic } from "./types/index.js"; +import type { DeadCodeSummaryCacheStats } from "./types/dead-code.js"; import { collectDeadCodePatterns } from "./dead-code/collect-dead-code-patterns.js"; import { collectAnalyzedFileStats, @@ -91,11 +92,6 @@ interface CheckDeadCodeOptions { readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStats) => void; } -interface DeadCodeSummaryCacheStats { - readonly hits: number; - readonly misses: number; -} - interface DeadCodeWorkerInput { readonly rootDirectory: string; readonly entryPatterns: ReadonlyArray; diff --git a/packages/core/src/project-info/collect-project-facts.ts b/packages/core/src/project-info/collect-project-facts.ts index 9feb8acfe..602681cf8 100644 --- a/packages/core/src/project-info/collect-project-facts.ts +++ b/packages/core/src/project-info/collect-project-facts.ts @@ -18,7 +18,7 @@ import { import { isFile } from "./fs-utils.js"; import { findMonorepoRoot } from "./monorepo-root.js"; import { readPackageJson } from "./package-json.js"; -import { frameworkMergeRank } from "./detectors.js"; +import { frameworkMergeRank } from "./detect-framework.js"; import { isPackageJsonReactNativeAware, isPackageJsonReanimatedAware } from "./rn-metadata.js"; import { isPackageJsonSsrAware } from "./ssr-metadata.js"; import { getWorkspacePatterns, resolveWorkspaceDirectories } from "./workspaces.js"; diff --git a/packages/core/src/project-info/dependencies.ts b/packages/core/src/project-info/dependencies.ts index f112c6ed6..d75d22665 100644 --- a/packages/core/src/project-info/dependencies.ts +++ b/packages/core/src/project-info/dependencies.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { DependencyInfo, PackageJson } from "../types/index.js"; -import { detectFramework } from "./detectors.js"; +import { detectFramework } from "./detect-framework.js"; import { isFile, isPlainObject } from "./fs-utils.js"; import { findMonorepoRoot } from "./monorepo-root.js"; import { readPackageJson } from "./package-json.js"; diff --git a/packages/core/src/project-info/detect-framework.ts b/packages/core/src/project-info/detect-framework.ts new file mode 100644 index 000000000..6918908a8 --- /dev/null +++ b/packages/core/src/project-info/detect-framework.ts @@ -0,0 +1,66 @@ +import type { Framework } from "../types/index.js"; + +const FRAMEWORK_PACKAGES: Record = { + next: "nextjs", + "@tanstack/react-start": "tanstack-start", + "@remix-run/react": "remix", + gatsby: "gatsby", + astro: "astro", + vite: "vite", + "react-scripts": "cra", + expo: "expo", + "react-native": "react-native", +}; + +const FRAMEWORK_DISPLAY_NAMES: Record = { + nextjs: "Next.js", + astro: "Astro", + "tanstack-start": "TanStack Start", + vite: "Vite", + cra: "Create React App", + remix: "Remix", + gatsby: "Gatsby", + expo: "Expo", + "react-native": "React Native", + preact: "Preact", + unknown: "React", +}; + +export const formatFrameworkName = (framework: Framework): string => + FRAMEWORK_DISPLAY_NAMES[framework]; + +// Preact is treated as a framework only when no React-based framework +// (`next` / `vite` / `react-scripts` / …) AND no `react` itself is +// present — i.e. a pure-Preact codebase with no bundler manifest react- +// doctor recognises. Component libraries that list both `react` and +// `preact` as peer deps stay `unknown`, which is what they were before +// this branch existed; they still pick up a non-null `preactVersion` +// (see `discover-project.ts`) so Preact-bucket rules activate without +// overwriting the framework classification. +export const detectFramework = (dependencies: Record): Framework => { + for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) { + if (dependencies[packageName]) { + return frameworkName; + } + } + if (dependencies.preact && !dependencies.react) { + return "preact"; + } + return "unknown"; +}; + +const MOBILE_FRAMEWORKS: ReadonlySet = new Set(["expo", "react-native"]); + +// The cross-workspace merge tier: a monorepo whose `apps/mobile` is Expo and +// `apps/web` is Next.js classifies by the WEB framework no matter which +// workspace the walk visits first — the same web-over-mobile priority +// `detectFramework` applies within one manifest. Web wins because it's +// coverage-maximizing: `rn-*` / Expo rules still load via +// `hasReactNativeWorkspace` / `expoVersion`, while the web framework's rules +// gate on this classification alone. Within a tier (two web apps, or two +// mobile apps) the first workspace in walk order keeps the slot; `unknown` +// never displaces anything. +export const frameworkMergeRank = (framework: Framework): number => { + if (framework === "unknown") return 3; + return MOBILE_FRAMEWORKS.has(framework) ? 2 : 1; +}; diff --git a/packages/core/src/project-info/detect-pre-es2023-target.ts b/packages/core/src/project-info/detect-pre-es2023-target.ts new file mode 100644 index 000000000..fec544505 --- /dev/null +++ b/packages/core/src/project-info/detect-pre-es2023-target.ts @@ -0,0 +1,212 @@ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import ts from "typescript"; +import { ES2023_YEAR, ES_TARGET_YEAR_BY_NAME, TSCONFIG_EXTENDS_MAX_DEPTH } from "../constants.js"; +import { isFile, isPlainObject } from "./fs-utils.js"; +import { isLocalModuleSpecifier } from "./is-local-module-specifier.js"; + +const TSCONFIG_FILENAME = "tsconfig.json"; +const FALLBACK_TSCONFIG_FILENAMES = ["tsconfig.app.json", "tsconfig.build.json"] as const; + +interface TsConfigCompilerOptions { + readonly target?: string; + readonly lib?: readonly string[]; + readonly hasExplicitLib: boolean; +} + +interface TsConfigShape { + readonly extends?: string; + readonly referencePaths: readonly string[]; + readonly compilerOptions: TsConfigCompilerOptions; +} + +const ensureJsonExtension = (filePath: string): string => + path.extname(filePath) === "" ? `${filePath}.json` : filePath; + +const resolvePackageExtendsPath = ( + extendsValue: string, + fromConfigDirectory: string, +): string | null => { + const requireFromConfig = createRequire(path.join(fromConfigDirectory, "tsconfig.json")); + const candidates = [ + extendsValue, + ensureJsonExtension(extendsValue), + `${extendsValue.replace(/\/$/, "")}/tsconfig.json`, + ]; + + for (const candidate of candidates) { + try { + return requireFromConfig.resolve(candidate); + } catch { + continue; + } + } + + return null; +}; + +const resolveExtendsPath = (extendsValue: string, fromConfigDirectory: string): string | null => { + if (isLocalModuleSpecifier(extendsValue)) { + const resolvedPath = path.resolve(fromConfigDirectory, extendsValue); + if (isFile(resolvedPath)) return resolvedPath; + const directoryConfigPath = path.join(resolvedPath, TSCONFIG_FILENAME); + return isFile(directoryConfigPath) ? directoryConfigPath : ensureJsonExtension(resolvedPath); + } + + return resolvePackageExtendsPath(extendsValue, fromConfigDirectory); +}; + +const normalizeCompilerOptions = (compilerOptions: unknown): TsConfigCompilerOptions => { + if (!isPlainObject(compilerOptions)) return { hasExplicitLib: false }; + + const target = typeof compilerOptions.target === "string" ? compilerOptions.target : undefined; + const hasExplicitLib = Object.hasOwn(compilerOptions, "lib"); + const lib = Array.isArray(compilerOptions.lib) + ? compilerOptions.lib.filter((entry): entry is string => typeof entry === "string") + : undefined; + + return { target, lib, hasExplicitLib }; +}; + +const readTsConfig = (filePath: string): TsConfigShape | null => { + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } + + const parsed = ts.parseConfigFileTextToJson(filePath, content); + if (!isPlainObject(parsed.config)) return null; + + return { + extends: typeof parsed.config.extends === "string" ? parsed.config.extends : undefined, + referencePaths: normalizeReferencePaths(parsed.config.references), + compilerOptions: normalizeCompilerOptions(parsed.config.compilerOptions), + }; +}; + +const normalizeReferencePaths = (references: unknown): string[] => { + if (!Array.isArray(references)) return []; + return references + .map((reference) => + isPlainObject(reference) && typeof reference.path === "string" ? reference.path : null, + ) + .filter((referencePath): referencePath is string => referencePath !== null); +}; + +const mergeCompilerOptions = ( + inherited: TsConfigCompilerOptions | null, + current: TsConfigCompilerOptions, +): TsConfigCompilerOptions => { + const target = current.target ?? inherited?.target; + const hasExplicitLib = current.hasExplicitLib || Boolean(inherited?.hasExplicitLib); + const lib = current.hasExplicitLib ? current.lib : inherited?.lib; + return { target, lib, hasExplicitLib }; +}; + +const readResolvedCompilerOptions = ( + tsConfigPath: string, + extendsDepth: number, + visitedPaths: ReadonlySet, +): TsConfigCompilerOptions | null => { + let realPath: string; + try { + realPath = fs.realpathSync.native(tsConfigPath); + } catch { + return null; + } + if (visitedPaths.has(realPath)) return null; + + const tsConfig = readTsConfig(realPath); + if (!tsConfig) return null; + + const nextVisitedPaths = new Set(visitedPaths); + nextVisitedPaths.add(realPath); + + if (tsConfig.extends && extendsDepth < TSCONFIG_EXTENDS_MAX_DEPTH) { + const parentPath = resolveExtendsPath(tsConfig.extends, path.dirname(realPath)); + if (parentPath && isFile(parentPath)) { + const inherited = readResolvedCompilerOptions(parentPath, extendsDepth + 1, nextVisitedPaths); + return mergeCompilerOptions(inherited, tsConfig.compilerOptions); + } + } + + return tsConfig.compilerOptions; +}; + +const targetYearIsPreES2023 = (target: string): boolean => { + const year = ES_TARGET_YEAR_BY_NAME[target.toLowerCase()]; + return year !== undefined && year < ES2023_YEAR; +}; + +const libEntryIncludesES2023Array = (entry: string): boolean => { + const normalizedEntry = entry.toLowerCase(); + if (normalizedEntry === "esnext" || normalizedEntry === "esnext.array") return true; + const esYearMatch = /^es(\d{4})(?:\.(.+))?$/.exec(normalizedEntry); + if (!esYearMatch) return false; + + const year = Number(esYearMatch[1]); + if (year < ES2023_YEAR) return false; + + const component = esYearMatch[2]; + return component === undefined || component === "array"; +}; + +const libIncludesES2023 = (lib: ReadonlyArray): boolean => + lib.some(libEntryIncludesES2023Array); + +const compilerOptionsArePreES2023 = (compilerOptions: TsConfigCompilerOptions): boolean => { + if (compilerOptions.target) { + return targetYearIsPreES2023(compilerOptions.target); + } + + if (compilerOptions.hasExplicitLib) { + return !libIncludesES2023(compilerOptions.lib ?? []); + } + + return false; +}; + +const compilerOptionsDeclareTargetOrLib = (compilerOptions: TsConfigCompilerOptions): boolean => + compilerOptions.hasExplicitLib || compilerOptions.target !== undefined; + +const detectPreES2023FromConfig = ( + tsConfigPath: string, + visitedConfigPaths: ReadonlySet = new Set(), +): boolean => { + if (visitedConfigPaths.has(tsConfigPath)) return false; + const compilerOptions = readResolvedCompilerOptions(tsConfigPath, 0, new Set()); + if (!compilerOptions) return false; + if (!compilerOptionsDeclareTargetOrLib(compilerOptions)) { + const tsConfig = readTsConfig(tsConfigPath); + if (!tsConfig) return false; + const nextVisitedConfigPaths = new Set(visitedConfigPaths); + nextVisitedConfigPaths.add(tsConfigPath); + const configDirectory = path.dirname(tsConfigPath); + return tsConfig.referencePaths.some((referencePath) => { + const resolvedReferencePath = path.resolve(configDirectory, referencePath); + const referencedConfigPath = isFile(resolvedReferencePath) + ? resolvedReferencePath + : path.join(resolvedReferencePath, TSCONFIG_FILENAME); + return ( + isFile(referencedConfigPath) && + detectPreES2023FromConfig(referencedConfigPath, nextVisitedConfigPaths) + ); + }); + } + return compilerOptionsArePreES2023(compilerOptions); +}; + +export const detectPreES2023Target = (directory: string): boolean => { + const tsConfigPath = path.join(directory, TSCONFIG_FILENAME); + if (isFile(tsConfigPath)) return detectPreES2023FromConfig(tsConfigPath); + + for (const fallbackFilename of FALLBACK_TSCONFIG_FILENAMES) { + const fallbackPath = path.join(directory, fallbackFilename); + if (isFile(fallbackPath)) return detectPreES2023FromConfig(fallbackPath); + } + + return false; +}; diff --git a/packages/core/src/project-info/detectors.ts b/packages/core/src/project-info/detectors.ts index 5c5684bda..5da0ed64a 100644 --- a/packages/core/src/project-info/detectors.ts +++ b/packages/core/src/project-info/detectors.ts @@ -3,296 +3,14 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { ResolverFactory } from "oxc-resolver"; import ts from "typescript"; -import { - ES2023_YEAR, - ES_TARGET_YEAR_BY_NAME, - REACT_COMPILER_CONFIG_IMPORT_MAX_DEPTH, - TSCONFIG_EXTENDS_MAX_DEPTH, -} from "../constants.js"; -import type { Framework, PackageJson } from "../types/index.js"; +import { REACT_COMPILER_CONFIG_IMPORT_MAX_DEPTH } from "../constants.js"; +import type { PackageJson } from "../types/index.js"; import { isProjectBoundary } from "../utils/is-project-boundary.js"; import { unwrapTypescriptExpression } from "../utils/unwrap-typescript-expression.js"; import { isFile, isPlainObject } from "./fs-utils.js"; +import { isLocalModuleSpecifier } from "./is-local-module-specifier.js"; import { readPackageJson } from "./package-json.js"; -const TSCONFIG_FILENAME = "tsconfig.json"; - -interface TsConfigCompilerOptions { - readonly target?: string; - readonly lib?: readonly string[]; - readonly hasExplicitLib: boolean; -} - -interface TsConfigShape { - readonly extends?: string; - readonly referencePaths: readonly string[]; - readonly compilerOptions: TsConfigCompilerOptions; -} - -const isLocalModuleSpecifier = (moduleSpecifier: string): boolean => - moduleSpecifier === "." || - moduleSpecifier === ".." || - moduleSpecifier.startsWith("./") || - moduleSpecifier.startsWith("../") || - path.isAbsolute(moduleSpecifier); - -const ensureJsonExtension = (filePath: string): string => - path.extname(filePath) === "" ? `${filePath}.json` : filePath; - -const resolvePackageExtendsPath = ( - extendsValue: string, - fromConfigDirectory: string, -): string | null => { - const requireFromConfig = createRequire(path.join(fromConfigDirectory, "tsconfig.json")); - const candidates = [ - extendsValue, - ensureJsonExtension(extendsValue), - `${extendsValue.replace(/\/$/, "")}/tsconfig.json`, - ]; - - for (const candidate of candidates) { - try { - return requireFromConfig.resolve(candidate); - } catch { - continue; - } - } - - return null; -}; - -const resolveExtendsPath = (extendsValue: string, fromConfigDirectory: string): string | null => { - if (isLocalModuleSpecifier(extendsValue)) { - const resolvedPath = path.resolve(fromConfigDirectory, extendsValue); - if (isFile(resolvedPath)) return resolvedPath; - const directoryConfigPath = path.join(resolvedPath, TSCONFIG_FILENAME); - return isFile(directoryConfigPath) ? directoryConfigPath : ensureJsonExtension(resolvedPath); - } - - return resolvePackageExtendsPath(extendsValue, fromConfigDirectory); -}; - -const normalizeCompilerOptions = (compilerOptions: unknown): TsConfigCompilerOptions => { - if (!isPlainObject(compilerOptions)) return { hasExplicitLib: false }; - - const target = typeof compilerOptions.target === "string" ? compilerOptions.target : undefined; - const hasExplicitLib = Object.hasOwn(compilerOptions, "lib"); - const lib = Array.isArray(compilerOptions.lib) - ? compilerOptions.lib.filter((entry): entry is string => typeof entry === "string") - : undefined; - - return { target, lib, hasExplicitLib }; -}; - -const readTsConfig = (filePath: string): TsConfigShape | null => { - let content: string; - try { - content = fs.readFileSync(filePath, "utf-8"); - } catch { - return null; - } - - const parsed = ts.parseConfigFileTextToJson(filePath, content); - if (!isPlainObject(parsed.config)) return null; - - return { - extends: typeof parsed.config.extends === "string" ? parsed.config.extends : undefined, - referencePaths: normalizeReferencePaths(parsed.config.references), - compilerOptions: normalizeCompilerOptions(parsed.config.compilerOptions), - }; -}; - -const normalizeReferencePaths = (references: unknown): string[] => { - if (!Array.isArray(references)) return []; - return references - .map((reference) => - isPlainObject(reference) && typeof reference.path === "string" ? reference.path : null, - ) - .filter((referencePath): referencePath is string => referencePath !== null); -}; - -const mergeCompilerOptions = ( - inherited: TsConfigCompilerOptions | null, - current: TsConfigCompilerOptions, -): TsConfigCompilerOptions => { - const target = current.target ?? inherited?.target; - const hasExplicitLib = current.hasExplicitLib || Boolean(inherited?.hasExplicitLib); - const lib = current.hasExplicitLib ? current.lib : inherited?.lib; - return { target, lib, hasExplicitLib }; -}; - -const readResolvedCompilerOptions = ( - tsConfigPath: string, - extendsDepth: number, - visitedPaths: ReadonlySet, -): TsConfigCompilerOptions | null => { - let realPath: string; - try { - realPath = fs.realpathSync.native(tsConfigPath); - } catch { - return null; - } - if (visitedPaths.has(realPath)) return null; - - const tsConfig = readTsConfig(realPath); - if (!tsConfig) return null; - - const nextVisitedPaths = new Set(visitedPaths); - nextVisitedPaths.add(realPath); - - if (tsConfig.extends && extendsDepth < TSCONFIG_EXTENDS_MAX_DEPTH) { - const parentPath = resolveExtendsPath(tsConfig.extends, path.dirname(realPath)); - if (parentPath && isFile(parentPath)) { - const inherited = readResolvedCompilerOptions(parentPath, extendsDepth + 1, nextVisitedPaths); - return mergeCompilerOptions(inherited, tsConfig.compilerOptions); - } - } - - return tsConfig.compilerOptions; -}; - -const targetYearIsPreES2023 = (target: string): boolean => { - const year = ES_TARGET_YEAR_BY_NAME[target.toLowerCase()]; - return year !== undefined && year < ES2023_YEAR; -}; - -const libEntryIncludesES2023Array = (entry: string): boolean => { - const normalizedEntry = entry.toLowerCase(); - if (normalizedEntry === "esnext" || normalizedEntry === "esnext.array") return true; - const esYearMatch = /^es(\d{4})(?:\.(.+))?$/.exec(normalizedEntry); - if (!esYearMatch) return false; - - const year = Number(esYearMatch[1]); - if (year < ES2023_YEAR) return false; - - const component = esYearMatch[2]; - return component === undefined || component === "array"; -}; - -const libIncludesES2023 = (lib: ReadonlyArray): boolean => - lib.some(libEntryIncludesES2023Array); - -const compilerOptionsArePreES2023 = (compilerOptions: TsConfigCompilerOptions): boolean => { - if (compilerOptions.target) { - return targetYearIsPreES2023(compilerOptions.target); - } - - if (compilerOptions.hasExplicitLib) { - return !libIncludesES2023(compilerOptions.lib ?? []); - } - - return false; -}; - -const compilerOptionsDeclareTargetOrLib = (compilerOptions: TsConfigCompilerOptions): boolean => - compilerOptions.hasExplicitLib || compilerOptions.target !== undefined; - -const detectPreES2023FromConfig = ( - tsConfigPath: string, - visitedConfigPaths: ReadonlySet = new Set(), -): boolean => { - if (visitedConfigPaths.has(tsConfigPath)) return false; - const compilerOptions = readResolvedCompilerOptions(tsConfigPath, 0, new Set()); - if (!compilerOptions) return false; - if (!compilerOptionsDeclareTargetOrLib(compilerOptions)) { - const tsConfig = readTsConfig(tsConfigPath); - if (!tsConfig) return false; - const nextVisitedConfigPaths = new Set(visitedConfigPaths); - nextVisitedConfigPaths.add(tsConfigPath); - const configDirectory = path.dirname(tsConfigPath); - return tsConfig.referencePaths.some((referencePath) => { - const resolvedReferencePath = path.resolve(configDirectory, referencePath); - const referencedConfigPath = isFile(resolvedReferencePath) - ? resolvedReferencePath - : path.join(resolvedReferencePath, TSCONFIG_FILENAME); - return ( - isFile(referencedConfigPath) && - detectPreES2023FromConfig(referencedConfigPath, nextVisitedConfigPaths) - ); - }); - } - return compilerOptionsArePreES2023(compilerOptions); -}; - -export const detectPreES2023Target = (directory: string): boolean => { - const tsConfigPath = path.join(directory, TSCONFIG_FILENAME); - if (isFile(tsConfigPath)) return detectPreES2023FromConfig(tsConfigPath); - - for (const fallbackFilename of FALLBACK_TSCONFIG_FILENAMES) { - const fallbackPath = path.join(directory, fallbackFilename); - if (isFile(fallbackPath)) return detectPreES2023FromConfig(fallbackPath); - } - - return false; -}; - -const FALLBACK_TSCONFIG_FILENAMES = ["tsconfig.app.json", "tsconfig.build.json"] as const; - -const FRAMEWORK_PACKAGES: Record = { - next: "nextjs", - "@tanstack/react-start": "tanstack-start", - "@remix-run/react": "remix", - gatsby: "gatsby", - astro: "astro", - vite: "vite", - "react-scripts": "cra", - expo: "expo", - "react-native": "react-native", -}; - -const FRAMEWORK_DISPLAY_NAMES: Record = { - nextjs: "Next.js", - astro: "Astro", - "tanstack-start": "TanStack Start", - vite: "Vite", - cra: "Create React App", - remix: "Remix", - gatsby: "Gatsby", - expo: "Expo", - "react-native": "React Native", - preact: "Preact", - unknown: "React", -}; - -export const formatFrameworkName = (framework: Framework): string => - FRAMEWORK_DISPLAY_NAMES[framework]; - -// Preact is treated as a framework only when no React-based framework -// (`next` / `vite` / `react-scripts` / …) AND no `react` itself is -// present — i.e. a pure-Preact codebase with no bundler manifest react- -// doctor recognises. Component libraries that list both `react` and -// `preact` as peer deps stay `unknown`, which is what they were before -// this branch existed; they still pick up a non-null `preactVersion` -// (see `discover-project.ts`) so Preact-bucket rules activate without -// overwriting the framework classification. -export const detectFramework = (dependencies: Record): Framework => { - for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) { - if (dependencies[packageName]) { - return frameworkName; - } - } - if (dependencies.preact && !dependencies.react) { - return "preact"; - } - return "unknown"; -}; - -const MOBILE_FRAMEWORKS: ReadonlySet = new Set(["expo", "react-native"]); - -// The cross-workspace merge tier: a monorepo whose `apps/mobile` is Expo and -// `apps/web` is Next.js classifies by the WEB framework no matter which -// workspace the walk visits first — the same web-over-mobile priority -// `detectFramework` applies within one manifest. Web wins because it's -// coverage-maximizing: `rn-*` / Expo rules still load via -// `hasReactNativeWorkspace` / `expoVersion`, while the web framework's rules -// gate on this classification alone. Within a tier (two web apps, or two -// mobile apps) the first workspace in walk order keeps the slot; `unknown` -// never displaces anything. -export const frameworkMergeRank = (framework: Framework): number => { - if (framework === "unknown") return 3; - return MOBILE_FRAMEWORKS.has(framework) ? 2 : 1; -}; - const REACT_COMPILER_LINT_PACKAGES = new Set(["eslint-plugin-react-compiler"]); const REACT_COMPILER_RUNTIME_PACKAGES = new Set(["react-compiler-runtime"]); diff --git a/packages/core/src/project-info/discover-project.ts b/packages/core/src/project-info/discover-project.ts index 9df69cc3a..c0999b6fc 100644 --- a/packages/core/src/project-info/discover-project.ts +++ b/packages/core/src/project-info/discover-project.ts @@ -6,10 +6,10 @@ import { isFile } from "./fs-utils.js"; import { countSourceFiles } from "./count-source-files.js"; import { detectNextjsStaticExport, - detectPreES2023Target, detectReactCompiler, detectReactCompilerLintPlugin, } from "./detectors.js"; +import { detectPreES2023Target } from "./detect-pre-es2023-target.js"; import { extractDependencyInfo, getDependencyDeclaration, @@ -40,7 +40,7 @@ import { import { clearTargetBlankOpenerProtectionCache } from "./detect-target-blank-opener-protection.js"; export { discoverReactSubprojects } from "./discover-react-subprojects.js"; -export { formatFrameworkName } from "./detectors.js"; +export { formatFrameworkName } from "./detect-framework.js"; export { listWorkspacePackages } from "./workspaces.js"; const cachedProjectInfos = new Map(); diff --git a/packages/core/src/project-info/is-local-module-specifier.ts b/packages/core/src/project-info/is-local-module-specifier.ts new file mode 100644 index 000000000..ea68ba79c --- /dev/null +++ b/packages/core/src/project-info/is-local-module-specifier.ts @@ -0,0 +1,8 @@ +import * as path from "node:path"; + +export const isLocalModuleSpecifier = (moduleSpecifier: string): boolean => + moduleSpecifier === "." || + moduleSpecifier === ".." || + moduleSpecifier.startsWith("./") || + moduleSpecifier.startsWith("../") || + path.isAbsolute(moduleSpecifier); diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index b54928a48..b726d5fb2 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -5,14 +5,7 @@ import * as Filter from "effect/Filter"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import type { - Diagnostic, - DiagnosticSurface, - ProjectInfo, - ReactDoctorConfig, - ScoreResult, - SuppressedRuleCount, -} from "./types/index.js"; +import type { Diagnostic, DiagnosticSurface } from "./types/index.js"; import { assignFixGroups } from "./utils/assign-fix-groups.js"; import { dedupeRelatedDiagnostics } from "./utils/dedupe-related-diagnostics.js"; import { isPathInsideDirectory } from "./utils/is-path-inside-directory.js"; @@ -70,217 +63,8 @@ import { resolveGithubActionsScoreMetadata } from "./utils/resolve-github-action import { resolveScanConcurrency } from "./utils/resolve-scan-concurrency.js"; import { toNormalizedRelativePath } from "./utils/to-normalized-relative-path.js"; -export interface InspectInput { - readonly directory: string; - readonly precomputedSourceFileCount?: number; - readonly includePaths: ReadonlyArray; - readonly customRulesOnly: boolean; - readonly respectInlineDisables: boolean; - /** - * Per-call override for `ReactDoctorConfig.warnings`. When omitted, - * the loaded config's `warnings` value wins (defaulting to `true`), - * so warnings surface unless the user opts out via `--no-warnings` or - * `warnings: false`. - */ - readonly warnings?: boolean; - readonly adoptExistingLintConfig: boolean; - readonly ignoredTags: ReadonlySet; - readonly includedTags?: ReadonlySet; - readonly includeTagDefaults?: boolean; - readonly nodeBinaryPath?: string; - /** Whether dead-code analysis runs. Gated also on `!isDiffMode`. */ - readonly runDeadCode: boolean; - /** Marks the run as CI-originated for the Score API. */ - readonly isCi: boolean; - /** react-doctor release version sent with score requests. */ - readonly doctorVersion?: string; - /** Random per-run id. */ - readonly runId?: string; - /** Enables best-effort authenticated local GitHub permission lookup for score metadata. */ - readonly resolveLocalGithubViewerPermission?: boolean; - /** - * Diagnostic surface fed to the Score service. Defaults to `"score"`, - * which excludes weak-signal rule families (e.g. `design`-tagged) from - * the score so they can't dilute the headline number. Public-API shells - * (`inspect()` / `diagnose()`) leave this at the default; pass `"cli"` - * (or any other surface) to score against an unfiltered diagnostic set. - * - * The returned `InspectOutput.diagnostics` is always the full - * per-element-filtered list — surface filtering only affects scoring. - */ - readonly scoreSurface?: DiagnosticSurface; - /** - * Suppresses the orchestrator's own persistent "Scanned N files" - * success line. The live scan spinner still runs for feedback but - * clears on completion instead of leaving a status line behind. The - * CLI sets this when scanning multiple projects so it can render a - * single aggregate "Scanned N files" line in their place — the - * per-project file count + scan duration are surfaced on - * `InspectOutput` for that summary. Lint / dead-code failures still - * surface their own spinner state regardless of this flag. - */ - readonly suppressScanSummary?: boolean; - /** - * When `true`, `includePaths` is linted verbatim instead of being filtered - * to React Doctor's supported source-file set. Editor scans use this for the - * exact buffer supplied by the language server. - */ - readonly skipExplicitIncludePathFilter?: boolean; - /** - * Whether the scanned project's `package.json` is among the changed files - * in a diff / staged scan. Dependency health is a whole-project property - * (read from `package.json`, not the changed source files), so the - * supply-chain check is normally skipped in diff mode — but a PR that edits - * `package.json` should still have its dependencies scored. When `true`, - * the supply-chain pass runs even in diff mode. Ignored on full scans - * (those always run it). Defaults to `false`. - */ - readonly supplyChainManifestChanged?: boolean; - /** - * Absolute epoch-millisecond deadline for the scan (the CLI's - * `--max-duration` budget resolved against the scan start). Past it the - * scan degrades gracefully: un-started lint batches are skipped (surfaced - * via `skippedCheckReasons["lint:partial"]` with the file list) and the - * dead-code phase is skipped or capped to the remaining budget. - */ - readonly deadlineEpochMs?: number; - readonly signal?: AbortSignal; - /** Descendant project roots covered by sibling scans in a workspace batch. */ - readonly excludedProjectDirectories?: ReadonlyArray; - /** Keep descendant dead-code findings when this scan owns the workspace-wide pass. */ - readonly retainExcludedProjectDeadCodeDiagnostics?: boolean; -} - -export interface InspectOutput { - readonly project: ProjectInfo; - readonly userConfig: ReactDoctorConfig | null; - readonly resolvedDirectory: string; - readonly diagnostics: ReadonlyArray; - readonly score: ScoreResult | null; - readonly scoreMetadata: ScoreRequestMetadata; - readonly didLintFail: boolean; - readonly lintFailureReason: string | null; - /** - * The `_tag` of `error.reason` when the lint stream raised a - * `ReactDoctorError`, or `null` otherwise. Lets renderers dispatch - * on the typed reason without `error.message.includes(...)` style - * sniffs (e.g. show the "upgrade Node" hint only on - * `OxlintUnavailable` with `kind: "native-binding-missing"`). - */ - readonly lintFailureReasonTag: ReactDoctorErrorReason["_tag"] | null; - /** - * The `kind` of an `OxlintUnavailable` lint failure - * (`binary-not-found` / `native-binding-missing`), or `null` for any - * other failure. Lets renderers show the "upgrade Node" hint by - * dispatching on structured data instead of matching message text. - */ - readonly lintFailureReasonKind: OxlintUnavailable["kind"] | null; - readonly lintPartialFailures: ReadonlyArray; - /** `false` when run-dead-code was disabled, diff/staged mode, or analysis crashed. */ - readonly didDeadCodeFail: boolean; - readonly deadCodeFailureReason: string | null; - /** - * Whether the dead-code pass actually ran concurrently with lint this scan. - * Only `REACT_DOCTOR_DEAD_CODE_OVERLAP=on` overlaps; the default `"auto"` - * and explicit `"off"` paths stay sequential. Internal telemetry only - * (rides the per-scan wide event); NOT part of the public `inspect()` - * `InspectResult`. - */ - readonly deadCodeOverlapped: boolean; - /** - * Number of files the scan reported (lint progress total, falling - * back to the project source-file count). Surfaced so a caller that - * sets `suppressScanSummary` can render its own aggregate - * "Scanned N files" line. - */ - readonly scannedFileCount: number; - /** - * Absolute paths of every file this scan considered. Used by the - * multi-project summary to count UNIQUE files across projects: - * nested workspace packages (a parent whose tree contains a child - * package) would otherwise double-count the shared files when their - * per-project counts are summed. - */ - readonly scannedFilePaths: ReadonlyArray; - /** Project-relative POSIX paths the lint pass completed successfully. */ - readonly analyzedFiles: ReadonlyArray; - /** Wall-clock duration of the scan phase, in milliseconds. */ - readonly scanElapsedMilliseconds: number; - /** - * Resolved lint worker count the linter actually fanned out to (the - * `OxlintConcurrency` Reference read through the spawn-boundary clamp). - * Surfaced so CLI telemetry reports the real worker count on the auto - * path, where the caller's `concurrency` option is `undefined`. - */ - readonly scanConcurrency: number; - /** - * `true` when the background supply-chain fiber hit its overlap budget - * (`SupplyChainOverlapTimeoutMs`) and failed open to no diagnostics — a - * rare hung-socket guard, surfaced for telemetry and skipped-check - * accounting. `false` on the healthy path and whenever supply-chain was - * skipped (diff/staged scans). - */ - readonly supplyChainOverlapTimedOut: boolean; - /** - * `true` when the forked security scan failed or reached the shared deadline. - * Filesystem failures fail open to no diagnostics; deadline truncation keeps - * findings collected before time elapsed. Surfaced for telemetry and - * skipped-check accounting so an incomplete pass is distinguishable from a - * clean one with zero findings. `false` on the healthy path and when the pass - * was skipped (diff/staged scans). - */ - readonly securityScanFailed: boolean; - readonly securityScanFailureReason: string | null; - /** - * Per-file lint cache outcome for the lint pass: files served from cache and - * total files considered. Both `null` when the cache was disabled or bypassed - * (audit mode, adopted `extends`, user plugins) so the run never split. Fed - * to the Sentry wide event as `lint.cacheHitRatio`. - */ - readonly lintCacheHitFileCount: number | null; - readonly lintCacheTotalFileCount: number | null; - /** - * Sidecar lint cache outcome for the lint pass: cache-hit files whose - * cross-file diagnostics replayed from the sidecar store, and the hits - * considered. Both `null` when the sidecar cache was disabled or bypassed - * (per-file cache off, `REACT_DOCTOR_NO_SIDECAR_CACHE`, no bounded - * cross-file rule enabled). Fed to the Sentry wide event as - * `lint.sidecarReplayRatio`. - */ - readonly lintSidecarReplayedFileCount: number | null; - readonly lintSidecarTotalFileCount: number | null; - /** - * Dead-code result cache outcome for this scan's dead-code pass: `true` - * when the cached result was replayed (the analysis worker never spawned), - * `false` on a miss (fresh analysis). `null` when the pass never consulted - * the cache — dead-code skipped/disabled, the cache off - * (`REACT_DOCTOR_NO_CACHE` / `REACT_DOCTOR_NO_DEAD_CODE_CACHE`), or the - * pass discarded by a lint failure. Fed to the Sentry wide event as - * `deadCode.cacheHit`. - */ - readonly deadCodeCacheHit: boolean | null; - /** - * deslop's incremental summary-cache outcome for this scan's dead-code - * ANALYSIS: collected files served from cached parse summaries vs freshly - * parsed. Both `null` whenever no analysis consulted the incremental store — - * a whole-result cache hit (no analysis ran), the cache off, dead-code - * skipped/disabled, or the pass discarded by a lint failure. Fed to the - * Sentry wide event as `deadCode.summaryCacheHits` / - * `deadCode.summaryCacheMisses`. - */ - readonly deadCodeSummaryCacheHits: number | null; - readonly deadCodeSummaryCacheMisses: number | null; - /** - * Per-rule tallies of diagnostics the pipeline dropped because the user - * explicitly silenced the rule (config off switches, per-path overrides, - * inline disable comments) — see `DiagnosticPipeline.summarizeSuppressions`. - * Telemetry-only; NOT part of the public `inspect()` `InspectResult`. Note - * that a `rules: "off"` lint rule is removed from the generated oxlint - * config upstream and never fires, so its findings can't be counted here — - * the CLI's scan-level `rule.disabled` counter covers that case. - */ - readonly suppressedRuleCounts: ReadonlyArray; -} +export type { InspectHooks, InspectInput, InspectOutput } from "./types/run-inspect.js"; +import type { InspectHooks, InspectInput, InspectOutput } from "./types/run-inspect.js"; /** * The settled result of the background supply-chain fiber: its collected @@ -292,12 +76,6 @@ interface SupplyChainForkResult { readonly timedOut: boolean; } -/** Hooks the caller participates in without owning the orchestration. */ -export interface InspectHooks { - readonly beforeLint?: (project: ProjectInfo) => Effect.Effect; - readonly afterLint?: (didFail: boolean) => Effect.Effect; -} - const NO_HOOKS: Required> = { beforeLint: () => Effect.void, afterLint: () => Effect.void, diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts index e7a28b0af..8fe9d6e31 100644 --- a/packages/core/src/run-oxlint.ts +++ b/packages/core/src/run-oxlint.ts @@ -7,7 +7,7 @@ import { collectCrossFileDependencyProbes, resetManifestCaches, } from "oxlint-plugin-react-doctor/core"; -import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js"; +import type { Diagnostic } from "./types/index.js"; import { batchIncludePaths } from "./batch-include-paths.js"; import { COOPERATIVE_YIELD_BUDGET_MS } from "./constants.js"; import { buildRuleSeverityControls } from "./build-rule-severity-controls.js"; @@ -28,7 +28,6 @@ import type { SidecarDependencyProbe, SidecarLintCache, } from "./runners/oxlint/sidecar-lint-cache.js"; -import type { WorkerSlots } from "./utils/create-worker-slots.js"; import { resolveUserPlugins } from "./runners/oxlint/plugin-resolution.js"; import { resolveOxlintToolchainVersions } from "./runners/oxlint/resolve-toolchain-versions.js"; import { @@ -47,118 +46,8 @@ import { prepareLintSources } from "./utils/prepare-lint-sources.js"; import { resolveReactDoctorCacheDir } from "./utils/resolve-react-doctor-cache-dir.js"; import { yieldToEventLoop } from "./utils/yield-to-event-loop.js"; -interface RunOxlintOptions { - rootDirectory: string; - project: ProjectInfo; - includePaths?: string[]; - nodeBinaryPath?: string; - customRulesOnly?: boolean; - respectInlineDisables?: boolean; - adoptExistingLintConfig?: boolean; - ignoredTags?: ReadonlySet; - includedTags?: ReadonlySet; - includeTagDefaults?: boolean; - /** - * Optional react-doctor user config (already-loaded - * `react-doctor.config.json` or `package.json#reactDoctor`). When - * provided, project-level knobs the rule surface honors — - * currently `serverAuthFunctionNames` — are forwarded to the - * generated oxlint settings so plugin rules can read them via - * `context.settings`. `userConfig.plugins` resolves through - * `configSourceDirectory` (or `rootDirectory` as the fallback). - */ - userConfig?: ReactDoctorConfig | null; - /** - * Directory of the `react-doctor.config.json` (or `package.json`) - * that supplied `userConfig`. Used as the resolution base for - * `userConfig.plugins` entries — relative paths resolve against - * this directory and npm package names resolve through its - * `node_modules`, matching how `rootDir` resolves. Diverges from - * `rootDirectory` whenever `userConfig.rootDir` redirects the scan. - * - * Defaults to `rootDirectory` for direct callers that don't load - * a config file. - */ - configSourceDirectory?: string; - /** - * Called once per soft-fail event (e.g. a batch hit - * `OXLINT_SPAWN_TIMEOUT_MS` and was skipped). The lint scan keeps - * going on remaining batches; the caller is expected to surface - * the warning to the user (via `skippedCheckReasons` in JSON - * mode, or a logger message in human mode). - */ - onPartialFailure?: (reason: string) => void; - onFileCoverage?: (coverage: RunOxlintFileCoverage) => void; - onFileProgress?: (scannedFileCount: number, totalFileCount: number) => void; - /** - * Enables the per-file lint cache, resolved from the - * `PerFileLintCacheEnabled` Reference. When on (and the scan is eligible — - * no audit mode, no adopted `extends`, no user plugins), unchanged files - * replay their cached cacheable-rule diagnostics and only changed files are - * re-linted; the cross-file rules always run fresh on every file (in the - * misses' full pass, and in a sidecar pass over the cache hits). - */ - perFileLintCacheEnabled?: boolean; - /** - * Enables the sidecar lint cache, resolved from the - * `SidecarLintCacheEnabled` Reference. When on (and the per-file cache is - * active), each cache-hit file's cross-file diagnostics replay from the - * sidecar store as long as the file's recorded dependency probes still - * match the tree; only mismatching files re-lint. Off → every cache hit - * runs the always-fresh sidecar pass (the pre-cache behavior). - */ - sidecarLintCacheEnabled?: boolean; - /** - * Called once after the cache split with `(cacheHitFileCount, - * totalConsideredFileCount)`. Surfaced to the Sentry wide event as - * `lintCacheHitRatio`. Not invoked when the cache is disabled or bypassed. - */ - onCacheStats?: (cacheHitFileCount: number, totalConsideredFileCount: number) => void; - /** - * Called once with `(sidecarReplayedFileCount, sidecarConsideredFileCount)` - * — how many cache-hit files replayed their cross-file diagnostics from - * the sidecar store vs. the hits considered. Surfaced to the Sentry wide - * event as `lint.sidecarReplayRatio`. Not invoked when the sidecar cache - * is disabled or bypassed. - */ - onSidecarStats?: (sidecarReplayedFileCount: number, sidecarConsideredFileCount: number) => void; - /** Per-batch wall-clock budget, resolved from the `OxlintSpawnTimeoutMs` Reference. */ - spawnTimeoutMs?: number; - /** Per-batch stdout+stderr byte cap, resolved from the `OxlintOutputMaxBytes` Reference. */ - outputMaxBytes?: number; - /** - * Number of oxlint subprocesses to run in parallel, resolved from the - * `OxlintConcurrency` Reference (which itself defaults to parallel — - * auto-detected cores). Omitting it here uses the low-level serial - * default; the orchestrated path always threads the Reference value - * through. A parallel pass auto-falls-back to serial on resource - * exhaustion (see `spawnLintBatches`). - */ - concurrency?: number; - spawnSlots?: WorkerSlots; - /** - * Aborted when the orchestrator's lint-phase timeout fires; forwarded to - * `spawnLintBatches` so in-flight oxlint subprocesses are torn down instead - * of running on after the phase is abandoned. - */ - signal?: AbortSignal; - /** See `SpawnLintBatchesInput.deadlineEpochMs`. */ - deadlineEpochMs?: number; - /** - * Full-scan batch planning, resolved from the `LintBatchOrdering` - * Reference. `"cost"` (the default) plans size-balanced LPT batches via - * `planLintBatches`; `"arrival"` is the rollback hatch to the plain greedy - * fixed-size chunking in discovery order. Only affects the full-scan branch - * (`includePaths` undefined) — diff / staged scans pass explicit paths and - * are untouched. - */ - lintBatchOrdering?: "cost" | "arrival"; -} - -export interface RunOxlintFileCoverage { - readonly candidateFiles: ReadonlyArray; - readonly analyzedFiles: ReadonlyArray; -} +export type { LintFileCoverage as RunOxlintFileCoverage } from "./types/run-oxlint.js"; +import type { RunOxlintOptions } from "./types/run-oxlint.js"; /** * Atomically (re)writes the generated oxlintrc.json. Used twice in diff --git a/packages/core/src/services/dead-code.ts b/packages/core/src/services/dead-code.ts index 0ab7fe31b..2c956a50d 100644 --- a/packages/core/src/services/dead-code.ts +++ b/packages/core/src/services/dead-code.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import type { Diagnostic } from "../types/index.js"; +import type { DeadCodeSummaryCacheStats } from "../types/dead-code.js"; import { checkDeadCode } from "../check-dead-code.js"; import { DeadCodeAnalysisFailed, ReactDoctorError } from "../errors.js"; import { DeadCodeResultCacheEnabled } from "../refs.js"; @@ -33,12 +34,7 @@ interface DeadCodeInput { * parsed file counts) when the analysis ran with the incremental store. * Not invoked on a whole-result cache hit or when caching is off. */ - readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStatsInput) => void; -} - -interface DeadCodeSummaryCacheStatsInput { - readonly hits: number; - readonly misses: number; + readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStats) => void; } /** diff --git a/packages/core/src/services/git.ts b/packages/core/src/services/git.ts index 22c8f1571..4772dd364 100644 --- a/packages/core/src/services/git.ts +++ b/packages/core/src/services/git.ts @@ -25,25 +25,20 @@ import { import { parseChangedLineRanges } from "../parse-changed-line-ranges.js"; import { isDirectory } from "../project-info/fs-utils.js"; import type { ChangedFileLineRanges } from "../types/index.js"; - -interface GitInvocationResult { - readonly status: number; - readonly stdout: string; - readonly stderr: string; -} - -interface CommandInvocationInput { - readonly command: string; - readonly args: ReadonlyArray; - readonly directory: string; - readonly env?: Record; - /** - * Hard cap on stdout bytes. When set, the command fails with a - * `GitInvocationFailed` once the streamed output crosses the budget - * instead of buffering the whole payload into memory. - */ - readonly maxStdoutBytes?: number; -} +export type { GitBaselineDiffPlan, GitDiffSelection } from "../types/git.js"; +import type { + CommandInvocationInput, + GitBaselineDiffPlan, + GitChangedLineRangesInput, + GitDiffRange, + GitDiffSelection, + GitDiffSelectionInput, + GitGrepInput, + GitGrepResult, + GitInvocationResult, + GitLayerSnapshot, + GitShowOptions, +} from "../types/git.js"; const trimOrNull = (value: string): string | null => { const trimmed = value.trim(); @@ -91,19 +86,6 @@ const resolveSpawnArgsLengthCap = (): number => { return SPAWN_ARGS_MAX_LENGTH_CHARS_POSIX; }; -interface GitDiffRange { - /** Left endpoint (before the operator); empty string defaults to `HEAD`. */ - readonly base: string; - /** Right endpoint (after the operator); empty string defaults to `HEAD`. */ - readonly head: string; - /** - * `true` for three-dot `A...B` (diff from the merge-base of A and B to - * B), `false` for two-dot `A..B` (diff A directly against B). Mirrors - * git's own `diff` range semantics. - */ - readonly symmetric: boolean; -} - /** * Splits a git revision range into its endpoints: three-dot `A...B` * (symmetric, merge-base) or two-dot `A..B` (direct). Returns `null` @@ -162,12 +144,6 @@ const parseGithubViewerPermission = (stdout: string): string | null => { const splitNullSeparated = (value: string): ReadonlyArray => value.split("\0").filter((entry) => entry.length > 0); -export interface GitBaselineDiffPlan { - readonly baseFiles: ReadonlyArray; - readonly headFiles: ReadonlyArray; - readonly untrackedFiles: ReadonlyArray; -} - const parseBaselineDiffPlan = (value: string): GitBaselineDiffPlan | null => { const entries = splitNullSeparated(value); const baseFiles = new Set(); @@ -198,75 +174,6 @@ const parseBaselineDiffPlan = (value: string): GitBaselineDiffPlan | null => { // every line as changed by spanning the whole file (1 → last possible line). const UNTRACKED_FILE_LAST_LINE = Number.MAX_SAFE_INTEGER; -export interface GitDiffSelection { - /** - * `null` when `HEAD` is detached (e.g. GitHub Actions - * `pull_request` runs that check out `refs/pull/N/merge`). - */ - readonly currentBranch: string | null; - readonly baseBranch: string; - /** - * The commit the changed-file diff was actually computed against — for - * two-dot `A..B` it's `A`, for three-dot `A...B` and the single-base path - * it's the merge-base. Baseline reads base content from here so the file set - * and the base snapshot agree (two-dot must NOT be merge-based with HEAD). - * Absent for uncommitted (`isCurrentChanges`) selections. - */ - readonly diffBaseRef?: string; - readonly changedFiles: ReadonlyArray; - readonly isCurrentChanges: boolean; -} - -interface GitDiffSelectionInput { - readonly directory: string; - readonly explicitBaseBranch?: string; - /** - * Fold ordinary untracked files (`git ls-files --others`, minus ignored - * ones) into the working-tree selection. Off by default — opt in via the - * CLI `--include-untracked` flag. Never applies to an explicit `A..B` range. - */ - readonly includeUntracked?: boolean; -} - -interface GitShowOptions { - /** - * Hard limit on the bytes `git show :` may stream before the - * read fails (so the caller skips the file rather than buffering it - * whole). Enforced by `runCommand` via a streaming byte counter. - */ - readonly maxBufferBytes?: number; -} - -interface GitGrepInput { - readonly directory: string; - readonly pattern: string; - readonly extendedRegexp?: boolean; - readonly listMatchingFiles?: boolean; - readonly includeUntracked?: boolean; - readonly includePaths?: ReadonlyArray; - readonly maxBufferBytes?: number; -} - -interface GitGrepResult { - readonly status: number; - readonly stdout: string; -} - -interface GitChangedLineRangesInput { - readonly directory: string; - /** Ref to diff against; omit for working-tree / index diffs. */ - readonly baseRef?: string; - /** When `true`, diff the index (`--cached`) instead of the working tree. */ - readonly cached?: boolean; - /** Files to limit the diff to (relative to `directory`). */ - readonly files: ReadonlyArray; - /** - * When `true`, treat any of `files` that is an ordinary untracked file as - * fully changed (every line new). Off by default; ignored when `cached`. - */ - readonly includeUntracked?: boolean; -} - /** * `Git` wraps every `git`-via-subprocess call react-doctor makes * behind a `Context.Service`. The production layer (`layerNode`) @@ -1044,24 +951,7 @@ export class Git extends Context.Service< * resolve to safe defaults (current branch null, no staged files, * grep returns null = "git unavailable, fall back"). */ - static readonly layerOf = (snapshot: { - readonly currentBranch?: string | null; - readonly defaultBranch?: string | null; - readonly headSha?: string | null; - readonly githubRepo?: string | null; - readonly githubViewerPermission?: string | null; - readonly branchExists?: ReadonlyMap; - /** Keyed by the `ref` argument; value is the resolved merge-base SHA. */ - readonly mergeBase?: ReadonlyMap; - readonly baselineDiffPlan?: GitBaselineDiffPlan | null; - readonly stagedFiles?: ReadonlyArray; - readonly stagedContent?: ReadonlyMap; - /** Keyed by `:`. */ - readonly refContent?: ReadonlyMap; - readonly diffSelection?: GitDiffSelection | null; - readonly grepMatches?: ReadonlyArray | null; - readonly changedLineRanges?: ReadonlyArray; - }): Layer.Layer => + static readonly layerOf = (snapshot: GitLayerSnapshot): Layer.Layer => Layer.succeed( Git, Git.of({ diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index 6f75912ac..4e9255896 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "../types/index.js"; +export type { LintFileCoverage } from "../types/run-oxlint.js"; +import type { LintFileCoverage } from "../types/run-oxlint.js"; import { OxlintSpawnFailed, ReactDoctorError } from "../errors.js"; import { LintBatchOrdering, @@ -60,11 +62,6 @@ export interface LintInput { readonly deadlineEpochMs?: number; } -export interface LintFileCoverage { - readonly candidateFiles: ReadonlyArray; - readonly analyzedFiles: ReadonlyArray; -} - /** * runOxlint already raises tagged errors (PR 2). Narrow whatever * `tryPromise` caught: tagged errors pass through unchanged, diff --git a/packages/core/src/types/dead-code.ts b/packages/core/src/types/dead-code.ts new file mode 100644 index 000000000..699f30824 --- /dev/null +++ b/packages/core/src/types/dead-code.ts @@ -0,0 +1,4 @@ +export interface DeadCodeSummaryCacheStats { + readonly hits: number; + readonly misses: number; +} diff --git a/packages/core/src/types/git.ts b/packages/core/src/types/git.ts new file mode 100644 index 000000000..1ec3e6e72 --- /dev/null +++ b/packages/core/src/types/git.ts @@ -0,0 +1,127 @@ +import type { ChangedFileLineRanges } from "./inspect.js"; + +export interface GitLayerSnapshot { + readonly currentBranch?: string | null; + readonly defaultBranch?: string | null; + readonly headSha?: string | null; + readonly githubRepo?: string | null; + readonly githubViewerPermission?: string | null; + readonly branchExists?: ReadonlyMap; + /** Keyed by the `ref` argument; value is the resolved merge-base SHA. */ + readonly mergeBase?: ReadonlyMap; + readonly baselineDiffPlan?: GitBaselineDiffPlan | null; + readonly stagedFiles?: ReadonlyArray; + readonly stagedContent?: ReadonlyMap; + /** Keyed by `:`. */ + readonly refContent?: ReadonlyMap; + readonly diffSelection?: GitDiffSelection | null; + readonly grepMatches?: ReadonlyArray | null; + readonly changedLineRanges?: ReadonlyArray; +} + +export interface GitInvocationResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface CommandInvocationInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly directory: string; + readonly env?: Record; + /** + * Hard cap on stdout bytes. When set, the command fails with a + * `GitInvocationFailed` once the streamed output crosses the budget + * instead of buffering the whole payload into memory. + */ + readonly maxStdoutBytes?: number; +} + +export interface GitDiffRange { + /** Left endpoint (before the operator); empty string defaults to `HEAD`. */ + readonly base: string; + /** Right endpoint (after the operator); empty string defaults to `HEAD`. */ + readonly head: string; + /** + * `true` for three-dot `A...B` (diff from the merge-base of A and B to + * B), `false` for two-dot `A..B` (diff A directly against B). Mirrors + * git's own `diff` range semantics. + */ + readonly symmetric: boolean; +} + +export interface GitBaselineDiffPlan { + readonly baseFiles: ReadonlyArray; + readonly headFiles: ReadonlyArray; + readonly untrackedFiles: ReadonlyArray; +} + +export interface GitDiffSelection { + /** + * `null` when `HEAD` is detached (e.g. GitHub Actions + * `pull_request` runs that check out `refs/pull/N/merge`). + */ + readonly currentBranch: string | null; + readonly baseBranch: string; + /** + * The commit the changed-file diff was actually computed against — for + * two-dot `A..B` it's `A`, for three-dot `A...B` and the single-base path + * it's the merge-base. Baseline reads base content from here so the file set + * and the base snapshot agree (two-dot must NOT be merge-based with HEAD). + * Absent for uncommitted (`isCurrentChanges`) selections. + */ + readonly diffBaseRef?: string; + readonly changedFiles: ReadonlyArray; + readonly isCurrentChanges: boolean; +} + +export interface GitDiffSelectionInput { + readonly directory: string; + readonly explicitBaseBranch?: string; + /** + * Fold ordinary untracked files (`git ls-files --others`, minus ignored + * ones) into the working-tree selection. Off by default — opt in via the + * CLI `--include-untracked` flag. Never applies to an explicit `A..B` range. + */ + readonly includeUntracked?: boolean; +} + +export interface GitShowOptions { + /** + * Hard limit on the bytes `git show :` may stream before the + * read fails (so the caller skips the file rather than buffering it + * whole). Enforced by `runCommand` via a streaming byte counter. + */ + readonly maxBufferBytes?: number; +} + +export interface GitGrepInput { + readonly directory: string; + readonly pattern: string; + readonly extendedRegexp?: boolean; + readonly listMatchingFiles?: boolean; + readonly includeUntracked?: boolean; + readonly includePaths?: ReadonlyArray; + readonly maxBufferBytes?: number; +} + +export interface GitGrepResult { + readonly status: number; + readonly stdout: string; +} + +export interface GitChangedLineRangesInput { + readonly directory: string; + /** Ref to diff against; omit for working-tree / index diffs. */ + readonly baseRef?: string; + /** When `true`, diff the index (`--cached`) instead of the working tree. */ + readonly cached?: boolean; + /** Files to limit the diff to (relative to `directory`). */ + readonly files: ReadonlyArray; + /** + * When `true`, treat any of `files` that is an ordinary untracked file as + * fully changed (every line new). Off by default; ignored when `cached`. + */ + readonly includeUntracked?: boolean; +} diff --git a/packages/core/src/types/run-inspect.ts b/packages/core/src/types/run-inspect.ts new file mode 100644 index 000000000..ad3402d10 --- /dev/null +++ b/packages/core/src/types/run-inspect.ts @@ -0,0 +1,225 @@ +import * as Effect from "effect/Effect"; +import type { ScoreRequestMetadata } from "../calculate-score.js"; +import type { OxlintUnavailable, ReactDoctorErrorReason } from "../errors.js"; +import type { DiagnosticSurface, ReactDoctorConfig } from "./config.js"; +import type { Diagnostic, SuppressedRuleCount } from "./diagnostic.js"; +import type { ProjectInfo } from "./project-info.js"; +import type { ScoreResult } from "./score.js"; + +export interface InspectInput { + readonly directory: string; + readonly precomputedSourceFileCount?: number; + readonly includePaths: ReadonlyArray; + readonly customRulesOnly: boolean; + readonly respectInlineDisables: boolean; + /** + * Per-call override for `ReactDoctorConfig.warnings`. When omitted, + * the loaded config's `warnings` value wins (defaulting to `true`), + * so warnings surface unless the user opts out via `--no-warnings` or + * `warnings: false`. + */ + readonly warnings?: boolean; + readonly adoptExistingLintConfig: boolean; + readonly ignoredTags: ReadonlySet; + readonly includedTags?: ReadonlySet; + readonly includeTagDefaults?: boolean; + readonly nodeBinaryPath?: string; + /** Whether dead-code analysis runs. Gated also on `!isDiffMode`. */ + readonly runDeadCode: boolean; + /** Marks the run as CI-originated for the Score API. */ + readonly isCi: boolean; + /** react-doctor release version sent with score requests. */ + readonly doctorVersion?: string; + /** Random per-run id. */ + readonly runId?: string; + /** Enables best-effort authenticated local GitHub permission lookup for score metadata. */ + readonly resolveLocalGithubViewerPermission?: boolean; + /** + * Diagnostic surface fed to the Score service. Defaults to `"score"`, + * which excludes weak-signal rule families (e.g. `design`-tagged) from + * the score so they can't dilute the headline number. Public-API shells + * (`inspect()` / `diagnose()`) leave this at the default; pass `"cli"` + * (or any other surface) to score against an unfiltered diagnostic set. + * + * The returned `InspectOutput.diagnostics` is always the full + * per-element-filtered list — surface filtering only affects scoring. + */ + readonly scoreSurface?: DiagnosticSurface; + /** + * Suppresses the orchestrator's own persistent "Scanned N files" + * success line. The live scan spinner still runs for feedback but + * clears on completion instead of leaving a status line behind. The + * CLI sets this when scanning multiple projects so it can render a + * single aggregate "Scanned N files" line in their place — the + * per-project file count + scan duration are surfaced on + * `InspectOutput` for that summary. Lint / dead-code failures still + * surface their own spinner state regardless of this flag. + */ + readonly suppressScanSummary?: boolean; + /** + * When `true`, `includePaths` is linted verbatim instead of being filtered + * to React Doctor's supported source-file set. Editor scans use this for the + * exact buffer supplied by the language server. + */ + readonly skipExplicitIncludePathFilter?: boolean; + /** + * Whether the scanned project's `package.json` is among the changed files + * in a diff / staged scan. Dependency health is a whole-project property + * (read from `package.json`, not the changed source files), so the + * supply-chain check is normally skipped in diff mode — but a PR that edits + * `package.json` should still have its dependencies scored. When `true`, + * the supply-chain pass runs even in diff mode. Ignored on full scans + * (those always run it). Defaults to `false`. + */ + readonly supplyChainManifestChanged?: boolean; + /** + * Absolute epoch-millisecond deadline for the scan (the CLI's + * `--max-duration` budget resolved against the scan start). Past it the + * scan degrades gracefully: un-started lint batches are skipped (surfaced + * via `skippedCheckReasons["lint:partial"]` with the file list) and the + * dead-code phase is skipped or capped to the remaining budget. + */ + readonly deadlineEpochMs?: number; + readonly signal?: AbortSignal; + /** Descendant project roots covered by sibling scans in a workspace batch. */ + readonly excludedProjectDirectories?: ReadonlyArray; + /** Keep descendant dead-code findings when this scan owns the workspace-wide pass. */ + readonly retainExcludedProjectDeadCodeDiagnostics?: boolean; +} + +export interface InspectOutput { + readonly project: ProjectInfo; + readonly userConfig: ReactDoctorConfig | null; + readonly resolvedDirectory: string; + readonly diagnostics: ReadonlyArray; + readonly score: ScoreResult | null; + readonly scoreMetadata: ScoreRequestMetadata; + readonly didLintFail: boolean; + readonly lintFailureReason: string | null; + /** + * The `_tag` of `error.reason` when the lint stream raised a + * `ReactDoctorError`, or `null` otherwise. Lets renderers dispatch + * on the typed reason without `error.message.includes(...)` style + * sniffs (e.g. show the "upgrade Node" hint only on + * `OxlintUnavailable` with `kind: "native-binding-missing"`). + */ + readonly lintFailureReasonTag: ReactDoctorErrorReason["_tag"] | null; + /** + * The `kind` of an `OxlintUnavailable` lint failure + * (`binary-not-found` / `native-binding-missing`), or `null` for any + * other failure. Lets renderers show the "upgrade Node" hint by + * dispatching on structured data instead of matching message text. + */ + readonly lintFailureReasonKind: OxlintUnavailable["kind"] | null; + readonly lintPartialFailures: ReadonlyArray; + /** `false` when run-dead-code was disabled, diff/staged mode, or analysis crashed. */ + readonly didDeadCodeFail: boolean; + readonly deadCodeFailureReason: string | null; + /** + * Whether the dead-code pass actually ran concurrently with lint this scan. + * Only `REACT_DOCTOR_DEAD_CODE_OVERLAP=on` overlaps; the default `"auto"` + * and explicit `"off"` paths stay sequential. Internal telemetry only + * (rides the per-scan wide event); NOT part of the public `inspect()` + * `InspectResult`. + */ + readonly deadCodeOverlapped: boolean; + /** + * Number of files the scan reported (lint progress total, falling + * back to the project source-file count). Surfaced so a caller that + * sets `suppressScanSummary` can render its own aggregate + * "Scanned N files" line. + */ + readonly scannedFileCount: number; + /** + * Absolute paths of every file this scan considered. Used by the + * multi-project summary to count UNIQUE files across projects: + * nested workspace packages (a parent whose tree contains a child + * package) would otherwise double-count the shared files when their + * per-project counts are summed. + */ + readonly scannedFilePaths: ReadonlyArray; + /** Project-relative POSIX paths the lint pass completed successfully. */ + readonly analyzedFiles: ReadonlyArray; + /** Wall-clock duration of the scan phase, in milliseconds. */ + readonly scanElapsedMilliseconds: number; + /** + * Resolved lint worker count the linter actually fanned out to (the + * `OxlintConcurrency` Reference read through the spawn-boundary clamp). + * Surfaced so CLI telemetry reports the real worker count on the auto + * path, where the caller's `concurrency` option is `undefined`. + */ + readonly scanConcurrency: number; + /** + * `true` when the background supply-chain fiber hit its overlap budget + * (`SupplyChainOverlapTimeoutMs`) and failed open to no diagnostics — a + * rare hung-socket guard, surfaced for telemetry and skipped-check + * accounting. `false` on the healthy path and whenever supply-chain was + * skipped (diff/staged scans). + */ + readonly supplyChainOverlapTimedOut: boolean; + /** + * `true` when the forked security scan failed or reached the shared deadline. + * Filesystem failures fail open to no diagnostics; deadline truncation keeps + * findings collected before time elapsed. Surfaced for telemetry and + * skipped-check accounting so an incomplete pass is distinguishable from a + * clean one with zero findings. `false` on the healthy path and when the pass + * was skipped (diff/staged scans). + */ + readonly securityScanFailed: boolean; + readonly securityScanFailureReason: string | null; + /** + * Per-file lint cache outcome for the lint pass: files served from cache and + * total files considered. Both `null` when the cache was disabled or bypassed + * (audit mode, adopted `extends`, user plugins) so the run never split. Fed + * to the Sentry wide event as `lint.cacheHitRatio`. + */ + readonly lintCacheHitFileCount: number | null; + readonly lintCacheTotalFileCount: number | null; + /** + * Sidecar lint cache outcome for the lint pass: cache-hit files whose + * cross-file diagnostics replayed from the sidecar store, and the hits + * considered. Both `null` when the sidecar cache was disabled or bypassed + * (per-file cache off, `REACT_DOCTOR_NO_SIDECAR_CACHE`, no bounded + * cross-file rule enabled). Fed to the Sentry wide event as + * `lint.sidecarReplayRatio`. + */ + readonly lintSidecarReplayedFileCount: number | null; + readonly lintSidecarTotalFileCount: number | null; + /** + * Dead-code result cache outcome for this scan's dead-code pass: `true` + * when the cached result was replayed (the analysis worker never spawned), + * `false` on a miss (fresh analysis). `null` when the pass never consulted + * the cache — dead-code skipped/disabled, the cache off + * (`REACT_DOCTOR_NO_CACHE` / `REACT_DOCTOR_NO_DEAD_CODE_CACHE`), or the + * pass discarded by a lint failure. Fed to the Sentry wide event as + * `deadCode.cacheHit`. + */ + readonly deadCodeCacheHit: boolean | null; + /** + * deslop's incremental summary-cache outcome for this scan's dead-code + * ANALYSIS: collected files served from cached parse summaries vs freshly + * parsed. Both `null` whenever no analysis consulted the incremental store — + * a whole-result cache hit (no analysis ran), the cache off, dead-code + * skipped/disabled, or the pass discarded by a lint failure. Fed to the + * Sentry wide event as `deadCode.summaryCacheHits` / + * `deadCode.summaryCacheMisses`. + */ + readonly deadCodeSummaryCacheHits: number | null; + readonly deadCodeSummaryCacheMisses: number | null; + /** + * Per-rule tallies of diagnostics the pipeline dropped because the user + * explicitly silenced the rule (config off switches, per-path overrides, + * inline disable comments) — see `DiagnosticPipeline.summarizeSuppressions`. + * Telemetry-only; NOT part of the public `inspect()` `InspectResult`. Note + * that a `rules: "off"` lint rule is removed from the generated oxlint + * config upstream and never fires, so its findings can't be counted here — + * the CLI's scan-level `rule.disabled` counter covers that case. + */ + readonly suppressedRuleCounts: ReadonlyArray; +} + +/** Hooks the caller participates in without owning the orchestration. */ +export interface InspectHooks { + readonly beforeLint?: (project: ProjectInfo) => Effect.Effect; + readonly afterLint?: (didFail: boolean) => Effect.Effect; +} diff --git a/packages/core/src/types/run-oxlint.ts b/packages/core/src/types/run-oxlint.ts new file mode 100644 index 000000000..c50b1ba7a --- /dev/null +++ b/packages/core/src/types/run-oxlint.ts @@ -0,0 +1,116 @@ +import type { WorkerSlots } from "../utils/create-worker-slots.js"; +import type { ReactDoctorConfig } from "./config.js"; +import type { ProjectInfo } from "./project-info.js"; + +export interface RunOxlintOptions { + rootDirectory: string; + project: ProjectInfo; + includePaths?: string[]; + nodeBinaryPath?: string; + customRulesOnly?: boolean; + respectInlineDisables?: boolean; + adoptExistingLintConfig?: boolean; + ignoredTags?: ReadonlySet; + includedTags?: ReadonlySet; + includeTagDefaults?: boolean; + /** + * Optional react-doctor user config (already-loaded + * `react-doctor.config.json` or `package.json#reactDoctor`). When + * provided, project-level knobs the rule surface honors — + * currently `serverAuthFunctionNames` — are forwarded to the + * generated oxlint settings so plugin rules can read them via + * `context.settings`. `userConfig.plugins` resolves through + * `configSourceDirectory` (or `rootDirectory` as the fallback). + */ + userConfig?: ReactDoctorConfig | null; + /** + * Directory of the `react-doctor.config.json` (or `package.json`) + * that supplied `userConfig`. Used as the resolution base for + * `userConfig.plugins` entries — relative paths resolve against + * this directory and npm package names resolve through its + * `node_modules`, matching how `rootDir` resolves. Diverges from + * `rootDirectory` whenever `userConfig.rootDir` redirects the scan. + * + * Defaults to `rootDirectory` for direct callers that don't load + * a config file. + */ + configSourceDirectory?: string; + /** + * Called once per soft-fail event (e.g. a batch hit + * `OXLINT_SPAWN_TIMEOUT_MS` and was skipped). The lint scan keeps + * going on remaining batches; the caller is expected to surface + * the warning to the user (via `skippedCheckReasons` in JSON + * mode, or a logger message in human mode). + */ + onPartialFailure?: (reason: string) => void; + onFileCoverage?: (coverage: LintFileCoverage) => void; + onFileProgress?: (scannedFileCount: number, totalFileCount: number) => void; + /** + * Enables the per-file lint cache, resolved from the + * `PerFileLintCacheEnabled` Reference. When on (and the scan is eligible — + * no audit mode, no adopted `extends`, no user plugins), unchanged files + * replay their cached cacheable-rule diagnostics and only changed files are + * re-linted; the cross-file rules always run fresh on every file (in the + * misses' full pass, and in a sidecar pass over the cache hits). + */ + perFileLintCacheEnabled?: boolean; + /** + * Enables the sidecar lint cache, resolved from the + * `SidecarLintCacheEnabled` Reference. When on (and the per-file cache is + * active), each cache-hit file's cross-file diagnostics replay from the + * sidecar store as long as the file's recorded dependency probes still + * match the tree; only mismatching files re-lint. Off → every cache hit + * runs the always-fresh sidecar pass (the pre-cache behavior). + */ + sidecarLintCacheEnabled?: boolean; + /** + * Called once after the cache split with `(cacheHitFileCount, + * totalConsideredFileCount)`. Surfaced to the Sentry wide event as + * `lintCacheHitRatio`. Not invoked when the cache is disabled or bypassed. + */ + onCacheStats?: (cacheHitFileCount: number, totalConsideredFileCount: number) => void; + /** + * Called once with `(sidecarReplayedFileCount, sidecarConsideredFileCount)` + * — how many cache-hit files replayed their cross-file diagnostics from + * the sidecar store vs. the hits considered. Surfaced to the Sentry wide + * event as `lint.sidecarReplayRatio`. Not invoked when the sidecar cache + * is disabled or bypassed. + */ + onSidecarStats?: (sidecarReplayedFileCount: number, sidecarConsideredFileCount: number) => void; + /** Per-batch wall-clock budget, resolved from the `OxlintSpawnTimeoutMs` Reference. */ + spawnTimeoutMs?: number; + /** Per-batch stdout+stderr byte cap, resolved from the `OxlintOutputMaxBytes` Reference. */ + outputMaxBytes?: number; + /** + * Number of oxlint subprocesses to run in parallel, resolved from the + * `OxlintConcurrency` Reference (which itself defaults to parallel — + * auto-detected cores). Omitting it here uses the low-level serial + * default; the orchestrated path always threads the Reference value + * through. A parallel pass auto-falls-back to serial on resource + * exhaustion (see `spawnLintBatches`). + */ + concurrency?: number; + spawnSlots?: WorkerSlots; + /** + * Aborted when the orchestrator's lint-phase timeout fires; forwarded to + * `spawnLintBatches` so in-flight oxlint subprocesses are torn down instead + * of running on after the phase is abandoned. + */ + signal?: AbortSignal; + /** See `SpawnLintBatchesInput.deadlineEpochMs`. */ + deadlineEpochMs?: number; + /** + * Full-scan batch planning, resolved from the `LintBatchOrdering` + * Reference. `"cost"` (the default) plans size-balanced LPT batches via + * `planLintBatches`; `"arrival"` is the rollback hatch to the plain greedy + * fixed-size chunking in discovery order. Only affects the full-scan branch + * (`includePaths` undefined) — diff / staged scans pass explicit paths and + * are untouched. + */ + lintBatchOrdering?: "cost" | "arrival"; +} + +export interface LintFileCoverage { + readonly candidateFiles: ReadonlyArray; + readonly analyzedFiles: ReadonlyArray; +} diff --git a/packages/core/tests/detect-pre-es2023-target.test.ts b/packages/core/tests/detect-pre-es2023-target.test.ts index d10b53a90..6b3e60132 100644 --- a/packages/core/tests/detect-pre-es2023-target.test.ts +++ b/packages/core/tests/detect-pre-es2023-target.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; -import { detectPreES2023Target } from "../src/project-info/detectors.js"; +import { detectPreES2023Target } from "../src/project-info/detect-pre-es2023-target.js"; import { discoverProject } from "../src/project-info/discover-project.js"; import { buildCapabilities } from "../src/project-info/capabilities.js"; diff --git a/packages/deslop-cli/src/cli.ts b/packages/deslop-cli/src/cli.ts index 1c9677b33..1064515da 100644 --- a/packages/deslop-cli/src/cli.ts +++ b/packages/deslop-cli/src/cli.ts @@ -3,47 +3,30 @@ import { DEFAULT_ROOT_DIRECTORY, EXIT_CODE_RUNTIME_ERROR } from "./constants.js" import type { AnalyzeOptions } from "./types.js"; import { runAnalyze } from "./run-analyze.js"; import { readPackageVersion } from "./utils/read-package-version.js"; +import { parsePathMappings } from "./utils/parse-path-mappings.js"; -const parsePathsOption = (rawPaths: string[] | undefined): Record | undefined => { - if (!rawPaths || rawPaths.length === 0) return undefined; - const pathMap: Record = {}; - for (const entry of rawPaths) { - const separatorIndex = entry.indexOf("="); - const pattern = separatorIndex === -1 ? "" : entry.slice(0, separatorIndex); - const target = separatorIndex === -1 ? "" : entry.slice(separatorIndex + 1); - if (!pattern || !target) { - process.stderr.write( - `deslop: ignoring malformed --paths entry "${entry}" (expected "alias=target", e.g. "@app/*=src/*")\n`, - ); - continue; - } - const existing = pathMap[pattern]; - if (existing) { - existing.push(target); - } else { - pathMap[pattern] = [target]; - } +const toAnalyzeOptions = (root: string | undefined, optionValues: OptionValues): AnalyzeOptions => { + const parsedPathMappings = parsePathMappings(optionValues.paths); + for (const invalidEntry of parsedPathMappings.invalidEntries) { + process.stderr.write( + `deslop: ignoring malformed --paths entry "${invalidEntry}" (expected "alias=target", e.g. "@app/*=src/*")\n`, + ); } - return Object.keys(pathMap).length > 0 ? pathMap : undefined; + return { + root: root ?? DEFAULT_ROOT_DIRECTORY, + entry: optionValues.entry, + ignore: optionValues.ignore, + extensions: optionValues.extensions, + tsconfig: optionValues.tsconfig, + paths: parsedPathMappings.paths, + reportTypes: Boolean(optionValues.reportTypes), + includeEntryExports: Boolean(optionValues.includeEntryExports), + json: Boolean(optionValues.json), + failOnIssues: Boolean(optionValues.failOnIssues), + failOnCycles: Boolean(optionValues.failOnCycles), + }; }; -const toAnalyzeOptions = ( - root: string | undefined, - optionValues: OptionValues, -): AnalyzeOptions => ({ - root: root ?? DEFAULT_ROOT_DIRECTORY, - entry: optionValues.entry, - ignore: optionValues.ignore, - extensions: optionValues.extensions, - tsconfig: optionValues.tsconfig, - paths: parsePathsOption(optionValues.paths), - reportTypes: Boolean(optionValues.reportTypes), - includeEntryExports: Boolean(optionValues.includeEntryExports), - json: Boolean(optionValues.json), - failOnIssues: Boolean(optionValues.failOnIssues), - failOnCycles: Boolean(optionValues.failOnCycles), -}); - const runAnalyzeAction = async ( root: string | undefined, optionValues: OptionValues, diff --git a/packages/deslop-cli/src/utils/parse-path-mappings.ts b/packages/deslop-cli/src/utils/parse-path-mappings.ts new file mode 100644 index 000000000..e16b2f048 --- /dev/null +++ b/packages/deslop-cli/src/utils/parse-path-mappings.ts @@ -0,0 +1,33 @@ +interface ParsedPathMappings { + paths: Record | undefined; + invalidEntries: string[]; +} + +export const parsePathMappings = (rawMappings: string[] | undefined): ParsedPathMappings => { + if (!rawMappings || rawMappings.length === 0) { + return { paths: undefined, invalidEntries: [] }; + } + + const paths: Record = {}; + const invalidEntries: string[] = []; + for (const entry of rawMappings) { + const separatorIndex = entry.indexOf("="); + const pattern = separatorIndex === -1 ? "" : entry.slice(0, separatorIndex); + const target = separatorIndex === -1 ? "" : entry.slice(separatorIndex + 1); + if (!pattern || !target) { + invalidEntries.push(entry); + continue; + } + const existingTargets = paths[pattern]; + if (existingTargets) { + existingTargets.push(target); + } else { + paths[pattern] = [target]; + } + } + + return { + paths: Object.keys(paths).length > 0 ? paths : undefined, + invalidEntries, + }; +}; diff --git a/packages/deslop-cli/tests/cli.test.ts b/packages/deslop-cli/tests/cli.test.ts index 9806bf727..5120e03c1 100644 --- a/packages/deslop-cli/tests/cli.test.ts +++ b/packages/deslop-cli/tests/cli.test.ts @@ -19,6 +19,7 @@ import { } from "../src/format-result.js"; import { resolveAnalyzeExitCode, runAnalyze } from "../src/run-analyze.js"; import { validateRootDirectory } from "../src/utils/validate-root-directory.js"; +import { parsePathMappings } from "../src/utils/parse-path-mappings.js"; import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; const testDirectory = resolve(fileURLToPath(import.meta.url), ".."); @@ -120,6 +121,22 @@ describe("validateRootDirectory", () => { }); }); +describe("parsePathMappings", () => { + it("groups repeated aliases and returns malformed entries separately", () => { + const parsed = parsePathMappings([ + "@app/*=src/*", + "@app/*=generated/*", + "missing-separator", + "=missing-pattern", + ]); + + assert.deepEqual(parsed.paths, { + "@app/*": ["src/*", "generated/*"], + }); + assert.deepEqual(parsed.invalidEntries, ["missing-separator", "=missing-pattern"]); + }); +}); + describe("resolveAnalyzeExitCode", () => { it("should return success when no fail flags are set", () => { const exitCode = resolveAnalyzeExitCode( diff --git a/packages/deslop-js/src/collect/entries.ts b/packages/deslop-js/src/collect/entries.ts index 8c79070d9..42a9fd5d2 100644 --- a/packages/deslop-js/src/collect/entries.ts +++ b/packages/deslop-js/src/collect/entries.ts @@ -1,6 +1,5 @@ import fg from "fast-glob"; import { dirname, join, resolve } from "node:path"; -import { readFile } from "node:fs/promises"; import { readFileSync, existsSync } from "node:fs"; import type { SourceFile, DeslopConfig, ResolvedEntries } from "../types.js"; import { @@ -11,7 +10,6 @@ import { SCRIPT_EXTENSIONLESS_FILE_PATTERN, SCRIPT_CONFIG_FILE_PATTERN, SHALLOW_WORKSPACE_MAX_DEPTH, - SOURCE_EXTENSIONS as IMPORTABLE_SOURCE_EXTENSIONS, } from "../constants.js"; import { resolveWorkspaces, detectFrameworkEntries } from "./workspaces.js"; import type { WorkspacePackage } from "./workspaces.js"; @@ -21,10 +19,8 @@ import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; import { extractConfigStringReferencedEntries } from "./config-string-entries.js"; import { extractSectionsModuleEntries } from "./sections-module-entries.js"; import { extractSiblingWorkspaceImportEntries } from "./sibling-workspace-import-entries.js"; -import { - resolveEntryPathWithExtensions, - resolveEntryWithExtensions, -} from "../utils/resolve-entry-with-extensions.js"; +import { extractPackageJsonEntries, findDefaultIndexEntry } from "./package-json-entries.js"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; import { toPosixPath } from "../utils/to-posix-path.js"; export const collectSourceFiles = async (config: DeslopConfig): Promise => { @@ -330,256 +326,6 @@ export const resolveEntries = async (config: DeslopConfig): Promise { - for (const pattern of DEFAULT_INDEX_PATTERNS) { - const candidatePath = resolve(directory, pattern); - if (existsSync(candidatePath)) return candidatePath; - } - return undefined; -}; - -const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; - -const COMMON_SOURCE_DIRECTORIES = ["src", "lib", "main", "app", "source"]; -const BUILD_OUTPUT_DIRECTORY_PATTERN = - /^(?:\.\/)?(?:dist(?:-[a-z]+)?|build|out|esm|cjs)\/(?:(?:esm|cjs|es|lib|commonjs|module)\/)?/; - -const findSourceFile = (baseDir: string, relativePath: string): string | undefined => { - const pathWithoutExtension = join(baseDir, relativePath).replace(/\.[cm]?js(x?)$/, ""); - for (const sourceExtension of SOURCE_EXTENSIONS) { - const candidatePath = pathWithoutExtension + sourceExtension; - if (existsSync(candidatePath)) return candidatePath; - } - const indexCandidate = join(pathWithoutExtension, "index.ts"); - if (existsSync(indexCandidate)) return indexCandidate; - return undefined; -}; - -const findSourceFileStrict = (baseDir: string, relativePath: string): string | undefined => { - const pathWithoutExtension = join(baseDir, relativePath).replace(/\.[cm]?js(x?)$/, ""); - for (const sourceExtension of SOURCE_EXTENSIONS) { - const candidatePath = pathWithoutExtension + sourceExtension; - if (existsSync(candidatePath)) return candidatePath; - } - const exactPath = join(baseDir, relativePath); - if (existsSync(exactPath)) return exactPath; - return undefined; -}; - -const resolveBuiltPathToSource = ( - builtAbsolutePath: string, - rootDir: string, -): string | undefined => { - if (existsSync(builtAbsolutePath)) return undefined; - - try { - const tsconfigPath = join(rootDir, "tsconfig.json"); - if (!existsSync(tsconfigPath)) return undefined; - const tsconfigContent = readFileSync(tsconfigPath, "utf-8") - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, ""); - const tsconfig = JSON.parse(tsconfigContent); - const outDir = tsconfig?.compilerOptions?.outDir; - if (!outDir) return undefined; - - const absoluteOutDir = resolve(rootDir, outDir); - const relativeToBuild = builtAbsolutePath.startsWith(absoluteOutDir) - ? builtAbsolutePath.slice(absoluteOutDir.length) - : undefined; - if (!relativeToBuild) return undefined; - - const rootDirOption = tsconfig?.compilerOptions?.rootDir; - const sourceRoot = rootDirOption ? resolve(rootDir, rootDirOption) : rootDir; - const sourceFileMatch = findSourceFile(sourceRoot, relativeToBuild); - if (sourceFileMatch) return sourceFileMatch; - const directCandidate = join(sourceRoot, relativeToBuild); - if (existsSync(directCandidate)) return directCandidate; - if (!rootDirOption) { - for (const sourceDir of COMMON_SOURCE_DIRECTORIES) { - const candidate = findSourceFile(resolve(rootDir, sourceDir), relativeToBuild); - if (candidate) return candidate; - } - } - } catch {} - return undefined; -}; - -const resolveEntryPathViaHeuristic = (entryPath: string, rootDir: string): string | undefined => { - if (!BUILD_OUTPUT_DIRECTORY_PATTERN.test(entryPath)) return undefined; - const buildDirMatch = entryPath.match(BUILD_OUTPUT_DIRECTORY_PATTERN); - if (!buildDirMatch) return undefined; - const relativeToBuildDir = entryPath.slice(buildDirMatch[0].length); - for (const sourceDir of COMMON_SOURCE_DIRECTORIES) { - const sourceBaseDir = resolve(rootDir, sourceDir); - if (!existsSync(sourceBaseDir)) continue; - const sourceFileMatch = findSourceFileStrict(sourceBaseDir, relativeToBuildDir); - if (sourceFileMatch) return sourceFileMatch; - } - return undefined; -}; - -const resolveEntryPath = (entryPath: string, rootDir: string): string => { - const absolutePath = resolve(rootDir, entryPath); - const normalizedEntry = entryPath.replace(/^\.\//, ""); - const isInBuildOutputDirectory = BUILD_OUTPUT_DIRECTORY_PATTERN.test(normalizedEntry); - if (isInBuildOutputDirectory) { - const sourcePath = resolveBuiltPathToSource(absolutePath, rootDir); - if (sourcePath) return sourcePath; - const heuristicMatch = resolveEntryPathViaHeuristic(normalizedEntry, rootDir); - if (heuristicMatch) return heuristicMatch; - } - if (existsSync(absolutePath)) return absolutePath; - const sourcePath = resolveBuiltPathToSource(absolutePath, rootDir); - if (sourcePath) return sourcePath; - const directSourceMatch = findSourceFile(rootDir, normalizedEntry); - if (directSourceMatch) return directSourceMatch; - const heuristicMatch = resolveEntryPathViaHeuristic(normalizedEntry, rootDir); - if (heuristicMatch) return heuristicMatch; - return absolutePath; -}; - -const extractPackageJsonEntries = async (packageJsonPath: string): Promise => { - const entries: string[] = []; - - try { - const content = await readFile(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const rootDir = packageJsonPath.replace(/\/package\.json$/, ""); - - const entryFields = ["main", "module", "browser", "types", "typings", "style", "source"]; - for (const field of entryFields) { - if (typeof packageJson[field] === "string") { - entries.push(resolveEntryPath(packageJson[field], rootDir)); - } - } - - if (packageJson.exports) { - const exportEntries: string[] = []; - collectExportPaths(packageJson.exports, rootDir, exportEntries); - for (const exportEntry of exportEntries) { - const resolvedExportEntry = - resolveEntryWithExtensions(exportEntry) ?? - resolveEntryPathWithExtensions(exportEntry, rootDir) ?? - resolveSourcePath(exportEntry, rootDir); - - if (resolvedExportEntry && existsSync(resolvedExportEntry)) { - entries.push(resolvedExportEntry); - continue; - } - - if (exportEntry.endsWith(".ts")) { - const tsxFallback = exportEntry.replace(/\.ts$/, ".tsx"); - if (existsSync(tsxFallback)) { - entries.push(tsxFallback); - continue; - } - } - - if (existsSync(exportEntry)) { - entries.push(exportEntry); - } else { - entries.push(resolveEntryPath(exportEntry, rootDir)); - } - } - } - - if (packageJson.bin) { - if (typeof packageJson.bin === "string") { - entries.push(resolveEntryPath(packageJson.bin, rootDir)); - } else if (typeof packageJson.bin === "object") { - for (const binPath of Object.values(packageJson.bin)) { - if (typeof binPath === "string") { - entries.push(resolveEntryPath(binPath, rootDir)); - } - } - } - } - - if (Array.isArray(packageJson.sideEffects)) { - for (const sideEffectPattern of packageJson.sideEffects) { - if (typeof sideEffectPattern !== "string") continue; - const sourcePatterns = expandSideEffectGlobToSourcePatterns(sideEffectPattern); - for (const sourcePattern of sourcePatterns) { - const matchedSideEffectFiles = fg.sync(sourcePattern, { - cwd: rootDir, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - }); - for (const matchedSideEffectFile of matchedSideEffectFiles) { - if (isImportableSourceFile(matchedSideEffectFile)) { - entries.push(matchedSideEffectFile); - } - } - } - } - } - - if (packageJson.build && typeof packageJson.build === "object") { - const buildConfig = packageJson.build as Record; - if (Array.isArray(buildConfig.files)) { - for (const buildFileEntry of buildConfig.files) { - if (typeof buildFileEntry !== "string") continue; - if (buildFileEntry.includes("*")) continue; - const resolvedBuildFile = - resolveEntryWithExtensions(resolve(rootDir, buildFileEntry)) ?? - resolveEntryPathWithExtensions(buildFileEntry, rootDir); - if (resolvedBuildFile && existsSync(resolvedBuildFile)) { - entries.push(resolvedBuildFile); - } - } - } - } - - if (packageJson.jest && typeof packageJson.jest === "object") { - const jestConfigContent = JSON.stringify(packageJson.jest); - const jestRootDirMatches = jestConfigContent.matchAll(/\/([^"\\]+)/g); - for (const jestRootDirMatch of jestRootDirMatches) { - const resolvedJestFile = resolveEntryPathWithExtensions(jestRootDirMatch[1], rootDir); - if (resolvedJestFile && existsSync(resolvedJestFile)) { - entries.push(resolvedJestFile); - } - } - } - } catch {} - - return entries; -}; - -const expandSideEffectGlobToSourcePatterns = (pattern: string): string[] => { - const patterns = new Set([pattern]); - if (pattern.endsWith(".js")) { - patterns.add(pattern.replace(/\.js$/, ".ts")); - patterns.add(pattern.replace(/\.js$/, ".tsx")); - } - if (pattern.includes("/lib/") || pattern.startsWith("lib/")) { - patterns.add(pattern.replace(/\blib\b/g, "src")); - } - if (pattern.includes("/esm/") || pattern.startsWith("esm/")) { - patterns.add(pattern.replace(/\besm\b/g, "src")); - } - return [...patterns]; -}; - const SHELL_OPERATORS_PATTERN = /\s*(?:&&|\|\||[;&|])\s*/; const SCRIPT_MULTIPLEXERS = new Set([ @@ -1826,42 +1572,6 @@ const extractTestSetupFiles = (directory: string): string[] => { return entries; }; -const IMPORTABLE_EXTENSION_SET = new Set( - IMPORTABLE_SOURCE_EXTENSIONS.map((extension) => `.${extension}`), -); - -const isImportableSourceFile = (filePath: string): boolean => - IMPORTABLE_EXTENSION_SET.has(filePath.slice(filePath.lastIndexOf("."))); - -const expandWildcardExportPattern = (pattern: string, rootDir: string): string[] => { - const normalized = pattern.startsWith("./") ? pattern.slice(2) : pattern; - const matchedFiles = fg.sync(normalized, { - cwd: rootDir, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - return matchedFiles.filter(isImportableSourceFile); -}; - -const collectExportPaths = (exportValue: unknown, rootDir: string, entries: string[]): void => { - if (typeof exportValue === "string") { - if (exportValue.includes("*")) { - const expandedFiles = expandWildcardExportPattern(exportValue, rootDir); - entries.push(...expandedFiles); - return; - } - entries.push(resolveEntryPath(exportValue, rootDir)); - return; - } - - if (typeof exportValue !== "object" || exportValue === null) return; - - for (const [, nestedValue] of Object.entries(exportValue as Record)) { - collectExportPaths(nestedValue, rootDir, entries); - } -}; - interface TestRunnerDefinition { enablers: string[]; configFileActivators: string[]; diff --git a/packages/deslop-js/src/collect/package-json-entries.ts b/packages/deslop-js/src/collect/package-json-entries.ts new file mode 100644 index 000000000..56bd90b07 --- /dev/null +++ b/packages/deslop-js/src/collect/package-json-entries.ts @@ -0,0 +1,296 @@ +import { existsSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import fg from "fast-glob"; +import { resolveSourcePath } from "../resolver/source-path.js"; +import { + resolveEntryPathWithExtensions, + resolveEntryWithExtensions, +} from "../utils/resolve-entry-with-extensions.js"; + +interface PackageJsonEntryFields { + [key: string]: unknown; + exports?: unknown; + bin?: unknown; + sideEffects?: unknown; + build?: unknown; + jest?: unknown; +} + +interface PackageBuildConfig { + files?: unknown; +} + +const DEFAULT_INDEX_PATTERNS = [ + "src/index.ts", + "src/index.tsx", + "src/index.js", + "src/index.jsx", + "src/main.ts", + "src/main.tsx", + "src/main.js", + "src/main.jsx", + "index.ts", + "index.tsx", + "index.js", + "index.jsx", + "main.ts", + "main.tsx", + "main.js", + "main.jsx", +]; + +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; +const COMMON_SOURCE_DIRECTORIES = ["src", "lib", "main", "app", "source"]; +const BUILD_OUTPUT_DIRECTORY_PATTERN = + /^(?:\.\/)?(?:dist(?:-[a-z]+)?|build|out|esm|cjs)\/(?:(?:esm|cjs|es|lib|commonjs|module)\/)?/; +const IMPORTABLE_EXTENSION_SET = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mts", + ".mjs", + ".cts", + ".cjs", + ".css", + ".scss", + ".less", + ".sass", +]); +const PACKAGE_ENTRY_FIELDS = ["main", "module", "browser", "types", "typings", "style", "source"]; + +export const findDefaultIndexEntry = (directory: string): string | undefined => { + for (const pattern of DEFAULT_INDEX_PATTERNS) { + const candidatePath = resolve(directory, pattern); + if (existsSync(candidatePath)) return candidatePath; + } + return undefined; +}; + +const findSourceFile = (baseDirectory: string, relativePath: string): string | undefined => { + const pathWithoutExtension = join(baseDirectory, relativePath).replace(/\.[cm]?js(x?)$/, ""); + for (const sourceExtension of SOURCE_EXTENSIONS) { + const candidatePath = pathWithoutExtension + sourceExtension; + if (existsSync(candidatePath)) return candidatePath; + } + const indexCandidate = join(pathWithoutExtension, "index.ts"); + return existsSync(indexCandidate) ? indexCandidate : undefined; +}; + +const findSourceFileStrict = (baseDirectory: string, relativePath: string): string | undefined => { + const pathWithoutExtension = join(baseDirectory, relativePath).replace(/\.[cm]?js(x?)$/, ""); + for (const sourceExtension of SOURCE_EXTENSIONS) { + const candidatePath = pathWithoutExtension + sourceExtension; + if (existsSync(candidatePath)) return candidatePath; + } + const exactPath = join(baseDirectory, relativePath); + return existsSync(exactPath) ? exactPath : undefined; +}; + +const resolveBuiltPathToSource = ( + builtAbsolutePath: string, + rootDirectory: string, +): string | undefined => { + if (existsSync(builtAbsolutePath)) return undefined; + + try { + const tsconfigPath = join(rootDirectory, "tsconfig.json"); + if (!existsSync(tsconfigPath)) return undefined; + const tsconfigContent = readFileSync(tsconfigPath, "utf-8") + .replace(/\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); + const tsconfig = JSON.parse(tsconfigContent); + const outDirectory = tsconfig?.compilerOptions?.outDir; + if (!outDirectory) return undefined; + + const absoluteOutDirectory = resolve(rootDirectory, outDirectory); + const relativeToBuild = builtAbsolutePath.startsWith(absoluteOutDirectory) + ? builtAbsolutePath.slice(absoluteOutDirectory.length) + : undefined; + if (!relativeToBuild) return undefined; + + const configuredRootDirectory = tsconfig?.compilerOptions?.rootDir; + const sourceRoot = configuredRootDirectory + ? resolve(rootDirectory, configuredRootDirectory) + : rootDirectory; + const sourceFileMatch = findSourceFile(sourceRoot, relativeToBuild); + if (sourceFileMatch) return sourceFileMatch; + const directCandidate = join(sourceRoot, relativeToBuild); + if (existsSync(directCandidate)) return directCandidate; + if (!configuredRootDirectory) { + for (const sourceDirectory of COMMON_SOURCE_DIRECTORIES) { + const candidate = findSourceFile(resolve(rootDirectory, sourceDirectory), relativeToBuild); + if (candidate) return candidate; + } + } + } catch {} + return undefined; +}; + +const resolveEntryPathViaHeuristic = ( + entryPath: string, + rootDirectory: string, +): string | undefined => { + const buildDirectoryMatch = entryPath.match(BUILD_OUTPUT_DIRECTORY_PATTERN); + if (!buildDirectoryMatch) return undefined; + const relativeToBuildDirectory = entryPath.slice(buildDirectoryMatch[0].length); + for (const sourceDirectory of COMMON_SOURCE_DIRECTORIES) { + const sourceBaseDirectory = resolve(rootDirectory, sourceDirectory); + if (!existsSync(sourceBaseDirectory)) continue; + const sourceFileMatch = findSourceFileStrict(sourceBaseDirectory, relativeToBuildDirectory); + if (sourceFileMatch) return sourceFileMatch; + } + return undefined; +}; + +const resolveEntryPath = (entryPath: string, rootDirectory: string): string => { + const absolutePath = resolve(rootDirectory, entryPath); + const normalizedEntry = entryPath.replace(/^\.\//, ""); + if (BUILD_OUTPUT_DIRECTORY_PATTERN.test(normalizedEntry)) { + const sourcePath = resolveBuiltPathToSource(absolutePath, rootDirectory); + if (sourcePath) return sourcePath; + const heuristicMatch = resolveEntryPathViaHeuristic(normalizedEntry, rootDirectory); + if (heuristicMatch) return heuristicMatch; + } + if (existsSync(absolutePath)) return absolutePath; + return ( + resolveBuiltPathToSource(absolutePath, rootDirectory) ?? + findSourceFile(rootDirectory, normalizedEntry) ?? + resolveEntryPathViaHeuristic(normalizedEntry, rootDirectory) ?? + absolutePath + ); +}; + +const collectExportPaths = ( + exportValue: unknown, + rootDirectory: string, + entries: string[], +): void => { + if (typeof exportValue === "string") { + if (exportValue.includes("*")) { + const normalizedPattern = exportValue.startsWith("./") ? exportValue.slice(2) : exportValue; + const matchedFiles = fg.sync(normalizedPattern, { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + entries.push(...matchedFiles.filter(isImportableSourceFile)); + } else { + entries.push(resolveEntryPath(exportValue, rootDirectory)); + } + return; + } + if (!exportValue || typeof exportValue !== "object") return; + for (const nestedExportValue of Object.values(exportValue)) { + collectExportPaths(nestedExportValue, rootDirectory, entries); + } +}; + +const isImportableSourceFile = (filePath: string): boolean => + IMPORTABLE_EXTENSION_SET.has(filePath.slice(filePath.lastIndexOf("."))); + +const expandSideEffectGlobToSourcePatterns = (pattern: string): string[] => { + const patterns = new Set([pattern]); + if (pattern.endsWith(".js")) { + patterns.add(pattern.replace(/\.js$/, ".ts")); + patterns.add(pattern.replace(/\.js$/, ".tsx")); + } + if (pattern.includes("/lib/") || pattern.startsWith("lib/")) { + patterns.add(pattern.replace(/\blib\b/g, "src")); + } + if (pattern.includes("/esm/") || pattern.startsWith("esm/")) { + patterns.add(pattern.replace(/\besm\b/g, "src")); + } + return [...patterns]; +}; + +export const extractPackageJsonEntries = async (packageJsonPath: string): Promise => { + const entries: string[] = []; + + try { + const content = await readFile(packageJsonPath, "utf-8"); + const packageJson: PackageJsonEntryFields = JSON.parse(content); + const rootDirectory = packageJsonPath.replace(/\/package\.json$/, ""); + + for (const field of PACKAGE_ENTRY_FIELDS) { + const entryPath = packageJson[field]; + if (typeof entryPath === "string") entries.push(resolveEntryPath(entryPath, rootDirectory)); + } + + if (packageJson.exports) { + const exportEntries: string[] = []; + collectExportPaths(packageJson.exports, rootDirectory, exportEntries); + for (const exportEntry of exportEntries) { + const resolvedExportEntry = + resolveEntryWithExtensions(exportEntry) ?? + resolveEntryPathWithExtensions(exportEntry, rootDirectory) ?? + resolveSourcePath(exportEntry, rootDirectory); + if (resolvedExportEntry && existsSync(resolvedExportEntry)) { + entries.push(resolvedExportEntry); + } else if ( + exportEntry.endsWith(".ts") && + existsSync(exportEntry.replace(/\.ts$/, ".tsx")) + ) { + entries.push(exportEntry.replace(/\.ts$/, ".tsx")); + } else { + entries.push( + existsSync(exportEntry) ? exportEntry : resolveEntryPath(exportEntry, rootDirectory), + ); + } + } + } + + if (typeof packageJson.bin === "string") { + entries.push(resolveEntryPath(packageJson.bin, rootDirectory)); + } else if (packageJson.bin && typeof packageJson.bin === "object") { + for (const binPath of Object.values(packageJson.bin)) { + if (typeof binPath === "string") entries.push(resolveEntryPath(binPath, rootDirectory)); + } + } + + if (Array.isArray(packageJson.sideEffects)) { + for (const sideEffectPattern of packageJson.sideEffects) { + if (typeof sideEffectPattern !== "string") continue; + for (const sourcePattern of expandSideEffectGlobToSourcePatterns(sideEffectPattern)) { + entries.push( + ...fg + .sync(sourcePattern, { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }) + .filter(isImportableSourceFile), + ); + } + } + } + + const buildConfig: PackageBuildConfig | undefined = + packageJson.build && typeof packageJson.build === "object" ? packageJson.build : undefined; + if (Array.isArray(buildConfig?.files)) { + for (const buildFileEntry of buildConfig.files) { + if (typeof buildFileEntry !== "string" || buildFileEntry.includes("*")) continue; + const resolvedBuildFile = + resolveEntryWithExtensions(resolve(rootDirectory, buildFileEntry)) ?? + resolveEntryPathWithExtensions(buildFileEntry, rootDirectory); + if (resolvedBuildFile && existsSync(resolvedBuildFile)) entries.push(resolvedBuildFile); + } + } + + if (packageJson.jest && typeof packageJson.jest === "object") { + const jestConfigContent = JSON.stringify(packageJson.jest); + for (const jestRootDirectoryMatch of jestConfigContent.matchAll(/\/([^"\\]+)/g)) { + const resolvedJestFile = resolveEntryPathWithExtensions( + jestRootDirectoryMatch[1], + rootDirectory, + ); + if (resolvedJestFile && existsSync(resolvedJestFile)) entries.push(resolvedJestFile); + } + } + } catch {} + + return entries; +}; diff --git a/packages/deslop-js/src/collect/parse.ts b/packages/deslop-js/src/collect/parse.ts index cad47d519..d855d14f2 100644 --- a/packages/deslop-js/src/collect/parse.ts +++ b/packages/deslop-js/src/collect/parse.ts @@ -18,11 +18,6 @@ import type { VariableDeclaration, BindingPattern, ModuleExportName, - CallExpression, - StaticMemberExpression, - ImportExpression, - StringLiteral, - Expression, ModuleDeclaration, } from "@oxc-project/types"; import type { @@ -47,6 +42,7 @@ import { collectInlineTypeLiterals } from "../utils/collect-inline-type-literals import { collectSimplifiableFunctions } from "../utils/collect-simplifiable-functions.js"; import { collectSimplifiableExpressions } from "../utils/collect-simplifiable-expressions.js"; import { collectDuplicateConstantCandidates } from "../utils/collect-duplicate-constants.js"; +import { getIdentifierName } from "../utils/oxc-ast-node.js"; export interface ParsedRedundantTypePattern { typeName: string; @@ -412,19 +408,19 @@ const collectTopLevelImportReferences = ( if (importLocalNames.size === 0) return []; const visitClassBody = (classBody: WalkableNode): void => { - const bodyElements = (classBody as unknown as { body?: WalkableNode[] }).body ?? []; + const bodyElements = Array.isArray(classBody.body) ? classBody.body.filter(isWalkableNode) : []; for (const element of bodyElements) { if (element.type === "StaticBlock") { - visitValueNode((element as unknown as { body: unknown }).body); + visitValueNode(element.body); continue; } - const isComputedKey = Boolean((element as unknown as { computed?: boolean }).computed); - if (isComputedKey) visitValueNode((element as unknown as { key: unknown }).key); - const isStatic = Boolean((element as unknown as { static?: boolean }).static); + const isComputedKey = Boolean(element.computed); + if (isComputedKey) visitValueNode(element.key); + const isStatic = Boolean(element.static); if (element.type === "PropertyDefinition" && isStatic) { - visitValueNode((element as unknown as { value: unknown }).value); + visitValueNode(element.value); } - visitValueNode((element as unknown as { decorators: unknown }).decorators); + visitValueNode(element.decorators); } }; @@ -436,8 +432,8 @@ const collectTopLevelImportReferences = ( if (!isWalkableNode(node)) return; if (node.type === "Identifier" || node.type === "JSXIdentifier") { - const identifierName = (node as unknown as { name?: string }).name; - if (identifierName && importLocalNames.has(identifierName)) { + if (typeof node.name === "string" && importLocalNames.has(node.name)) { + const identifierName = node.name; referencedNames.add(identifierName); } return; @@ -445,7 +441,7 @@ const collectTopLevelImportReferences = ( if (node.type.startsWith("TS")) { if (TS_VALUE_WRAPPER_NODE_TYPES.has(node.type)) { - visitValueNode((node as unknown as { expression: unknown }).expression); + visitValueNode(node.expression); return; } if (!TS_RUNTIME_DECLARATION_NODE_TYPES.has(node.type)) return; @@ -454,35 +450,31 @@ const collectTopLevelImportReferences = ( if (FUNCTION_NODE_TYPES.has(node.type)) return; if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { - visitValueNode((node as unknown as { superClass: unknown }).superClass); - visitValueNode((node as unknown as { decorators: unknown }).decorators); - const classBody = (node as unknown as { body?: WalkableNode }).body; - if (classBody) visitClassBody(classBody); + visitValueNode(node.superClass); + visitValueNode(node.decorators); + if (isWalkableNode(node.body)) visitClassBody(node.body); return; } if (node.type === "CallExpression" || node.type === "NewExpression") { - const callee = (node as unknown as { callee?: WalkableNode }).callee; - if (callee && FUNCTION_NODE_TYPES.has(callee.type)) { - visitValueNode((callee as unknown as { body: unknown }).body); + if (isWalkableNode(node.callee) && FUNCTION_NODE_TYPES.has(node.callee.type)) { + visitValueNode(node.callee.body); } } if (node.type === "MemberExpression" || node.type === "JSXMemberExpression") { - const memberNode = node as unknown as { object: unknown; property: unknown }; - visitValueNode(memberNode.object); - if ((node as unknown as { computed?: boolean }).computed) { - visitValueNode(memberNode.property); + visitValueNode(node.object); + if (node.computed) { + visitValueNode(node.property); } return; } if (node.type === "Property") { - const propertyNode = node as unknown as { key: unknown; value: unknown }; - if ((node as unknown as { computed?: boolean }).computed) { - visitValueNode(propertyNode.key); + if (node.computed) { + visitValueNode(node.key); } - visitValueNode(propertyNode.value); + visitValueNode(node.value); return; } @@ -1143,37 +1135,23 @@ const collectMemberAccesses = ( ): void => { const walkForMemberAccesses = (node: WalkableNode): void => { if (node.type === "MemberExpression" && !node.computed) { - const memberExpression = node as unknown as StaticMemberExpression; - if ( - memberExpression.object.type === "Identifier" && - namespaceLocalNames.has((memberExpression.object as { name: string }).name) - ) { - const objectName = (memberExpression.object as { name: string }).name; - const memberName = memberExpression.property.name; - if (memberName) { - memberAccesses.push({ objectName, memberName }); - } + const objectName = getIdentifierName(node.object); + const memberName = getIdentifierName(node.property); + if (objectName && memberName && namespaceLocalNames.has(objectName)) { + memberAccesses.push({ objectName, memberName }); } } if (node.type === "MemberExpression" && Boolean(node.computed)) { - const computedExpression = node as unknown as { - object: Expression; - expression: Expression; - }; - if ( - computedExpression.object.type === "Identifier" && - namespaceLocalNames.has((computedExpression.object as { name: string }).name) - ) { - const objectName = (computedExpression.object as { name: string }).name; - const expressionNode = (node as unknown as { expression: WalkableNode }).expression; - if (expressionNode?.type === "Literal") { - const literalValue = (expressionNode as unknown as StringLiteral).value; - if (typeof literalValue === "string") { - memberAccesses.push({ objectName, memberName: literalValue }); - } else { - wholeObjectUses.push(objectName); - } + const objectName = getIdentifierName(node.object); + if (objectName && namespaceLocalNames.has(objectName)) { + const expressionNode = node.expression; + if ( + isWalkableNode(expressionNode) && + expressionNode.type === "Literal" && + typeof expressionNode.value === "string" + ) { + memberAccesses.push({ objectName, memberName: expressionNode.value }); } else { wholeObjectUses.push(objectName); } @@ -1184,30 +1162,25 @@ const collectMemberAccesses = ( // import. The name node is a `JSXMemberExpression`, not a `MemberExpression`, // so it would otherwise be missed and the export reported unused (#875). if (node.type === "JSXMemberExpression") { - const jsxMember = node as unknown as { - object: { type: string; name?: string }; - property: { name?: string }; - }; + const objectNode = isWalkableNode(node.object) ? node.object : undefined; + const propertyNode = isWalkableNode(node.property) ? node.property : undefined; if ( - jsxMember.object.type === "JSXIdentifier" && - jsxMember.object.name !== undefined && - namespaceLocalNames.has(jsxMember.object.name) && - jsxMember.property.name !== undefined + objectNode?.type === "JSXIdentifier" && + typeof objectNode.name === "string" && + namespaceLocalNames.has(objectNode.name) && + typeof propertyNode?.name === "string" ) { memberAccesses.push({ - objectName: jsxMember.object.name, - memberName: jsxMember.property.name, + objectName: objectNode.name, + memberName: propertyNode.name, }); } } if (node.type === "SpreadElement") { - const spreadArgument = (node as unknown as { argument: WalkableNode }).argument; - if ( - spreadArgument?.type === "Identifier" && - namespaceLocalNames.has((spreadArgument as unknown as { name: string }).name) - ) { - wholeObjectUses.push((spreadArgument as unknown as { name: string }).name); + const spreadArgumentName = getIdentifierName(node.argument); + if (spreadArgumentName && namespaceLocalNames.has(spreadArgumentName)) { + wholeObjectUses.push(spreadArgumentName); } } @@ -1215,62 +1188,54 @@ const collectMemberAccesses = ( // members without a MemberExpression, so it would otherwise be invisible // to the usage map and the destructured exports reported unused (#875). if (node.type === "VariableDeclarator") { - const declarator = node as unknown as { id?: WalkableNode; init?: WalkableNode }; + const namespaceName = getIdentifierName(node.init); if ( - declarator.init?.type === "Identifier" && - namespaceLocalNames.has((declarator.init as unknown as { name: string }).name) && - declarator.id?.type === "ObjectPattern" + namespaceName && + namespaceLocalNames.has(namespaceName) && + isWalkableNode(node.id) && + node.id.type === "ObjectPattern" && + Array.isArray(node.id.properties) ) { - const namespaceName = (declarator.init as unknown as { name: string }).name; - const patternProperties = (declarator.id as unknown as { properties: WalkableNode[] }) - .properties; - for (const property of patternProperties) { + for (const property of node.id.properties.filter(isWalkableNode)) { if (property.type === "RestElement") { wholeObjectUses.push(namespaceName); continue; } - const propertyKey = ( - property as unknown as { key?: { type: string; name?: string; value?: unknown } } - ).key; - const isComputed = Boolean((property as unknown as { computed?: boolean }).computed); - if (isComputed) { + if (property.computed) { wholeObjectUses.push(namespaceName); - } else if (propertyKey?.type === "Identifier" && propertyKey.name) { - memberAccesses.push({ objectName: namespaceName, memberName: propertyKey.name }); - } else if (propertyKey?.type === "Literal" && typeof propertyKey.value === "string") { - memberAccesses.push({ objectName: namespaceName, memberName: propertyKey.value }); + } else if (isWalkableNode(property.key)) { + const propertyName = getIdentifierName(property.key); + if (propertyName) { + memberAccesses.push({ objectName: namespaceName, memberName: propertyName }); + } else if (property.key.type === "Literal" && typeof property.key.value === "string") { + memberAccesses.push({ objectName: namespaceName, memberName: property.key.value }); + } } } } } if (node.type === "ForInStatement") { - const forInRight = (node as unknown as { right: WalkableNode }).right; - if ( - forInRight?.type === "Identifier" && - namespaceLocalNames.has((forInRight as unknown as { name: string }).name) - ) { - wholeObjectUses.push((forInRight as unknown as { name: string }).name); + const rightName = getIdentifierName(node.right); + if (rightName && namespaceLocalNames.has(rightName)) { + wholeObjectUses.push(rightName); } } if (node.type === "CallExpression") { - const callExpression = node as unknown as CallExpression; - if (callExpression.callee.type === "MemberExpression" && !callExpression.callee.computed) { - const calleeMember = callExpression.callee as StaticMemberExpression; + const calleeMember = isWalkableNode(node.callee) ? node.callee : undefined; + if (calleeMember?.type === "MemberExpression" && !calleeMember.computed) { + const calleeObjectName = getIdentifierName(calleeMember.object); + const calleePropertyName = getIdentifierName(calleeMember.property); if ( - calleeMember.object.type === "Identifier" && - (calleeMember.object as { name: string }).name === "Object" && - WHOLE_OBJECT_FUNCTION_NAMES.has(calleeMember.property.name) + calleeObjectName === "Object" && + calleePropertyName && + WHOLE_OBJECT_FUNCTION_NAMES.has(calleePropertyName) && + Array.isArray(node.arguments) ) { - const firstArgument = callExpression.arguments[0]; - if ( - firstArgument && - firstArgument.type !== "SpreadElement" && - firstArgument.type === "Identifier" && - namespaceLocalNames.has((firstArgument as { name: string }).name) - ) { - wholeObjectUses.push((firstArgument as { name: string }).name); + const firstArgumentName = getIdentifierName(node.arguments[0]); + if (firstArgumentName && namespaceLocalNames.has(firstArgumentName)) { + wholeObjectUses.push(firstArgumentName); } } } @@ -1584,26 +1549,45 @@ interface WalkableNode { [key: string]: unknown; } +const isObjectRecord = (value: unknown): value is Record => + value !== null && typeof value === "object"; + const isWalkableNode = (value: unknown): value is WalkableNode => - Boolean(value) && typeof value === "object" && typeof (value as WalkableNode).type === "string"; + isObjectRecord(value) && typeof value.type === "string"; + +const getTemplateCookedValues = (expression: WalkableNode): string[] | undefined => { + if (!Array.isArray(expression.quasis)) return undefined; + const cookedValues: string[] = []; + for (const quasi of expression.quasis) { + if ( + !isObjectRecord(quasi) || + !isObjectRecord(quasi.value) || + typeof quasi.value.cooked !== "string" + ) { + return undefined; + } + cookedValues.push(quasi.value.cooked); + } + return cookedValues; +}; -const extractStringLiteralFromArgument = ( - callArguments: CallExpression["arguments"], -): string | undefined => { +const extractStringLiteralFromArgument = (callArguments: unknown): string | undefined => { + if (!Array.isArray(callArguments)) return undefined; const firstArgument = callArguments[0]; - if (!firstArgument) return undefined; + if (!isWalkableNode(firstArgument)) return undefined; if (firstArgument.type === "SpreadElement") return undefined; if (firstArgument.type !== "Literal") return undefined; - const literalValue = (firstArgument as StringLiteral).value; + const literalValue = firstArgument.value; return typeof literalValue === "string" ? literalValue : undefined; }; -const extractGlobPatterns = (callArguments: CallExpression["arguments"]): string[] => { +const extractGlobPatterns = (callArguments: unknown): string[] => { + if (!Array.isArray(callArguments)) return []; const firstArgument = callArguments[0]; - if (!firstArgument || firstArgument.type === "SpreadElement") return []; + if (!isWalkableNode(firstArgument) || firstArgument.type === "SpreadElement") return []; if (firstArgument.type === "Literal") { - const literalValue = (firstArgument as StringLiteral).value; + const literalValue = firstArgument.value; if ( typeof literalValue === "string" && (literalValue.startsWith("./") || literalValue.startsWith("../")) @@ -1614,30 +1598,40 @@ const extractGlobPatterns = (callArguments: CallExpression["arguments"]): string } if (firstArgument.type === "ArrayExpression") { - const arrayExpression = firstArgument as unknown as { - elements: Array<{ type: string; value?: unknown }>; - }; - return arrayExpression.elements - .filter( - (element): element is { type: "Literal"; value: string } => - element.type === "Literal" && - typeof element.value === "string" && - ((element.value as string).startsWith("./") || - (element.value as string).startsWith("../")), - ) - .map((element) => element.value); + if (!Array.isArray(firstArgument.elements)) return []; + return firstArgument.elements.flatMap((element) => { + if ( + !isWalkableNode(element) || + element.type !== "Literal" || + typeof element.value !== "string" || + (!element.value.startsWith("./") && !element.value.startsWith("../")) + ) { + return []; + } + return [element.value]; + }); } return []; }; -const extractRegexGlobSuffix = (callArguments: CallExpression["arguments"]): string | undefined => { +interface RegexMetadata { + pattern: string; +} + +const isRegexMetadata = (value: unknown): value is RegexMetadata => + value !== null && + typeof value === "object" && + "pattern" in value && + typeof value.pattern === "string"; + +const extractRegexGlobSuffix = (callArguments: unknown): string | undefined => { + if (!Array.isArray(callArguments)) return undefined; const thirdArgument = callArguments[2]; - if (!thirdArgument || thirdArgument.type === "SpreadElement") return undefined; + if (!isWalkableNode(thirdArgument) || thirdArgument.type === "SpreadElement") return undefined; if (thirdArgument.type !== "Literal") return undefined; - const regExpValue = (thirdArgument as unknown as { regex?: { pattern: string } }).regex; - if (!regExpValue) return undefined; - const pattern = regExpValue.pattern; + if (!isRegexMetadata(thirdArgument.regex)) return undefined; + const pattern = thirdArgument.regex.pattern; const extensionMatch = pattern.match(/^\\\.([\w|]+)\$$/); if (extensionMatch) { const extensions = extensionMatch[1].split("|"); @@ -1647,9 +1641,10 @@ const extractRegexGlobSuffix = (callArguments: CallExpression["arguments"]): str return undefined; }; -const hasMockFactoryArgument = (callExpression: CallExpression): boolean => { - const secondArgument = callExpression.arguments[1]; - if (!secondArgument) return false; +const hasMockFactoryArgument = (callArguments: unknown): boolean => { + if (!Array.isArray(callArguments)) return false; + const secondArgument = callArguments[1]; + if (!isWalkableNode(secondArgument)) return false; if (secondArgument.type === "SpreadElement") return false; return ( secondArgument.type === "ArrowFunctionExpression" || @@ -1681,27 +1676,24 @@ const collectDynamicImports = ( ): void => { const walkNode = (node: WalkableNode): void => { if (node.type === "ImportExpression") { - const importExpression = node as unknown as ImportExpression; - const sourceExpression = importExpression.source; + const sourceExpression = isWalkableNode(node.source) ? node.source : undefined; + if (!sourceExpression) return; if (sourceExpression.type === "Literal") { - const specifierValue = (sourceExpression as StringLiteral).value; - if (specifierValue) { + if (typeof sourceExpression.value === "string" && sourceExpression.value) { imports.push({ - specifier: specifierValue, + specifier: sourceExpression.value, importedNames: [createNamespaceImportBinding()], isTypeOnly: false, isDynamic: true, isSideEffect: false, - line: getLineFromOffset(sourceText, importExpression.start), - column: getColumnFromOffset(sourceText, importExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } else if (sourceExpression.type === "TemplateLiteral") { - const templateLiteral = sourceExpression as unknown as { - quasis: Array<{ value: { cooked: string } }>; - }; - if (templateLiteral.quasis.length >= 2) { - const globPattern = templateLiteral.quasis.map((quasi) => quasi.value.cooked).join("*"); + const cookedValues = getTemplateCookedValues(sourceExpression); + if (cookedValues && cookedValues.length >= 2) { + const globPattern = cookedValues.join("*"); if (globPattern.startsWith("./") || globPattern.startsWith("../")) { imports.push({ specifier: globPattern, @@ -1710,8 +1702,8 @@ const collectDynamicImports = ( isDynamic: true, isSideEffect: false, isGlob: true, - line: getLineFromOffset(sourceText, importExpression.start), - column: getColumnFromOffset(sourceText, importExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } @@ -1720,10 +1712,9 @@ const collectDynamicImports = ( } if (node.type === "CallExpression") { - const callExpression = node as unknown as CallExpression; - - if (callExpression.callee.type === "Identifier" && callExpression.callee.name === "require") { - const requireSpecifier = extractStringLiteralFromArgument(callExpression.arguments); + const callee = isWalkableNode(node.callee) ? node.callee : undefined; + if (getIdentifierName(callee) === "require") { + const requireSpecifier = extractStringLiteralFromArgument(node.arguments); if (requireSpecifier) { imports.push({ specifier: requireSpecifier, @@ -1731,21 +1722,18 @@ const collectDynamicImports = ( isTypeOnly: false, isDynamic: true, isSideEffect: false, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } - if (callExpression.callee.type === "MemberExpression" && !callExpression.callee.computed) { - const memberExpression = callExpression.callee as StaticMemberExpression; + if (callee?.type === "MemberExpression" && !callee.computed) { + const objectName = getIdentifierName(callee.object); + const propertyName = getIdentifierName(callee.property); - if ( - memberExpression.object.type === "Identifier" && - memberExpression.object.name === "require" && - memberExpression.property.name === "resolve" - ) { - const resolveSpecifier = extractStringLiteralFromArgument(callExpression.arguments); + if (objectName === "require" && propertyName === "resolve") { + const resolveSpecifier = extractStringLiteralFromArgument(node.arguments); if (resolveSpecifier) { imports.push({ specifier: resolveSpecifier, @@ -1753,18 +1741,14 @@ const collectDynamicImports = ( isTypeOnly: false, isDynamic: true, isSideEffect: false, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } - if ( - memberExpression.object.type === "Identifier" && - (memberExpression.object.name === "vi" || memberExpression.object.name === "jest") && - memberExpression.property.name === "mock" - ) { - const mockSpecifier = extractStringLiteralFromArgument(callExpression.arguments); + if ((objectName === "vi" || objectName === "jest") && propertyName === "mock") { + const mockSpecifier = extractStringLiteralFromArgument(node.arguments); if (mockSpecifier) { imports.push({ specifier: mockSpecifier, @@ -1772,11 +1756,11 @@ const collectDynamicImports = ( isTypeOnly: false, isDynamic: true, isSideEffect: true, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); - const hasFactoryArgument = hasMockFactoryArgument(callExpression); + const hasFactoryArgument = hasMockFactoryArgument(node.arguments); const autoMockSibling = synthesizeAutoMockSibling(mockSpecifier); if (!hasFactoryArgument && autoMockSibling) { imports.push({ @@ -1785,17 +1769,18 @@ const collectDynamicImports = ( isTypeOnly: false, isDynamic: true, isSideEffect: true, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } } if ( - memberExpression.object.type === "MetaProperty" && - memberExpression.property.name === "glob" + isWalkableNode(callee.object) && + callee.object.type === "MetaProperty" && + propertyName === "glob" ) { - const globPatterns = extractGlobPatterns(callExpression.arguments); + const globPatterns = extractGlobPatterns(node.arguments); for (const globPattern of globPatterns) { imports.push({ specifier: globPattern, @@ -1804,31 +1789,31 @@ const collectDynamicImports = ( isDynamic: true, isSideEffect: false, isGlob: true, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } - if ( - memberExpression.object.type === "Identifier" && - memberExpression.object.name === "require" && - memberExpression.property.name === "context" - ) { - const directoryArgument = extractStringLiteralFromArgument(callExpression.arguments); + if (objectName === "require" && propertyName === "context") { + const directoryArgument = extractStringLiteralFromArgument(node.arguments); if ( directoryArgument && (directoryArgument.startsWith("./") || directoryArgument.startsWith("../")) ) { const hasRegexArgument = - callExpression.arguments.length >= 3 && - callExpression.arguments[2].type !== "SpreadElement"; - const regexSuffix = extractRegexGlobSuffix(callExpression.arguments); + Array.isArray(node.arguments) && + node.arguments.length >= 3 && + isWalkableNode(node.arguments[2]) && + node.arguments[2].type !== "SpreadElement"; + const regexSuffix = extractRegexGlobSuffix(node.arguments); const canResolveFilter = !hasRegexArgument || Boolean(regexSuffix); if (canResolveFilter) { const isRecursive = - callExpression.arguments[1]?.type === "Literal" && - (callExpression.arguments[1] as unknown as { value: unknown }).value === true; + Array.isArray(node.arguments) && + isWalkableNode(node.arguments[1]) && + node.arguments[1].type === "Literal" && + node.arguments[1].value === true; const contextGlobPrefix = isRecursive ? `${directoryArgument}/**/` : `${directoryArgument}/`; @@ -1842,8 +1827,8 @@ const collectDynamicImports = ( isDynamic: true, isSideEffect: false, isGlob: true, - line: getLineFromOffset(sourceText, callExpression.start), - column: getColumnFromOffset(sourceText, callExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } @@ -1852,23 +1837,16 @@ const collectDynamicImports = ( } if (node.type === "NewExpression") { - const newExpression = node as unknown as { - callee: Expression; - arguments: CallExpression["arguments"]; - start: number; - }; - if ( - newExpression.callee.type === "Identifier" && - (newExpression.callee as { name: string }).name === "URL" && - newExpression.arguments.length >= 2 - ) { - const secondArgument = newExpression.arguments[1]; + const calleeName = getIdentifierName(node.callee); + if (calleeName === "URL" && Array.isArray(node.arguments) && node.arguments.length >= 2) { + const secondArgument = isWalkableNode(node.arguments[1]) ? node.arguments[1] : undefined; const isImportMetaUrl = - secondArgument.type === "MemberExpression" && - (secondArgument as unknown as StaticMemberExpression).object.type === "MetaProperty" && - (secondArgument as unknown as StaticMemberExpression).property.name === "url"; + secondArgument?.type === "MemberExpression" && + isWalkableNode(secondArgument.object) && + secondArgument.object.type === "MetaProperty" && + getIdentifierName(secondArgument.property) === "url"; if (isImportMetaUrl) { - const urlSpecifier = extractStringLiteralFromArgument(newExpression.arguments); + const urlSpecifier = extractStringLiteralFromArgument(node.arguments); if (urlSpecifier) { imports.push({ specifier: urlSpecifier, @@ -1876,8 +1854,8 @@ const collectDynamicImports = ( isTypeOnly: false, isDynamic: true, isSideEffect: true, - line: getLineFromOffset(sourceText, newExpression.start), - column: getColumnFromOffset(sourceText, newExpression.start), + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), }); } } @@ -1885,65 +1863,68 @@ const collectDynamicImports = ( } if (node.type === "Decorator") { - const decoratorNode = node as unknown as { expression: WalkableNode }; - const expression = decoratorNode.expression; - if (expression?.type === "CallExpression") { - const callNode = expression as unknown as CallExpression; - const callee = callNode.callee; - if (callee.type === "Identifier" && (callee as { name: string }).name === "Component") { - const objectArgument = callNode.arguments[0]; - if (objectArgument?.type === "ObjectExpression") { - const objectProperties = ( - objectArgument as unknown as { properties: Array } - ).properties; - for (const property of objectProperties) { - if (property.type !== "ObjectProperty" && property.type !== "Property") continue; - const propertyKey = ( - property as unknown as { key: { name?: string; value?: string } } - ).key; - const propertyName = propertyKey?.name ?? propertyKey?.value; - const propertyValue = (property as unknown as { value: WalkableNode }).value; - if (propertyName === "templateUrl" && propertyValue?.type === "Literal") { - const templatePath = (propertyValue as unknown as StringLiteral).value; - if (templatePath) { - imports.push({ - specifier: templatePath.startsWith(".") ? templatePath : `./${templatePath}`, - importedNames: [], - isTypeOnly: false, - isDynamic: false, - isSideEffect: true, - line: getLineFromOffset(sourceText, property.start), - column: getColumnFromOffset(sourceText, property.start), - }); - } - } - if ((propertyName === "styleUrl" || propertyName === "styleUrls") && propertyValue) { - const styleUrlValues: string[] = []; - if (propertyValue.type === "Literal") { - const singleValue = (propertyValue as unknown as StringLiteral).value; - if (singleValue) styleUrlValues.push(singleValue); - } else if (propertyValue.type === "ArrayExpression") { - const arrayElements = ( - propertyValue as unknown as { elements: Array } - ).elements; - for (const element of arrayElements) { - if (element?.type === "Literal") { - const elementValue = (element as unknown as StringLiteral).value; - if (elementValue) styleUrlValues.push(elementValue); - } + const expression = isWalkableNode(node.expression) ? node.expression : undefined; + if ( + expression?.type === "CallExpression" && + getIdentifierName(expression.callee) === "Component" + ) { + const objectArgument = Array.isArray(expression.arguments) + ? expression.arguments[0] + : undefined; + if (isWalkableNode(objectArgument) && objectArgument.type === "ObjectExpression") { + const objectProperties = Array.isArray(objectArgument.properties) + ? objectArgument.properties.filter(isWalkableNode) + : []; + for (const property of objectProperties) { + if (property.type !== "ObjectProperty" && property.type !== "Property") continue; + const propertyKey = isWalkableNode(property.key) ? property.key : undefined; + const propertyName = getIdentifierName(propertyKey) ?? propertyKey?.value; + const propertyValue = isWalkableNode(property.value) ? property.value : undefined; + if ( + propertyName === "templateUrl" && + propertyValue?.type === "Literal" && + typeof propertyValue.value === "string" && + propertyValue.value + ) { + const templatePath = propertyValue.value; + imports.push({ + specifier: templatePath.startsWith(".") ? templatePath : `./${templatePath}`, + importedNames: [], + isTypeOnly: false, + isDynamic: false, + isSideEffect: true, + line: getLineFromOffset(sourceText, property.start), + column: getColumnFromOffset(sourceText, property.start), + }); + } + if ((propertyName === "styleUrl" || propertyName === "styleUrls") && propertyValue) { + const styleUrlValues: string[] = []; + if (propertyValue.type === "Literal" && typeof propertyValue.value === "string") { + styleUrlValues.push(propertyValue.value); + } else if ( + propertyValue.type === "ArrayExpression" && + Array.isArray(propertyValue.elements) + ) { + for (const element of propertyValue.elements) { + if ( + isWalkableNode(element) && + element.type === "Literal" && + typeof element.value === "string" + ) { + styleUrlValues.push(element.value); } } - for (const styleUrl of styleUrlValues) { - imports.push({ - specifier: styleUrl.startsWith(".") ? styleUrl : `./${styleUrl}`, - importedNames: [], - isTypeOnly: false, - isDynamic: false, - isSideEffect: true, - line: getLineFromOffset(sourceText, property.start), - column: getColumnFromOffset(sourceText, property.start), - }); - } + } + for (const styleUrl of styleUrlValues) { + imports.push({ + specifier: styleUrl.startsWith(".") ? styleUrl : `./${styleUrl}`, + importedNames: [], + isTypeOnly: false, + isDynamic: false, + isSideEffect: true, + line: getLineFromOffset(sourceText, property.start), + column: getColumnFromOffset(sourceText, property.start), + }); } } } @@ -1975,16 +1956,13 @@ const ROUTE_CALL_FILE_ARG_INDEX: Record = { const extractStringFromExpression = (expression: WalkableNode): string | undefined => { if (expression.type === "Literal") { - const literalValue = (expression as unknown as StringLiteral).value; + const literalValue = expression.value; return typeof literalValue === "string" ? literalValue : undefined; } if (expression.type === "TemplateLiteral") { - const templateLiteral = expression as unknown as { - quasis: Array<{ value: { cooked: string } }>; - expressions: unknown[]; - }; - if (templateLiteral.expressions.length === 0 && templateLiteral.quasis.length === 1) { - return templateLiteral.quasis[0]?.value.cooked; + const cookedValues = getTemplateCookedValues(expression); + if (Array.isArray(expression.expressions) && expression.expressions.length === 0) { + return cookedValues?.length === 1 ? cookedValues[0] : undefined; } } return undefined; @@ -2002,17 +1980,14 @@ export const extractReactRouterRouteModuleEntries = (routesFilePath: string): st const walkForRouteCalls = (node: WalkableNode): void => { if (node.type === "CallExpression") { - const callExpression = node as unknown as CallExpression; - const callee = callExpression.callee; - - if (callee.type === "Identifier") { - const calleeName = (callee as { name: string }).name; + const calleeName = getIdentifierName(node.callee); + if (calleeName) { const fileArgumentIndex = ROUTE_CALL_FILE_ARG_INDEX[calleeName]; - if (fileArgumentIndex !== undefined) { - const fileArgument = callExpression.arguments[fileArgumentIndex]; - if (fileArgument && fileArgument.type !== "SpreadElement") { - const filePath = extractStringFromExpression(fileArgument as unknown as WalkableNode); + if (fileArgumentIndex !== undefined && Array.isArray(node.arguments)) { + const fileArgument = node.arguments[fileArgumentIndex]; + if (isWalkableNode(fileArgument) && fileArgument.type !== "SpreadElement") { + const filePath = extractStringFromExpression(fileArgument); if (filePath) { modulePaths.push(filePath); } diff --git a/packages/deslop-js/src/config.ts b/packages/deslop-js/src/config.ts new file mode 100644 index 000000000..7c5b3acd0 --- /dev/null +++ b/packages/deslop-js/src/config.ts @@ -0,0 +1,89 @@ +import { resolve } from "node:path"; +import { + DEFAULT_COGNITIVE_THRESHOLD, + DEFAULT_CYCLOMATIC_THRESHOLD, + DEFAULT_DUPLICATE_BLOCK_MIN_LINES, + DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, + DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, + DEFAULT_ENTRY_GLOBS, + DEFAULT_EXTENSIONS, + DEFAULT_FUNCTION_LINE_THRESHOLD, + DEFAULT_PARAM_COUNT_THRESHOLD, + DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, +} from "./constants.js"; +import type { DeslopConfig } from "./types.js"; + +const fillSemanticConfig = ( + semanticOverrides: Partial | undefined, +): DeslopConfig["semantic"] => { + const overrides = semanticOverrides ?? {}; + return { + enabled: overrides.enabled ?? true, + reportUnusedTypes: overrides.reportUnusedTypes ?? true, + reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true, + reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false, + reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true, + reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true, + reportRoundTripAliases: overrides.reportRoundTripAliases ?? true, + decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, + }; +}; + +const fillDuplicateBlocksConfig = ( + duplicateBlocksOverrides: Partial | undefined, +): DeslopConfig["duplicateBlocks"] => { + const overrides = duplicateBlocksOverrides ?? {}; + return { + enabled: overrides.enabled ?? true, + mode: overrides.mode ?? "semantic", + minTokens: overrides.minTokens ?? DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, + minLines: overrides.minLines ?? DEFAULT_DUPLICATE_BLOCK_MIN_LINES, + minOccurrences: overrides.minOccurrences ?? DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, + skipLocal: overrides.skipLocal ?? false, + }; +}; + +const fillFeatureFlagsConfig = ( + featureFlagOverrides: Partial | undefined, +): DeslopConfig["featureFlags"] => { + const overrides = featureFlagOverrides ?? {}; + return { + enabled: overrides.enabled ?? true, + extraEnvPrefixes: overrides.extraEnvPrefixes ?? [], + extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [], + detectConfigObjects: overrides.detectConfigObjects ?? false, + }; +}; + +const fillComplexityConfig = ( + complexityOverrides: Partial | undefined, +): DeslopConfig["complexity"] => { + const overrides = complexityOverrides ?? {}; + return { + enabled: overrides.enabled ?? true, + cyclomaticThreshold: overrides.cyclomaticThreshold ?? DEFAULT_CYCLOMATIC_THRESHOLD, + cognitiveThreshold: overrides.cognitiveThreshold ?? DEFAULT_COGNITIVE_THRESHOLD, + paramCountThreshold: overrides.paramCountThreshold ?? DEFAULT_PARAM_COUNT_THRESHOLD, + functionLineThreshold: overrides.functionLineThreshold ?? DEFAULT_FUNCTION_LINE_THRESHOLD, + }; +}; + +export const defineConfig = ( + options: Partial & { rootDir: string }, +): DeslopConfig => ({ + rootDir: resolve(options.rootDir), + entryPatterns: options.entryPatterns ?? DEFAULT_ENTRY_GLOBS, + ignorePatterns: options.ignorePatterns ?? [], + includeExtensions: options.includeExtensions ?? DEFAULT_EXTENSIONS, + tsConfigPath: options.tsConfigPath, + paths: options.paths, + incrementalCachePath: options.incrementalCachePath, + reportTypes: options.reportTypes ?? false, + includeEntryExports: options.includeEntryExports ?? false, + reportRedundancy: options.reportRedundancy ?? true, + reportCodeQuality: options.reportCodeQuality ?? true, + semantic: fillSemanticConfig(options.semantic), + duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks), + featureFlags: fillFeatureFlagsConfig(options.featureFlags), + complexity: fillComplexityConfig(options.complexity), +}); diff --git a/packages/deslop-js/src/index.ts b/packages/deslop-js/src/index.ts index 322d5ebf7..4e794c083 100644 --- a/packages/deslop-js/src/index.ts +++ b/packages/deslop-js/src/index.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { resolve } from "node:path"; import { existsSync, readFileSync } from "node:fs"; -import fg from "fast-glob"; import type { DeslopConfig, DeslopError, ScanResult } from "./types.js"; import { ConfigError, @@ -9,25 +8,14 @@ import { WorkspaceError, describeUnknownError, } from "./errors.js"; -import { - DEFAULT_DUPLICATE_BLOCK_MIN_LINES, - DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, - DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, - DEFAULT_COGNITIVE_THRESHOLD, - DEFAULT_CYCLOMATIC_THRESHOLD, - DEFAULT_FUNCTION_LINE_THRESHOLD, - DEFAULT_PARAM_COUNT_THRESHOLD, - DEFAULT_ENTRY_GLOBS, - DEFAULT_EXTENSIONS, - DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, - OUTPUT_DIRECTORIES, -} from "./constants.js"; +import { OUTPUT_DIRECTORIES } from "./constants.js"; import { collectSourceFiles, resolveEntries, getFrameworkExclusions } from "./collect/entries.js"; import { resolveWorkspaces } from "./collect/workspaces.js"; -import { parseSourceFile } from "./collect/parse.js"; import { parseFilesInParallel } from "./collect/parallel-parse.js"; import { createResolver } from "./resolver/resolve.js"; -import { buildDependencyGraph, type ModuleLinkInput } from "./linker/build.js"; +import { buildDependencyGraph } from "./linker/build.js"; +import { buildModuleLinkInputs } from "./linker/build-module-link-inputs.js"; +import { markFilenameRegistryEntries } from "./linker/mark-filename-registry-entries.js"; import { traceReachability } from "./linker/reachability.js"; import { resolveReExportChains } from "./linker/re-exports.js"; import { generateReport } from "./report/generate.js"; @@ -35,82 +23,11 @@ import { resolveEntriesInWorker } from "./collect/entries-in-worker.js"; import { loadSummaryCache } from "./summary-cache.js"; import { findMonorepoRoot } from "./utils/find-monorepo-root.js"; import { collectGitIgnoredPaths } from "./utils/collect-git-ignored-paths.js"; -import { normalizeRegistryModulePath } from "./utils/normalize-registry-module-path.js"; -const STYLE_EXTENSIONS = [".css", ".scss"]; +export { defineConfig } from "./config.js"; const REACT_NATIVE_ENABLERS = ["react-native", "expo"]; -const basenameFromPath = (filePath: string): string => { - const lastSlashIndex = filePath.lastIndexOf("/"); - return lastSlashIndex === -1 ? filePath : filePath.slice(lastSlashIndex + 1); -}; - -/** - * Dynamic registry pattern: many codebases use a central "schema/registry" - * module that lists tool/command/page filenames as string literals, then a - * runner spawns them via `path.resolve(dir, file)` or `import()`. Static - * analysis can't follow the indirection, so those targets get falsely - * flagged as unused. - * - * Heuristic: if a parsed string literal exactly matches the basename or - * extensionless path suffix of exactly one file in the project, treat that - * file as an entry point. Uniqueness guards against false-positives from - * common names like `index.ts` matching dozens of unrelated files. - */ -const markFilenameRegistryEntries = ( - moduleGraph: ReturnType, -): void => { - const basenameToModuleIndex = new Map(); - const pathSuffixToModuleIndex = new Map(); - const referencedPathSet = new Set(); - for (const module of moduleGraph.modules) { - for (const referencedFilename of module.referencedFilenames) { - if (referencedFilename.includes("/")) { - referencedPathSet.add(normalizeRegistryModulePath(referencedFilename)); - } - } - } - - for (const module of moduleGraph.modules) { - const basename = basenameFromPath(module.fileId.path); - const existing = basenameToModuleIndex.get(basename); - if (existing === undefined) { - basenameToModuleIndex.set(basename, module.fileId.index); - } else if (existing !== "ambiguous") { - basenameToModuleIndex.set(basename, "ambiguous"); - } - - const extensionlessPath = normalizeRegistryModulePath(module.fileId.path); - let slashIndex = extensionlessPath.indexOf("/"); - while (slashIndex !== -1) { - const pathSuffix = extensionlessPath.slice(slashIndex + 1); - slashIndex = extensionlessPath.indexOf("/", slashIndex + 1); - if (!referencedPathSet.has(pathSuffix)) continue; - const existingPathIndex = pathSuffixToModuleIndex.get(pathSuffix); - if (existingPathIndex === undefined) { - pathSuffixToModuleIndex.set(pathSuffix, module.fileId.index); - } else if (existingPathIndex !== "ambiguous") { - pathSuffixToModuleIndex.set(pathSuffix, "ambiguous"); - } - } - } - - for (const module of moduleGraph.modules) { - for (const referencedFilename of module.referencedFilenames) { - const normalizedReference = normalizeRegistryModulePath(referencedFilename); - const targetIndex = referencedFilename.includes("/") - ? pathSuffixToModuleIndex.get(normalizedReference) - : basenameToModuleIndex.get(referencedFilename); - if (typeof targetIndex !== "number") continue; - const targetModule = moduleGraph.modules[targetIndex]; - if (!targetModule || targetModule.isEntryPoint) continue; - if (targetModule.fileId.index === module.fileId.index) continue; - targetModule.isEntryPoint = true; - } - } -}; - const detectReactNative = ( rootDir: string, workspacePackages: Array<{ directory: string }>, @@ -208,109 +125,6 @@ export type { DeslopErrorSeverity, } from "./types.js"; -/** - * Default flags below mark rules off-by-default. Rationale for each: - * - * - `reportUnusedClassMembers: false` — class-member dead-code detection - * requires whole-program semantic analysis to be sound (subclass overrides, - * structural typing, framework method-by-name invocation like `@HttpGet`). - * When enabled on real React/Effect/NestJS codebases it produces a high - * rate of stylistic-FP findings (lifecycle methods, framework hooks). Off - * by default until the heuristics are tightened. Opt in via - * `semantic.reportUnusedClassMembers = true` when you accept the noise. - * - * - `reportTypes: false` — type-only exports are over-represented in - * barrel re-exports (the canonical `export type * from "./types"` pattern) - * and are rarely actionable signal. Off by default; opt in when auditing - * a type-heavy package. - * - * - `includeEntryExports: false` — exports from entry-point files are - * "API surface" and intentionally exported for external consumers; flagging - * them as "unused" is noise within a single repo scan. Opt in when auditing - * a package boundary (e.g. before deleting public APIs). - * - * - `reportRedundancy: true` — on because redundancy findings are mostly - * high-signal and the detectors carry their own confidence tiers. - * - * - `duplicateBlocks: undefined` — token-based copy-paste detection (suffix - * array + LCP) is opt-in. It re-parses every source - * file to emit a token stream and adds significant runtime to the scan. - * Pass `duplicateBlocks: { enabled: true }` to turn it on. - */ -const fillSemanticConfig = ( - semanticOverrides: Partial | undefined, -): DeslopConfig["semantic"] => { - const overrides = semanticOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - reportUnusedTypes: overrides.reportUnusedTypes ?? true, - reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true, - reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false, - reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true, - reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true, - reportRoundTripAliases: overrides.reportRoundTripAliases ?? true, - decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, - }; -}; - -const fillDuplicateBlocksConfig = ( - duplicateBlocksOverrides: Partial | undefined, -): DeslopConfig["duplicateBlocks"] => { - const overrides = duplicateBlocksOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - mode: overrides.mode ?? "semantic", - minTokens: overrides.minTokens ?? DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, - minLines: overrides.minLines ?? DEFAULT_DUPLICATE_BLOCK_MIN_LINES, - minOccurrences: overrides.minOccurrences ?? DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, - skipLocal: overrides.skipLocal ?? false, - }; -}; - -const fillFeatureFlagsConfig = ( - flagsOverrides: Partial | undefined, -): DeslopConfig["featureFlags"] => { - const overrides = flagsOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - extraEnvPrefixes: overrides.extraEnvPrefixes ?? [], - extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [], - detectConfigObjects: overrides.detectConfigObjects ?? false, - }; -}; - -const fillComplexityConfig = ( - complexityOverrides: Partial | undefined, -): DeslopConfig["complexity"] => { - const overrides = complexityOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - cyclomaticThreshold: overrides.cyclomaticThreshold ?? DEFAULT_CYCLOMATIC_THRESHOLD, - cognitiveThreshold: overrides.cognitiveThreshold ?? DEFAULT_COGNITIVE_THRESHOLD, - paramCountThreshold: overrides.paramCountThreshold ?? DEFAULT_PARAM_COUNT_THRESHOLD, - functionLineThreshold: overrides.functionLineThreshold ?? DEFAULT_FUNCTION_LINE_THRESHOLD, - }; -}; -export const defineConfig = ( - options: Partial & { rootDir: string }, -): DeslopConfig => ({ - rootDir: resolve(options.rootDir), - entryPatterns: options.entryPatterns ?? DEFAULT_ENTRY_GLOBS, - ignorePatterns: options.ignorePatterns ?? [], - includeExtensions: options.includeExtensions ?? DEFAULT_EXTENSIONS, - tsConfigPath: options.tsConfigPath, - paths: options.paths, - incrementalCachePath: options.incrementalCachePath, - reportTypes: options.reportTypes ?? false, - includeEntryExports: options.includeEntryExports ?? false, - reportRedundancy: options.reportRedundancy ?? true, - reportCodeQuality: options.reportCodeQuality ?? true, - semantic: fillSemanticConfig(options.semantic), - duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks), - featureFlags: fillFeatureFlagsConfig(options.featureFlags), - complexity: fillComplexityConfig(options.complexity), -}); - const buildEmptyScanResult = (errors: DeslopError[], elapsedMs: number): ScanResult => ({ unusedFiles: [], unusedExports: [], @@ -588,173 +402,18 @@ export const analyze = async (config: DeslopConfig): Promise => { } const discoveredEntries = await entriesPromise; - const productionEntrySet = new Set(discoveredEntries.productionEntries); - const testEntrySet = new Set(discoveredEntries.testEntries); - const alwaysUsedFileSet = new Set(discoveredEntries.alwaysUsedFiles); - - const graphInputs: ModuleLinkInput[] = []; - - for (let fileIndex = 0; fileIndex < files.length; fileIndex++) { - const file = files[fileIndex]; - const parsedModule = parsedModules[fileIndex]; - const resolvedImportMap = new Map>(); - - const safeResolveImport = ( - specifier: string, - ): ReturnType => { - try { - return resolveModuleThroughCache(specifier, file.path); - } catch (resolveError) { - setupErrors.push( - new ResolverError({ - severity: "warning", - message: `moduleResolver.resolveModule threw on specifier "${specifier}"`, - path: file.path, - detail: describeUnknownError(resolveError), - }), - ); - return { resolvedPath: undefined, isExternal: false, packageName: undefined }; - } - }; - - for (const importInfo of parsedModule.imports) { - if (importInfo.isGlob) { - const fileDir = dirname(file.path); - let expandedFiles: string[] = []; - try { - expandedFiles = fg.sync(importInfo.specifier, { - cwd: fileDir, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - } catch (globError) { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: `fast-glob threw on import glob "${importInfo.specifier}"`, - path: file.path, - detail: describeUnknownError(globError), - }), - ); - } - for (const expandedFile of expandedFiles) { - resolvedImportMap.set(expandedFile, { - resolvedPath: expandedFile, - isExternal: false, - packageName: undefined, - }); - } - resolvedImportMap.set(importInfo.specifier, { - resolvedPath: undefined, - isExternal: false, - packageName: undefined, - }); - continue; - } - resolvedImportMap.set(importInfo.specifier, safeResolveImport(importInfo.specifier)); - } - - for (const exportInfo of parsedModule.exports) { - if (exportInfo.isReExport && exportInfo.reExportSource) { - if (!resolvedImportMap.has(exportInfo.reExportSource)) { - resolvedImportMap.set( - exportInfo.reExportSource, - safeResolveImport(exportInfo.reExportSource), - ); - } - } - } - - const isAlwaysUsed = alwaysUsedFileSet.has(file.path); - graphInputs.push({ - fileId: file, - parsed: parsedModule, - resolvedImports: resolvedImportMap, - isEntryPoint: - isAlwaysUsed || productionEntrySet.has(file.path) || testEntrySet.has(file.path), - isTestEntry: testEntrySet.has(file.path), - isGitIgnored: gitIgnoredFileSet.has(file.path), - }); - } - - const discoveredFilePaths = new Set(files.map((file) => file.path)); - const styleFilesToAdd = new Set(); - - for (const input of graphInputs) { - for (const [, resolvedImport] of input.resolvedImports) { - if (!resolvedImport.resolvedPath || resolvedImport.isExternal) continue; - if (discoveredFilePaths.has(resolvedImport.resolvedPath)) continue; - const isStyleFile = STYLE_EXTENSIONS.some((ext) => - resolvedImport.resolvedPath!.endsWith(ext), - ); - if (isStyleFile && existsSync(resolvedImport.resolvedPath)) { - styleFilesToAdd.add(resolvedImport.resolvedPath); - } - } - } - - const styleFileQueue = [...styleFilesToAdd].sort(); - let nextFileIndex = files.length; - let styleFileQueueIndex = 0; - while (styleFileQueueIndex < styleFileQueue.length) { - const styleFilePath = styleFileQueue[styleFileQueueIndex]; - styleFileQueueIndex++; - if (discoveredFilePaths.has(styleFilePath)) continue; - - const styleSourceFile = { index: nextFileIndex, path: styleFilePath }; - const parsedStyleModule = parseSourceFile(styleFilePath); - const resolvedStyleImportMap = new Map< - string, - ReturnType - >(); - - for (const importInfo of parsedStyleModule.imports) { - let resolvedImport: ReturnType; - try { - resolvedImport = resolveModuleThroughCache(importInfo.specifier, styleFilePath); - } catch (styleResolveError) { - setupErrors.push( - new ResolverError({ - severity: "warning", - message: `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, - path: styleFilePath, - detail: describeUnknownError(styleResolveError), - }), - ); - resolvedImport = { resolvedPath: undefined, isExternal: false, packageName: undefined }; - } - resolvedStyleImportMap.set(importInfo.specifier, resolvedImport); - if (resolvedImport.resolvedPath && !discoveredFilePaths.has(resolvedImport.resolvedPath)) { - const isNestedStyle = STYLE_EXTENSIONS.some((ext) => - resolvedImport.resolvedPath!.endsWith(ext), - ); - if ( - isNestedStyle && - !styleFilesToAdd.has(resolvedImport.resolvedPath) && - existsSync(resolvedImport.resolvedPath) - ) { - styleFilesToAdd.add(resolvedImport.resolvedPath); - styleFileQueue.push(resolvedImport.resolvedPath); - } - } - } - - graphInputs.push({ - fileId: styleSourceFile, - parsed: parsedStyleModule, - resolvedImports: resolvedStyleImportMap, - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: gitIgnoredFileSet.has(styleFilePath), - }); - discoveredFilePaths.add(styleFilePath); - nextFileIndex++; - } + const moduleLinkInputsResult = buildModuleLinkInputs({ + files, + parsedModules, + resolvedEntries: discoveredEntries, + gitIgnoredFilePaths: gitIgnoredFileSet, + resolveModule: resolveModuleThroughCache, + }); + setupErrors.push(...moduleLinkInputsResult.errors); let moduleGraph: ReturnType; try { - moduleGraph = buildDependencyGraph(graphInputs); + moduleGraph = buildDependencyGraph(moduleLinkInputsResult.graphInputs); } catch (graphError) { setupErrors.push( new DetectorError({ diff --git a/packages/deslop-js/src/linker/build-module-link-inputs.ts b/packages/deslop-js/src/linker/build-module-link-inputs.ts new file mode 100644 index 000000000..7a47ed37d --- /dev/null +++ b/packages/deslop-js/src/linker/build-module-link-inputs.ts @@ -0,0 +1,207 @@ +import { dirname } from "node:path"; +import { existsSync } from "node:fs"; +import fg from "fast-glob"; +import type { DeslopError, ResolvedEntries, SourceFile } from "../types.js"; +import { ResolverError, WorkspaceError, describeUnknownError } from "../errors.js"; +import { parseSourceFile, type ParsedSource } from "../collect/parse.js"; +import type { ResolvedImport } from "../resolver/resolve.js"; +import type { ModuleLinkInput } from "./build.js"; + +interface BuildModuleLinkInputsOptions { + files: SourceFile[]; + parsedModules: ParsedSource[]; + resolvedEntries: ResolvedEntries; + gitIgnoredFilePaths: ReadonlySet; + resolveModule: (specifier: string, fromFile: string) => ResolvedImport; +} + +interface ModuleLinkInputsResult { + graphInputs: ModuleLinkInput[]; + errors: DeslopError[]; +} + +const STYLE_EXTENSIONS = [".css", ".scss"]; + +const isStyleFile = (filePath: string): boolean => + STYLE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); + +const unresolvedImport = (): ResolvedImport => ({ + resolvedPath: undefined, + isExternal: false, + packageName: undefined, +}); + +const buildSourceModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, +): ModuleLinkInputsResult => { + const errors: DeslopError[] = []; + const productionEntryPaths = new Set(options.resolvedEntries.productionEntries); + const testEntryPaths = new Set(options.resolvedEntries.testEntries); + const alwaysUsedFilePaths = new Set(options.resolvedEntries.alwaysUsedFiles); + const graphInputs: ModuleLinkInput[] = []; + + for (let fileIndex = 0; fileIndex < options.files.length; fileIndex++) { + const file = options.files[fileIndex]; + const parsedModule = options.parsedModules[fileIndex]; + const resolvedImports = new Map(); + const safelyResolveImport = (specifier: string): ResolvedImport => { + try { + return options.resolveModule(specifier, file.path); + } catch (resolveError) { + errors.push( + new ResolverError({ + severity: "warning", + message: `moduleResolver.resolveModule threw on specifier "${specifier}"`, + path: file.path, + detail: describeUnknownError(resolveError), + }), + ); + return unresolvedImport(); + } + }; + + for (const importInfo of parsedModule.imports) { + if (importInfo.isGlob) { + let expandedFilePaths: string[] = []; + try { + expandedFilePaths = fg.sync(importInfo.specifier, { + cwd: dirname(file.path), + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + } catch (globError) { + errors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: `fast-glob threw on import glob "${importInfo.specifier}"`, + path: file.path, + detail: describeUnknownError(globError), + }), + ); + } + for (const expandedFilePath of expandedFilePaths) { + resolvedImports.set(expandedFilePath, { + resolvedPath: expandedFilePath, + isExternal: false, + packageName: undefined, + }); + } + resolvedImports.set(importInfo.specifier, unresolvedImport()); + continue; + } + resolvedImports.set(importInfo.specifier, safelyResolveImport(importInfo.specifier)); + } + + for (const exportInfo of parsedModule.exports) { + if ( + exportInfo.isReExport && + exportInfo.reExportSource && + !resolvedImports.has(exportInfo.reExportSource) + ) { + resolvedImports.set( + exportInfo.reExportSource, + safelyResolveImport(exportInfo.reExportSource), + ); + } + } + + graphInputs.push({ + fileId: file, + parsed: parsedModule, + resolvedImports, + isEntryPoint: + alwaysUsedFilePaths.has(file.path) || + productionEntryPaths.has(file.path) || + testEntryPaths.has(file.path), + isTestEntry: testEntryPaths.has(file.path), + isGitIgnored: options.gitIgnoredFilePaths.has(file.path), + }); + } + + return { graphInputs, errors }; +}; + +const buildStyleModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, + sourceGraphInputs: ModuleLinkInput[], +): ModuleLinkInputsResult => { + const errors: DeslopError[] = []; + const graphInputs: ModuleLinkInput[] = []; + const discoveredFilePaths = new Set(options.files.map((file) => file.path)); + const pendingStyleFilePaths = new Set(); + for (const graphInput of sourceGraphInputs) { + for (const resolvedImport of graphInput.resolvedImports.values()) { + if ( + resolvedImport.resolvedPath && + !resolvedImport.isExternal && + !discoveredFilePaths.has(resolvedImport.resolvedPath) && + isStyleFile(resolvedImport.resolvedPath) && + existsSync(resolvedImport.resolvedPath) + ) { + pendingStyleFilePaths.add(resolvedImport.resolvedPath); + } + } + } + + const styleFileQueue = [...pendingStyleFilePaths].sort(); + let nextFileIndex = options.files.length; + for (let queueIndex = 0; queueIndex < styleFileQueue.length; queueIndex++) { + const styleFilePath = styleFileQueue[queueIndex]; + if (discoveredFilePaths.has(styleFilePath)) continue; + + const parsedStyleModule = parseSourceFile(styleFilePath); + const resolvedStyleImports = new Map(); + for (const importInfo of parsedStyleModule.imports) { + let resolvedImport: ResolvedImport; + try { + resolvedImport = options.resolveModule(importInfo.specifier, styleFilePath); + } catch (styleResolveError) { + errors.push( + new ResolverError({ + severity: "warning", + message: `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, + path: styleFilePath, + detail: describeUnknownError(styleResolveError), + }), + ); + resolvedImport = unresolvedImport(); + } + resolvedStyleImports.set(importInfo.specifier, resolvedImport); + if ( + resolvedImport.resolvedPath && + !discoveredFilePaths.has(resolvedImport.resolvedPath) && + isStyleFile(resolvedImport.resolvedPath) && + !pendingStyleFilePaths.has(resolvedImport.resolvedPath) && + existsSync(resolvedImport.resolvedPath) + ) { + pendingStyleFilePaths.add(resolvedImport.resolvedPath); + styleFileQueue.push(resolvedImport.resolvedPath); + } + } + + graphInputs.push({ + fileId: { index: nextFileIndex, path: styleFilePath }, + parsed: parsedStyleModule, + resolvedImports: resolvedStyleImports, + isEntryPoint: false, + isTestEntry: false, + isGitIgnored: options.gitIgnoredFilePaths.has(styleFilePath), + }); + discoveredFilePaths.add(styleFilePath); + nextFileIndex++; + } + + return { graphInputs, errors }; +}; + +export const buildModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, +): ModuleLinkInputsResult => { + const sourceResult = buildSourceModuleLinkInputs(options); + const styleResult = buildStyleModuleLinkInputs(options, sourceResult.graphInputs); + return { + graphInputs: [...sourceResult.graphInputs, ...styleResult.graphInputs], + errors: [...sourceResult.errors, ...styleResult.errors], + }; +}; diff --git a/packages/deslop-js/src/linker/mark-filename-registry-entries.ts b/packages/deslop-js/src/linker/mark-filename-registry-entries.ts new file mode 100644 index 000000000..c9c110fea --- /dev/null +++ b/packages/deslop-js/src/linker/mark-filename-registry-entries.ts @@ -0,0 +1,81 @@ +import type { DependencyGraph } from "../types.js"; +import { normalizeRegistryModulePath } from "../utils/normalize-registry-module-path.js"; + +interface RegistryModuleLookup { + basenameToModuleIndex: Map; + pathSuffixToModuleIndex: Map; +} + +const basenameFromPath = (filePath: string): string => { + const lastSlashIndex = filePath.lastIndexOf("/"); + return lastSlashIndex === -1 ? filePath : filePath.slice(lastSlashIndex + 1); +}; + +const recordUniqueModuleIndex = ( + moduleIndexByReference: Map, + reference: string, + moduleIndex: number, +): void => { + const existingModuleIndex = moduleIndexByReference.get(reference); + if (existingModuleIndex === undefined) { + moduleIndexByReference.set(reference, moduleIndex); + } else if (existingModuleIndex !== "ambiguous") { + moduleIndexByReference.set(reference, "ambiguous"); + } +}; + +const buildRegistryModuleLookup = (moduleGraph: DependencyGraph): RegistryModuleLookup => { + const basenameToModuleIndex = new Map(); + const pathSuffixToModuleIndex = new Map(); + const referencedPathSuffixes = new Set(); + + for (const module of moduleGraph.modules) { + for (const referencedFilename of module.referencedFilenames) { + if (referencedFilename.includes("/")) { + referencedPathSuffixes.add(normalizeRegistryModulePath(referencedFilename)); + } + } + } + + for (const module of moduleGraph.modules) { + recordUniqueModuleIndex( + basenameToModuleIndex, + basenameFromPath(module.fileId.path), + module.fileId.index, + ); + + const extensionlessPath = normalizeRegistryModulePath(module.fileId.path); + let slashIndex = extensionlessPath.indexOf("/"); + while (slashIndex !== -1) { + const pathSuffix = extensionlessPath.slice(slashIndex + 1); + slashIndex = extensionlessPath.indexOf("/", slashIndex + 1); + if (referencedPathSuffixes.has(pathSuffix)) { + recordUniqueModuleIndex(pathSuffixToModuleIndex, pathSuffix, module.fileId.index); + } + } + } + + return { basenameToModuleIndex, pathSuffixToModuleIndex }; +}; + +export const markFilenameRegistryEntries = (moduleGraph: DependencyGraph): void => { + const { basenameToModuleIndex, pathSuffixToModuleIndex } = buildRegistryModuleLookup(moduleGraph); + + for (const module of moduleGraph.modules) { + for (const referencedFilename of module.referencedFilenames) { + const targetModuleIndex = referencedFilename.includes("/") + ? pathSuffixToModuleIndex.get(normalizeRegistryModulePath(referencedFilename)) + : basenameToModuleIndex.get(referencedFilename); + if (typeof targetModuleIndex !== "number") continue; + + const targetModule = moduleGraph.modules[targetModuleIndex]; + if ( + targetModule && + !targetModule.isEntryPoint && + targetModule.fileId.index !== module.fileId.index + ) { + targetModule.isEntryPoint = true; + } + } + } +}; diff --git a/packages/deslop-js/src/report/typescript-smells.ts b/packages/deslop-js/src/report/typescript-smells.ts index 98066d6aa..3e6c56ec5 100644 --- a/packages/deslop-js/src/report/typescript-smells.ts +++ b/packages/deslop-js/src/report/typescript-smells.ts @@ -41,7 +41,7 @@ const parseSource = (filePath: string): ParsedSource | undefined => { } catch { return undefined; } - const rawComments = (parseResult as unknown as { comments?: unknown }).comments; + const rawComments = parseResult.comments; const comments = Array.isArray(rawComments) ? rawComments.filter(isParsedSourceComment) : []; return { programNode: parseResult.program, diff --git a/packages/deslop-js/src/resolver/resolve.ts b/packages/deslop-js/src/resolver/resolve.ts index 1446d3cda..fa0fd0e9b 100644 --- a/packages/deslop-js/src/resolver/resolve.ts +++ b/packages/deslop-js/src/resolver/resolve.ts @@ -1051,7 +1051,7 @@ export const createResolver = ( const cached = resolveResultCache.get(cacheKey); if (cached) return cached; - if (isBuiltinModule(cleanedSpecifier)) { + if (isPlatformBuiltinOrVirtualSpecifier(cleanedSpecifier)) { const resolvedResult: ResolvedImport = { resolvedPath: undefined, isExternal: true, @@ -1322,9 +1322,6 @@ const stripJsonComments = (content: string): string => { return result.replace(/,(\s*[}\]])/g, "$1"); }; -const isBuiltinModule = (specifier: string): boolean => - isPlatformBuiltinOrVirtualSpecifier(specifier); - const isBareSpecifier = (specifier: string): boolean => !specifier.startsWith(".") && !specifier.startsWith("/"); diff --git a/packages/deslop-js/src/summary-cache.ts b/packages/deslop-js/src/summary-cache.ts index 3c544f5c9..3640ca92b 100644 --- a/packages/deslop-js/src/summary-cache.ts +++ b/packages/deslop-js/src/summary-cache.ts @@ -356,33 +356,29 @@ const emptyStore = (scopeHash: string): PersistedSummaryCache => ({ packageFacts: {}, }); -// Top-level shape validation only; every entry is re-validated at lookup time -// so hand-corrupted (yet JSON-valid) entries degrade to per-item misses. The -// single boundary cast is the JSON-revival idiom shared with core's -// `failOpenReadJson`. +const isPersistedSummaryCache = (value: unknown): value is PersistedSummaryCache => + isRecordValue(value) && + value.version === SUMMARY_CACHE_SCHEMA_VERSION && + typeof value.scopeHash === "string" && + isRecordValue(value.summaries) && + isRecordValue(value.packageFacts); + const readPersistedStore = (cachePath: string, scopeHash: string): PersistedSummaryCache => { try { const parsed: unknown = JSON.parse(readFileSync(cachePath, "utf-8")); - if ( - isRecordValue(parsed) && - parsed.version === SUMMARY_CACHE_SCHEMA_VERSION && - parsed.scopeHash === scopeHash && - isRecordValue(parsed.summaries) && - isRecordValue(parsed.packageFacts) - ) { - const persisted = parsed as unknown as PersistedSummaryCache; + if (isPersistedSummaryCache(parsed) && parsed.scopeHash === scopeHash) { return { version: SUMMARY_CACHE_SCHEMA_VERSION, scopeHash, - fileList: isRecordValue(persisted.fileList) ? persisted.fileList : null, + fileList: isRecordValue(parsed.fileList) ? parsed.fileList : null, resolutions: - isRecordValue(persisted.resolutions) && - typeof persisted.resolutions.hash === "string" && - isRecordValue(persisted.resolutions.entries) - ? persisted.resolutions + isRecordValue(parsed.resolutions) && + typeof parsed.resolutions.hash === "string" && + isRecordValue(parsed.resolutions.entries) + ? parsed.resolutions : null, - summaries: persisted.summaries, - packageFacts: persisted.packageFacts, + summaries: parsed.summaries, + packageFacts: parsed.packageFacts, }; } } catch { @@ -456,29 +452,29 @@ const PERSISTED_SOURCE_ARRAY_FIELDS = [ "errors", ] as const; +const isPersistedParsedSource = (value: unknown): value is PersistedParsedSource => + isRecordValue(value) && + PERSISTED_SOURCE_ARRAY_FIELDS.every((fieldName) => isOptionalArray(value[fieldName])); + const reviveParsedSource = (persisted: unknown): ParsedSource | null => { - if (!isRecordValue(persisted)) return null; - for (const fieldName of PERSISTED_SOURCE_ARRAY_FIELDS) { - if (!isOptionalArray(persisted[fieldName])) return null; - } - const source = persisted as unknown as PersistedParsedSource; - const persistedErrors = source.errors ?? []; + if (!isPersistedParsedSource(persisted)) return null; + const persistedErrors = persisted.errors ?? []; if (!persistedErrors.every(isPersistedErrorJson)) return null; return { - imports: source.imports ?? [], - exports: source.exports ?? [], - memberAccesses: source.memberAccesses ?? [], - wholeObjectUses: source.wholeObjectUses ?? [], - localIdentifierReferences: source.localIdentifierReferences ?? [], - topLevelImportReferences: source.topLevelImportReferences ?? [], - referencedFilenames: source.referencedFilenames ?? [], - redundantTypePatterns: source.redundantTypePatterns ?? [], - identityWrappers: source.identityWrappers ?? [], - typeDefinitionHashes: source.typeDefinitionHashes ?? [], - inlineTypeLiterals: source.inlineTypeLiterals ?? [], - simplifiableFunctions: source.simplifiableFunctions ?? [], - simplifiableExpressions: source.simplifiableExpressions ?? [], - duplicateConstantCandidates: source.duplicateConstantCandidates ?? [], + imports: persisted.imports ?? [], + exports: persisted.exports ?? [], + memberAccesses: persisted.memberAccesses ?? [], + wholeObjectUses: persisted.wholeObjectUses ?? [], + localIdentifierReferences: persisted.localIdentifierReferences ?? [], + topLevelImportReferences: persisted.topLevelImportReferences ?? [], + referencedFilenames: persisted.referencedFilenames ?? [], + redundantTypePatterns: persisted.redundantTypePatterns ?? [], + identityWrappers: persisted.identityWrappers ?? [], + typeDefinitionHashes: persisted.typeDefinitionHashes ?? [], + inlineTypeLiterals: persisted.inlineTypeLiterals ?? [], + simplifiableFunctions: persisted.simplifiableFunctions ?? [], + simplifiableExpressions: persisted.simplifiableExpressions ?? [], + duplicateConstantCandidates: persisted.duplicateConstantCandidates ?? [], errors: persistedErrors.map( (errorJson) => new DeslopError({ diff --git a/packages/eslint-plugin-react-doctor/src/index.ts b/packages/eslint-plugin-react-doctor/src/index.ts index 081534c36..5aef38687 100644 --- a/packages/eslint-plugin-react-doctor/src/index.ts +++ b/packages/eslint-plugin-react-doctor/src/index.ts @@ -77,7 +77,7 @@ const wrapAsEslintRule = (ruleName: string, ruleImpl: EslintAdapterRule): Eslint }, schema: [], }, - create: (context: EslintRuleContext) => ruleImpl.create(context), + create: ruleImpl.create, }); const eslintShapedRules: Record = Object.fromEntries( diff --git a/packages/evals/src/matrix-artifact.ts b/packages/evals/src/matrix-artifact.ts index b649f143e..737a52ded 100644 --- a/packages/evals/src/matrix-artifact.ts +++ b/packages/evals/src/matrix-artifact.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { once } from "node:events"; -import { createReadStream, createWriteStream } from "node:fs"; +import { createWriteStream } from "node:fs"; import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { finished } from "node:stream/promises"; @@ -13,6 +13,7 @@ import type { MatrixBaseArtifactBinding, } from "./utils/matrix-base-artifact-binding.js"; import { materializeMatrixBaseArtifactBinding } from "./utils/matrix-base-artifact-binding.js"; +import { hashFileSha256 } from "./utils/hash-file-sha256.js"; import { serializeNdjsonRecord } from "./utils/serialize-ndjson-record.js"; import { writeWritableContents } from "./utils/write-writable-contents.js"; @@ -24,16 +25,8 @@ export interface MatrixArtifactProvenance { expectedProjectCount: number; recordCount: number; failedRecordCount: number; - artifact: { - path: string; - sha256: string; - byteLength: number; - }; - corpusManifest: { - path: string; - sha256: string; - byteLength: number; - }; + artifact: MatrixArtifactFile; + corpusManifest: MatrixArtifactFile; descriptorSha256: string; impactManifestSha256: string; rulesSha256: string; @@ -42,18 +35,18 @@ export interface MatrixArtifactProvenance { evaluation?: EvaluationProvenance; } +interface MatrixArtifactFile { + path: string; + sha256: string; + byteLength: number; +} + export interface MatrixArtifactWriter { write: (record: CorpusEvaluationRecord) => Promise; finalize: (baseArtifact?: MatrixBaseArtifactBinding) => Promise; abort: () => Promise; } -const hashFile = async (filePath: string): Promise => { - const hasher = createHash("sha256"); - for await (const chunk of createReadStream(filePath)) hasher.update(chunk); - return hasher.digest("hex"); -}; - interface RecordSpool { write: (record: CorpusEvaluationRecord) => Promise; materialize: (outputPath: string) => Promise; @@ -195,7 +188,7 @@ export const createMatrixArtifactWriter = async ({ const [candidateStats, corpusManifestStats, copiedCorpusManifestSha256] = await Promise.all([ stat(candidatePath), stat(corpusManifestPath), - hashFile(corpusManifestPath), + hashFileSha256(corpusManifestPath), ]); if (copiedCorpusManifestSha256 !== corpusManifestSha256) { throw new Error("Matrix corpus manifest changed before artifact finalization"); @@ -221,7 +214,7 @@ export const createMatrixArtifactWriter = async ({ failedRecordCount, artifact: { path: "candidate.ndjson", - sha256: await hashFile(candidatePath), + sha256: await hashFileSha256(candidatePath), byteLength: candidateStats.size, }, corpusManifest: { @@ -231,7 +224,7 @@ export const createMatrixArtifactWriter = async ({ }, descriptorSha256: treatment.descriptorSha256, impactManifestSha256: treatment.descriptor.impactManifestSha256, - rulesSha256: await hashFile(rulesPath), + rulesSha256: await hashFileSha256(rulesPath), baseArtifact: materializedBaseArtifact, ruleKeys: treatment.ruleKeys, evaluation, diff --git a/packages/evals/src/utils/hash-file-sha256.ts b/packages/evals/src/utils/hash-file-sha256.ts new file mode 100644 index 000000000..2e7abeb57 --- /dev/null +++ b/packages/evals/src/utils/hash-file-sha256.ts @@ -0,0 +1,8 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; + +export const hashFileSha256 = async (filePath: string): Promise => { + const hasher = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) hasher.update(chunk); + return hasher.digest("hex"); +}; diff --git a/packages/evals/src/utils/matrix-base-artifact-binding.ts b/packages/evals/src/utils/matrix-base-artifact-binding.ts index 353abc97c..50f455b1c 100644 --- a/packages/evals/src/utils/matrix-base-artifact-binding.ts +++ b/packages/evals/src/utils/matrix-base-artifact-binding.ts @@ -1,10 +1,10 @@ import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; import { copyFile, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { MATRIX_BASE_ARTIFACT_CONTRACT } from "../constants.js"; import type { EvaluationProvenance } from "../corpus.js"; +import { hashFileSha256 } from "./hash-file-sha256.js"; export interface MatrixBaseArtifactBinding { contract: string; @@ -35,12 +35,6 @@ export interface MaterializedMatrixBaseArtifactBinding { verified: boolean; } -const hashFile = async (filePath: string): Promise => { - const hasher = createHash("sha256"); - for await (const chunk of createReadStream(filePath)) hasher.update(chunk); - return hasher.digest("hex"); -}; - const hashProducer = (producer: EvaluationProvenance): string => createHash("sha256").update(JSON.stringify(producer)).digest("hex"); @@ -57,8 +51,8 @@ export const createMatrixBaseArtifactBinding = async ({ }): Promise => { const [sourceStats, sha256, provenanceSha256] = await Promise.all([ stat(sourcePath), - hashFile(sourcePath), - provenanceSourcePath ? hashFile(provenanceSourcePath) : undefined, + hashFileSha256(sourcePath), + provenanceSourcePath ? hashFileSha256(provenanceSourcePath) : undefined, ]); if ( expected && @@ -98,8 +92,8 @@ export const materializeMatrixBaseArtifactBinding = async ({ } const [copiedStats, copiedSha256, copiedProvenanceSha256] = await Promise.all([ stat(path), - hashFile(path), - provenancePath ? hashFile(provenancePath) : undefined, + hashFileSha256(path), + provenancePath ? hashFileSha256(provenancePath) : undefined, ]); const verified = copiedStats.size === binding.byteLength && diff --git a/packages/evals/src/verify-matrix-baseline-cache.ts b/packages/evals/src/verify-matrix-baseline-cache.ts index c83a9befd..f06b87918 100644 --- a/packages/evals/src/verify-matrix-baseline-cache.ts +++ b/packages/evals/src/verify-matrix-baseline-cache.ts @@ -10,10 +10,9 @@ import { } from "./constants.js"; import type { MatrixEvaluationGroup } from "./matrix-treatment-descriptor.js"; import { getEvaluationTimeoutSeconds } from "./utils/get-evaluation-timeout-seconds.js"; +import type { MatrixBaseArtifactVerification } from "./utils/matrix-base-artifact-binding.js"; -export interface MatrixBaselineArtifactVerification { - sha256: string; - byteLength: number; +export interface MatrixBaselineArtifactVerification extends MatrixBaseArtifactVerification { provenanceSha256: string; } diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index e69110f10..3df0b904f 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -428,10 +428,11 @@ export const createServer = ( params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration, ); supportsWorkDoneProgress = Boolean(params.capabilities.window?.workDoneProgress); - const experimental = params.capabilities.experimental as - | { serverStatusNotification?: boolean } - | undefined; - supportsServerStatus = Boolean(experimental?.serverStatusNotification); + supportsServerStatus = readBooleanInitOption( + params.capabilities.experimental, + "serverStatusNotification", + false, + ); // `onDidChangeWorkspaceFolders` throws if the client didn't advertise // workspace-folder support — guard the registration on this. supportsWorkspaceFolderChange = Boolean(params.capabilities.workspace?.workspaceFolders); @@ -808,28 +809,28 @@ const extractUri = (argument: unknown): string | null => extractString(argument, const extractString = (argument: unknown, key: string): string | null => { if (argument === null || typeof argument !== "object") return null; - const value = (argument as Record)[key]; + const value = Reflect.get(argument, key); return typeof value === "string" ? value : null; }; const asFalsePositiveReport = (argument: unknown): FalsePositiveReport | null => { if (argument === null || typeof argument !== "object") return null; - const record = argument as Record; - const ruleId = record.ruleId; - if (typeof ruleId !== "string") return null; + const ruleId = extractString(argument, "ruleId"); + if (ruleId === null) return null; + const line = Reflect.get(argument, "line"); return { ruleId, - severity: typeof record.severity === "string" ? record.severity : "warning", - category: typeof record.category === "string" ? record.category : "", - message: typeof record.message === "string" ? record.message : "", - relativeFilePath: typeof record.relativeFilePath === "string" ? record.relativeFilePath : "", - line: typeof record.line === "number" ? record.line : 1, + severity: extractString(argument, "severity") ?? "warning", + category: extractString(argument, "category") ?? "", + message: extractString(argument, "message") ?? "", + relativeFilePath: extractString(argument, "relativeFilePath") ?? "", + line: typeof line === "number" ? line : 1, }; }; const readBooleanInitOption = (options: unknown, key: string, fallback: boolean): boolean => { if (options === null || typeof options !== "object") return fallback; - const value = (options as Record)[key]; + const value = Reflect.get(options, key); return typeof value === "boolean" ? value : fallback; }; diff --git a/packages/language-server/src/utils/read-diagnostic-data.ts b/packages/language-server/src/utils/read-diagnostic-data.ts index fde6046d9..c65c42570 100644 --- a/packages/language-server/src/utils/read-diagnostic-data.ts +++ b/packages/language-server/src/utils/read-diagnostic-data.ts @@ -4,8 +4,8 @@ import type { ReactDoctorDiagnosticData } from "../types.js"; /** * Reads the structured payload this server attaches to every diagnostic's * `data` field and a client echoes back on hover / code-action / command - * requests. Returns `null` for diagnostics this server didn't emit. The - * cast is sound: `ruleId` discriminates our own round-tripped payload. + * requests. Returns `null` for diagnostics this server didn't emit or whose + * round-tripped payload no longer matches the server-owned contract. */ export const readDiagnosticData = (diagnostic: { source?: string; @@ -13,6 +13,44 @@ export const readDiagnosticData = (diagnostic: { }): ReactDoctorDiagnosticData | null => { if (diagnostic.source !== DIAGNOSTIC_SOURCE) return null; const { data } = diagnostic; - if (data === null || typeof data !== "object" || !("ruleId" in data)) return null; - return data as ReactDoctorDiagnosticData; + if (data === null || typeof data !== "object") return null; + const identity = Reflect.get(data, "identity"); + const plugin = Reflect.get(data, "plugin"); + const rule = Reflect.get(data, "rule"); + const ruleId = Reflect.get(data, "ruleId"); + const category = Reflect.get(data, "category"); + const help = Reflect.get(data, "help"); + const url = Reflect.get(data, "url"); + const suppressionHint = Reflect.get(data, "suppressionHint"); + const line = Reflect.get(data, "line"); + const column = Reflect.get(data, "column"); + const fsPath = Reflect.get(data, "fsPath"); + if ( + typeof identity !== "string" || + typeof plugin !== "string" || + typeof rule !== "string" || + typeof ruleId !== "string" || + typeof category !== "string" || + typeof help !== "string" || + (url !== null && typeof url !== "string") || + (suppressionHint !== null && typeof suppressionHint !== "string") || + typeof line !== "number" || + typeof column !== "number" || + typeof fsPath !== "string" + ) { + return null; + } + return { + identity, + plugin, + rule, + ruleId, + category, + help, + url, + suppressionHint, + line, + column, + fsPath, + }; }; diff --git a/packages/language-server/tests/unit/read-diagnostic-data.test.ts b/packages/language-server/tests/unit/read-diagnostic-data.test.ts new file mode 100644 index 000000000..e896db288 --- /dev/null +++ b/packages/language-server/tests/unit/read-diagnostic-data.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; +import { DIAGNOSTIC_SOURCE } from "../../src/constants.js"; +import { readDiagnosticData } from "../../src/utils/read-diagnostic-data.js"; + +const diagnosticData = { + identity: "src/app.tsx:1:1:react-doctor/example", + plugin: "react-doctor", + rule: "example", + ruleId: "react-doctor/example", + category: "Maintainability", + help: "Apply the recommendation.", + url: null, + suppressionHint: null, + line: 1, + column: 1, + fsPath: "/workspace/src/app.tsx", +}; + +describe("readDiagnosticData", () => { + it("returns a complete server-owned payload", () => { + expect(readDiagnosticData({ source: DIAGNOSTIC_SOURCE, data: diagnosticData })).toEqual( + diagnosticData, + ); + }); + + it("rejects incomplete round-tripped payloads", () => { + expect( + readDiagnosticData({ + source: DIAGNOSTIC_SOURCE, + data: { ruleId: diagnosticData.ruleId }, + }), + ).toBeNull(); + }); + + it("rejects payloads from another diagnostic source", () => { + expect(readDiagnosticData({ source: "typescript", data: diagnosticData })).toBeNull(); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts index b8a34f649..4c98946b5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts @@ -15,7 +15,7 @@ import { import { __clearParseSourceFileCacheForTests } from "./utils/parse-source-file.js"; import { resetManifestCaches } from "./utils/read-nearest-package-manifest.js"; import { resetCrossFileExportCaches } from "./utils/resolve-cross-file-function-export.js"; -import { __clearTsconfigAliasCacheForTests } from "./utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "./utils/resolve-tsconfig-alias.js"; // The collectors' contract (see cross-file-dependencies.ts): for a given file, // the recorded probe set must contain every path whose existence or content @@ -32,7 +32,7 @@ const DAYJS_DEPENDENCY_DAG_MAX_ANALYSIS_DURATION_MS = 2_000; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-cross-file-deps-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); resetCrossFileExportCaches(); resetManifestCaches(); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts index 5779fe95c..bd9a2b973 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts @@ -7,7 +7,6 @@ import { PAGE_OR_LAYOUT_FILE_PATTERN, } from "./constants/nextjs.js"; import { - CROSS_FILE_BARREL_FOLLOW_DEPTH, CUSTOM_HOOK_DEPENDENCY_FORWARD_DEPTH, DAYJS_STATE_UPDATER_DEPENDENCY_FOLLOW_DEPTH, } from "./constants/thresholds.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-cramped-container-padding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-cramped-container-padding.ts index 56dce048e..1600efd99 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-cramped-container-padding.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-cramped-container-padding.ts @@ -261,9 +261,6 @@ const getTailwindPaddingResolution = (tokens: string[]): TailwindPaddingResoluti return { minimumImportantPaddingPx, minimumPaddingPx }; }; -const getTailwindUtilityResolution = (tokens: string[], predicate: (utility: string) => boolean) => - resolveEffectiveTailwindClassNameToken(tokens, predicate); - export const noCrampedContainerPadding = defineRule({ id: "no-cramped-container-padding", title: "Bounded text container has cramped padding", @@ -300,13 +297,13 @@ export const noCrampedContainerPadding = defineRule({ if (classNameValue && hasCapabilityOrUnspecified(context.settings, "tailwind")) { const tokens = getUnvariantClassNameTokensWithImportantModifiers(classNameValue); const paddingResolution = getTailwindPaddingResolution(tokens); - const backgroundResolution = getTailwindUtilityResolution(tokens, (utility) => + const backgroundResolution = resolveEffectiveTailwindClassNameToken(tokens, (utility) => TAILWIND_BACKGROUND_COLOR_PATTERN.test(utility), ); - const borderResolution = getTailwindUtilityResolution(tokens, (utility) => + const borderResolution = resolveEffectiveTailwindClassNameToken(tokens, (utility) => TAILWIND_BORDER_GEOMETRY_PATTERN.test(utility), ); - const shadowResolution = getTailwindUtilityResolution(tokens, (utility) => + const shadowResolution = resolveEffectiveTailwindClassNameToken(tokens, (utility) => TAILWIND_SHADOW_GEOMETRY_PATTERN.test(utility), ); const isBackgroundProtected = diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-use-search-params-without-suspense.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-use-search-params-without-suspense.cross-file.test.ts index 1f6bd9d19..c12507c30 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-use-search-params-without-suspense.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-use-search-params-without-suspense.cross-file.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { nextjsNoUseSearchParamsWithoutSuspense } from "./nextjs-no-use-search-params-without-suspense.js"; let temporaryDirectory: string; @@ -12,7 +12,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "use-search-params-xfile-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/async-defer-await.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/async-defer-await.ts index c558430be..26d6f675c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/async-defer-await.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/async-defer-await.ts @@ -9,6 +9,7 @@ import { collectPatternNames } from "../../utils/collect-pattern-names.js"; import { collectReferenceIdentifierNames } from "../../utils/collect-reference-identifier-names.js"; import { containsDirectAwait } from "../../utils/contains-direct-await.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import { findEnclosingFunction } from "../../utils/find-enclosing-function.js"; import { isBareAwaitExpressionStatement } from "../../utils/is-bare-await-expression-statement.js"; import { isEarlyExitIfStatement } from "../../utils/is-early-exit-if-statement.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; @@ -362,15 +363,6 @@ const guardConsequentPerformsSideEffects = (consequent: EsTreeNode | null | unde return performsSideEffects; }; -const findEnclosingFunction = (node: EsTreeNode): EsTreeNode | null => { - let ancestor: EsTreeNode | null | undefined = node.parent; - while (ancestor) { - if (isFunctionLike(ancestor)) return ancestor; - ancestor = ancestor.parent; - } - return null; -}; - // `let failed = false; try { await del(); } catch { failed = true; } // if (failed) return;` — the guard reads a local flag that the function // itself reassigns, so the flag's value depends on work around the await. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/no-unguarded-browser-global-in-render-or-hook-init.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/no-unguarded-browser-global-in-render-or-hook-init.regressions.test.ts index 5593b8c9e..d431163d5 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/no-unguarded-browser-global-in-render-or-hook-init.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/no-unguarded-browser-global-in-render-or-hook-init.regressions.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noUnguardedBrowserGlobalInRenderOrHookInit } from "./no-unguarded-browser-global-in-render-or-hook-init.js"; const run = (code: string, filename = "src/components/animated-background-image.tsx") => @@ -335,7 +335,7 @@ describe("no-unguarded-browser-global-in-render-or-hook-init — imported server JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"] } } }), ); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { @@ -413,7 +413,7 @@ describe("no-unguarded-browser-global-in-render-or-hook-init — imported server }), ); writeFixtureFile("node_modules/hydration-library/index.ts", falseSnapshotHook); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); const result = runImportedHook(`import { useHydrated } from "@vendor";`); expect(result.parseErrors).toEqual([]); expect(result.diagnostics).toHaveLength(1); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-create-ref-in-function-component.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-create-ref-in-function-component.cross-file.test.ts index 86f8abb76..418a4fa17 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-create-ref-in-function-component.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/no-create-ref-in-function-component.cross-file.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noCreateRefInFunctionComponent } from "./no-create-ref-in-function-component.js"; let temporaryDirectory: string; @@ -12,7 +12,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "create-ref-write-only-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.cross-file.test.ts index c35b6c641..2c673f57b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.cross-file.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noAdjustStateOnPropChange } from "./no-adjust-state-on-prop-change.js"; import { noDerivedStateEffect } from "./no-derived-state-effect.js"; import { noDerivedState } from "./no-derived-state.js"; @@ -15,7 +15,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "no-derived-state-helper-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.cross-file.test.ts index 86baa2e71..591403abb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutating-reducer-state.cross-file.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noMutatingReducerState } from "./no-mutating-reducer-state.js"; // Cross-file tests need actual files on disk so the rule's @@ -18,7 +18,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "no-mutating-reducer-xfile-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-side-effect-in-state-updater-function.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-side-effect-in-state-updater-function.cross-file.test.ts index d2fe3fb25..3c2f1c645 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-side-effect-in-state-updater-function.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-side-effect-in-state-updater-function.cross-file.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { CROSS_FILE_BARREL_FOLLOW_DEPTH } from "../../constants/thresholds.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noSideEffectInStateUpdaterFunction } from "./no-side-effect-in-state-updater-function.js"; let temporaryDirectory: string; @@ -13,7 +13,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "state-updater-dayjs-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/build-local-dependency-graph.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/build-local-dependency-graph.ts index 8d51c6bbc..3c01347af 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/build-local-dependency-graph.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/build-local-dependency-graph.ts @@ -53,13 +53,6 @@ const addDependencyNames = (into: Set, dependencyNames: Set): vo for (const dependencyName of dependencyNames) into.add(dependencyName); }; -const getPatternDefaultReferenceNames = ( - pattern: EsTreeNode, - scope: BindingScope, - eventHandlerReferenceNames: Set, -): Set => - collectScopedPatternDefaultReferenceNames(pattern, scope, eventHandlerReferenceNames); - const addVariableDeclarationDependencies = ( graph: Map>, statement: EsTreeNode, @@ -74,7 +67,7 @@ const addVariableDeclarationDependencies = ( : new Set(); addDependencyNames( dependencyNames, - getPatternDefaultReferenceNames(declarator.id, scope, eventHandlerReferenceNames), + collectScopedPatternDefaultReferenceNames(declarator.id, scope, eventHandlerReferenceNames), ); const declaredNames = addPatternBindings(declarator.id, declarationScope); for (const declaredName of declaredNames) { @@ -98,7 +91,7 @@ const addAssignmentExpressionDependencies = ( ); addDependencyNames( dependencyNames, - getPatternDefaultReferenceNames(expression.left, scope, eventHandlerReferenceNames), + collectScopedPatternDefaultReferenceNames(expression.left, scope, eventHandlerReferenceNames), ); addDependencyNames(dependencyNames, controlDependencyNames); if (expression.operator !== "=") { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/collect-handler-binding-names.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/collect-handler-binding-names.ts index b0d50b164..a7f91258e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/collect-handler-binding-names.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/collect-handler-binding-names.ts @@ -1,13 +1,12 @@ import type { EsTreeNode } from "../../../utils/es-tree-node.js"; +import { isEventHandlerAttribute } from "../../../utils/is-event-handler-attribute.js"; import { walkAst } from "../../../utils/walk-ast.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; export const collectHandlerBindingNames = (componentBody: EsTreeNode): Set => { const handlerNames = new Set(); walkAst(componentBody, (child: EsTreeNode) => { - if (!isNodeOfType(child, "JSXAttribute")) return; - if (!isNodeOfType(child.name, "JSXIdentifier")) return; - if (!/^on[A-Z]/.test(child.name.name)) return; + if (!isEventHandlerAttribute(child)) return; if (!isNodeOfType(child.value, "JSXExpressionContainer")) return; const expression = child.value.expression; if (isNodeOfType(expression, "Identifier")) handlerNames.add(expression.name); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-inside-event-handler.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-inside-event-handler.ts index 7a1ff4b48..da60fad51 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-inside-event-handler.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-inside-event-handler.ts @@ -1,34 +1,23 @@ import type { EsTreeNode } from "../../../utils/es-tree-node.js"; +import { findEnclosingFunction } from "../../../utils/find-enclosing-function.js"; +import { isEventHandlerAttribute } from "../../../utils/is-event-handler-attribute.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; export const isInsideEventHandler = ( node: EsTreeNode, handlerBindingNames: Set, ): boolean => { - let cursor: EsTreeNode | null = node.parent ?? null; - while (cursor) { - if ( - isNodeOfType(cursor, "ArrowFunctionExpression") || - isNodeOfType(cursor, "FunctionExpression") || - isNodeOfType(cursor, "FunctionDeclaration") - ) { - let outer: EsTreeNode | null = cursor.parent ?? null; - while (outer) { - if (isNodeOfType(outer, "JSXAttribute")) { - const attrName = isNodeOfType(outer.name, "JSXIdentifier") ? outer.name.name : null; - if (attrName && /^on[A-Z]/.test(attrName)) return true; - return false; - } - if (isNodeOfType(outer, "VariableDeclarator")) { - const declaredName = isNodeOfType(outer.id, "Identifier") ? outer.id.name : null; - return Boolean(declaredName && handlerBindingNames.has(declaredName)); - } - if (isNodeOfType(outer, "Program")) return false; - outer = outer.parent ?? null; - } - return false; + let functionOwner = findEnclosingFunction(node)?.parent; + while (functionOwner) { + if (isEventHandlerAttribute(functionOwner)) return true; + if (isNodeOfType(functionOwner, "VariableDeclarator")) { + return ( + isNodeOfType(functionOwner.id, "Identifier") && + handlerBindingNames.has(functionOwner.id.name) + ); } - cursor = cursor.parent ?? null; + if (isNodeOfType(functionOwner, "Program")) return false; + functionOwner = functionOwner.parent; } return false; }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/export-all-adds-runtime-values.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/export-all-adds-runtime-values.ts index 22af7f41a..ca6ab6124 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/export-all-adds-runtime-values.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/export-all-adds-runtime-values.ts @@ -1,17 +1,11 @@ import * as path from "node:path"; import type { EsTreeNode } from "./es-tree-node.js"; +import { getModuleSpecifierName } from "./get-module-specifier-name.js"; import { isEverySpecifierInlineType } from "./is-type-only-import.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { parseSourceFile } from "./parse-source-file.js"; import { resolveRelativeImportPath } from "./resolve-relative-import-path.js"; -const getExportedName = (node: EsTreeNode | null | undefined): string | null => { - if (!node) return null; - if (isNodeOfType(node, "Identifier")) return node.name; - if (isNodeOfType(node, "Literal") && typeof node.value === "string") return node.value; - return null; -}; - const programHasRuntimeNamedExports = ( filePath: string, program: EsTreeNode, @@ -48,7 +42,7 @@ const programHasRuntimeNamedExports = ( if (!isNodeOfType(specifier, "ExportSpecifier") || specifier.exportKind === "type") { continue; } - if (getExportedName(specifier.exported) !== "default") return true; + if (getModuleSpecifierName(specifier.exported) !== "default") return true; } } return false; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts index c92e5dcfc..89328eba9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts @@ -1,4 +1,5 @@ import type { EsTreeNode } from "./es-tree-node.js"; +import { getModuleSpecifierName } from "./get-module-specifier-name.js"; import { isNodeOfType } from "./is-node-of-type.js"; export interface ReExportTarget { @@ -53,19 +54,9 @@ export const findReExportTargetsForName = ( for (const specifier of statement.specifiers ?? []) { if (!isNodeOfType(specifier, "ExportSpecifier")) continue; if (specifier.exportKind === "type") continue; - const exported = specifier.exported; - const exportedNameSpec = isNodeOfType(exported, "Identifier") - ? exported.name - : isNodeOfType(exported, "Literal") && typeof exported.value === "string" - ? exported.value - : null; + const exportedNameSpec = getModuleSpecifierName(specifier.exported); if (exportedNameSpec !== exportedName) continue; - const local = specifier.local; - const importedName = isNodeOfType(local, "Identifier") - ? local.name - : isNodeOfType(local, "Literal") && typeof local.value === "string" - ? local.value - : null; + const importedName = getModuleSpecifierName(specifier.local); if (importedName) return [{ importedName, source: sourceValue }]; } } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-function-export-names.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-function-export-names.ts index d722043eb..d58753589 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-function-export-names.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-function-export-names.ts @@ -2,24 +2,9 @@ import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; import { getDirectFunctionBindingIdentifier } from "./get-direct-function-binding-identifier.js"; +import { getModuleSpecifierName } from "./get-module-specifier-name.js"; import { isNodeOfType } from "./is-node-of-type.js"; -const getExportedSpecifierName = ( - specifier: EsTreeNodeOfType<"ExportSpecifier">, -): string | null => { - const exported = specifier.exported; - if (isNodeOfType(exported, "Identifier")) return exported.name; - return isNodeOfType(exported, "Literal") && typeof exported.value === "string" - ? exported.value - : null; -}; - -const getLocalSpecifierName = (specifier: EsTreeNodeOfType<"ExportSpecifier">): string | null => { - const local = specifier.local; - if (isNodeOfType(local, "Identifier")) return local.name; - return isNodeOfType(local, "Literal") && typeof local.value === "string" ? local.value : null; -}; - export const getFunctionExportNames = ( programNode: EsTreeNodeOfType<"Program">, functionNode: EsTreeNode, @@ -54,8 +39,8 @@ export const getFunctionExportNames = ( if (!bindingName || statement.source) continue; for (const specifier of statement.specifiers) { if (!isNodeOfType(specifier, "ExportSpecifier")) continue; - if (getLocalSpecifierName(specifier) !== bindingName) continue; - const exportedName = getExportedSpecifierName(specifier); + if (getModuleSpecifierName(specifier.local) !== bindingName) continue; + const exportedName = getModuleSpecifierName(specifier.exported); if (exportedName) exportedNames.add(exportedName); } } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-module-specifier-name.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-module-specifier-name.ts new file mode 100644 index 000000000..c59c542e5 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-module-specifier-name.ts @@ -0,0 +1,7 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const getModuleSpecifierName = (node: EsTreeNode | null | undefined): string | null => { + if (isNodeOfType(node, "Identifier")) return node.name; + return isNodeOfType(node, "Literal") && typeof node.value === "string" ? node.value : null; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-exported-jsx-owned-by-generated-image-renderers.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-exported-jsx-owned-by-generated-image-renderers.ts index 232b89648..69cdb7365 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-exported-jsx-owned-by-generated-image-renderers.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-exported-jsx-owned-by-generated-image-renderers.ts @@ -10,13 +10,13 @@ import { findProgramRoot } from "./find-program-root.js"; import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; import { getDirectFunctionBindingIdentifier } from "./get-direct-function-binding-identifier.js"; import { getFunctionExportNames } from "./get-function-export-names.js"; +import { getModuleSpecifierName } from "./get-module-specifier-name.js"; import { getReactDoctorOptionalStringArraySetting, getReactDoctorStringSetting, } from "./get-react-doctor-setting.js"; import { getStaticPropertyName } from "./get-static-property-name.js"; import type { EsTreeNode } from "./es-tree-node.js"; -import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { isFunctionLike } from "./is-function-like.js"; import { GENERATED_IMAGE_RENDERER_MODULES, @@ -41,32 +41,10 @@ interface GeneratedImageOwnershipState { didReachRenderer: boolean; } -const getExportedSpecifierName = ( - specifier: EsTreeNodeOfType<"ExportSpecifier">, -): string | null => { - const exported = specifier.exported; - if (isNodeOfType(exported, "Identifier")) return exported.name; - return isNodeOfType(exported, "Literal") && typeof exported.value === "string" - ? exported.value - : null; -}; - -const getImportedSpecifierName = ( - specifier: EsTreeNodeOfType<"ExportSpecifier">, -): string | null => { - const local = specifier.local; - if (isNodeOfType(local, "Identifier")) return local.name; - return isNodeOfType(local, "Literal") && typeof local.value === "string" ? local.value : null; -}; - const getImportSpecifierName = (specifier: EsTreeNode): string | null => { if (isNodeOfType(specifier, "ImportDefaultSpecifier")) return "default"; if (!isNodeOfType(specifier, "ImportSpecifier")) return null; - const imported = specifier.imported; - if (isNodeOfType(imported, "Identifier")) return imported.name; - return isNodeOfType(imported, "Literal") && typeof imported.value === "string" - ? imported.value - : null; + return getModuleSpecifierName(specifier.imported); }; const isTransparentGeneratedImageValueFlow = ( @@ -206,7 +184,7 @@ const classifySymbolReferences = ( } const parent = identifier.parent; if (isNodeOfType(parent, "ExportSpecifier") && parent.local === identifier) { - const exportedName = getExportedSpecifierName(parent); + const exportedName = getModuleSpecifierName(parent.exported); if (!exportedName) return false; enqueueExport(state, module.filePath, exportedName); continue; @@ -305,8 +283,8 @@ const classifyImportsFromExport = ( } for (const specifier of statement.specifiers) { if (!isNodeOfType(specifier, "ExportSpecifier")) continue; - if (getImportedSpecifierName(specifier) !== exportIdentity.exportedName) continue; - const exportedName = getExportedSpecifierName(specifier); + if (getModuleSpecifierName(specifier.local) !== exportIdentity.exportedName) continue; + const exportedName = getModuleSpecifierName(specifier.exported); if (!exportedName) return false; state.currentExportWasUsed = true; enqueueExport(state, module.filePath, exportedName); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.test.ts index 5b7772505..2e5f447de 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.test.ts @@ -4,17 +4,13 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { CROSS_FILE_DIRECTORY_WALK_MAX_LEVELS } from "../constants/thresholds.js"; import { collectCrossFileProbes } from "./cross-file-probe-recorder.js"; -import { - __clearTsconfigAliasCacheForTests, - resetTsconfigAliasCaches, - resolveTsconfigAliasPath, -} from "./resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches, resolveTsconfigAliasPath } from "./resolve-tsconfig-alias.js"; let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "tsconfig-alias-")); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.ts index 95d661e19..2f60c1096 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-tsconfig-alias.ts @@ -377,5 +377,3 @@ export const resetTsconfigAliasCaches = (): void => { configByFilePath.clear(); nearestTsconfigByDirectory.clear(); }; - -export const __clearTsconfigAliasCacheForTests = resetTsconfigAliasCaches; diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 29562f97a..f3f57aad1 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -1,20 +1,13 @@ -import { tmpdir } from "node:os"; 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 { - buildJsonReport, type DiffInfo, getBaselineDiffPlan, getChangedLineRanges, getDiffInfo, - hasReactRuntime, highlighter, - type InspectResult, isPathInsideDirectory, - type JsonReportMode, - type ReactDoctorConfig, remainingDeadlineBudgetMs, resolveScanTarget, toRelativePath, @@ -24,14 +17,12 @@ import { flushSentry } from "../../instrument.js"; import type { JsonReportSkippedProject } 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"; +import { METRIC } from "../utils/constants.js"; import { recordCount, recordDistribution } from "../utils/record-metric.js"; -import { getStagedSourceFiles, materializeStagedFiles } from "../utils/get-staged-files.js"; import type { InspectFlags } from "../utils/inspect-flags.js"; import { filterDiagnosticsByCategories } from "../utils/filter-diagnostics-by-categories.js"; import { deduplicateProjectScans } from "../utils/deduplicate-project-scans.js"; import { collectProjectSourceFileCounts } from "../utils/collect-project-source-file-counts.js"; -import { formatSkippedProjectsMessage } from "../utils/format-skipped-projects-message.js"; import { handleError, handleUserError } from "../utils/handle-error.js"; import { isDebugFlagEnabled } from "../utils/is-debug-flag.js"; import { isExpectedUserError } from "../utils/is-expected-user-error.js"; @@ -42,7 +33,6 @@ import { setJsonReportDirectory, setJsonReportMode, writeJsonErrorReport, - writeJsonReport, } from "../utils/json-mode.js"; import { reportErrorToSentry } from "../utils/report-error.js"; import { readChangedFilesFrom } from "../utils/read-changed-files-from.js"; @@ -61,203 +51,29 @@ import { } 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 { resolveWorkspaceDeadCodeOwner } from "../utils/resolve-workspace-dead-code-owner.js"; import { retryMissingProjectScores } from "../utils/retry-missing-project-scores.js"; import { resolveProjectChangedLineRanges } from "../utils/resolve-project-diff-include-paths.js"; -import { resolveProjectSourceFilePaths } from "../utils/resolve-project-source-file-paths.js"; import { resolveProjectScan, type ResolvedProjectScan } from "../utils/resolve-project-scan.js"; import { runExplain } from "../utils/run-explain.js"; import { runProjectScanBatch } from "../utils/run-project-scan-batch.js"; import { buildProjectScanPlan } from "../utils/build-project-scan-plan.js"; import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; import { selectProjects } from "../utils/select-projects.js"; -import { - STAGED_PROJECT_FALLBACK_HINT, - selectStagedProjects, -} from "../utils/select-staged-projects.js"; import { resolveProjectRelativeDirectory } from "../utils/resolve-project-relative-directory.js"; import { spinner } from "../utils/spinner.js"; -import { shouldFailScanGate } from "../utils/should-fail-scan-gate.js"; import { shouldSkipPrompts } from "../utils/should-skip-prompts.js"; import { warnDeprecatedFailOn } from "../utils/warn-deprecated-fail-on.js"; import { warnIfAiTrainingEnvironment } from "../utils/warn-ai-training-environment.js"; import { validateIncludeUntrackedScope, validateModeFlags } from "../utils/validate-mode-flags.js"; -import { VERSION } from "../utils/version.js"; import { findStagedSnapshotDivergences } from "../utils/find-staged-snapshot-divergences.js"; import { CliInputError } from "../utils/cli-input-error.js"; - -interface CompletedScan { - directory: string; - result: InspectResult; - // The merged (root + module) config the scan ran under — surface - // filtering of its diagnostics must use this, not the root config. - config: ReactDoctorConfig | null; -} - -interface StagedProjectScanContext { - readonly projectDirectory: string; - readonly scanDirectory: string; - /** `scanDirectory` relative to the scan root; empty when they're the same. */ - readonly treeRelativeDirectory: string; - readonly projectConfig: ReactDoctorConfig | null; - readonly projectConfigSourceDirectory: string | null; -} - -interface StagedProjectScan extends StagedProjectScanContext { - /** - * The staged paths this project owns, relative to the **scan root** (the - * space `git diff --cached --relative` reports and the snapshot mirrors). - * Each staged path belongs to exactly one project, so nested packages never - * scan the same file twice. - */ - readonly stagedFiles: ReadonlyArray; -} - -const filterCompletedScansByCategories = ( - completedScans: ReadonlyArray, - categoryFilters: ReadonlySet, -): CompletedScan[] => { - if (categoryFilters.size === 0) return [...completedScans]; - - return completedScans.map((scan) => ({ - ...scan, - result: { - ...scan.result, - diagnostics: filterDiagnosticsByCategories(scan.result.diagnostics, categoryFilters), - }, - })); -}; - -interface FinalizeScansInput { - readonly completedScans: CompletedScan[]; - readonly skippedProjects: ReadonlyArray; - readonly mode: JsonReportMode; - readonly diff: DiffInfo | null; - /** - * True when a baseline comparison was attempted (a committed diff against a - * base). If it produced no delta — the base ref was unfetchable, or the head - * or base lint failed — the run degrades to a plain diff: findings stay - * visible but the gate is skipped (don't block on uncertain attribution). - */ - readonly baselineIntended: boolean; - readonly isJsonMode: boolean; - readonly isScoreOnly: boolean; - readonly flags: InspectFlags; - readonly categoryFilters: ReadonlySet; - readonly userConfig: ReactDoctorConfig | null; - readonly resolvedDirectory: string; - readonly startTime: number; -} - -interface ReportSkippedProjectsInput { - readonly skippedProjects: JsonReportSkippedProject[]; - readonly isQuiet: boolean; -} - -const reportSkippedProjects = (input: ReportSkippedProjectsInput): void => { - input.skippedProjects.sort((left, right) => left.directory.localeCompare(right.directory)); - if (input.skippedProjects.length === 0) return; - - recordCount(METRIC.scanProjectSkipped, input.skippedProjects.length, { - reason: "max-duration", - }); - if (!input.isQuiet) { - logger.warn(formatSkippedProjectsMessage(input.skippedProjects.length)); - logger.break(); - } -}; - -/** - * Post-scan finalization shared by the staged-arm and project-loop - * paths of `inspectAction`: emit the JSON report (when in JSON mode) - * and set `process.exitCode = 1` when any scan's lint pass hard-failed - * (an engine/plugin/binding failure destroys the findings, so success - * would be a false clean) or a diagnostic at or above the `--blocking` - * threshold (default `"error"`) reaches the `ciFailure` surface. - * `--blocking none` keeps the scan advisory (always exits 0), and - * fail-open degradations — `--no-lint`, `--max-duration` truncation, - * supply-chain/security skips — stay advisory too, surfaced through - * `complete: false` in the JSON report. - */ -const finalizeScans = (input: FinalizeScansInput): void => { - // Aggregate the per-project baseline deltas into one report-level block so the - // JSON (and the GitHub Action) sees a single new/fixed total across a - // workspace scan. Present only when at least one project produced a delta. - const baselineDeltas = input.completedScans.flatMap((scan) => - scan.result.baselineDelta ? [scan.result.baselineDelta] : [], - ); - // Baseline succeeded only if at least one project ran AND every scanned - // project produced a delta. Otherwise — a project's base ref was unfetchable, - // its head/base lint failed, or no project had changed source to scan — the - // run degrades to a plain diff: report `diff` not `baseline`, drop the baseline - // block, and skip the gate so CI never blocks on findings whose - // new-vs-pre-existing attribution is unknown. Findings stay visible. (An empty - // scan set is degraded too, so it can't slip through as a "clean baseline".) - // - // v1 limitation: in a partial-degraded workspace, sibling projects that DID - // compute a delta still expose only their introduced diagnostics (filtering - // happens per project inside `inspect()`), so a degraded run under-shows their - // pre-existing issues. The gate is still correct (it never blocks here); - // surfacing full findings everywhere would mean deferring per-project - // filtering out of `inspect()` (an InspectResult contract change) — a v2 - // follow-up. Single-project and all-succeed runs are unaffected. - const baselineComputed = - input.skippedProjects.length === 0 && - input.completedScans.length > 0 && - input.completedScans.every((scan) => scan.result.baselineDelta !== undefined); - const baselineDegraded = input.baselineIntended && !baselineComputed; - const mode: JsonReportMode = baselineDegraded ? "diff" : input.mode; - const isReactDetected = input.completedScans.some((scan) => hasReactRuntime(scan.result.project)); - if (input.completedScans.length > 0 && !isReactDetected) { - recordCount(METRIC.scanNoReactDetected, 1); - logger.warn( - `No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`, - ); - } - const jsonCompletedScans = filterCompletedScansByCategories( - input.completedScans, - input.categoryFilters, - ); - - if (input.isJsonMode) { - const baseline = - baselineComputed && baselineDeltas.length > 0 - ? { - baseRef: baselineDeltas[0].baseRef, - fixedCount: baselineDeltas.reduce((total, delta) => total + delta.fixedCount, 0), - baseTotalCount: baselineDeltas.reduce( - (total, delta) => total + delta.baseTotalCount, - 0, - ), - } - : undefined; - writeJsonReport( - buildJsonReport({ - version: VERSION, - directory: input.resolvedDirectory, - mode, - diff: input.diff, - scans: jsonCompletedScans, - skippedProjects: input.skippedProjects, - totalElapsedMilliseconds: performance.now() - input.startTime, - baseline, - baselineDegraded, - }), - ); - } - - const blockingLevel = resolveBlockingLevel(input.flags, input.userConfig); - if ( - shouldFailScanGate({ - scans: input.completedScans, - blockingLevel, - diagnosticsAreGateExempt: input.isScoreOnly || baselineDegraded, - }) - ) { - process.exitCode = 1; - } -}; +import { + type CompletedScan, + finalizeCliScans, + reportSkippedProjects, +} from "../utils/finalize-cli-scans.js"; +import { runStagedInspect } from "../utils/run-staged-inspect.js"; const buildChangedFilesDiffInfo = (changedFiles: string[]): DiffInfo => ({ currentBranch: process.env.GITHUB_HEAD_REF?.trim() || null, @@ -394,378 +210,20 @@ export const inspectAction = async ( const skipPrompts = shouldSkipPrompts({ yes: flags.yes, json: flags.json }); if (flags.staged) { - // `--staged` scans the index. `--project`, or `doctor.config`'s - // `projects`, names the packages that own the staged paths, so each one - // materializes its own config and keeps its React identity. With neither, - // one scan at the scan root. - const hasConfigProjects = (userConfig?.projects ?? []).some( - (projectName) => projectName.trim().length > 0, - ); - // `projects` entries resolve against the scan root, not the directory that - // declared them, so they only apply when react-doctor was invoked from the - // config's own directory. Without this a per-package or positional run - // would resolve an ancestor config's entries against the package. - const configProjectsApply = - hasConfigProjects && scanTarget.requestedDirectory === scanTarget.configSourceDirectory; - const projectDirectories = await selectStagedProjects({ - rootDirectory: resolvedDirectory, - projectFlag: flags.project, - configProjects: configProjectsApply ? userConfig?.projects : undefined, - }); - - // Nothing to scan is not a failure: `--staged` is wired into commit hooks - // and `lint-staged`, so it must not fail a commit that stages no source. - const reportNothingToScan = (input: { - readonly reason: string; - readonly severity?: "dim" | "warn"; - }): void => { - if (isJsonMode) { - writeJsonReport( - buildJsonReport({ - version: VERSION, - directory: resolvedDirectory, - mode: "staged", - diff: null, - scans: [], - totalElapsedMilliseconds: performance.now() - startTime, - }), - ); - } else if (!isScoreOnly) { - if (input.severity === "warn") { - logger.warn(input.reason); - logger.break(); - } else { - logger.dim(input.reason); - } - } - }; - - const rootStagedFiles = await getStagedSourceFiles(resolvedDirectory); - if (rootStagedFiles.length === 0) { - reportNothingToScan({ reason: "No staged source files found." }); - return; - } - - const buildProjectScanContext = async ( - projectDirectory: string, - ): Promise => { - const projectScan = await resolveProjectScan(scanTarget, projectDirectory); - const scanDirectory = projectScan.directory; - const treeRelativeDirectory = resolveProjectRelativeDirectory( - resolvedDirectory, - scanDirectory, - ); - // A project outside the scan root owns none of the index paths — the - // index is keyed to the root, so there is nothing for it to scan. - if (treeRelativeDirectory === null) return null; - return { - projectDirectory, - scanDirectory, - treeRelativeDirectory, - projectConfig: projectScan.config, - projectConfigSourceDirectory: projectScan.configSourceDirectory, - }; - }; - - const projectScanContexts: StagedProjectScanContext[] = []; - const seenScanDirectories = new Set(); - for (const projectDirectory of projectDirectories) { - const projectScanContext = await buildProjectScanContext(projectDirectory); - if (projectScanContext === null) { - // An explicit `--project` naming an outside directory is a mistake - // worth failing on; a config entry falls back with the others below. - if (flags.project) { - throw new CliInputError( - `Project "${toRelativePath(projectDirectory, resolvedDirectory)}" is outside ${resolvedDirectory}, so it holds none of the staged files. Run --staged from a directory that contains the project.`, - ); - } - continue; - } - // Two entries can name one directory — as spellings of the same package, - // or via a `rootDir` redirect onto a shared target. Scanning it twice - // doubles its diagnostics into the summary, the report, and the gate. - if (seenScanDirectories.has(projectScanContext.scanDirectory)) continue; - seenScanDirectories.add(projectScanContext.scanDirectory); - projectScanContexts.push(projectScanContext); - } - - // Every configured project resolved outside the scan root, so none of them - // can own an index path. Falling through would scan nothing and report - // "no staged files in the selected projects" — a clean gate for a reason - // that is really a misconfiguration. Warn and scan the root instead, the - // same way an unresolvable entry does. - if (projectScanContexts.length === 0) { - logger.warn( - `No configured project is inside ${resolvedDirectory}. ${STAGED_PROJECT_FALLBACK_HINT}`, - ); - logger.break(); - const rootScanContext = await buildProjectScanContext(resolvedDirectory); - if (rootScanContext !== null) projectScanContexts.push(rootScanContext); - } - - // Assign each staged path to exactly one project, deepest first, so a - // nested package claims its own files before its parent does. - // Among the ancestors of one staged path, a longer tree-relative directory - // is always the deeper one. - const contextsByLongestPathFirst = [...projectScanContexts].sort( - (left, right) => right.treeRelativeDirectory.length - left.treeRelativeDirectory.length, - ); - const ownedStagedFiles = new Map(); - for (const stagedFile of rootStagedFiles) { - const owner = contextsByLongestPathFirst.find( - (context) => - context.treeRelativeDirectory.length === 0 || - stagedFile.startsWith(`${context.treeRelativeDirectory}/`), - ); - if (owner === undefined) continue; - const ownedFiles = ownedStagedFiles.get(owner.scanDirectory); - if (ownedFiles === undefined) ownedStagedFiles.set(owner.scanDirectory, [stagedFile]); - else ownedFiles.push(stagedFile); - } - - const stagedProjectScans: StagedProjectScan[] = []; - for (const projectScanContext of projectScanContexts) { - const projectStagedFiles = ownedStagedFiles.get(projectScanContext.scanDirectory); - if (projectStagedFiles === undefined) continue; - stagedProjectScans.push({ ...projectScanContext, stagedFiles: projectStagedFiles }); - } - - if (stagedProjectScans.length === 0) { - reportNothingToScan({ reason: "No staged source files in the selected projects." }); - return; - } - - // Ownership is exclusive, so this is already duplicate-free. Everything - // downstream works from it rather than the whole index: `showStagedContent` - // spawns one `git show` per file, so an unowned path would cost a - // subprocess to write bytes no scan reads. - const selectedStagedFiles = stagedProjectScans.flatMap( - (projectScan) => projectScan.stagedFiles, - ); - const stagedFileCount = selectedStagedFiles.length; - const unselectedStagedFileCount = rootStagedFiles.length - stagedFileCount; - if (!isQuiet) { - logger.log(`Scanning ${highlighter.info(`${stagedFileCount}`)} staged files...`); - // Staged paths outside the selected projects are out of scope, not - // missed — a plain scan would skip them too. Say so anyway, so the - // count above can't read as the whole staged set. - if (unselectedStagedFileCount > 0) { - logger.dim( - `${unselectedStagedFileCount} more staged file${unselectedStagedFileCount === 1 ? "" : "s"} outside the selected projects.`, - ); - } - logger.break(); - } - - // `--staged --scope lines`: only report issues on the staged hunks. Ranges - // are computed once at the scan root (repo-relative paths), then re-keyed - // per project so they match project-relative diagnostic paths. A `null` - // result (git diff failed) degrades to file-level rather than hiding - // everything behind an empty filter. - const stagedWantsLines = resolveScope(flags, userConfig).scope === "lines"; - const stagedLineRanges = stagedWantsLines - ? await getChangedLineRanges({ - directory: resolvedDirectory, - cached: true, - files: selectedStagedFiles, - }) - : null; - if (stagedWantsLines && stagedLineRanges === null && !isQuiet) { - logger.warn( - "Could not determine staged changed lines; reporting all issues in staged files.", - ); - logger.break(); - } - // Every run that scans a package rather than the scan root, whether it - // selected one or several — a single selected package is the whole feature - // working, so gating this on more than one would hide the common case. - if (stagedProjectScans.some((projectScan) => projectScan.treeRelativeDirectory.length > 0)) { - recordCount(METRIC.stagedPerProject, 1, { projectCount: stagedProjectScans.length }); - } - const tempDirectory = fs.mkdtempSync(path.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX)); - const configSubdirectories = new Set(); - for (const projectScan of stagedProjectScans) { - configSubdirectories.add(projectScan.treeRelativeDirectory); - const projectRelativeDirectory = resolveProjectRelativeDirectory( - resolvedDirectory, - projectScan.projectDirectory, - ); - if (projectRelativeDirectory !== null) { - configSubdirectories.add(projectRelativeDirectory); - } - } - // If materialization throws before `snapshot.cleanup` is wired up, remove - // the temp dir we just created so it can't leak. - const snapshot = await materializeStagedFiles({ - directory: resolvedDirectory, - stagedFiles: selectedStagedFiles, - tempDirectory, - configSubdirectories: [...configSubdirectories], - }).catch((error: unknown) => { - fs.rmSync(tempDirectory, { recursive: true, force: true }); - throw error; - }); - const materializedStagedFiles = new Set(snapshot.stagedFiles); - // A project whose own staged files could not be snapshotted is dropped, - // not failed — see the empty case below for why none of the causes may - // block a commit. - const stagedProjectRuns = stagedProjectScans - .map((projectScan) => ({ - projectScan, - includePaths: resolveProjectSourceFilePaths( - resolvedDirectory, - projectScan.scanDirectory, - projectScan.stagedFiles.filter((stagedFile) => materializedStagedFiles.has(stagedFile)), - ), - })) - .filter((projectRun) => projectRun.includePaths.length > 0); - // Derived from the projects that survived the drop above, not the ones - // selected: a lone survivor renders inline like any single-project scan - // instead of being suppressed in favour of an aggregate summary of one. - const isMultiProject = stagedProjectRuns.length > 1; - const skippedProjects: JsonReportSkippedProject[] = []; - // Nothing at all came out of the index. An unreadable index already failed - // upstream — the divergence guard runs `git status` before any of this, and - // `getStagedSourceFiles` throws when `git diff --cached` reports failure — - // so what is left here is an oversized blob (`GIT_SHOW_MAX_BUFFER_BYTES`), - // a transient `git show` failure, or a path the snapshot refused. The - // committer can act on none of them, and failing would only teach them to - // reach for `--no-verify`, which drops every other hook with it. Warn and - // let the commit through. Nothing owns the snapshot yet, so tear it down. - if (stagedProjectRuns.length === 0) { - snapshot.cleanup(); - reportNothingToScan({ - reason: `Could not read any of the ${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"} out of the index, so nothing was scanned. An unusually large staged file is the usual cause.`, - severity: "warn", - }); - return; - } - if (snapshot.unmaterializedFiles.length > 0 && !isQuiet) { - // "not snapshotted", not "unreadable": the set also holds paths the - // snapshot refused because they resolved outside the temp tree. - const stagedFileLabel = `staged file${stagedFileCount === 1 ? "" : "s"}`; - logger.warn( - `Skipped ${snapshot.unmaterializedFiles.length} of ${stagedFileCount} ${stagedFileLabel}; they could not be snapshotted from the index.`, - ); - logger.break(); - } - - const scanStagedProject = async ( - projectRun: (typeof stagedProjectRuns)[number], - ): Promise => { - const { projectScan, includePaths } = projectRun; - if ( - scanDeadlineEpochMs !== undefined && - remainingDeadlineBudgetMs(scanDeadlineEpochMs) === 0 - ) { - skippedProjects.push({ directory: projectScan.scanDirectory, reason: "max-duration" }); - return null; - } - const projectTempDirectory = path.join( - snapshot.tempDirectory, - projectScan.treeRelativeDirectory, - ); - const scanResult = await inspectProject(projectTempDirectory, { - ...scanOptions, - deadlineEpochMs: scanDeadlineEpochMs, - includePaths: [...includePaths], - configOverride: projectScan.projectConfig, - // Resolve `config.plugins` from the real config directory — the - // staged temp snapshot has no node_modules or plugin files, so - // anchoring resolution there silently drops every custom plugin - // from pre-commit scans. - configSourceDirectory: projectScan.projectConfigSourceDirectory ?? undefined, - changedLineRanges: - stagedLineRanges === null - ? undefined - : resolveProjectChangedLineRanges( - resolvedDirectory, - projectScan.scanDirectory, - stagedLineRanges, - ), - suppressRendering: isMultiProject, - concurrentScan: isMultiProject, - }); - - const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({ - ...diagnostic, - filePath: path.isAbsolute(diagnostic.filePath) - ? diagnostic.filePath.replaceAll(projectTempDirectory, () => projectScan.scanDirectory) - : diagnostic.filePath, - })); - return { - directory: projectScan.scanDirectory, - result: { - ...scanResult, - diagnostics: remappedDiagnostics, - project: { ...scanResult.project, rootDirectory: projectScan.scanDirectory }, - }, - config: projectScan.projectConfig, - }; - }; - - const stagedBatch = await runProjectScanBatch({ - projects: stagedProjectRuns, + await runStagedInspect({ + flags, + scanTarget, + scanOptions, + inspectProject, + scanDeadlineEpochMs, + categoryFilters, isQuiet, - isSilent: scanOptions.silent === true, - scanProject: scanStagedProject, - }).catch((error: unknown) => { - snapshot.cleanup(); - throw error; - }); - const completedScans = stagedBatch.completedScans; - snapshot.cleanup(); - - reportSkippedProjects({ skippedProjects, isQuiet }); - if (!isQuiet && isMultiProject && completedScans.length > 0) { - await Effect.runPromise( - printCompletedScansHeadless({ - categoryFilters, - completedScans, - elapsedMilliseconds: stagedBatch.elapsedMilliseconds, - noScoreMessage: "Score unavailable.", - outputDirectory: flags.outputDir, - outputSurface: scanOptions.outputSurface ?? "cli", - projectName: path.basename(resolvedDirectory), - verbose: Boolean(flags.verbose), - }), - ); - } - - // Single-project scans dump from `inspect()`, and non-quiet workspace - // scans from the aggregate headless report. Quiet workspace scans have - // neither, so write their requested dump here. - if (flags.outputDir && isMultiProject && isQuiet) { - await Effect.runPromise( - printDiagnosticsDump( - filterDiagnosticsByCategories( - filterScansForSurface(completedScans, scanOptions.outputSurface ?? "cli"), - categoryFilters, - ), - flags.outputDir, - false, - "stderr", - ), - ); - } - - finalizeScans({ - completedScans, - skippedProjects, - mode: "staged", - diff: null, - baselineIntended: false, isJsonMode, isScoreOnly, - flags, - categoryFilters, - userConfig, - resolvedDirectory, startTime, }); return; } - const projectDirectories = await selectProjects( resolvedDirectory, flags.project, @@ -1066,10 +524,10 @@ export const inspectAction = async ( ); } - finalizeScans({ + finalizeCliScans({ completedScans, skippedProjects, - // A resolved base ref means a baseline run; finalizeScans downgrades this + // A resolved base ref means a baseline run; finalization downgrades this // to `diff` if no delta was produced (degraded run). mode: baselineRef ? "baseline" : isDiffMode ? "diff" : "full", diff: isDiffMode ? diffInfo : null, diff --git a/packages/react-doctor/src/cli/utils/build-inspect-result.ts b/packages/react-doctor/src/cli/utils/build-inspect-result.ts new file mode 100644 index 000000000..56002f94d --- /dev/null +++ b/packages/react-doctor/src/cli/utils/build-inspect-result.ts @@ -0,0 +1,68 @@ +import { buildSkippedChecks, type InspectResult } from "@react-doctor/core"; +import type { CachedScanPayload } from "./scan-result-cache-payload.js"; + +export interface InspectExecutionCacheStats { + 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; +} + +interface BuildInspectResultInput { + readonly payload: CachedScanPayload; + readonly cacheStats: InspectExecutionCacheStats; + readonly elapsedMilliseconds: number; +} + +export const buildInspectResult = (input: BuildInspectResultInput): InspectResult => { + const { payload, cacheStats } = input; + const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ + didLintFail: payload.didLintFail, + lintFailureReason: payload.lintFailureReason, + lintPartialFailures: payload.lintPartialFailures, + didDeadCodeFail: payload.didDeadCodeFail, + deadCodeFailureReason: payload.deadCodeFailureReason, + supplyChainOverlapTimedOut: payload.supplyChainOverlapTimedOut, + securityScanFailed: payload.securityScanFailed ?? false, + securityScanFailureReason: payload.securityScanFailureReason ?? null, + }); + + return { + diagnostics: [...payload.diagnostics], + score: payload.score, + skippedChecks, + ...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}), + project: payload.project, + elapsedMilliseconds: input.elapsedMilliseconds, + scannedFileCount: payload.scannedFileCount, + scannedFilePaths: payload.scannedFilePaths, + analyzedFiles: payload.analyzedFiles ?? [], + scanElapsedMilliseconds: payload.scanElapsedMilliseconds, + ...(cacheStats.lintCacheTotalFileCount === null + ? {} + : { + lintCacheHitFileCount: cacheStats.lintCacheHitFileCount, + lintCacheTotalFileCount: cacheStats.lintCacheTotalFileCount, + }), + ...(cacheStats.lintSidecarTotalFileCount === null + ? {} + : { + lintSidecarReplayedFileCount: cacheStats.lintSidecarReplayedFileCount, + lintSidecarTotalFileCount: cacheStats.lintSidecarTotalFileCount, + }), + ...(cacheStats.deadCodeCacheHit === null + ? {} + : { deadCodeCacheHit: cacheStats.deadCodeCacheHit }), + ...(cacheStats.deadCodeSummaryCacheHits === null || + cacheStats.deadCodeSummaryCacheMisses === null + ? {} + : { + deadCodeSummaryCacheHits: cacheStats.deadCodeSummaryCacheHits, + deadCodeSummaryCacheMisses: cacheStats.deadCodeSummaryCacheMisses, + }), + ...(payload.baselineDelta ? { baselineDelta: payload.baselineDelta } : {}), + }; +}; 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 a6d7f5447..221c4f407 100644 --- a/packages/react-doctor/src/cli/utils/build-run-event.ts +++ b/packages/react-doctor/src/cli/utils/build-run-event.ts @@ -121,6 +121,25 @@ export interface RunEventInput { readonly error?: unknown; } +export interface RunEventConfig extends Pick< + RunEventInput, + | "scope" + | "parallel" + | "workerCount" + | "maxDurationMs" + | "lint" + | "deadCode" + | "supplyChain" + | "scoreOnly" + | "noScore" + | "respectInlineDisables" + | "showWarnings" + | "usedOutputDir" + | "ignoredTagCount" + | "hasCustomConfig" + | "userConfig" +> {} + const readEnvBoolean = (name: string): boolean | null => { const value = process.env[name]; if (value === undefined) return null; diff --git a/packages/react-doctor/src/cli/utils/ci/manage-ci.ts b/packages/react-doctor/src/cli/utils/ci/manage-ci.ts index 47fa1ff7d..8c807a490 100644 --- a/packages/react-doctor/src/cli/utils/ci/manage-ci.ts +++ b/packages/react-doctor/src/cli/utils/ci/manage-ci.ts @@ -24,6 +24,7 @@ import { summarizeGate, type CiGate, type CiProvider, + type CiWorkflowFile, } from "./ci-provider.js"; import { CI_PROVIDERS, getCiProvider, isCiProviderId } from "./ci-provider-registry.js"; import { detectCiProvider } from "./detect-ci-provider.js"; @@ -50,6 +51,14 @@ export interface CiCommandOptions { checkCommandAvailable?: (command: string) => boolean; } +interface CiCommandContext { + readonly projectRoot: string; + readonly prompt: typeof prompts; + readonly run: CommandRunner; + readonly skipPrompts: boolean; + readonly provider: CiProvider; +} + const resolveProjectRoot = (options: CiCommandOptions): string => { const requestedDirectory = path.resolve(options.cwd ?? process.cwd()); return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory; @@ -106,6 +115,26 @@ const resolveProvider = async ( return getCiProvider(providerId); }; +const resolveCiCommandContext = async ( + options: CiCommandOptions, +): Promise => { + const projectRoot = resolveProjectRoot(options); + const prompt = options.prompt ?? prompts; + const run = options.run ?? runCommand; + const skipPrompts = shouldSkipPrompts({ yes: options.yes }); + const provider = await resolveProvider(options, projectRoot, prompt, skipPrompts, run); + return provider === null ? null : { projectRoot, prompt, run, skipPrompts, provider }; +}; + +const readCiWorkflow = (context: CiCommandContext): CiWorkflowFile | null => { + const workflow = context.provider.readWorkflow(context.projectRoot); + if (workflow !== null) return workflow; + logger.error(`No ${context.provider.displayName} workflow found.`); + logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`); + process.exitCode = 1; + return null; +}; + // Flags a provider can't honor (e.g. `--comment` on GitLab) so the user isn't // left thinking a setting took effect when it was dropped. const warnUnsupportedGateFlags = (provider: CiProvider, options: CiCommandOptions): void => { @@ -279,13 +308,9 @@ const pullRequestMode = (status: OpenWorkflowPullRequestResult["status"]): "pr" status === "not-attempted" ? "tree" : "pr"; export const runCiInstall = async (options: CiCommandOptions = {}): Promise => { - const projectRoot = resolveProjectRoot(options); - const prompt = options.prompt ?? prompts; - const run = options.run ?? runCommand; - const skipPrompts = shouldSkipPrompts({ yes: options.yes }); - - const provider = await resolveProvider(options, projectRoot, prompt, skipPrompts, run); - if (provider === null) return; + const context = await resolveCiCommandContext(options); + if (context === null) return; + const { projectRoot, run, provider } = context; warnUnsupportedGateFlags(provider, options); const { gate, error } = applyGateFlags(ADVISORY_GATE, options); @@ -361,21 +386,12 @@ export const runCiInstall = async (options: CiCommandOptions = {}): Promise => { - const projectRoot = resolveProjectRoot(options); - const prompt = options.prompt ?? prompts; - const run = options.run ?? runCommand; - const skipPrompts = shouldSkipPrompts({ yes: options.yes }); - - const provider = await resolveProvider(options, projectRoot, prompt, skipPrompts, run); - if (provider === null) return; + const context = await resolveCiCommandContext(options); + if (context === null) return; + const { projectRoot, run, provider } = context; - const workflow = provider.readWorkflow(projectRoot); - if (workflow === null) { - logger.error(`No ${provider.displayName} workflow found.`); - logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`); - process.exitCode = 1; - return; - } + const workflow = readCiWorkflow(context); + if (workflow === null) return; if (provider.upgradeMajor === undefined) { logger.log( @@ -455,21 +471,12 @@ export const runCiUpgrade = async (options: CiCommandOptions = {}): Promise => { - const projectRoot = resolveProjectRoot(options); - const prompt = options.prompt ?? prompts; - const run = options.run ?? runCommand; - const skipPrompts = shouldSkipPrompts({ yes: options.yes }); - - const provider = await resolveProvider(options, projectRoot, prompt, skipPrompts, run); - if (provider === null) return; + const context = await resolveCiCommandContext(options); + if (context === null) return; + const { projectRoot, prompt, skipPrompts, provider } = context; - const workflow = provider.readWorkflow(projectRoot); - if (workflow === null) { - logger.error(`No ${provider.displayName} workflow found.`); - logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`); - process.exitCode = 1; - return; - } + const workflow = readCiWorkflow(context); + if (workflow === null) return; // The file can exist without wiring up React Doctor (a `.gitlab-ci.yml` is // often a full pipeline with no scan job); say so plainly instead of treating diff --git a/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts b/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts new file mode 100644 index 000000000..6d045706a --- /dev/null +++ b/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts @@ -0,0 +1,130 @@ +import { performance } from "node:perf_hooks"; +import { + buildJsonReport, + type DiffInfo, + hasReactRuntime, + type InspectResult, + type JsonReportMode, + type JsonReportSkippedProject, + type ReactDoctorConfig, +} from "@react-doctor/core"; +import { cliLogger as logger } from "./cli-logger.js"; +import { METRIC } from "./constants.js"; +import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; +import { formatSkippedProjectsMessage } from "./format-skipped-projects-message.js"; +import type { InspectFlags } from "./inspect-flags.js"; +import { writeJsonReport } from "./json-mode.js"; +import { recordCount } from "./record-metric.js"; +import { resolveBlockingLevel } from "./resolve-blocking-level.js"; +import { shouldFailScanGate } from "./should-fail-scan-gate.js"; +import { VERSION } from "./version.js"; + +export interface CompletedScan { + readonly directory: string; + readonly result: InspectResult; + readonly config: ReactDoctorConfig | null; +} + +interface FinalizeCliScansInput { + readonly completedScans: ReadonlyArray; + readonly skippedProjects: ReadonlyArray; + readonly mode: JsonReportMode; + readonly diff: DiffInfo | null; + readonly baselineIntended: boolean; + readonly isJsonMode: boolean; + readonly isScoreOnly: boolean; + readonly flags: InspectFlags; + readonly categoryFilters: ReadonlySet; + readonly userConfig: ReactDoctorConfig | null; + readonly resolvedDirectory: string; + readonly startTime: number; +} + +interface ReportSkippedProjectsInput { + readonly skippedProjects: JsonReportSkippedProject[]; + readonly isQuiet: boolean; +} + +const filterCompletedScansByCategories = ( + completedScans: ReadonlyArray, + categoryFilters: ReadonlySet, +): CompletedScan[] => + categoryFilters.size === 0 + ? [...completedScans] + : completedScans.map((scan) => ({ + ...scan, + result: { + ...scan.result, + diagnostics: filterDiagnosticsByCategories(scan.result.diagnostics, categoryFilters), + }, + })); + +export const reportSkippedProjects = (input: ReportSkippedProjectsInput): void => { + input.skippedProjects.sort((left, right) => left.directory.localeCompare(right.directory)); + if (input.skippedProjects.length === 0) return; + + recordCount(METRIC.scanProjectSkipped, input.skippedProjects.length, { + reason: "max-duration", + }); + if (!input.isQuiet) { + logger.warn(formatSkippedProjectsMessage(input.skippedProjects.length)); + logger.break(); + } +}; + +export const finalizeCliScans = (input: FinalizeCliScansInput): void => { + const baselineDeltas = input.completedScans.flatMap((scan) => + scan.result.baselineDelta ? [scan.result.baselineDelta] : [], + ); + const baselineComputed = + input.skippedProjects.length === 0 && + input.completedScans.length > 0 && + input.completedScans.every((scan) => scan.result.baselineDelta !== undefined); + const baselineDegraded = input.baselineIntended && !baselineComputed; + const mode: JsonReportMode = baselineDegraded ? "diff" : input.mode; + const isReactDetected = input.completedScans.some((scan) => hasReactRuntime(scan.result.project)); + + if (input.completedScans.length > 0 && !isReactDetected) { + recordCount(METRIC.scanNoReactDetected, 1); + logger.warn( + `No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`, + ); + } + + if (input.isJsonMode) { + const baseline = + baselineComputed && baselineDeltas.length > 0 + ? { + baseRef: baselineDeltas[0].baseRef, + fixedCount: baselineDeltas.reduce((total, delta) => total + delta.fixedCount, 0), + baseTotalCount: baselineDeltas.reduce( + (total, delta) => total + delta.baseTotalCount, + 0, + ), + } + : undefined; + writeJsonReport( + buildJsonReport({ + version: VERSION, + directory: input.resolvedDirectory, + mode, + diff: input.diff, + scans: filterCompletedScansByCategories(input.completedScans, input.categoryFilters), + skippedProjects: input.skippedProjects, + totalElapsedMilliseconds: performance.now() - input.startTime, + baseline, + baselineDegraded, + }), + ); + } + + if ( + shouldFailScanGate({ + scans: input.completedScans, + blockingLevel: resolveBlockingLevel(input.flags, input.userConfig), + diagnosticsAreGateExempt: input.isScoreOnly || baselineDegraded, + }) + ) { + process.exitCode = 1; + } +}; diff --git a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts index 91b060a1b..ca5c3012a 100644 --- a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts +++ b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts @@ -1,13 +1,9 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { - buildSkippedChecks, - filterDiagnosticsForSurface, - highlighter, - type InspectResult, -} from "@react-doctor/core"; +import { filterDiagnosticsForSurface, highlighter, type InspectResult } from "@react-doctor/core"; import type { ResolvedInspectOptions } from "../../inspect-options.js"; import { buildEmptyReportMessage } from "./build-empty-report-message.js"; +import { buildInspectResult, type InspectExecutionCacheStats } from "./build-inspect-result.js"; import { buildNoScoreMessage } from "./build-no-score-message.js"; import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; import { hasIncompleteScoreAnalysis } from "./has-incomplete-score-analysis.js"; @@ -17,15 +13,7 @@ import { printHeadlessReport } from "./print-headless-report.js"; import { printAgentGuidance } from "./render-agent-guidance.js"; import type { CachedScanPayload } from "./scan-result-cache-payload.js"; -export interface InspectExecutionCacheStats { - 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 type { InspectExecutionCacheStats } from "./build-inspect-result.js"; interface FinalizeInspectResultInput { readonly options: ResolvedInspectOptions; @@ -38,58 +26,14 @@ export const finalizeInspectResult = ( input: FinalizeInspectResultInput, ): Effect.Effect => Effect.gen(function* () { - const { payload, cacheStats } = input; - const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({ - didLintFail: payload.didLintFail, - lintFailureReason: payload.lintFailureReason, - lintPartialFailures: payload.lintPartialFailures, - didDeadCodeFail: payload.didDeadCodeFail, - deadCodeFailureReason: payload.deadCodeFailureReason, - supplyChainOverlapTimedOut: payload.supplyChainOverlapTimedOut, - securityScanFailed: payload.securityScanFailed ?? false, - securityScanFailureReason: payload.securityScanFailureReason ?? null, - }); - const hasSkippedChecks = skippedChecks.length > 0; + const { payload } = input; + const result = buildInspectResult(input); + const hasSkippedChecks = result.skippedChecks.length > 0; const noScoreMessage = buildNoScoreMessage({ isScoreDisabled: input.options.noScore, - isAnalysisIncomplete: hasIncompleteScoreAnalysis(skippedChecks), + isAnalysisIncomplete: hasIncompleteScoreAnalysis(result.skippedChecks), disabledMessage: input.options.scoreDisabledMessage, }); - const result: InspectResult = { - diagnostics: [...payload.diagnostics], - score: payload.score, - skippedChecks, - ...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}), - project: payload.project, - elapsedMilliseconds: input.elapsedMilliseconds, - scannedFileCount: payload.scannedFileCount, - scannedFilePaths: payload.scannedFilePaths, - analyzedFiles: payload.analyzedFiles ?? [], - scanElapsedMilliseconds: payload.scanElapsedMilliseconds, - ...(cacheStats.lintCacheTotalFileCount !== null - ? { - lintCacheHitFileCount: cacheStats.lintCacheHitFileCount, - lintCacheTotalFileCount: cacheStats.lintCacheTotalFileCount, - } - : {}), - ...(cacheStats.lintSidecarTotalFileCount !== null - ? { - lintSidecarReplayedFileCount: cacheStats.lintSidecarReplayedFileCount, - lintSidecarTotalFileCount: cacheStats.lintSidecarTotalFileCount, - } - : {}), - ...(cacheStats.deadCodeCacheHit !== null - ? { deadCodeCacheHit: cacheStats.deadCodeCacheHit } - : {}), - ...(cacheStats.deadCodeSummaryCacheHits !== null && - cacheStats.deadCodeSummaryCacheMisses !== null - ? { - deadCodeSummaryCacheHits: cacheStats.deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses: cacheStats.deadCodeSummaryCacheMisses, - } - : {}), - ...(payload.baselineDelta ? { baselineDelta: payload.baselineDelta } : {}), - }; if (input.options.suppressRendering) return result; @@ -137,7 +81,7 @@ export const finalizeInspectResult = ( projectName: payload.project.projectName, scannedFileCount: payload.scannedFileCount, scoreResult: hasSkippedChecks ? null : payload.score, - skippedChecks, + skippedChecks: result.skippedChecks, }); if (input.options.outputDirectory !== null || input.options.verbose) { diff --git a/packages/react-doctor/src/cli/utils/render-and-record-scan.ts b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts index ff5589c30..2e3873210 100644 --- a/packages/react-doctor/src/cli/utils/render-and-record-scan.ts +++ b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts @@ -3,7 +3,7 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import type { InspectResult, ReactDoctorConfig } from "@react-doctor/core"; import type { ResolvedInspectOptions } from "../../inspect-options.js"; -import { recordRunEvent } from "./build-run-event.js"; +import { recordRunEvent, type RunEventConfig } from "./build-run-event.js"; import { countDeadlineSkippedFiles } from "./count-deadline-skipped-files.js"; import { countDroppedLintFiles } from "./count-dropped-lint-files.js"; import { @@ -27,24 +27,6 @@ export interface RenderAndRecordScanInput { readonly cacheStats?: Partial; } -export interface RunEventConfig { - readonly scope: string; - readonly parallel: boolean; - readonly workerCount: number | undefined; - readonly maxDurationMs: number | null; - readonly lint: boolean; - readonly deadCode: boolean; - readonly supplyChain: boolean; - readonly scoreOnly: boolean; - readonly noScore: boolean; - readonly respectInlineDisables: boolean; - readonly showWarnings: boolean; - readonly usedOutputDir: boolean; - readonly ignoredTagCount: number; - readonly hasCustomConfig: boolean; - readonly userConfig: ReactDoctorConfig | null; -} - const silentConsole = makeNoopConsole(); const deriveScope = (options: ResolvedInspectOptions): string => { diff --git a/packages/react-doctor/src/cli/utils/run-staged-inspect.ts b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts new file mode 100644 index 000000000..c84acbbf8 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts @@ -0,0 +1,408 @@ +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import * as Effect from "effect/Effect"; +import { + buildJsonReport, + getChangedLineRanges, + highlighter, + type JsonReportSkippedProject, + remainingDeadlineBudgetMs, + type ResolvedScanTarget, + toRelativePath, +} from "@react-doctor/core"; +import type { createInvocationInspect } from "../../inspect.js"; +import { CliInputError } from "./cli-input-error.js"; +import { cliLogger as logger } from "./cli-logger.js"; +import { METRIC, STAGED_FILES_TEMP_DIR_PREFIX } from "./constants.js"; +import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; +import { + type CompletedScan, + finalizeCliScans, + reportSkippedProjects, +} from "./finalize-cli-scans.js"; +import { getStagedSourceFiles, materializeStagedFiles } from "./get-staged-files.js"; +import type { InspectFlags } from "./inspect-flags.js"; +import { writeJsonReport } from "./json-mode.js"; +import { printCompletedScansHeadless } from "./print-completed-scans-headless.js"; +import { printDiagnosticsDump } from "./print-diagnostics-dump.js"; +import { recordCount } from "./record-metric.js"; +import type { CliInspectOptions } from "./resolve-cli-inspect-options.js"; +import { resolveProjectChangedLineRanges } from "./resolve-project-diff-include-paths.js"; +import { resolveProjectRelativeDirectory } from "./resolve-project-relative-directory.js"; +import { resolveProjectScan } from "./resolve-project-scan.js"; +import { resolveProjectSourceFilePaths } from "./resolve-project-source-file-paths.js"; +import { resolveScope } from "./resolve-scope.js"; +import { runProjectScanBatch } from "./run-project-scan-batch.js"; +import { filterScansForSurface } from "./filter-scans-for-surface.js"; +import { STAGED_PROJECT_FALLBACK_HINT, selectStagedProjects } from "./select-staged-projects.js"; +import { VERSION } from "./version.js"; + +interface RunStagedInspectInput { + readonly flags: InspectFlags; + readonly scanTarget: ResolvedScanTarget; + readonly scanOptions: CliInspectOptions; + readonly inspectProject: ReturnType; + readonly scanDeadlineEpochMs: number | undefined; + readonly categoryFilters: ReadonlySet; + readonly isQuiet: boolean; + readonly isJsonMode: boolean; + readonly isScoreOnly: boolean; + readonly startTime: number; +} + +interface StagedProjectScanContext { + readonly projectDirectory: string; + readonly scanDirectory: string; + readonly treeRelativeDirectory: string; + readonly projectConfig: ResolvedScanTarget["userConfig"]; + readonly projectConfigSourceDirectory: string | null; +} + +interface StagedProjectScan extends StagedProjectScanContext { + readonly stagedFiles: ReadonlyArray; +} + +interface EmptyStagedScanInput { + readonly directory: string; + readonly isJsonMode: boolean; + readonly isScoreOnly: boolean; + readonly startTime: number; +} + +const reportEmptyStagedScan = ( + input: EmptyStagedScanInput, + reason: string, + severity: "dim" | "warn" = "dim", +): void => { + if (input.isJsonMode) { + writeJsonReport( + buildJsonReport({ + version: VERSION, + directory: input.directory, + mode: "staged", + diff: null, + scans: [], + totalElapsedMilliseconds: performance.now() - input.startTime, + }), + ); + return; + } + if (input.isScoreOnly) return; + if (severity === "warn") { + logger.warn(reason); + logger.break(); + return; + } + logger.dim(reason); +}; + +const resolveStagedProjectScanContexts = async ( + input: RunStagedInspectInput, +): Promise => { + const { flags, scanTarget } = input; + const rootDirectory = scanTarget.resolvedDirectory; + const userConfig = scanTarget.userConfig; + const hasConfigProjects = (userConfig?.projects ?? []).some( + (projectName) => projectName.trim().length > 0, + ); + const configProjectsApply = + hasConfigProjects && scanTarget.requestedDirectory === scanTarget.configSourceDirectory; + const projectDirectories = await selectStagedProjects({ + rootDirectory, + projectFlag: flags.project, + configProjects: configProjectsApply ? userConfig?.projects : undefined, + }); + const projectScanContexts: StagedProjectScanContext[] = []; + const seenScanDirectories = new Set(); + + for (const projectDirectory of projectDirectories) { + const projectScan = await resolveProjectScan(scanTarget, projectDirectory); + const treeRelativeDirectory = resolveProjectRelativeDirectory( + rootDirectory, + projectScan.directory, + ); + if (treeRelativeDirectory === null) { + if (flags.project) { + throw new CliInputError( + `Project "${toRelativePath(projectDirectory, rootDirectory)}" is outside ${rootDirectory}, so it holds none of the staged files. Run --staged from a directory that contains the project.`, + ); + } + continue; + } + if (seenScanDirectories.has(projectScan.directory)) continue; + seenScanDirectories.add(projectScan.directory); + projectScanContexts.push({ + projectDirectory, + scanDirectory: projectScan.directory, + treeRelativeDirectory, + projectConfig: projectScan.config, + projectConfigSourceDirectory: projectScan.configSourceDirectory, + }); + } + + if (projectScanContexts.length > 0) return projectScanContexts; + + logger.warn(`No configured project is inside ${rootDirectory}. ${STAGED_PROJECT_FALLBACK_HINT}`); + logger.break(); + const rootProjectScan = await resolveProjectScan(scanTarget, rootDirectory); + const treeRelativeDirectory = resolveProjectRelativeDirectory( + rootDirectory, + rootProjectScan.directory, + ); + return treeRelativeDirectory === null + ? [] + : [ + { + projectDirectory: rootDirectory, + scanDirectory: rootProjectScan.directory, + treeRelativeDirectory, + projectConfig: rootProjectScan.config, + projectConfigSourceDirectory: rootProjectScan.configSourceDirectory, + }, + ]; +}; + +const assignStagedFilesToProjects = ( + projectScanContexts: ReadonlyArray, + stagedFiles: ReadonlyArray, +): StagedProjectScan[] => { + const contextsByLongestPathFirst = [...projectScanContexts].sort( + (left, right) => right.treeRelativeDirectory.length - left.treeRelativeDirectory.length, + ); + const ownedStagedFiles = new Map(); + + for (const stagedFile of stagedFiles) { + const owner = contextsByLongestPathFirst.find( + (context) => + context.treeRelativeDirectory.length === 0 || + stagedFile.startsWith(`${context.treeRelativeDirectory}/`), + ); + if (owner === undefined) continue; + const ownedFiles = ownedStagedFiles.get(owner.scanDirectory); + if (ownedFiles === undefined) ownedStagedFiles.set(owner.scanDirectory, [stagedFile]); + else ownedFiles.push(stagedFile); + } + + return projectScanContexts.flatMap((projectScanContext) => { + const projectStagedFiles = ownedStagedFiles.get(projectScanContext.scanDirectory); + return projectStagedFiles === undefined + ? [] + : [{ ...projectScanContext, stagedFiles: projectStagedFiles }]; + }); +}; + +const collectConfigSubdirectories = ( + rootDirectory: string, + projectScans: ReadonlyArray, +): string[] => { + const configSubdirectories = new Set(); + for (const projectScan of projectScans) { + configSubdirectories.add(projectScan.treeRelativeDirectory); + const projectRelativeDirectory = resolveProjectRelativeDirectory( + rootDirectory, + projectScan.projectDirectory, + ); + if (projectRelativeDirectory !== null) configSubdirectories.add(projectRelativeDirectory); + } + return [...configSubdirectories]; +}; + +export const runStagedInspect = async (input: RunStagedInspectInput): Promise => { + const { flags, scanTarget, scanOptions } = input; + const resolvedDirectory = scanTarget.resolvedDirectory; + const emptyScanInput: EmptyStagedScanInput = { + directory: resolvedDirectory, + isJsonMode: input.isJsonMode, + isScoreOnly: input.isScoreOnly, + startTime: input.startTime, + }; + const projectScanContexts = await resolveStagedProjectScanContexts(input); + const rootStagedFiles = await getStagedSourceFiles(resolvedDirectory); + if (rootStagedFiles.length === 0) { + reportEmptyStagedScan(emptyScanInput, "No staged source files found."); + return; + } + + const stagedProjectScans = assignStagedFilesToProjects(projectScanContexts, rootStagedFiles); + if (stagedProjectScans.length === 0) { + reportEmptyStagedScan(emptyScanInput, "No staged source files in the selected projects."); + return; + } + + const selectedStagedFiles = stagedProjectScans.flatMap((projectScan) => projectScan.stagedFiles); + const stagedFileCount = selectedStagedFiles.length; + const unselectedStagedFileCount = rootStagedFiles.length - stagedFileCount; + if (!input.isQuiet) { + logger.log(`Scanning ${highlighter.info(`${stagedFileCount}`)} staged files...`); + if (unselectedStagedFileCount > 0) { + logger.dim( + `${unselectedStagedFileCount} more staged file${unselectedStagedFileCount === 1 ? "" : "s"} outside the selected projects.`, + ); + } + logger.break(); + } + + const stagedWantsLines = resolveScope(flags, scanTarget.userConfig).scope === "lines"; + const stagedLineRanges = stagedWantsLines + ? await getChangedLineRanges({ + directory: resolvedDirectory, + cached: true, + files: selectedStagedFiles, + }) + : null; + if (stagedWantsLines && stagedLineRanges === null && !input.isQuiet) { + logger.warn("Could not determine staged changed lines; reporting all issues in staged files."); + logger.break(); + } + if (stagedProjectScans.some((projectScan) => projectScan.treeRelativeDirectory.length > 0)) { + recordCount(METRIC.stagedPerProject, 1, { projectCount: stagedProjectScans.length }); + } + + const tempDirectory = fs.mkdtempSync(path.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX)); + const snapshot = await materializeStagedFiles({ + directory: resolvedDirectory, + stagedFiles: selectedStagedFiles, + tempDirectory, + configSubdirectories: collectConfigSubdirectories(resolvedDirectory, stagedProjectScans), + }).catch((error: unknown) => { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + throw error; + }); + + try { + const materializedStagedFiles = new Set(snapshot.stagedFiles); + const stagedProjectRuns = stagedProjectScans + .map((projectScan) => ({ + projectScan, + includePaths: resolveProjectSourceFilePaths( + resolvedDirectory, + projectScan.scanDirectory, + projectScan.stagedFiles.filter((stagedFile) => materializedStagedFiles.has(stagedFile)), + ), + })) + .filter((projectRun) => projectRun.includePaths.length > 0); + const isMultiProject = stagedProjectRuns.length > 1; + const skippedProjects: JsonReportSkippedProject[] = []; + + if (stagedProjectRuns.length === 0) { + reportEmptyStagedScan( + emptyScanInput, + `Could not read any of the ${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"} out of the index, so nothing was scanned. An unusually large staged file is the usual cause.`, + "warn", + ); + return; + } + if (snapshot.unmaterializedFiles.length > 0 && !input.isQuiet) { + const stagedFileLabel = `staged file${stagedFileCount === 1 ? "" : "s"}`; + logger.warn( + `Skipped ${snapshot.unmaterializedFiles.length} of ${stagedFileCount} ${stagedFileLabel}; they could not be snapshotted from the index.`, + ); + logger.break(); + } + + const scanStagedProject = async ( + projectRun: (typeof stagedProjectRuns)[number], + ): Promise => { + const { projectScan, includePaths } = projectRun; + if ( + input.scanDeadlineEpochMs !== undefined && + remainingDeadlineBudgetMs(input.scanDeadlineEpochMs) === 0 + ) { + skippedProjects.push({ directory: projectScan.scanDirectory, reason: "max-duration" }); + return null; + } + const projectTempDirectory = path.join( + snapshot.tempDirectory, + projectScan.treeRelativeDirectory, + ); + const scanResult = await input.inspectProject(projectTempDirectory, { + ...scanOptions, + deadlineEpochMs: input.scanDeadlineEpochMs, + includePaths: [...includePaths], + configOverride: projectScan.projectConfig, + configSourceDirectory: projectScan.projectConfigSourceDirectory ?? undefined, + changedLineRanges: + stagedLineRanges === null + ? undefined + : resolveProjectChangedLineRanges( + resolvedDirectory, + projectScan.scanDirectory, + stagedLineRanges, + ), + suppressRendering: isMultiProject, + concurrentScan: isMultiProject, + }); + const diagnostics = scanResult.diagnostics.map((diagnostic) => ({ + ...diagnostic, + filePath: path.isAbsolute(diagnostic.filePath) + ? diagnostic.filePath.replaceAll(projectTempDirectory, () => projectScan.scanDirectory) + : diagnostic.filePath, + })); + return { + directory: projectScan.scanDirectory, + result: { + ...scanResult, + diagnostics, + project: { ...scanResult.project, rootDirectory: projectScan.scanDirectory }, + }, + config: projectScan.projectConfig, + }; + }; + + const stagedBatch = await runProjectScanBatch({ + projects: stagedProjectRuns, + isQuiet: input.isQuiet, + isSilent: scanOptions.silent === true, + scanProject: scanStagedProject, + }); + const completedScans = stagedBatch.completedScans; + reportSkippedProjects({ skippedProjects, isQuiet: input.isQuiet }); + + if (!input.isQuiet && isMultiProject && completedScans.length > 0) { + await Effect.runPromise( + printCompletedScansHeadless({ + categoryFilters: input.categoryFilters, + completedScans, + elapsedMilliseconds: stagedBatch.elapsedMilliseconds, + noScoreMessage: "Score unavailable.", + outputDirectory: flags.outputDir, + outputSurface: scanOptions.outputSurface ?? "cli", + projectName: path.basename(resolvedDirectory), + verbose: Boolean(flags.verbose), + }), + ); + } + if (flags.outputDir && isMultiProject && input.isQuiet) { + await Effect.runPromise( + printDiagnosticsDump( + filterDiagnosticsByCategories( + filterScansForSurface(completedScans, scanOptions.outputSurface ?? "cli"), + input.categoryFilters, + ), + flags.outputDir, + false, + "stderr", + ), + ); + } + + finalizeCliScans({ + completedScans, + skippedProjects, + mode: "staged", + diff: null, + baselineIntended: false, + isJsonMode: input.isJsonMode, + isScoreOnly: input.isScoreOnly, + flags, + categoryFilters: input.categoryFilters, + userConfig: scanTarget.userConfig, + resolvedDirectory, + startTime: input.startTime, + }); + } finally { + snapshot.cleanup(); + } +}; diff --git a/packages/vscode-react-doctor/src/extension.ts b/packages/vscode-react-doctor/src/extension.ts index 574d6f47a..6e201ab93 100644 --- a/packages/vscode-react-doctor/src/extension.ts +++ b/packages/vscode-react-doctor/src/extension.ts @@ -42,11 +42,12 @@ const renderStatus = (item: vscode.StatusBarItem, status: ServerStatusParams): v /** Opts into the server's `experimental/serverStatus` notification. */ const createServerStatusFeature = (): StaticFeature => ({ fillClientCapabilities(capabilities: ClientCapabilities) { - const experimental = (capabilities.experimental ?? (capabilities.experimental = {})) as Record< - string, - unknown - >; - experimental.serverStatusNotification = true; + const experimental = + typeof capabilities.experimental === "object" && capabilities.experimental !== null + ? capabilities.experimental + : {}; + Reflect.set(experimental, "serverStatusNotification", true); + capabilities.experimental = experimental; }, initialize() {}, getState(): FeatureState { From a38b5e611595c28fae22c2bb59b2faf3ca083a8d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 6 Aug 2026 22:53:24 +0000 Subject: [PATCH 07/17] refactor: harden internal ownership boundaries --- package.json | 3 + packages/core/src/build-json-report.ts | 2 +- .../detect-nextjs-static-export.ts | 20 ++ .../src/project-info/detect-react-compiler.ts | 57 ++++ .../core/src/project-info/discover-project.ts | 7 +- ....ts => react-compiler-config-evaluator.ts} | 316 +++++------------- packages/core/src/services/reporter.ts | 33 +- .../tests/detect-nextjs-static-export.test.ts | 2 +- packages/core/tests/services/reporter.test.ts | 44 +++ .../src/collect/package-json-entries.ts | 42 ++- .../tests/package-json-entries.test.ts | 35 ++ .../no-unescaped-dynamic-string-in-regexp.ts | 12 +- .../utils/is-controlled-prop-mirror.ts | 13 +- .../plugin/utils/is-property-name-position.ts | 11 + .../plugin/utils/reads-post-mount-value.ts | 13 +- .../react-doctor/src/cli/commands/inspect.ts | 22 +- packages/react-doctor/src/cli/index.ts | 115 ++++--- .../react-doctor/src/cli/ink/run-scan-app.tsx | 54 ++- .../react-doctor/src/cli/ink/scan-app.tsx | 4 +- .../react-doctor/src/cli/ink/scan-store.ts | 47 ++- .../cli/utils/active-scan-abort-registry.ts | 3 +- .../cli/utils/build-final-cli-scan-outcome.ts | 84 +++++ .../src/cli/utils/exit-gracefully.ts | 8 +- .../src/cli/utils/finalize-cli-scans.ts | 64 +--- .../src/cli/utils/project-scan-outcome.ts | 34 ++ .../src/cli/utils/run-project-scan-batch.ts | 31 +- .../src/cli/utils/run-staged-inspect.ts | 28 +- .../tests/active-scan-abort-registry.test.ts | 17 + .../build-final-cli-scan-outcome.test.ts | 117 +++++++ .../react-doctor/tests/ink/scan-store.test.ts | 49 +++ .../tests/run-project-scan-batch.test.ts | 25 ++ pnpm-lock.yaml | 4 + 32 files changed, 849 insertions(+), 467 deletions(-) create mode 100644 packages/core/src/project-info/detect-nextjs-static-export.ts create mode 100644 packages/core/src/project-info/detect-react-compiler.ts rename packages/core/src/project-info/{detectors.ts => react-compiler-config-evaluator.ts} (89%) create mode 100644 packages/deslop-js/tests/package-json-entries.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/is-property-name-position.ts create mode 100644 packages/react-doctor/src/cli/utils/build-final-cli-scan-outcome.ts create mode 100644 packages/react-doctor/src/cli/utils/project-scan-outcome.ts create mode 100644 packages/react-doctor/tests/build-final-cli-scan-outcome.test.ts create mode 100644 packages/react-doctor/tests/ink/scan-store.test.ts create mode 100644 packages/react-doctor/tests/run-project-scan-batch.test.ts diff --git a/package.json b/package.json index dcfca4d64..a3ccb6759 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "smoke:tty-prompt": "python3 scripts/smoke-tty-prompt.py", "fn-mining": "tsx scripts/fn-mining/run-fn-mining.ts" }, + "dependencies": { + "effect": "4.0.0-beta.70" + }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", diff --git a/packages/core/src/build-json-report.ts b/packages/core/src/build-json-report.ts index 7e94647d6..2623a7731 100644 --- a/packages/core/src/build-json-report.ts +++ b/packages/core/src/build-json-report.ts @@ -22,7 +22,7 @@ interface BuildJsonReportInput { directory: string; mode: JsonReportMode; diff: DiffInfo | null; - scans: Array<{ directory: string; result: InspectResult }>; + scans: ReadonlyArray<{ directory: string; result: InspectResult }>; skippedProjects?: ReadonlyArray; totalElapsedMilliseconds: number; /** diff --git a/packages/core/src/project-info/detect-nextjs-static-export.ts b/packages/core/src/project-info/detect-nextjs-static-export.ts new file mode 100644 index 000000000..711a6ca6e --- /dev/null +++ b/packages/core/src/project-info/detect-nextjs-static-export.ts @@ -0,0 +1,20 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isFile } from "./fs-utils.js"; + +export const NEXT_CONFIG_FILENAMES = [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "next.config.cjs", +]; + +const STATIC_EXPORT_OUTPUT_PATTERN = /(?:^|[^.\w])["']?output["']?\s*:\s*["']export["']/m; + +export const detectNextjsStaticExport = (directory: string): boolean => + NEXT_CONFIG_FILENAMES.some((filename) => { + const filePath = path.join(directory, filename); + return ( + isFile(filePath) && STATIC_EXPORT_OUTPUT_PATTERN.test(fs.readFileSync(filePath, "utf-8")) + ); + }); diff --git a/packages/core/src/project-info/detect-react-compiler.ts b/packages/core/src/project-info/detect-react-compiler.ts new file mode 100644 index 000000000..de9f72459 --- /dev/null +++ b/packages/core/src/project-info/detect-react-compiler.ts @@ -0,0 +1,57 @@ +import * as path from "node:path"; +import type { PackageJson } from "../types/index.js"; +import { isProjectBoundary } from "../utils/is-project-boundary.js"; +import { isFile } from "./fs-utils.js"; +import { + hasReactCompilerConfiguration, + hasReactCompilerConfigurationInAncestors, +} from "./react-compiler-config-evaluator.js"; +import { readPackageJson } from "./package-json.js"; + +const REACT_COMPILER_LINT_PACKAGES = new Set(["eslint-plugin-react-compiler"]); +const REACT_COMPILER_RUNTIME_PACKAGES = new Set(["react-compiler-runtime"]); + +const hasCompilerPackage = ( + packageJson: PackageJson, + compilerPackages: ReadonlySet, +): boolean => { + const allDependencies = { + ...packageJson.peerDependencies, + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + return Object.keys(allDependencies).some((packageName) => compilerPackages.has(packageName)); +}; + +const hasCompilerPackageInAncestors = ( + directory: string, + compilerPackages: ReadonlySet, +): boolean => { + if (isProjectBoundary(directory)) return false; + + let ancestorDirectory = path.dirname(directory); + while (ancestorDirectory !== path.dirname(ancestorDirectory)) { + const ancestorPackagePath = path.join(ancestorDirectory, "package.json"); + if (isFile(ancestorPackagePath)) { + const ancestorPackageJson = readPackageJson(ancestorPackagePath); + if (hasCompilerPackage(ancestorPackageJson, compilerPackages)) return true; + } + if (isProjectBoundary(ancestorDirectory)) return false; + ancestorDirectory = path.dirname(ancestorDirectory); + } + + return false; +}; + +export const detectReactCompiler = (directory: string, packageJson: PackageJson): boolean => + hasCompilerPackage(packageJson, REACT_COMPILER_RUNTIME_PACKAGES) || + hasCompilerPackageInAncestors(directory, REACT_COMPILER_RUNTIME_PACKAGES) || + hasReactCompilerConfiguration(directory, packageJson) || + hasReactCompilerConfigurationInAncestors(directory); + +export const detectReactCompilerLintPlugin = ( + directory: string, + packageJson: PackageJson, +): boolean => + hasCompilerPackage(packageJson, REACT_COMPILER_LINT_PACKAGES) || + hasCompilerPackageInAncestors(directory, REACT_COMPILER_LINT_PACKAGES); diff --git a/packages/core/src/project-info/discover-project.ts b/packages/core/src/project-info/discover-project.ts index c0999b6fc..d9c69511b 100644 --- a/packages/core/src/project-info/discover-project.ts +++ b/packages/core/src/project-info/discover-project.ts @@ -4,11 +4,8 @@ import type { PackageJson, ProjectInfo } from "../types/index.js"; import { LATEST_SUPPORTED_MOBX_MAJOR } from "../constants.js"; import { isFile } from "./fs-utils.js"; import { countSourceFiles } from "./count-source-files.js"; -import { - detectNextjsStaticExport, - detectReactCompiler, - detectReactCompilerLintPlugin, -} from "./detectors.js"; +import { detectNextjsStaticExport } from "./detect-nextjs-static-export.js"; +import { detectReactCompiler, detectReactCompilerLintPlugin } from "./detect-react-compiler.js"; import { detectPreES2023Target } from "./detect-pre-es2023-target.js"; import { extractDependencyInfo, diff --git a/packages/core/src/project-info/detectors.ts b/packages/core/src/project-info/react-compiler-config-evaluator.ts similarity index 89% rename from packages/core/src/project-info/detectors.ts rename to packages/core/src/project-info/react-compiler-config-evaluator.ts index 5da0ed64a..481cf70ae 100644 --- a/packages/core/src/project-info/detectors.ts +++ b/packages/core/src/project-info/react-compiler-config-evaluator.ts @@ -9,18 +9,9 @@ import { isProjectBoundary } from "../utils/is-project-boundary.js"; import { unwrapTypescriptExpression } from "../utils/unwrap-typescript-expression.js"; import { isFile, isPlainObject } from "./fs-utils.js"; import { isLocalModuleSpecifier } from "./is-local-module-specifier.js"; +import { NEXT_CONFIG_FILENAMES } from "./detect-nextjs-static-export.js"; import { readPackageJson } from "./package-json.js"; -const REACT_COMPILER_LINT_PACKAGES = new Set(["eslint-plugin-react-compiler"]); -const REACT_COMPILER_RUNTIME_PACKAGES = new Set(["react-compiler-runtime"]); - -const NEXT_CONFIG_FILENAMES = [ - "next.config.js", - "next.config.mjs", - "next.config.ts", - "next.config.cjs", -]; - const BABEL_CONFIG_FILENAMES = [ ".babelrc", ".babelrc.js", @@ -93,43 +84,6 @@ const REACT_COMPILER_CONFIG_RESOLVER = new ResolverFactory({ extensions: REACT_COMPILER_CONFIG_SOURCE_EXTENSIONS, }); -// `output: "export"` (static HTML export) in next.config.*. The leading -// `(?:^|[^.\w])` boundary keeps it from matching a nested/namespaced key like -// `experimental.output` or `outputFileTracingRoot`. -const STATIC_EXPORT_OUTPUT_PATTERN = /(?:^|[^.\w])["']?output["']?\s*:\s*["']export["']/m; - -const hasCompilerPackage = ( - packageJson: PackageJson, - compilerPackages: ReadonlySet, -): boolean => { - const allDependencies = { - ...packageJson.peerDependencies, - ...packageJson.dependencies, - ...packageJson.devDependencies, - }; - return Object.keys(allDependencies).some((packageName) => compilerPackages.has(packageName)); -}; - -const hasCompilerPackageInAncestors = ( - directory: string, - compilerPackages: ReadonlySet, -): boolean => { - if (isProjectBoundary(directory)) return false; - - let ancestorDirectory = path.dirname(directory); - while (ancestorDirectory !== path.dirname(ancestorDirectory)) { - const ancestorPackagePath = path.join(ancestorDirectory, "package.json"); - if (isFile(ancestorPackagePath)) { - const ancestorPackageJson = readPackageJson(ancestorPackagePath); - if (hasCompilerPackage(ancestorPackageJson, compilerPackages)) return true; - } - if (isProjectBoundary(ancestorDirectory)) return false; - ancestorDirectory = path.dirname(ancestorDirectory); - } - - return false; -}; - const resolveImportedConfigFile = ( fromFilePath: string, moduleSpecifier: string, @@ -183,6 +137,15 @@ const getStaticPropertyName = (propertyName: ts.PropertyName): string | null => ? propertyName.expression.text : null; +const getAccessedPropertyName = ( + expression: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null => + ts.isPropertyAccessExpression(expression) + ? expression.name.text + : expression.argumentExpression && ts.isStringLiteralLike(expression.argumentExpression) + ? expression.argumentExpression.text + : null; + const isCommonJsConfigExportAssignment = ( node: ts.Node, sourceFile: ts.SourceFile, @@ -463,15 +426,24 @@ const isConstantVariableInitializer = (node: ts.Node): node is ts.Expression => ts.isVariableDeclarationList(node.parent.parent) && Boolean(node.parent.parent.flags & ts.NodeFlags.Const); +type TransparentConfigExpression = + | ts.ParenthesizedExpression + | ts.AsExpression + | ts.TypeAssertion + | ts.SatisfiesExpression + | ts.NonNullExpression + | ts.AwaitExpression; + +const isTransparentConfigExpression = (node: ts.Node): node is TransparentConfigExpression => + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) || + ts.isAwaitExpression(node); + const isNodeCreateRequireCall = (node: ts.Node, analysis: ConfigExpressionAnalysis): boolean => { - if ( - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) || - ts.isTypeAssertionExpression(node) || - ts.isSatisfiesExpression(node) || - ts.isNonNullExpression(node) || - ts.isAwaitExpression(node) - ) { + if (isTransparentConfigExpression(node)) { return isNodeCreateRequireCall(node.expression, analysis); } if (!ts.isCallExpression(node)) return false; @@ -490,11 +462,7 @@ const isNodeCreateRequireCall = (node: ts.Node, analysis: ConfigExpressionAnalys if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) { return false; } - const propertyName = ts.isPropertyAccessExpression(target) - ? target.name.text - : target.argumentExpression && ts.isStringLiteralLike(target.argumentExpression) - ? target.argumentExpression.text - : null; + const propertyName = getAccessedPropertyName(target); if (propertyName !== "createRequire") return false; const createRequireReceiver = target.expression; if (ts.isCallExpression(createRequireReceiver)) { @@ -534,11 +502,7 @@ const getNodeRequireResolveModuleSpecifier = ( if (!moduleSpecifierNode || !ts.isStringLiteralLike(moduleSpecifierNode)) return null; const target = callExpression.expression; if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) return null; - const propertyName = ts.isPropertyAccessExpression(target) - ? target.name.text - : target.argumentExpression && ts.isStringLiteralLike(target.argumentExpression) - ? target.argumentExpression.text - : null; + const propertyName = getAccessedPropertyName(target); if (propertyName !== "resolve") return null; if (isNodeCreateRequireCall(target.expression, analysis)) return moduleSpecifierNode.text; if (!ts.isIdentifier(target.expression)) return null; @@ -849,16 +813,7 @@ const getSelectedObjectProperty = ( analysis: ConfigExpressionAnalysis, visitedExpressions: Set = new Set(), ): ConfigPropertyReference | null => { - let resolvedExpression = expression; - while ( - ts.isParenthesizedExpression(resolvedExpression) || - ts.isAsExpression(resolvedExpression) || - ts.isTypeAssertionExpression(resolvedExpression) || - ts.isSatisfiesExpression(resolvedExpression) || - ts.isNonNullExpression(resolvedExpression) - ) { - resolvedExpression = resolvedExpression.expression; - } + const resolvedExpression = unwrapTypescriptExpression(expression); if (visitedExpressions.has(resolvedExpression)) return null; visitedExpressions.add(resolvedExpression); if (ts.isIdentifier(resolvedExpression)) { @@ -922,16 +877,7 @@ const configExpressionMayDefineProperty = ( analysis: ConfigExpressionAnalysis, visitedExpressions: ReadonlySet = new Set(), ): boolean => { - let resolvedExpression = expression; - while ( - ts.isParenthesizedExpression(resolvedExpression) || - ts.isAsExpression(resolvedExpression) || - ts.isTypeAssertionExpression(resolvedExpression) || - ts.isSatisfiesExpression(resolvedExpression) || - ts.isNonNullExpression(resolvedExpression) - ) { - resolvedExpression = resolvedExpression.expression; - } + const resolvedExpression = unwrapTypescriptExpression(expression); if (visitedExpressions.has(resolvedExpression)) return true; const nextVisitedExpressions = new Set(visitedExpressions); nextVisitedExpressions.add(resolvedExpression); @@ -1480,11 +1426,7 @@ const analyzeConfigCallTarget = ( return null; } if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) return null; - const propertyName = ts.isPropertyAccessExpression(target) - ? target.name.text - : target.argumentExpression && ts.isStringLiteralLike(target.argumentExpression) - ? target.argumentExpression.text - : null; + const propertyName = getAccessedPropertyName(target); if (propertyName === null) return null; const requiredModuleSpecifier = getRequireModuleSpecifier(target.expression); @@ -1555,6 +1497,46 @@ const analyzeConfigCallTarget = ( : analyzeConfigNode(selectedProperty.node, selectedProperty.analysis, allowCompilerTransform); }; +const analyzeConfigMemberAccess = ( + expression: ts.Expression, + propertyName: string, + analysis: ConfigExpressionAnalysis, + allowCompilerTransform: boolean, +): boolean => { + if (!ts.isIdentifier(expression)) { + return analyzeConfigNode(expression, analysis, allowCompilerTransform); + } + if (analysis.localBindings.has(expression.text) || getScopedConfigBinding(expression).wasFound) { + const selectedProperty = getSelectedObjectProperty(expression, propertyName, analysis); + return Boolean( + selectedProperty && + analyzeConfigNode(selectedProperty.node, selectedProperty.analysis, allowCompilerTransform), + ); + } + const importBinding = getImportBinding(analysis.sourceFile, expression.text); + if (importBinding?.isNamespace) { + if ( + allowCompilerTransform && + isCompilerTransformModule(importBinding.moduleSpecifier, propertyName) + ) { + return true; + } + return Boolean( + analyzeImportedConfig({ + analysis, + moduleSpecifier: importBinding.moduleSpecifier, + exportName: propertyName, + allowCompilerTransform, + }), + ); + } + const selectedProperty = getSelectedObjectProperty(expression, propertyName, analysis); + return Boolean( + selectedProperty && + analyzeConfigNode(selectedProperty.node, selectedProperty.analysis, allowCompilerTransform), + ); +}; + const analyzeConfigNode = ( node: ts.Node, analysis: ConfigExpressionAnalysis, @@ -1584,14 +1566,7 @@ const analyzeConfigNode = ( (node.text === "babel-plugin-react-compiler" || node.text === "react-compiler") ); } - if ( - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) || - ts.isTypeAssertionExpression(node) || - ts.isSatisfiesExpression(node) || - ts.isNonNullExpression(node) || - ts.isAwaitExpression(node) - ) { + if (isTransparentConfigExpression(node)) { return analyzeConfigNode( node.expression, analysis, @@ -1843,113 +1818,24 @@ const analyzeConfigNode = ( allowCompilerTransform && isCompilerTransformModule(requiredModuleSpecifier, node.name.text) ); } - if (ts.isIdentifier(node.expression)) { - if ( - analysis.localBindings.has(node.expression.text) || - getScopedConfigBinding(node.expression).wasFound - ) { - const selectedProperty = getSelectedObjectProperty( - node.expression, - node.name.text, - analysis, - ); - return Boolean( - selectedProperty && - analyzeConfigNode( - selectedProperty.node, - selectedProperty.analysis, - allowCompilerTransform, - ), - ); - } - const importBinding = getImportBinding(analysis.sourceFile, node.expression.text); - if (importBinding?.isNamespace) { - if ( - allowCompilerTransform && - isCompilerTransformModule(importBinding.moduleSpecifier, node.name.text) - ) { - return true; - } - if ( - analyzeImportedConfig({ - analysis, - moduleSpecifier: importBinding.moduleSpecifier, - exportName: node.name.text, - allowCompilerTransform, - }) - ) { - return true; - } - return false; - } - const selectedProperty = getSelectedObjectProperty(node.expression, node.name.text, analysis); - if (selectedProperty) { - return analyzeConfigNode( - selectedProperty.node, - selectedProperty.analysis, - allowCompilerTransform, - ); - } - return false; - } - return analyzeConfigNode(node.expression, analysis, allowCompilerTransform); + return analyzeConfigMemberAccess( + node.expression, + node.name.text, + analysis, + allowCompilerTransform, + ); } if ( ts.isElementAccessExpression(node) && node.argumentExpression && ts.isStringLiteralLike(node.argumentExpression) ) { - if (ts.isIdentifier(node.expression)) { - if ( - analysis.localBindings.has(node.expression.text) || - getScopedConfigBinding(node.expression).wasFound - ) { - const selectedProperty = getSelectedObjectProperty( - node.expression, - node.argumentExpression.text, - analysis, - ); - return Boolean( - selectedProperty && - analyzeConfigNode( - selectedProperty.node, - selectedProperty.analysis, - allowCompilerTransform, - ), - ); - } - const importBinding = getImportBinding(analysis.sourceFile, node.expression.text); - if (importBinding?.isNamespace) { - if ( - allowCompilerTransform && - isCompilerTransformModule(importBinding.moduleSpecifier, node.argumentExpression.text) - ) { - return true; - } - return Boolean( - analyzeImportedConfig({ - analysis, - moduleSpecifier: importBinding.moduleSpecifier, - exportName: node.argumentExpression.text, - allowCompilerTransform, - }), - ); - } - const selectedProperty = getSelectedObjectProperty( - node.expression, - node.argumentExpression.text, - analysis, - ); - if (selectedProperty) { - return analyzeConfigNode( - selectedProperty.node, - selectedProperty.analysis, - allowCompilerTransform, - ); - } - return false; - } - return analyzeConfigNode(node.expression, analysis, allowCompilerTransform); + return analyzeConfigMemberAccess( + node.expression, + node.argumentExpression.text, + analysis, + allowCompilerTransform, + ); } if (ts.isBinaryExpression(node)) { if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { @@ -2107,11 +1993,14 @@ const hasCompilerInPackageJsonConfig = (directory: string, packageJson: PackageJ ); }; -const hasCompilerConfiguration = (directory: string, packageJson: PackageJson): boolean => +export const hasReactCompilerConfiguration = ( + directory: string, + packageJson: PackageJson, +): boolean => hasCompilerInPackageJsonConfig(directory, packageJson) || hasCompilerInConfigFiles(directory, REACT_COMPILER_CONFIG_FILENAMES); -const hasCompilerConfigurationInAncestors = (directory: string): boolean => { +export const hasReactCompilerConfigurationInAncestors = (directory: string): boolean => { if (isProjectBoundary(directory)) return false; let ancestorDirectory = path.dirname(directory); @@ -2132,28 +2021,3 @@ const hasCompilerConfigurationInAncestors = (directory: string): boolean => { return false; }; - -export const detectReactCompiler = (directory: string, packageJson: PackageJson): boolean => - hasCompilerPackage(packageJson, REACT_COMPILER_RUNTIME_PACKAGES) || - hasCompilerPackageInAncestors(directory, REACT_COMPILER_RUNTIME_PACKAGES) || - hasCompilerConfiguration(directory, packageJson) || - hasCompilerConfigurationInAncestors(directory); - -export const detectReactCompilerLintPlugin = ( - directory: string, - packageJson: PackageJson, -): boolean => - hasCompilerPackage(packageJson, REACT_COMPILER_LINT_PACKAGES) || - hasCompilerPackageInAncestors(directory, REACT_COMPILER_LINT_PACKAGES); - -// Whether `next.config.*` opts into static HTML export (`output: "export"`). -// Reuses the same next.config filenames + raw-text read as the React Compiler -// detector above (the config can be TS/ESM, so it can't be cheaply imported at -// discovery time). A per-project fact — not walked into ancestors. -export const detectNextjsStaticExport = (directory: string): boolean => - NEXT_CONFIG_FILENAMES.some((filename) => { - const filePath = path.join(directory, filename); - return ( - isFile(filePath) && STATIC_EXPORT_OUTPUT_PATTERN.test(fs.readFileSync(filePath, "utf-8")) - ); - }); diff --git a/packages/core/src/services/reporter.ts b/packages/core/src/services/reporter.ts index 75d04c07f..fac6e9a9a 100644 --- a/packages/core/src/services/reporter.ts +++ b/packages/core/src/services/reporter.ts @@ -62,21 +62,28 @@ export class Reporter extends Context.Service< static readonly layerNdjson = (filePath: string): Layer.Layer => Layer.effect( Reporter, - Effect.sync(() => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const handle = fs.openSync(filePath, "a"); - const encode = Schema.encodeUnknownSync(Diagnostic); + Effect.acquireRelease( + Effect.sync(() => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + let handle: number | null = fs.openSync(filePath, "a"); + const encode = Schema.encodeUnknownSync(Diagnostic); - const emit = (diagnostic: Diagnostic): Effect.Effect => - Effect.sync(() => { - fs.writeSync(handle, `${JSON.stringify(encode(diagnostic))}\n`); - }); + const emit = (diagnostic: Diagnostic): Effect.Effect => + Effect.sync(() => { + if (handle === null) throw new Error("Cannot emit after Reporter.finalize"); + fs.writeSync(handle, `${JSON.stringify(encode(diagnostic))}\n`); + }); - const finalize = Effect.sync(() => { - fs.closeSync(handle); - }); + const finalize = Effect.sync(() => { + if (handle === null) return; + const openHandle = handle; + handle = null; + fs.closeSync(openHandle); + }); - return Reporter.of({ emit, finalize }); - }), + return Reporter.of({ emit, finalize }); + }), + (reporter) => reporter.finalize, + ), ); } diff --git a/packages/core/tests/detect-nextjs-static-export.test.ts b/packages/core/tests/detect-nextjs-static-export.test.ts index 25e8186bb..3f835d83e 100644 --- a/packages/core/tests/detect-nextjs-static-export.test.ts +++ b/packages/core/tests/detect-nextjs-static-export.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; -import { detectNextjsStaticExport } from "../src/project-info/detectors.js"; +import { detectNextjsStaticExport } from "../src/project-info/detect-nextjs-static-export.js"; const temporaryRoots: string[] = []; diff --git a/packages/core/tests/services/reporter.test.ts b/packages/core/tests/services/reporter.test.ts index 078941388..691497ad1 100644 --- a/packages/core/tests/services/reporter.test.ts +++ b/packages/core/tests/services/reporter.test.ts @@ -1,6 +1,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { Diagnostic } from "../../src/schemas.js"; import { Reporter, ReporterCapture } from "../../src/services/reporter.js"; @@ -70,3 +73,44 @@ describe("Reporter.layerCapture", () => { expect(captured).toEqual([]); }); }); + +describe("Reporter.layerNdjson", () => { + it("owns the file handle and allows explicit finalization to be repeated", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-reporter-")); + const filePath = path.join(directory, "diagnostics.ndjson"); + try { + await Effect.runPromise( + Effect.gen(function* () { + const reporter = yield* Reporter; + yield* reporter.emit(sampleDiagnostic); + yield* reporter.finalize; + yield* reporter.finalize; + }).pipe(Effect.provide(Reporter.layerNdjson(filePath))), + ); + + expect(fs.readFileSync(filePath, "utf8").trim()).toContain('"rule":"no-danger"'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("closes the file handle when the layer scope ends", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-reporter-")); + const filePath = path.join(directory, "diagnostics.ndjson"); + try { + const reporter = await Effect.runPromise( + Effect.gen(function* () { + const scopedReporter = yield* Reporter; + yield* scopedReporter.emit(sampleDiagnostic); + return scopedReporter; + }).pipe(Effect.provide(Reporter.layerNdjson(filePath))), + ); + + const emitAfterScope = await Effect.runPromiseExit(reporter.emit(sampleDiagnostic)); + expect(emitAfterScope._tag).toBe("Failure"); + expect(fs.readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/deslop-js/src/collect/package-json-entries.ts b/packages/deslop-js/src/collect/package-json-entries.ts index 56bd90b07..765c572e0 100644 --- a/packages/deslop-js/src/collect/package-json-entries.ts +++ b/packages/deslop-js/src/collect/package-json-entries.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import fg from "fast-glob"; import { resolveSourcePath } from "../resolver/source-path.js"; import { @@ -68,26 +68,31 @@ export const findDefaultIndexEntry = (directory: string): string | undefined => return undefined; }; -const findSourceFile = (baseDirectory: string, relativePath: string): string | undefined => { - const pathWithoutExtension = join(baseDirectory, relativePath).replace(/\.[cm]?js(x?)$/, ""); +const findSourceFileWithKnownExtension = (pathWithoutExtension: string): string | undefined => { for (const sourceExtension of SOURCE_EXTENSIONS) { const candidatePath = pathWithoutExtension + sourceExtension; if (existsSync(candidatePath)) return candidatePath; } - const indexCandidate = join(pathWithoutExtension, "index.ts"); - return existsSync(indexCandidate) ? indexCandidate : undefined; + return undefined; }; -const findSourceFileStrict = (baseDirectory: string, relativePath: string): string | undefined => { +const findSourceFile = ( + baseDirectory: string, + relativePath: string, + shouldResolveDirectoryIndex = true, +): string | undefined => { const pathWithoutExtension = join(baseDirectory, relativePath).replace(/\.[cm]?js(x?)$/, ""); - for (const sourceExtension of SOURCE_EXTENSIONS) { - const candidatePath = pathWithoutExtension + sourceExtension; - if (existsSync(candidatePath)) return candidatePath; - } - const exactPath = join(baseDirectory, relativePath); - return existsSync(exactPath) ? exactPath : undefined; + const sourceFile = findSourceFileWithKnownExtension(pathWithoutExtension); + if (sourceFile) return sourceFile; + const fallbackPath = shouldResolveDirectoryIndex + ? join(pathWithoutExtension, "index.ts") + : join(baseDirectory, relativePath); + return existsSync(fallbackPath) ? fallbackPath : undefined; }; +const findSourceFileStrict = (baseDirectory: string, relativePath: string): string | undefined => + findSourceFile(baseDirectory, relativePath, false); + const resolveBuiltPathToSource = ( builtAbsolutePath: string, rootDirectory: string, @@ -105,10 +110,15 @@ const resolveBuiltPathToSource = ( if (!outDirectory) return undefined; const absoluteOutDirectory = resolve(rootDirectory, outDirectory); - const relativeToBuild = builtAbsolutePath.startsWith(absoluteOutDirectory) - ? builtAbsolutePath.slice(absoluteOutDirectory.length) - : undefined; - if (!relativeToBuild) return undefined; + const relativeToBuild = relative(absoluteOutDirectory, builtAbsolutePath); + if ( + relativeToBuild.length === 0 || + relativeToBuild === ".." || + relativeToBuild.startsWith(`..${sep}`) || + isAbsolute(relativeToBuild) + ) { + return undefined; + } const configuredRootDirectory = tsconfig?.compilerOptions?.rootDir; const sourceRoot = configuredRootDirectory diff --git a/packages/deslop-js/tests/package-json-entries.test.ts b/packages/deslop-js/tests/package-json-entries.test.ts new file mode 100644 index 000000000..21582be4e --- /dev/null +++ b/packages/deslop-js/tests/package-json-entries.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { extractPackageJsonEntries } from "../src/collect/package-json-entries.js"; + +const temporaryRoot = mkdtempSync(join(os.tmpdir(), "deslop-package-entries-")); + +after(() => { + rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +describe("extractPackageJsonEntries", () => { + it("does not treat sibling output-directory prefixes as descendants", async () => { + const projectDirectory = join(temporaryRoot, "out-directory-prefix"); + const expectedSourcePath = join(projectDirectory, "src", "index.ts"); + const misleadingSourcePath = join(projectDirectory, "-other", "index.ts"); + mkdirSync(join(projectDirectory, "src"), { recursive: true }); + mkdirSync(join(projectDirectory, "-other"), { recursive: true }); + writeFileSync(expectedSourcePath, "export const expected = true;\n"); + writeFileSync(misleadingSourcePath, "export const misleading = true;\n"); + writeFileSync( + join(projectDirectory, "tsconfig.json"), + JSON.stringify({ compilerOptions: { outDir: "dist", rootDir: "." } }), + ); + const packageJsonPath = join(projectDirectory, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ main: "dist-other/index.js" })); + + const entries = await extractPackageJsonEntries(packageJsonPath); + + assert.ok(entries.includes(expectedSourcePath)); + assert.ok(!entries.includes(misleadingSourcePath)); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unescaped-dynamic-string-in-regexp.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unescaped-dynamic-string-in-regexp.ts index 67ea07fc8..588c4b395 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unescaped-dynamic-string-in-regexp.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unescaped-dynamic-string-in-regexp.ts @@ -10,6 +10,7 @@ import { hasBindingWriteBetween } from "../../utils/has-binding-write-between.js import { isEarlyExitStatement } from "../../utils/is-early-exit-statement.js"; import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { isPropertyNamePosition } from "../../utils/is-property-name-position.js"; import { stripParenExpression } from "../../utils/strip-paren-expression.js"; import { walkAst } from "../../utils/walk-ast.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; @@ -321,17 +322,6 @@ const isRegexSourceAccess = (node: EsTreeNode): boolean => isNodeOfType(node.property, "Identifier") && node.property.name === "source"; -// Method/property name positions (`terms.filter(...)`, `{ query: x }`) are -// not value reads — only value-position identifiers can carry the term. -const isPropertyNamePosition = (identifier: EsTreeNode): boolean => { - const parent = identifier.parent; - if (!parent) return false; - if (isNodeOfType(parent, "MemberExpression")) { - return parent.property === identifier && !parent.computed; - } - return isNodeOfType(parent, "Property") && parent.key === identifier && !parent.computed; -}; - const isTypePositionIdentifier = (identifier: EsTreeNode): boolean => { let child = identifier; let ancestor = identifier.parent; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts index 6c9eb2787..bd8789701 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/is-controlled-prop-mirror.ts @@ -3,6 +3,7 @@ import type { EsTreeNode } from "../../../utils/es-tree-node.js"; import { getJsxAttributeName } from "../../../utils/get-jsx-attribute-name.js"; import { isFunctionLike } from "../../../utils/is-function-like.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; +import { isPropertyNamePosition } from "../../../utils/is-property-name-position.js"; import { walkAst } from "../../../utils/walk-ast.js"; import { isEventHandlerName } from "./event-handler-reference.js"; @@ -31,18 +32,6 @@ const getOwnScopeBoundNames = (functionNode: EsTreeNode): ReadonlySet => const declaresBindingNamed = (functionNode: EsTreeNode, bindingName: string): boolean => getOwnScopeBoundNames(functionNode).has(bindingName); -const isPropertyNamePosition = (identifier: EsTreeNode): boolean => { - const parent = identifier.parent; - if (!parent) return false; - if (isNodeOfType(parent, "MemberExpression")) { - return parent.property === identifier && !parent.computed; - } - if (isNodeOfType(parent, "Property")) { - return parent.key === identifier && !parent.computed; - } - return false; -}; - const referencesIdentifierNamed = (root: EsTreeNode, identifierName: string): boolean => { let isReferenced = false; walkAst(root, (child: EsTreeNode): boolean | void => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-property-name-position.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-property-name-position.ts new file mode 100644 index 000000000..fe5762041 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-property-name-position.ts @@ -0,0 +1,11 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isPropertyNamePosition = (identifier: EsTreeNode): boolean => { + const parent = identifier.parent; + if (!parent) return false; + if (isNodeOfType(parent, "MemberExpression")) { + return parent.property === identifier && !parent.computed; + } + return isNodeOfType(parent, "Property") && parent.key === identifier && !parent.computed; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts index a69f0e15a..83245012b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reads-post-mount-value.ts @@ -2,6 +2,7 @@ import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { findProgramRoot } from "./find-program-root.js"; import { isNodeOfType } from "./is-node-of-type.js"; +import { isPropertyNamePosition } from "./is-property-name-position.js"; import { walkAst } from "./walk-ast.js"; // DOM/layout reads + globals that are NOT knowable at render time. A value @@ -178,18 +179,6 @@ export const isPostMountMemberRead = (node: EsTreeNode): boolean => { return isRefLikeReceiver(node.object as EsTreeNode); }; -const isPropertyNamePosition = (identifier: EsTreeNode): boolean => { - const parent = identifier.parent; - if (!parent) return false; - if (isNodeOfType(parent, "MemberExpression")) { - return parent.property === identifier && !parent.computed; - } - if (isNodeOfType(parent, "Property")) { - return parent.key === identifier && !parent.computed; - } - return false; -}; - // A read of a browser global itself — NOT a same-named property on a data // object (`data.document`, `config.window`). export const isPostMountGlobalRead = (node: EsTreeNode): boolean => diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index f3f57aad1..d7ba62080 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -56,7 +56,7 @@ import { retryMissingProjectScores } from "../utils/retry-missing-project-scores import { resolveProjectChangedLineRanges } from "../utils/resolve-project-diff-include-paths.js"; import { resolveProjectScan, type ResolvedProjectScan } from "../utils/resolve-project-scan.js"; import { runExplain } from "../utils/run-explain.js"; -import { runProjectScanBatch } from "../utils/run-project-scan-batch.js"; +import { type ProjectScanOutcome, runProjectScanBatch } from "../utils/run-project-scan-batch.js"; import { buildProjectScanPlan } from "../utils/build-project-scan-plan.js"; import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; import { selectProjects } from "../utils/select-projects.js"; @@ -390,15 +390,17 @@ export const inspectAction = async ( projectScans.map((projectScan) => projectScan.directory), ) : null; - const skippedProjects: JsonReportSkippedProject[] = []; - - const scanProject = async (projectScan: ResolvedProjectScan): Promise => { + const scanProject = async ( + projectScan: ResolvedProjectScan, + ): Promise> => { if ( scanDeadlineEpochMs !== undefined && remainingDeadlineBudgetMs(scanDeadlineEpochMs) === 0 ) { - skippedProjects.push({ directory: projectScan.directory, reason: "max-duration" }); - return null; + return { + status: "skipped", + value: { directory: projectScan.directory, reason: "max-duration" }, + }; } const scanDirectory = projectScan.directory; const projectConfig = projectScan.config; @@ -421,7 +423,7 @@ export const inspectAction = async ( logger.dim(`No changed source files in ${scanDirectory}, skipping.`); logger.break(); } - return null; + return { status: "omitted" }; } if (!isQuiet && !isMultiProject) { @@ -464,7 +466,10 @@ export const inspectAction = async ( if (!isQuiet && !isMultiProject) { logger.break(); } - return { directory: scanDirectory, result: scanResult, config: projectConfig }; + return { + status: "completed", + value: { directory: scanDirectory, result: scanResult, config: projectConfig }, + }; }; const projectBatch = await runProjectScanBatch({ @@ -479,6 +484,7 @@ export const inspectAction = async ( isScoreDisabled: scanOptions.noScore ?? completedScan.config?.noScore ?? false, })), ); + const skippedProjects = projectBatch.skippedScans; reportSkippedProjects({ skippedProjects, isQuiet }); if (!isQuiet && isMultiProject && completedScans.length > 0) { diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index c7514365c..6fdfca00d 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -1,22 +1,6 @@ import { Command, Option } from "commander"; import { CANONICAL_GITHUB_URL, CI_URL, highlighter } from "@react-doctor/core"; import { flushSentry, initializeSentry } from "../instrument.js"; -import { ciConfigAction, ciInstallAction, ciUpgradeAction } from "./commands/ci.js"; -import { designAction } from "./commands/design.js"; -import { installAction } from "./commands/install.js"; -import { runScanCommand } from "./commands/scan.js"; -import { - rulesCategoryAction, - rulesDisableAction, - rulesEnableAction, - rulesExplainAction, - rulesIgnoreTagAction, - rulesListAction, - rulesSetAction, - rulesUnignoreTagAction, -} from "./commands/rules.js"; -import { versionAction } from "./commands/version.js"; -import { whyAction } from "./commands/why.js"; import { applyColorPreference } from "./utils/apply-color-preference.js"; import { ensureWindowsUtf8Console } from "./utils/ensure-windows-utf8-console.js"; import { exitGracefully } from "./utils/exit-gracefully.js"; @@ -254,21 +238,23 @@ const program = new Command() .option("--no-color", "disable colored output (also honors NO_COLOR)") .addHelpText("after", renderRootHelpEpilog); -program.action((directory = ".", flags: InspectFlags) => - runScanCommand({ +program.action(async (directory = ".", flags: InspectFlags) => { + const { runScanCommand } = await import("./commands/scan.js"); + return runScanCommand({ directory, flags, invocationCommand: "inspect", - }), -); + }); +}); program .command("design [directory]") .description("Run only the focused UI design diagnostics") .addHelpText("after", renderDesignHelpEpilog) - .action((directory, _options, command) => - designAction(directory ?? ".", command.optsWithGlobals()), - ); + .action(async (directory, _options, command) => { + const { designAction } = await import("./commands/design.js"); + return designAction(directory ?? ".", command.optsWithGlobals()); + }); program .command("why ") @@ -280,7 +266,10 @@ program .option("-c, --cwd ", "working directory", process.cwd()) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") - .action((location, options) => whyAction(location, options)); + .action(async (location, options) => { + const { whyAction } = await import("./commands/why.js"); + return whyAction(location, options); + }); program .command("install") @@ -293,7 +282,10 @@ program .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") .addHelpText("after", renderInstallHelpEpilog) - .action(installAction); + .action(async (options, command) => { + const { installAction } = await import("./commands/install.js"); + return installAction(options, command); + }); const providerOption: [string, string] = [ "--provider ", @@ -333,7 +325,10 @@ ci.command("install") .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") .addHelpText("after", renderCiHelpEpilog) - .action((_options, command) => ciInstallAction(command.optsWithGlobals())); + .action(async (_options, command) => { + const { ciInstallAction } = await import("./commands/ci.js"); + return ciInstallAction(command.optsWithGlobals()); + }); ci.command("config") .description("Change the gate, scan scope, and pull-request reporting") @@ -350,7 +345,10 @@ ci.command("config") .option("-c, --cwd ", "working directory", process.cwd()) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") - .action((_options, command) => ciConfigAction(command.optsWithGlobals())); + .action(async (_options, command) => { + const { ciConfigAction } = await import("./commands/ci.js"); + return ciConfigAction(command.optsWithGlobals()); + }); ci.command("upgrade") .description("Upgrade the CI workflow to the action's current major") @@ -360,14 +358,20 @@ ci.command("upgrade") .option("-c, --cwd ", "working directory", process.cwd()) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") - .action((_options, command) => ciUpgradeAction(command.optsWithGlobals())); + .action(async (_options, command) => { + const { ciUpgradeAction } = await import("./commands/ci.js"); + return ciUpgradeAction(command.optsWithGlobals()); + }); program .command("version") .description("show the version with Node and platform info") .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") - .action(versionAction); + .action(async () => { + const { versionAction } = await import("./commands/version.js"); + return versionAction(); + }); const rules = program .command("rules") @@ -387,55 +391,75 @@ rules .option("--configured", "only show rules your config has changed from the default") .option("--json", "output a structured JSON array") .option("-c, --cwd ", "working directory", process.cwd()) - .action((_options, command) => rulesListAction(command.optsWithGlobals())); + .action(async (_options, command) => { + const { rulesListAction } = await import("./commands/rules.js"); + return rulesListAction(command.optsWithGlobals()); + }); rules .command("explain ") .description("Explain why a rule matters, its current severity, and how to configure it") .option("--json", "output a structured JSON object") .option("-c, --cwd ", "working directory", process.cwd()) - .action((rule, _options, command) => rulesExplainAction(rule, command.optsWithGlobals())); + .action(async (rule, _options, command) => { + const { rulesExplainAction } = await import("./commands/rules.js"); + return rulesExplainAction(rule, command.optsWithGlobals()); + }); rules .command("set ") .description("Set a rule's severity: off, warn, or error") .option("-c, --cwd ", "working directory", process.cwd()) - .action((rule, severity, _options, command) => - rulesSetAction(rule, severity, command.optsWithGlobals()), - ); + .action(async (rule, severity, _options, command) => { + const { rulesSetAction } = await import("./commands/rules.js"); + return rulesSetAction(rule, severity, command.optsWithGlobals()); + }); rules .command("enable ") .description("Enable a rule at its recommended severity (or pass --severity)") .option("--severity ", "severity to enable at: warn or error") .option("-c, --cwd ", "working directory", process.cwd()) - .action((rule, _options, command) => rulesEnableAction(rule, command.optsWithGlobals())); + .action(async (rule, _options, command) => { + const { rulesEnableAction } = await import("./commands/rules.js"); + return rulesEnableAction(rule, command.optsWithGlobals()); + }); rules .command("disable ") .description("Disable a rule so it never runs") .option("-c, --cwd ", "working directory", process.cwd()) - .action((rule, _options, command) => rulesDisableAction(rule, command.optsWithGlobals())); + .action(async (rule, _options, command) => { + const { rulesDisableAction } = await import("./commands/rules.js"); + return rulesDisableAction(rule, command.optsWithGlobals()); + }); rules .command("category ") .description("Set the severity for a whole category (off, warn, error)") .option("-c, --cwd ", "working directory", process.cwd()) - .action((category, severity, _options, command) => - rulesCategoryAction(category, severity, command.optsWithGlobals()), - ); + .action(async (category, severity, _options, command) => { + const { rulesCategoryAction } = await import("./commands/rules.js"); + return rulesCategoryAction(category, severity, command.optsWithGlobals()); + }); rules .command("ignore-tag ") .description("Skip a whole rule family by tag before linting (e.g. design)") .option("-c, --cwd ", "working directory", process.cwd()) - .action((tag, _options, command) => rulesIgnoreTagAction(tag, command.optsWithGlobals())); + .action(async (tag, _options, command) => { + const { rulesIgnoreTagAction } = await import("./commands/rules.js"); + return rulesIgnoreTagAction(tag, command.optsWithGlobals()); + }); rules .command("unignore-tag ") .description("Stop ignoring a tag previously skipped via ignore-tag") .option("-c, --cwd ", "working directory", process.cwd()) - .action((tag, _options, command) => rulesUnignoreTagAction(tag, command.optsWithGlobals())); + .action(async (tag, _options, command) => { + const { rulesUnignoreTagAction } = await import("./commands/rules.js"); + return rulesUnignoreTagAction(tag, command.optsWithGlobals()); + }); // NOTE: `react-doctor experimental-lsp` is intentionally NOT wired through // commander. The bin shim (bin/react-doctor.js) fast-paths it to a dedicated @@ -467,13 +491,14 @@ program .option("--max-duration ", MAX_DURATION_OPTION_DESCRIPTION) .option("-p, --project ", "scan specific workspace projects (comma-separated, or *)") .option("-y, --yes", "skip the project prompt and scan every discovered project") - .action((directory = ".", _localOptions, command) => - runScanCommand({ + .action(async (directory = ".", _localOptions, command) => { + const { runScanCommand } = await import("./commands/scan.js"); + return runScanCommand({ directory, flags: command.optsWithGlobals(), invocationCommand: "experimental-tui", - }), - ); + }); + }); // HACK: when output is piped into a process that closes early (e.g. // `react-doctor . | head`), Node throws an uncaught EPIPE on the next 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 674edb9eb..5b76d176f 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -60,6 +60,10 @@ import { resolveBlockingLevel } from "../utils/resolve-blocking-level.js"; import { resolveProjectTuiScanScope } from "../utils/resolve-project-tui-scan-scope.js"; import { resolveProjectScan, type ResolvedProjectScan } from "../utils/resolve-project-scan.js"; import { resolveTuiScanScope, type TuiScanScopePlan } from "../utils/resolve-tui-scan-scope.js"; +import { + partitionProjectScanOutcomes, + type ProjectScanOutcome, +} from "../utils/project-scan-outcome.js"; import { selectReportDiagnostics } from "../utils/select-report-diagnostics.js"; import { shouldFailScanGate } from "../utils/should-fail-scan-gate.js"; import { ProjectSelect } from "./components/project-select.js"; @@ -98,17 +102,12 @@ interface ScanPresentation { readonly verbose: boolean; } -interface CompletedProjectScanOutcome { - readonly status: "completed"; +interface TuiProjectScan { readonly directory: string; readonly result: InspectResult; readonly config: ReactDoctorConfig | null; } -interface SkippedProjectScanOutcome extends JsonReportSkippedProject { - readonly status: "skipped"; -} - const qualifyDiagnosticPaths = ( diagnostics: ReadonlyArray, rootDirectory: string, @@ -646,7 +645,10 @@ const runMultiProjectScan = async ( const scanOutcomes = await mapWithConcurrency( projectScans, DEFAULT_PROJECT_SCAN_CONCURRENCY, - async ({ projectScan, scopeOptions }) => { + async ({ + projectScan, + scopeOptions, + }): Promise> => { if ( input.options?.deadlineEpochMs !== undefined && remainingDeadlineBudgetMs(input.options.deadlineEpochMs) === 0 @@ -657,9 +659,8 @@ const runMultiProjectScan = async ( ); return { status: "skipped", - directory: projectScan.directory, - reason: "max-duration", - } satisfies SkippedProjectScanOutcome; + value: { directory: projectScan.directory, reason: "max-duration" }, + }; } const projectLabel = path.relative(rootDirectory, projectScan.directory) || path.basename(rootDirectory); @@ -698,29 +699,24 @@ const runMultiProjectScan = async ( await yieldToEventLoop(); return { status: "completed", - directory: projectScan.directory, - result, - config: projectScan.config, - } satisfies CompletedProjectScanOutcome; + value: { + directory: projectScan.directory, + result, + config: projectScan.config, + }, + }; }, ); + const { completedScans, skippedScans } = partitionProjectScanOutcomes(scanOutcomes); const results = await retryMissingProjectScores( - scanOutcomes - .filter( - (scanOutcome): scanOutcome is CompletedProjectScanOutcome => - scanOutcome.status === "completed", - ) - .map((completedScan) => ({ - ...completedScan, - isScoreDisabled: input.options?.noScore ?? completedScan.config?.noScore ?? false, - })), + completedScans.map((completedScan) => ({ + ...completedScan, + isScoreDisabled: input.options?.noScore ?? completedScan.config?.noScore ?? false, + })), + ); + const skippedProjects = skippedScans.sort((left, right) => + left.directory.localeCompare(right.directory), ); - const skippedProjects = scanOutcomes - .filter( - (scanOutcome): scanOutcome is SkippedProjectScanOutcome => scanOutcome.status === "skipped", - ) - .map(({ directory, reason }) => ({ directory, reason })) - .sort((left, right) => left.directory.localeCompare(right.directory)); if (skippedProjects.length > 0) { recordCount(METRIC.scanProjectSkipped, skippedProjects.length, { reason: "max-duration", diff --git a/packages/react-doctor/src/cli/ink/scan-app.tsx b/packages/react-doctor/src/cli/ink/scan-app.tsx index 8ff8d8136..46781ca7e 100644 --- a/packages/react-doctor/src/cli/ink/scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/scan-app.tsx @@ -35,7 +35,7 @@ export const ScanApp = ({ exit(); }; - if (displayMode === "report" && snapshot.phase === "summary" && snapshot.summary) { + if (displayMode === "report" && snapshot.phase === "summary") { return ( ; readonly progress: string | null; - readonly report: ScanReport | null; - readonly summary: MultiProjectSummary | null; } +interface ScanningSnapshot extends ScanStoreSnapshotBase { + readonly phase: "scanning"; + readonly report?: never; + readonly summary?: never; +} + +interface ReportSnapshot extends ScanStoreSnapshotBase { + readonly phase: "report"; + readonly report: ScanReport; + readonly summary?: never; +} + +interface SummarySnapshot extends ScanStoreSnapshotBase { + readonly phase: "summary"; + readonly report?: never; + readonly summary: MultiProjectSummary; +} + +export type ScanStoreSnapshot = ScanningSnapshot | ReportSnapshot | SummarySnapshot; + export interface ScanStore { readonly subscribe: (listener: () => void) => () => void; readonly getSnapshot: () => ScanStoreSnapshot; @@ -68,19 +83,17 @@ const INITIAL_SNAPSHOT: ScanStoreSnapshot = { phase: "scanning", liveDiagnostics: [], progress: null, - report: null, - summary: null, }; export const createScanStore = (): ScanStore => { - let snapshot = INITIAL_SNAPSHOT; + let snapshot: ScanStoreSnapshot = INITIAL_SNAPSHOT; const listeners = new Set<() => void>(); let pendingProgress: string | null = null; let progressTimer: ReturnType | null = null; const commit = (next: ScanStoreSnapshot): void => { snapshot = next; - for (const listener of listeners) listener(); + for (const listener of [...listeners]) listener(); }; const cancelPendingProgress = (): void => { @@ -124,11 +137,21 @@ export const createScanStore = (): ScanStore => { setProgress, setReport: (report) => { cancelPendingProgress(); - commit({ ...snapshot, report, phase: "report" }); + commit({ + phase: "report", + liveDiagnostics: snapshot.liveDiagnostics, + progress: snapshot.progress, + report, + }); }, setSummary: (summary) => { cancelPendingProgress(); - commit({ ...snapshot, summary, phase: "summary" }); + commit({ + phase: "summary", + liveDiagnostics: snapshot.liveDiagnostics, + progress: snapshot.progress, + summary, + }); }, }; }; diff --git a/packages/react-doctor/src/cli/utils/active-scan-abort-registry.ts b/packages/react-doctor/src/cli/utils/active-scan-abort-registry.ts index 22b84c675..b3b32977b 100644 --- a/packages/react-doctor/src/cli/utils/active-scan-abort-registry.ts +++ b/packages/react-doctor/src/cli/utils/active-scan-abort-registry.ts @@ -11,7 +11,8 @@ export const activeScanAbortRegistry: ActiveScanAbortRegistry = { return () => activeScanAbortControllers.delete(controller); }, abortAll: () => { - for (const controller of activeScanAbortControllers) controller.abort(); + const controllers = [...activeScanAbortControllers]; activeScanAbortControllers.clear(); + for (const controller of controllers) controller.abort(); }, }; diff --git a/packages/react-doctor/src/cli/utils/build-final-cli-scan-outcome.ts b/packages/react-doctor/src/cli/utils/build-final-cli-scan-outcome.ts new file mode 100644 index 000000000..f39a6db5e --- /dev/null +++ b/packages/react-doctor/src/cli/utils/build-final-cli-scan-outcome.ts @@ -0,0 +1,84 @@ +import { + hasReactRuntime, + type InspectResult, + type JsonReportMode, + type JsonReportSkippedProject, + type ReactDoctorConfig, +} from "@react-doctor/core"; +import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; + +export interface CompletedScan { + readonly directory: string; + readonly result: InspectResult; + readonly config: ReactDoctorConfig | null; +} + +export interface AggregatedBaselineDelta { + readonly baseRef: string; + readonly fixedCount: number; + readonly baseTotalCount: number; +} + +export interface BuildFinalCliScanOutcomeInput { + readonly completedScans: ReadonlyArray; + readonly skippedProjects: ReadonlyArray; + readonly mode: JsonReportMode; + readonly baselineIntended: boolean; + readonly categoryFilters: ReadonlySet; +} + +export interface FinalCliScanOutcome { + readonly baseline: AggregatedBaselineDelta | undefined; + readonly baselineDegraded: boolean; + readonly mode: JsonReportMode; + readonly scansForJsonReport: ReadonlyArray; + readonly shouldWarnNoReactDetected: boolean; +} + +const filterCompletedScansByCategories = ( + completedScans: ReadonlyArray, + categoryFilters: ReadonlySet, +): CompletedScan[] => + categoryFilters.size === 0 + ? [...completedScans] + : completedScans.map((scan) => ({ + ...scan, + result: { + ...scan.result, + diagnostics: filterDiagnosticsByCategories(scan.result.diagnostics, categoryFilters), + }, + })); + +export const buildFinalCliScanOutcome = ( + input: BuildFinalCliScanOutcomeInput, +): FinalCliScanOutcome => { + const baselineDeltas = input.completedScans.flatMap((scan) => + scan.result.baselineDelta === undefined ? [] : [scan.result.baselineDelta], + ); + const baselineComputed = + input.skippedProjects.length === 0 && + input.completedScans.length > 0 && + input.completedScans.every((scan) => scan.result.baselineDelta !== undefined); + const baselineDegraded = input.baselineIntended && !baselineComputed; + const baseline = + baselineComputed && baselineDeltas.length > 0 + ? { + baseRef: baselineDeltas[0].baseRef, + fixedCount: baselineDeltas.reduce((total, delta) => total + delta.fixedCount, 0), + baseTotalCount: baselineDeltas.reduce((total, delta) => total + delta.baseTotalCount, 0), + } + : undefined; + + return { + baseline, + baselineDegraded, + mode: baselineDegraded ? "diff" : input.mode, + scansForJsonReport: filterCompletedScansByCategories( + input.completedScans, + input.categoryFilters, + ), + shouldWarnNoReactDetected: + input.completedScans.length > 0 && + !input.completedScans.some((scan) => hasReactRuntime(scan.result.project)), + }; +}; diff --git a/packages/react-doctor/src/cli/utils/exit-gracefully.ts b/packages/react-doctor/src/cli/utils/exit-gracefully.ts index bf73c6dde..0cfd2e92e 100644 --- a/packages/react-doctor/src/cli/utils/exit-gracefully.ts +++ b/packages/react-doctor/src/cli/utils/exit-gracefully.ts @@ -13,8 +13,12 @@ export const exitGracefully = (): void => { // of printing the cancellation footer twice. if (didStartExiting) return process.exit(SIGINT_EXIT_CODE); didStartExiting = true; - activeScanAbortRegistry.abortAll(); - preserveActiveTuiRendererOutput(); + try { + activeScanAbortRegistry.abortAll(); + } catch {} + try { + preserveActiveTuiRendererOutput(); + } catch {} try { if (isJsonModeActive()) { writeJsonErrorReport(new Error("Scan cancelled by user (SIGINT/SIGTERM)")); diff --git a/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts b/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts index 6d045706a..68f90f7de 100644 --- a/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts +++ b/packages/react-doctor/src/cli/utils/finalize-cli-scans.ts @@ -2,15 +2,13 @@ import { performance } from "node:perf_hooks"; import { buildJsonReport, type DiffInfo, - hasReactRuntime, - type InspectResult, type JsonReportMode, type JsonReportSkippedProject, type ReactDoctorConfig, } from "@react-doctor/core"; +import { buildFinalCliScanOutcome, type CompletedScan } from "./build-final-cli-scan-outcome.js"; import { cliLogger as logger } from "./cli-logger.js"; import { METRIC } from "./constants.js"; -import { filterDiagnosticsByCategories } from "./filter-diagnostics-by-categories.js"; import { formatSkippedProjectsMessage } from "./format-skipped-projects-message.js"; import type { InspectFlags } from "./inspect-flags.js"; import { writeJsonReport } from "./json-mode.js"; @@ -19,11 +17,7 @@ import { resolveBlockingLevel } from "./resolve-blocking-level.js"; import { shouldFailScanGate } from "./should-fail-scan-gate.js"; import { VERSION } from "./version.js"; -export interface CompletedScan { - readonly directory: string; - readonly result: InspectResult; - readonly config: ReactDoctorConfig | null; -} +export type { CompletedScan } from "./build-final-cli-scan-outcome.js"; interface FinalizeCliScansInput { readonly completedScans: ReadonlyArray; @@ -45,20 +39,6 @@ interface ReportSkippedProjectsInput { readonly isQuiet: boolean; } -const filterCompletedScansByCategories = ( - completedScans: ReadonlyArray, - categoryFilters: ReadonlySet, -): CompletedScan[] => - categoryFilters.size === 0 - ? [...completedScans] - : completedScans.map((scan) => ({ - ...scan, - result: { - ...scan.result, - diagnostics: filterDiagnosticsByCategories(scan.result.diagnostics, categoryFilters), - }, - })); - export const reportSkippedProjects = (input: ReportSkippedProjectsInput): void => { input.skippedProjects.sort((left, right) => left.directory.localeCompare(right.directory)); if (input.skippedProjects.length === 0) return; @@ -73,18 +53,15 @@ export const reportSkippedProjects = (input: ReportSkippedProjectsInput): void = }; export const finalizeCliScans = (input: FinalizeCliScansInput): void => { - const baselineDeltas = input.completedScans.flatMap((scan) => - scan.result.baselineDelta ? [scan.result.baselineDelta] : [], - ); - const baselineComputed = - input.skippedProjects.length === 0 && - input.completedScans.length > 0 && - input.completedScans.every((scan) => scan.result.baselineDelta !== undefined); - const baselineDegraded = input.baselineIntended && !baselineComputed; - const mode: JsonReportMode = baselineDegraded ? "diff" : input.mode; - const isReactDetected = input.completedScans.some((scan) => hasReactRuntime(scan.result.project)); + const outcome = buildFinalCliScanOutcome({ + completedScans: input.completedScans, + skippedProjects: input.skippedProjects, + mode: input.mode, + baselineIntended: input.baselineIntended, + categoryFilters: input.categoryFilters, + }); - if (input.completedScans.length > 0 && !isReactDetected) { + if (outcome.shouldWarnNoReactDetected) { recordCount(METRIC.scanNoReactDetected, 1); logger.warn( `No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`, @@ -92,28 +69,17 @@ export const finalizeCliScans = (input: FinalizeCliScansInput): void => { } if (input.isJsonMode) { - const baseline = - baselineComputed && baselineDeltas.length > 0 - ? { - baseRef: baselineDeltas[0].baseRef, - fixedCount: baselineDeltas.reduce((total, delta) => total + delta.fixedCount, 0), - baseTotalCount: baselineDeltas.reduce( - (total, delta) => total + delta.baseTotalCount, - 0, - ), - } - : undefined; writeJsonReport( buildJsonReport({ version: VERSION, directory: input.resolvedDirectory, - mode, + mode: outcome.mode, diff: input.diff, - scans: filterCompletedScansByCategories(input.completedScans, input.categoryFilters), + scans: outcome.scansForJsonReport, skippedProjects: input.skippedProjects, totalElapsedMilliseconds: performance.now() - input.startTime, - baseline, - baselineDegraded, + baseline: outcome.baseline, + baselineDegraded: outcome.baselineDegraded, }), ); } @@ -122,7 +88,7 @@ export const finalizeCliScans = (input: FinalizeCliScansInput): void => { shouldFailScanGate({ scans: input.completedScans, blockingLevel: resolveBlockingLevel(input.flags, input.userConfig), - diagnosticsAreGateExempt: input.isScoreOnly || baselineDegraded, + diagnosticsAreGateExempt: input.isScoreOnly || outcome.baselineDegraded, }) ) { process.exitCode = 1; diff --git a/packages/react-doctor/src/cli/utils/project-scan-outcome.ts b/packages/react-doctor/src/cli/utils/project-scan-outcome.ts new file mode 100644 index 000000000..828ac025f --- /dev/null +++ b/packages/react-doctor/src/cli/utils/project-scan-outcome.ts @@ -0,0 +1,34 @@ +interface CompletedProjectScanOutcome { + readonly status: "completed"; + readonly value: Scan; +} + +interface SkippedProjectScanOutcome { + readonly status: "skipped"; + readonly value: SkippedScan; +} + +interface OmittedProjectScanOutcome { + readonly status: "omitted"; +} + +export type ProjectScanOutcome = + | CompletedProjectScanOutcome + | SkippedProjectScanOutcome + | OmittedProjectScanOutcome; + +interface PartitionedProjectScanOutcomes { + readonly completedScans: Scan[]; + readonly skippedScans: SkippedScan[]; +} + +export const partitionProjectScanOutcomes = ( + outcomes: ReadonlyArray>, +): PartitionedProjectScanOutcomes => ({ + completedScans: outcomes.flatMap((outcome) => + outcome.status === "completed" ? [outcome.value] : [], + ), + skippedScans: outcomes.flatMap((outcome) => + outcome.status === "skipped" ? [outcome.value] : [], + ), +}); diff --git a/packages/react-doctor/src/cli/utils/run-project-scan-batch.ts b/packages/react-doctor/src/cli/utils/run-project-scan-batch.ts index 838034f95..c4ab1e461 100644 --- a/packages/react-doctor/src/cli/utils/run-project-scan-batch.ts +++ b/packages/react-doctor/src/cli/utils/run-project-scan-batch.ts @@ -1,7 +1,23 @@ import { performance } from "node:perf_hooks"; import { DEFAULT_PROJECT_SCAN_CONCURRENCY, mapWithConcurrency } from "@react-doctor/core"; +import { partitionProjectScanOutcomes, type ProjectScanOutcome } from "./project-scan-outcome.js"; import { isSpinnerSilent, setSpinnerSilent, spinner } from "./spinner.js"; +export type { ProjectScanOutcome } from "./project-scan-outcome.js"; + +interface RunProjectScanBatchInput { + readonly projects: ReadonlyArray; + readonly isQuiet: boolean; + readonly isSilent: boolean; + readonly scanProject: (project: Project) => Promise>; +} + +interface ProjectScanBatchResult { + readonly completedScans: Scan[]; + readonly skippedScans: SkippedScan[]; + readonly elapsedMilliseconds: number; +} + /** * Run one scan per project through the same bounded pool as * `diagnose({ projects })`, with the batch spinner and its progress counter. @@ -10,15 +26,10 @@ import { isSpinnerSilent, setSpinnerSilent, spinner } from "./spinner.js"; * overlapping save/restore pairs would race — so the batch owns that toggle once * around the whole run. * - * A `scanProject` that returns `null` drops the project from the results: diff - * mode skips projects with no changed source. */ -export const runProjectScanBatch = async (input: { - readonly projects: ReadonlyArray; - readonly isQuiet: boolean; - readonly isSilent: boolean; - readonly scanProject: (project: Project) => Promise; -}): Promise<{ completedScans: Scan[]; elapsedMilliseconds: number }> => { +export const runProjectScanBatch = async ( + input: RunProjectScanBatchInput, +): Promise> => { const startTime = performance.now(); const projectCount = input.projects.length; const isMultiProject = projectCount > 1; @@ -28,7 +39,7 @@ export const runProjectScanBatch = async (input: { const wasSpinnerSilent = isSpinnerSilent(); if (ownsBatchSpinnerSilence) setSpinnerSilent(true); let finishedProjectCount = 0; - let scanOutcomes: ReadonlyArray; + let scanOutcomes: ReadonlyArray>; try { scanOutcomes = await mapWithConcurrency( input.projects, @@ -47,7 +58,7 @@ export const runProjectScanBatch = async (input: { batchSpinner?.stop(); } return { - completedScans: scanOutcomes.filter((scanOutcome): scanOutcome is Scan => scanOutcome !== null), + ...partitionProjectScanOutcomes(scanOutcomes), elapsedMilliseconds: performance.now() - startTime, }; }; diff --git a/packages/react-doctor/src/cli/utils/run-staged-inspect.ts b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts index c84acbbf8..bcb48458e 100644 --- a/packages/react-doctor/src/cli/utils/run-staged-inspect.ts +++ b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts @@ -34,7 +34,7 @@ import { resolveProjectRelativeDirectory } from "./resolve-project-relative-dire import { resolveProjectScan } from "./resolve-project-scan.js"; import { resolveProjectSourceFilePaths } from "./resolve-project-source-file-paths.js"; import { resolveScope } from "./resolve-scope.js"; -import { runProjectScanBatch } from "./run-project-scan-batch.js"; +import { type ProjectScanOutcome, runProjectScanBatch } from "./run-project-scan-batch.js"; import { filterScansForSurface } from "./filter-scans-for-surface.js"; import { STAGED_PROJECT_FALLBACK_HINT, selectStagedProjects } from "./select-staged-projects.js"; import { VERSION } from "./version.js"; @@ -284,8 +284,6 @@ export const runStagedInspect = async (input: RunStagedInspectInput): Promise projectRun.includePaths.length > 0); const isMultiProject = stagedProjectRuns.length > 1; - const skippedProjects: JsonReportSkippedProject[] = []; - if (stagedProjectRuns.length === 0) { reportEmptyStagedScan( emptyScanInput, @@ -304,14 +302,16 @@ export const runStagedInspect = async (input: RunStagedInspectInput): Promise => { + ): Promise> => { const { projectScan, includePaths } = projectRun; if ( input.scanDeadlineEpochMs !== undefined && remainingDeadlineBudgetMs(input.scanDeadlineEpochMs) === 0 ) { - skippedProjects.push({ directory: projectScan.scanDirectory, reason: "max-duration" }); - return null; + return { + status: "skipped", + value: { directory: projectScan.scanDirectory, reason: "max-duration" }, + }; } const projectTempDirectory = path.join( snapshot.tempDirectory, @@ -341,13 +341,16 @@ export const runStagedInspect = async (input: RunStagedInspectInput): Promise 0) { diff --git a/packages/react-doctor/tests/active-scan-abort-registry.test.ts b/packages/react-doctor/tests/active-scan-abort-registry.test.ts index 9ff3df5c4..0ad600098 100644 --- a/packages/react-doctor/tests/active-scan-abort-registry.test.ts +++ b/packages/react-doctor/tests/active-scan-abort-registry.test.ts @@ -17,4 +17,21 @@ describe("activeScanAbortRegistry", () => { expect(secondActiveController.signal.aborted).toBe(true); expect(unregisteredController.signal.aborted).toBe(false); }); + + it("leaves controllers registered during abort callbacks active", () => { + const activeController = new AbortController(); + const registeredDuringAbortController = new AbortController(); + activeController.signal.addEventListener("abort", () => { + activeScanAbortRegistry.register(registeredDuringAbortController); + }); + activeScanAbortRegistry.register(activeController); + + activeScanAbortRegistry.abortAll(); + + expect(activeController.signal.aborted).toBe(true); + expect(registeredDuringAbortController.signal.aborted).toBe(false); + + activeScanAbortRegistry.abortAll(); + expect(registeredDuringAbortController.signal.aborted).toBe(true); + }); }); diff --git a/packages/react-doctor/tests/build-final-cli-scan-outcome.test.ts b/packages/react-doctor/tests/build-final-cli-scan-outcome.test.ts new file mode 100644 index 000000000..0dd7d5c5d --- /dev/null +++ b/packages/react-doctor/tests/build-final-cli-scan-outcome.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { Diagnostic, InspectResult } from "@react-doctor/core"; +import { + buildFinalCliScanOutcome, + type CompletedScan, +} from "../src/cli/utils/build-final-cli-scan-outcome.js"; +import { buildDiagnostic, buildTestProject } from "./regressions/_helpers.js"; + +interface BuildCompletedScanOptions { + readonly directory: string; + readonly diagnostics?: Diagnostic[]; + readonly hasReact?: boolean; + readonly baselineDelta?: InspectResult["baselineDelta"]; +} + +const buildCompletedScan = (options: BuildCompletedScanOptions): CompletedScan => ({ + directory: options.directory, + config: null, + result: { + diagnostics: options.diagnostics ?? [], + score: null, + skippedChecks: [], + project: buildTestProject({ + rootDirectory: options.directory, + reactMajorVersion: options.hasReact === false ? null : 19, + reactVersion: options.hasReact === false ? null : "^19.0.0", + }), + elapsedMilliseconds: 1, + baselineDelta: options.baselineDelta, + }, +}); + +const buildOutcome = ( + completedScans: ReadonlyArray, + overrides: Partial[0]> = {}, +) => + buildFinalCliScanOutcome({ + completedScans, + skippedProjects: [], + mode: "full", + baselineIntended: false, + categoryFilters: new Set(), + ...overrides, + }); + +describe("buildFinalCliScanOutcome", () => { + it("aggregates a complete baseline across projects", () => { + const outcome = buildOutcome( + [ + buildCompletedScan({ + directory: "/repo/apps/web", + baselineDelta: { baseRef: "abc123", fixedCount: 2, baseTotalCount: 5 }, + }), + buildCompletedScan({ + directory: "/repo/apps/docs", + baselineDelta: { baseRef: "abc123", fixedCount: 3, baseTotalCount: 7 }, + }), + ], + { mode: "baseline", baselineIntended: true }, + ); + + expect(outcome.baselineDegraded).toBe(false); + expect(outcome.mode).toBe("baseline"); + expect(outcome.baseline).toEqual({ + baseRef: "abc123", + fixedCount: 5, + baseTotalCount: 12, + }); + }); + + it("degrades an incomplete baseline to diff mode", () => { + const outcome = buildOutcome( + [ + buildCompletedScan({ + directory: "/repo/apps/web", + baselineDelta: { baseRef: "abc123", fixedCount: 2, baseTotalCount: 5 }, + }), + buildCompletedScan({ directory: "/repo/apps/docs" }), + ], + { mode: "baseline", baselineIntended: true }, + ); + + expect(outcome.baselineDegraded).toBe(true); + expect(outcome.mode).toBe("diff"); + expect(outcome.baseline).toBeUndefined(); + }); + + it("detects completed scans where React rules were gated off", () => { + expect( + buildOutcome([buildCompletedScan({ directory: "/repo", hasReact: false })]) + .shouldWarnNoReactDetected, + ).toBe(true); + expect( + buildOutcome([ + buildCompletedScan({ directory: "/repo/plain", hasReact: false }), + buildCompletedScan({ directory: "/repo/react" }), + ]).shouldWarnNoReactDetected, + ).toBe(false); + expect(buildOutcome([]).shouldWarnNoReactDetected).toBe(false); + }); + + it("filters only the JSON report diagnostics by category", () => { + const correctnessDiagnostic = buildDiagnostic({ category: "Correctness" }); + const designDiagnostic = buildDiagnostic({ category: "Design" }); + const completedScan = buildCompletedScan({ + directory: "/repo", + diagnostics: [correctnessDiagnostic, designDiagnostic], + }); + + const outcome = buildOutcome([completedScan], { + categoryFilters: new Set(["Correctness"]), + }); + + expect(outcome.scansForJsonReport[0]?.result.diagnostics).toEqual([correctnessDiagnostic]); + expect(completedScan.result.diagnostics).toEqual([correctnessDiagnostic, designDiagnostic]); + }); +}); diff --git a/packages/react-doctor/tests/ink/scan-store.test.ts b/packages/react-doctor/tests/ink/scan-store.test.ts new file mode 100644 index 000000000..f22bb9c83 --- /dev/null +++ b/packages/react-doctor/tests/ink/scan-store.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createScanStore } from "../../src/cli/ink/scan-store.js"; + +describe("createScanStore", () => { + it("does not notify listeners added during the current commit", () => { + const store = createScanStore(); + const notifications: string[] = []; + store.subscribe(() => { + notifications.push("first"); + store.subscribe(() => notifications.push("late")); + }); + + store.setProgress("first update"); + expect(notifications).toEqual(["first"]); + + store.setProgress("second update"); + expect(notifications).toEqual(["first", "first", "late"]); + }); + + it("replaces settled phase data instead of retaining stale state", () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [], + score: null, + projectedScore: null, + projectName: "web", + rootDirectory: "/project", + scannedFileCount: 1, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + store.setSummary({ + projects: [], + aggregateScore: null, + projectedScore: null, + combinedDiagnostics: [], + scannedFileCount: 1, + elapsedMilliseconds: 10, + projectName: "workspace", + rootDirectory: "/project", + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + expect(store.getSnapshot()).not.toHaveProperty("report"); + expect(store.getSnapshot()).toMatchObject({ phase: "summary" }); + }); +}); diff --git a/packages/react-doctor/tests/run-project-scan-batch.test.ts b/packages/react-doctor/tests/run-project-scan-batch.test.ts new file mode 100644 index 000000000..6fdb8d817 --- /dev/null +++ b/packages/react-doctor/tests/run-project-scan-batch.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + type ProjectScanOutcome, + runProjectScanBatch, +} from "../src/cli/utils/run-project-scan-batch.js"; + +describe("runProjectScanBatch", () => { + it("partitions explicit outcomes in project order", async () => { + const scanProject = async (project: number): Promise> => { + if (project === 2) return { status: "skipped", value: "deadline" }; + if (project === 3) return { status: "omitted" }; + return { status: "completed", value: `project-${project}` }; + }; + + const result = await runProjectScanBatch({ + projects: [1, 2, 3, 4], + isQuiet: true, + isSilent: false, + scanProject, + }); + + expect(result.completedScans).toEqual(["project-1", "project-4"]); + expect(result.skippedScans).toEqual(["deadline"]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 707eecffd..a101027cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,10 @@ overrides: importers: .: + dependencies: + effect: + specifier: 4.0.0-beta.70 + version: 4.0.0-beta.70 devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 From 998c3b4d1b01f62afd811438ffb6116188885e20 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 7 Aug 2026 11:26:40 +0000 Subject: [PATCH 08/17] refactor: clarify analysis and scan phases --- .../react-compiler-config-evaluator.ts | 449 ++++++++++------ .../src/collect/package-json-entries.ts | 311 ++++++----- .../src/linker/build-module-link-inputs.ts | 304 +++++++---- .../find-strongly-connected-components.ts | 151 +++--- .../tests/build-module-link-inputs.test.ts | 89 ++++ ...find-strongly-connected-components.test.ts | 11 + .../tests/package-json-entries.test.ts | 64 ++- ...change-media-capability.cross-file.test.ts | 4 +- .../zustand-no-mutating-state.ts | 156 +++--- .../utils/find-exported-function-body.ts | 61 ++- .../react-doctor/src/cli/commands/inspect.ts | 247 ++++++--- .../src/cli/utils/finalize-inspect-result.ts | 198 ++++--- .../src/cli/utils/resolve-inspect-options.ts | 8 +- .../src/cli/utils/run-staged-inspect.ts | 503 ++++++++++++------ .../cli/utils/scan-result-cache-payload.ts | 83 +-- .../tests/node-support-metadata.test.ts | 2 +- 16 files changed, 1726 insertions(+), 915 deletions(-) create mode 100644 packages/deslop-js/tests/build-module-link-inputs.test.ts create mode 100644 packages/deslop-js/tests/find-strongly-connected-components.test.ts diff --git a/packages/core/src/project-info/react-compiler-config-evaluator.ts b/packages/core/src/project-info/react-compiler-config-evaluator.ts index 53f2eb19b..c2955fd8f 100644 --- a/packages/core/src/project-info/react-compiler-config-evaluator.ts +++ b/packages/core/src/project-info/react-compiler-config-evaluator.ts @@ -210,6 +210,11 @@ interface ScopedConfigBinding { readonly initializer: ts.Expression | null; } +interface CommonJsConfigExportMatch { + readonly node: ts.Node; + readonly strategy: "source-order" | "append-mutation" | "replace-mutation"; +} + const bindingNameContainsIdentifier = ( bindingName: ts.BindingName, identifierName: string, @@ -494,6 +499,33 @@ const isNodeCreateRequireCall = (node: ts.Node, analysis: ConfigExpressionAnalys ); }; +const isUnshadowedGlobalRequireIdentifier = ( + identifier: ts.Identifier, + analysis: ConfigExpressionAnalysis, +): boolean => + identifier.text === "require" && + !hasTopLevelValueBinding(analysis.sourceFile, identifier.text) && + getImportBinding(analysis.sourceFile, identifier.text) === null; + +const isNodeRequireResolverIdentifier = ( + identifier: ts.Identifier, + analysis: ConfigExpressionAnalysis, +): boolean => { + if (analysis.localBindings.has(identifier.text)) return false; + const scopedBinding = getScopedConfigBinding(identifier); + const resolverInitializer = scopedBinding.wasFound + ? scopedBinding.initializer + : getTopLevelBinding(analysis.sourceFile, identifier.text); + if (!scopedBinding.wasFound && resolverInitializer === null) { + return isUnshadowedGlobalRequireIdentifier(identifier, analysis); + } + return Boolean( + resolverInitializer && + isConstantVariableInitializer(resolverInitializer) && + isNodeCreateRequireCall(resolverInitializer, analysis), + ); +}; + const getNodeRequireResolveModuleSpecifier = ( callExpression: ts.CallExpression, analysis: ConfigExpressionAnalysis, @@ -506,31 +538,9 @@ const getNodeRequireResolveModuleSpecifier = ( if (propertyName !== "resolve") return null; if (isNodeCreateRequireCall(target.expression, analysis)) return moduleSpecifierNode.text; if (!ts.isIdentifier(target.expression)) return null; - - const resolverIdentifier = target.expression; - if (analysis.localBindings.has(resolverIdentifier.text)) return null; - const scopedBinding = getScopedConfigBinding(resolverIdentifier); - const topLevelBinding = scopedBinding.wasFound - ? null - : getTopLevelBinding(analysis.sourceFile, resolverIdentifier.text); - const resolverInitializer = scopedBinding.wasFound ? scopedBinding.initializer : topLevelBinding; - if ( - resolverInitializer === null && - !scopedBinding.wasFound && - resolverIdentifier.text === "require" && - !hasTopLevelValueBinding(analysis.sourceFile, resolverIdentifier.text) && - getImportBinding(analysis.sourceFile, resolverIdentifier.text) === null - ) { - return moduleSpecifierNode.text; - } - if ( - !resolverInitializer || - !isConstantVariableInitializer(resolverInitializer) || - !isNodeCreateRequireCall(resolverInitializer, analysis) - ) { - return null; - } - return moduleSpecifierNode.text; + return isNodeRequireResolverIdentifier(target.expression, analysis) + ? moduleSpecifierNode.text + : null; }; interface ReactCompilerFlagState { @@ -807,6 +817,78 @@ const getReactCompilerFlagState = ( return null; }; +const getSelectedIdentifierObjectProperty = ( + identifier: ts.Identifier, + propertyName: string, + analysis: ConfigExpressionAnalysis, + visitedExpressions: Set, +): ConfigPropertyReference | null => { + if (analysis.localBindings.has(identifier.text)) { + const localReference = analysis.localBindings.get(identifier.text); + return localReference?.expression + ? getSelectedObjectProperty( + localReference.expression, + propertyName, + localReference.analysis, + visitedExpressions, + ) + : null; + } + const scopedBinding = getScopedConfigBinding(identifier); + if (scopedBinding.wasFound) { + return scopedBinding.initializer + ? getSelectedObjectProperty( + scopedBinding.initializer, + propertyName, + analysis, + visitedExpressions, + ) + : null; + } + const topLevelBinding = getTopLevelBinding(analysis.sourceFile, identifier.text); + return topLevelBinding && ts.isExpression(topLevelBinding) + ? getSelectedObjectProperty(topLevelBinding, propertyName, analysis, visitedExpressions) + : null; +}; + +const getDirectObjectPropertyReference = ( + property: ts.ObjectLiteralElementLike, + propertyName: string, + analysis: ConfigExpressionAnalysis, +): ConfigPropertyReference | null => { + if (ts.isPropertyAssignment(property) && getStaticPropertyName(property.name) === propertyName) { + return { node: property.initializer, analysis }; + } + if (ts.isMethodDeclaration(property) && getStaticPropertyName(property.name) === propertyName) { + return { node: property, analysis }; + } + if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) { + return { node: property.name, analysis }; + } + return null; +}; + +const getSelectedObjectLiteralProperty = ( + objectLiteral: ts.ObjectLiteralExpression, + propertyName: string, + analysis: ConfigExpressionAnalysis, + visitedExpressions: Set, +): ConfigPropertyReference | null => { + for (const property of [...objectLiteral.properties].reverse()) { + const directProperty = getDirectObjectPropertyReference(property, propertyName, analysis); + if (directProperty) return directProperty; + if (!ts.isSpreadAssignment(property)) continue; + const spreadProperty = getSelectedObjectProperty( + property.expression, + propertyName, + analysis, + visitedExpressions, + ); + if (spreadProperty) return spreadProperty; + } + return null; +}; + const getSelectedObjectProperty = ( expression: ts.Expression, propertyName: string, @@ -817,58 +899,21 @@ const getSelectedObjectProperty = ( if (visitedExpressions.has(resolvedExpression)) return null; visitedExpressions.add(resolvedExpression); if (ts.isIdentifier(resolvedExpression)) { - if (analysis.localBindings.has(resolvedExpression.text)) { - const localReference = analysis.localBindings.get(resolvedExpression.text); - return localReference?.expression - ? getSelectedObjectProperty( - localReference.expression, - propertyName, - localReference.analysis, - visitedExpressions, - ) - : null; - } - const scopedBinding = getScopedConfigBinding(resolvedExpression); - if (scopedBinding.wasFound) { - return scopedBinding.initializer - ? getSelectedObjectProperty( - scopedBinding.initializer, - propertyName, - analysis, - visitedExpressions, - ) - : null; - } - const topLevelBinding = getTopLevelBinding(analysis.sourceFile, resolvedExpression.text); - return topLevelBinding && ts.isExpression(topLevelBinding) - ? getSelectedObjectProperty(topLevelBinding, propertyName, analysis, visitedExpressions) - : null; + return getSelectedIdentifierObjectProperty( + resolvedExpression, + propertyName, + analysis, + visitedExpressions, + ); } - if (!ts.isObjectLiteralExpression(resolvedExpression)) return null; - for (const property of [...resolvedExpression.properties].reverse()) { - if ( - ts.isPropertyAssignment(property) && - getStaticPropertyName(property.name) === propertyName - ) { - return { node: property.initializer, analysis }; - } - if (ts.isMethodDeclaration(property) && getStaticPropertyName(property.name) === propertyName) { - return { node: property, analysis }; - } - if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) { - return { node: property.name, analysis }; - } - if (ts.isSpreadAssignment(property)) { - const spreadProperty = getSelectedObjectProperty( - property.expression, + return ts.isObjectLiteralExpression(resolvedExpression) + ? getSelectedObjectLiteralProperty( + resolvedExpression, propertyName, analysis, visitedExpressions, - ); - if (spreadProperty) return spreadProperty; - } - } - return null; + ) + : null; }; const configExpressionMayDefineProperty = ( @@ -970,9 +1015,153 @@ const configExpressionMayDefineProperty = ( }); }; -const getExportedConfigNodes = (sourceFile: ts.SourceFile, exportName: string): ts.Node[] => { +const getNamedObjectLiteralExportNode = ( + objectLiteral: ts.ObjectLiteralExpression, + exportName: string, +): ts.Node | null => { + for (const property of [...objectLiteral.properties].reverse()) { + if (ts.isPropertyAssignment(property) && getStaticPropertyName(property.name) === exportName) { + return property.initializer; + } + if (ts.isShorthandPropertyAssignment(property) && property.name.text === exportName) { + return property.name; + } + } + return null; +}; + +const getCommonJsRootConfigExportMatch = ( + assignment: ts.BinaryExpression, + sourceFile: ts.SourceFile, + exportName: string, +): CommonJsConfigExportMatch | null => { + if (!isCommonJsConfigExportAssignment(assignment, sourceFile)) return null; + if (exportName === "default") { + return { + node: assignment.right, + strategy: "replace-mutation", + }; + } + if (!ts.isObjectLiteralExpression(assignment.right)) return null; + const exportedNode = getNamedObjectLiteralExportNode(assignment.right, exportName); + return exportedNode ? { node: exportedNode, strategy: "source-order" } : null; +}; + +const getCommonJsPropertyConfigExportMatch = ( + assignment: ts.BinaryExpression, + sourceFile: ts.SourceFile, + exportName: string, +): CommonJsConfigExportMatch | null => { + if (assignment.left.getText(sourceFile) === `exports.${exportName}`) { + return { + node: assignment.right, + strategy: "source-order", + }; + } + if ( + exportName !== "default" || + (!ts.isPropertyAccessExpression(assignment.left) && + !ts.isElementAccessExpression(assignment.left)) + ) { + return null; + } + + const assignmentObjectText = assignment.left.expression.getText(sourceFile); + const assignmentPropertyName = getAccessedPropertyName(assignment.left); + if ( + !assignmentPropertyName || + (assignmentObjectText !== "module.exports" && + assignmentObjectText !== "exports" && + assignmentObjectText !== "exports.default") + ) { + return null; + } + return { + node: assignment, + strategy: "append-mutation", + }; +}; + +const getCommonJsConfigExportMatch = ( + statement: ts.Statement, + sourceFile: ts.SourceFile, + exportName: string, +): CommonJsConfigExportMatch | null => { + if ( + !ts.isExpressionStatement(statement) || + !ts.isBinaryExpression(statement.expression) || + statement.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) { + return null; + } + return ( + getCommonJsRootConfigExportMatch(statement.expression, sourceFile, exportName) ?? + getCommonJsPropertyConfigExportMatch(statement.expression, sourceFile, exportName) + ); +}; + +const getExportedVariableInitializerNodes = ( + statement: ts.Statement, + exportName: string, +): ts.Expression[] => { + if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) return []; + const exportedNodes: ts.Expression[] = []; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === exportName && + declaration.initializer + ) { + exportedNodes.push(declaration.initializer); + } + } + return exportedNodes; +}; + +const getLocalNamedExportNodes = (statement: ts.Statement, exportName: string): ts.Node[] => { + if ( + !ts.isExportDeclaration(statement) || + statement.moduleSpecifier || + !statement.exportClause || + !ts.isNamedExports(statement.exportClause) + ) { + return []; + } const exportedNodes: ts.Node[] = []; - const commonJsExportedNodes: ts.Node[] = []; + for (const exportSpecifier of statement.exportClause.elements) { + if (exportSpecifier.name.text === exportName) { + exportedNodes.push(exportSpecifier.propertyName ?? exportSpecifier.name); + } + } + return exportedNodes; +}; + +const getDefaultEsmConfigExportNodes = (statement: ts.Statement, exportName: string): ts.Node[] => { + if (exportName !== "default") return []; + if (ts.isExportAssignment(statement)) return [statement.expression]; + if (!ts.isFunctionDeclaration(statement) && !ts.isClassDeclaration(statement)) return []; + if (!hasExportModifier(statement)) return []; + const isDefaultExport = ts + .getModifiers(statement) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + return isDefaultExport ? [statement] : []; +}; + +const getEsmConfigExportNodes = (statement: ts.Statement, exportName: string): ts.Node[] => { + const exportedNodes = getDefaultEsmConfigExportNodes(statement, exportName); + exportedNodes.push(...getExportedVariableInitializerNodes(statement, exportName)); + if ( + ts.isFunctionDeclaration(statement) && + hasExportModifier(statement) && + statement.name?.text === exportName + ) { + exportedNodes.push(statement); + } + exportedNodes.push(...getLocalNamedExportNodes(statement, exportName)); + return exportedNodes; +}; + +const getExportedConfigNodes = (sourceFile: ts.SourceFile, exportName: string): ts.Node[] => { const isJsonConfig = sourceFile.fileName.endsWith(".json") || path.basename(sourceFile.fileName) === ".babelrc"; if (isJsonConfig && exportName === "default") { @@ -981,110 +1170,20 @@ const getExportedConfigNodes = (sourceFile: ts.SourceFile, exportName: string): ); } + const exportedNodes: ts.Node[] = []; + const commonJsExportedNodes: ts.Node[] = []; for (const statement of sourceFile.statements) { - if (exportName === "default" && ts.isExportAssignment(statement)) { - exportedNodes.push(statement.expression); - } - if ( - exportName === "default" && - (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && - hasExportModifier(statement) && - ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) - ) { - exportedNodes.push(statement); - } - if ( - ts.isExpressionStatement(statement) && - isCommonJsConfigExportAssignment(statement.expression, sourceFile) && - exportName === "default" - ) { - commonJsExportedNodes.length = 0; - commonJsExportedNodes.push(statement.expression.right); + exportedNodes.push(...getEsmConfigExportNodes(statement, exportName)); + const commonJsExportMatch = getCommonJsConfigExportMatch(statement, sourceFile, exportName); + if (!commonJsExportMatch) continue; + if (commonJsExportMatch.strategy === "source-order") { + exportedNodes.push(commonJsExportMatch.node); continue; } - if ( - ts.isExpressionStatement(statement) && - isCommonJsConfigExportAssignment(statement.expression, sourceFile) && - exportName !== "default" && - ts.isObjectLiteralExpression(statement.expression.right) - ) { - for (const property of [...statement.expression.right.properties].reverse()) { - if ( - ts.isPropertyAssignment(property) && - getStaticPropertyName(property.name) === exportName - ) { - exportedNodes.push(property.initializer); - break; - } - if (ts.isShorthandPropertyAssignment(property) && property.name.text === exportName) { - exportedNodes.push(property.name); - break; - } - } - } - if ( - ts.isExpressionStatement(statement) && - ts.isBinaryExpression(statement.expression) && - statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && - statement.expression.left.getText(sourceFile) === `exports.${exportName}` - ) { - exportedNodes.push(statement.expression.right); - } - if ( - exportName === "default" && - ts.isExpressionStatement(statement) && - ts.isBinaryExpression(statement.expression) && - statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && - (ts.isPropertyAccessExpression(statement.expression.left) || - ts.isElementAccessExpression(statement.expression.left)) - ) { - const assignmentTarget = statement.expression.left; - const assignmentObjectText = assignmentTarget.expression.getText(sourceFile); - const assignmentPropertyName = ts.isPropertyAccessExpression(assignmentTarget) - ? assignmentTarget.name.text - : assignmentTarget.argumentExpression && - ts.isStringLiteralLike(assignmentTarget.argumentExpression) - ? assignmentTarget.argumentExpression.text - : null; - if ( - assignmentPropertyName && - (assignmentObjectText === "module.exports" || - assignmentObjectText === "exports" || - assignmentObjectText === "exports.default") - ) { - commonJsExportedNodes.push(statement.expression); - continue; - } - } - if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { - for (const declaration of statement.declarationList.declarations) { - if ( - ts.isIdentifier(declaration.name) && - declaration.name.text === exportName && - declaration.initializer - ) { - exportedNodes.push(declaration.initializer); - } - } - } - if ( - ts.isFunctionDeclaration(statement) && - hasExportModifier(statement) && - statement.name?.text === exportName - ) { - exportedNodes.push(statement); - } - if ( - ts.isExportDeclaration(statement) && - !statement.moduleSpecifier && - statement.exportClause && - ts.isNamedExports(statement.exportClause) - ) { - for (const exportSpecifier of statement.exportClause.elements) { - if (exportSpecifier.name.text === exportName) - exportedNodes.push(exportSpecifier.propertyName ?? exportSpecifier.name); - } + if (commonJsExportMatch.strategy === "replace-mutation") { + commonJsExportedNodes.length = 0; } + commonJsExportedNodes.push(commonJsExportMatch.node); } return [...exportedNodes, ...commonJsExportedNodes]; }; diff --git a/packages/deslop-js/src/collect/package-json-entries.ts b/packages/deslop-js/src/collect/package-json-entries.ts index 765c572e0..13a7b24ed 100644 --- a/packages/deslop-js/src/collect/package-json-entries.ts +++ b/packages/deslop-js/src/collect/package-json-entries.ts @@ -17,8 +17,10 @@ interface PackageJsonEntryFields { jest?: unknown; } -interface PackageBuildConfig { - files?: unknown; +interface TypeScriptBuildDirectories { + absoluteOutDirectory: string; + sourceRoot: string; + shouldSearchCommonSourceDirectories: boolean; } const DEFAULT_INDEX_PATTERNS = [ @@ -93,6 +95,62 @@ const findSourceFile = ( const findSourceFileStrict = (baseDirectory: string, relativePath: string): string | undefined => findSourceFile(baseDirectory, relativePath, false); +const readTypeScriptBuildDirectories = ( + rootDirectory: string, +): TypeScriptBuildDirectories | undefined => { + const tsconfigPath = join(rootDirectory, "tsconfig.json"); + if (!existsSync(tsconfigPath)) return undefined; + const tsconfigContent = readFileSync(tsconfigPath, "utf-8") + .replace(/\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); + const tsconfig = JSON.parse(tsconfigContent); + const outDirectory = tsconfig?.compilerOptions?.outDir; + if (!outDirectory) return undefined; + + const configuredRootDirectory = tsconfig?.compilerOptions?.rootDir; + return { + absoluteOutDirectory: resolve(rootDirectory, outDirectory), + sourceRoot: configuredRootDirectory + ? resolve(rootDirectory, configuredRootDirectory) + : rootDirectory, + shouldSearchCommonSourceDirectories: !configuredRootDirectory, + }; +}; + +const findRelativeBuildPath = ( + absoluteOutDirectory: string, + builtAbsolutePath: string, +): string | undefined => { + const relativeToBuild = relative(absoluteOutDirectory, builtAbsolutePath); + if ( + relativeToBuild.length === 0 || + relativeToBuild === ".." || + relativeToBuild.startsWith(`..${sep}`) || + isAbsolute(relativeToBuild) + ) { + return undefined; + } + return relativeToBuild; +}; + +const findSourcePathForBuildOutput = ( + buildDirectories: TypeScriptBuildDirectories, + relativeBuildPath: string, + rootDirectory: string, +): string | undefined => { + const sourceFileMatch = findSourceFile(buildDirectories.sourceRoot, relativeBuildPath); + if (sourceFileMatch) return sourceFileMatch; + const directCandidate = join(buildDirectories.sourceRoot, relativeBuildPath); + if (existsSync(directCandidate)) return directCandidate; + if (!buildDirectories.shouldSearchCommonSourceDirectories) return undefined; + + for (const sourceDirectory of COMMON_SOURCE_DIRECTORIES) { + const candidate = findSourceFile(resolve(rootDirectory, sourceDirectory), relativeBuildPath); + if (candidate) return candidate; + } + return undefined; +}; + const resolveBuiltPathToSource = ( builtAbsolutePath: string, rootDirectory: string, @@ -100,40 +158,14 @@ const resolveBuiltPathToSource = ( if (existsSync(builtAbsolutePath)) return undefined; try { - const tsconfigPath = join(rootDirectory, "tsconfig.json"); - if (!existsSync(tsconfigPath)) return undefined; - const tsconfigContent = readFileSync(tsconfigPath, "utf-8") - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, ""); - const tsconfig = JSON.parse(tsconfigContent); - const outDirectory = tsconfig?.compilerOptions?.outDir; - if (!outDirectory) return undefined; - - const absoluteOutDirectory = resolve(rootDirectory, outDirectory); - const relativeToBuild = relative(absoluteOutDirectory, builtAbsolutePath); - if ( - relativeToBuild.length === 0 || - relativeToBuild === ".." || - relativeToBuild.startsWith(`..${sep}`) || - isAbsolute(relativeToBuild) - ) { - return undefined; - } - - const configuredRootDirectory = tsconfig?.compilerOptions?.rootDir; - const sourceRoot = configuredRootDirectory - ? resolve(rootDirectory, configuredRootDirectory) - : rootDirectory; - const sourceFileMatch = findSourceFile(sourceRoot, relativeToBuild); - if (sourceFileMatch) return sourceFileMatch; - const directCandidate = join(sourceRoot, relativeToBuild); - if (existsSync(directCandidate)) return directCandidate; - if (!configuredRootDirectory) { - for (const sourceDirectory of COMMON_SOURCE_DIRECTORIES) { - const candidate = findSourceFile(resolve(rootDirectory, sourceDirectory), relativeToBuild); - if (candidate) return candidate; - } - } + const buildDirectories = readTypeScriptBuildDirectories(rootDirectory); + if (!buildDirectories) return undefined; + const relativeBuildPath = findRelativeBuildPath( + buildDirectories.absoluteOutDirectory, + builtAbsolutePath, + ); + if (!relativeBuildPath) return undefined; + return findSourcePathForBuildOutput(buildDirectories, relativeBuildPath, rootDirectory); } catch {} return undefined; }; @@ -180,13 +212,9 @@ const collectExportPaths = ( if (typeof exportValue === "string") { if (exportValue.includes("*")) { const normalizedPattern = exportValue.startsWith("./") ? exportValue.slice(2) : exportValue; - const matchedFiles = fg.sync(normalizedPattern, { - cwd: rootDirectory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - entries.push(...matchedFiles.filter(isImportableSourceFile)); + entries.push( + ...findImportableFiles(normalizedPattern, rootDirectory, ["**/node_modules/**"]), + ); } else { entries.push(resolveEntryPath(exportValue, rootDirectory)); } @@ -201,6 +229,20 @@ const collectExportPaths = ( const isImportableSourceFile = (filePath: string): boolean => IMPORTABLE_EXTENSION_SET.has(filePath.slice(filePath.lastIndexOf("."))); +const findImportableFiles = ( + pattern: string, + rootDirectory: string, + ignoredPatterns: string[], +): string[] => + fg + .sync(pattern, { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + ignore: ignoredPatterns, + }) + .filter(isImportableSourceFile); + const expandSideEffectGlobToSourcePatterns = (pattern: string): string[] => { const patterns = new Set([pattern]); if (pattern.endsWith(".js")) { @@ -216,90 +258,121 @@ const expandSideEffectGlobToSourcePatterns = (pattern: string): string[] => { return [...patterns]; }; -export const extractPackageJsonEntries = async (packageJsonPath: string): Promise => { - const entries: string[] = []; +const collectFieldEntries = ( + packageJson: PackageJsonEntryFields, + rootDirectory: string, + entries: string[], +): void => { + for (const field of PACKAGE_ENTRY_FIELDS) { + const entryPath = packageJson[field]; + if (typeof entryPath === "string") entries.push(resolveEntryPath(entryPath, rootDirectory)); + } +}; - try { - const content = await readFile(packageJsonPath, "utf-8"); - const packageJson: PackageJsonEntryFields = JSON.parse(content); - const rootDirectory = packageJsonPath.replace(/\/package\.json$/, ""); +const resolveExportEntry = (exportEntry: string, rootDirectory: string): string => { + const resolvedExportEntry = + resolveEntryWithExtensions(exportEntry) ?? + resolveEntryPathWithExtensions(exportEntry, rootDirectory) ?? + resolveSourcePath(exportEntry, rootDirectory); + if (resolvedExportEntry && existsSync(resolvedExportEntry)) return resolvedExportEntry; - for (const field of PACKAGE_ENTRY_FIELDS) { - const entryPath = packageJson[field]; - if (typeof entryPath === "string") entries.push(resolveEntryPath(entryPath, rootDirectory)); - } + const typescriptReactEntry = exportEntry.endsWith(".ts") + ? exportEntry.replace(/\.ts$/, ".tsx") + : undefined; + if (typescriptReactEntry && existsSync(typescriptReactEntry)) return typescriptReactEntry; + return existsSync(exportEntry) ? exportEntry : resolveEntryPath(exportEntry, rootDirectory); +}; - if (packageJson.exports) { - const exportEntries: string[] = []; - collectExportPaths(packageJson.exports, rootDirectory, exportEntries); - for (const exportEntry of exportEntries) { - const resolvedExportEntry = - resolveEntryWithExtensions(exportEntry) ?? - resolveEntryPathWithExtensions(exportEntry, rootDirectory) ?? - resolveSourcePath(exportEntry, rootDirectory); - if (resolvedExportEntry && existsSync(resolvedExportEntry)) { - entries.push(resolvedExportEntry); - } else if ( - exportEntry.endsWith(".ts") && - existsSync(exportEntry.replace(/\.ts$/, ".tsx")) - ) { - entries.push(exportEntry.replace(/\.ts$/, ".tsx")); - } else { - entries.push( - existsSync(exportEntry) ? exportEntry : resolveEntryPath(exportEntry, rootDirectory), - ); - } - } - } +const collectPackageExportEntries = ( + exportValue: unknown, + rootDirectory: string, + entries: string[], +): void => { + if (!exportValue) return; + const exportEntries: string[] = []; + collectExportPaths(exportValue, rootDirectory, exportEntries); + for (const exportEntry of exportEntries) { + entries.push(resolveExportEntry(exportEntry, rootDirectory)); + } +}; - if (typeof packageJson.bin === "string") { - entries.push(resolveEntryPath(packageJson.bin, rootDirectory)); - } else if (packageJson.bin && typeof packageJson.bin === "object") { - for (const binPath of Object.values(packageJson.bin)) { - if (typeof binPath === "string") entries.push(resolveEntryPath(binPath, rootDirectory)); - } - } +const collectPackageBinEntries = ( + binValue: unknown, + rootDirectory: string, + entries: string[], +): void => { + if (typeof binValue === "string") { + entries.push(resolveEntryPath(binValue, rootDirectory)); + return; + } + if (!binValue || typeof binValue !== "object") return; + for (const binPath of Object.values(binValue)) { + if (typeof binPath === "string") entries.push(resolveEntryPath(binPath, rootDirectory)); + } +}; - if (Array.isArray(packageJson.sideEffects)) { - for (const sideEffectPattern of packageJson.sideEffects) { - if (typeof sideEffectPattern !== "string") continue; - for (const sourcePattern of expandSideEffectGlobToSourcePatterns(sideEffectPattern)) { - entries.push( - ...fg - .sync(sourcePattern, { - cwd: rootDirectory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - }) - .filter(isImportableSourceFile), - ); - } - } +const collectSideEffectEntries = ( + sideEffectValue: unknown, + rootDirectory: string, + entries: string[], +): void => { + if (!Array.isArray(sideEffectValue)) return; + for (const sideEffectPattern of sideEffectValue) { + if (typeof sideEffectPattern !== "string") continue; + for (const sourcePattern of expandSideEffectGlobToSourcePatterns(sideEffectPattern)) { + entries.push( + ...findImportableFiles(sourcePattern, rootDirectory, [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + ]), + ); } + } +}; - const buildConfig: PackageBuildConfig | undefined = - packageJson.build && typeof packageJson.build === "object" ? packageJson.build : undefined; - if (Array.isArray(buildConfig?.files)) { - for (const buildFileEntry of buildConfig.files) { - if (typeof buildFileEntry !== "string" || buildFileEntry.includes("*")) continue; - const resolvedBuildFile = - resolveEntryWithExtensions(resolve(rootDirectory, buildFileEntry)) ?? - resolveEntryPathWithExtensions(buildFileEntry, rootDirectory); - if (resolvedBuildFile && existsSync(resolvedBuildFile)) entries.push(resolvedBuildFile); - } - } +const collectBuildEntries = ( + buildValue: unknown, + rootDirectory: string, + entries: string[], +): void => { + if (typeof buildValue !== "object" || buildValue === null) return; + const buildFileEntries: unknown = Reflect.get(buildValue, "files"); + if (!Array.isArray(buildFileEntries)) return; - if (packageJson.jest && typeof packageJson.jest === "object") { - const jestConfigContent = JSON.stringify(packageJson.jest); - for (const jestRootDirectoryMatch of jestConfigContent.matchAll(/\/([^"\\]+)/g)) { - const resolvedJestFile = resolveEntryPathWithExtensions( - jestRootDirectoryMatch[1], - rootDirectory, - ); - if (resolvedJestFile && existsSync(resolvedJestFile)) entries.push(resolvedJestFile); - } - } + for (const buildFileEntry of buildFileEntries) { + if (typeof buildFileEntry !== "string" || buildFileEntry.includes("*")) continue; + const resolvedBuildFile = resolveEntryPathWithExtensions(buildFileEntry, rootDirectory); + if (resolvedBuildFile && existsSync(resolvedBuildFile)) entries.push(resolvedBuildFile); + } +}; + +const collectJestEntries = (jestValue: unknown, rootDirectory: string, entries: string[]): void => { + if (!jestValue || typeof jestValue !== "object") return; + const jestConfigContent = JSON.stringify(jestValue); + for (const jestRootDirectoryMatch of jestConfigContent.matchAll(/\/([^"\\]+)/g)) { + const resolvedJestFile = resolveEntryPathWithExtensions( + jestRootDirectoryMatch[1], + rootDirectory, + ); + if (resolvedJestFile && existsSync(resolvedJestFile)) entries.push(resolvedJestFile); + } +}; + +export const extractPackageJsonEntries = async (packageJsonPath: string): Promise => { + const entries: string[] = []; + + try { + const content = await readFile(packageJsonPath, "utf-8"); + const packageJson: PackageJsonEntryFields = JSON.parse(content); + const rootDirectory = packageJsonPath.replace(/\/package\.json$/, ""); + + collectFieldEntries(packageJson, rootDirectory, entries); + collectPackageExportEntries(packageJson.exports, rootDirectory, entries); + collectPackageBinEntries(packageJson.bin, rootDirectory, entries); + collectSideEffectEntries(packageJson.sideEffects, rootDirectory, entries); + collectBuildEntries(packageJson.build, rootDirectory, entries); + collectJestEntries(packageJson.jest, rootDirectory, entries); } catch {} return entries; diff --git a/packages/deslop-js/src/linker/build-module-link-inputs.ts b/packages/deslop-js/src/linker/build-module-link-inputs.ts index 7a47ed37d..92d42cf13 100644 --- a/packages/deslop-js/src/linker/build-module-link-inputs.ts +++ b/packages/deslop-js/src/linker/build-module-link-inputs.ts @@ -20,6 +20,17 @@ interface ModuleLinkInputsResult { errors: DeslopError[]; } +interface ModuleResolutionContext { + errors: DeslopError[]; + resolveModule: (specifier: string, fromFile: string) => ResolvedImport; +} + +interface StyleDiscoveryContext extends ModuleResolutionContext { + discoveredFilePaths: Set; + pendingStyleFilePaths: Set; + styleFileQueue: string[]; +} + const STYLE_EXTENSIONS = [".css", ".scss"]; const isStyleFile = (filePath: string): boolean => @@ -31,6 +42,113 @@ const unresolvedImport = (): ResolvedImport => ({ packageName: undefined, }); +const resolveImport = ( + context: ModuleResolutionContext, + specifier: string, + fromFilePath: string, + failureMessage: string, +): ResolvedImport => { + try { + return context.resolveModule(specifier, fromFilePath); + } catch (resolveError) { + context.errors.push( + new ResolverError({ + severity: "warning", + message: failureMessage, + path: fromFilePath, + detail: describeUnknownError(resolveError), + }), + ); + return unresolvedImport(); + } +}; + +const expandImportGlob = ( + specifier: string, + fromFilePath: string, + errors: DeslopError[], +): string[] => { + try { + return fg.sync(specifier, { + cwd: dirname(fromFilePath), + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + } catch (globError) { + errors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: `fast-glob threw on import glob "${specifier}"`, + path: fromFilePath, + detail: describeUnknownError(globError), + }), + ); + return []; + } +}; + +const collectSourceImports = ( + parsedModule: ParsedSource, + filePath: string, + context: ModuleResolutionContext, +): Map => { + const resolvedImports = new Map(); + for (const importInfo of parsedModule.imports) { + if (importInfo.isGlob) { + for (const expandedFilePath of expandImportGlob( + importInfo.specifier, + filePath, + context.errors, + )) { + resolvedImports.set(expandedFilePath, { + resolvedPath: expandedFilePath, + isExternal: false, + packageName: undefined, + }); + } + resolvedImports.set(importInfo.specifier, unresolvedImport()); + continue; + } + resolvedImports.set( + importInfo.specifier, + resolveImport( + context, + importInfo.specifier, + filePath, + `moduleResolver.resolveModule threw on specifier "${importInfo.specifier}"`, + ), + ); + } + return resolvedImports; +}; + +const collectReExportImports = ( + parsedModule: ParsedSource, + filePath: string, + resolvedImports: Map, + context: ModuleResolutionContext, +): void => { + for (const exportInfo of parsedModule.exports) { + if ( + !exportInfo.isReExport || + !exportInfo.reExportSource || + resolvedImports.has(exportInfo.reExportSource) + ) { + continue; + } + resolvedImports.set( + exportInfo.reExportSource, + resolveImport( + context, + exportInfo.reExportSource, + filePath, + `moduleResolver.resolveModule threw on specifier "${exportInfo.reExportSource}"`, + ), + ); + } +}; + const buildSourceModuleLinkInputs = ( options: BuildModuleLinkInputsOptions, ): ModuleLinkInputsResult => { @@ -39,72 +157,16 @@ const buildSourceModuleLinkInputs = ( const testEntryPaths = new Set(options.resolvedEntries.testEntries); const alwaysUsedFilePaths = new Set(options.resolvedEntries.alwaysUsedFiles); const graphInputs: ModuleLinkInput[] = []; + const resolutionContext: ModuleResolutionContext = { + errors, + resolveModule: options.resolveModule, + }; for (let fileIndex = 0; fileIndex < options.files.length; fileIndex++) { const file = options.files[fileIndex]; const parsedModule = options.parsedModules[fileIndex]; - const resolvedImports = new Map(); - const safelyResolveImport = (specifier: string): ResolvedImport => { - try { - return options.resolveModule(specifier, file.path); - } catch (resolveError) { - errors.push( - new ResolverError({ - severity: "warning", - message: `moduleResolver.resolveModule threw on specifier "${specifier}"`, - path: file.path, - detail: describeUnknownError(resolveError), - }), - ); - return unresolvedImport(); - } - }; - - for (const importInfo of parsedModule.imports) { - if (importInfo.isGlob) { - let expandedFilePaths: string[] = []; - try { - expandedFilePaths = fg.sync(importInfo.specifier, { - cwd: dirname(file.path), - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - } catch (globError) { - errors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: `fast-glob threw on import glob "${importInfo.specifier}"`, - path: file.path, - detail: describeUnknownError(globError), - }), - ); - } - for (const expandedFilePath of expandedFilePaths) { - resolvedImports.set(expandedFilePath, { - resolvedPath: expandedFilePath, - isExternal: false, - packageName: undefined, - }); - } - resolvedImports.set(importInfo.specifier, unresolvedImport()); - continue; - } - resolvedImports.set(importInfo.specifier, safelyResolveImport(importInfo.specifier)); - } - - for (const exportInfo of parsedModule.exports) { - if ( - exportInfo.isReExport && - exportInfo.reExportSource && - !resolvedImports.has(exportInfo.reExportSource) - ) { - resolvedImports.set( - exportInfo.reExportSource, - safelyResolveImport(exportInfo.reExportSource), - ); - } - } + const resolvedImports = collectSourceImports(parsedModule, file.path, resolutionContext); + collectReExportImports(parsedModule, file.path, resolvedImports, resolutionContext); graphInputs.push({ fileId: file, @@ -122,63 +184,95 @@ const buildSourceModuleLinkInputs = ( return { graphInputs, errors }; }; -const buildStyleModuleLinkInputs = ( - options: BuildModuleLinkInputsOptions, +const findUndiscoveredStyleFilePath = ( + resolvedImport: ResolvedImport, + discoveredFilePaths: ReadonlySet, +): string | undefined => { + const resolvedPath = resolvedImport.resolvedPath; + if ( + !resolvedPath || + discoveredFilePaths.has(resolvedPath) || + !isStyleFile(resolvedPath) || + !existsSync(resolvedPath) + ) { + return undefined; + } + return resolvedPath; +}; + +const collectPendingStyleFilePaths = ( sourceGraphInputs: ModuleLinkInput[], -): ModuleLinkInputsResult => { - const errors: DeslopError[] = []; - const graphInputs: ModuleLinkInput[] = []; - const discoveredFilePaths = new Set(options.files.map((file) => file.path)); + discoveredFilePaths: ReadonlySet, +): Set => { const pendingStyleFilePaths = new Set(); for (const graphInput of sourceGraphInputs) { for (const resolvedImport of graphInput.resolvedImports.values()) { - if ( - resolvedImport.resolvedPath && - !resolvedImport.isExternal && - !discoveredFilePaths.has(resolvedImport.resolvedPath) && - isStyleFile(resolvedImport.resolvedPath) && - existsSync(resolvedImport.resolvedPath) - ) { - pendingStyleFilePaths.add(resolvedImport.resolvedPath); - } + if (resolvedImport.isExternal) continue; + const styleFilePath = findUndiscoveredStyleFilePath(resolvedImport, discoveredFilePaths); + if (styleFilePath) pendingStyleFilePaths.add(styleFilePath); + } + } + return pendingStyleFilePaths; +}; + +const collectStyleImports = ( + parsedStyleModule: ParsedSource, + styleFilePath: string, + context: StyleDiscoveryContext, +): Map => { + const resolvedStyleImports = new Map(); + for (const importInfo of parsedStyleModule.imports) { + const resolvedImport = resolveImport( + context, + importInfo.specifier, + styleFilePath, + `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, + ); + resolvedStyleImports.set(importInfo.specifier, resolvedImport); + + const importedStyleFilePath = findUndiscoveredStyleFilePath( + resolvedImport, + context.discoveredFilePaths, + ); + if (!importedStyleFilePath || context.pendingStyleFilePaths.has(importedStyleFilePath)) { + continue; } + context.pendingStyleFilePaths.add(importedStyleFilePath); + context.styleFileQueue.push(importedStyleFilePath); } + return resolvedStyleImports; +}; +const buildStyleModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, + sourceGraphInputs: ModuleLinkInput[], +): ModuleLinkInputsResult => { + const errors: DeslopError[] = []; + const graphInputs: ModuleLinkInput[] = []; + const discoveredFilePaths = new Set(options.files.map((file) => file.path)); + const pendingStyleFilePaths = collectPendingStyleFilePaths( + sourceGraphInputs, + discoveredFilePaths, + ); const styleFileQueue = [...pendingStyleFilePaths].sort(); + const discoveryContext: StyleDiscoveryContext = { + discoveredFilePaths, + errors, + pendingStyleFilePaths, + resolveModule: options.resolveModule, + styleFileQueue, + }; let nextFileIndex = options.files.length; for (let queueIndex = 0; queueIndex < styleFileQueue.length; queueIndex++) { const styleFilePath = styleFileQueue[queueIndex]; if (discoveredFilePaths.has(styleFilePath)) continue; const parsedStyleModule = parseSourceFile(styleFilePath); - const resolvedStyleImports = new Map(); - for (const importInfo of parsedStyleModule.imports) { - let resolvedImport: ResolvedImport; - try { - resolvedImport = options.resolveModule(importInfo.specifier, styleFilePath); - } catch (styleResolveError) { - errors.push( - new ResolverError({ - severity: "warning", - message: `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, - path: styleFilePath, - detail: describeUnknownError(styleResolveError), - }), - ); - resolvedImport = unresolvedImport(); - } - resolvedStyleImports.set(importInfo.specifier, resolvedImport); - if ( - resolvedImport.resolvedPath && - !discoveredFilePaths.has(resolvedImport.resolvedPath) && - isStyleFile(resolvedImport.resolvedPath) && - !pendingStyleFilePaths.has(resolvedImport.resolvedPath) && - existsSync(resolvedImport.resolvedPath) - ) { - pendingStyleFilePaths.add(resolvedImport.resolvedPath); - styleFileQueue.push(resolvedImport.resolvedPath); - } - } + const resolvedStyleImports = collectStyleImports( + parsedStyleModule, + styleFilePath, + discoveryContext, + ); graphInputs.push({ fileId: { index: nextFileIndex, path: styleFilePath }, diff --git a/packages/deslop-js/src/utils/find-strongly-connected-components.ts b/packages/deslop-js/src/utils/find-strongly-connected-components.ts index 304ee4bbf..55b0cfeb3 100644 --- a/packages/deslop-js/src/utils/find-strongly-connected-components.ts +++ b/packages/deslop-js/src/utils/find-strongly-connected-components.ts @@ -3,78 +3,107 @@ interface StronglyConnectedComponentFrame { successorIndex: number; } -export const findStronglyConnectedComponents = ( - adjacencyList: ReadonlyArray>, -): number[][] => { - const nodeIndices: Array = new Array(adjacencyList.length); - const lowLinks: number[] = new Array(adjacencyList.length).fill(0); - const nodesOnStack: boolean[] = new Array(adjacencyList.length).fill(false); - const componentStack: number[] = []; - const components: number[][] = []; - let nextNodeIndex = 0; - - for (let startNodeIndex = 0; startNodeIndex < adjacencyList.length; startNodeIndex++) { - if (nodeIndices[startNodeIndex] !== undefined) continue; - - nodeIndices[startNodeIndex] = nextNodeIndex; - lowLinks[startNodeIndex] = nextNodeIndex; - nextNodeIndex++; - nodesOnStack[startNodeIndex] = true; - componentStack.push(startNodeIndex); +interface StronglyConnectedComponentState { + nodeIndices: Array; + lowLinks: number[]; + nodesOnStack: boolean[]; + componentStack: number[]; + components: number[][]; + nextNodeIndex: number; +} - const traversalStack: StronglyConnectedComponentFrame[] = [ - { nodeIndex: startNodeIndex, successorIndex: 0 }, - ]; +const popStronglyConnectedComponent = ( + currentNodeIndex: number, + componentStack: number[], + nodesOnStack: boolean[], +): number[] => { + const component: number[] = []; + let componentNodeIndex: number | undefined; + do { + componentNodeIndex = componentStack.pop(); + if (componentNodeIndex === undefined) { + throw new Error("Strongly connected component stack was unexpectedly empty."); + } + nodesOnStack[componentNodeIndex] = false; + component.push(componentNodeIndex); + } while (componentNodeIndex !== currentNodeIndex); + return component; +}; - while (traversalStack.length > 0) { - const frame = traversalStack[traversalStack.length - 1]; - const successors = adjacencyList[frame.nodeIndex]; +const discoverNode = (nodeIndex: number, state: StronglyConnectedComponentState): void => { + state.nodeIndices[nodeIndex] = state.nextNodeIndex; + state.lowLinks[nodeIndex] = state.nextNodeIndex; + state.nextNodeIndex++; + state.nodesOnStack[nodeIndex] = true; + state.componentStack.push(nodeIndex); +}; - if (frame.successorIndex < successors.length) { - const successorNodeIndex = successors[frame.successorIndex]; - frame.successorIndex++; - const successorTraversalIndex = nodeIndices[successorNodeIndex]; +const traverseStronglyConnectedComponent = ( + startNodeIndex: number, + adjacencyList: ReadonlyArray>, + state: StronglyConnectedComponentState, +): void => { + discoverNode(startNodeIndex, state); + const traversalStack: StronglyConnectedComponentFrame[] = [ + { nodeIndex: startNodeIndex, successorIndex: 0 }, + ]; - if (successorTraversalIndex === undefined) { - nodeIndices[successorNodeIndex] = nextNodeIndex; - lowLinks[successorNodeIndex] = nextNodeIndex; - nextNodeIndex++; - nodesOnStack[successorNodeIndex] = true; - componentStack.push(successorNodeIndex); - traversalStack.push({ nodeIndex: successorNodeIndex, successorIndex: 0 }); - } else if (nodesOnStack[successorNodeIndex]) { - lowLinks[frame.nodeIndex] = Math.min(lowLinks[frame.nodeIndex], successorTraversalIndex); - } - continue; - } + while (traversalStack.length > 0) { + const frame = traversalStack[traversalStack.length - 1]; + const successors = adjacencyList[frame.nodeIndex]; - const currentNodeIndex = frame.nodeIndex; - const currentTraversalIndex = nodeIndices[currentNodeIndex]; - traversalStack.pop(); + if (frame.successorIndex < successors.length) { + const successorNodeIndex = successors[frame.successorIndex]; + frame.successorIndex++; + const successorTraversalIndex = state.nodeIndices[successorNodeIndex]; - if (traversalStack.length > 0) { - const parentFrame = traversalStack[traversalStack.length - 1]; - lowLinks[parentFrame.nodeIndex] = Math.min( - lowLinks[parentFrame.nodeIndex], - lowLinks[currentNodeIndex], + if (successorTraversalIndex === undefined) { + discoverNode(successorNodeIndex, state); + traversalStack.push({ nodeIndex: successorNodeIndex, successorIndex: 0 }); + } else if (state.nodesOnStack[successorNodeIndex]) { + state.lowLinks[frame.nodeIndex] = Math.min( + state.lowLinks[frame.nodeIndex], + successorTraversalIndex, ); } + continue; + } - if (currentTraversalIndex !== lowLinks[currentNodeIndex]) continue; + const currentNodeIndex = frame.nodeIndex; + const currentTraversalIndex = state.nodeIndices[currentNodeIndex]; + traversalStack.pop(); - const component: number[] = []; - let componentNodeIndex: number | undefined; - do { - componentNodeIndex = componentStack.pop(); - if (componentNodeIndex === undefined) { - throw new Error("Strongly connected component stack was unexpectedly empty."); - } - nodesOnStack[componentNodeIndex] = false; - component.push(componentNodeIndex); - } while (componentNodeIndex !== currentNodeIndex); - components.push(component); + if (traversalStack.length > 0) { + const parentFrame = traversalStack[traversalStack.length - 1]; + state.lowLinks[parentFrame.nodeIndex] = Math.min( + state.lowLinks[parentFrame.nodeIndex], + state.lowLinks[currentNodeIndex], + ); } + + if (currentTraversalIndex !== state.lowLinks[currentNodeIndex]) continue; + state.components.push( + popStronglyConnectedComponent(currentNodeIndex, state.componentStack, state.nodesOnStack), + ); + } +}; + +export const findStronglyConnectedComponents = ( + adjacencyList: ReadonlyArray>, +): number[][] => { + const state: StronglyConnectedComponentState = { + nodeIndices: new Array(adjacencyList.length), + lowLinks: new Array(adjacencyList.length).fill(0), + nodesOnStack: new Array(adjacencyList.length).fill(false), + componentStack: [], + components: [], + nextNodeIndex: 0, + }; + + for (let startNodeIndex = 0; startNodeIndex < adjacencyList.length; startNodeIndex++) { + if (state.nodeIndices[startNodeIndex] !== undefined) continue; + traverseStronglyConnectedComponent(startNodeIndex, adjacencyList, state); } - return components; + return state.components; }; diff --git a/packages/deslop-js/tests/build-module-link-inputs.test.ts b/packages/deslop-js/tests/build-module-link-inputs.test.ts new file mode 100644 index 000000000..16fef84ce --- /dev/null +++ b/packages/deslop-js/tests/build-module-link-inputs.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { parseSourceFile } from "../src/collect/parse.js"; +import { buildModuleLinkInputs } from "../src/linker/build-module-link-inputs.js"; + +const temporaryRoot = mkdtempSync(join(os.tmpdir(), "deslop-module-link-inputs-")); + +after(() => { + rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +describe("buildModuleLinkInputs", () => { + it("preserves source ordering while discovering sorted and transitive style imports", () => { + const projectDirectory = join(temporaryRoot, "style-discovery"); + const sourceFilePath = join(projectDirectory, "src", "index.ts"); + const firstStyleFilePath = join(projectDirectory, "styles", "a.css"); + const secondStyleFilePath = join(projectDirectory, "styles", "z.css"); + const nestedStyleFilePath = join(projectDirectory, "styles", "nested.css"); + const sourceExternalStyleFilePath = join(projectDirectory, "styles", "source-external.css"); + const nestedExternalStyleFilePath = join(projectDirectory, "styles", "nested-external.css"); + mkdirSync(join(projectDirectory, "src"), { recursive: true }); + mkdirSync(join(projectDirectory, "styles"), { recursive: true }); + writeFileSync( + sourceFilePath, + 'import "../styles/z.css";\nimport "../styles/a.css";\nimport "source-external";\nexport { missing } from "./missing.js";\n', + ); + writeFileSync(firstStyleFilePath, '@import "./nested.css";\n'); + writeFileSync(secondStyleFilePath, ".second {}\n"); + writeFileSync(nestedStyleFilePath, '@import "./broken.css";\n@import "nested-external";\n'); + writeFileSync(sourceExternalStyleFilePath, ".source-external {}\n"); + writeFileSync(nestedExternalStyleFilePath, ".nested-external {}\n"); + + const resolvedPaths = new Map([ + [`${sourceFilePath}:../styles/a.css`, firstStyleFilePath], + [`${sourceFilePath}:../styles/z.css`, secondStyleFilePath], + [`${sourceFilePath}:source-external`, sourceExternalStyleFilePath], + [`${firstStyleFilePath}:./nested.css`, nestedStyleFilePath], + [`${nestedStyleFilePath}:nested-external`, nestedExternalStyleFilePath], + ]); + const result = buildModuleLinkInputs({ + files: [{ index: 0, path: sourceFilePath }], + parsedModules: [parseSourceFile(sourceFilePath)], + resolvedEntries: { + productionEntries: [sourceFilePath], + testEntries: [], + alwaysUsedFiles: [], + }, + gitIgnoredFilePaths: new Set([nestedStyleFilePath]), + resolveModule: (specifier, fromFilePath) => { + const resolvedPath = resolvedPaths.get(`${fromFilePath}:${specifier}`); + if (!resolvedPath) throw new Error(`could not resolve ${specifier}`); + return { + resolvedPath, + isExternal: specifier === "source-external" || specifier === "nested-external", + packageName: undefined, + }; + }, + }); + + assert.deepEqual( + result.graphInputs.map((graphInput) => graphInput.fileId), + [ + sourceFilePath, + firstStyleFilePath, + secondStyleFilePath, + nestedStyleFilePath, + nestedExternalStyleFilePath, + ].map((filePath, index) => ({ index, path: filePath })), + ); + assert.equal(result.graphInputs[0].isEntryPoint, true); + assert.equal(result.graphInputs[3].isGitIgnored, true); + assert.deepEqual( + result.errors.map((error) => ({ message: error.message, path: error.path })), + [ + { + message: 'moduleResolver.resolveModule threw on specifier "./missing.js"', + path: sourceFilePath, + }, + { + message: 'moduleResolver.resolveModule threw on style import "./broken.css"', + path: nestedStyleFilePath, + }, + ], + ); + }); +}); diff --git a/packages/deslop-js/tests/find-strongly-connected-components.test.ts b/packages/deslop-js/tests/find-strongly-connected-components.test.ts new file mode 100644 index 000000000..99ade1316 --- /dev/null +++ b/packages/deslop-js/tests/find-strongly-connected-components.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { findStronglyConnectedComponents } from "../src/utils/find-strongly-connected-components.js"; + +describe("findStronglyConnectedComponents", () => { + it("preserves depth-first component and node emission order", () => { + const adjacencyList = [[1], [2, 3], [0], [4], [3], [], [6]]; + + assert.deepEqual(findStronglyConnectedComponents(adjacencyList), [[4, 3], [2, 1, 0], [5], [6]]); + }); +}); diff --git a/packages/deslop-js/tests/package-json-entries.test.ts b/packages/deslop-js/tests/package-json-entries.test.ts index 21582be4e..9eeb72d50 100644 --- a/packages/deslop-js/tests/package-json-entries.test.ts +++ b/packages/deslop-js/tests/package-json-entries.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { after, describe, it } from "node:test"; import { extractPackageJsonEntries } from "../src/collect/package-json-entries.js"; @@ -12,6 +12,47 @@ after(() => { }); describe("extractPackageJsonEntries", () => { + it("collects package metadata entry categories in declaration order", async () => { + const projectDirectory = join(temporaryRoot, "package-metadata-categories"); + const relativeEntryPaths = [ + "src/main.ts", + "src/export.tsx", + "src/wildcard.ts", + "src/cli.ts", + "src/side-effect.ts", + "src/build-entry.ts", + "src/jest-setup.ts", + ]; + for (const relativeEntryPath of relativeEntryPaths) { + const absoluteEntryPath = join(projectDirectory, relativeEntryPath); + mkdirSync(dirname(absoluteEntryPath), { recursive: true }); + writeFileSync(absoluteEntryPath, "export const entry = true;\n"); + } + + const packageJsonPath = join(projectDirectory, "package.json"); + writeFileSync( + packageJsonPath, + JSON.stringify({ + main: "src/main.ts", + exports: { + ".": "./src/export.ts", + "./wildcard": "./src/wildcard.*", + }, + bin: { cli: "src/cli", ignored: false }, + sideEffects: ["src/side-effect.js", false], + build: { files: ["src/build-entry", "src/*.ts", false] }, + jest: { setupFilesAfterEnv: ["/src/jest-setup"] }, + }), + ); + + const entries = await extractPackageJsonEntries(packageJsonPath); + + assert.deepEqual( + entries, + relativeEntryPaths.map((relativeEntryPath) => join(projectDirectory, relativeEntryPath)), + ); + }); + it("does not treat sibling output-directory prefixes as descendants", async () => { const projectDirectory = join(temporaryRoot, "out-directory-prefix"); const expectedSourcePath = join(projectDirectory, "src", "index.ts"); @@ -32,4 +73,25 @@ describe("extractPackageJsonEntries", () => { assert.ok(entries.includes(expectedSourcePath)); assert.ok(!entries.includes(misleadingSourcePath)); }); + + it("prefers the configured source root over common source-directory fallbacks", async () => { + const projectDirectory = join(temporaryRoot, "configured-source-root"); + const configuredSourcePath = join(projectDirectory, "source", "index.ts"); + const heuristicSourcePath = join(projectDirectory, "src", "index.ts"); + mkdirSync(dirname(configuredSourcePath), { recursive: true }); + mkdirSync(dirname(heuristicSourcePath), { recursive: true }); + writeFileSync(configuredSourcePath, "export const configured = true;\n"); + writeFileSync(heuristicSourcePath, "export const heuristic = true;\n"); + writeFileSync( + join(projectDirectory, "tsconfig.json"), + JSON.stringify({ compilerOptions: { outDir: "dist", rootDir: "source" } }), + ); + const packageJsonPath = join(projectDirectory, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ main: "dist/index.js" })); + + const entries = await extractPackageJsonEntries(packageJsonPath); + + assert.ok(entries.includes(configuredSourcePath)); + assert.ok(!entries.includes(heuristicSourcePath)); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-adjust-state-on-prop-change-media-capability.cross-file.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-adjust-state-on-prop-change-media-capability.cross-file.test.ts index 6b2663009..aa6632727 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-adjust-state-on-prop-change-media-capability.cross-file.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-adjust-state-on-prop-change-media-capability.cross-file.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; -import { __clearTsconfigAliasCacheForTests } from "../../utils/resolve-tsconfig-alias.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; import { noAdjustStateOnPropChange } from "./no-adjust-state-on-prop-change.js"; let temporaryDirectory: string; @@ -12,7 +12,7 @@ let temporaryDirectory: string; beforeEach(() => { temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "media-capability-helper-")); __clearParseSourceFileCacheForTests(); - __clearTsconfigAliasCacheForTests(); + resetTsconfigAliasCaches(); }); afterEach(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts index 34a7bd88d..7b080c8c9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/zustand-no-mutating-state.ts @@ -91,6 +91,11 @@ interface ConditionalNotifierGroupWithStatementIndex { statementIndex: number; } +interface ObjectTargetPropertyDispositionHandlers { + propertyValueDisposition: (propertyValue: EsTreeNode, propertyName: string) => boolean | null; + spreadElementDisposition: (spreadElement: EsTreeNodeOfType<"SpreadElement">) => boolean | null; +} + const findIdentifierParameter = ( parameter: EsTreeNode | undefined, ): EsTreeNodeOfType<"Identifier"> | null => { @@ -536,64 +541,77 @@ const isProvenFreshReplacementExpression = ( ); }; -const objectTargetReplacementDisposition = ( +const objectTargetPropertyDisposition = ( objectExpression: EsTreeNodeOfType<"ObjectExpression">, targetPath: readonly string[], - targetKey: string, - ancestorKey: string, - mutationNode: EsTreeNode, isPartialUpdateRoot: boolean, - context: RuleContext, - ancestorPath: readonly string[] = [], + handlers: ObjectTargetPropertyDispositionHandlers, ): boolean | null => { const propertyName = targetPath[0]; if (!propertyName) return true; let disposition: boolean | null = !isPartialUpdateRoot; for (const property of objectExpression.properties) { if (isNodeOfType(property, "SpreadElement")) { - const spreadKey = resolveExpressionKey(property.argument, context); - const spreadPath = staticPropertyPathForExpression(property.argument, context); - disposition = - spreadKey === ancestorKey || staticPathPreservesTarget(spreadPath, ancestorPath) - ? false - : null; + disposition = handlers.spreadElementDisposition(property); continue; } - if (!isNodeOfType(property, "Property")) continue; - if (getStaticPropertyKeyName(property) !== propertyName) continue; - if (targetPath.length === 1) { - if (expressionPreservesTarget(property.value, targetKey, mutationNode, context)) { - disposition = false; - } else { - disposition = isProvenFreshReplacementExpression(property.value, targetKey, context) - ? true - : null; - } + if ( + !isNodeOfType(property, "Property") || + getStaticPropertyKeyName(property) !== propertyName + ) { continue; } - const propertyValue = stripParenExpression(property.value); - if (isNodeOfType(propertyValue, "ObjectExpression")) { - disposition = objectTargetReplacementDisposition( - propertyValue, - targetPath.slice(1), - targetKey, - `${ancestorKey}.${propertyName}`, - mutationNode, - false, - context, - [...ancestorPath, propertyName], - ); - } else if (expressionKeyPreservesTarget(propertyValue, targetKey, context)) { - disposition = false; - } else { - disposition = isProvenFreshReplacementExpression(propertyValue, targetKey, context) - ? true - : null; - } + disposition = handlers.propertyValueDisposition( + stripParenExpression(property.value), + propertyName, + ); } return disposition; }; +const objectTargetReplacementDisposition = ( + objectExpression: EsTreeNodeOfType<"ObjectExpression">, + targetPath: readonly string[], + targetKey: string, + ancestorKey: string, + mutationNode: EsTreeNode, + isPartialUpdateRoot: boolean, + context: RuleContext, + ancestorPath: readonly string[] = [], +): boolean | null => { + return objectTargetPropertyDisposition(objectExpression, targetPath, isPartialUpdateRoot, { + propertyValueDisposition: (propertyValue, propertyName) => { + if (targetPath.length === 1) { + if (expressionPreservesTarget(propertyValue, targetKey, mutationNode, context)) { + return false; + } + return isProvenFreshReplacementExpression(propertyValue, targetKey, context) ? true : null; + } + if (isNodeOfType(propertyValue, "ObjectExpression")) { + return objectTargetReplacementDisposition( + propertyValue, + targetPath.slice(1), + targetKey, + `${ancestorKey}.${propertyName}`, + mutationNode, + false, + context, + [...ancestorPath, propertyName], + ); + } + if (expressionKeyPreservesTarget(propertyValue, targetKey, context)) return false; + return isProvenFreshReplacementExpression(propertyValue, targetKey, context) ? true : null; + }, + spreadElementDisposition: (spreadElement) => { + const spreadKey = resolveExpressionKey(spreadElement.argument, context); + const spreadPath = staticPropertyPathForExpression(spreadElement.argument, context); + return spreadKey === ancestorKey || staticPathPreservesTarget(spreadPath, ancestorPath) + ? false + : null; + }, + }); +}; + const staticPathPreservesTarget = ( candidatePath: readonly string[] | null, targetPath: readonly string[], @@ -611,39 +629,29 @@ const objectTargetPathReplacementDisposition = ( context: RuleContext, ancestorPath: readonly string[] = [], ): boolean | null => { - const propertyName = targetPath[0]; - if (!propertyName) return true; - let disposition: boolean | null = !isPartialUpdateRoot; - for (const property of objectExpression.properties) { - if (isNodeOfType(property, "SpreadElement")) { - disposition = null; - continue; - } - if (!isNodeOfType(property, "Property")) continue; - if (getStaticPropertyKeyName(property) !== propertyName) continue; - const propertyValue = stripParenExpression(property.value); - if (targetPath.length > 1 && isNodeOfType(propertyValue, "ObjectExpression")) { - disposition = objectTargetPathReplacementDisposition( - propertyValue, - targetPath.slice(1), - false, - context, - [...ancestorPath, propertyName], - ); - continue; - } - if ( - staticPathPreservesTarget(staticPropertyPathForExpression(propertyValue, context), [ - ...ancestorPath, - ...targetPath, - ]) - ) { - disposition = false; - continue; - } - disposition = isProvenFreshReplacementExpression(propertyValue, "", context) ? true : null; - } - return disposition; + return objectTargetPropertyDisposition(objectExpression, targetPath, isPartialUpdateRoot, { + propertyValueDisposition: (propertyValue, propertyName) => { + if (targetPath.length > 1 && isNodeOfType(propertyValue, "ObjectExpression")) { + return objectTargetPathReplacementDisposition( + propertyValue, + targetPath.slice(1), + false, + context, + [...ancestorPath, propertyName], + ); + } + if ( + staticPathPreservesTarget(staticPropertyPathForExpression(propertyValue, context), [ + ...ancestorPath, + ...targetPath, + ]) + ) { + return false; + } + return isProvenFreshReplacementExpression(propertyValue, "", context) ? true : null; + }, + spreadElementDisposition: () => null, + }); }; const objectExpressionPublishesSymbolAtPath = ( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts index 89328eba9..ae474375b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/find-exported-function-body.ts @@ -29,6 +29,43 @@ export const resolveImportedExportName = (importSpecifier: EsTreeNode): string | return null; }; +const namedReExportTargetForName = ( + statement: EsTreeNode, + exportedName: string, +): ReExportTarget | null => { + if (!isNodeOfType(statement, "ExportNamedDeclaration")) return null; + if (!statement.source || statement.exportKind === "type") return null; + const source = statement.source.value; + if (typeof source !== "string") return null; + for (const specifier of statement.specifiers ?? []) { + const target = reExportTargetFromSpecifier(specifier, exportedName, source); + if (target) return target; + } + return null; +}; + +const reExportTargetFromSpecifier = ( + specifier: EsTreeNode, + exportedName: string, + source: string, +): ReExportTarget | null => { + if (!isNodeOfType(specifier, "ExportSpecifier")) return null; + if (specifier.exportKind === "type") return null; + if (getModuleSpecifierName(specifier.exported) !== exportedName) return null; + const importedName = getModuleSpecifierName(specifier.local); + return importedName ? { importedName, source } : null; +}; + +const exportAllTargetForName = ( + statement: EsTreeNode, + exportedName: string, +): ReExportTarget | null => { + if (!isNodeOfType(statement, "ExportAllDeclaration")) return null; + if (!statement.source || statement.exportKind === "type" || statement.exported) return null; + const source = statement.source.value; + return typeof source === "string" ? { importedName: exportedName, source } : null; +}; + // Returns the source/name pairs the caller should probe to resolve // `exportedName` through a re-export, in priority order: // @@ -47,26 +84,10 @@ export const findReExportTargetsForName = ( if (!isNodeOfType(programRoot, "Program")) return []; const exportAllTargets: ReExportTarget[] = []; for (const statement of programRoot.body ?? []) { - if (isNodeOfType(statement, "ExportNamedDeclaration") && statement.source) { - if (statement.exportKind === "type") continue; - const sourceValue = statement.source.value; - if (typeof sourceValue !== "string") continue; - for (const specifier of statement.specifiers ?? []) { - if (!isNodeOfType(specifier, "ExportSpecifier")) continue; - if (specifier.exportKind === "type") continue; - const exportedNameSpec = getModuleSpecifierName(specifier.exported); - if (exportedNameSpec !== exportedName) continue; - const importedName = getModuleSpecifierName(specifier.local); - if (importedName) return [{ importedName, source: sourceValue }]; - } - } - if (isNodeOfType(statement, "ExportAllDeclaration") && statement.source) { - if (statement.exportKind === "type" || statement.exported) continue; - const sourceValue = statement.source.value; - if (typeof sourceValue === "string") { - exportAllTargets.push({ importedName: exportedName, source: sourceValue }); - } - } + const namedTarget = namedReExportTargetForName(statement, exportedName); + if (namedTarget) return [namedTarget]; + const exportAllTarget = exportAllTargetForName(statement, exportedName); + if (exportAllTarget) exportAllTargets.push(exportAllTarget); } return exportAllTargets; }; diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index d7ba62080..b5cd6dae2 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -2,19 +2,21 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import * as Effect from "effect/Effect"; import { + type ChangedFileLineRanges, type DiffInfo, getBaselineDiffPlan, getChangedLineRanges, getDiffInfo, highlighter, isPathInsideDirectory, + type JsonReportSkippedProject, remainingDeadlineBudgetMs, resolveScanTarget, toRelativePath, } from "@react-doctor/core"; import { createInvocationInspect } from "../../inspect.js"; +import type { ReactDoctorInspectOptions } from "../../inspect-options.js"; import { flushSentry } from "../../instrument.js"; -import type { JsonReportSkippedProject } from "@react-doctor/core"; import type { RequestedScope } from "../utils/resolve-scope.js"; import { cliLogger as logger } from "../utils/cli-logger.js"; import { METRIC } from "../utils/constants.js"; @@ -57,7 +59,7 @@ import { resolveProjectChangedLineRanges } from "../utils/resolve-project-diff-i import { resolveProjectScan, type ResolvedProjectScan } from "../utils/resolve-project-scan.js"; import { runExplain } from "../utils/run-explain.js"; import { type ProjectScanOutcome, runProjectScanBatch } from "../utils/run-project-scan-batch.js"; -import { buildProjectScanPlan } from "../utils/build-project-scan-plan.js"; +import { buildProjectScanPlan, type ProjectScanPlan } from "../utils/build-project-scan-plan.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"; @@ -91,6 +93,147 @@ interface MigrationGuardInput { readonly isStaged: boolean; } +interface ProjectScanExecutionContext { + readonly flags: InspectFlags; + readonly resolvedDirectory: string; + readonly scanOptions: CliInspectOptions; + readonly inspectProject: ReturnType; + readonly scanDeadlineEpochMs: number | undefined; + readonly baselineDiffPlan: Awaited>; + readonly diffInfo: DiffInfo | null; + readonly isDiffMode: boolean; + readonly isQuiet: boolean; + readonly isMultiProject: boolean; + readonly workspaceDeadCodeOwner: string | null; + readonly precomputedSourceFileCounts: ReadonlyMap | null; + readonly projectScans: ReadonlyArray; + readonly baselineRef: string | null; + readonly scope: RequestedScope["scope"]; + readonly changedLineRanges: ReadonlyArray | null; +} + +interface BuildProjectInspectOptionsInput { + readonly context: ProjectScanExecutionContext; + readonly projectScan: ResolvedProjectScan; + readonly projectScanPlan: ProjectScanPlan; + readonly ownsWorkspaceDeadCode: boolean; +} + +interface IsProjectSupplyChainEnabledInput { + readonly flags: InspectFlags; + readonly projectConfig: ResolvedProjectScan["config"]; +} + +interface RunConfiguredProjectScanInput { + readonly context: ProjectScanExecutionContext; + readonly projectScan: ResolvedProjectScan; +} + +const hasScanDeadlineExpired = (scanDeadlineEpochMs: number | undefined): boolean => + scanDeadlineEpochMs !== undefined && remainingDeadlineBudgetMs(scanDeadlineEpochMs) === 0; + +const isProjectSupplyChainEnabled = ({ + flags, + projectConfig, +}: IsProjectSupplyChainEnabledInput): boolean => + flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false; + +const buildProjectInspectOptions = ({ + context, + projectScan, + projectScanPlan, + ownsWorkspaceDeadCode, +}: BuildProjectInspectOptionsInput): ReactDoctorInspectOptions => { + const scanDirectory = projectScan.directory; + return { + ...context.scanOptions, + deadCode: + context.workspaceDeadCodeOwner === null + ? context.scanOptions.deadCode + : ownsWorkspaceDeadCode, + precomputedSourceFileCount: context.precomputedSourceFileCounts?.get(scanDirectory), + deadlineEpochMs: context.scanDeadlineEpochMs, + includePaths: projectScanPlan.includePaths, + configOverride: projectScan.config, + configSourceDirectory: projectScan.configSourceDirectory ?? undefined, + suppressRendering: context.isMultiProject, + concurrentScan: context.isMultiProject, + excludedProjectDirectories: context.projectScans + .filter((candidateProjectScan) => + isPathInsideDirectory(candidateProjectScan.directory, scanDirectory), + ) + .map((candidateProjectScan) => candidateProjectScan.directory), + retainExcludedProjectDeadCodeDiagnostics: ownsWorkspaceDeadCode, + baseline: + context.baselineRef !== null && + projectScanPlan.projectBaselineBaseFiles !== null && + projectScanPlan.projectBaselineHeadFiles !== null + ? { + ref: context.baselineRef, + baseFiles: projectScanPlan.projectBaselineBaseFiles, + headFiles: projectScanPlan.projectBaselineHeadFiles, + } + : undefined, + changedLineRanges: + context.scope === "lines" && context.changedLineRanges !== null + ? resolveProjectChangedLineRanges( + context.resolvedDirectory, + scanDirectory, + context.changedLineRanges, + ) + : undefined, + supplyChainManifestChanged: projectScanPlan.supplyChainManifestChanged, + }; +}; + +const runConfiguredProjectScan = async ({ + context, + projectScan, +}: RunConfiguredProjectScanInput): Promise< + ProjectScanOutcome +> => { + if (hasScanDeadlineExpired(context.scanDeadlineEpochMs)) { + return { + status: "skipped", + value: { directory: projectScan.directory, reason: "max-duration" }, + }; + } + + const scanDirectory = projectScan.directory; + const projectConfig = projectScan.config; + const ownsWorkspaceDeadCode = scanDirectory === context.workspaceDeadCodeOwner; + const supplyChainEnabled = isProjectSupplyChainEnabled({ + flags: context.flags, + projectConfig, + }); + const projectScanPlan = buildProjectScanPlan({ + rootDirectory: context.resolvedDirectory, + projectDirectory: scanDirectory, + baselineDiffPlan: context.baselineDiffPlan, + diffInfo: context.diffInfo, + isDiffMode: context.isDiffMode, + supplyChainEnabled, + }); + if (projectScanPlan.shouldSkipProject) { + if (!context.isQuiet) { + logger.dim(`No changed source files in ${scanDirectory}, skipping.`); + logger.break(); + } + return { status: "omitted" }; + } + + if (!context.isQuiet && !context.isMultiProject) logger.dim(" "); + const scanResult = await context.inspectProject( + scanDirectory, + buildProjectInspectOptions({ context, projectScan, projectScanPlan, ownsWorkspaceDeadCode }), + ); + if (!context.isQuiet && !context.isMultiProject) logger.break(); + return { + status: "completed", + value: { directory: scanDirectory, result: scanResult, config: projectConfig }, + }; +}; + /** * On an interactive human run, rename a pre-migration * `react-doctor.config.json` to `doctor.config.ts` before config is loaded, @@ -390,93 +533,31 @@ export const inspectAction = async ( projectScans.map((projectScan) => projectScan.directory), ) : null; - const scanProject = async ( - projectScan: ResolvedProjectScan, - ): Promise> => { - if ( - scanDeadlineEpochMs !== undefined && - remainingDeadlineBudgetMs(scanDeadlineEpochMs) === 0 - ) { - return { - status: "skipped", - value: { directory: projectScan.directory, reason: "max-duration" }, - }; - } - const scanDirectory = projectScan.directory; - const projectConfig = projectScan.config; - const ownsWorkspaceDeadCode = scanDirectory === workspaceDeadCodeOwner; - // The Socket supply-chain check runs by default; opted out by - // `--no-supply-chain` (wins) or per-project config. Off ⇒ a manifest-only - // diff change shouldn't pull a project into the scan (nothing to report). - const supplyChainEnabled = flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false; - - 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(); - } - return { status: "omitted" }; - } - - if (!isQuiet && !isMultiProject) { - logger.dim(" "); - } - const scanResult = await inspectProject(scanDirectory, { - ...scanOptions, - deadCode: workspaceDeadCodeOwner === null ? scanOptions.deadCode : ownsWorkspaceDeadCode, - precomputedSourceFileCount: precomputedSourceFileCounts?.get(scanDirectory), - deadlineEpochMs: scanDeadlineEpochMs, - includePaths: projectScanPlan.includePaths, - configOverride: projectConfig, - configSourceDirectory: projectScan.configSourceDirectory ?? undefined, - suppressRendering: isMultiProject, - // Pool members overlap; they must not own the process-global Sentry - // run state (see `InspectOptions.concurrentScan`). - concurrentScan: isMultiProject, - excludedProjectDirectories: projectScans - .filter((candidateProjectScan) => - isPathInsideDirectory(candidateProjectScan.directory, scanDirectory), - ) - .map((candidateProjectScan) => candidateProjectScan.directory), - retainExcludedProjectDeadCodeDiagnostics: ownsWorkspaceDeadCode, - baseline: - baselineRef !== null && - projectScanPlan.projectBaselineBaseFiles !== null && - projectScanPlan.projectBaselineHeadFiles !== null - ? { - ref: baselineRef, - baseFiles: projectScanPlan.projectBaselineBaseFiles, - headFiles: projectScanPlan.projectBaselineHeadFiles, - } - : undefined, - changedLineRanges: - scope === "lines" && changedLineRanges !== null - ? resolveProjectChangedLineRanges(resolvedDirectory, scanDirectory, changedLineRanges) - : undefined, - supplyChainManifestChanged: projectScanPlan.supplyChainManifestChanged, - }); - if (!isQuiet && !isMultiProject) { - logger.break(); - } - return { - status: "completed", - value: { directory: scanDirectory, result: scanResult, config: projectConfig }, - }; + const projectScanExecutionContext: ProjectScanExecutionContext = { + flags, + resolvedDirectory, + scanOptions, + inspectProject, + scanDeadlineEpochMs, + baselineDiffPlan, + diffInfo, + isDiffMode, + isQuiet, + isMultiProject, + workspaceDeadCodeOwner, + precomputedSourceFileCounts, + projectScans, + baselineRef, + scope, + changedLineRanges, }; const projectBatch = await runProjectScanBatch({ projects: projectScans, isQuiet, isSilent: scanOptions.silent === true, - scanProject, + scanProject: (projectScan) => + runConfiguredProjectScan({ context: projectScanExecutionContext, projectScan }), }); const completedScans = await retryMissingProjectScores( projectBatch.completedScans.map((completedScan) => ({ diff --git a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts index ca5c3012a..1988f5e12 100644 --- a/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts +++ b/packages/react-doctor/src/cli/utils/finalize-inspect-result.ts @@ -1,6 +1,11 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { filterDiagnosticsForSurface, highlighter, type InspectResult } from "@react-doctor/core"; +import { + type Diagnostic, + filterDiagnosticsForSurface, + highlighter, + type InspectResult, +} from "@react-doctor/core"; import type { ResolvedInspectOptions } from "../../inspect-options.js"; import { buildEmptyReportMessage } from "./build-empty-report-message.js"; import { buildInspectResult, type InspectExecutionCacheStats } from "./build-inspect-result.js"; @@ -22,89 +27,154 @@ interface FinalizeInspectResultInput { readonly cacheStats: InspectExecutionCacheStats; } -export const finalizeInspectResult = ( - input: FinalizeInspectResultInput, -): Effect.Effect => - Effect.gen(function* () { - const { payload } = input; - const result = buildInspectResult(input); - const hasSkippedChecks = result.skippedChecks.length > 0; - const noScoreMessage = buildNoScoreMessage({ - isScoreDisabled: input.options.noScore, - isAnalysisIncomplete: hasIncompleteScoreAnalysis(result.skippedChecks), - disabledMessage: input.options.scoreDisabledMessage, - }); +interface InspectPresentation { + readonly diagnostics: Diagnostic[]; + readonly demotedDiagnosticCount: number; +} - if (input.options.suppressRendering) return result; +interface PrintScoreOnlyInspectResultInput { + readonly options: ResolvedInspectOptions; + readonly payload: CachedScanPayload; + readonly diagnostics: Diagnostic[]; + readonly noScoreMessage: string; +} - const surfaceDiagnostics = filterDiagnosticsForSurface( - [...payload.diagnostics], - input.options.outputSurface, - payload.userConfig, - ); - const printedDiagnostics = filterDiagnosticsByCategories( - surfaceDiagnostics, - input.options.categoryFilters, - ); +interface PrintFullInspectResultInput extends FinalizeInspectResultInput, InspectPresentation { + readonly result: InspectResult; + readonly noScoreMessage: string; +} - if (input.options.scoreOnly) { - if (input.options.outputDirectory !== null) { - yield* printDiagnosticsDump( - printedDiagnostics, - input.options.outputDirectory, - false, - "stderr", - ); - } - if (payload.score) { - yield* Console.log(`${payload.score.score}`); - } else { - yield* Console.error(highlighter.gray(noScoreMessage)); - } - return result; +interface PrintFullInspectResultDetailsInput extends InspectPresentation { + readonly options: ResolvedInspectOptions; + readonly payload: CachedScanPayload; +} + +const buildInspectPresentation = ({ + options, + payload, +}: FinalizeInspectResultInput): InspectPresentation => { + const surfaceDiagnostics = filterDiagnosticsForSurface( + [...payload.diagnostics], + options.outputSurface, + payload.userConfig, + ); + return { + diagnostics: filterDiagnosticsByCategories(surfaceDiagnostics, options.categoryFilters), + demotedDiagnosticCount: payload.diagnostics.length - surfaceDiagnostics.length, + }; +}; + +const printScoreOnlyInspectResult = ({ + options, + payload, + diagnostics, + noScoreMessage, +}: PrintScoreOnlyInspectResultInput): Effect.Effect => + Effect.gen(function* () { + if (options.outputDirectory !== null) { + yield* printDiagnosticsDump(diagnostics, options.outputDirectory, false, "stderr"); + } + if (payload.score) { + yield* Console.log(`${payload.score.score}`); + return; } + yield* Console.error(highlighter.gray(noScoreMessage)); + }); - const demotedDiagnosticCount = payload.diagnostics.length - surfaceDiagnostics.length; - if (input.options.isNonInteractiveEnvironment && input.options.outputSurface !== "prComment") { +const printFullInspectResultDetails = ({ + options, + payload, + diagnostics, + demotedDiagnosticCount, +}: PrintFullInspectResultDetailsInput): Effect.Effect => + Effect.gen(function* () { + if (options.outputDirectory !== null || options.verbose) { + yield* printDiagnosticsDump(diagnostics, options.outputDirectory, options.verbose); + } + 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(""); + } + yield* printFooter({ + diagnostics, + scoreResult: payload.score, + projectName: payload.project.projectName, + isOffline: options.isCi || !options.share || payload.score === null, + }); + }); + +const printFullInspectResult = ({ + options, + elapsedMilliseconds, + payload, + result, + diagnostics, + demotedDiagnosticCount, + noScoreMessage, +}: PrintFullInspectResultInput): Effect.Effect => + Effect.gen(function* () { + if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") { yield* printAgentGuidance(); } yield* printHeadlessReport({ - diagnostics: printedDiagnostics, - elapsedMilliseconds: input.elapsedMilliseconds, + diagnostics, + elapsedMilliseconds, emptyStateMessage: buildEmptyReportMessage({ - categoryFilters: input.options.categoryFilters, + categoryFilters: options.categoryFilters, demotedDiagnosticCount, - outputSurface: input.options.outputSurface, + outputSurface: options.outputSurface, }), noScoreMessage, projectName: payload.project.projectName, scannedFileCount: payload.scannedFileCount, - scoreResult: hasSkippedChecks ? null : payload.score, + scoreResult: result.skippedChecks.length > 0 ? null : payload.score, skippedChecks: result.skippedChecks, }); - if (input.options.outputDirectory !== null || input.options.verbose) { - yield* printDiagnosticsDump( - printedDiagnostics, - input.options.outputDirectory, - input.options.verbose, - ); - } - if (input.options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) { - yield* Console.log( - highlighter.gray( - ` ${demotedDiagnosticCount} demoted from the ${input.options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`, - ), - ); - yield* Console.log(""); + yield* printFullInspectResultDetails({ + options, + payload, + diagnostics, + demotedDiagnosticCount, + }); + }); + +export const finalizeInspectResult = ( + input: FinalizeInspectResultInput, +): Effect.Effect => + Effect.gen(function* () { + const { payload } = input; + const result = buildInspectResult(input); + const noScoreMessage = buildNoScoreMessage({ + isScoreDisabled: input.options.noScore, + isAnalysisIncomplete: hasIncompleteScoreAnalysis(result.skippedChecks), + disabledMessage: input.options.scoreDisabledMessage, + }); + + if (input.options.suppressRendering) return result; + + const presentation = buildInspectPresentation(input); + + if (input.options.scoreOnly) { + yield* printScoreOnlyInspectResult({ + options: input.options, + payload, + diagnostics: presentation.diagnostics, + noScoreMessage, + }); + return result; } - yield* printFooter({ - diagnostics: printedDiagnostics, - scoreResult: payload.score, - projectName: payload.project.projectName, - isOffline: input.options.isCi || !input.options.share || payload.score === null, + yield* printFullInspectResult({ + ...input, + ...presentation, + result, + noScoreMessage, }); return result; diff --git a/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts b/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts index 3e09f9956..5545dac15 100644 --- a/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts +++ b/packages/react-doctor/src/cli/utils/resolve-inspect-options.ts @@ -18,6 +18,7 @@ export const resolveInspectOptions = ( userConfig: ReactDoctorConfig | null, ): ResolvedInspectOptions => { const includedTags = inputOptions.includedTags ?? new Set(); + const hasIncludedTags = includedTags.size > 0; return { lint: inputOptions.lint ?? userConfig?.lint ?? true, deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, @@ -31,14 +32,15 @@ export const resolveInspectOptions = ( isNonInteractiveEnvironment: isNonInteractiveEnvironment(), silent: inputOptions.silent ?? false, includePaths: inputOptions.includePaths ?? [], - customRulesOnly: includedTags.size > 0 ? false : (userConfig?.customRulesOnly ?? false), + customRulesOnly: hasIncludedTags ? 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), + adoptExistingLintConfig: hasIncludedTags + ? false + : (userConfig?.adoptExistingLintConfig ?? true), ignoredTags: resolveIgnoredTags(userConfig, includedTags), includedTags, includeTagDefaults: inputOptions.includeTagDefaults ?? false, diff --git a/packages/react-doctor/src/cli/utils/run-staged-inspect.ts b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts index bcb48458e..ae67aad9c 100644 --- a/packages/react-doctor/src/cli/utils/run-staged-inspect.ts +++ b/packages/react-doctor/src/cli/utils/run-staged-inspect.ts @@ -60,10 +60,29 @@ interface StagedProjectScanContext { readonly projectConfigSourceDirectory: string | null; } +interface ResolveStagedProjectScanContextInput { + readonly scanTarget: ResolvedScanTarget; + readonly rootDirectory: string; + readonly projectDirectory: string; + readonly isProjectExplicitlySelected: boolean; +} + +interface CollectStagedProjectScanContextsInput { + readonly scanTarget: ResolvedScanTarget; + readonly rootDirectory: string; + readonly projectDirectories: ReadonlyArray; + readonly isProjectExplicitlySelected: boolean; +} + interface StagedProjectScan extends StagedProjectScanContext { readonly stagedFiles: ReadonlyArray; } +interface StagedProjectRun { + readonly projectScan: StagedProjectScan; + readonly includePaths: ReadonlyArray; +} + interface EmptyStagedScanInput { readonly directory: string; readonly isJsonMode: boolean; @@ -71,6 +90,36 @@ interface EmptyStagedScanInput { readonly startTime: number; } +interface ScanStagedProjectInput { + readonly input: RunStagedInspectInput; + readonly projectRun: StagedProjectRun; + readonly snapshotDirectory: string; + readonly stagedLineRanges: Awaited>; + readonly isMultiProject: boolean; +} + +interface RunMaterializedStagedInspectInput { + readonly input: RunStagedInspectInput; + readonly projectScans: ReadonlyArray; + readonly snapshot: Awaited>; + readonly stagedFileCount: number; + readonly stagedLineRanges: Awaited>; + readonly emptyScanInput: EmptyStagedScanInput; +} + +interface PrintStagedBatchResultsInput { + readonly input: RunStagedInspectInput; + readonly completedScans: ReadonlyArray; + readonly elapsedMilliseconds: number; + readonly isMultiProject: boolean; +} + +interface FinalizeStagedBatchInput { + readonly input: RunStagedInspectInput; + readonly completedScans: ReadonlyArray; + readonly skippedProjects: ReadonlyArray; +} + const reportEmptyStagedScan = ( input: EmptyStagedScanInput, reason: string, @@ -98,6 +147,57 @@ const reportEmptyStagedScan = ( logger.dim(reason); }; +const resolveStagedProjectScanContext = async ({ + scanTarget, + rootDirectory, + projectDirectory, + isProjectExplicitlySelected, +}: ResolveStagedProjectScanContextInput): Promise => { + const projectScan = await resolveProjectScan(scanTarget, projectDirectory); + const treeRelativeDirectory = resolveProjectRelativeDirectory( + rootDirectory, + projectScan.directory, + ); + if (treeRelativeDirectory === null) { + if (isProjectExplicitlySelected) { + throw new CliInputError( + `Project "${toRelativePath(projectDirectory, rootDirectory)}" is outside ${rootDirectory}, so it holds none of the staged files. Run --staged from a directory that contains the project.`, + ); + } + return null; + } + return { + projectDirectory, + scanDirectory: projectScan.directory, + treeRelativeDirectory, + projectConfig: projectScan.config, + projectConfigSourceDirectory: projectScan.configSourceDirectory, + }; +}; + +const collectStagedProjectScanContexts = async ({ + scanTarget, + rootDirectory, + projectDirectories, + isProjectExplicitlySelected, +}: CollectStagedProjectScanContextsInput): Promise => { + const projectScanContexts: StagedProjectScanContext[] = []; + const seenScanDirectories = new Set(); + for (const projectDirectory of projectDirectories) { + const projectScanContext = await resolveStagedProjectScanContext({ + scanTarget, + rootDirectory, + projectDirectory, + isProjectExplicitlySelected, + }); + if (projectScanContext === null) continue; + if (seenScanDirectories.has(projectScanContext.scanDirectory)) continue; + seenScanDirectories.add(projectScanContext.scanDirectory); + projectScanContexts.push(projectScanContext); + } + return projectScanContexts; +}; + const resolveStagedProjectScanContexts = async ( input: RunStagedInspectInput, ): Promise => { @@ -114,54 +214,24 @@ const resolveStagedProjectScanContexts = async ( projectFlag: flags.project, configProjects: configProjectsApply ? userConfig?.projects : undefined, }); - const projectScanContexts: StagedProjectScanContext[] = []; - const seenScanDirectories = new Set(); - - for (const projectDirectory of projectDirectories) { - const projectScan = await resolveProjectScan(scanTarget, projectDirectory); - const treeRelativeDirectory = resolveProjectRelativeDirectory( - rootDirectory, - projectScan.directory, - ); - if (treeRelativeDirectory === null) { - if (flags.project) { - throw new CliInputError( - `Project "${toRelativePath(projectDirectory, rootDirectory)}" is outside ${rootDirectory}, so it holds none of the staged files. Run --staged from a directory that contains the project.`, - ); - } - continue; - } - if (seenScanDirectories.has(projectScan.directory)) continue; - seenScanDirectories.add(projectScan.directory); - projectScanContexts.push({ - projectDirectory, - scanDirectory: projectScan.directory, - treeRelativeDirectory, - projectConfig: projectScan.config, - projectConfigSourceDirectory: projectScan.configSourceDirectory, - }); - } + const projectScanContexts = await collectStagedProjectScanContexts({ + scanTarget, + rootDirectory, + projectDirectories, + isProjectExplicitlySelected: Boolean(flags.project), + }); if (projectScanContexts.length > 0) return projectScanContexts; logger.warn(`No configured project is inside ${rootDirectory}. ${STAGED_PROJECT_FALLBACK_HINT}`); logger.break(); - const rootProjectScan = await resolveProjectScan(scanTarget, rootDirectory); - const treeRelativeDirectory = resolveProjectRelativeDirectory( + const rootProjectScanContext = await resolveStagedProjectScanContext({ + scanTarget, rootDirectory, - rootProjectScan.directory, - ); - return treeRelativeDirectory === null - ? [] - : [ - { - projectDirectory: rootDirectory, - scanDirectory: rootProjectScan.directory, - treeRelativeDirectory, - projectConfig: rootProjectScan.config, - projectConfigSourceDirectory: rootProjectScan.configSourceDirectory, - }, - ]; + projectDirectory: rootDirectory, + isProjectExplicitlySelected: false, + }); + return rootProjectScanContext === null ? [] : [rootProjectScanContext]; }; const assignStagedFilesToProjects = ( @@ -209,8 +279,215 @@ const collectConfigSubdirectories = ( return [...configSubdirectories]; }; +const buildStagedProjectRuns = ( + rootDirectory: string, + projectScans: ReadonlyArray, + materializedStagedFiles: ReadonlySet, +): StagedProjectRun[] => + projectScans + .map((projectScan) => ({ + projectScan, + includePaths: resolveProjectSourceFilePaths( + rootDirectory, + projectScan.scanDirectory, + projectScan.stagedFiles.filter((stagedFile) => materializedStagedFiles.has(stagedFile)), + ), + })) + .filter((projectRun) => projectRun.includePaths.length > 0); + +const scanStagedProject = async ({ + input, + projectRun, + snapshotDirectory, + stagedLineRanges, + isMultiProject, +}: ScanStagedProjectInput): Promise< + ProjectScanOutcome +> => { + const { projectScan, includePaths } = projectRun; + if ( + input.scanDeadlineEpochMs !== undefined && + remainingDeadlineBudgetMs(input.scanDeadlineEpochMs) === 0 + ) { + return { + status: "skipped", + value: { directory: projectScan.scanDirectory, reason: "max-duration" }, + }; + } + + const rootDirectory = input.scanTarget.resolvedDirectory; + const projectTempDirectory = path.join(snapshotDirectory, projectScan.treeRelativeDirectory); + const scanResult = await input.inspectProject(projectTempDirectory, { + ...input.scanOptions, + deadlineEpochMs: input.scanDeadlineEpochMs, + includePaths: [...includePaths], + configOverride: projectScan.projectConfig, + configSourceDirectory: projectScan.projectConfigSourceDirectory ?? undefined, + changedLineRanges: + stagedLineRanges === null + ? undefined + : resolveProjectChangedLineRanges( + rootDirectory, + projectScan.scanDirectory, + stagedLineRanges, + ), + suppressRendering: isMultiProject, + concurrentScan: isMultiProject, + }); + const diagnostics = scanResult.diagnostics.map((diagnostic) => ({ + ...diagnostic, + filePath: path.isAbsolute(diagnostic.filePath) + ? diagnostic.filePath.replaceAll(projectTempDirectory, () => projectScan.scanDirectory) + : diagnostic.filePath, + })); + return { + status: "completed", + value: { + directory: projectScan.scanDirectory, + result: { + ...scanResult, + diagnostics, + project: { ...scanResult.project, rootDirectory: projectScan.scanDirectory }, + }, + config: projectScan.projectConfig, + }, + }; +}; + +const reportUnmaterializedStagedFiles = ( + unmaterializedFileCount: number, + stagedFileCount: number, + isQuiet: boolean, +): void => { + if (unmaterializedFileCount === 0 || isQuiet) return; + const stagedFileLabel = `staged file${stagedFileCount === 1 ? "" : "s"}`; + logger.warn( + `Skipped ${unmaterializedFileCount} of ${stagedFileCount} ${stagedFileLabel}; they could not be snapshotted from the index.`, + ); + logger.break(); +}; + +const printStagedBatchResults = async ({ + input, + completedScans, + elapsedMilliseconds, + isMultiProject, +}: PrintStagedBatchResultsInput): Promise => { + const { flags, scanOptions } = input; + const outputSurface = scanOptions.outputSurface ?? "cli"; + if (!input.isQuiet && isMultiProject && completedScans.length > 0) { + await Effect.runPromise( + printCompletedScansHeadless({ + categoryFilters: input.categoryFilters, + completedScans, + elapsedMilliseconds, + noScoreMessage: "Score unavailable.", + outputDirectory: flags.outputDir, + outputSurface, + projectName: path.basename(input.scanTarget.resolvedDirectory), + verbose: Boolean(flags.verbose), + }), + ); + } + if (!flags.outputDir || !isMultiProject || !input.isQuiet) return; + await Effect.runPromise( + printDiagnosticsDump( + filterDiagnosticsByCategories( + filterScansForSurface(completedScans, outputSurface), + input.categoryFilters, + ), + flags.outputDir, + false, + "stderr", + ), + ); +}; + +const finalizeStagedBatch = ({ + input, + completedScans, + skippedProjects, +}: FinalizeStagedBatchInput): void => { + const { flags, scanTarget } = input; + finalizeCliScans({ + completedScans, + skippedProjects, + mode: "staged", + diff: null, + baselineIntended: false, + isJsonMode: input.isJsonMode, + isScoreOnly: input.isScoreOnly, + flags, + categoryFilters: input.categoryFilters, + userConfig: scanTarget.userConfig, + resolvedDirectory: scanTarget.resolvedDirectory, + startTime: input.startTime, + }); +}; + +const runMaterializedStagedInspect = async ({ + input, + projectScans, + snapshot, + stagedFileCount, + stagedLineRanges, + emptyScanInput, +}: RunMaterializedStagedInspectInput): Promise => { + const { scanTarget, scanOptions } = input; + const resolvedDirectory = scanTarget.resolvedDirectory; + const projectRuns = buildStagedProjectRuns( + resolvedDirectory, + projectScans, + new Set(snapshot.stagedFiles), + ); + if (projectRuns.length === 0) { + reportEmptyStagedScan( + emptyScanInput, + `Could not read any of the ${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"} out of the index, so nothing was scanned. An unusually large staged file is the usual cause.`, + "warn", + ); + return; + } + + reportUnmaterializedStagedFiles( + snapshot.unmaterializedFiles.length, + stagedFileCount, + input.isQuiet, + ); + + const isMultiProject = projectRuns.length > 1; + const stagedBatch = await runProjectScanBatch({ + projects: projectRuns, + isQuiet: input.isQuiet, + isSilent: scanOptions.silent === true, + scanProject: (projectRun) => + scanStagedProject({ + input, + projectRun, + snapshotDirectory: snapshot.tempDirectory, + stagedLineRanges, + isMultiProject, + }), + }); + const completedScans = stagedBatch.completedScans; + const skippedProjects = stagedBatch.skippedScans; + reportSkippedProjects({ skippedProjects, isQuiet: input.isQuiet }); + await printStagedBatchResults({ + input, + completedScans, + elapsedMilliseconds: stagedBatch.elapsedMilliseconds, + isMultiProject, + }); + + finalizeStagedBatch({ + input, + completedScans, + skippedProjects, + }); +}; + export const runStagedInspect = async (input: RunStagedInspectInput): Promise => { - const { flags, scanTarget, scanOptions } = input; + const { flags, scanTarget } = input; const resolvedDirectory = scanTarget.resolvedDirectory; const emptyScanInput: EmptyStagedScanInput = { directory: resolvedDirectory, @@ -272,139 +549,13 @@ export const runStagedInspect = async (input: RunStagedInspectInput): Promise ({ - projectScan, - includePaths: resolveProjectSourceFilePaths( - resolvedDirectory, - projectScan.scanDirectory, - projectScan.stagedFiles.filter((stagedFile) => materializedStagedFiles.has(stagedFile)), - ), - })) - .filter((projectRun) => projectRun.includePaths.length > 0); - const isMultiProject = stagedProjectRuns.length > 1; - if (stagedProjectRuns.length === 0) { - reportEmptyStagedScan( - emptyScanInput, - `Could not read any of the ${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"} out of the index, so nothing was scanned. An unusually large staged file is the usual cause.`, - "warn", - ); - return; - } - if (snapshot.unmaterializedFiles.length > 0 && !input.isQuiet) { - const stagedFileLabel = `staged file${stagedFileCount === 1 ? "" : "s"}`; - logger.warn( - `Skipped ${snapshot.unmaterializedFiles.length} of ${stagedFileCount} ${stagedFileLabel}; they could not be snapshotted from the index.`, - ); - logger.break(); - } - - const scanStagedProject = async ( - projectRun: (typeof stagedProjectRuns)[number], - ): Promise> => { - const { projectScan, includePaths } = projectRun; - if ( - input.scanDeadlineEpochMs !== undefined && - remainingDeadlineBudgetMs(input.scanDeadlineEpochMs) === 0 - ) { - return { - status: "skipped", - value: { directory: projectScan.scanDirectory, reason: "max-duration" }, - }; - } - const projectTempDirectory = path.join( - snapshot.tempDirectory, - projectScan.treeRelativeDirectory, - ); - const scanResult = await input.inspectProject(projectTempDirectory, { - ...scanOptions, - deadlineEpochMs: input.scanDeadlineEpochMs, - includePaths: [...includePaths], - configOverride: projectScan.projectConfig, - configSourceDirectory: projectScan.projectConfigSourceDirectory ?? undefined, - changedLineRanges: - stagedLineRanges === null - ? undefined - : resolveProjectChangedLineRanges( - resolvedDirectory, - projectScan.scanDirectory, - stagedLineRanges, - ), - suppressRendering: isMultiProject, - concurrentScan: isMultiProject, - }); - const diagnostics = scanResult.diagnostics.map((diagnostic) => ({ - ...diagnostic, - filePath: path.isAbsolute(diagnostic.filePath) - ? diagnostic.filePath.replaceAll(projectTempDirectory, () => projectScan.scanDirectory) - : diagnostic.filePath, - })); - return { - status: "completed", - value: { - directory: projectScan.scanDirectory, - result: { - ...scanResult, - diagnostics, - project: { ...scanResult.project, rootDirectory: projectScan.scanDirectory }, - }, - config: projectScan.projectConfig, - }, - }; - }; - - const stagedBatch = await runProjectScanBatch({ - projects: stagedProjectRuns, - isQuiet: input.isQuiet, - isSilent: scanOptions.silent === true, - scanProject: scanStagedProject, - }); - const completedScans = stagedBatch.completedScans; - const skippedProjects = stagedBatch.skippedScans; - reportSkippedProjects({ skippedProjects, isQuiet: input.isQuiet }); - - if (!input.isQuiet && isMultiProject && completedScans.length > 0) { - await Effect.runPromise( - printCompletedScansHeadless({ - categoryFilters: input.categoryFilters, - completedScans, - elapsedMilliseconds: stagedBatch.elapsedMilliseconds, - noScoreMessage: "Score unavailable.", - outputDirectory: flags.outputDir, - outputSurface: scanOptions.outputSurface ?? "cli", - projectName: path.basename(resolvedDirectory), - verbose: Boolean(flags.verbose), - }), - ); - } - if (flags.outputDir && isMultiProject && input.isQuiet) { - await Effect.runPromise( - printDiagnosticsDump( - filterDiagnosticsByCategories( - filterScansForSurface(completedScans, scanOptions.outputSurface ?? "cli"), - input.categoryFilters, - ), - flags.outputDir, - false, - "stderr", - ), - ); - } - - finalizeCliScans({ - completedScans, - skippedProjects, - mode: "staged", - diff: null, - baselineIntended: false, - isJsonMode: input.isJsonMode, - isScoreOnly: input.isScoreOnly, - flags, - categoryFilters: input.categoryFilters, - userConfig: scanTarget.userConfig, - resolvedDirectory, - startTime: input.startTime, + await runMaterializedStagedInspect({ + input, + projectScans: stagedProjectScans, + snapshot, + stagedFileCount, + stagedLineRanges, + emptyScanInput, }); } finally { snapshot.cleanup(); diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts b/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts index 30c456dd4..bf297655e 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache-payload.ts @@ -36,6 +36,11 @@ export interface CachedScanPayload { readonly manifestContentHash?: string | null; } +interface CachedScanPayloadFieldValidator { + readonly key: keyof CachedScanPayload; + readonly isValid: (value: unknown) => boolean; +} + const decodeDiagnostic = Schema.decodeUnknownSync(DiagnosticSchema); const isStringArray = (value: unknown): value is string[] => @@ -44,6 +49,12 @@ const isStringArray = (value: unknown): value is string[] => const isNullableString = (value: unknown): value is string | null => value === null || typeof value === "string"; +const isBoolean = (value: unknown): value is boolean => typeof value === "boolean"; + +const isNumber = (value: unknown): value is number => typeof value === "number"; + +const isString = (value: unknown): value is string => typeof value === "string"; + const isDiagnosticArray = (value: unknown): value is Diagnostic[] => { if (!Array.isArray(value)) return false; try { @@ -82,38 +93,48 @@ const isSuppressedRuleCountArray = (value: unknown): value is SuppressedRuleCoun typeof entry.count === "number", ); +const REQUIRED_CACHED_SCAN_PAYLOAD_FIELDS: ReadonlyArray = [ + { key: "diagnostics", isValid: isDiagnosticArray }, + { key: "score", isValid: isScoreResult }, + { key: "project", isValid: isProjectInfo }, + { key: "userConfig", isValid: (value) => value === null || isRecord(value) }, + { key: "didLintFail", isValid: isBoolean }, + { key: "lintFailureReason", isValid: isNullableString }, + { key: "lintPartialFailures", isValid: isStringArray }, + { key: "didDeadCodeFail", isValid: isBoolean }, + { key: "deadCodeFailureReason", isValid: isNullableString }, + { key: "deadCodeOverlapped", isValid: isBoolean }, + { key: "directory", isValid: isString }, + { key: "scannedFileCount", isValid: isNumber }, + { key: "scannedFilePaths", isValid: isStringArray }, + { key: "scanElapsedMilliseconds", isValid: isNumber }, + { key: "lintFailureReasonKind", isValid: isNullableString }, + { key: "supplyChainOverlapTimedOut", isValid: isBoolean }, +]; + +const OPTIONAL_CACHED_SCAN_PAYLOAD_FIELDS: ReadonlyArray = [ + { key: "analyzedFiles", isValid: isStringArray }, + { key: "baselineDelta", isValid: isBaselineDelta }, + { key: "scanConcurrency", isValid: isNumber }, + { key: "securityScanFailed", isValid: isBoolean }, + { key: "securityScanFailureReason", isValid: isNullableString }, + { key: "suppressedRuleCounts", isValid: isSuppressedRuleCountArray }, + { key: "manifestContentHash", isValid: isNullableString }, +]; + +const hasValidRequiredCachedScanPayloadFields = (value: Record): boolean => + REQUIRED_CACHED_SCAN_PAYLOAD_FIELDS.every((field) => field.isValid(value[field.key])); + +const hasValidOptionalCachedScanPayloadFields = (value: Record): boolean => + OPTIONAL_CACHED_SCAN_PAYLOAD_FIELDS.every( + (field) => value[field.key] === undefined || field.isValid(value[field.key]), + ); + const isCachedScanPayload = (value: unknown): value is CachedScanPayload => { - if ( - !isRecord(value) || - !isDiagnosticArray(value.diagnostics) || - !isScoreResult(value.score) || - !isProjectInfo(value.project) || - !(value.userConfig === null || isRecord(value.userConfig)) || - typeof value.didLintFail !== "boolean" || - !isNullableString(value.lintFailureReason) || - !isStringArray(value.lintPartialFailures) || - typeof value.didDeadCodeFail !== "boolean" || - !isNullableString(value.deadCodeFailureReason) || - typeof value.deadCodeOverlapped !== "boolean" || - typeof value.directory !== "string" || - typeof value.scannedFileCount !== "number" || - !isStringArray(value.scannedFilePaths) || - (value.analyzedFiles !== undefined && !isStringArray(value.analyzedFiles)) || - typeof value.scanElapsedMilliseconds !== "number" || - (value.baselineDelta !== undefined && !isBaselineDelta(value.baselineDelta)) || - !(value.lintFailureReasonKind === null || typeof value.lintFailureReasonKind === "string") || - (value.scanConcurrency !== undefined && typeof value.scanConcurrency !== "number") || - typeof value.supplyChainOverlapTimedOut !== "boolean" || - (value.securityScanFailed !== undefined && typeof value.securityScanFailed !== "boolean") || - (value.securityScanFailureReason !== undefined && - !isNullableString(value.securityScanFailureReason)) || - (value.suppressedRuleCounts !== undefined && - !isSuppressedRuleCountArray(value.suppressedRuleCounts)) || - (value.manifestContentHash !== undefined && !isNullableString(value.manifestContentHash)) - ) { - return false; - } - return true; + if (!isRecord(value)) return false; + return ( + hasValidRequiredCachedScanPayloadFields(value) && hasValidOptionalCachedScanPayloadFields(value) + ); }; export const decodeCachedScanPayload = (value: unknown): CachedScanPayload | null => diff --git a/packages/react-doctor/tests/node-support-metadata.test.ts b/packages/react-doctor/tests/node-support-metadata.test.ts index ee9426b66..412283cbd 100644 --- a/packages/react-doctor/tests/node-support-metadata.test.ts +++ b/packages/react-doctor/tests/node-support-metadata.test.ts @@ -28,7 +28,7 @@ const packageManifests: PackageManifestExpectation[] = [ { packagePath: "package.json", shouldDependOnPlatformNodeShared: false, - shouldDependOnEffect: false, + shouldDependOnEffect: true, }, { packagePath: "packages/api/package.json", From 4afa48093de3ede30a94c59c2d9929a1c5180d0c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 7 Aug 2026 18:01:28 +0000 Subject: [PATCH 09/17] refactor(core): deepen Effect runtime boundaries --- packages/core/src/calculate-score.ts | 134 ++------- packages/core/src/editor-scan.ts | 258 ++++++++++-------- packages/core/src/request-score.ts | 136 +++++++++ packages/core/src/services/score.ts | 25 +- packages/core/src/types/index.ts | 8 +- packages/core/src/types/run-inspect.ts | 3 +- packages/core/src/types/score.ts | 21 ++ packages/core/tests/calculate-score.test.ts | 11 +- packages/core/tests/editor-scan.test.ts | 37 +++ packages/core/tests/services/score.test.ts | 33 ++- .../tests/inspect-surface-filter.test.ts | 16 +- 11 files changed, 421 insertions(+), 261 deletions(-) create mode 100644 packages/core/src/request-score.ts create mode 100644 packages/core/tests/editor-scan.test.ts diff --git a/packages/core/src/calculate-score.ts b/packages/core/src/calculate-score.ts index e91c31850..43e3069cd 100644 --- a/packages/core/src/calculate-score.ts +++ b/packages/core/src/calculate-score.ts @@ -1,121 +1,21 @@ -import { gzipSync } from "node:zlib"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FETCH_TIMEOUT_MS, SCORE_API_URL } from "./constants.js"; -import type { Diagnostic, ProjectInfo, ScoreResult } from "./types/index.js"; -import { redactSensitiveText } from "./utils/redact-sensitive-text.js"; -import { scrubSensitivePaths } from "./utils/scrub-sensitive-paths.js"; +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type { CalculateScoreOptions, Diagnostic, ScoreResult } from "./types/index.js"; +import { requestScore } from "./request-score.js"; -// Score API response shape, including the optional per-rule `priority`/`tier` -// payload. `Schema.Struct` ignores unknown fields, so extra keys (e.g. -// `stored`) pass through harmlessly. -const RulePrioritySchema = Schema.Struct({ - priority: Schema.NullOr(Schema.Number), - tier: Schema.Literals(["P0", "P1", "P2", "P3"]), -}); +export type { CalculateScoreOptions, ScoreRequestMetadata } from "./types/score.js"; -const ScoreApiResponseSchema = Schema.Struct({ - score: Schema.Number, - label: Schema.String, - rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema)), -}); - -// Decode the score API response; any shape mismatch drops the whole result to -// null, so a malformed payload simply falls back to "no score" (and severity -// ordering at render time) rather than throwing. -const parseScoreResult = (value: unknown): ScoreResult | null => - Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value)); - -const sanitizeScoreDiagnostics = ( - diagnostics: Diagnostic[], -): Omit[] => - diagnostics.map(({ filePath, fileContext: _fileContext, fixGroupId: _fixGroupId, ...rest }) => ({ - ...rest, - filePath: redactSensitiveText(scrubSensitivePaths(filePath)), - })); - -const isAbortError = (error: unknown): boolean => - error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"); - -const describeFailure = (error: unknown): string => { - if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1000}s`; - if (error instanceof Error && error.message) return error.message; - return String(error); -}; - -export interface CalculateScoreOptions { - /** Marks the run as CI-originated. */ - isCi?: boolean; - metadata?: ScoreRequestMetadata; -} - -export interface ScoreRequestMetadata { - repo?: string; - sha?: string; - framework?: ProjectInfo["framework"]; - reactVersion?: string; - sourceFileCount?: number; - defaultBranch?: string; - doctorVersion?: string; - runId?: string; - githubEventName?: string; - githubActorAssociation?: string; - githubViewerPermission?: string; -} - -export const calculateScore = async ( +export const calculateScore = ( diagnostics: Diagnostic[], options: CalculateScoreOptions = {}, -): Promise => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - const requestUrl = options.isCi ? `${SCORE_API_URL}?ci=1` : SCORE_API_URL; - - try { - const requestBody = JSON.stringify({ - diagnostics: sanitizeScoreDiagnostics(diagnostics), - ...(options.metadata?.repo ? { repo: options.metadata.repo } : {}), - ...(options.metadata?.sha ? { sha: options.metadata.sha } : {}), - ...(options.metadata?.framework ? { framework: options.metadata.framework } : {}), - ...(options.metadata?.reactVersion ? { reactVersion: options.metadata.reactVersion } : {}), - ...(typeof options.metadata?.sourceFileCount === "number" - ? { sourceFileCount: options.metadata.sourceFileCount } - : {}), - ...(options.metadata?.defaultBranch ? { defaultBranch: options.metadata.defaultBranch } : {}), - ...(options.metadata?.doctorVersion ? { doctorVersion: options.metadata.doctorVersion } : {}), - ...(options.metadata?.runId ? { runId: options.metadata.runId } : {}), - ...(options.metadata?.githubEventName - ? { githubEventName: options.metadata.githubEventName } - : {}), - ...(options.metadata?.githubActorAssociation - ? { githubActorAssociation: options.metadata.githubActorAssociation } - : {}), - ...(options.metadata?.githubViewerPermission - ? { githubViewerPermission: options.metadata.githubViewerPermission } - : {}), - }); - const compressedBody = gzipSync(requestBody); - - const response = await fetch(requestUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Encoding": "gzip", - }, - body: compressedBody, - signal: controller.signal, - }); - - if (!response.ok) { - console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText}`); - return null; - } - - return parseScoreResult(await response.json()); - } catch (error) { - console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)})`); - return null; - } finally { - clearTimeout(timeoutId); - } -}; +): Promise => + Effect.runPromise( + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return yield* requestScore(httpClient, diagnostics, options); + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + ), + ); diff --git a/packages/core/src/editor-scan.ts b/packages/core/src/editor-scan.ts index 581e41e31..240b5142e 100644 --- a/packages/core/src/editor-scan.ts +++ b/packages/core/src/editor-scan.ts @@ -4,14 +4,13 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js"; import { MIN_SCAN_CONCURRENCY } from "./constants.js"; -import { isReactDoctorError } from "./errors.js"; -import { loadConfigWithSource } from "./load-config.js"; +import { isReactDoctorError, type ReactDoctorError } from "./errors.js"; import { layerOtlp } from "./observability.js"; import { isProjectDiscoveryError } from "./project-info/index.js"; import { OxlintConcurrency } from "./refs.js"; -import { runInspect } from "./run-inspect.js"; +import { runInspect, type InspectOutput } from "./run-inspect.js"; import { messageFromUnknown } from "./utils/message-from-unknown.js"; -import { Config } from "./services/config.js"; +import { Config, type ResolvedConfig } from "./services/config.js"; import { DeadCode } from "./services/dead-code.js"; import { Files } from "./services/files.js"; import { Git } from "./services/git.js"; @@ -75,6 +74,16 @@ export interface EditorScanResult { readonly error: string | null; } +interface EditorScanSettings { + readonly lint: boolean; + readonly runDeadCode: boolean; + readonly respectInlineDisables: boolean; + readonly adoptExistingLintConfig: boolean; + readonly customRulesOnly: boolean; + readonly ignoredTags: ReadonlySet; + readonly warnings: boolean; +} + const skippedResult = (directory: string): EditorScanResult => ({ ok: true, skipped: true, @@ -98,124 +107,133 @@ const isGracefulSkip = (error: unknown): boolean => { return false; }; -export const runEditorScan = async (input: EditorScanInput): Promise => { - const hasConfigOverride = input.configOverride !== undefined; - const loaded = hasConfigOverride ? null : await loadConfigWithSource(input.directory); - const userConfig = hasConfigOverride ? (input.configOverride ?? null) : (loaded?.config ?? null); - - const lint = input.lint ?? userConfig?.lint ?? true; - const runDeadCode = input.runDeadCode ?? false; - const respectInlineDisables = - input.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true; - const adoptExistingLintConfig = userConfig?.adoptExistingLintConfig ?? true; - const customRulesOnly = userConfig?.customRulesOnly ?? false; - const ignoredTags = new Set(userConfig?.ignore?.tags ?? []); - // Editors surface warnings (like ESLint in-editor); the CLI's - // hide-warnings-by-default is a terminal-output choice. An explicit - // `warnings: false` in config still wins for users who opt out globally. - const warnings = userConfig?.warnings ?? true; - - const configLayer = hasConfigOverride - ? Config.layerOf({ - config: userConfig, - resolvedDirectory: input.directory, - configSourceDirectory: input.configSourceDirectory ?? null, - }) - : Config.layerNode; - - const layers = Layer.mergeAll( - Project.layerNode, - configLayer, - Files.layerNode, - // Editor scans never need git metadata; the null snapshot avoids a - // subprocess spawn per keystroke. - Git.layerOf({}), - lint ? Linter.layerOxlint : Linter.layerOf([]), - LintPartialFailures.layerLive, - runDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]), - Progress.layerNoop, - Reporter.layerNoop, - // No hosted score lookup in the editor — keep scans offline and fast. - Score.layerOf(null), - // No Socket.dev network lookups in the editor either — keep scans offline. - SupplyChain.layerOf([]), - // Pin oxlint to a single subprocess per editor scan. Core lints in - // parallel by default (auto-detect cores), but the language server - // already parallelizes at the scheduler level — one oxlint process per - // file/chunk, many chunks running at once. Letting each individual scan - // also fan out across cores would oversubscribe the machine (scheduler - // concurrency × per-scan workers). Serial here preserves the "one oxlint - // process per runEditorScan" invariant the chunked scheduler is built on. - Layer.succeed(OxlintConcurrency, MIN_SCAN_CONCURRENCY), - ); - - const program = runInspect({ - directory: input.directory, - includePaths: input.includePaths ?? [], - customRulesOnly, - respectInlineDisables, - adoptExistingLintConfig, - ignoredTags, - ...(input.nodeBinaryPath !== undefined ? { nodeBinaryPath: input.nodeBinaryPath } : {}), - runDeadCode, - warnings, - isCi: false, - resolveLocalGithubViewerPermission: false, - skipExplicitIncludePathFilter: true, - // `layerOtlp` is a no-op unless REACT_DOCTOR_OTLP_ENDPOINT + - // REACT_DOCTOR_OTLP_AUTH_HEADER are set; when they are, every - // `runInspect` / `Service.method` span from this scan is exported, - // giving editor scans the same observability as the CLI. - }).pipe( - // Parent span for the whole editor scan (grandparent of `runInspect`'s - // own span). Placed before the provides so the OTLP tracer layer is in - // scope; only booleans are attributed so no scanned path leaks. - Effect.withSpan("runEditorScan", { - attributes: { "editor.lint": lint, "editor.runDeadCode": runDeadCode }, - }), - Effect.provide(layers), - Effect.provide(layerOtlp), - ); - - const exit = await Effect.runPromiseExit(program); - - if (Exit.isSuccess(exit)) { - const output = exit.value; - return { - ok: true, - skipped: false, - diagnostics: [...output.diagnostics], - project: output.project, - resolvedDirectory: output.resolvedDirectory, - didLintFail: output.didLintFail, - lintFailureReason: output.lintFailureReason, - didDeadCodeFail: output.didDeadCodeFail, - deadCodeFailureReason: output.deadCodeFailureReason, - lintPartialFailures: [...output.lintPartialFailures], - error: null, - }; - } +const resolveBooleanSetting = ( + override: boolean | undefined, + configured: boolean | undefined, + defaultValue: boolean, +): boolean => { + if (override !== undefined) return override; + if (configured !== undefined) return configured; + return defaultValue; +}; + +const resolveEditorScanSettings = ( + input: EditorScanInput, + userConfig: ReactDoctorConfig | null, +): EditorScanSettings => ({ + lint: resolveBooleanSetting(input.lint, userConfig?.lint, true), + runDeadCode: resolveBooleanSetting(input.runDeadCode, undefined, false), + respectInlineDisables: resolveBooleanSetting( + input.respectInlineDisables, + userConfig?.respectInlineDisables, + true, + ), + adoptExistingLintConfig: resolveBooleanSetting( + undefined, + userConfig?.adoptExistingLintConfig, + true, + ), + customRulesOnly: resolveBooleanSetting(undefined, userConfig?.customRulesOnly, false), + ignoredTags: new Set(userConfig?.ignore?.tags), + warnings: resolveBooleanSetting(undefined, userConfig?.warnings, true), +}); + +const editorScanResultFromOutput = (output: InspectOutput): EditorScanResult => ({ + ok: true, + skipped: false, + diagnostics: [...output.diagnostics], + project: output.project, + resolvedDirectory: output.resolvedDirectory, + didLintFail: output.didLintFail, + lintFailureReason: output.lintFailureReason, + didDeadCodeFail: output.didDeadCodeFail, + deadCodeFailureReason: output.deadCodeFailureReason, + lintPartialFailures: [...output.lintPartialFailures], + error: null, +}); + +const failedEditorScanResult = (input: EditorScanInput, error: unknown): EditorScanResult => ({ + ok: false, + skipped: false, + diagnostics: [], + project: null, + resolvedDirectory: input.directory, + didLintFail: false, + lintFailureReason: null, + didDeadCodeFail: false, + deadCodeFailureReason: null, + lintPartialFailures: [], + error: messageFromUnknown(error), +}); - // `squash` collapses the cause to a single value: the first typed - // `Effect.fail(ReactDoctorError)`, else the first defect (e.g. a - // synchronous `PackageJsonNotFoundError` thrown during discovery). +const editorScanResultFromExit = ( + input: EditorScanInput, + exit: Exit.Exit, +): EditorScanResult => { + if (Exit.isSuccess(exit)) return editorScanResultFromOutput(exit.value); const error: unknown = Cause.squash(exit.cause); + if (isGracefulSkip(error)) return skippedResult(input.directory); + return failedEditorScanResult(input, error); +}; - if (isGracefulSkip(error)) { - return skippedResult(input.directory); +const resolveEditorConfig = (input: EditorScanInput): Effect.Effect => { + if (input.configOverride !== undefined) { + return Effect.succeed({ + config: input.configOverride, + resolvedDirectory: input.directory, + configSourceDirectory: input.configSourceDirectory ?? null, + }); } - - return { - ok: false, - skipped: false, - diagnostics: [], - project: null, - resolvedDirectory: input.directory, - didLintFail: false, - lintFailureReason: null, - didDeadCodeFail: false, - deadCodeFailureReason: null, - lintPartialFailures: [], - error: messageFromUnknown(error), - }; + return Effect.gen(function* () { + const configService = yield* Config; + return yield* configService.resolve(input.directory); + }).pipe(Effect.provide(Config.layerNode)); }; + +const runEditorScanEffect = (input: EditorScanInput): Effect.Effect => + Effect.gen(function* () { + const resolvedConfig = yield* resolveEditorConfig(input); + const userConfig = resolvedConfig.config; + const settings = resolveEditorScanSettings(input, userConfig); + yield* Effect.annotateCurrentSpan({ + "editor.lint": settings.lint, + "editor.runDeadCode": settings.runDeadCode, + }); + + const layers = Layer.mergeAll( + Project.layerNode, + Config.layerOf(resolvedConfig), + Files.layerNode, + Git.layerOf({}), + settings.lint ? Linter.layerOxlint : Linter.layerOf([]), + LintPartialFailures.layerLive, + settings.runDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]), + Progress.layerNoop, + Reporter.layerNoop, + Score.layerOf(null), + SupplyChain.layerOf([]), + Layer.succeed(OxlintConcurrency, MIN_SCAN_CONCURRENCY), + ); + + const exit = yield* Effect.exit( + runInspect({ + directory: input.directory, + includePaths: input.includePaths ?? [], + customRulesOnly: settings.customRulesOnly, + respectInlineDisables: settings.respectInlineDisables, + adoptExistingLintConfig: settings.adoptExistingLintConfig, + ignoredTags: settings.ignoredTags, + ...(input.nodeBinaryPath !== undefined ? { nodeBinaryPath: input.nodeBinaryPath } : {}), + runDeadCode: settings.runDeadCode, + warnings: settings.warnings, + isCi: false, + resolveLocalGithubViewerPermission: false, + skipExplicitIncludePathFilter: true, + }).pipe(Effect.provide(layers)), + ); + + return editorScanResultFromExit(input, exit); + }).pipe(Effect.withSpan("runEditorScan")); + +export const runEditorScan = (input: EditorScanInput): Promise => + Effect.runPromise(runEditorScanEffect(input).pipe(Effect.provide(layerOtlp))); diff --git a/packages/core/src/request-score.ts b/packages/core/src/request-score.ts new file mode 100644 index 000000000..591fde07e --- /dev/null +++ b/packages/core/src/request-score.ts @@ -0,0 +1,136 @@ +import { STATUS_CODES } from "node:http"; +import { gzipSync } from "node:zlib"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { FETCH_TIMEOUT_MS, SCORE_API_URL } from "./constants.js"; +import type { + CalculateScoreOptions, + Diagnostic, + ScoreRequestMetadata, + ScoreResult, +} from "./types/index.js"; +import { messageFromUnknown } from "./utils/message-from-unknown.js"; +import { redactSensitiveText } from "./utils/redact-sensitive-text.js"; +import { scrubSensitivePaths } from "./utils/scrub-sensitive-paths.js"; + +const RulePrioritySchema = Schema.Struct({ + priority: Schema.NullOr(Schema.Number), + tier: Schema.Literals(["P0", "P1", "P2", "P3"]), +}); + +const ScoreApiResponseSchema = Schema.Struct({ + score: Schema.Number, + label: Schema.String, + rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema)), +}); + +const EMPTY_SCORE_REQUEST_METADATA: ScoreRequestMetadata = {}; + +const parseScoreResult = (value: unknown): ScoreResult | null => + Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value)); + +const sanitizeScoreDiagnostics = ( + diagnostics: ReadonlyArray, +): ReadonlyArray> => + diagnostics.map(({ filePath, fileContext: _fileContext, fixGroupId: _fixGroupId, ...rest }) => ({ + ...rest, + filePath: redactSensitiveText(scrubSensitivePaths(filePath)), + })); + +const isPresentMetadataValue = (value: unknown): boolean => { + if (value === undefined || value === null) return false; + return value !== ""; +}; + +const buildScoreRequestMetadata = ( + metadata: ScoreRequestMetadata | undefined, +): Record => { + const resolvedMetadata = metadata || EMPTY_SCORE_REQUEST_METADATA; + return Object.fromEntries( + Object.entries({ + repo: resolvedMetadata.repo, + sha: resolvedMetadata.sha, + framework: resolvedMetadata.framework, + reactVersion: resolvedMetadata.reactVersion, + sourceFileCount: + typeof resolvedMetadata.sourceFileCount === "number" + ? resolvedMetadata.sourceFileCount + : undefined, + defaultBranch: resolvedMetadata.defaultBranch, + doctorVersion: resolvedMetadata.doctorVersion, + runId: resolvedMetadata.runId, + githubEventName: resolvedMetadata.githubEventName, + githubActorAssociation: resolvedMetadata.githubActorAssociation, + githubViewerPermission: resolvedMetadata.githubViewerPermission, + }).filter(([, value]) => isPresentMetadataValue(value)), + ); +}; + +const buildScoreRequestBody = ( + diagnostics: ReadonlyArray, + options: CalculateScoreOptions, +): Uint8Array => { + return gzipSync( + JSON.stringify({ + diagnostics: sanitizeScoreDiagnostics(diagnostics), + ...buildScoreRequestMetadata(options.metadata), + }), + ); +}; + +const warnScoreFailure = (detail: string): Effect.Effect => + Console.warn(`[react-doctor] Score API unreachable (${detail})`).pipe(Effect.as(null)); + +const describeScoreFailure = (error: unknown): string => { + if (HttpClientError.isHttpClientError(error) && error.reason.cause !== undefined) { + return messageFromUnknown(error.reason.cause); + } + return messageFromUnknown(error); +}; + +const describeHttpStatus = (status: number): string => { + const statusText = STATUS_CODES[status]; + if (statusText === undefined) return String(status); + return `${status} ${statusText}`; +}; + +export const requestScore = ( + httpClient: HttpClient.HttpClient, + diagnostics: ReadonlyArray, + options: CalculateScoreOptions = {}, +): Effect.Effect => { + const requestUrl = options.isCi ? `${SCORE_API_URL}?ci=1` : SCORE_API_URL; + const request = Effect.gen(function* () { + const compressedBody = yield* Effect.try({ + try: () => buildScoreRequestBody(diagnostics, options), + catch: (cause) => cause, + }); + const response = yield* httpClient.execute( + HttpClientRequest.post(requestUrl).pipe( + HttpClientRequest.bodyUint8Array(compressedBody, "application/json"), + HttpClientRequest.setHeader("Content-Encoding", "gzip"), + ), + ); + if (response.status < 200 || response.status >= 300) { + yield* Console.warn( + `[react-doctor] Score API returned ${describeHttpStatus(response.status)}`, + ); + return null; + } + return parseScoreResult(yield* response.json); + }).pipe(Effect.timeoutOption(FETCH_TIMEOUT_MS)); + + return Effect.matchEffect(request, { + onFailure: (error) => warnScoreFailure(describeScoreFailure(error)), + onSuccess: (result) => + Option.match(result, { + onNone: () => warnScoreFailure(`timed out after ${FETCH_TIMEOUT_MS / 1000}s`), + onSome: Effect.succeed, + }), + }); +}; diff --git a/packages/core/src/services/score.ts b/packages/core/src/services/score.ts index 8969ec92c..55e33a8b2 100644 --- a/packages/core/src/services/score.ts +++ b/packages/core/src/services/score.ts @@ -1,8 +1,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { Diagnostic, ScoreResult } from "../types/index.js"; -import { calculateScore, type ScoreRequestMetadata } from "../calculate-score.js"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Diagnostic, ScoreRequestMetadata, ScoreResult } from "../types/index.js"; +import { requestScore } from "../request-score.js"; interface ComputeInput { readonly diagnostics: ReadonlyArray; @@ -29,19 +31,20 @@ export class Score extends Context.Service< * cost when no tracing layer is provided; surfaces in * `Otlp.layerJson` traces when one is. */ - static readonly layerHttp = Layer.succeed( + static readonly layerHttp = Layer.effect( Score, - Score.of({ - compute: Effect.fn("Score.compute")(function* (input: ComputeInput) { - return yield* Effect.promise(() => - calculateScore([...input.diagnostics], { + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return Score.of({ + compute: Effect.fn("Score.compute")(function* (input: ComputeInput) { + return yield* requestScore(httpClient, input.diagnostics, { isCi: input.isCi, metadata: input.metadata, - }).catch((): ScoreResult | null => null), - ); - }), + }); + }), + }); }), - ); + ).pipe(Layer.provide(FetchHttpClient.layer)); static readonly layerOf = (result: ScoreResult | null): Layer.Layer => Layer.succeed( diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a5e17e878..9d1b7f96e 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -63,4 +63,10 @@ export type { PromptMultiselectChoiceState, PromptMultiselectContext } from "./p // `core/src/project-info/rn-metadata.ts`; // rule-side consumers import from the plugin package directly. // See that file for the duplication rationale. -export type { ScoreResult, RulePriority, RuleTier } from "./score.js"; +export type { + CalculateScoreOptions, + ScoreRequestMetadata, + ScoreResult, + RulePriority, + RuleTier, +} from "./score.js"; diff --git a/packages/core/src/types/run-inspect.ts b/packages/core/src/types/run-inspect.ts index ad3402d10..f90f31599 100644 --- a/packages/core/src/types/run-inspect.ts +++ b/packages/core/src/types/run-inspect.ts @@ -1,10 +1,9 @@ import * as Effect from "effect/Effect"; -import type { ScoreRequestMetadata } from "../calculate-score.js"; import type { OxlintUnavailable, ReactDoctorErrorReason } from "../errors.js"; import type { DiagnosticSurface, ReactDoctorConfig } from "./config.js"; import type { Diagnostic, SuppressedRuleCount } from "./diagnostic.js"; import type { ProjectInfo } from "./project-info.js"; -import type { ScoreResult } from "./score.js"; +import type { ScoreRequestMetadata, ScoreResult } from "./score.js"; export interface InspectInput { readonly directory: string; diff --git a/packages/core/src/types/score.ts b/packages/core/src/types/score.ts index 157316bf9..09d806d2a 100644 --- a/packages/core/src/types/score.ts +++ b/packages/core/src/types/score.ts @@ -1,5 +1,26 @@ +import type { ProjectInfo } from "./project-info.js"; + export type RuleTier = "P0" | "P1" | "P2" | "P3"; +export interface CalculateScoreOptions { + isCi?: boolean; + metadata?: ScoreRequestMetadata; +} + +export interface ScoreRequestMetadata { + repo?: string; + sha?: string; + framework?: ProjectInfo["framework"]; + reactVersion?: string; + sourceFileCount?: number; + defaultBranch?: string; + doctorVersion?: string; + runId?: string; + githubEventName?: string; + githubActorAssociation?: string; + githubViewerPermission?: string; +} + export interface RulePriority { // Intrinsic end-user value of the rule, 0-100, or null when the rule isn't // ranked yet. Higher = more worth fixing first. diff --git a/packages/core/tests/calculate-score.test.ts b/packages/core/tests/calculate-score.test.ts index a860256c7..d4fd9c1dc 100644 --- a/packages/core/tests/calculate-score.test.ts +++ b/packages/core/tests/calculate-score.test.ts @@ -38,7 +38,9 @@ describe("calculateScore", () => { const result = await calculateScore(sampleDiagnostics); expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + "[react-doctor] Score API unreachable (network unavailable)", + ); }); it("returns null and logs a warning when the API responds non-2xx", async () => { @@ -50,7 +52,9 @@ describe("calculateScore", () => { const result = await calculateScore(sampleDiagnostics); expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + "[react-doctor] Score API returned 500 Internal Server Error", + ); }); it("parses a well-formed API response and sends score metadata", async () => { @@ -81,8 +85,7 @@ describe("calculateScore", () => { }); expect(result).toEqual(apiScoreResponse); - const headerRecord = capturedHeaders as Record | undefined; - expect(headerRecord?.["Content-Encoding"]).toBe("gzip"); + expect(new Headers(capturedHeaders).get("Content-Encoding")).toBe("gzip"); const compressedBytes = capturedBody as Uint8Array; expect(compressedBytes).toBeInstanceOf(Uint8Array); const decompressedJson = gunzipSync(compressedBytes).toString("utf8"); diff --git a/packages/core/tests/editor-scan.test.ts b/packages/core/tests/editor-scan.test.ts new file mode 100644 index 000000000..cf2f719b7 --- /dev/null +++ b/packages/core/tests/editor-scan.test.ts @@ -0,0 +1,37 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { runEditorScan } from "@react-doctor/core"; + +describe("runEditorScan", () => { + it("resolves config rootDir inside the Effect-owned scan lifecycle", async () => { + const wrapperDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-editor-scan-")); + const projectDirectory = path.join(wrapperDirectory, "app"); + fs.mkdirSync(path.join(projectDirectory, "src"), { recursive: true }); + fs.writeFileSync( + path.join(wrapperDirectory, "doctor.config.json"), + JSON.stringify({ rootDir: "app", lint: false }), + ); + fs.writeFileSync( + path.join(projectDirectory, "package.json"), + JSON.stringify({ name: "editor-project", dependencies: { react: "^19.0.0" } }), + ); + fs.writeFileSync( + path.join(projectDirectory, "src", "index.tsx"), + "export const App = () => null;", + ); + + try { + const result = await runEditorScan({ directory: wrapperDirectory }); + + expect(result.ok).toBe(true); + expect(result.skipped).toBe(false); + expect(result.resolvedDirectory).toBe(projectDirectory); + expect(result.project?.projectName).toBe("editor-project"); + expect(result.diagnostics).toHaveLength(0); + } finally { + fs.rmSync(wrapperDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/tests/services/score.test.ts b/packages/core/tests/services/score.test.ts index 41e9ad6b7..262ce4d52 100644 --- a/packages/core/tests/services/score.test.ts +++ b/packages/core/tests/services/score.test.ts @@ -1,7 +1,12 @@ import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vite-plus/test"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { Score } from "../../src/services/score.js"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("Score.layerOf", () => { it("returns the supplied ScoreResult", async () => { const result = await Effect.runPromise( @@ -23,3 +28,29 @@ describe("Score.layerOf", () => { expect(result).toBeNull(); }); }); + +describe("Score.layerHttp", () => { + it("runs score requests through the Effect HTTP client", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + score: 91, + label: "Excellent", + }), + ), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const score = yield* Score; + return yield* score.compute({ diagnostics: [] }); + }).pipe( + Effect.provide(Score.layerHttp), + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + ), + ); + + expect(result).toEqual({ score: 91, label: "Excellent" }); + }); +}); diff --git a/packages/react-doctor/tests/inspect-surface-filter.test.ts b/packages/react-doctor/tests/inspect-surface-filter.test.ts index 442bfdb6d..5e1675b4d 100644 --- a/packages/react-doctor/tests/inspect-surface-filter.test.ts +++ b/packages/react-doctor/tests/inspect-surface-filter.test.ts @@ -54,10 +54,15 @@ interface CapturedFetchCall { body: string; } -const decodeRequestBody = (init: RequestInit | undefined): string => { - const rawBody = init?.body; +const decodeRequestBody = async ( + input: string | URL | Request, + init: RequestInit | undefined, +): Promise => { + const request = input instanceof Request ? input : undefined; + const rawBody = request?.body ? new Uint8Array(await request.clone().arrayBuffer()) : init?.body; if (!rawBody) return ""; - const encoding = new Headers(init?.headers ?? {}).get("content-encoding")?.toLowerCase() ?? ""; + const headers = request?.headers ?? new Headers(init?.headers ?? {}); + const encoding = headers.get("content-encoding")?.toLowerCase() ?? ""; if (rawBody instanceof Uint8Array) { return encoding === "gzip" ? gunzipSync(rawBody).toString("utf8") @@ -70,8 +75,9 @@ const stubScoreFetchAndCapture = (): { captured: CapturedFetchCall[] } => { const captured: CapturedFetchCall[] = []; vi.stubGlobal( "fetch", - vi.fn(async (url: string, init?: RequestInit) => { - captured.push({ url, body: decodeRequestBody(init) }); + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + captured.push({ url, body: await decodeRequestBody(input, init) }); return new Response(JSON.stringify({ score: 90, label: "Great" }), { status: 200, headers: { "Content-Type": "application/json" }, From 78ac0ecf9932cfbaa2cdce38cb3e809a0b93016f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 7 Aug 2026 23:02:18 +0000 Subject: [PATCH 10/17] refactor(core): align score transport with conventions --- packages/core/src/constants.ts | 3 +++ packages/core/src/request-score.ts | 16 +++++++++++++--- packages/core/src/types/run-inspect.ts | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 58fcaa313..5e05f4adc 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -37,6 +37,9 @@ export const DEFAULT_SHOW_WARNINGS = true; export const MILLISECONDS_PER_SECOND = 1000; +export const HTTP_SUCCESS_STATUS_CODE_MIN = 200; +export const HTTP_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 300; + // Upper bound for the `react:` capability loop in // `buildCapabilities`, clamping an unvalidated package.json spec like // `"react": "20240101"` that would otherwise drive the loop to tens of diff --git a/packages/core/src/request-score.ts b/packages/core/src/request-score.ts index 591fde07e..6051f4025 100644 --- a/packages/core/src/request-score.ts +++ b/packages/core/src/request-score.ts @@ -7,7 +7,13 @@ import * as Schema from "effect/Schema"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { FETCH_TIMEOUT_MS, SCORE_API_URL } from "./constants.js"; +import { + FETCH_TIMEOUT_MS, + HTTP_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE, + HTTP_SUCCESS_STATUS_CODE_MIN, + MILLISECONDS_PER_SECOND, + SCORE_API_URL, +} from "./constants.js"; import type { CalculateScoreOptions, Diagnostic, @@ -116,7 +122,10 @@ export const requestScore = ( HttpClientRequest.setHeader("Content-Encoding", "gzip"), ), ); - if (response.status < 200 || response.status >= 300) { + if ( + response.status < HTTP_SUCCESS_STATUS_CODE_MIN || + response.status >= HTTP_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE + ) { yield* Console.warn( `[react-doctor] Score API returned ${describeHttpStatus(response.status)}`, ); @@ -129,7 +138,8 @@ export const requestScore = ( onFailure: (error) => warnScoreFailure(describeScoreFailure(error)), onSuccess: (result) => Option.match(result, { - onNone: () => warnScoreFailure(`timed out after ${FETCH_TIMEOUT_MS / 1000}s`), + onNone: () => + warnScoreFailure(`timed out after ${FETCH_TIMEOUT_MS / MILLISECONDS_PER_SECOND}s`), onSome: Effect.succeed, }), }); diff --git a/packages/core/src/types/run-inspect.ts b/packages/core/src/types/run-inspect.ts index f90f31599..28eab9451 100644 --- a/packages/core/src/types/run-inspect.ts +++ b/packages/core/src/types/run-inspect.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect"; +import type * as Effect from "effect/Effect"; import type { OxlintUnavailable, ReactDoctorErrorReason } from "../errors.js"; import type { DiagnosticSurface, ReactDoctorConfig } from "./config.js"; import type { Diagnostic, SuppressedRuleCount } from "./diagnostic.js"; From 1840313922918429f6bdde14b2fc878f98ec7362 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 7 Aug 2026 23:46:20 +0000 Subject: [PATCH 11/17] refactor(core): modernize effect v4 usage --- .changeset/tall-adults-refuse.md | 2 +- AGENTS.md | 2 +- package.json | 2 +- packages/api/package.json | 2 +- packages/core/package.json | 6 +- packages/core/src/services/config.ts | 6 +- packages/core/src/services/project.ts | 13 +- packages/core/src/services/score.ts | 21 +-- .../tests/install-react-doctor.test.ts | 2 +- .../tests/node-support-metadata.test.ts | 12 +- pnpm-lock.yaml | 168 ++++++++++-------- 11 files changed, 115 insertions(+), 121 deletions(-) diff --git a/.changeset/tall-adults-refuse.md b/.changeset/tall-adults-refuse.md index c3c3adb5e..59b4ed37a 100644 --- a/.changeset/tall-adults-refuse.md +++ b/.changeset/tall-adults-refuse.md @@ -6,4 +6,4 @@ "oxlint-plugin-react-doctor": patch --- -Harden scan orchestration and cache persistence, simplify package boundaries and analyzers, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals. +Harden scan orchestration and cache persistence, modernize the Effect runtime, simplify package boundaries and analyzers, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals. diff --git a/AGENTS.md b/AGENTS.md index 13a9afbb0..44f05e80f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ packages/ ## Effect v4 Conventions -Built on `effect@4.0.0-beta.70`. See `tmp/effect/.patterns/effect.md` (cloned reference) +Built on `effect@4.0.0-beta.102`. See `tmp/effect/.patterns/effect.md` (cloned reference) and `~/Developer/react-doctor-evals/src/` (the application that pioneered these patterns for this codebase) for canonical examples. diff --git a/package.json b/package.json index a3ccb6759..bc2ee87ef 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "fn-mining": "tsx scripts/fn-mining/run-fn-mining.ts" }, "dependencies": { - "effect": "4.0.0-beta.70" + "effect": "4.0.0-beta.102" }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", diff --git a/packages/api/package.json b/packages/api/package.json index 9403ce223..27a793e57 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@react-doctor/core": "workspace:*", - "effect": "4.0.0-beta.70" + "effect": "4.0.0-beta.102" }, "devDependencies": { "@types/node": "^25.6.0" diff --git a/packages/core/package.json b/packages/core/package.json index ba4d237ac..051f8715c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,12 +23,12 @@ }, "dependencies": { "@astrojs/compiler": "^4.0.0", - "@effect/platform-node-shared": "4.0.0-beta.70", + "@effect/platform-node-shared": "4.0.0-beta.102", "@jridgewell/trace-mapping": "^0.3.31", "browserslist": "^4.28.1", "confbox": "^0.2.4", "deslop-js": "workspace:*", - "effect": "4.0.0-beta.70", + "effect": "4.0.0-beta.102", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "oxc-resolver": "^11.24.2", @@ -39,7 +39,7 @@ "typescript": ">=5.0.4 <7" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.70", + "@effect/vitest": "4.0.0-beta.102", "@types/node": "^25.6.0", "@types/picomatch": "^4.0.3", "@types/semver": "^7.7.1" diff --git a/packages/core/src/services/config.ts b/packages/core/src/services/config.ts index 670ecedec..210dc0563 100644 --- a/packages/core/src/services/config.ts +++ b/packages/core/src/services/config.ts @@ -47,11 +47,7 @@ export class Config extends Context.Service< }), }); return Config.of({ - // `Effect.fn("Config.resolve")` adds an OTel-compatible span - // name; canonical eval pattern. - resolve: Effect.fn("Config.resolve")(function* (directory: string) { - return yield* Cache.get(cache, directory); - }), + resolve: Effect.fn("Config.resolve")((directory: string) => Cache.get(cache, directory)), }); }), ); diff --git a/packages/core/src/services/project.ts b/packages/core/src/services/project.ts index 3547980fc..876ee5217 100644 --- a/packages/core/src/services/project.ts +++ b/packages/core/src/services/project.ts @@ -54,18 +54,13 @@ export class Project extends Context.Service< static readonly layerNode = Layer.succeed( Project, Project.of({ - // `Effect.fn("Project.discover")` adds an OTel-compatible span - // name to every invocation. Canonical eval pattern from - // `react-doctor-evals/src/Runner.ts` / `ReactDoctorV2.ts` — - // free observability with zero runtime cost when no tracer - // layer is provided. - discover: Effect.fn("Project.discover")(function* (input: ProjectDiscoveryInput) { - return yield* Effect.try({ + discover: Effect.fn("Project.discover")((input: ProjectDiscoveryInput) => + Effect.try({ try: () => discoverProjectSync(input.directory, { sourceFileCount: input.sourceFileCount }), catch: (cause) => translateProjectInfoError(cause, input.directory), - }); - }), + }), + ), }), ); diff --git a/packages/core/src/services/score.ts b/packages/core/src/services/score.ts index 55e33a8b2..85f300ebd 100644 --- a/packages/core/src/services/score.ts +++ b/packages/core/src/services/score.ts @@ -18,30 +18,17 @@ export class Score extends Context.Service< readonly compute: (input: ComputeInput) => Effect.Effect; } >()("react-doctor/Score") { - /** - * Hosted score API. Network failures collapse to `null` rather than - * propagating through the error channel — score isn't load-bearing - * for the linter contract, and the renderer distinguishes "user - * opted out" from "we tried and failed" via a separate `noScoreMessage` - * the caller picks based on `--no-score`. - * - * `Effect.fn("Score.compute")` wraps the body so the effect carries - * an OpenTelemetry-compatible span name out of the box (canonical - * eval pattern from `react-doctor-evals/src/Runner.ts`). Zero runtime - * cost when no tracing layer is provided; surfaces in - * `Otlp.layerJson` traces when one is. - */ static readonly layerHttp = Layer.effect( Score, Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; return Score.of({ - compute: Effect.fn("Score.compute")(function* (input: ComputeInput) { - return yield* requestScore(httpClient, input.diagnostics, { + compute: Effect.fn("Score.compute")((input: ComputeInput) => + requestScore(httpClient, input.diagnostics, { isCi: input.isCi, metadata: input.metadata, - }); - }), + }), + ), }); }), ).pipe(Layer.provide(FetchHttpClient.layer)); diff --git a/packages/react-doctor/tests/install-react-doctor.test.ts b/packages/react-doctor/tests/install-react-doctor.test.ts index dddf16c21..52ac3a8c7 100644 --- a/packages/react-doctor/tests/install-react-doctor.test.ts +++ b/packages/react-doctor/tests/install-react-doctor.test.ts @@ -407,7 +407,7 @@ describe("runInstallReactDoctor", () => { installDependencyRunner: (input) => { dependencyInstallCalls.push(input); throw Object.assign(new Error("pnpm add failed"), { - stderr: "ERR_PNPM_TRUST_DOWNGRADE High-risk trust downgrade for effect@4.0.0-beta.70", + stderr: "ERR_PNPM_TRUST_DOWNGRADE High-risk trust downgrade for effect@4.0.0-beta.102", }); }, }); diff --git a/packages/react-doctor/tests/node-support-metadata.test.ts b/packages/react-doctor/tests/node-support-metadata.test.ts index 412283cbd..a749d8a38 100644 --- a/packages/react-doctor/tests/node-support-metadata.test.ts +++ b/packages/react-doctor/tests/node-support-metadata.test.ts @@ -66,6 +66,8 @@ const packageBuildConfigs = [ ]; describe("Node support metadata", () => { + const effectVersion = readPackageJson("package.json").dependencies?.effect; + it("declares the same Node range across package manifests", () => { for (const { packagePath } of packageManifests) { const packageJson = readPackageJson(packagePath); @@ -73,7 +75,9 @@ describe("Node support metadata", () => { } }); - it("does not depend on the Undici-backed Effect platform package", () => { + it("keeps the Effect package family aligned without the Undici-backed platform", () => { + expect(effectVersion).toBeDefined(); + for (const { packagePath, shouldDependOnPlatformNodeShared, @@ -85,14 +89,12 @@ describe("Node support metadata", () => { expect(dependencies["@effect/platform-node"], packagePath).toBeUndefined(); expect(devDependencies["@effect/platform-node"], packagePath).toBeUndefined(); - const expectedSharedDependency = shouldDependOnPlatformNodeShared - ? "4.0.0-beta.70" - : undefined; + const expectedSharedDependency = shouldDependOnPlatformNodeShared ? effectVersion : undefined; expect(dependencies["@effect/platform-node-shared"], packagePath).toBe( expectedSharedDependency, ); - const expectedEffectDependency = shouldDependOnEffect ? "4.0.0-beta.70" : undefined; + const expectedEffectDependency = shouldDependOnEffect ? effectVersion : undefined; expect(dependencies.effect, packagePath).toBe(expectedEffectDependency); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a101027cc..b53f077f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: effect: - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70 + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102 devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 @@ -62,8 +62,8 @@ importers: specifier: workspace:* version: link:../core effect: - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70 + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102 devDependencies: '@types/node': specifier: ^25.6.0 @@ -75,8 +75,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 '@effect/platform-node-shared': - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70(effect@4.0.0-beta.70) + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(effect@4.0.0-beta.102) '@jridgewell/trace-mapping': specifier: ^0.3.31 version: 0.3.31 @@ -90,8 +90,8 @@ importers: specifier: workspace:* version: link:../deslop-js effect: - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70 + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102 eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) @@ -118,8 +118,8 @@ importers: version: 6.0.3 devDependencies: '@effect/vitest': - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70(effect@4.0.0-beta.70)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))) '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -188,7 +188,7 @@ importers: dependencies: '@daytona/sdk': specifier: ^0.196.0 - version: 0.196.0(ws@8.20.0) + version: 0.196.0(ws@8.21.1) p-limit: specifier: ^3.1.0 version: 3.1.0 @@ -636,16 +636,16 @@ packages: '@daytona/toolbox-api-client@0.196.0': resolution: {integrity: sha512-QjLGLr7NzD8+3SLwUGpIhroi8rdhP6VlUHYUD+mlR09xHHWlZA9QidbaoDyGqhLlH04PthXNB3nuX5UjXNAf4g==} - '@effect/platform-node-shared@4.0.0-beta.70': - resolution: {integrity: sha512-3VXuL63IDmq13We+ApRKn2JW3Rb9g5gj1YEmfb8u2b73norur1VsIJ/pRE4qjShevg19dQYi2JsLawSZ6gApug==} + '@effect/platform-node-shared@4.0.0-beta.102': + resolution: {integrity: sha512-gVd793I72MrkX4dXo7eYtRKfNj0RW4eMRfVEKEJI16h2+mBDCzQ+gqMrog2hSTHQnaIbvbYShNQ4TVGuRCYZeQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.70 + effect: ^4.0.0-beta.102 - '@effect/vitest@4.0.0-beta.70': - resolution: {integrity: sha512-XDteNN0xfOgoMauAVoN5iylxVgEjp7kFsGFq18tZ5XYjek0eOZa0nOoes5s7Bs71VvwjnCeCbFMD7IhxswEt8A==} + '@effect/vitest@4.0.0-beta.102': + resolution: {integrity: sha512-4dipFAYG6imOzrY3zy3BgzCJkbb9xESyzUef0WSx8bsK0/SpITqbJpdwKeOyDWLZ+rimjua8IbTFMAde865pIQ==} peerDependencies: - effect: ^4.0.0-beta.70 + effect: ^4.0.0-beta.102 vitest: ^3.0.0 || ^4.0.0 '@emnapi/core@1.10.0': @@ -1244,33 +1244,33 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} cpu: [x64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} cpu: [arm64] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} cpu: [arm] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} cpu: [x64] os: [linux] - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} cpu: [x64] os: [win32] @@ -3023,8 +3023,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - effect@4.0.0-beta.70: - resolution: {integrity: sha512-8AwGTRiNriirHGEYHrOS0E9fzdhIqCdZjiHP1YXmNo2UyPGS43ILsymsSHT7V0DJS+8dvlKq2RxnrDBUhDNZHg==} + effect@4.0.0-beta.102: + resolution: {integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w==} electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} @@ -3175,8 +3175,8 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - fast-check@4.8.0: - resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: @@ -3764,15 +3764,15 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@2.0.1: - resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} - multipasta@0.2.7: - resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -4229,8 +4229,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - toml@4.1.1: - resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} engines: {node: '>=20'} totalist@3.0.1: @@ -4297,8 +4297,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true vite-plus@0.1.20: @@ -4470,6 +4470,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4984,7 +4996,7 @@ snapshots: - debug - supports-color - '@daytona/sdk@0.196.0(ws@8.20.0)': + '@daytona/sdk@0.196.0(ws@8.21.1)': dependencies: '@aws-sdk/client-s3': 3.1085.0 '@aws-sdk/lib-storage': 3.1085.0(@aws-sdk/client-s3@3.1085.0) @@ -5005,7 +5017,7 @@ snapshots: expand-tilde: 2.0.2 fast-glob: 3.3.3 form-data: 4.0.6 - isomorphic-ws: 5.0.0(ws@8.20.0) + isomorphic-ws: 5.0.0(ws@8.21.1) pathe: 2.0.3 shell-quote: 1.10.0 tar: 7.5.20 @@ -5021,18 +5033,18 @@ snapshots: - debug - supports-color - '@effect/platform-node-shared@4.0.0-beta.70(effect@4.0.0-beta.70)': + '@effect/platform-node-shared@4.0.0-beta.102(effect@4.0.0-beta.102)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.70 - ws: 8.20.0 + effect: 4.0.0-beta.102 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/vitest@4.0.0-beta.70(effect@4.0.0-beta.70)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)))': dependencies: - effect: 4.0.0-beta.70 + effect: 4.0.0-beta.102 vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) '@emnapi/core@1.10.0': @@ -5428,22 +5440,22 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': optional: true - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -6859,17 +6871,17 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - effect@4.0.0-beta.70: + effect@4.0.0-beta.102: dependencies: '@standard-schema/spec': 1.1.0 - fast-check: 4.8.0 + fast-check: 4.9.0 find-my-way-ts: 0.1.6 ini: 7.0.0 kubernetes-types: 1.30.0 - msgpackr: 2.0.1 - multipasta: 0.2.7 - toml: 4.1.1 - uuid: 14.0.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 yaml: 2.9.0 electron-to-chromium@1.5.286: {} @@ -7103,7 +7115,7 @@ snapshots: extendable-error@0.1.7: {} - fast-check@4.8.0: + fast-check@4.9.0: dependencies: pure-rand: 8.4.0 @@ -7372,9 +7384,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.20.0): + isomorphic-ws@5.0.0(ws@8.21.1): dependencies: - ws: 8.20.0 + ws: 8.21.1 jiti@2.7.0: {} @@ -7600,23 +7612,23 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.3: + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@2.0.1: + msgpackr@2.0.5: optionalDependencies: - msgpackr-extract: 3.0.3 + msgpackr-extract: 3.0.4 - multipasta@0.2.7: {} + multipasta@0.2.8: {} nanoid@3.3.11: {} @@ -8175,7 +8187,7 @@ snapshots: dependencies: is-number: 7.0.0 - toml@4.1.1: {} + toml@4.3.0: {} totalist@3.0.1: {} @@ -8230,7 +8242,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.0: {} + uuid@14.0.1: {} vite-plus@0.1.20(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0): dependencies: @@ -8435,6 +8447,8 @@ snapshots: ws@8.20.0: {} + ws@8.21.1: {} + y18n@5.0.8: {} yallist@3.1.1: {} From 659d917dcef3483005569ee7d148480a86143219 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 00:26:17 +0000 Subject: [PATCH 12/17] refactor: remove dead internals --- AGENTS.md | 4 +- packages/core/src/check-reduced-motion.ts | 12 +- packages/core/src/constants.ts | 18 --- packages/core/src/run-inspect.ts | 14 +- .../oxlint/resolve-use-call-binding.ts | 19 +-- packages/core/src/services/linter.ts | 24 --- packages/core/src/services/reporter.ts | 36 ----- .../src/utils/get-import-module-source.ts | 12 ++ .../utils/read-system-concurrency-facts.ts | 14 ++ .../utils/resolve-auto-scan-concurrency.ts | 23 +-- .../utils/resolve-dead-code-concurrency.ts | 23 +-- packages/core/tests/run-inspect.test.ts | 38 +++++ packages/core/tests/services/linter.test.ts | 78 ---------- packages/core/tests/services/reporter.test.ts | 44 ------ packages/deslop-js/src/collect/parse.ts | 125 +++------------ packages/deslop-js/src/report/packages.ts | 10 +- packages/deslop-js/src/types.ts | 14 +- .../collect-override-mappings-from-record.ts | 2 +- .../utils/parse-pnpm-workspace-overrides.ts | 10 +- .../scripts/generate-rule-registry.mjs | 19 --- ...ler-destructure-method.regressions.test.ts | 30 ---- .../react-compiler-destructure-method.ts | 146 ------------------ .../src/cli/utils/action-upgrade-prompt.ts | 4 +- .../src/cli/utils/ci-prompt-decision.ts | 4 +- .../src/cli/utils/cli-lifecycle.ts | 37 ----- .../src/cli/utils/is-ci-environment.ts | 6 - .../src/cli/utils/onboarding-pacing.ts | 7 - .../src/cli/utils/onboarding-state.ts | 4 +- .../src/cli/utils/prompt-install-setup.ts | 8 +- .../src/cli/utils/wrap-indented-text.ts | 11 -- .../react-doctor/tests/action-upgrade.test.ts | 4 +- .../tests/ci-prompt-decision.test.ts | 9 +- .../react-doctor/tests/cli-lifecycle.test.ts | 19 +-- .../tests/is-ci-environment.test.ts | 12 +- .../tests/onboarding-pacing.test.ts | 24 --- .../tests/onboarding-state.test.ts | 11 +- .../tests/prompt-install-setup.test.ts | 12 +- .../regressions/architecture-rules.test.ts | 94 ----------- .../tests/wrap-indented-text.test.ts | 23 --- scripts/convert-node-imports.mjs | 131 ---------------- 40 files changed, 157 insertions(+), 978 deletions(-) create mode 100644 packages/core/src/utils/get-import-module-source.ts create mode 100644 packages/core/src/utils/read-system-concurrency-facts.ts delete mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.regressions.test.ts delete mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.ts delete mode 100644 packages/react-doctor/tests/wrap-indented-text.test.ts delete mode 100644 scripts/convert-node-imports.mjs diff --git a/AGENTS.md b/AGENTS.md index 44f05e80f..382d75122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,9 +158,7 @@ for this codebase) for canonical examples. sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`). - `layerNoop` for the production layer that has void-return / discard semantics (Reporter, Progress). Analyzers (Linter, DeadCode) use `layerOf([])` instead. -- `layerComposite(backends)` for the slot a future second backend plugs into. -- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerNdjson(path)`, - `layerOra(factory)`. +- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`. ### Schemas diff --git a/packages/core/src/check-reduced-motion.ts b/packages/core/src/check-reduced-motion.ts index 8fe96c82f..37f721420 100644 --- a/packages/core/src/check-reduced-motion.ts +++ b/packages/core/src/check-reduced-motion.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor/core"; import ts from "typescript"; import type { Diagnostic } from "./types/index.js"; +import { getImportModuleSource } from "./utils/get-import-module-source.js"; import { getTypescriptScriptKind } from "./utils/get-typescript-script-kind.js"; import { unwrapTypescriptExpression } from "./utils/unwrap-typescript-expression.js"; import { walkSourceTreeFiles } from "./utils/walk-source-tree-files.js"; @@ -90,17 +91,6 @@ const classifyMotionExport = (exportName: string): MotionExpressionEvidence => ( isReducedMotionHook: exportName === REDUCED_MOTION_HOOK_EXPORT_NAME, }); -const getImportModuleSource = (node: ts.Node): string | null => { - let currentNode: ts.Node | undefined = node; - while (currentNode) { - if (ts.isImportDeclaration(currentNode) && ts.isStringLiteral(currentNode.moduleSpecifier)) { - return currentNode.moduleSpecifier.text; - } - currentNode = currentNode.parent; - } - return null; -}; - const getImportedBindingEvidence = ( declaration: ts.Declaration, typeChecker: ts.TypeChecker, diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 5e05f4adc..8e4e16670 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -627,13 +627,6 @@ export const JSX_OPENER_SCAN_MAX_LINES = 32; // Larger gaps stop being intentional suppressions and become noise. export const SUPPRESSION_NEAR_MISS_MAX_LINES = 10; -// In the default human output, show several category sections like an -// audit report, but cap each section so one noisy category does not -// bury the rest of the scan. -export const MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE = 5; - -export const MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE = 3; - // `minimumReleaseAge` in `pnpm-workspace.yaml` is denominated in // minutes. 7 days × 24 h × 60 min = 10080. Surfaced as the // recommended starting point for the supply-chain hardening check. @@ -779,17 +772,6 @@ export const CODE_FRAME_LINES_BELOW = 1; // so we fall back to the bare `file:line` reference instead. export const CODE_FRAME_MAX_LINE_LENGTH_CHARS = 200; -// When one rule hits several sites in the same file, sites whose frames -// would overlap are merged into a single spanning frame instead of -// rendering near-duplicate boxes. Two sites merge when the gap between -// their lines is within this window (the frame's own context reach), and -// a merged frame never spans more offending lines than the max below — a -// long contiguous run is split into a few bounded frames rather than one -// giant wall. -export const CODE_FRAME_BATCH_MAX_SPAN_LINES = 20; - -export const OUTPUT_DETAIL_WRAP_WIDTH_CHARS = 88; - // Typographic "measure" — the line length (in characters) we wrap // prose explanations to for comfortable reading. Kept short (well under // the terminal width) so multi-line blurbs stay easy to scan. diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index b726d5fb2..ea0c04b45 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -408,10 +408,10 @@ export const runInspect = ( // fail-open `[]` + a `timedOut` marker — the same outcome class as a Socket // outage. The deadline is measured FROM FORK (before lint), so it bounds a // hung undici socket without depending on how long lint takes. (On the rare - // timeout, a stateful `Reporter` — only `layerNdjson`, which has no in-tree - // consumer — may hold supply-chain emits from before the deadline that the - // returned `[]` omits; production `Reporter.layerNoop` makes emit a no-op, - // and the returned `diagnostics`/score only ever read the joined value.) + // timeout, a stateful reporter may hold supply-chain emits from before the + // deadline that the returned `[]` omits; production `Reporter.layerNoop` + // makes emit a no-op, and the returned diagnostics/score only read the + // joined value.) // When skipped, the fork takes the empty branch so the join below stays // unconditional (mirroring the viewer-permission fiber above). const capToDeadline = (phaseTimeoutMs: number): number => @@ -514,6 +514,9 @@ export const runInspect = ( deadCodeParseConcurrency === undefined ? scanConcurrency : Math.max(MIN_SCAN_CONCURRENCY, scanConcurrency - deadCodeParseConcurrency); + let deadCodeCacheHit: boolean | null = null; + let deadCodeSummaryCacheHits: number | null = null; + let deadCodeSummaryCacheMisses: number | null = null; // Runs either forked (overlap) or inline (sequential) with the same pipeline // + failure Ref. Building this is side-effect-free; the worker spawns only @@ -604,9 +607,6 @@ export const runInspect = ( let lintCacheTotalFileCount: number | null = null; let lintSidecarReplayedFileCount: number | null = null; let lintSidecarTotalFileCount: number | null = null; - let deadCodeCacheHit: boolean | null = null; - let deadCodeSummaryCacheHits: number | null = null; - let deadCodeSummaryCacheMisses: number | null = null; const lintFileCoverageState: { value: LintFileCoverage | null } = { value: null }; const baseLintStream = linterService diff --git a/packages/core/src/runners/oxlint/resolve-use-call-binding.ts b/packages/core/src/runners/oxlint/resolve-use-call-binding.ts index 8e2e3859f..6a56c9f99 100644 --- a/packages/core/src/runners/oxlint/resolve-use-call-binding.ts +++ b/packages/core/src/runners/oxlint/resolve-use-call-binding.ts @@ -1,4 +1,5 @@ import ts from "typescript"; +import { getImportModuleSource } from "../../utils/get-import-module-source.js"; import { getTypescriptScriptKind } from "../../utils/get-typescript-script-kind.js"; import { unwrapTypescriptExpression } from "../../utils/unwrap-typescript-expression.js"; @@ -94,17 +95,6 @@ const isReactRequireCall = (expression: ts.Expression): boolean => { ); }; -const getModuleSource = (node: ts.Node): string | null => { - let currentNode: ts.Node | undefined = node; - while (currentNode) { - if (ts.isImportDeclaration(currentNode) && ts.isStringLiteral(currentNode.moduleSpecifier)) { - return currentNode.moduleSpecifier.text; - } - currentNode = currentNode.parent; - } - return null; -}; - const getImportedName = (importSpecifier: ts.ImportSpecifier): string => importSpecifier.propertyName?.text ?? importSpecifier.name.text; @@ -392,17 +382,18 @@ const getVariableDeclarationResolution = ( const getImportResolution = (node: ts.Node, identifierName: string): BindingResolution | null => { if (ts.isImportSpecifier(node) && node.name.text === identifierName) { - return getModuleSource(node) === REACT_MODULE_SOURCE && getImportedName(node) === USE_IDENTIFIER + return getImportModuleSource(node) === REACT_MODULE_SOURCE && + getImportedName(node) === USE_IDENTIFIER ? REACT_USE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION; } if (ts.isNamespaceImport(node) && node.name.text === identifierName) { - return getModuleSource(node) === REACT_MODULE_SOURCE + return getImportModuleSource(node) === REACT_MODULE_SOURCE ? REACT_NAMESPACE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION; } if (ts.isImportClause(node) && node.name?.text === identifierName) { - return getModuleSource(node) === REACT_MODULE_SOURCE + return getImportModuleSource(node) === REACT_MODULE_SOURCE ? REACT_NAMESPACE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION; } diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index 4e9255896..2d9fe50ae 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -190,28 +190,4 @@ export class Linter extends Context.Service< run: () => Stream.fromIterable(diagnostics), }), ); - - /** - * Composite layer: runs every supplied backend in sequence and - * concatenates their diagnostic streams. Slot for a future - * second-backend integration (ESLint worker pool, sandboxed runner) - * — register an additional Linter instance and pass the array here - * without changing the orchestrator. - */ - static readonly layerComposite = ( - backends: ReadonlyArray, - ): Layer.Layer => - Layer.succeed( - Linter, - Linter.of({ - run: (input) => { - if (backends.length === 0) return Stream.empty; - let stream = backends[0].run(input); - for (let index = 1; index < backends.length; index++) { - stream = stream.pipe(Stream.concat(backends[index].run(input))); - } - return stream; - }, - }), - ); } diff --git a/packages/core/src/services/reporter.ts b/packages/core/src/services/reporter.ts index fac6e9a9a..a44e959a2 100644 --- a/packages/core/src/services/reporter.ts +++ b/packages/core/src/services/reporter.ts @@ -2,9 +2,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as fs from "node:fs"; -import * as path from "node:path"; import { Diagnostic } from "../schemas.js"; /** @@ -53,37 +50,4 @@ export class Reporter extends Context.Service< }), ), ).pipe(Layer.provideMerge(ReporterCapture.layer)); - - /** - * Append-only NDJSON reporter. Schema-encodes each diagnostic at - * the wire boundary so the eval harness reads back via the same - * `Diagnostic` schema. - */ - static readonly layerNdjson = (filePath: string): Layer.Layer => - Layer.effect( - Reporter, - Effect.acquireRelease( - Effect.sync(() => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - let handle: number | null = fs.openSync(filePath, "a"); - const encode = Schema.encodeUnknownSync(Diagnostic); - - const emit = (diagnostic: Diagnostic): Effect.Effect => - Effect.sync(() => { - if (handle === null) throw new Error("Cannot emit after Reporter.finalize"); - fs.writeSync(handle, `${JSON.stringify(encode(diagnostic))}\n`); - }); - - const finalize = Effect.sync(() => { - if (handle === null) return; - const openHandle = handle; - handle = null; - fs.closeSync(openHandle); - }); - - return Reporter.of({ emit, finalize }); - }), - (reporter) => reporter.finalize, - ), - ); } diff --git a/packages/core/src/utils/get-import-module-source.ts b/packages/core/src/utils/get-import-module-source.ts new file mode 100644 index 000000000..306eabd11 --- /dev/null +++ b/packages/core/src/utils/get-import-module-source.ts @@ -0,0 +1,12 @@ +import ts from "typescript"; + +export const getImportModuleSource = (node: ts.Node): string | null => { + let currentNode: ts.Node | undefined = node; + while (currentNode) { + if (ts.isImportDeclaration(currentNode) && ts.isStringLiteral(currentNode.moduleSpecifier)) { + return currentNode.moduleSpecifier.text; + } + currentNode = currentNode.parent; + } + return null; +}; diff --git a/packages/core/src/utils/read-system-concurrency-facts.ts b/packages/core/src/utils/read-system-concurrency-facts.ts new file mode 100644 index 000000000..cfaf5a511 --- /dev/null +++ b/packages/core/src/utils/read-system-concurrency-facts.ts @@ -0,0 +1,14 @@ +import os from "node:os"; +import { readCgroupMemoryLimitBytes } from "./read-cgroup-memory-limit-bytes.js"; + +export interface SystemConcurrencyFacts { + readonly availableCores: number; + readonly totalMemoryBytes: number; + readonly cgroupMemoryLimitBytes: number | undefined; +} + +export const readSystemConcurrencyFacts = (): SystemConcurrencyFacts => ({ + availableCores: os.availableParallelism(), + totalMemoryBytes: os.totalmem(), + cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes(), +}); diff --git a/packages/core/src/utils/resolve-auto-scan-concurrency.ts b/packages/core/src/utils/resolve-auto-scan-concurrency.ts index 3db7623d1..c6ef5687c 100644 --- a/packages/core/src/utils/resolve-auto-scan-concurrency.ts +++ b/packages/core/src/utils/resolve-auto-scan-concurrency.ts @@ -1,23 +1,10 @@ -import os from "node:os"; import { AUTO_MAX_SCAN_CONCURRENCY, PER_WORKER_MEM_BUDGET_BYTES } from "../constants.js"; -import { readCgroupMemoryLimitBytes } from "./read-cgroup-memory-limit-bytes.js"; +import { + type SystemConcurrencyFacts, + readSystemConcurrencyFacts, +} from "./read-system-concurrency-facts.js"; import { resolveScanConcurrency } from "./resolve-scan-concurrency.js"; -export interface AutoScanConcurrencyFacts { - /** `os.availableParallelism()` — already cgroup-CPU-aware on the supported Node range. */ - readonly availableCores: number; - /** `os.totalmem()` — the HOST total; floored by `cgroupMemoryLimitBytes` below. */ - readonly totalMemoryBytes: number; - /** The cgroup memory limit, or `undefined` when there is none (bare metal / dev machines). */ - readonly cgroupMemoryLimitBytes: number | undefined; -} - -const readSystemFacts = (): AutoScanConcurrencyFacts => ({ - availableCores: os.availableParallelism(), - totalMemoryBytes: os.totalmem(), - cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes(), -}); - /** * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available @@ -36,7 +23,7 @@ const readSystemFacts = (): AutoScanConcurrencyFacts => ({ * limited, and ceiling cases without mocking `os` or the filesystem. */ export const resolveAutoScanConcurrency = ( - facts: AutoScanConcurrencyFacts = readSystemFacts(), + facts: SystemConcurrencyFacts = readSystemConcurrencyFacts(), ): number => { const availableMemoryBytes = Math.min( facts.totalMemoryBytes, diff --git a/packages/core/src/utils/resolve-dead-code-concurrency.ts b/packages/core/src/utils/resolve-dead-code-concurrency.ts index db9f8c95d..872ef29ed 100644 --- a/packages/core/src/utils/resolve-dead-code-concurrency.ts +++ b/packages/core/src/utils/resolve-dead-code-concurrency.ts @@ -1,21 +1,8 @@ -import os from "node:os"; import { DEAD_CODE_WORKER_MEM_BUDGET_BYTES } from "../constants.js"; -import { readCgroupMemoryLimitBytes } from "./read-cgroup-memory-limit-bytes.js"; - -export interface DeadCodeConcurrencyFacts { - /** `os.availableParallelism()` — cgroup-CPU-aware on the supported Node range. */ - readonly availableCores: number; - /** `os.totalmem()` — host total, floored by `cgroupMemoryLimitBytes`. */ - readonly totalMemoryBytes: number; - /** The cgroup memory limit, or `undefined` on bare metal. */ - readonly cgroupMemoryLimitBytes: number | undefined; -} - -const readSystemFacts = (): DeadCodeConcurrencyFacts => ({ - availableCores: os.availableParallelism(), - totalMemoryBytes: os.totalmem(), - cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes(), -}); +import { + type SystemConcurrencyFacts, + readSystemConcurrencyFacts, +} from "./read-system-concurrency-facts.js"; /** * How many real deslop dead-code child processes may run at once, across the @@ -34,7 +21,7 @@ const readSystemFacts = (): DeadCodeConcurrencyFacts => ({ * heavier dead-code worker. `facts` is injectable for tests. */ export const resolveDeadCodeConcurrency = ( - facts: DeadCodeConcurrencyFacts = readSystemFacts(), + facts: SystemConcurrencyFacts = readSystemConcurrencyFacts(), ): number => { const availableMemoryBytes = Math.min( facts.totalMemoryBytes, diff --git a/packages/core/tests/run-inspect.test.ts b/packages/core/tests/run-inspect.test.ts index 56a1d6f55..a18f7f427 100644 --- a/packages/core/tests/run-inspect.test.ts +++ b/packages/core/tests/run-inspect.test.ts @@ -784,6 +784,44 @@ describe("runInspect — dead-code failure", () => { }); describe("runInspect — dead-code/lint overlap", () => { + it("records synchronous cache callbacks from the overlap fiber", async () => { + const deadCodeWithCacheCallbacks = Layer.mock(DeadCode, { + run: (input) => { + input.onCacheOutcome?.(true); + input.onSummaryCacheStats?.({ hits: 7, misses: 2 }); + return Stream.fromIterable([deadCodeDiagnostic]); + }, + }); + const output = await Effect.runPromise( + runInspect(baseInput).pipe( + Effect.provide( + Layer.mergeAll( + Project.layerOf(sampleProject), + Config.layerOf({ + config: null, + resolvedDirectory: "/repo", + configSourceDirectory: null, + }), + Files.layerInMemory(new Map()), + Linter.layerOf([lintDiagnostic]), + LintPartialFailures.layerLive, + deadCodeWithCacheCallbacks, + Git.layerOf({}), + Score.layerOf({ score: 85, label: "Good" }), + SupplyChain.layerOf([]), + Progress.layerNoop, + Reporter.layerNoop, + Layer.succeed(DeadCodeOverlap, "on"), + ), + ), + ), + ); + + expect(output.deadCodeCacheHit).toBe(true); + expect(output.deadCodeSummaryCacheHits).toBe(7); + expect(output.deadCodeSummaryCacheMisses).toBe(2); + }); + it("forced on: diagnostics + score identical to sequential, overlap recorded", async () => { const result = await Effect.runPromise( Effect.gen(function* () { diff --git a/packages/core/tests/services/linter.test.ts b/packages/core/tests/services/linter.test.ts index 64d7f54ac..0719a0887 100644 --- a/packages/core/tests/services/linter.test.ts +++ b/packages/core/tests/services/linter.test.ts @@ -1,6 +1,5 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; import type { Diagnostic, ProjectInfo } from "@react-doctor/core"; @@ -76,80 +75,3 @@ describe("Linter.layerOf", () => { expect(Array.from(collected)).toEqual([]); }); }); - -describe("Linter.layerComposite", () => { - it("concatenates streams from every backend in order", async () => { - const backendA = Linter.of({ - run: () => Stream.fromIterable([{ ...sampleDiagnostic, rule: "rule-from-a" }]), - }); - const backendB = Linter.of({ - run: () => Stream.fromIterable([{ ...sampleDiagnostic, rule: "rule-from-b" }]), - }); - const collected = await Effect.runPromise( - Effect.gen(function* () { - const linter = yield* Linter; - return yield* Stream.runCollect(linter.run(lintInput)); - }).pipe( - Effect.provide( - Layer.mergeAll( - Linter.layerComposite([backendA, backendB]), - LintPartialFailures.layerLive, - ), - ), - ), - ); - const rules = Array.from(collected).map((diagnostic) => diagnostic.rule); - expect(rules).toEqual(["rule-from-a", "rule-from-b"]); - }); - - it("emits empty stream when constructed with []", async () => { - const collected = await Effect.runPromise( - Effect.gen(function* () { - const linter = yield* Linter; - return yield* Stream.runCollect(linter.run(lintInput)); - }).pipe( - Effect.provide(Layer.mergeAll(Linter.layerComposite([]), LintPartialFailures.layerLive)), - ), - ); - expect(Array.from(collected)).toEqual([]); - }); - - it("shares the same LintPartialFailures Ref across all backends", async () => { - const backendA = Linter.of({ - run: () => - Stream.unwrap( - Effect.gen(function* () { - const ref = yield* LintPartialFailures; - yield* Ref.update(ref, (existing) => [...existing, "from-a"]); - return Stream.empty; - }), - ), - }); - const backendB = Linter.of({ - run: () => - Stream.unwrap( - Effect.gen(function* () { - const ref = yield* LintPartialFailures; - yield* Ref.update(ref, (existing) => [...existing, "from-b"]); - return Stream.empty; - }), - ), - }); - const failures = await Effect.runPromise( - Effect.gen(function* () { - const linter = yield* Linter; - yield* Stream.runCollect(linter.run(lintInput)); - const ref = yield* LintPartialFailures; - return yield* Ref.get(ref); - }).pipe( - Effect.provide( - Layer.mergeAll( - Linter.layerComposite([backendA, backendB]), - LintPartialFailures.layerLive, - ), - ), - ), - ); - expect(failures).toEqual(["from-a", "from-b"]); - }); -}); diff --git a/packages/core/tests/services/reporter.test.ts b/packages/core/tests/services/reporter.test.ts index 691497ad1..078941388 100644 --- a/packages/core/tests/services/reporter.test.ts +++ b/packages/core/tests/services/reporter.test.ts @@ -1,9 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { Diagnostic } from "../../src/schemas.js"; import { Reporter, ReporterCapture } from "../../src/services/reporter.js"; @@ -73,44 +70,3 @@ describe("Reporter.layerCapture", () => { expect(captured).toEqual([]); }); }); - -describe("Reporter.layerNdjson", () => { - it("owns the file handle and allows explicit finalization to be repeated", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-reporter-")); - const filePath = path.join(directory, "diagnostics.ndjson"); - try { - await Effect.runPromise( - Effect.gen(function* () { - const reporter = yield* Reporter; - yield* reporter.emit(sampleDiagnostic); - yield* reporter.finalize; - yield* reporter.finalize; - }).pipe(Effect.provide(Reporter.layerNdjson(filePath))), - ); - - expect(fs.readFileSync(filePath, "utf8").trim()).toContain('"rule":"no-danger"'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - }); - - it("closes the file handle when the layer scope ends", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-reporter-")); - const filePath = path.join(directory, "diagnostics.ndjson"); - try { - const reporter = await Effect.runPromise( - Effect.gen(function* () { - const scopedReporter = yield* Reporter; - yield* scopedReporter.emit(sampleDiagnostic); - return scopedReporter; - }).pipe(Effect.provide(Reporter.layerNdjson(filePath))), - ); - - const emitAfterScope = await Effect.runPromiseExit(reporter.emit(sampleDiagnostic)); - expect(emitAfterScope._tag).toBe("Failure"); - expect(fs.readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/deslop-js/src/collect/parse.ts b/packages/deslop-js/src/collect/parse.ts index d855d14f2..54ce9f07b 100644 --- a/packages/deslop-js/src/collect/parse.ts +++ b/packages/deslop-js/src/collect/parse.ts @@ -25,10 +25,14 @@ import type { ExportReference, ImportBinding, MemberAccess, - InlineTypeContext, - RedundantTypePatternKind, - SimplifiableExpressionKind, - SimplifiableFunctionKind, + SourceModuleAnalysis, + SourceModuleDuplicateConstantCandidate, + SourceModuleIdentityWrapper, + SourceModuleInlineTypeLiteral, + SourceModuleRedundantTypePattern, + SourceModuleSimplifiableExpression, + SourceModuleSimplifiableFunction, + SourceModuleTypeDefinitionHash, } from "../types.js"; import { getLineFromOffset, getColumnFromOffset } from "../utils/line-column.js"; import { extractDefaultExportLocalName } from "../utils/extract-default-export-local-name.js"; @@ -44,87 +48,7 @@ import { collectSimplifiableExpressions } from "../utils/collect-simplifiable-ex import { collectDuplicateConstantCandidates } from "../utils/collect-duplicate-constants.js"; import { getIdentifierName } from "../utils/oxc-ast-node.js"; -export interface ParsedRedundantTypePattern { - typeName: string; - kind: RedundantTypePatternKind; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface ParsedIdentityWrapper { - wrapperName: string; - wrappedExpression: string; - line: number; - column: number; -} - -export interface ParsedTypeDefinitionHash { - typeName: string; - structuralHash: string; - line: number; - column: number; -} - -export interface ParsedInlineTypeLiteral { - structuralHash: string; - memberCount: number; - preview: string; - context: InlineTypeContext; - nearestName?: string; - line: number; - column: number; -} - -export interface ParsedSimplifiableFunction { - kind: SimplifiableFunctionKind; - functionName?: string; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface ParsedSimplifiableExpression { - kind: SimplifiableExpressionKind; - snippet: string; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface ParsedDuplicateConstantCandidate { - constantName: string; - literalHash: string; - literalPreview: string; - line: number; - column: number; -} - -export interface ParsedSource { - imports: ImportReference[]; - exports: ExportReference[]; - memberAccesses: MemberAccess[]; - wholeObjectUses: string[]; - localIdentifierReferences: string[]; - /** - * Local names of static import bindings referenced in module-init-executed - * positions (top-level statements outside function bodies and erased TS - * type positions). Cycle detection uses this to tell an initialization- - * order hazard from a cycle whose back edges are only dereferenced later, - * inside function bodies invoked after every module has initialized. - */ - topLevelImportReferences: string[]; - referencedFilenames: string[]; - redundantTypePatterns: ParsedRedundantTypePattern[]; - identityWrappers: ParsedIdentityWrapper[]; - typeDefinitionHashes: ParsedTypeDefinitionHash[]; - inlineTypeLiterals: ParsedInlineTypeLiteral[]; - simplifiableFunctions: ParsedSimplifiableFunction[]; - simplifiableExpressions: ParsedSimplifiableExpression[]; - duplicateConstantCandidates: ParsedDuplicateConstantCandidate[]; +export interface ParsedSource extends SourceModuleAnalysis { errors: DeslopError[]; } @@ -842,9 +766,9 @@ export const parseSourceFile = (filePath: string): ParsedSource => { [], ); - const redundantTypePatterns: ParsedRedundantTypePattern[] = []; - const identityWrappers: ParsedIdentityWrapper[] = []; - const typeDefinitionHashes: ParsedTypeDefinitionHash[] = []; + const redundantTypePatterns: SourceModuleRedundantTypePattern[] = []; + const identityWrappers: SourceModuleIdentityWrapper[] = []; + const typeDefinitionHashes: SourceModuleTypeDefinitionHash[] = []; safeWalk( "collectDryPatterns", () => { @@ -865,7 +789,7 @@ export const parseSourceFile = (filePath: string): ParsedSource => { () => collectInlineTypeLiterals(program.body), [], ); - const inlineTypeLiterals: ParsedInlineTypeLiteral[] = inlineTypeCaptures.map((capture) => ({ + const inlineTypeLiterals: SourceModuleInlineTypeLiteral[] = inlineTypeCaptures.map((capture) => ({ structuralHash: capture.structuralHash, memberCount: capture.memberCount, preview: capture.preview, @@ -880,7 +804,7 @@ export const parseSourceFile = (filePath: string): ParsedSource => { () => collectSimplifiableFunctions(program.body), [], ); - const simplifiableFunctions: ParsedSimplifiableFunction[] = simplifiableCaptures.map( + const simplifiableFunctions: SourceModuleSimplifiableFunction[] = simplifiableCaptures.map( (capture) => ({ kind: capture.kind, functionName: capture.functionName, @@ -896,7 +820,7 @@ export const parseSourceFile = (filePath: string): ParsedSource => { () => collectSimplifiableExpressions(program.body), [], ); - const simplifiableExpressions: ParsedSimplifiableExpression[] = expressionCaptures.map( + const simplifiableExpressions: SourceModuleSimplifiableExpression[] = expressionCaptures.map( (capture) => ({ kind: capture.kind, snippet: capture.snippet, @@ -912,15 +836,14 @@ export const parseSourceFile = (filePath: string): ParsedSource => { () => collectDuplicateConstantCandidates(program.body), [], ); - const duplicateConstantCandidates: ParsedDuplicateConstantCandidate[] = constantCaptures.map( - (capture) => ({ + const duplicateConstantCandidates: SourceModuleDuplicateConstantCandidate[] = + constantCaptures.map((capture) => ({ constantName: capture.constantName, literalHash: capture.literalHash, literalPreview: capture.literalPreview, line: getLineFromOffset(sourceText, capture.startOffset), column: getColumnFromOffset(sourceText, capture.startOffset), - }), - ); + })); const referencedFilenames = extractReferencedFilenames(sourceText, program.body); @@ -1000,9 +923,9 @@ const extractReferencedFilenames = ( const collectDryPatterns = ( bodyNodes: Array, sourceText: string, - redundantTypePatterns: ParsedRedundantTypePattern[], - identityWrappers: ParsedIdentityWrapper[], - typeDefinitionHashes: ParsedTypeDefinitionHash[], + redundantTypePatterns: SourceModuleRedundantTypePattern[], + identityWrappers: SourceModuleIdentityWrapper[], + typeDefinitionHashes: SourceModuleTypeDefinitionHash[], ): void => { for (const statement of bodyNodes) { inspectStatement( @@ -1018,9 +941,9 @@ const collectDryPatterns = ( const inspectStatement = ( statementNode: Statement | ModuleDeclaration, sourceText: string, - redundantTypePatterns: ParsedRedundantTypePattern[], - identityWrappers: ParsedIdentityWrapper[], - typeDefinitionHashes: ParsedTypeDefinitionHash[], + redundantTypePatterns: SourceModuleRedundantTypePattern[], + identityWrappers: SourceModuleIdentityWrapper[], + typeDefinitionHashes: SourceModuleTypeDefinitionHash[], ): void => { let declarationOfInterest: unknown = statementNode; if ( diff --git a/packages/deslop-js/src/report/packages.ts b/packages/deslop-js/src/report/packages.ts index c314d0505..f36d6e9ab 100644 --- a/packages/deslop-js/src/report/packages.ts +++ b/packages/deslop-js/src/report/packages.ts @@ -10,7 +10,10 @@ import type { } from "../types.js"; import { IMPLICIT_DEPENDENCIES } from "../constants.js"; import { extractPackageName } from "../utils/package-name.js"; -import { collectOverrideMappingsFromRecord } from "../utils/collect-override-mappings-from-record.js"; +import { + collectOverrideMappingsFromRecord, + type OverrideMapping, +} from "../utils/collect-override-mappings-from-record.js"; import { collectPnpmWorkspaceOverrideMappings } from "../utils/parse-pnpm-workspace-overrides.js"; import { matchesPackageImportReference } from "../utils/matches-package-import-reference.js"; import { matchesPackageTokenReference } from "../utils/matches-package-token-reference.js"; @@ -18,11 +21,6 @@ import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; import { extractExpoConfigPluginEntries } from "../collect/expo-config-plugin-entries.js"; import type { PackageFactKind, SummaryCache } from "../summary-cache.js"; -interface OverrideMapping { - fromPackage: string; - toPackage: string; -} - interface PackageFileGlobOptions { readonly ignore: ReadonlyArray; readonly deep: number; diff --git a/packages/deslop-js/src/types.ts b/packages/deslop-js/src/types.ts index 598844cac..caf93b698 100644 --- a/packages/deslop-js/src/types.ts +++ b/packages/deslop-js/src/types.ts @@ -111,13 +111,19 @@ export interface SourceModuleDuplicateConstantCandidate { column: number; } -export interface SourceModule { - fileId: SourceFile; +export interface SourceModuleAnalysis { imports: ImportReference[]; exports: ExportReference[]; memberAccesses: MemberAccess[]; wholeObjectUses: string[]; localIdentifierReferences: string[]; + /** + * Local names of static import bindings referenced in module-init-executed + * positions (top-level statements outside function bodies and erased TS + * type positions). Cycle detection uses this to tell an initialization- + * order hazard from a cycle whose back edges are only dereferenced later, + * inside function bodies invoked after every module has initialized. + */ topLevelImportReferences: string[]; referencedFilenames: string[]; redundantTypePatterns: SourceModuleRedundantTypePattern[]; @@ -127,6 +133,10 @@ export interface SourceModule { simplifiableFunctions: SourceModuleSimplifiableFunction[]; simplifiableExpressions: SourceModuleSimplifiableExpression[]; duplicateConstantCandidates: SourceModuleDuplicateConstantCandidate[]; +} + +export interface SourceModule extends SourceModuleAnalysis { + fileId: SourceFile; parseErrors: DeslopError[]; isEntryPoint: boolean; isTestEntry: boolean; diff --git a/packages/deslop-js/src/utils/collect-override-mappings-from-record.ts b/packages/deslop-js/src/utils/collect-override-mappings-from-record.ts index 522d36cd3..ca66d2d20 100644 --- a/packages/deslop-js/src/utils/collect-override-mappings-from-record.ts +++ b/packages/deslop-js/src/utils/collect-override-mappings-from-record.ts @@ -1,6 +1,6 @@ import { extractOverrideTargetPackage } from "./extract-override-target.js"; -interface OverrideMapping { +export interface OverrideMapping { fromPackage: string; toPackage: string; } diff --git a/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts b/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts index 75a7dde24..b77a9e0fa 100644 --- a/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts +++ b/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts @@ -1,11 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { collectOverrideMappingsFromRecord } from "./collect-override-mappings-from-record.js"; - -interface OverrideMapping { - fromPackage: string; - toPackage: string; -} +import { + collectOverrideMappingsFromRecord, + type OverrideMapping, +} from "./collect-override-mappings-from-record.js"; const PNPM_WORKSPACE_FILENAMES = ["pnpm-workspace.yaml", "pnpm-workspace.yml"] as const; diff --git a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs index 851835854..b14067e3b 100644 --- a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs +++ b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs @@ -172,24 +172,6 @@ const RULES_NOT_PORTED_FROM_EXTERNAL = new Set([ "role-button-requires-complete-keyboard-activation", ]); -// Rule ids whose source files are kept on disk but intentionally NOT -// registered. Use sparingly — the canonical way to retire a rule is to -// delete its file (and its tests, fixture references, etc.). This -// skiplist exists for rules we want to stop shipping right away while -// preserving their implementation, tests, and regression fixtures so -// re-enabling is a one-line change. Add a brief justification next to -// every entry. -const RULE_IDS_TO_SKIP_REGISTRATION = new Set([ - // The React-Compiler memoization premise didn't hold: the three - // canonical hooks it targeted (`useRouter`, `useSearchParams`, - // `useNavigation`) all return stable references, so destructuring - // their methods produces no measurable compiler win — and on Pages - // Router (`next/router`) destructuring `push` captures a stale - // reference. Implementation + regression suite + fixture lines kept - // in place; remove this entry to re-enable. - "react-compiler-destructure-method", -]); - // Fine-grained category → the clear, user-facing bucket the scan output // groups & labels by. Rules (and the buckets below) declare a detailed // category for intent; the reporter only ever shows these five outcome @@ -310,7 +292,6 @@ for (const bucket of fs.readdirSync(PLUGIN_RULES_ROOT, { withFileTypes: true })) process.exit(1); } const ruleId = idMatch[1]; - if (RULE_IDS_TO_SKIP_REGISTRATION.has(ruleId)) continue; const category = toBucket(categoryMatch ? categoryMatch[1] : defaultCategory); const severity = severityMatch[1]; // Force POSIX separators — `path.relative()` returns backslashes on diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.regressions.test.ts deleted file mode 100644 index d21ae29da..000000000 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.regressions.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { runRule } from "../../../test-utils/run-rule.js"; -import { reactCompilerDestructureMethod } from "./react-compiler-destructure-method.js"; - -const run = (code: string) => - runRule(reactCompilerDestructureMethod, code, { filename: "fixture.tsx" }); - -describe("architecture/react-compiler-destructure-method — regressions", () => { - it("does not flag useSearchParams().get() — its methods need their `this` receiver", () => { - const result = run( - `import { useSearchParams } from "next/navigation"; - function Page() { const searchParams = useSearchParams(); const q = searchParams.get("q"); return
{q}
; }`, - ); - expect(result.diagnostics).toEqual([]); - }); - - it("still flags useRouter().push() (a bound function property)", () => { - const result = run( - `function Page() { const router = useRouter(); return ; }`, - ); - expect(result.diagnostics).toHaveLength(1); - }); - - it("still flags useNavigation().navigate() (a bound function property)", () => { - const result = run( - `function Screen() { const navigation = useNavigation(); return ; }`, - ); - expect(result.diagnostics).toHaveLength(1); - }); -}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.ts deleted file mode 100644 index c86c3d2fe..000000000 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-destructure-method.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { defineRule } from "../../utils/define-rule.js"; -import { isComponentAssignment } from "../../utils/is-component-assignment.js"; -import { isInlineFunctionExpression } from "../../utils/is-inline-function-expression.js"; -import { isUppercaseName } from "../../utils/is-uppercase-name.js"; -import type { EsTreeNode } from "../../utils/es-tree-node.js"; -import type { RuleContext } from "../../utils/rule-context.js"; -import { isImportedFromModule } from "../../utils/find-import-source-for-name.js"; -import { isNodeOfType } from "../../utils/is-node-of-type.js"; -import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; - -// Only hooks that return an object of BOUND function properties belong -// here. `useSearchParams` is intentionally excluded: it returns a -// `ReadonlyURLSearchParams` instance whose methods (`get`/`has`/…) are -// unbound prototype methods that need their `this` receiver, so the -// destructure recommendation (`const { get } = useSearchParams()`) -// throws `TypeError: Illegal invocation`. -const HOOK_OBJECTS_WITH_METHODS = new Map>([ - ["useRouter", new Set(["push", "replace", "back", "forward", "refresh", "prefetch"])], - [ - "useNavigation", - new Set(["navigate", "push", "goBack", "popToTop", "reset", "replace", "dispatch"]), - ], -]); - -// Some libraries expose method-bearing hook objects where destructuring is not -// part of the supported API shape, even though the hook name and method access -// look like a normal React Compiler candidate. Keep those carve-outs keyed by -// hook name and import source so similarly named userland hooks still report. -const HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING = new Map>([ - ["useNavigation", new Set(["@react-navigation/native", "@react-navigation/core"])], -]); - -const isUnsafeMethodDestructureHookImport = (node: EsTreeNode, hookSource: string): boolean => { - const moduleSources = HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING.get(hookSource); - if (!moduleSources) return false; - for (const moduleSource of moduleSources) { - if (isImportedFromModule(node, hookSource, moduleSource)) return true; - } - return false; -}; - -// HACK: O(1) lookup. Indexes top-level `const x = useFooBar(...)` -// declarations once per component on enter, so subsequent -// MemberExpression visitors don't re-walk the whole body for every -// access. -const buildHookBindingMap = (componentBody: EsTreeNode | null | undefined): Map => { - const result = new Map(); - if (!componentBody || !isNodeOfType(componentBody, "BlockStatement")) return result; - for (const statement of componentBody.body ?? []) { - if (!isNodeOfType(statement, "VariableDeclaration")) continue; - for (const declarator of statement.declarations ?? []) { - if (!isNodeOfType(declarator.id, "Identifier")) continue; - if (!isNodeOfType(declarator.init, "CallExpression")) continue; - const callee = declarator.init.callee; - if (!isNodeOfType(callee, "Identifier")) continue; - result.set(declarator.id.name, callee.name); - } - } - return result; -}; - -// HACK: React Compiler memoizes inside a component based on stable -// reference equality of *destructured* values. `router.push("/x")` -// reads `push` off the hook return on every render, which the compiler -// can't memoize as cleanly as a destructured `const { push } = useRouter()`. -// The destructured form also makes the dependency graph obvious — if -// you only need `push`, the compiler doesn't need to track all of -// `router`. This is a soft signal even without React Compiler enabled -// (it makes intent clearer and reduces accidental capture). -// -// Heuristic: `router.push(...)` (or any of the canonical hook objects) -// where `router` is bound to a `useRouter()` call in the same component. -export const reactCompilerDestructureMethod = defineRule({ - id: "react-compiler-destructure-method", - title: "Hook method called without destructuring", - tags: ["test-noise"], - severity: "warn", - recommendation: - "Pull the method out first: `const { push } = useRouter()`, then call `push(...)` directly. It's clearer and easier for React Compiler to optimize.", - create: (context: RuleContext) => { - const hookBindingMapStack: Array> = []; - - const isComponent = (node: EsTreeNode): boolean => { - if (isNodeOfType(node, "FunctionDeclaration")) { - return Boolean(node.id?.name && isUppercaseName(node.id.name)); - } - if (isNodeOfType(node, "VariableDeclarator")) { - return isComponentAssignment(node); - } - return false; - }; - - // HACK: push UNCONDITIONALLY for every component so push/pop stay - // balanced. A concise-arrow component (`const Foo = () =>
`) - // has no BlockStatement body and therefore no hook bindings, but it - // still triggers the matching `:exit` — without an unconditional - // push, the exit would pop the *outer* component's frame and silently - // drop diagnostics on every member access in the parent. The empty - // Map returned by `buildHookBindingMap` for non-Block bodies is the - // correct semantic for "this component declares zero hook bindings". - const enter = (node: EsTreeNode): void => { - if (!isComponent(node)) return; - let body: EsTreeNode | null | undefined; - if (isNodeOfType(node, "FunctionDeclaration")) { - body = node.body; - } else if (isNodeOfType(node, "VariableDeclarator")) { - const initializer = node.init; - body = isInlineFunctionExpression(initializer) ? initializer.body : null; - } - hookBindingMapStack.push(buildHookBindingMap(body)); - }; - const exit = (node: EsTreeNode): void => { - if (isComponent(node)) hookBindingMapStack.pop(); - }; - - return { - FunctionDeclaration: enter, - "FunctionDeclaration:exit": exit, - VariableDeclarator: enter, - "VariableDeclarator:exit": exit, - MemberExpression(node: EsTreeNodeOfType<"MemberExpression">) { - if (hookBindingMapStack.length === 0) return; - if (node.computed) return; - if (!isNodeOfType(node.object, "Identifier")) return; - if (!isNodeOfType(node.property, "Identifier")) return; - - const bindingName = node.object.name; - const methodName = node.property.name; - const hookBindings = hookBindingMapStack[hookBindingMapStack.length - 1]; - const hookSource = hookBindings.get(bindingName); - if (!hookSource) return; - - const allowedMethods = HOOK_OBJECTS_WITH_METHODS.get(hookSource); - if (!allowedMethods || !allowedMethods.has(methodName)) return; - if (isUnsafeMethodDestructureHookImport(node, hookSource)) return; - - if (!isNodeOfType(node.parent, "CallExpression") || node.parent.callee !== node) return; - - context.report({ - node, - message: `React Compiler can't optimize \`${hookSource}().${methodName}(...)\` as cleanly, so pull the method out first: \`const { ${methodName} } = ${hookSource}()\`, then call \`${methodName}(...)\` directly.`, - }); - }, - }; - }, -}); diff --git a/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts b/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts index 06e293f62..1e9939775 100644 --- a/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts +++ b/packages/react-doctor/src/cli/utils/action-upgrade-prompt.ts @@ -1,4 +1,4 @@ -import { type CliStateOptions, ACTION_UPGRADE_EVENT, getCliStatePath } from "./cli-state-store.js"; +import { type CliStateOptions, ACTION_UPGRADE_EVENT } from "./cli-state-store.js"; import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // The `@v1` → `@v2` action-upgrade offer: a once-per-repo gate. Either answer @@ -7,8 +7,6 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // bumping this one, so the v2 answer stays remembered. const ACTION_UPGRADE_GATE: Gate = { id: ACTION_UPGRADE_EVENT, scope: "project" }; -export const getActionUpgradePromptConfigPath = getCliStatePath; - // Whether the upgrade offer was already answered for this repo. Fails safe to // "handled" on an unreadable store. export const hasHandledActionUpgrade = ( diff --git a/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts b/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts index 65b10a77e..4cedcd96f 100644 --- a/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts +++ b/packages/react-doctor/src/cli/utils/ci-prompt-decision.ts @@ -1,4 +1,4 @@ -import { type CliStateOptions, CI_PITCH_EVENT, getCliStatePath } from "./cli-state-store.js"; +import { type CliStateOptions, CI_PITCH_EVENT } from "./cli-state-store.js"; import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // The "Add React Doctor to CI?" pitch: a once-per-repo gate shared by `install` @@ -8,8 +8,6 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // install`). Bump `version` to re-pitch everyone after a reworked campaign. const CI_PITCH_GATE: Gate = { id: CI_PITCH_EVENT, scope: "project" }; -export const getCiPromptConfigPath = getCliStatePath; - // Whether the CI pitch was already answered for this repo. Fails safe to // "handled" on an unreadable store so we never nag where it can't be remembered. export const hasHandledCiPrompt = (projectRoot: string, options: CliStateOptions = {}): boolean => diff --git a/packages/react-doctor/src/cli/utils/cli-lifecycle.ts b/packages/react-doctor/src/cli/utils/cli-lifecycle.ts index d76e88fb0..666cf2671 100644 --- a/packages/react-doctor/src/cli/utils/cli-lifecycle.ts +++ b/packages/react-doctor/src/cli/utils/cli-lifecycle.ts @@ -128,14 +128,6 @@ const updateScope = ( }; }; -const omitKey = ( - record: Record | undefined, - key: string, -): Record => { - const { [key]: _removed, ...rest } = record ?? {}; - return rest; -}; - // === Gates === // True when the gate has not fired at its current version — never recorded, or @@ -158,18 +150,6 @@ export const isGatePending = ( ); }; -// The outcome recorded for the gate's latest firing, or null if never fired. -export const readGateOutcome = ( - gate: Gate, - target: GateTarget = {}, - options: CliStateOptions = {}, -): EventOutcome | null => - readCliState( - (state) => selectScope(state, gate, target.projectRoot)?.events?.[gate.id]?.outcome ?? null, - null, - options, - ); - // Records that the gate fired (optionally with an outcome). Returns whether it // persisted. Callers wanting a stable first-fire timestamp guard with // `isGatePending` before calling. @@ -194,23 +174,6 @@ export const recordGate = ( options, ); -// Invalidation/admin: clear a gate's recorded firing so it becomes pending -// again. (Day-to-day invalidation is a `version` bump; this is the explicit -// reset for tests and one-off resets.) -export const clearGate = ( - gate: Gate, - target: GateTarget = {}, - options: CliStateOptions = {}, -): boolean => - updateCliState( - (state) => - updateScope(state, gate, target.projectRoot, (scope) => ({ - ...scope, - events: omitKey(scope.events, gate.id), - })), - options, - ); - // === Preferences === // The value last written for this preference, or null when never written (or diff --git a/packages/react-doctor/src/cli/utils/is-ci-environment.ts b/packages/react-doctor/src/cli/utils/is-ci-environment.ts index efdbfb278..dc80247a3 100644 --- a/packages/react-doctor/src/cli/utils/is-ci-environment.ts +++ b/packages/react-doctor/src/cli/utils/is-ci-environment.ts @@ -125,12 +125,6 @@ export const isOfficialGithubAction = (): boolean => // off GitHub Actions. Low-cardinality, so safe as a run tag. export const detectCiEventName = (): string | null => process.env.GITHUB_EVENT_NAME?.trim() || null; -// Whether the CI run was triggered by a pull request event. -export const isPullRequestCiEvent = (): boolean => { - const eventName = detectCiEventName(); - return eventName === "pull_request" || eventName === "pull_request_target"; -}; - // The runner OS GitHub Actions exposes as `RUNNER_OS` (`Linux`/`Windows`/ // `macOS`). Null off GitHub Actions; the local `process.platform` covers the // non-action case. diff --git a/packages/react-doctor/src/cli/utils/onboarding-pacing.ts b/packages/react-doctor/src/cli/utils/onboarding-pacing.ts index 66f81c240..eab50c80d 100644 --- a/packages/react-doctor/src/cli/utils/onboarding-pacing.ts +++ b/packages/react-doctor/src/cli/utils/onboarding-pacing.ts @@ -1,9 +1,6 @@ -import * as Effect from "effect/Effect"; import { isCiEnvironment } from "./is-ci-environment.js"; import { isGitHookEnvironment } from "./is-git-hook-environment.js"; -// Each scan-report section waits this long before printing, so a first human -// run reads as a guided reveal rather than one painted frame. export const ONBOARDING_SECTION_DELAY_MS = 850; // Internal escape hatch: force the first-run onboarding on any run, bypassing @@ -17,10 +14,6 @@ export const isOnboardingForced = (environment: NodeJS.ProcessEnv = process.env) return value !== undefined && !FALSY_FLAG_VALUES.has(value.toLowerCase()); }; -// The beat to `yield*` before a section: a sleep when pacing, else a no-op. -export const onboardingSectionPause = (shouldPace: boolean): Effect.Effect => - shouldPace ? Effect.sleep(ONBOARDING_SECTION_DELAY_MS) : Effect.void; - export interface OnboardingRecordInput { // Section pacing was enabled for this run (so the reveal actually showed). readonly paceOnboardingSections: boolean; diff --git a/packages/react-doctor/src/cli/utils/onboarding-state.ts b/packages/react-doctor/src/cli/utils/onboarding-state.ts index a6eed18ec..3d88072de 100644 --- a/packages/react-doctor/src/cli/utils/onboarding-state.ts +++ b/packages/react-doctor/src/cli/utils/onboarding-state.ts @@ -1,4 +1,4 @@ -import { type CliStateOptions, ONBOARDING_EVENT, getCliStatePath } from "./cli-state-store.js"; +import { type CliStateOptions, ONBOARDING_EVENT } from "./cli-state-store.js"; import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // The first-run onboarding reveal, expressed as a global gate: it fires once @@ -7,8 +7,6 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; // machinery supports both with no other change. const ONBOARDING_GATE: Gate = { id: ONBOARDING_EVENT, scope: "global" }; -export const getOnboardingConfigPath = getCliStatePath; - // `isGatePending` defaults to "not pending" on an unreadable store, so this // fails safe to "already onboarded" — a broken config dir never replays the // reveal. 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 149ef1d1b..c84f72618 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/wrap-indented-text.ts b/packages/react-doctor/src/cli/utils/wrap-indented-text.ts index 7f816138d..2b9105f5a 100644 --- a/packages/react-doctor/src/cli/utils/wrap-indented-text.ts +++ b/packages/react-doctor/src/cli/utils/wrap-indented-text.ts @@ -1,5 +1,3 @@ -import { indentMultilineText } from "./indent-multiline-text.js"; - interface WrapTextOptions { /** * When `true` (the default), a single word longer than `width` is @@ -55,12 +53,3 @@ export const wrapTextToWidth = ( const breakLongWords = options.breakLongWords ?? true; return text.split("\n").flatMap((lineText) => wrapLine(lineText, width, breakLongWords)); }; - -export const wrapIndentedText = (text: string, linePrefix: string, width: number): string => { - const contentWidth = width - linePrefix.length; - if (contentWidth <= 0) return indentMultilineText(text, linePrefix); - - return wrapTextToWidth(text, contentWidth) - .map((lineText) => `${linePrefix}${lineText}`) - .join("\n"); -}; diff --git a/packages/react-doctor/tests/action-upgrade.test.ts b/packages/react-doctor/tests/action-upgrade.test.ts index fdd7195d2..ad7c978d9 100644 --- a/packages/react-doctor/tests/action-upgrade.test.ts +++ b/packages/react-doctor/tests/action-upgrade.test.ts @@ -10,10 +10,10 @@ import { workflowUsesV1Action, } from "../src/cli/utils/install-github-workflow.js"; import { - getActionUpgradePromptConfigPath, hasHandledActionUpgrade, recordActionUpgradeDecision, } from "../src/cli/utils/action-upgrade-prompt.js"; +import { getCliStatePath } from "../src/cli/utils/cli-state-store.js"; const buildWorkflow = (actionRef: string): string => [ @@ -124,7 +124,7 @@ describe("action upgrade prompt state", () => { it("stores the decision as an action-upgrade event in the shared react-doctor config file", () => { recordActionUpgradeDecision("/repo/a", "declined", { cwd: configRoot }); - const configPath = getActionUpgradePromptConfigPath({ cwd: configRoot }); + const configPath = getCliStatePath({ cwd: configRoot }); const stored = JSON.parse(fs.readFileSync(configPath, "utf8")); const records = Object.values(stored.projects) .map( diff --git a/packages/react-doctor/tests/ci-prompt-decision.test.ts b/packages/react-doctor/tests/ci-prompt-decision.test.ts index c1d26e2ed..f638496d6 100644 --- a/packages/react-doctor/tests/ci-prompt-decision.test.ts +++ b/packages/react-doctor/tests/ci-prompt-decision.test.ts @@ -2,11 +2,8 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { - getCiPromptConfigPath, - hasHandledCiPrompt, - recordCiPromptDecision, -} from "../src/cli/utils/ci-prompt-decision.js"; +import { getCliStatePath } from "../src/cli/utils/cli-state-store.js"; +import { hasHandledCiPrompt, recordCiPromptDecision } from "../src/cli/utils/ci-prompt-decision.js"; describe("ci prompt decision state", () => { let configRoot: string; @@ -44,7 +41,7 @@ describe("ci prompt decision state", () => { it("stores the decision as a ci-pitch event in the shared react-doctor config file", () => { recordCiPromptDecision("/repo/a", "declined", { cwd: configRoot }); - const configPath = getCiPromptConfigPath({ cwd: configRoot }); + const configPath = getCliStatePath({ cwd: configRoot }); const stored = JSON.parse(fs.readFileSync(configPath, "utf8")); const records = Object.values(stored.projects) .map( diff --git a/packages/react-doctor/tests/cli-lifecycle.test.ts b/packages/react-doctor/tests/cli-lifecycle.test.ts index da360f1fb..318471c2b 100644 --- a/packages/react-doctor/tests/cli-lifecycle.test.ts +++ b/packages/react-doctor/tests/cli-lifecycle.test.ts @@ -6,10 +6,8 @@ import { type Gate, type Migration, type Preference, - clearGate, isGatePending, isMigrationPending, - readGateOutcome, readPreference, recordGate, runMigrations, @@ -37,10 +35,9 @@ describe("cli-lifecycle", () => { expect(isGatePending(gate, {}, options)).toBe(false); }); - it("records and reads an outcome (project scope)", () => { + it("records an outcome (project scope)", () => { const gate: Gate = { id: "ci-pitch", scope: "project" }; recordGate(gate, { projectRoot: "/repo/a", outcome: "accepted" }, options); - expect(readGateOutcome(gate, { projectRoot: "/repo/a" }, options)).toBe("accepted"); expect(isGatePending(gate, { projectRoot: "/repo/a" }, options)).toBe(false); }); @@ -62,14 +59,6 @@ describe("cli-lifecycle", () => { expect(isGatePending(v2, {}, options)).toBe(false); }); - it("can be explicitly cleared so it fires again", () => { - const gate: Gate = { id: "cta", scope: "project" }; - recordGate(gate, { projectRoot: "/repo/a" }, options); - expect(isGatePending(gate, { projectRoot: "/repo/a" }, options)).toBe(false); - clearGate(gate, { projectRoot: "/repo/a" }, options); - expect(isGatePending(gate, { projectRoot: "/repo/a" }, options)).toBe(true); - }); - it("fails safe to not-pending on an unreadable store (default)", () => { // A cwd whose parent is a file makes conf's mkdir throw. const unwritableCwd = path.join(configRoot, "file-as-dir"); @@ -191,14 +180,12 @@ describe("cli-lifecycle", () => { expect(readPreference(preference, {}, options)).toBe(null); }); - it("keeps gate outcomes and preferences in separate namespaces", () => { + it("keeps gate events and preferences in separate namespaces", () => { const gate: Gate = { id: "handoff-target", scope: "global" }; const preference: Preference = { id: "handoff-target", scope: "global" }; recordGate(gate, { outcome: "accepted" }, options); writePreference(preference, "skip", {}, options); - // Same id, different stores: the gate outcome is "accepted", the - // preference value is "skip" — neither clobbers the other. - expect(readGateOutcome(gate, {}, options)).toBe("accepted"); + expect(isGatePending(gate, {}, options)).toBe(false); expect(readPreference(preference, {}, options)).toBe("skip"); }); }); diff --git a/packages/react-doctor/tests/is-ci-environment.test.ts b/packages/react-doctor/tests/is-ci-environment.test.ts index 33fc75f7c..b594da504 100644 --- a/packages/react-doctor/tests/is-ci-environment.test.ts +++ b/packages/react-doctor/tests/is-ci-environment.test.ts @@ -11,7 +11,6 @@ import { isCiOrCodingAgentEnvironment, isCodingAgentEnvironment, isOfficialGithubAction, - isPullRequestCiEvent, } from "../src/cli/utils/is-ci-environment.js"; const ENVIRONMENT_VARIABLES = [ @@ -216,21 +215,14 @@ describe("GitHub Actions CI detectors", () => { expect(isOfficialGithubAction()).toBe(true); }); - it("reads the GitHub event name and pull-request signal", () => { + it("reads the GitHub event name", () => { expect(detectCiEventName()).toBeNull(); - expect(isPullRequestCiEvent()).toBe(false); process.env.GITHUB_EVENT_NAME = "pull_request"; expect(detectCiEventName()).toBe("pull_request"); - expect(isPullRequestCiEvent()).toBe(true); process.env.GITHUB_EVENT_NAME = "push"; - expect(isPullRequestCiEvent()).toBe(false); - }); - - it("treats pull_request_target as a pull request event", () => { - process.env.GITHUB_EVENT_NAME = "pull_request_target"; - expect(isPullRequestCiEvent()).toBe(true); + expect(detectCiEventName()).toBe("push"); }); it("reads the runner OS when present", () => { diff --git a/packages/react-doctor/tests/onboarding-pacing.test.ts b/packages/react-doctor/tests/onboarding-pacing.test.ts index 55457eb05..d3d37a048 100644 --- a/packages/react-doctor/tests/onboarding-pacing.test.ts +++ b/packages/react-doctor/tests/onboarding-pacing.test.ts @@ -1,11 +1,7 @@ -import { performance } from "node:perf_hooks"; -import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; import { FORCE_ONBOARDING_ENV_VAR, isOnboardingForced, - ONBOARDING_SECTION_DELAY_MS, - onboardingSectionPause, shouldRecordOnboarding, } from "../src/cli/utils/onboarding-pacing.js"; @@ -54,23 +50,3 @@ describe("shouldRecordOnboarding", () => { expect(shouldRecordOnboarding({ ...baseInput, isNonInteractiveEnvironment: true })).toBe(false); }); }); - -describe("onboardingSectionPause", () => { - it("is a no-op when pacing is off", async () => { - expect(onboardingSectionPause(false)).toBe(Effect.void); - - const start = performance.now(); - await Effect.runPromise(onboardingSectionPause(false)); - expect(performance.now() - start).toBeLessThan(50); - }); - - it("waits the configured delay when pacing is on", async () => { - expect(ONBOARDING_SECTION_DELAY_MS).toBe(850); - - const start = performance.now(); - await Effect.runPromise(onboardingSectionPause(true)); - // Generous lower bound: a real sleep never returns early, but timer - // granularity / CI jitter can shave a few milliseconds off the wall clock. - expect(performance.now() - start).toBeGreaterThanOrEqual(700); - }); -}); diff --git a/packages/react-doctor/tests/onboarding-state.test.ts b/packages/react-doctor/tests/onboarding-state.test.ts index 7502fc42d..36e961d9c 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/prompt-install-setup.test.ts b/packages/react-doctor/tests/prompt-install-setup.test.ts index e6d43faff..1164a027f 100644 --- a/packages/react-doctor/tests/prompt-install-setup.test.ts +++ b/packages/react-doctor/tests/prompt-install-setup.test.ts @@ -5,13 +5,13 @@ import * as fs from "node:fs"; import { AGENT_INSTALL_HINT_LINES, disableSetupPrompt, - getSetupPromptConfigPath, - getSetupPromptProjectKey, hasDisabledSetupPrompt, printAgentInstallHint, resolveInstallSetupProjectRoot, shouldShowAgentInstallHint, } from "../src/cli/utils/prompt-install-setup.js"; +import { getCliStatePath } from "../src/cli/utils/cli-state-store.js"; +import { hashProjectRoot } from "../src/cli/utils/hash-project-root.js"; interface PromptInstallSetupFixture { readonly configRoot: string; @@ -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 879134fb4..641736a48 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/wrap-indented-text.test.ts b/packages/react-doctor/tests/wrap-indented-text.test.ts deleted file mode 100644 index a8603a5fc..000000000 --- a/packages/react-doctor/tests/wrap-indented-text.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { wrapIndentedText } from "../src/cli/utils/wrap-indented-text.js"; - -const TEST_WRAP_WIDTH_CHARS = 36; - -describe("wrapIndentedText", () => { - it("wraps continuation lines with the same prefix", () => { - const output = wrapIndentedText( - "Return a cleanup function that releases the subscription timer before the component unmounts", - " ", - TEST_WRAP_WIDTH_CHARS, - ); - - expect(output).toBe( - [ - " Return a cleanup function", - " that releases the", - " subscription timer before the", - " component unmounts", - ].join("\n"), - ); - }); -}); diff --git a/scripts/convert-node-imports.mjs b/scripts/convert-node-imports.mjs deleted file mode 100644 index 1a9be5408..000000000 --- a/scripts/convert-node-imports.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { extname, join } from "node:path"; - -const ROOT = join(import.meta.dirname, ".."); -const EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]); - -const collectFiles = (directory) => { - const entries = readdirSync(directory); - const files = []; - for (const entry of entries) { - if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; - const fullPath = join(directory, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - files.push(...collectFiles(fullPath)); - continue; - } - if (EXTENSIONS.has(extname(fullPath))) files.push(fullPath); - } - return files; -}; - -const parseNamedImport = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{([^}]+)\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?`, - "g", - ); - const names = []; - let match; - while ((match = pattern.exec(source)) !== null) { - const imported = match[1] - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const aliasMatch = part.match(/^(\w+)\s+as\s+(\w+)$/); - if (aliasMatch) return { local: aliasMatch[2], imported: aliasMatch[1] }; - return { local: part, imported: part }; - }); - names.push(...imported); - } - return names; -}; - -const removeNamedImports = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{[^}]+\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?\\n?`, - "g", - ); - return source.replace(pattern, ""); -}; - -const hasNamespaceImport = (source, moduleSpecifier, alias) => { - const pattern = new RegExp( - `import\\s+\\*\\s+as\\s+${alias}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["']`, - ); - return pattern.test(source); -}; - -const ensureNamespaceImport = (source, moduleSpecifier, alias) => { - if (hasNamespaceImport(source, moduleSpecifier, alias)) return source; - const importLine = `import * as ${alias} from "${moduleSpecifier}";\n`; - const importMatch = source.match(/^((?:import\s.+;\n)*)/); - if (importMatch) { - return source.replace(importMatch[1], `${importMatch[1]}${importLine}`); - } - return `${importLine}${source}`; -}; - -const prefixUsages = (source, names, alias) => { - let result = source; - for (const { local, imported } of names) { - const member = imported === local ? local : imported; - const replacement = `${alias}.${member}`; - const pattern = new RegExp(`(? { - let source = readFileSync(filePath, "utf8"); - const original = source; - - source = source.replace( - /import\s+fs\s+from\s+["']node:fs["'];?/g, - 'import * as fs from "node:fs";', - ); - source = source.replace( - /import\s+path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace( - /import\s+\*\s+as\s+Path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace(/\bPath\./g, "path."); - - const fsNames = parseNamedImport(source, "node:fs"); - const pathNames = parseNamedImport(source, "node:path"); - - if (fsNames.length > 0) { - source = removeNamedImports(source, "node:fs"); - source = ensureNamespaceImport(source, "node:fs", "fs"); - source = prefixUsages(source, fsNames, "fs"); - } - - if (pathNames.length > 0) { - source = removeNamedImports(source, "node:path"); - source = ensureNamespaceImport(source, "node:path", "path"); - source = prefixUsages(source, pathNames, "path"); - } - - source = source.replace(/\n{3,}/g, "\n\n"); - - if (source !== original) { - writeFileSync(filePath, source); - return true; - } - return false; -}; - -const files = collectFiles(ROOT).filter( - (filePath) => !filePath.includes("convert-node-imports.mjs"), -); -const changed = files.filter(convertFile); -console.log(`Updated ${changed.length} files`); From 6cd4b261d29ea7188dead6914891c664284f1a14 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 01:05:29 +0000 Subject: [PATCH 13/17] ci: drive interactive e2e with termctrl --- .github/workflows/terminal-recording.yml | 58 ++++---------- scripts/setup-terminal-recording.sh | 15 +++- scripts/terminal-e2e-video.json | 49 ++++++++++++ scripts/terminal-e2e.sh | 98 ++++++++++++++++++++++++ scripts/terminal-recording.tape | 54 ------------- 5 files changed, 175 insertions(+), 99 deletions(-) create mode 100644 scripts/terminal-e2e-video.json create mode 100755 scripts/terminal-e2e.sh delete mode 100644 scripts/terminal-recording.tape diff --git a/.github/workflows/terminal-recording.yml b/.github/workflows/terminal-recording.yml index a10ddeca8..472c3f342 100644 --- a/.github/workflows/terminal-recording.yml +++ b/.github/workflows/terminal-recording.yml @@ -19,7 +19,6 @@ jobs: timeout-minutes: 15 outputs: artifact-url: ${{ steps.upload.outputs.artifact-url }} - recording-url: ${{ steps.publish.outputs.recording-url }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: @@ -32,37 +31,24 @@ jobs: node-version: "22.18.0" cache: pnpm - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: "1.25.8" - cache: false + - name: Install package runner + run: npm install --global @antfu/ni@30.3.0 - name: Install dependencies - run: pnpm install --frozen-lockfile --prefer-offline + run: ni --frozen-lockfile --prefer-offline - name: Build packages - run: pnpm build - - - name: Create recording directory - run: mkdir -p artifacts + run: nr build - - name: Install VHS runtime + - name: Install Terminal Control runtime run: | sudo apt-get update sudo apt-get install --yes ffmpeg - mkdir -p "$RUNNER_TEMP/vhs-bin" - curl --fail --location --silent --show-error \ - https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 \ - --output "$RUNNER_TEMP/vhs-bin/ttyd" - echo "8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 $RUNNER_TEMP/vhs-bin/ttyd" \ - | sha256sum --check - chmod +x "$RUNNER_TEMP/vhs-bin/ttyd" - go install github.com/charmbracelet/vhs@v0.11.0 - echo "$RUNNER_TEMP/vhs-bin" >> "$GITHUB_PATH" - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + ni --global @kitlangton/terminal-control-linux-x64-gnu@0.6.0 + termctrl --version - - name: Render terminal recording - run: vhs scripts/terminal-recording.tape + - name: Run interactive terminal E2E + run: bash scripts/terminal-e2e.sh - name: Upload recording artifact id: upload @@ -70,21 +56,11 @@ jobs: with: name: terminal-recording-${{ github.event.pull_request.number }} path: | - artifacts/react-doctor-terminal.gif artifacts/react-doctor-terminal.mp4 + artifacts/react-doctor-terminal.png if-no-files-found: error retention-days: 7 - - name: Publish GIF preview - id: publish - run: | - recording_url="$(vhs publish artifacts/react-doctor-terminal.gif)" - case "$recording_url" in - https://vhs.charm.sh/*) ;; - *) echo "Unexpected VHS recording URL: $recording_url" >&2; exit 1 ;; - esac - echo "recording-url=$recording_url" >> "$GITHUB_OUTPUT" - comment: name: Post recording to PR needs: record @@ -99,24 +75,20 @@ jobs: env: ARTIFACT_URL: ${{ needs.record.outputs.artifact-url }} COMMIT_SHA: ${{ github.event.pull_request.head.sha }} - RECORDING_URL: ${{ needs.record.outputs.recording-url }} with: script: | const marker = ""; - const recordingUrl = process.env.RECORDING_URL; - if (!recordingUrl.startsWith("https://vhs.charm.sh/")) { - core.setFailed(`Unexpected VHS recording URL: ${recordingUrl}`); - return; - } const body = [ marker, "## Interactive terminal E2E", "", - `![React Doctor interactive terminal recording](${recordingUrl})`, + `Terminal Control verified the built CLI at \`${process.env.COMMIT_SHA.slice(0, 7)}\` in a real PTY:`, "", - `Recorded from the built CLI at \`${process.env.COMMIT_SHA.slice(0, 7)}\` in a real terminal. The fixture holds Git busy for three seconds, so \`Scanning...\` must appear immediately after project selection, then exercises the compact interactive report.`, + "- selected a project interactively and observed `Scanning...` before the three-second Git delay completed", + "- waited for the clean result and exercised the compact report", + "- opened copy context and the GitHub Actions confirmation, then cancelled safely", "", - `[Download the GIF and MP4 artifact](${process.env.ARTIFACT_URL})`, + `[Download the edited MP4 and PNG evidence](${process.env.ARTIFACT_URL})`, ].join("\n"); const { owner, repo } = context.repo; const issueNumber = context.payload.pull_request.number; diff --git a/scripts/setup-terminal-recording.sh b/scripts/setup-terminal-recording.sh index 998519bde..e8e8ea180 100755 --- a/scripts/setup-terminal-recording.sh +++ b/scripts/setup-terminal-recording.sh @@ -46,12 +46,23 @@ unset AMP_THREAD_ID AGENT_THREAD_ID AGENT react-doctor() { node "$TERMINAL_RECORDING_REPOSITORY_ROOT/packages/react-doctor/dist/cli.js" "$@" } -export -f react-doctor + +run-terminal-recording-clean-scan() { + clear + react-doctor --no-lint --no-dead-code --no-supply-chain --no-score + printf '\nterminal-e2e-first-run-finished\n' +} + +run-terminal-recording-tui-scan() { + clear + react-doctor --no-score --no-supply-chain --project app-a + printf '\nterminal-e2e-second-run-finished\n' +} use-terminal-recording-tui-fixture() { cd "$TERMINAL_RECORDING_TUI_DIRECTORY" + printf 'terminal-e2e-tui-fixture-ready\n' } -export -f use-terminal-recording-tui-fixture cd "$TERMINAL_RECORDING_DIRECTORY" clear diff --git a/scripts/terminal-e2e-video.json b/scripts/terminal-e2e-video.json new file mode 100644 index 000000000..f91e61fc7 --- /dev/null +++ b/scripts/terminal-e2e-video.json @@ -0,0 +1,49 @@ +{ + "clips": [ + { + "from": "project-command", + "to": "project-selection", + "speed": 1.5, + "caption": "Choose projects in the interactive CLI", + "hold_ms": 1000 + }, + { + "from": "project-selection", + "to": "scan-started", + "caption": "Scanning starts before the delayed Git operation finishes", + "hold_ms": 1000 + }, + { + "from": "scan-started", + "to": "clean-report", + "speed": 3, + "caption": "The selected project completes with a clean result", + "hold_ms": 1200 + }, + { + "from": "report-command", + "to": "action-menu", + "speed": 2, + "caption": "Inspect the compact interactive report", + "hold_ms": 1000 + }, + { + "from": "action-menu", + "to": "copy-context", + "caption": "Open the copy-context action", + "hold_ms": 1000 + }, + { + "from": "action-menu-returned", + "to": "action-confirmation", + "caption": "Open the GitHub Actions confirmation", + "hold_ms": 1000 + }, + { + "from": "action-confirmation", + "to": "action-confirmation-cancelled", + "caption": "Cancel safely and return to the report", + "hold_ms": 1200 + } + ] +} diff --git a/scripts/terminal-e2e.sh b/scripts/terminal-e2e.sh new file mode 100755 index 000000000..e6399126d --- /dev/null +++ b/scripts/terminal-e2e.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TERMINAL_E2E_REPOSITORY_ROOT="$PWD" +TERMINAL_E2E_ARTIFACT_DIRECTORY="${TERMINAL_E2E_ARTIFACT_DIRECTORY:-$TERMINAL_E2E_REPOSITORY_ROOT/artifacts}" +TERMINAL_E2E_SESSION_NAME="react-doctor-terminal-e2e" +TERMINAL_E2E_RECORDING_PATH="$TERMINAL_E2E_ARTIFACT_DIRECTORY/react-doctor-terminal.termctrl" +TERMINAL_E2E_VIDEO_PATH="$TERMINAL_E2E_ARTIFACT_DIRECTORY/react-doctor-terminal.mp4" +TERMINAL_E2E_SCREENSHOT_PATH="$TERMINAL_E2E_ARTIFACT_DIRECTORY/react-doctor-terminal.png" +TERMINAL_E2E_COLUMNS=112 +TERMINAL_E2E_ROWS=30 +TERMINAL_E2E_LONG_WAIT_MS=60000 +TERMINAL_E2E_SCAN_FEEDBACK_WAIT_MS=1500 +TERMINAL_E2E_TYPING_PACE_MS=15 +TERMINAL_E2E_VIDEO_FPS=30 + +mkdir -p "$TERMINAL_E2E_ARTIFACT_DIRECTORY" + +stop_terminal_e2e_session() { + termctrl stop "$TERMINAL_E2E_SESSION_NAME" >/dev/null 2>&1 || true +} + +trap stop_terminal_e2e_session EXIT + +termctrl start "$TERMINAL_E2E_SESSION_NAME" \ + --record "$TERMINAL_E2E_RECORDING_PATH" \ + --cols "$TERMINAL_E2E_COLUMNS" \ + --rows "$TERMINAL_E2E_ROWS" \ + --cwd "$TERMINAL_E2E_REPOSITORY_ROOT" \ + --color always \ + -- bash --noprofile --norc -i + +termctrl send "$TERMINAL_E2E_SESSION_NAME" \ + text:"export PS1='terminal-e2e$ '" enter \ + text:"source scripts/setup-terminal-recording.sh" enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "terminal-recording-ready" \ + --timeout "$TERMINAL_E2E_LONG_WAIT_MS" +termctrl send "$TERMINAL_E2E_SESSION_NAME" text:clear enter +termctrl mark "$TERMINAL_E2E_SESSION_NAME" project-command + +termctrl send "$TERMINAL_E2E_SESSION_NAME" --pace-ms "$TERMINAL_E2E_TYPING_PACE_MS" \ + text:run-terminal-recording-clean-scan enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Select projects" \ + --timeout "$TERMINAL_E2E_LONG_WAIT_MS" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" project-selection +termctrl send "$TERMINAL_E2E_SESSION_NAME" "text: " +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "1/2" +termctrl send "$TERMINAL_E2E_SESSION_NAME" enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Scanning" \ + --timeout "$TERMINAL_E2E_SCAN_FEEDBACK_WAIT_MS" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" scan-started +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "No issues found" \ + --timeout "$TERMINAL_E2E_LONG_WAIT_MS" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" clean-report +termctrl show "$TERMINAL_E2E_SESSION_NAME" +termctrl send "$TERMINAL_E2E_SESSION_NAME" text:q +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "terminal-e2e-first-run-finished" + +termctrl send "$TERMINAL_E2E_SESSION_NAME" text:use-terminal-recording-tui-fixture enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "terminal-e2e-tui-fixture-ready" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" report-command +termctrl send "$TERMINAL_E2E_SESSION_NAME" --pace-ms "$TERMINAL_E2E_TYPING_PACE_MS" \ + text:run-terminal-recording-tui-scan enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Add to GitHub Actions" \ + --timeout "$TERMINAL_E2E_LONG_WAIT_MS" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" action-menu +termctrl send "$TERMINAL_E2E_SESSION_NAME" enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "enter copy context" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" copy-context +termctrl send "$TERMINAL_E2E_SESSION_NAME" escape +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Add to GitHub Actions" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" action-menu-returned +termctrl send "$TERMINAL_E2E_SESSION_NAME" enter +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Add React Doctor to GitHub Actions?" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" action-confirmation +termctrl show "$TERMINAL_E2E_SESSION_NAME" +termctrl send "$TERMINAL_E2E_SESSION_NAME" escape +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "Add to GitHub Actions" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" action-confirmation-cancelled + +termctrl save "$TERMINAL_E2E_SESSION_NAME" \ + --format png \ + --out "$TERMINAL_E2E_SCREENSHOT_PATH" \ + --hide-cursor +termctrl send "$TERMINAL_E2E_SESSION_NAME" text:q +termctrl wait "$TERMINAL_E2E_SESSION_NAME" "terminal-e2e-second-run-finished" +termctrl mark "$TERMINAL_E2E_SESSION_NAME" finished +termctrl stop "$TERMINAL_E2E_SESSION_NAME" + +termctrl markers "$TERMINAL_E2E_RECORDING_PATH" +termctrl video "$TERMINAL_E2E_RECORDING_PATH" \ + --edit "$TERMINAL_E2E_REPOSITORY_ROOT/scripts/terminal-e2e-video.json" \ + --footer \ + --fps "$TERMINAL_E2E_VIDEO_FPS" \ + --tail-ms 0 \ + --hide-cursor \ + --out "$TERMINAL_E2E_VIDEO_PATH" diff --git a/scripts/terminal-recording.tape b/scripts/terminal-recording.tape deleted file mode 100644 index 4c5641609..000000000 --- a/scripts/terminal-recording.tape +++ /dev/null @@ -1,54 +0,0 @@ -Output artifacts/react-doctor-terminal.gif -Output artifacts/react-doctor-terminal.mp4 - -Set Shell "bash" -Set Width 1200 -Set Height 480 -Set FontSize 18 -Set TypingSpeed 35ms -Set Framerate 30 -Set CursorBlink false -Set Theme "Catppuccin Mocha" -Set WaitTimeout 60s - -Hide -Type "source scripts/setup-terminal-recording.sh" -Enter -Wait+Screen /terminal-recording-ready/ -Type "clear" -Enter -Sleep 500ms -Show - -Type "react-doctor --no-lint --no-dead-code --no-supply-chain --no-score" -Enter -Wait+Screen /Select projects/ -Sleep 500ms -Space -Enter -Wait+Screen /No issues found/ -Sleep 2s -Type "q" -Sleep 1s - -Hide -Type "use-terminal-recording-tui-fixture" -Enter -Show - -Type "clear && react-doctor --no-score --no-supply-chain --project app-a" -Enter -Wait+Screen /Add to GitHub Actions/ -Sleep 2s -Enter -Wait+Screen /enter copy context/ -Sleep 2s -Escape -Wait+Screen /Add to GitHub Actions/ -Sleep 1s -Enter -Wait+Screen /Add React Doctor to GitHub Actions?/ -Sleep 2s -Escape -Wait+Screen /Add to GitHub Actions/ -Type "q" From 5ab63075c185f06900d19de60235296e1f2b4be3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 01:18:42 +0000 Subject: [PATCH 14/17] fix(core): preserve refactor API compatibility --- packages/core/src/constants.ts | 8 +++ packages/core/src/services/linter.ts | 20 ++++++ packages/core/src/services/reporter.ts | 24 +++++++ .../utils/resolve-auto-scan-concurrency.ts | 4 +- .../tests/public-api-compatibility.test.ts | 27 +++++++ packages/core/tests/services/linter.test.ts | 71 +++++++++++++++++++ packages/core/tests/services/reporter.test.ts | 24 +++++++ 7 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/public-api-compatibility.test.ts diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 8e4e16670..6fa639ae1 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -627,6 +627,10 @@ export const JSX_OPENER_SCAN_MAX_LINES = 32; // Larger gaps stop being intentional suppressions and become noise. export const SUPPRESSION_NEAR_MISS_MAX_LINES = 10; +export const MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE = 5; + +export const MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE = 3; + // `minimumReleaseAge` in `pnpm-workspace.yaml` is denominated in // minutes. 7 days × 24 h × 60 min = 10080. Surfaced as the // recommended starting point for the supply-chain hardening check. @@ -772,6 +776,10 @@ export const CODE_FRAME_LINES_BELOW = 1; // so we fall back to the bare `file:line` reference instead. export const CODE_FRAME_MAX_LINE_LENGTH_CHARS = 200; +export const CODE_FRAME_BATCH_MAX_SPAN_LINES = 20; + +export const OUTPUT_DETAIL_WRAP_WIDTH_CHARS = 88; + // Typographic "measure" — the line length (in characters) we wrap // prose explanations to for comfortable reading. Kept short (well under // the terminal width) so multi-line blurbs stay easy to scan. diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index 2d9fe50ae..5b179b93a 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -190,4 +190,24 @@ export class Linter extends Context.Service< run: () => Stream.fromIterable(diagnostics), }), ); + + static readonly layerComposite = ( + backends: ReadonlyArray, + ): Layer.Layer => + Layer.succeed( + Linter, + Linter.of({ + run: (input) => { + if (backends.length === 0) { + return Stream.empty; + } + + let diagnostics = backends[0].run(input); + for (let index = 1; index < backends.length; index++) { + diagnostics = diagnostics.pipe(Stream.concat(backends[index].run(input))); + } + return diagnostics; + }, + }), + ); } diff --git a/packages/core/src/services/reporter.ts b/packages/core/src/services/reporter.ts index a44e959a2..512139c9d 100644 --- a/packages/core/src/services/reporter.ts +++ b/packages/core/src/services/reporter.ts @@ -2,6 +2,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { Diagnostic } from "../schemas.js"; /** @@ -50,4 +53,25 @@ export class Reporter extends Context.Service< }), ), ).pipe(Layer.provideMerge(ReporterCapture.layer)); + + static readonly layerNdjson = (filePath: string): Layer.Layer => + Layer.effect( + Reporter, + Effect.sync(() => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const fileHandle = fs.openSync(filePath, "a"); + const encodeDiagnostic = Schema.encodeUnknownSync(Diagnostic); + + const emit = (diagnostic: Diagnostic): Effect.Effect => + Effect.sync(() => { + fs.writeSync(fileHandle, `${JSON.stringify(encodeDiagnostic(diagnostic))}\n`); + }); + + const finalize = Effect.sync(() => { + fs.closeSync(fileHandle); + }); + + return Reporter.of({ emit, finalize }); + }), + ); } diff --git a/packages/core/src/utils/resolve-auto-scan-concurrency.ts b/packages/core/src/utils/resolve-auto-scan-concurrency.ts index c6ef5687c..9a089cf98 100644 --- a/packages/core/src/utils/resolve-auto-scan-concurrency.ts +++ b/packages/core/src/utils/resolve-auto-scan-concurrency.ts @@ -5,6 +5,8 @@ import { } from "./read-system-concurrency-facts.js"; import { resolveScanConcurrency } from "./resolve-scan-concurrency.js"; +export interface AutoScanConcurrencyFacts extends SystemConcurrencyFacts {} + /** * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available @@ -23,7 +25,7 @@ import { resolveScanConcurrency } from "./resolve-scan-concurrency.js"; * limited, and ceiling cases without mocking `os` or the filesystem. */ export const resolveAutoScanConcurrency = ( - facts: SystemConcurrencyFacts = readSystemConcurrencyFacts(), + facts: AutoScanConcurrencyFacts = readSystemConcurrencyFacts(), ): number => { const availableMemoryBytes = Math.min( facts.totalMemoryBytes, diff --git a/packages/core/tests/public-api-compatibility.test.ts b/packages/core/tests/public-api-compatibility.test.ts new file mode 100644 index 000000000..f6b132ee8 --- /dev/null +++ b/packages/core/tests/public-api-compatibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + CODE_FRAME_BATCH_MAX_SPAN_LINES, + MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE, + MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE, + OUTPUT_DETAIL_WRAP_WIDTH_CHARS, + type AutoScanConcurrencyFacts, + resolveAutoScanConcurrency, +} from "@react-doctor/core"; + +describe("public API compatibility", () => { + it("retains output constants", () => { + expect(MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE).toBe(5); + expect(MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE).toBe(3); + expect(CODE_FRAME_BATCH_MAX_SPAN_LINES).toBe(20); + expect(OUTPUT_DETAIL_WRAP_WIDTH_CHARS).toBe(88); + }); + + it("retains AutoScanConcurrencyFacts", () => { + const facts: AutoScanConcurrencyFacts = { + availableCores: 4, + totalMemoryBytes: 4 * 1024 * 1024 * 1024, + cgroupMemoryLimitBytes: undefined, + }; + expect(resolveAutoScanConcurrency(facts)).toBe(4); + }); +}); diff --git a/packages/core/tests/services/linter.test.ts b/packages/core/tests/services/linter.test.ts index 0719a0887..ace3c8815 100644 --- a/packages/core/tests/services/linter.test.ts +++ b/packages/core/tests/services/linter.test.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; import type { Diagnostic, ProjectInfo } from "@react-doctor/core"; @@ -75,3 +76,73 @@ describe("Linter.layerOf", () => { expect(Array.from(collected)).toEqual([]); }); }); + +describe("Linter.layerComposite", () => { + it("concatenates streams from every backend in order", async () => { + const firstBackend = Linter.of({ + run: () => Stream.fromIterable([{ ...sampleDiagnostic, rule: "rule-from-first" }]), + }); + const secondBackend = Linter.of({ + run: () => Stream.fromIterable([{ ...sampleDiagnostic, rule: "rule-from-second" }]), + }); + const collected = await Effect.runPromise( + Effect.gen(function* () { + const linter = yield* Linter; + return yield* Stream.runCollect(linter.run(lintInput)); + }).pipe( + Effect.provide( + Layer.mergeAll( + Linter.layerComposite([firstBackend, secondBackend]), + LintPartialFailures.layerLive, + ), + ), + ), + ); + expect(Array.from(collected).map((diagnostic) => diagnostic.rule)).toEqual([ + "rule-from-first", + "rule-from-second", + ]); + }); + + it("emits an empty stream without backends", async () => { + const collected = await Effect.runPromise( + Effect.gen(function* () { + const linter = yield* Linter; + return yield* Stream.runCollect(linter.run(lintInput)); + }).pipe( + Effect.provide(Layer.mergeAll(Linter.layerComposite([]), LintPartialFailures.layerLive)), + ), + ); + expect(Array.from(collected)).toEqual([]); + }); + + it("shares partial failures across backends", async () => { + const createBackend = (failure: string): Linter["Service"] => + Linter.of({ + run: () => + Stream.unwrap( + Effect.gen(function* () { + const partialFailures = yield* LintPartialFailures; + yield* Ref.update(partialFailures, (existing) => [...existing, failure]); + return Stream.empty; + }), + ), + }); + const failures = await Effect.runPromise( + Effect.gen(function* () { + const linter = yield* Linter; + yield* Stream.runCollect(linter.run(lintInput)); + const partialFailures = yield* LintPartialFailures; + return yield* Ref.get(partialFailures); + }).pipe( + Effect.provide( + Layer.mergeAll( + Linter.layerComposite([createBackend("first"), createBackend("second")]), + LintPartialFailures.layerLive, + ), + ), + ), + ); + expect(failures).toEqual(["first", "second"]); + }); +}); diff --git a/packages/core/tests/services/reporter.test.ts b/packages/core/tests/services/reporter.test.ts index 078941388..c20cf07ae 100644 --- a/packages/core/tests/services/reporter.test.ts +++ b/packages/core/tests/services/reporter.test.ts @@ -1,6 +1,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { Diagnostic } from "../../src/schemas.js"; import { Reporter, ReporterCapture } from "../../src/services/reporter.js"; @@ -70,3 +73,24 @@ describe("Reporter.layerCapture", () => { expect(captured).toEqual([]); }); }); + +describe("Reporter.layerNdjson", () => { + it("appends schema-encoded diagnostics", async () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-reporter-")); + const reportPath = path.join(temporaryDirectory, "nested", "diagnostics.ndjson"); + + try { + await Effect.runPromise( + Effect.gen(function* () { + const reporter = yield* Reporter; + yield* reporter.emit(sampleDiagnostic); + yield* reporter.finalize; + }).pipe(Effect.provide(Reporter.layerNdjson(reportPath))), + ); + + expect(fs.readFileSync(reportPath, "utf8")).toBe(`${JSON.stringify(sampleDiagnostic)}\n`); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); +}); From 86c0553e3eb639a4fdf64cebb678fc0f822d8cf7 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 06:57:01 +0000 Subject: [PATCH 15/17] test(api): allow full-scan CI headroom --- packages/api/tests/diagnose.test.ts | 33 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 0c407f4cf..4d24fee4d 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -17,6 +17,7 @@ const FIXTURES_DIRECTORY = path.resolve( "tests", "fixtures", ); +const DIAGNOSE_LINT_TOGGLE_TEST_TIMEOUT_MS = 60_000; const noReactTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-api-test-")); fs.writeFileSync( @@ -103,20 +104,24 @@ describe("diagnose", () => { expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0); }); - it("respects lint: false by providing a no-op linter layer", async () => { - const directory = path.join(FIXTURES_DIRECTORY, "basic-react"); - const lintEnabledResult = await diagnose(directory, { deadCode: false, lint: true }); - const lintDisabledResult = await diagnose(directory, { deadCode: false, lint: false }); - const lintEnabledSourceDiagnostics = lintEnabledResult.diagnostics.filter( - (diagnostic) => diagnostic.filePath !== "package.json", - ); - const lintDisabledSourceDiagnostics = lintDisabledResult.diagnostics.filter( - (diagnostic) => diagnostic.filePath !== "package.json", - ); - - expect(lintEnabledSourceDiagnostics.length).toBeGreaterThan(0); - expect(lintDisabledSourceDiagnostics).toHaveLength(0); - }); + it( + "respects lint: false by providing a no-op linter layer", + { timeout: DIAGNOSE_LINT_TOGGLE_TEST_TIMEOUT_MS }, + async () => { + const directory = path.join(FIXTURES_DIRECTORY, "basic-react"); + const lintEnabledResult = await diagnose(directory, { deadCode: false, lint: true }); + const lintDisabledResult = await diagnose(directory, { deadCode: false, lint: false }); + const lintEnabledSourceDiagnostics = lintEnabledResult.diagnostics.filter( + (diagnostic) => diagnostic.filePath !== "package.json", + ); + const lintDisabledSourceDiagnostics = lintDisabledResult.diagnostics.filter( + (diagnostic) => diagnostic.filePath !== "package.json", + ); + + expect(lintEnabledSourceDiagnostics.length).toBeGreaterThan(0); + expect(lintDisabledSourceDiagnostics).toHaveLength(0); + }, + ); }); describe("diagnose({ projects })", () => { From 8fa98d7cb568900bd34920e4ae357b718d99915d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 07:04:19 +0000 Subject: [PATCH 16/17] test(api): avoid redundant lint subprocess --- packages/api/tests/diagnose.test.ts | 31 ++++++++++------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 4d24fee4d..d00e343a8 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -17,8 +17,6 @@ const FIXTURES_DIRECTORY = path.resolve( "tests", "fixtures", ); -const DIAGNOSE_LINT_TOGGLE_TEST_TIMEOUT_MS = 60_000; - const noReactTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-api-test-")); fs.writeFileSync( path.join(noReactTempDirectory, "package.json"), @@ -104,24 +102,17 @@ describe("diagnose", () => { expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0); }); - it( - "respects lint: false by providing a no-op linter layer", - { timeout: DIAGNOSE_LINT_TOGGLE_TEST_TIMEOUT_MS }, - async () => { - const directory = path.join(FIXTURES_DIRECTORY, "basic-react"); - const lintEnabledResult = await diagnose(directory, { deadCode: false, lint: true }); - const lintDisabledResult = await diagnose(directory, { deadCode: false, lint: false }); - const lintEnabledSourceDiagnostics = lintEnabledResult.diagnostics.filter( - (diagnostic) => diagnostic.filePath !== "package.json", - ); - const lintDisabledSourceDiagnostics = lintDisabledResult.diagnostics.filter( - (diagnostic) => diagnostic.filePath !== "package.json", - ); - - expect(lintEnabledSourceDiagnostics.length).toBeGreaterThan(0); - expect(lintDisabledSourceDiagnostics).toHaveLength(0); - }, - ); + it("respects lint: false by providing a no-op linter layer", async () => { + const result = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), { + deadCode: false, + lint: false, + }); + const sourceDiagnostics = result.diagnostics.filter( + (diagnostic) => diagnostic.filePath !== "package.json", + ); + + expect(sourceDiagnostics).toHaveLength(0); + }); }); describe("diagnose({ projects })", () => { From 7ab01e4f12261d9dc8d20248bc3443480c9e9150 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 8 Aug 2026 07:15:29 +0000 Subject: [PATCH 17/17] ci: serialize Node 20 test packages --- .github/workflows/ci.yml | 6 +++++- packages/api/tests/diagnose.test.ts | 16 ++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ecd2d454..43b0ec5ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,9 +64,13 @@ jobs: - run: pnpm install --frozen-lockfile --prefer-offline - name: Run tests - if: ${{ matrix.os != 'blacksmith-8vcpu-windows-2025' }} + if: ${{ matrix.os != 'blacksmith-8vcpu-windows-2025' && matrix.node-version != '20.19.0' }} run: pnpm test + - name: Run Node 20 tests serially + if: ${{ matrix.node-version == '20.19.0' }} + run: pnpm test --concurrency=1 + - name: Run Windows tests serially if: ${{ matrix.os == 'blacksmith-8vcpu-windows-2025' }} run: pnpm test --concurrency=1 diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index d00e343a8..0c407f4cf 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -17,6 +17,7 @@ const FIXTURES_DIRECTORY = path.resolve( "tests", "fixtures", ); + const noReactTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-api-test-")); fs.writeFileSync( path.join(noReactTempDirectory, "package.json"), @@ -103,15 +104,18 @@ describe("diagnose", () => { }); it("respects lint: false by providing a no-op linter layer", async () => { - const result = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), { - deadCode: false, - lint: false, - }); - const sourceDiagnostics = result.diagnostics.filter( + const directory = path.join(FIXTURES_DIRECTORY, "basic-react"); + const lintEnabledResult = await diagnose(directory, { deadCode: false, lint: true }); + const lintDisabledResult = await diagnose(directory, { deadCode: false, lint: false }); + const lintEnabledSourceDiagnostics = lintEnabledResult.diagnostics.filter( + (diagnostic) => diagnostic.filePath !== "package.json", + ); + const lintDisabledSourceDiagnostics = lintDisabledResult.diagnostics.filter( (diagnostic) => diagnostic.filePath !== "package.json", ); - expect(sourceDiagnostics).toHaveLength(0); + expect(lintEnabledSourceDiagnostics.length).toBeGreaterThan(0); + expect(lintDisabledSourceDiagnostics).toHaveLength(0); }); });