diff --git a/.changeset/light-contracts-share.md b/.changeset/light-contracts-share.md new file mode 100644 index 0000000000..58c87ff637 --- /dev/null +++ b/.changeset/light-contracts-share.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Add a lightweight contracts entry for shared framework and capability metadata. diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index 3b3d2ccb86..cae39b8083 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -17,6 +17,7 @@ import { mergeReactDoctorConfigs, Progress, Project, + ProjectChecks, Reporter, resolveScanTarget, restoreLegacyThrow, @@ -80,6 +81,7 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => { }); return Layer.mergeAll( Project.layerNode, + ProjectChecks.layerNode, configLayer, input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]), Files.layerNode, diff --git a/packages/core/src/assemble-inspect-output.ts b/packages/core/src/assemble-inspect-output.ts new file mode 100644 index 0000000000..a95a5f70ce --- /dev/null +++ b/packages/core/src/assemble-inspect-output.ts @@ -0,0 +1,139 @@ +import type { ScoreRequestMetadata } from "./calculate-score.js"; +import type { OxlintUnavailable, ReactDoctorErrorReason } from "./errors.js"; +import type { + Diagnostic, + ProjectInfo, + ReactDoctorConfig, + ScoreResult, + SuppressedRuleCount, +} from "./types/index.js"; + +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. + */ + readonly lintFailureReasonTag: ReactDoctorErrorReason["_tag"] | null; + /** + * The `kind` of an `OxlintUnavailable` lint failure, or `null` for any + * other failure. + */ + readonly lintFailureReasonKind: OxlintUnavailable["kind"] | null; + readonly lintPartialFailures: ReadonlyArray; + /** `false` when dead-code analysis was disabled, skipped, or discarded. */ + readonly didDeadCodeFail: boolean; + readonly deadCodeFailureReason: string | null; + /** Whether dead-code analysis ran concurrently with lint for this scan. */ + readonly deadCodeOverlapped: boolean; + /** Number of files reported by the scan. */ + readonly scannedFileCount: number; + /** Absolute paths considered by scans that request a multi-project summary. */ + 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 used by the linter. */ + readonly scanConcurrency: number; + /** Whether the supply-chain pass failed open after exceeding its overlap budget. */ + readonly supplyChainOverlapTimedOut: boolean; + /** Whether the security scan failed open after a filesystem error. */ + readonly securityScanFailed: boolean; + /** Per-file lint cache outcomes, or `null` when the cache was not consulted. */ + readonly lintCacheHitFileCount: number | null; + readonly lintCacheTotalFileCount: number | null; + /** Sidecar replay outcomes, or `null` when the sidecar was not consulted. */ + readonly lintSidecarReplayedFileCount: number | null; + readonly lintSidecarTotalFileCount: number | null; + /** Whole-result dead-code cache outcome, or `null` when it was not consulted. */ + readonly deadCodeCacheHit: boolean | null; + /** Incremental dead-code summary-cache outcomes, or `null` when unavailable. */ + readonly deadCodeSummaryCacheHits: number | null; + readonly deadCodeSummaryCacheMisses: number | null; + /** Per-rule tallies of diagnostics explicitly suppressed by the user. */ + readonly suppressedRuleCounts: ReadonlyArray; +} + +export interface InspectLintCompletion { + readonly didFail: boolean; + readonly failureReason: string | null; + readonly failureReasonTag: ReactDoctorErrorReason["_tag"] | null; + readonly failureReasonKind: OxlintUnavailable["kind"] | null; + readonly partialFailures: ReadonlyArray; + readonly analyzedFiles: ReadonlyArray; + readonly cacheHitFileCount: number | null; + readonly cacheTotalFileCount: number | null; + readonly sidecarReplayedFileCount: number | null; + readonly sidecarTotalFileCount: number | null; +} + +export interface InspectDeadCodeCompletion { + readonly didFail: boolean; + readonly failureReason: string | null; + readonly didOverlap: boolean; + readonly cacheHit: boolean | null; + readonly summaryCacheHits: number | null; + readonly summaryCacheMisses: number | null; +} + +export interface InspectScanCompletion { + readonly scannedFileCount: number; + readonly scannedFilePaths: ReadonlyArray; + readonly elapsedMilliseconds: number; + readonly concurrency: number; +} + +export interface AssembleInspectOutputInput { + readonly project: ProjectInfo; + readonly userConfig: ReactDoctorConfig | null; + readonly resolvedDirectory: string; + readonly diagnostics: ReadonlyArray; + readonly score: ScoreResult | null; + readonly scoreMetadata: ScoreRequestMetadata; + readonly lint: InspectLintCompletion; + readonly deadCode: InspectDeadCodeCompletion; + readonly scan: InspectScanCompletion; + readonly supplyChainOverlapTimedOut: boolean; + readonly securityScanFailed: boolean; + readonly suppressedRuleCounts: ReadonlyArray; +} + +export const assembleInspectOutput = (input: AssembleInspectOutputInput): InspectOutput => ({ + project: input.project, + userConfig: input.userConfig, + resolvedDirectory: input.resolvedDirectory, + diagnostics: input.diagnostics, + score: input.score, + scoreMetadata: input.scoreMetadata, + didLintFail: input.lint.didFail, + lintFailureReason: input.lint.failureReason, + lintFailureReasonTag: input.lint.failureReasonTag, + lintFailureReasonKind: input.lint.failureReasonKind, + lintPartialFailures: input.lint.partialFailures, + didDeadCodeFail: input.deadCode.didFail, + deadCodeFailureReason: input.deadCode.failureReason, + deadCodeOverlapped: input.deadCode.didOverlap, + scannedFileCount: input.scan.scannedFileCount, + scannedFilePaths: input.scan.scannedFilePaths, + analyzedFiles: input.lint.analyzedFiles, + scanElapsedMilliseconds: input.scan.elapsedMilliseconds, + scanConcurrency: input.scan.concurrency, + supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut, + securityScanFailed: input.securityScanFailed, + lintCacheHitFileCount: input.lint.cacheHitFileCount, + lintCacheTotalFileCount: input.lint.cacheTotalFileCount, + lintSidecarReplayedFileCount: input.lint.sidecarReplayedFileCount, + lintSidecarTotalFileCount: input.lint.sidecarTotalFileCount, + deadCodeCacheHit: input.lint.didFail ? null : input.deadCode.cacheHit, + deadCodeSummaryCacheHits: input.lint.didFail ? null : input.deadCode.summaryCacheHits, + deadCodeSummaryCacheMisses: input.lint.didFail ? null : input.deadCode.summaryCacheMisses, + suppressedRuleCounts: input.suppressedRuleCounts, +}); diff --git a/packages/core/src/background-analyzer-execution.ts b/packages/core/src/background-analyzer-execution.ts new file mode 100644 index 0000000000..43fc2645b3 --- /dev/null +++ b/packages/core/src/background-analyzer-execution.ts @@ -0,0 +1,126 @@ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { checkSecurityScanCooperative } from "./check-security-scan.js"; +import { SupplyChainOverlapTimeoutMs } from "./refs.js"; +import type { ProjectChecks } from "./services/project-checks.js"; +import type { SupplyChain } from "./services/supply-chain.js"; +import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js"; + +interface StartBackgroundAnalyzerExecutionInput { + readonly projectChecksService: ProjectChecks["Service"]; + readonly supplyChainService: SupplyChain["Service"]; + readonly rootDirectory: string; + readonly project: ProjectInfo; + readonly userConfig: ReactDoctorConfig | null; + readonly isDiffMode: boolean; + readonly shouldRunSupplyChain: boolean; + readonly ignoredTags: ReadonlySet; + readonly includedTags: ReadonlySet | undefined; + readonly includeTagDefaults: boolean | undefined; + readonly processDiagnostics: ( + stream: Stream.Stream, + ) => Stream.Stream; +} + +interface SupplyChainForkResult { + readonly diagnostics: ReadonlyArray; + readonly timedOut: boolean; +} + +interface BackgroundAnalyzerResult { + readonly environmentDiagnostics: ReadonlyArray; + readonly securityDiagnostics: ReadonlyArray; + readonly supplyChainDiagnostics: ReadonlyArray; + readonly securityScanFailed: boolean; + readonly supplyChainOverlapTimedOut: boolean; +} + +interface BackgroundAnalyzerExecution { + readonly join: Effect.Effect; +} + +export const startBackgroundAnalyzerExecution = ( + input: StartBackgroundAnalyzerExecutionInput, +): Effect.Effect => + Effect.gen(function* () { + const environmentDiagnostics = input.isDiffMode + ? [] + : yield* input.projectChecksService.run({ + rootDirectory: input.rootDirectory, + project: input.project, + }); + const processedEnvironmentDiagnostics = yield* Stream.runCollect( + input.processDiagnostics(Stream.fromIterable(environmentDiagnostics)), + ); + + const securityScanFailedRef = yield* Ref.make(false); + const securityScanFiber = yield* Effect.forkChild( + Stream.runCollect( + input.processDiagnostics( + input.isDiffMode + ? Stream.empty + : Stream.unwrap( + Effect.tryPromise(() => + checkSecurityScanCooperative(input.rootDirectory, { + project: input.project, + ignoredTags: input.ignoredTags, + includedTags: input.includedTags, + includeTagDefaults: input.includeTagDefaults, + }), + ).pipe( + Effect.map(Stream.fromIterable), + Effect.catch(() => + Ref.set(securityScanFailedRef, true).pipe(Effect.as(Stream.empty)), + ), + ), + ), + ), + ).pipe(Effect.withSpan("SecurityScan.run")), + ); + + const supplyChainOverlapTimeoutMs = yield* SupplyChainOverlapTimeoutMs; + const supplyChainFiber = yield* Effect.forkChild( + input.shouldRunSupplyChain + ? Stream.runCollect( + input.processDiagnostics( + input.supplyChainService.run({ + rootDirectory: input.rootDirectory, + userConfig: input.userConfig, + }), + ), + ).pipe( + Effect.map( + (diagnostics): SupplyChainForkResult => ({ + diagnostics, + timedOut: false, + }), + ), + Effect.timeout(supplyChainOverlapTimeoutMs), + Effect.orElseSucceed( + (): SupplyChainForkResult => ({ diagnostics: [], timedOut: true }), + ), + ) + : Effect.succeed({ + diagnostics: [], + timedOut: false, + }), + ); + + return { + join: Effect.gen(function* () { + const supplyChainResult = yield* Fiber.join(supplyChainFiber); + const securityDiagnostics = yield* Fiber.join(securityScanFiber); + const securityScanFailed = yield* Ref.get(securityScanFailedRef); + + return { + environmentDiagnostics: processedEnvironmentDiagnostics, + securityDiagnostics, + supplyChainDiagnostics: supplyChainResult.diagnostics, + securityScanFailed, + supplyChainOverlapTimedOut: supplyChainResult.timedOut, + }; + }), + }; + }); diff --git a/packages/core/src/build-dead-code-plan.ts b/packages/core/src/build-dead-code-plan.ts new file mode 100644 index 0000000000..6bc45c13b6 --- /dev/null +++ b/packages/core/src/build-dead-code-plan.ts @@ -0,0 +1,47 @@ +import { + DEAD_CODE_OVERLAP_PARSE_SHARE, + MIN_DEAD_CODE_PARSE_CONCURRENCY, + MIN_SCAN_CONCURRENCY, +} from "./constants.js"; +import type { ReactDoctorConfig } from "./types/index.js"; +import { deadCodeMaySurfaceWhenWarningsHidden } from "./utils/dead-code-may-surface.js"; + +export interface BuildDeadCodePlanInput { + readonly runDeadCode: boolean; + readonly isDiffMode: boolean; + readonly showWarnings: boolean; + readonly userConfig: ReactDoctorConfig | null; + readonly overlapMode: "auto" | "on" | "off"; + readonly scanConcurrency: number; +} + +export interface DeadCodePlan { + readonly shouldRun: boolean; + readonly shouldOverlap: boolean; + readonly parseConcurrency: number | undefined; + readonly lintConcurrency: number; +} + +export const buildDeadCodePlan = (input: BuildDeadCodePlanInput): DeadCodePlan => { + const shouldRun = + input.runDeadCode && + !input.isDiffMode && + (input.showWarnings || deadCodeMaySurfaceWhenWarningsHidden(input.userConfig)); + const shouldOverlap = shouldRun && input.overlapMode === "on"; + const parseConcurrency = shouldOverlap + ? Math.max( + MIN_DEAD_CODE_PARSE_CONCURRENCY, + Math.floor(input.scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE), + ) + : undefined; + + return { + shouldRun, + shouldOverlap, + parseConcurrency, + lintConcurrency: + parseConcurrency === undefined + ? input.scanConcurrency + : Math.max(MIN_SCAN_CONCURRENCY, input.scanConcurrency - parseConcurrency), + }; +}; diff --git a/packages/core/src/build-lint-execution.ts b/packages/core/src/build-lint-execution.ts new file mode 100644 index 0000000000..944dc81e6a --- /dev/null +++ b/packages/core/src/build-lint-execution.ts @@ -0,0 +1,82 @@ +import type { ProjectInfo } from "./types/index.js"; +import type { ResolvedConfig } from "./services/config.js"; +import type { LintFileCoverage, LintInput } from "./services/linter.js"; + +interface LintExecutionOptions { + readonly nodeBinaryPath?: string; + readonly customRulesOnly: boolean; + readonly respectInlineDisables: boolean; + readonly adoptExistingLintConfig: boolean; + readonly ignoredTags: ReadonlySet; + readonly includedTags?: ReadonlySet; + readonly includeTagDefaults?: boolean; + readonly deadlineEpochMs?: number; +} + +interface BuildLintExecutionInput { + readonly rootDirectory: string; + readonly project: ProjectInfo; + readonly includePaths: ReadonlyArray | null | undefined; + readonly options: LintExecutionOptions; + readonly resolvedConfig: ResolvedConfig; + readonly reportFileProgress: (scannedFileCount: number, totalFileCount: number) => void; +} + +interface LintExecutionState { + lastReportedTotalFileCount: number; + fileCoverage: LintFileCoverage | null; + cacheHitFileCount: number | null; + cacheTotalFileCount: number | null; + sidecarReplayedFileCount: number | null; + sidecarTotalFileCount: number | null; +} + +interface LintExecution { + readonly input: LintInput; + readonly state: LintExecutionState; +} + +export const buildLintExecution = (input: BuildLintExecutionInput): LintExecution => { + const state: LintExecutionState = { + lastReportedTotalFileCount: 0, + fileCoverage: null, + cacheHitFileCount: null, + cacheTotalFileCount: null, + sidecarReplayedFileCount: null, + sidecarTotalFileCount: null, + }; + + return { + input: { + rootDirectory: input.rootDirectory, + project: input.project, + includePaths: input.includePaths ?? undefined, + nodeBinaryPath: input.options.nodeBinaryPath, + customRulesOnly: input.options.customRulesOnly, + respectInlineDisables: input.options.respectInlineDisables, + adoptExistingLintConfig: input.options.adoptExistingLintConfig, + ignoredTags: input.options.ignoredTags, + includedTags: input.options.includedTags, + includeTagDefaults: input.options.includeTagDefaults, + userConfig: input.resolvedConfig.config ?? undefined, + configSourceDirectory: input.resolvedConfig.configSourceDirectory ?? undefined, + onFileProgress: (scannedFileCount, totalFileCount) => { + state.lastReportedTotalFileCount = totalFileCount; + input.reportFileProgress(scannedFileCount, totalFileCount); + }, + onFileCoverage: (coverage) => { + state.fileCoverage = coverage; + }, + onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => { + state.cacheHitFileCount = cacheHitFileCount; + state.cacheTotalFileCount = totalConsideredFileCount; + }, + onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => { + state.sidecarReplayedFileCount = sidecarReplayedFileCount; + state.sidecarTotalFileCount = sidecarConsideredFileCount; + }, + deadlineEpochMs: input.options.deadlineEpochMs, + }, + state, + }; +}; diff --git a/packages/core/src/check-reduced-motion.ts b/packages/core/src/check-reduced-motion.ts index 35865fe847..b074e0d3b4 100644 --- a/packages/core/src/check-reduced-motion.ts +++ b/packages/core/src/check-reduced-motion.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor"; +import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor/contracts"; import ts from "typescript"; import type { Diagnostic } from "./types/index.js"; import { getTypescriptScriptKind } from "./utils/get-typescript-script-kind.js"; diff --git a/packages/core/src/check-security-scan.ts b/packages/core/src/check-security-scan.ts index ae90c97f2e..634fa15f63 100644 --- a/packages/core/src/check-security-scan.ts +++ b/packages/core/src/check-security-scan.ts @@ -9,7 +9,7 @@ import type { Diagnostic, ProjectInfo } from "./types/index.js"; import { isPathGitIgnored } from "./utils/is-path-git-ignored.js"; import { shouldEnableRuleByDefaultStatus } from "./utils/should-enable-rule-by-default-status.js"; import { yieldToEventLoop } from "./utils/yield-to-event-loop.js"; -import type { Capability } from "oxlint-plugin-react-doctor"; +import type { Capability } from "oxlint-plugin-react-doctor/contracts"; export interface CheckSecurityScanOptions { readonly project?: ProjectInfo; diff --git a/packages/core/src/clear-caches.ts b/packages/core/src/clear-caches.ts new file mode 100644 index 0000000000..3ba09d1633 --- /dev/null +++ b/packages/core/src/clear-caches.ts @@ -0,0 +1,15 @@ +import { clearIgnorePatternsCache } from "./collect-ignore-patterns.js"; +import { clearConfigCache } from "./load-config.js"; +import { clearPackageJsonCache } from "./project-info/package-json.js"; +import { clearProjectCache } from "./project-info/discover-project.js"; +import { clearMinifiedFileCache } from "./utils/is-large-minified-file.js"; +import { clearPackageRoleCache } from "./utils/classify-package-role.js"; + +export const clearCoreCaches = (): void => { + clearProjectCache(); + clearConfigCache(); + clearPackageJsonCache(); + clearIgnorePatternsCache(); + clearPackageRoleCache(); + clearMinifiedFileCache(); +}; diff --git a/packages/core/src/dead-code-execution.ts b/packages/core/src/dead-code-execution.ts new file mode 100644 index 0000000000..bc636234e4 --- /dev/null +++ b/packages/core/src/dead-code-execution.ts @@ -0,0 +1,186 @@ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { DeadCodePlan } from "./build-dead-code-plan.js"; +import { DEAD_CODE_PHASE_TIMEOUT_MS, MILLISECONDS_PER_SECOND } from "./constants.js"; +import { ReactDoctorError } from "./errors.js"; +import type { DeadCode } from "./services/dead-code.js"; +import type { ProgressHandle } from "./services/progress.js"; +import type { Diagnostic } from "./types/index.js"; +import { remainingDeadlineBudgetMs } from "./utils/remaining-deadline-budget-ms.js"; +import { resolveDeadCodeTimeout } from "./utils/resolve-dead-code-timeout.js"; + +export interface DeadCodeFailureState { + readonly didFail: boolean; + readonly reason: string | null; +} + +interface StartDeadCodeExecutionInput { + readonly deadCodeService: DeadCode["Service"]; + readonly failureRef: Ref.Ref; + readonly plan: DeadCodePlan; + readonly rootDirectory: string; + readonly discoveredSourceFileCount: number; + readonly scanConcurrency: number; + readonly configuredPhaseTimeoutMs: number; + readonly deadlineEpochMs: number | undefined; + readonly processDiagnostics: ( + stream: Stream.Stream, + ) => Stream.Stream; +} + +interface SettleDeadCodeExecutionInput { + readonly lintDidFail: boolean; + readonly totalFileCount: number; + readonly scannedFilesLabel: string; + readonly progress: ProgressHandle; +} + +export interface DeadCodeExecutionResult { + readonly diagnostics: ReadonlyArray; + readonly failure: DeadCodeFailureState; + readonly cacheHit: boolean | null; + readonly summaryCacheHits: number | null; + readonly summaryCacheMisses: number | null; +} + +export interface DeadCodeExecution { + readonly settle: (input: SettleDeadCodeExecutionInput) => Effect.Effect; +} + +interface DeadCodeTimeout { + readonly workerTimeoutMs: number; + readonly phaseTimeoutMs: number; +} + +export const startDeadCodeExecution = ( + input: StartDeadCodeExecutionInput, +): Effect.Effect => + Effect.gen(function* () { + let cacheHit: boolean | null = null; + let summaryCacheHits: number | null = null; + let summaryCacheMisses: number | null = null; + + const resolvePhaseTimeoutMs = (scaledPhaseTimeoutMs: number): number => + input.configuredPhaseTimeoutMs === DEAD_CODE_PHASE_TIMEOUT_MS + ? scaledPhaseTimeoutMs + : input.configuredPhaseTimeoutMs; + + const capToDeadline = (phaseTimeoutMs: number): number => + input.deadlineEpochMs === undefined + ? phaseTimeoutMs + : Math.min(phaseTimeoutMs, remainingDeadlineBudgetMs(input.deadlineEpochMs)); + + const collectDiagnostics = (timeout: DeadCodeTimeout) => + Stream.runCollect( + input.processDiagnostics( + input.deadCodeService + .run({ + rootDirectory: input.rootDirectory, + parseConcurrency: input.plan.parseConcurrency, + workerTimeoutMs: timeout.workerTimeoutMs, + onCacheOutcome: (didHitCache) => { + cacheHit = didHitCache; + }, + onSummaryCacheStats: (stats) => { + summaryCacheHits = stats.hits; + summaryCacheMisses = stats.misses; + }, + }) + .pipe( + Stream.catchTag("ReactDoctorError", (error: ReactDoctorError) => + Stream.unwrap( + Ref.set(input.failureRef, { + didFail: true, + reason: error.message, + }).pipe(Effect.as(Stream.empty)), + ), + ), + ), + ), + ).pipe( + Effect.timeoutOption(timeout.phaseTimeoutMs), + Effect.flatMap( + Option.match({ + onNone: () => + Ref.set(input.failureRef, { + didFail: true, + reason: `Dead-code analysis exceeded ${Math.round( + timeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND, + )}s and was skipped.`, + }).pipe(Effect.as([])), + onSome: Effect.succeed, + }), + ), + ); + + // Overlap starts before lint. Its timeout uses discovery's file count and + // the reduced parse share because lint's final file count is not known yet. + const overlapTimeout = resolveDeadCodeTimeout({ + sourceFileCount: input.discoveredSourceFileCount, + deadCodeConcurrency: input.plan.parseConcurrency ?? input.scanConcurrency, + fullConcurrency: input.scanConcurrency, + }); + const overlapFiber = input.plan.shouldOverlap + ? yield* Effect.forkChild( + collectDiagnostics({ + workerTimeoutMs: overlapTimeout.workerTimeoutMs, + phaseTimeoutMs: capToDeadline(resolvePhaseTimeoutMs(overlapTimeout.phaseTimeoutMs)), + }), + ) + : null; + + return { + settle: (settleInput) => + Effect.gen(function* () { + let diagnostics: ReadonlyArray = []; + + if (settleInput.lintDidFail) { + if (overlapFiber !== null) yield* Fiber.interrupt(overlapFiber); + } else if (input.plan.shouldRun) { + const isDeadlineSpent = + input.deadlineEpochMs !== undefined && + remainingDeadlineBudgetMs(input.deadlineEpochMs) === 0; + + if (isDeadlineSpent) { + if (overlapFiber !== null) yield* Fiber.interrupt(overlapFiber); + yield* Ref.set(input.failureRef, { + didFail: true, + reason: "Dead-code analysis skipped — max scan duration reached.", + }); + } else { + yield* settleInput.progress.update( + `Scanned ${settleInput.scannedFilesLabel}, analyzing dead code...`, + ); + + const sequentialTimeout = resolveDeadCodeTimeout({ + sourceFileCount: settleInput.totalFileCount, + deadCodeConcurrency: input.scanConcurrency, + fullConcurrency: input.scanConcurrency, + }); + diagnostics = + overlapFiber === null + ? yield* collectDiagnostics({ + workerTimeoutMs: sequentialTimeout.workerTimeoutMs, + phaseTimeoutMs: capToDeadline( + resolvePhaseTimeoutMs(sequentialTimeout.phaseTimeoutMs), + ), + }) + : yield* Fiber.join(overlapFiber); + } + } + + return { + diagnostics, + failure: settleInput.lintDidFail + ? { didFail: false, reason: null } + : yield* Ref.get(input.failureRef), + cacheHit, + summaryCacheHits, + summaryCacheMisses, + }; + }), + }; + }); diff --git a/packages/core/src/dead-code/dead-code-worker-slots.ts b/packages/core/src/dead-code/dead-code-worker-slots.ts index 5ce426cc75..19e912a044 100644 --- a/packages/core/src/dead-code/dead-code-worker-slots.ts +++ b/packages/core/src/dead-code/dead-code-worker-slots.ts @@ -1,67 +1,16 @@ import { resolveDeadCodeConcurrency } from "../utils/resolve-dead-code-concurrency.js"; +import { createWorkerSlots } from "../utils/create-worker-slots.js"; +import type { WorkerSlots } from "../utils/create-worker-slots.js"; -// A process-global counting semaphore bounding how many real deslop dead-code -// child processes run at once, to the memory budget (`resolveDeadCodeConcurrency`). -// -// It's process-global on purpose: the CLI scans the projects of a workspace in -// concurrent `runInspect` fibers within ONE process, and each spawns its own -// dead-code worker — without a shared cap, N concurrent projects could -// oversubscribe memory with N simultaneous children on a small runner. This -// gates only HOW MANY start; each worker still self-terminates via the proven -// one-shot lifecycle (spawn → analyze → exit), so the semaphore adds no -// process-lifecycle surface — it's plain in-process bookkeeping. -// -// `-1` is the un-initialized sentinel; the first acquirer reads the budget once -// (after which the cap is fixed for the process). -let availableSlots = -1; -const waiters: Array<() => void> = []; +let deadCodeWorkerSlots: WorkerSlots | null = null; -const releaseSlot = (): void => { - const nextWaiter = waiters.shift(); - // Hand the slot straight to the next waiter (no increment); only return it to - // the pool when nobody is waiting. Keeps the count balanced either way. - if (nextWaiter !== undefined) nextWaiter(); - else availableSlots += 1; -}; - -/** - * Runs `task` once a dead-code worker slot is free, releasing the slot when the - * task settles (success or failure). With a high cap (roomy machine) every - * caller proceeds immediately; with a low cap (constrained runner) callers - * queue and run as slots free. - * - * `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while - * this caller is queued, the call rejects without acquiring a slot or running - * `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and - * then spawn a child only to tear it down. A queued caller that aborts removes - * its own waiter so a later release never hands a slot to a dead request. - */ export const withDeadCodeWorkerSlot = async ( task: () => Promise, abortSignal?: AbortSignal, ): Promise => { - if (abortSignal?.aborted) throw new Error("Dead-code worker aborted."); - if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency(); - if (availableSlots > 0) { - availableSlots -= 1; - } else { - await new Promise((resolve, reject) => { - const waiter = (): void => { - abortSignal?.removeEventListener("abort", onAbort); - resolve(); - }; - const onAbort = (): void => { - const queuedIndex = waiters.indexOf(waiter); - if (queuedIndex !== -1) waiters.splice(queuedIndex, 1); - reject(new Error("Dead-code worker aborted.")); - }; - waiters.push(waiter); - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); - } - try { - return await task(); - } finally { - releaseSlot(); - } + deadCodeWorkerSlots ??= createWorkerSlots({ + slotCount: resolveDeadCodeConcurrency(), + createAbortError: () => new Error("Dead-code worker aborted."), + }); + return deadCodeWorkerSlots.run(task, abortSignal); }; diff --git a/packages/core/src/editor-scan.ts b/packages/core/src/editor-scan.ts index 581e41e31a..40f31ed14d 100644 --- a/packages/core/src/editor-scan.ts +++ b/packages/core/src/editor-scan.ts @@ -18,6 +18,7 @@ import { Git } from "./services/git.js"; import { Linter, LintPartialFailures } from "./services/linter.js"; import { Progress } from "./services/progress.js"; import { Project } from "./services/project.js"; +import { ProjectChecks } from "./services/project-checks.js"; import { Reporter } from "./services/reporter.js"; import { Score } from "./services/score.js"; import { SupplyChain } from "./services/supply-chain.js"; @@ -125,6 +126,7 @@ export const runEditorScan = async (input: EditorScanInput): Promise; + readonly securityDiagnostics: ReadonlyArray; + readonly supplyChainDiagnostics: ReadonlyArray; + readonly lintDiagnostics: ReadonlyArray; + readonly deadCodeDiagnostics: ReadonlyArray; + readonly scoreSurface: DiagnosticSurface; + readonly userConfig: ReactDoctorConfig | null; +} + +export interface FinalizedDiagnosticOutput { + readonly diagnostics: ReadonlyArray; + readonly scoreDiagnostics: ReadonlyArray; +} + +export const finalizeDiagnosticOutput = ( + input: FinalizeDiagnosticOutputInput, +): FinalizedDiagnosticOutput => { + const diagnostics = sortDiagnosticsStable( + assignFixGroups([ + ...input.environmentDiagnostics, + ...input.securityDiagnostics, + ...input.supplyChainDiagnostics, + ...input.lintDiagnostics, + ...input.deadCodeDiagnostics, + ]), + ); + const scoreDiagnostics = filterDiagnosticsForSurface( + diagnostics, + input.scoreSurface, + input.userConfig, + ); + return { diagnostics, scoreDiagnostics }; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d8a829745..b850f55628 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export * from "./services/linter.js"; export * from "./services/node-resolver.js"; export * from "./services/progress.js"; export * from "./services/project.js"; +export * from "./services/project-checks.js"; export * from "./services/reporter.js"; export * from "./services/score.js"; export * from "./services/staged-files.js"; @@ -39,6 +40,7 @@ export * from "./build-json-report.js"; export * from "./build-skipped-checks.js"; export * from "./calculate-score.js"; export * from "./can-oxlint-extend-config.js"; +export * from "./clear-caches.js"; export * from "./check-dead-code.js"; export * from "./check-expo-project.js"; export * from "./check-pnpm-hardening.js"; @@ -89,6 +91,8 @@ export * from "./utils/assign-fix-groups.js"; export * from "./utils/build-rule-docs-url.js"; export * from "./utils/classify-package-role.js"; export * from "./utils/compute-config-fingerprint.js"; +export * from "./utils/create-oxlint-spawn-slots.js"; +export * from "./utils/create-worker-slots.js"; export * from "./utils/dedupe-diagnostics.js"; export * from "./utils/define-config.js"; export * from "./utils/detect-ai-training-environment.js"; diff --git a/packages/core/src/project-info/build-package-capabilities.ts b/packages/core/src/project-info/build-package-capabilities.ts new file mode 100644 index 0000000000..918f979cfe --- /dev/null +++ b/packages/core/src/project-info/build-package-capabilities.ts @@ -0,0 +1,193 @@ +import * as path from "node:path"; +import type { Capability } from "oxlint-plugin-react-doctor/contracts"; +import { LATEST_SUPPORTED_MOBX_MAJOR } from "../constants.js"; +import type { ProjectInfo } from "../types/index.js"; +import { getCapabilities } from "./capabilities.js"; +import { + MOBX_REACT_LITE_PACKAGE_NAME, + MOBX_REACT_OBSERVER_PACKAGE_NAME, + MOBX_REACT_PACKAGE_NAME, + MOBX_STATE_TREE_PACKAGE_NAME, + REACT_ROUTER_DEPENDENCY_NAMES, + REACT_THREE_FIBER_DEPENDENCY_NAMES, + REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES, + TANSTACK_REACT_QUERY_PACKAGE_NAMES, +} from "./capability-dependency-names.js"; +import { detectPreES2023Target } from "./detect-pre-es2023-target.js"; +import { REACT_SECTIONS, TAILWIND_ZOD_SECTIONS } from "./dependencies.js"; +import { + detectNextjsStaticExport, + detectReactCompiler, + detectReactCompilerLintPlugin, +} from "./detectors.js"; +import { findPreferredDependency } from "./find-preferred-dependency.js"; +import { isFile } from "./fs-utils.js"; +import { hasI18nDependency } from "./has-i18n-dependency.js"; +import type { + PackageGraph, + PackageGraphDependencyDeclaration, + PackageGraphPackage, +} from "./package-graph.js"; +import { isPackageJsonReactNativeAware, isPackageJsonReanimatedAware } from "./rn-metadata.js"; +import { isPackageJsonSsrAware } from "./ssr-metadata.js"; +import { + getDependencyMajorWithinSupportedRange, + getLowestDependencyMajor, + parseReactMajor, + parseThreeRelease, + resolveEffectiveReactMajor, +} from "./version.js"; + +const getDependencyVersion = ( + packageGraph: PackageGraph, + packageNode: PackageGraphPackage, + dependencyName: string, + sections?: ReadonlyArray, +): string | null => { + const dependencyDeclaration = packageGraph.getDependency( + packageNode.directory, + dependencyName, + sections, + ); + if (dependencyDeclaration === null) return null; + if (dependencyDeclaration.workspaceTargetPackageDirectory === null) { + return dependencyDeclaration.resolvedSpecifier; + } + const workspaceTarget = packageGraph.packages.find( + (candidatePackage) => + candidatePackage.directory === dependencyDeclaration.workspaceTargetPackageDirectory, + ); + return workspaceTarget?.version ?? dependencyDeclaration.resolvedSpecifier; +}; + +export const buildPackageCapabilities = ( + packageGraph: PackageGraph, + packageNode: PackageGraphPackage, +): ReadonlySet => { + const reactVersion = getDependencyVersion(packageGraph, packageNode, "react", REACT_SECTIONS); + const tailwindVersion = getDependencyVersion( + packageGraph, + packageNode, + "tailwindcss", + TAILWIND_ZOD_SECTIONS, + ); + const zodVersion = getDependencyVersion(packageGraph, packageNode, "zod", TAILWIND_ZOD_SECTIONS); + const mobxVersion = getDependencyVersion(packageGraph, packageNode, "mobx"); + const mobxReactVersion = getDependencyVersion(packageGraph, packageNode, MOBX_REACT_PACKAGE_NAME); + const mobxReactLiteVersion = getDependencyVersion( + packageGraph, + packageNode, + MOBX_REACT_LITE_PACKAGE_NAME, + ); + const zustandVersion = getDependencyVersion(packageGraph, packageNode, "zustand"); + const findPackageDependency = (dependencyNames: ReadonlyArray) => + findPreferredDependency({ + dependencyNames, + getValue: (dependencyName) => + getDependencyVersion(packageGraph, packageNode, dependencyName, REACT_SECTIONS), + }); + const tanstackQuery = findPackageDependency(TANSTACK_REACT_QUERY_PACKAGE_NAMES); + const reactRouter = findPackageDependency(REACT_ROUTER_DEPENDENCY_NAMES); + const reactThreeFiber = findPackageDependency(REACT_THREE_FIBER_DEPENDENCY_NAMES); + const preactVersion = getDependencyVersion(packageGraph, packageNode, "preact", REACT_SECTIONS); + const remotionVersion = getDependencyVersion(packageGraph, packageNode, "remotion"); + const threeVersion = getDependencyVersion(packageGraph, packageNode, "three"); + const valtioVersion = getDependencyVersion(packageGraph, packageNode, "valtio"); + const styledComponentsVersion = getDependencyVersion( + packageGraph, + packageNode, + "styled-components", + REACT_SECTIONS, + ); + const hasReactNativePackage = + packageNode.dependencyInfo.framework === "expo" || + packageNode.dependencyInfo.framework === "react-native" || + isPackageJsonReactNativeAware(packageNode.manifest); + const expoVersion = hasReactNativePackage + ? getDependencyVersion(packageGraph, packageNode, "expo") + : null; + const shopifyFlashListVersion = hasReactNativePackage + ? getDependencyVersion(packageGraph, packageNode, "@shopify/flash-list") + : null; + const hasReanimated = hasReactNativePackage && isPackageJsonReanimatedAware(packageNode.manifest); + const reanimatedVersion = hasReanimated + ? getDependencyVersion(packageGraph, packageNode, "react-native-reanimated") + : null; + const nextjsVersion = + packageNode.dependencyInfo.framework === "nextjs" + ? getDependencyVersion(packageGraph, packageNode, "next") + : null; + const hasTypeScript = isFile(path.join(packageNode.directory, "tsconfig.json")); + const hasReactThreeFiber = REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES.some( + (dependencyName) => getDependencyVersion(packageGraph, packageNode, dependencyName) !== null, + ); + const projectInfo: ProjectInfo = { + rootDirectory: packageNode.directory, + projectName: packageNode.name ?? packageNode.directory, + reactVersion, + reactMajorVersion: resolveEffectiveReactMajor(reactVersion, packageNode.manifest), + tailwindVersion, + zodVersion, + zodMajorVersion: zodVersion === null ? null : getLowestDependencyMajor(zodVersion), + mobxVersion, + mobxMajorVersion: + mobxVersion === null + ? null + : getDependencyMajorWithinSupportedRange(mobxVersion, LATEST_SUPPORTED_MOBX_MAJOR), + hasMobxReact: mobxReactVersion !== null, + mobxReactVersion, + hasMobxReactLite: mobxReactLiteVersion !== null, + mobxReactLiteVersion, + hasMobxStateTree: + getDependencyVersion(packageGraph, packageNode, MOBX_STATE_TREE_PACKAGE_NAME) !== null, + hasMobxReactObserver: + getDependencyVersion(packageGraph, packageNode, MOBX_REACT_OBSERVER_PACKAGE_NAME) !== null, + zustandVersion, + zustandMajorVersion: zustandVersion === null ? null : getLowestDependencyMajor(zustandVersion), + framework: packageNode.dependencyInfo.framework, + hasTypeScript, + hasReactCompiler: detectReactCompiler(packageNode.directory, packageNode.manifest), + hasReactCompilerLintPlugin: detectReactCompilerLintPlugin( + packageNode.directory, + packageNode.manifest, + ), + hasTanStackQuery: tanstackQuery !== null, + hasI18nLibrary: hasI18nDependency(packageNode.manifest), + tanstackQueryVersion: tanstackQuery?.value ?? null, + styledComponentsVersion, + valtioVersion, + valtioMajorVersion: valtioVersion === null ? null : getLowestDependencyMajor(valtioVersion), + hasRemotion: remotionVersion !== null, + remotionVersion, + remotionMajorVersion: + remotionVersion === null ? null : getLowestDependencyMajor(remotionVersion), + hasThree: threeVersion !== null || hasReactThreeFiber, + threeVersion, + threeRelease: parseThreeRelease(threeVersion), + hasReactThreeFiber, + reactThreeFiberVersion: reactThreeFiber?.value ?? null, + reactThreeFiberMajorVersion: + reactThreeFiber === null ? null : getLowestDependencyMajor(reactThreeFiber.value), + hasSsrDependency: isPackageJsonSsrAware(packageNode.manifest), + preactVersion, + preactMajorVersion: parseReactMajor(preactVersion), + hasReactNativeWorkspace: hasReactNativePackage, + nextjsVersion, + nextjsMajorVersion: nextjsVersion === null ? null : getLowestDependencyMajor(nextjsVersion), + reactRouterVersion: reactRouter?.value ?? null, + hasReactRouterFramework: + getDependencyVersion(packageGraph, packageNode, "@react-router/dev") !== null, + expoVersion, + shopifyFlashListVersion, + shopifyFlashListMajorVersion: + shopifyFlashListVersion === null ? null : getLowestDependencyMajor(shopifyFlashListVersion), + hasReanimated, + reanimatedVersion, + isPreES2023Target: hasTypeScript && detectPreES2023Target(packageNode.directory), + isStaticExport: + packageNode.dependencyInfo.framework === "nextjs" && + detectNextjsStaticExport(packageNode.directory), + sourceFileCount: 0, + }; + return getCapabilities(projectInfo); +}; diff --git a/packages/core/src/project-info/capabilities.ts b/packages/core/src/project-info/capabilities.ts index 4cc547c9d9..67085d9a4a 100644 --- a/packages/core/src/project-info/capabilities.ts +++ b/packages/core/src/project-info/capabilities.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import type { Capability } from "oxlint-plugin-react-doctor"; +import type { Capability } from "oxlint-plugin-react-doctor/contracts"; import type { Framework, ProjectInfo } from "../types/index.js"; import { EARLIEST_GATED_MOBX_MAJOR, @@ -37,6 +37,8 @@ import { parseTailwindMajorMinor, } from "./version.js"; import { detectTargetBlankOpenerProtection } from "./detect-target-blank-opener-protection.js"; +import { findNearestAncestorPackageJson } from "./find-nearest-ancestor-package-json.js"; +import { isFile } from "./fs-utils.js"; import { readPackageJson } from "./package-json.js"; // SPA / mobile frameworks with no server-side form handler at all — @@ -351,9 +353,13 @@ export const getCapabilities = (project: ProjectInfo): ReadonlySet = const cached = capabilitiesByProject.get(project); if (cached !== undefined) return cached; const capabilities = new Set(buildCapabilities(project)); - const packageJson = readPackageJson(path.join(project.rootDirectory, "package.json")); + const packageJsonPath = path.join(project.rootDirectory, "package.json"); + const capabilityRootDirectory = isFile(packageJsonPath) + ? project.rootDirectory + : (findNearestAncestorPackageJson(project.rootDirectory) ?? project.rootDirectory); + const packageJson = readPackageJson(path.join(capabilityRootDirectory, "package.json")); const targetBlankOpenerProtection = detectTargetBlankOpenerProtection( - project.rootDirectory, + capabilityRootDirectory, packageJson, ); if (targetBlankOpenerProtection !== undefined) { diff --git a/packages/core/src/project-info/capability-dependency-names.ts b/packages/core/src/project-info/capability-dependency-names.ts new file mode 100644 index 0000000000..e417dce70c --- /dev/null +++ b/packages/core/src/project-info/capability-dependency-names.ts @@ -0,0 +1,23 @@ +export const MOBX_REACT_PACKAGE_NAME = "mobx-react"; +export const MOBX_REACT_LITE_PACKAGE_NAME = "mobx-react-lite"; +export const MOBX_STATE_TREE_PACKAGE_NAME = "mobx-state-tree"; +export const MOBX_REACT_OBSERVER_PACKAGE_NAME = "mobx-react-observer"; +export const REANIMATED_DEPENDENCY_NAME = "react-native-reanimated"; +export const REACT_THREE_FIBER_DEPENDENCY_NAMES = [ + "@react-three/fiber", + "react-three-fiber", +] as const; +export const REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES = [ + ...REACT_THREE_FIBER_DEPENDENCY_NAMES, + "@react-three/drei", +] as const; +export const THREE_DEPENDENCY_NAMES = [ + ...REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES, + "three", +] as const; +export const REACT_ROUTER_DEPENDENCY_NAMES = [ + "@react-router/dev", + "react-router-dom", + "react-router", +] as const; +export const TANSTACK_REACT_QUERY_PACKAGE_NAMES = ["@tanstack/react-query", "react-query"] as const; diff --git a/packages/core/src/project-info/collect-project-facts.ts b/packages/core/src/project-info/collect-project-facts.ts index 9feb8acfe9..08deb6bebd 100644 --- a/packages/core/src/project-info/collect-project-facts.ts +++ b/packages/core/src/project-info/collect-project-facts.ts @@ -7,21 +7,32 @@ import { } from "../constants.js"; import { EMPTY_DEPENDENCY_INFO, - extractDependencyInfo, getDependencyDeclaration, getDependencySpec, + isCatalogReference, REACT_SECTIONS, resolveCatalogBackedDependencyVersion, resolveCatalogVersion, TAILWIND_ZOD_SECTIONS, } from "./dependencies.js"; +import { + MOBX_REACT_LITE_PACKAGE_NAME, + MOBX_REACT_OBSERVER_PACKAGE_NAME, + MOBX_REACT_PACKAGE_NAME, + MOBX_STATE_TREE_PACKAGE_NAME, + REACT_ROUTER_DEPENDENCY_NAMES, + REACT_THREE_FIBER_DEPENDENCY_NAMES, + REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES, + REANIMATED_DEPENDENCY_NAME, + THREE_DEPENDENCY_NAMES, +} from "./capability-dependency-names.js"; 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"; +import { buildPackageGraph, type PackageGraph } from "./package-graph.js"; import { getDependencyMajorWithinSupportedRange, getLowestDependencyMajor, @@ -34,12 +45,6 @@ import { getTanStackQueryVersion } from "./get-tanstack-query-version.js"; import { getStyledComponentsVersion } from "./get-styled-components-version.js"; import { hasI18nDependency } from "./has-i18n-dependency.js"; -const REANIMATED_DEPENDENCY_NAME = "react-native-reanimated"; -const MOBX_REACT_PACKAGE_NAME = "mobx-react"; -const MOBX_REACT_LITE_PACKAGE_NAME = "mobx-react-lite"; -const MOBX_STATE_TREE_PACKAGE_NAME = "mobx-state-tree"; -const MOBX_REACT_OBSERVER_PACKAGE_NAME = "mobx-react-observer"; -const REACT_THREE_FIBER_DEPENDENCY_NAMES = ["@react-three/fiber", "react-three-fiber"] as const; const REACT_THREE_FIBER_SECTIONS = [ "dependencies", "peerDependencies", @@ -52,17 +57,6 @@ const THREE_DEPENDENCY_SECTIONS = [ "optionalDependencies", "devDependencies", ] as const; -const REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES = [ - ...REACT_THREE_FIBER_DEPENDENCY_NAMES, - "@react-three/drei", -] as const; -const THREE_DEPENDENCY_NAMES = [...REACT_THREE_FIBER_ECOSYSTEM_DEPENDENCY_NAMES, "three"] as const; -const REACT_ROUTER_DEPENDENCY_NAMES: readonly string[] = [ - "@react-router/dev", - "react-router-dom", - "react-router", -]; - // A dependency's declared spec plus the directory whose manifest supplied // it — the scan root, or the workspace package that declares the package. // `sourceDirectory` lets config-file detectors (e.g. the Next.js static- @@ -130,45 +124,33 @@ export const SHOPIFY_FLASH_LIST_PACKAGE_NAME = "@shopify/flash-list"; interface ResolveWorkspaceDependencyVersionOptions { concreteVersion: string | null; + packageGraph: PackageGraph; packageName: string; - rootDirectory: string; - rootPackageJson: PackageJson; sections: ReadonlyArray<"dependencies" | "peerDependencies" | "devDependencies">; workspaceDirectory: string; - workspacePackageJson: PackageJson; } const resolveWorkspaceDependencyVersion = ({ concreteVersion, + packageGraph, packageName, - rootDirectory, - rootPackageJson, sections, workspaceDirectory, - workspacePackageJson, }: ResolveWorkspaceDependencyVersionOptions): string | null => { - const dependencyDeclaration = getDependencyDeclaration({ - packageJson: workspacePackageJson, + if (concreteVersion !== null) return concreteVersion; + const dependencyDeclaration = packageGraph.getDependency( + workspaceDirectory, packageName, sections, - }); - if (!dependencyDeclaration.hasDeclaration) return null; - - return ( - concreteVersion ?? - resolveCatalogVersion( - workspacePackageJson, - packageName, - workspaceDirectory, - dependencyDeclaration.catalogReference, - ) ?? - resolveCatalogVersion( - rootPackageJson, - packageName, - rootDirectory, - dependencyDeclaration.catalogReference, - ) ); + if ( + dependencyDeclaration === null || + !isCatalogReference(dependencyDeclaration.rawSpecifier) || + dependencyDeclaration.resolutionSource === "unresolved-catalog" + ) { + return null; + } + return dependencyDeclaration.resolvedSpecifier; }; // Lowest-major-wins: mixed-version monorepos must be linted against the @@ -495,10 +477,11 @@ interface CollectWorkspaceFactsOptions { // next) that each re-resolved the same globs and re-visited the same // manifests. export const collectWorkspaceFacts = ( - rootDirectory: string, - rootPackageJson: PackageJson, + packageGraph: PackageGraph, { collectReactGroup }: CollectWorkspaceFactsOptions, ): WorkspaceFacts => { + const rootPackage = packageGraph.rootPackage; + const rootPackageJson = rootPackage.manifest; const facts: WorkspaceFacts = { reactVersion: null, tailwindVersion: null, @@ -534,7 +517,13 @@ export const collectWorkspaceFacts = ( reanimatedVersion: null, }; - evaluateManifestFacts(facts, rootPackageJson, rootDirectory, rootDirectory, rootPackageJson); + evaluateManifestFacts( + facts, + rootPackage.manifest, + rootPackage.directory, + packageGraph.rootDirectory, + rootPackageJson, + ); // Once react (major ≤ 17), tailwind, and the framework are all pinned, // later workspaces can't change the outcome the legacy walk would have @@ -542,83 +531,70 @@ export const collectWorkspaceFacts = ( // accumulating to preserve those exact results. let isReactGroupSettled = !collectReactGroup; - const visitedDirectories = new Set(); - for (const pattern of getWorkspacePatterns(rootDirectory, rootPackageJson)) { - // Sorted so every fact resolves to the same workspace on repeated - // analysis of the same tree — raw readdir order isn't stable. - const directories = resolveWorkspaceDirectories(rootDirectory, pattern).toSorted(); - for (const workspaceDirectory of directories) { - if (visitedDirectories.has(workspaceDirectory)) continue; - visitedDirectories.add(workspaceDirectory); - const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json")); - - evaluateManifestFacts( - facts, - workspacePackageJson, - workspaceDirectory, - rootDirectory, - rootPackageJson, - ); + for (const workspacePackage of packageGraph.packages.slice(1)) { + const workspaceDirectory = workspacePackage.directory; + const workspacePackageJson = workspacePackage.manifest; - const info = extractDependencyInfo(workspacePackageJson); - // Priority merge, not first-hit: a web framework outranks a mobile one - // across workspaces (see `frameworkMergeRank`), with walk order only - // breaking ties between equal ranks. - if ( - info.framework !== "unknown" && - frameworkMergeRank(info.framework) < frameworkMergeRank(facts.framework) - ) { - facts.framework = info.framework; - } + evaluateManifestFacts( + facts, + workspacePackageJson, + workspaceDirectory, + packageGraph.rootDirectory, + rootPackageJson, + ); - if (isReactGroupSettled) continue; - const reactVersion = resolveWorkspaceDependencyVersion({ - concreteVersion: info.reactVersion, - packageName: "react", - rootDirectory, - rootPackageJson, - sections: REACT_SECTIONS, - workspaceDirectory, - workspacePackageJson, - }); - const tailwindVersion = resolveWorkspaceDependencyVersion({ - concreteVersion: info.tailwindVersion, - packageName: "tailwindcss", - rootDirectory, - rootPackageJson, - sections: TAILWIND_ZOD_SECTIONS, - workspaceDirectory, - workspacePackageJson, - }); - const zodVersion = resolveWorkspaceDependencyVersion({ - concreteVersion: info.zodVersion, - packageName: "zod", - rootDirectory, - rootPackageJson, - sections: TAILWIND_ZOD_SECTIONS, - workspaceDirectory, - workspacePackageJson, - }); + const info = workspacePackage.dependencyInfo; + // Priority merge, not first-hit: a web framework outranks a mobile one + // across workspaces (see `frameworkMergeRank`), with walk order only + // breaking ties between equal ranks. + if ( + info.framework !== "unknown" && + frameworkMergeRank(info.framework) < frameworkMergeRank(facts.framework) + ) { + facts.framework = info.framework; + } - if (reactVersion && shouldReplaceWithLowerMajor(facts.reactVersion, reactVersion)) { - facts.reactVersion = reactVersion; - } - if (tailwindVersion && !facts.tailwindVersion) { - facts.tailwindVersion = tailwindVersion; - } - if (zodVersion && !facts.zodVersion) { - facts.zodVersion = zodVersion; - } + if (isReactGroupSettled) continue; + const reactVersion = resolveWorkspaceDependencyVersion({ + concreteVersion: info.reactVersion, + packageGraph, + packageName: "react", + sections: REACT_SECTIONS, + workspaceDirectory, + }); + const tailwindVersion = resolveWorkspaceDependencyVersion({ + concreteVersion: info.tailwindVersion, + packageGraph, + packageName: "tailwindcss", + sections: TAILWIND_ZOD_SECTIONS, + workspaceDirectory, + }); + const zodVersion = resolveWorkspaceDependencyVersion({ + concreteVersion: info.zodVersion, + packageGraph, + packageName: "zod", + sections: TAILWIND_ZOD_SECTIONS, + workspaceDirectory, + }); - const settledReactMajor = parseReactMajor(facts.reactVersion); - isReactGroupSettled = Boolean( - facts.reactVersion && - facts.tailwindVersion && - facts.framework !== "unknown" && - settledReactMajor !== null && - settledReactMajor <= 17, - ); + if (reactVersion && shouldReplaceWithLowerMajor(facts.reactVersion, reactVersion)) { + facts.reactVersion = reactVersion; + } + if (tailwindVersion && !facts.tailwindVersion) { + facts.tailwindVersion = tailwindVersion; } + if (zodVersion && !facts.zodVersion) { + facts.zodVersion = zodVersion; + } + + const settledReactMajor = parseReactMajor(facts.reactVersion); + isReactGroupSettled = Boolean( + facts.reactVersion && + facts.tailwindVersion && + facts.framework !== "unknown" && + settledReactMajor !== null && + settledReactMajor <= 17, + ); } return facts; @@ -632,7 +608,10 @@ export const collectWorkspaceFacts = ( // root's version); tailwind/zod fall back only when the leaf DOES declare // them (or has no manifest at all) — otherwise a sibling workspace's // styling stack would leak into an unrelated leaf. -export const findDependencyInfoFromMonorepoRoot = (directory: string): DependencyInfo => { +export const findDependencyInfoFromMonorepoRoot = ( + directory: string, + sourcePackageGraph: PackageGraph, +): DependencyInfo => { const monorepoRoot = findMonorepoRoot(directory); if (!monorepoRoot) return EMPTY_DEPENDENCY_INFO; @@ -640,47 +619,31 @@ export const findDependencyInfoFromMonorepoRoot = (directory: string): Dependenc if (!isFile(monorepoPackageJsonPath)) return EMPTY_DEPENDENCY_INFO; const rootPackageJson = readPackageJson(monorepoPackageJsonPath); - const rootInfo = extractDependencyInfo(rootPackageJson); - const leafPackageJsonPath = path.join(directory, "package.json"); - const leafPackageJson = isFile(leafPackageJsonPath) ? readPackageJson(leafPackageJsonPath) : null; - const leafReactDeclaration = leafPackageJson - ? getDependencyDeclaration({ - packageJson: leafPackageJson, - packageName: "react", - sections: REACT_SECTIONS, - }) - : null; - const leafTailwindDeclaration = leafPackageJson - ? getDependencyDeclaration({ - packageJson: leafPackageJson, - packageName: "tailwindcss", - sections: TAILWIND_ZOD_SECTIONS, - }) - : null; - const leafZodDeclaration = leafPackageJson - ? getDependencyDeclaration({ - packageJson: leafPackageJson, - packageName: "zod", - sections: TAILWIND_ZOD_SECTIONS, - }) - : null; - const shouldUseReactFallback = !leafReactDeclaration?.hasDeclaration; - const shouldUseTailwindFallback = leafTailwindDeclaration?.hasDeclaration ?? true; - const shouldUseZodFallback = leafZodDeclaration?.hasDeclaration ?? true; + const packageGraph = buildPackageGraph(monorepoRoot, rootPackageJson); + const rootInfo = packageGraph.rootPackage.dependencyInfo; + const leafReactDeclaration = sourcePackageGraph.getDependency(directory, "react", REACT_SECTIONS); + const leafTailwindDeclaration = sourcePackageGraph.getDependency( + directory, + "tailwindcss", + TAILWIND_ZOD_SECTIONS, + ); + const leafZodDeclaration = sourcePackageGraph.getDependency( + directory, + "zod", + TAILWIND_ZOD_SECTIONS, + ); + const shouldUseReactFallback = leafReactDeclaration === null; + const shouldUseTailwindFallback = leafTailwindDeclaration !== null; + const shouldUseZodFallback = leafZodDeclaration !== null; const reactCatalogVersion = shouldUseReactFallback - ? resolveCatalogVersion( - rootPackageJson, - "react", - monorepoRoot, - leafReactDeclaration?.catalogReference, - ) + ? resolveCatalogVersion(rootPackageJson, "react", monorepoRoot) : null; const tailwindCatalogVersion = shouldUseTailwindFallback ? resolveCatalogVersion( rootPackageJson, "tailwindcss", monorepoRoot, - leafTailwindDeclaration?.catalogReference, + leafTailwindDeclaration.catalogReference, ) : null; const zodCatalogVersion = shouldUseZodFallback @@ -688,17 +651,17 @@ export const findDependencyInfoFromMonorepoRoot = (directory: string): Dependenc rootPackageJson, "zod", monorepoRoot, - leafZodDeclaration?.catalogReference, + leafZodDeclaration.catalogReference, ) : null; - const workspaceFacts = collectWorkspaceFacts(monorepoRoot, rootPackageJson, { + const workspaceFacts = collectWorkspaceFacts(packageGraph, { collectReactGroup: true, }); return { reactVersion: shouldUseReactFallback ? (reactCatalogVersion ?? rootInfo.reactVersion ?? workspaceFacts.reactVersion) - : (rootInfo.reactVersion ?? workspaceFacts.reactVersion), + : null, tailwindVersion: shouldUseTailwindFallback ? (tailwindCatalogVersion ?? rootInfo.tailwindVersion ?? workspaceFacts.tailwindVersion) : null, diff --git a/packages/core/src/project-info/dependencies.ts b/packages/core/src/project-info/dependencies.ts index af097f64ec..9745130251 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"; @@ -362,7 +362,7 @@ export const getPreactVersion = (packageJson: PackageJson): string | null => { return allDependencies.preact ?? null; }; -interface ResolveCatalogBackedDependencyVersionOptions { +interface ResolveCatalogBackedDependencyOptions { rootDirectory: string; rootPackageJson: PackageJson; packageName: string; @@ -371,15 +371,40 @@ interface ResolveCatalogBackedDependencyVersionOptions { version: string | null; } -export const resolveCatalogBackedDependencyVersion = ({ +export interface CatalogBackedDependencyResolution { + readonly resolvedVersion: string | null; + readonly resolutionSource: + | "none" + | "manifest" + | "declaring-package-catalog" + | "workspace-root-catalog" + | "monorepo-root-catalog" + | "unresolved-catalog"; + readonly resolutionSourceDirectory: string | null; +} + +export const resolveCatalogBackedDependency = ({ rootDirectory, rootPackageJson, packageName, sourceDirectory = rootDirectory, sourcePackageJson = rootPackageJson, version, -}: ResolveCatalogBackedDependencyVersionOptions): string | null => { - if (version === null || !isCatalogReference(version)) return version; +}: ResolveCatalogBackedDependencyOptions): CatalogBackedDependencyResolution => { + if (version === null) { + return { + resolvedVersion: null, + resolutionSource: "none", + resolutionSourceDirectory: null, + }; + } + if (!isCatalogReference(version)) { + return { + resolvedVersion: version, + resolutionSource: "manifest", + resolutionSourceDirectory: sourceDirectory, + }; + } const catalogName = extractCatalogName(version); const resolvedSourceVersion = resolveCatalogVersion( @@ -388,7 +413,13 @@ export const resolveCatalogBackedDependencyVersion = ({ sourceDirectory, catalogName, ); - if (resolvedSourceVersion) return resolvedSourceVersion; + if (resolvedSourceVersion) { + return { + resolvedVersion: resolvedSourceVersion, + resolutionSource: "declaring-package-catalog", + resolutionSourceDirectory: sourceDirectory, + }; + } if (sourceDirectory !== rootDirectory || sourcePackageJson !== rootPackageJson) { const resolvedRootVersion = resolveCatalogVersion( @@ -397,21 +428,54 @@ export const resolveCatalogBackedDependencyVersion = ({ rootDirectory, catalogName, ); - if (resolvedRootVersion) return resolvedRootVersion; + if (resolvedRootVersion) { + return { + resolvedVersion: resolvedRootVersion, + resolutionSource: "workspace-root-catalog", + resolutionSourceDirectory: rootDirectory, + }; + } } const monorepoRoot = findMonorepoRoot(rootDirectory); - if (!monorepoRoot) return version; + if (!monorepoRoot) { + return { + resolvedVersion: version, + resolutionSource: "unresolved-catalog", + resolutionSourceDirectory: null, + }; + } const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json"); - if (!isFile(monorepoPackageJsonPath)) return version; + if (!isFile(monorepoPackageJsonPath)) { + return { + resolvedVersion: version, + resolutionSource: "unresolved-catalog", + resolutionSourceDirectory: null, + }; + } - return ( - resolveCatalogVersion( - readPackageJson(monorepoPackageJsonPath), - packageName, - monorepoRoot, - catalogName, - ) ?? version + const resolvedMonorepoVersion = resolveCatalogVersion( + readPackageJson(monorepoPackageJsonPath), + packageName, + monorepoRoot, + catalogName, ); + if (resolvedMonorepoVersion) { + return { + resolvedVersion: resolvedMonorepoVersion, + resolutionSource: "monorepo-root-catalog", + resolutionSourceDirectory: monorepoRoot, + }; + } + + return { + resolvedVersion: version, + resolutionSource: "unresolved-catalog", + resolutionSourceDirectory: null, + }; }; + +export const resolveCatalogBackedDependencyVersion = ( + options: ResolveCatalogBackedDependencyOptions, +): string | null => resolveCatalogBackedDependency(options).resolvedVersion; 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 0000000000..99fa1e2126 --- /dev/null +++ b/packages/core/src/project-info/detect-framework.ts @@ -0,0 +1,64 @@ +import type { Framework } from "../types/index.js"; + +const FRAMEWORK_PACKAGES: Record = { + next: "nextjs", + "@tanstack/react-start": "tanstack-start", + "@remix-run/react": "remix", + gatsby: "gatsby", + vite: "vite", + "react-scripts": "cra", + expo: "expo", + "react-native": "react-native", +}; + +const FRAMEWORK_DISPLAY_NAMES: Record = { + nextjs: "Next.js", + "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 0000000000..e1f99181cb --- /dev/null +++ b/packages/core/src/project-info/detect-pre-es2023-target.ts @@ -0,0 +1,210 @@ +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 { isLocalModuleSpecifier } from "../utils/is-local-module-specifier.js"; +import { isFile, isPlainObject } from "./fs-utils.js"; + +const TSCONFIG_FILENAME = "tsconfig.json"; +const FALLBACK_TSCONFIG_FILENAMES: ReadonlyArray = [ + "tsconfig.app.json", + "tsconfig.build.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 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 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 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 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 => { + const realPath = fs.realpathSync.native(tsConfigPath); + 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 3ec537b135..4ea14c7a23 100644 --- a/packages/core/src/project-info/detectors.ts +++ b/packages/core/src/project-info/detectors.ts @@ -3,288 +3,16 @@ 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 { isLocalModuleSpecifier } from "../utils/is-local-module-specifier.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 { 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 => { - const realPath = fs.realpathSync.native(tsConfigPath); - 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", - vite: "vite", - "react-scripts": "cra", - expo: "expo", - "react-native": "react-native", -}; - -const FRAMEWORK_DISPLAY_NAMES: Record = { - nextjs: "Next.js", - "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; -}; +export { detectFramework, formatFrameworkName, frameworkMergeRank } from "./detect-framework.js"; +export { detectPreES2023Target } from "./detect-pre-es2023-target.js"; 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 75465d429b..3da2846b60 100644 --- a/packages/core/src/project-info/discover-project.ts +++ b/packages/core/src/project-info/discover-project.ts @@ -1,27 +1,23 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { PackageJsonNotFoundError } from "./errors.js"; -import type { PackageJson, ProjectInfo } from "../types/index.js"; +import type { 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, - detectPreES2023Target, detectReactCompiler, detectReactCompilerLintPlugin, } from "./detectors.js"; +import { detectPreES2023Target } from "./detect-pre-es2023-target.js"; import { - extractDependencyInfo, - getDependencyDeclaration, getPreactVersion, - isCatalogReference, REACT_SECTIONS, resolveCatalogBackedDependencyVersion, - resolveCatalogVersion, TAILWIND_ZOD_SECTIONS, } from "./dependencies.js"; -import { findMonorepoRoot, isMonorepoRoot } from "./monorepo-root.js"; +import { isMonorepoRoot } from "./monorepo-root.js"; import { findNearestAncestorPackageJson } from "./find-nearest-ancestor-package-json.js"; import { collectWorkspaceFacts, @@ -31,6 +27,11 @@ import { import { resolveInstalledReactVersion } from "./resolve-installed-react-version.js"; import { readPackageJson } from "./package-json.js"; import { getTanStackQueryVersion } from "./get-tanstack-query-version.js"; +import { + buildPackageGraph, + type PackageGraph, + type PackageGraphDependencyDeclaration, +} from "./package-graph.js"; import { getDependencyMajorWithinSupportedRange, getLowestDependencyMajor, @@ -41,19 +42,44 @@ 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(); +const cachedPackageGraphs = new Map(); + +const getCatalogResolvedVersion = ( + dependencyDeclaration: PackageGraphDependencyDeclaration | null, +): string | null => { + if ( + dependencyDeclaration === null || + dependencyDeclaration.resolutionSource === "manifest" || + dependencyDeclaration.resolutionSource === "unresolved-catalog" + ) { + return null; + } + return dependencyDeclaration.resolvedSpecifier; +}; + +const getRawManifestVersion = ( + dependencyDeclaration: PackageGraphDependencyDeclaration | null, +): string | null => + dependencyDeclaration?.resolutionSource === "manifest" + ? dependencyDeclaration.rawSpecifier + : null; // HACK: paired with clearConfigCache — exposed so programmatic API // consumers can re-detect after the project's package.json / // tsconfig.json / monorepo manifests change between diagnose() calls. export const clearProjectCache = (): void => { cachedProjectInfos.clear(); + cachedPackageGraphs.clear(); clearTargetBlankOpenerProtectionCache(); }; +export const getDiscoveredPackageGraph = (directory: string): PackageGraph | null => + cachedPackageGraphs.get(directory) ?? null; + /** * Build a `ProjectInfo` for a directory that has no `package.json` of * its own — a monorepo subfolder like `repo/packages`, or any loose tree @@ -77,6 +103,11 @@ const discoverProjectWithoutPackageJson = (directory: string): ProjectInfo => { // to this folder so React capabilities survive when a React monorepo // subdirectory is scanned. if (enclosingProject !== null) { + const enclosingPackageGraph = + enclosingProjectRoot === null ? undefined : cachedPackageGraphs.get(enclosingProjectRoot); + if (enclosingPackageGraph !== undefined) { + cachedPackageGraphs.set(directory, enclosingPackageGraph); + } return { ...enclosingProject, rootDirectory: directory, @@ -159,58 +190,31 @@ export const discoverProject = (directory: string): ProjectInfo => { return synthesized; } - const packageJson = readPackageJson(packageJsonPath); - const rootInfo = extractDependencyInfo(packageJson); + const packageGraph = buildPackageGraph(directory, readPackageJson(packageJsonPath)); + const rootPackage = packageGraph.rootPackage; + const packageJson = rootPackage.manifest; + const rootInfo = rootPackage.dependencyInfo; let framework = rootInfo.framework; - // One resolution ladder, written once for all three root-tracked - // dependencies: root concrete spec → root catalogs → monorepo-root - // catalogs → workspace walk (stage gate below) → enclosing-monorepo - // fallback → raw declared spec. - const tracked = { - react: { version: rootInfo.reactVersion, sections: REACT_SECTIONS }, - tailwindcss: { version: rootInfo.tailwindVersion, sections: TAILWIND_ZOD_SECTIONS }, - zod: { version: rootInfo.zodVersion, sections: TAILWIND_ZOD_SECTIONS }, + const declarations = { + react: packageGraph.getDependency(directory, "react", REACT_SECTIONS), + tailwindcss: packageGraph.getDependency(directory, "tailwindcss", TAILWIND_ZOD_SECTIONS), + zod: packageGraph.getDependency(directory, "zod", TAILWIND_ZOD_SECTIONS), }; - const declarations = Object.fromEntries( - Object.entries(tracked).map(([packageName, entry]) => [ - packageName, - getDependencyDeclaration({ packageJson, packageName, sections: entry.sections }), - ]), - ); - const fillFromCatalogs = (source: PackageJson, sourceDirectory: string): void => { - for (const [packageName, entry] of Object.entries(tracked)) { - if (!entry.version && declarations[packageName].hasDeclaration) { - entry.version = resolveCatalogVersion( - source, - packageName, - sourceDirectory, - declarations[packageName].catalogReference, - ); - } - } + const tracked = { + react: { + version: rootInfo.reactVersion ?? getCatalogResolvedVersion(declarations.react), + }, + tailwindcss: { + version: rootInfo.tailwindVersion ?? getCatalogResolvedVersion(declarations.tailwindcss), + }, + zod: { + version: rootInfo.zodVersion ?? getCatalogResolvedVersion(declarations.zod), + }, }; - fillFromCatalogs(packageJson, directory); - - // HACK: keep the monorepo-root catalog read cheap (one package.json plus - // pnpm-workspace catalogs). The expensive workspace walks below still key - // off React/framework misses; if we walk anyway, they can fill Zod too. - if (!tracked.react.version || !tracked.tailwindcss.version || !tracked.zod.version) { - const monorepoRoot = findMonorepoRoot(directory); - if (monorepoRoot) { - const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json"); - if (isFile(monorepoPackageJsonPath)) { - fillFromCatalogs(readPackageJson(monorepoPackageJsonPath), monorepoRoot); - } - } - } - - // The one workspace traversal: every workspace-derived fact (the react - // group, RN/reanimated awareness, expo / flash-list / next specs) comes - // out of this single pass; the gates below decide which apply. const shouldCollectReactGroup = !tracked.react.version || framework === "unknown"; - const workspaceFacts = collectWorkspaceFacts(directory, packageJson, { + const workspaceFacts = collectWorkspaceFacts(packageGraph, { collectReactGroup: shouldCollectReactGroup, }); @@ -224,7 +228,7 @@ export const discoverProject = (directory: string): ProjectInfo => { } if ((!tracked.react.version || framework === "unknown") && !isMonorepoRoot(directory)) { - const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory); + const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory, packageGraph); tracked.react.version ||= monorepoInfo.reactVersion; tracked.tailwindcss.version ||= monorepoInfo.tailwindVersion; tracked.zod.version ||= monorepoInfo.zodVersion; @@ -233,12 +237,9 @@ export const discoverProject = (directory: string): ProjectInfo => { } } - for (const [packageName, entry] of Object.entries(tracked)) { - const declaredVersion = declarations[packageName].version; - if (!entry.version && declaredVersion && !isCatalogReference(declaredVersion)) { - entry.version = declaredVersion; - } - } + tracked.react.version ||= getRawManifestVersion(declarations.react); + tracked.tailwindcss.version ||= getRawManifestVersion(declarations.tailwindcss); + tracked.zod.version ||= getRawManifestVersion(declarations.zod); const { react, tailwindcss, zod } = tracked; let reactVersion = react.version; if (!reactVersion || parseReactMajor(reactVersion) === null) { @@ -412,5 +413,6 @@ export const discoverProject = (directory: string): ProjectInfo => { sourceFileCount, }; cachedProjectInfos.set(directory, projectInfo); + cachedPackageGraphs.set(directory, packageGraph); return projectInfo; }; diff --git a/packages/core/src/project-info/discover-react-subprojects.ts b/packages/core/src/project-info/discover-react-subprojects.ts index 32f78e0435..e4ec9dadf2 100644 --- a/packages/core/src/project-info/discover-react-subprojects.ts +++ b/packages/core/src/project-info/discover-react-subprojects.ts @@ -3,10 +3,11 @@ import { IGNORED_DIRECTORIES } from "./constants.js"; import type { PackageJson, WorkspacePackage } from "../types/index.js"; import { isDirectory, isFile, readDirectoryEntries } from "./fs-utils.js"; import { hasReactDependency } from "./dependencies.js"; +import { buildPackageGraph } from "./package-graph.js"; import { readPackageJson } from "./package-json.js"; import { getNxWorkspaceDirectories, - listWorkspacePackages, + getWorkspacePatterns, parsePnpmWorkspacePatterns, resolveWorkspaceDirectories, } from "./workspaces.js"; @@ -28,10 +29,21 @@ const toReactWorkspacePackages = (directories: string[]): WorkspacePackage[] => return packages; }; -const listManifestWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { +const listPackageGraphWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { const packageJsonPath = path.join(rootDirectory, "package.json"); - if (isFile(packageJsonPath)) return listWorkspacePackages(rootDirectory); + const rootPackageJson = readPackageJson(packageJsonPath); + if (getWorkspacePatterns(rootDirectory, rootPackageJson).length === 0) return []; + + const packageGraph = buildPackageGraph(rootDirectory, rootPackageJson); + return packageGraph.packages + .filter((packageNode) => hasReactDependency(packageNode.manifest)) + .map((packageNode) => ({ + name: packageNode.name ?? path.basename(packageNode.directory), + directory: packageNode.directory, + })); +}; +const listWorkspacePackagesWithoutRootManifest = (rootDirectory: string): WorkspacePackage[] => { const patterns = parsePnpmWorkspacePatterns(rootDirectory); const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory); const directories = (patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => @@ -114,7 +126,9 @@ const discoverReactSubprojectsByFilesystem = (rootDirectory: string): WorkspaceP export const discoverReactSubprojects = (rootDirectory: string): WorkspacePackage[] => { if (!isDirectory(rootDirectory)) return []; - const manifestPackages = listManifestWorkspacePackages(rootDirectory); + const manifestPackages = isFile(path.join(rootDirectory, "package.json")) + ? listPackageGraphWorkspacePackages(rootDirectory) + : listWorkspacePackagesWithoutRootManifest(rootDirectory); if (manifestPackages.length > 0) return manifestPackages; return discoverReactSubprojectsByFilesystem(rootDirectory); diff --git a/packages/core/src/project-info/find-preferred-dependency.ts b/packages/core/src/project-info/find-preferred-dependency.ts new file mode 100644 index 0000000000..a701582854 --- /dev/null +++ b/packages/core/src/project-info/find-preferred-dependency.ts @@ -0,0 +1,20 @@ +export interface PreferredDependency { + readonly dependencyName: string; + readonly value: Value; +} + +interface FindPreferredDependencyOptions { + readonly dependencyNames: ReadonlyArray; + readonly getValue: (dependencyName: string) => Value | null; +} + +export const findPreferredDependency = ({ + dependencyNames, + getValue, +}: FindPreferredDependencyOptions): PreferredDependency | null => { + for (const dependencyName of dependencyNames) { + const value = getValue(dependencyName); + if (value !== null) return { dependencyName, value }; + } + return null; +}; diff --git a/packages/core/src/project-info/get-preferred-dependency-version.ts b/packages/core/src/project-info/get-preferred-dependency-version.ts index f3d050b2dd..b4363692a3 100644 --- a/packages/core/src/project-info/get-preferred-dependency-version.ts +++ b/packages/core/src/project-info/get-preferred-dependency-version.ts @@ -1,5 +1,6 @@ import type { PackageJson } from "../types/index.js"; import { getDependencyDeclaration } from "./dependencies.js"; +import { findPreferredDependency } from "./find-preferred-dependency.js"; const PREFERRED_DEPENDENCY_SECTIONS: ReadonlyArray< "dependencies" | "peerDependencies" | "devDependencies" @@ -13,14 +14,13 @@ interface GetPreferredDependencyVersionOptions { export const getPreferredDependencyVersion = ({ packageJson, packageNames, -}: GetPreferredDependencyVersionOptions): string | null => { - for (const packageName of packageNames) { - const declaration = getDependencyDeclaration({ - packageJson, - packageName, - sections: PREFERRED_DEPENDENCY_SECTIONS, - }); - if (declaration.version !== null) return declaration.version; - } - return null; -}; +}: GetPreferredDependencyVersionOptions): string | null => + findPreferredDependency({ + dependencyNames: packageNames, + getValue: (packageName) => + getDependencyDeclaration({ + packageJson, + packageName, + sections: PREFERRED_DEPENDENCY_SECTIONS, + }).version, + })?.value ?? null; diff --git a/packages/core/src/project-info/get-tanstack-query-version.ts b/packages/core/src/project-info/get-tanstack-query-version.ts index a9850f34e8..ef07e5042c 100644 --- a/packages/core/src/project-info/get-tanstack-query-version.ts +++ b/packages/core/src/project-info/get-tanstack-query-version.ts @@ -1,11 +1,10 @@ import type { PackageJson } from "../types/index.js"; +import { TANSTACK_REACT_QUERY_PACKAGE_NAMES } from "./capability-dependency-names.js"; import { getPreferredDependencyVersion } from "./get-preferred-dependency-version.js"; -const TANSTACK_REACT_QUERY_PACKAGES = ["@tanstack/react-query", "react-query"]; - export const getTanStackQueryVersion = (packageJson: PackageJson): string | null => { return getPreferredDependencyVersion({ packageJson, - packageNames: TANSTACK_REACT_QUERY_PACKAGES, + packageNames: TANSTACK_REACT_QUERY_PACKAGE_NAMES, }); }; diff --git a/packages/core/src/project-info/index.ts b/packages/core/src/project-info/index.ts index a448ef1a26..f219f2bca1 100644 --- a/packages/core/src/project-info/index.ts +++ b/packages/core/src/project-info/index.ts @@ -6,6 +6,13 @@ export { listWorkspacePackages, } from "./discover-project.js"; export { clearPackageJsonCache, readPackageJson } from "./package-json.js"; +export { buildPackageGraph } from "./package-graph.js"; +export type { + PackageGraph, + PackageGraphDependencyDeclaration, + PackageGraphPackage, + PackageGraphWorkspaceEdge, +} from "./package-graph.js"; export { isAnalyzableProject } from "./is-analyzable-project.js"; export { parseReactMajor, diff --git a/packages/core/src/project-info/package-graph.ts b/packages/core/src/project-info/package-graph.ts new file mode 100644 index 0000000000..313a032c3f --- /dev/null +++ b/packages/core/src/project-info/package-graph.ts @@ -0,0 +1,366 @@ +import * as path from "node:path"; +import type { Capability } from "oxlint-plugin-react-doctor/contracts"; +import type { DependencyInfo, PackageJson } from "../types/index.js"; +import { + extractCatalogName, + extractDependencyInfo, + hasReactDependency, + resolveCatalogBackedDependency, +} from "./dependencies.js"; +import { buildPackageCapabilities } from "./build-package-capabilities.js"; +import { isPlainObject } from "./fs-utils.js"; +import { readPackageJson } from "./package-json.js"; +import { doesDependencyVersionIntersectRange } from "./version.js"; +import { getWorkspacePatterns, resolveWorkspaceDirectories } from "./workspaces.js"; + +const WORKSPACE_PROTOCOL_PREFIX = "workspace:"; +const DEPENDENCY_SECTIONS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +export interface PackageGraphDependencyDeclaration { + readonly declaringPackageDirectory: string; + readonly packageName: string; + readonly section: + | "dependencies" + | "devDependencies" + | "peerDependencies" + | "optionalDependencies"; + readonly rawSpecifier: string; + readonly resolvedSpecifier: string; + readonly catalogReference: string | null; + readonly resolutionSource: + | "manifest" + | "declaring-package-catalog" + | "workspace-root-catalog" + | "monorepo-root-catalog" + | "unresolved-catalog"; + readonly resolutionSourceDirectory: string | null; + readonly workspaceTargetPackageDirectory: string | null; +} + +export interface PackageGraphWorkspaceEdge { + readonly sourcePackageDirectory: string; + readonly targetPackageDirectory: string; + readonly targetPackageVersion: string | null; + readonly dependencyName: string; + readonly section: + | "dependencies" + | "devDependencies" + | "peerDependencies" + | "optionalDependencies"; + readonly workspaceSpecifier: string; +} + +export interface PackageGraphPackage { + readonly directory: string; + readonly manifestPath: string; + readonly name: string | null; + readonly version: string | null; + readonly isRoot: boolean; + readonly hasReactDependency: boolean; + readonly manifest: PackageJson; + readonly dependencyInfo: DependencyInfo; + readonly dependencyDeclarations: ReadonlyArray; +} + +export interface PackageGraph { + readonly rootDirectory: string; + readonly rootPackage: PackageGraphPackage; + readonly packages: ReadonlyArray; + readonly workspacePatterns: ReadonlyArray; + readonly workspaceEdges: ReadonlyArray; + readonly getCapabilities: (packageDirectory: string) => ReadonlySet | null; + readonly getCapabilitiesForFile: (filePath: string) => ReadonlySet | null; + readonly findOwningPackage: ( + filePath: string, + predicate?: (packageNode: PackageGraphPackage) => boolean, + ) => PackageGraphPackage | null; + readonly getDependency: ( + packageDirectory: string, + dependencyName: string, + sections?: ReadonlyArray< + "dependencies" | "devDependencies" | "peerDependencies" | "optionalDependencies" + >, + ) => PackageGraphDependencyDeclaration | null; + readonly getDependencyDeclarations: ( + packageDirectory: string, + dependencyName: string, + ) => ReadonlyArray; + readonly hasDependency: ( + packageDirectory: string, + dependencyName: string, + versionRange?: string, + ) => boolean; +} + +interface BuildPackageGraphNodeOptions { + readonly directory: string; + readonly isRoot: boolean; + readonly manifest: PackageJson; + readonly rootDirectory: string; + readonly rootPackageJson: PackageJson; +} + +const buildPackageGraphNode = ({ + directory, + isRoot, + manifest, + rootDirectory, + rootPackageJson, +}: BuildPackageGraphNodeOptions): PackageGraphPackage => { + const dependencyDeclarations: PackageGraphDependencyDeclaration[] = []; + + for (const section of DEPENDENCY_SECTIONS) { + const dependencies = manifest[section]; + if (!isPlainObject(dependencies)) continue; + + for (const [packageName, rawSpecifier] of Object.entries(dependencies)) { + if (typeof rawSpecifier !== "string") continue; + const resolution = resolveCatalogBackedDependency({ + rootDirectory, + rootPackageJson, + sourceDirectory: directory, + sourcePackageJson: manifest, + packageName, + version: rawSpecifier, + }); + if (resolution.resolvedVersion === null || resolution.resolutionSource === "none") continue; + + dependencyDeclarations.push({ + declaringPackageDirectory: directory, + packageName, + section, + rawSpecifier, + resolvedSpecifier: resolution.resolvedVersion, + catalogReference: extractCatalogName(rawSpecifier), + resolutionSource: resolution.resolutionSource, + resolutionSourceDirectory: resolution.resolutionSourceDirectory, + workspaceTargetPackageDirectory: null, + }); + } + } + + return { + directory, + manifestPath: path.join(directory, "package.json"), + name: typeof manifest.name === "string" ? manifest.name : null, + version: typeof manifest.version === "string" ? manifest.version : null, + isRoot, + hasReactDependency: hasReactDependency(manifest), + manifest, + dependencyInfo: extractDependencyInfo(manifest), + dependencyDeclarations, + }; +}; + +const isPathInsideDirectory = (filePath: string, directory: string): boolean => { + const relativePath = path.relative(directory, filePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; + +const getWorkspaceSpecifier = ( + dependencyDeclaration: PackageGraphDependencyDeclaration, +): string | null => { + if (dependencyDeclaration.rawSpecifier.startsWith(WORKSPACE_PROTOCOL_PREFIX)) { + return dependencyDeclaration.rawSpecifier; + } + if (dependencyDeclaration.resolvedSpecifier.startsWith(WORKSPACE_PROTOCOL_PREFIX)) { + return dependencyDeclaration.resolvedSpecifier; + } + return null; +}; + +export const buildPackageGraph = ( + rootDirectory: string, + rootPackageJson: PackageJson, +): PackageGraph => { + const normalizedRootDirectory = path.normalize(rootDirectory); + const workspacePatterns = getWorkspacePatterns(normalizedRootDirectory, rootPackageJson); + const unresolvedRootPackage = buildPackageGraphNode({ + directory: normalizedRootDirectory, + isRoot: true, + manifest: rootPackageJson, + rootDirectory: normalizedRootDirectory, + rootPackageJson, + }); + const unresolvedPackages: PackageGraphPackage[] = [unresolvedRootPackage]; + const visitedDirectories = new Set([normalizedRootDirectory]); + + for (const pattern of workspacePatterns) { + const workspaceDirectories = resolveWorkspaceDirectories( + normalizedRootDirectory, + pattern, + ).toSorted(); + for (const workspaceDirectory of workspaceDirectories) { + const normalizedWorkspaceDirectory = path.normalize(workspaceDirectory); + if (visitedDirectories.has(normalizedWorkspaceDirectory)) continue; + visitedDirectories.add(normalizedWorkspaceDirectory); + const manifest = readPackageJson(path.join(normalizedWorkspaceDirectory, "package.json")); + unresolvedPackages.push( + buildPackageGraphNode({ + directory: normalizedWorkspaceDirectory, + isRoot: false, + manifest, + rootDirectory: normalizedRootDirectory, + rootPackageJson, + }), + ); + } + } + + const packagesByName = new Map(); + for (const packageNode of unresolvedPackages) { + if (packageNode.name === null) continue; + const matchingPackages = packagesByName.get(packageNode.name) ?? []; + matchingPackages.push(packageNode); + packagesByName.set(packageNode.name, matchingPackages); + } + + const resolvePackageWorkspaceTargets = ( + packageNode: PackageGraphPackage, + ): PackageGraphPackage => ({ + ...packageNode, + dependencyDeclarations: packageNode.dependencyDeclarations.map( + (dependencyDeclaration): PackageGraphDependencyDeclaration => { + if (getWorkspaceSpecifier(dependencyDeclaration) === null) return dependencyDeclaration; + const matchingPackages = packagesByName.get(dependencyDeclaration.packageName) ?? []; + if ( + matchingPackages.length !== 1 || + matchingPackages[0].directory === packageNode.directory + ) { + return dependencyDeclaration; + } + return { + ...dependencyDeclaration, + workspaceTargetPackageDirectory: matchingPackages[0].directory, + }; + }, + ), + }); + const rootPackage = resolvePackageWorkspaceTargets(unresolvedRootPackage); + const packages = [ + rootPackage, + ...unresolvedPackages.slice(1).map(resolvePackageWorkspaceTargets), + ]; + const packagesByDirectory = new Map( + packages.map((packageNode) => [packageNode.directory, packageNode]), + ); + const packagesByDescendingDirectoryLength = packages.toSorted( + (leftPackage, rightPackage) => rightPackage.directory.length - leftPackage.directory.length, + ); + const workspaceEdges: PackageGraphWorkspaceEdge[] = []; + for (const packageNode of packages) { + for (const dependencyDeclaration of packageNode.dependencyDeclarations) { + const workspaceSpecifier = getWorkspaceSpecifier(dependencyDeclaration); + const targetPackageDirectory = dependencyDeclaration.workspaceTargetPackageDirectory; + if (workspaceSpecifier === null || targetPackageDirectory === null) continue; + const targetPackage = packagesByDirectory.get(targetPackageDirectory); + if (!targetPackage) continue; + workspaceEdges.push({ + sourcePackageDirectory: packageNode.directory, + targetPackageDirectory, + targetPackageVersion: targetPackage.version, + dependencyName: dependencyDeclaration.packageName, + section: dependencyDeclaration.section, + workspaceSpecifier, + }); + } + } + const dependencyDeclarationsByPackageDirectory = new Map< + string, + Map + >(); + for (const packageNode of packages) { + const dependencyDeclarationsByName = new Map(); + for (const dependencyDeclaration of packageNode.dependencyDeclarations) { + const matchingDeclarations = + dependencyDeclarationsByName.get(dependencyDeclaration.packageName) ?? []; + matchingDeclarations.push(dependencyDeclaration); + dependencyDeclarationsByName.set(dependencyDeclaration.packageName, matchingDeclarations); + } + dependencyDeclarationsByPackageDirectory.set( + packageNode.directory, + dependencyDeclarationsByName, + ); + } + const getDependencyDeclarations = ( + packageDirectory: string, + dependencyName: string, + ): ReadonlyArray => + dependencyDeclarationsByPackageDirectory + .get(path.normalize(packageDirectory)) + ?.get(dependencyName) ?? []; + const getDependency = ( + packageDirectory: string, + dependencyName: string, + sections?: ReadonlyArray< + "dependencies" | "devDependencies" | "peerDependencies" | "optionalDependencies" + >, + ): PackageGraphDependencyDeclaration | null => { + const dependencyDeclarations = getDependencyDeclarations(packageDirectory, dependencyName); + if (sections === undefined) return dependencyDeclarations[0] ?? null; + for (const section of sections) { + const matchingDeclaration = dependencyDeclarations.find( + (dependencyDeclaration) => dependencyDeclaration.section === section, + ); + if (matchingDeclaration) return matchingDeclaration; + } + return null; + }; + const findOwningPackage = ( + filePath: string, + predicate?: (packageNode: PackageGraphPackage) => boolean, + ): PackageGraphPackage | null => { + const normalizedFilePath = path.normalize(filePath); + return ( + packagesByDescendingDirectoryLength.find( + (packageNode) => + (predicate === undefined || predicate(packageNode)) && + isPathInsideDirectory(normalizedFilePath, packageNode.directory), + ) ?? null + ); + }; + const capabilitiesByPackageDirectory = new Map>(); + const packageGraph: PackageGraph = { + rootDirectory: normalizedRootDirectory, + rootPackage, + packages, + workspacePatterns, + workspaceEdges, + getCapabilities: (packageDirectory) => { + const normalizedPackageDirectory = path.normalize(packageDirectory); + const packageNode = packagesByDirectory.get(normalizedPackageDirectory); + if (packageNode === undefined) return null; + const cachedCapabilities = capabilitiesByPackageDirectory.get(normalizedPackageDirectory); + if (cachedCapabilities !== undefined) return cachedCapabilities; + const capabilities = buildPackageCapabilities(packageGraph, packageNode); + capabilitiesByPackageDirectory.set(normalizedPackageDirectory, capabilities); + return capabilities; + }, + getCapabilitiesForFile: (filePath) => { + const owningPackage = findOwningPackage(filePath); + return owningPackage === null ? null : packageGraph.getCapabilities(owningPackage.directory); + }, + findOwningPackage, + getDependency, + getDependencyDeclarations, + hasDependency: (packageDirectory, dependencyName, versionRange) => { + const dependencyDeclaration = getDependency(packageDirectory, dependencyName); + if (dependencyDeclaration === null) return false; + if (versionRange === undefined) return true; + + const workspaceTarget = + dependencyDeclaration.workspaceTargetPackageDirectory === null + ? null + : (packagesByDirectory.get(dependencyDeclaration.workspaceTargetPackageDirectory) ?? + null); + const dependencyVersion = workspaceTarget?.version ?? dependencyDeclaration.resolvedSpecifier; + return doesDependencyVersionIntersectRange(dependencyVersion, versionRange); + }, + }; + return packageGraph; +}; diff --git a/packages/core/src/project-info/version.ts b/packages/core/src/project-info/version.ts index 736d7db74d..6844f8def7 100644 --- a/packages/core/src/project-info/version.ts +++ b/packages/core/src/project-info/version.ts @@ -161,6 +161,22 @@ export const normalizeDependencyVersion = (version: string): string | null => { return normalizedVersion; }; +export const doesDependencyVersionIntersectRange = ( + dependencyVersion: string, + requiredVersionRange: string, +): boolean => { + const normalizedDependencyVersion = normalizeDependencyVersion(dependencyVersion); + if (normalizedDependencyVersion === null) return false; + + const trimmedRequiredVersionRange = requiredVersionRange.trim(); + if (trimmedRequiredVersionRange.length === 0) return false; + const dependencyRange = semver.validRange(normalizedDependencyVersion); + const requiredRange = semver.validRange(trimmedRequiredVersionRange); + if (dependencyRange === null || requiredRange === null) return false; + + return semver.intersects(dependencyRange, requiredRange); +}; + export const splitDependencyVersionBranches = (version: string): string[] => version .split("||") diff --git a/packages/core/src/refs.ts b/packages/core/src/refs.ts index ab42473eb9..c01667079b 100644 --- a/packages/core/src/refs.ts +++ b/packages/core/src/refs.ts @@ -12,6 +12,7 @@ import { readPositiveEnvMs } from "./utils/read-positive-env-ms.js"; import { resolveAutoScanConcurrency } from "./utils/resolve-auto-scan-concurrency.js"; import { resolveLintBatchOrdering } from "./utils/resolve-lint-batch-ordering.js"; import { resolveScanConcurrency } from "./utils/resolve-scan-concurrency.js"; +import type { WorkerSlots } from "./utils/create-worker-slots.js"; /** * Per-batch oxlint wall-clock budget. Reads from the env var on @@ -126,6 +127,13 @@ export class OxlintConcurrency extends Context.Reference("react-doctor/O }, }) {} +export class OxlintSpawnSlots extends Context.Reference( + "react-doctor/OxlintSpawnSlots", + { + defaultValue: () => null, + }, +) {} + /** * Three-state control for overlapping the dead-code pass with the lint pass — * forking dead-code as a child fiber that runs DURING lint instead of strictly diff --git a/packages/core/src/resolve-inspect-scan-settings.ts b/packages/core/src/resolve-inspect-scan-settings.ts new file mode 100644 index 0000000000..1f4de0146f --- /dev/null +++ b/packages/core/src/resolve-inspect-scan-settings.ts @@ -0,0 +1,42 @@ +import { DEFAULT_SHOW_WARNINGS } from "./constants.js"; +import { computeExplicitLintIncludePaths } from "./explicit-lint-include-paths.js"; +import { resolveLintIncludePaths } from "./resolve-lint-include-paths.js"; +import type { InspectInput } from "./run-inspect-types.js"; +import type { ReactDoctorConfig } from "./types/index.js"; + +interface InspectScanSettings { + readonly lintIncludePaths: ReadonlyArray | undefined; + readonly isDiffMode: boolean; + readonly showWarnings: boolean; + readonly shouldCollectFallbackScannedFilePaths: boolean; + readonly shouldRunSupplyChain: boolean; +} + +interface ResolveInspectScanSettingsInput { + readonly input: InspectInput; + readonly rootDirectory: string; + readonly userConfig: ReactDoctorConfig | null; +} + +export const resolveInspectScanSettings = ( + settingsInput: ResolveInspectScanSettingsInput, +): InspectScanSettings => { + const { input, rootDirectory, userConfig } = settingsInput; + let explicitLintIncludePaths: ReadonlyArray | undefined; + if (input.skipExplicitIncludePathFilter) { + explicitLintIncludePaths = input.includePaths.length > 0 ? [...input.includePaths] : undefined; + } else { + explicitLintIncludePaths = computeExplicitLintIncludePaths([...input.includePaths]); + } + const lintIncludePaths = + explicitLintIncludePaths ?? resolveLintIncludePaths(rootDirectory, userConfig); + const isDiffMode = input.includePaths.length > 0; + + return { + lintIncludePaths, + isDiffMode, + showWarnings: input.warnings ?? userConfig?.warnings ?? DEFAULT_SHOW_WARNINGS, + shouldCollectFallbackScannedFilePaths: Boolean(input.suppressScanSummary), + shouldRunSupplyChain: !isDiffMode || (input.supplyChainManifestChanged ?? false), + }; +}; diff --git a/packages/core/src/run-inspect-types.ts b/packages/core/src/run-inspect-types.ts new file mode 100644 index 0000000000..c44d4491d5 --- /dev/null +++ b/packages/core/src/run-inspect-types.ts @@ -0,0 +1,105 @@ +import * as Effect from "effect/Effect"; +import type { DiagnosticSurface, ProjectInfo } from "./types/index.js"; + +export interface InspectInput { + readonly directory: string; + 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; + /** + * Set when this scan runs concurrently with sibling scans in one process + * (the CLI's multi-project pool). Such a scan can't safely reason about the + * shared memory budget from its own available-memory reading — N concurrent + * scans each reading "plenty available" would each fork a dead-code worker + * and sum past the single-scan budget — so the dead-code overlap memory gate + * (`"auto"`) stays sequential for concurrent members. An explicit + * `REACT_DOCTOR_DEAD_CODE_OVERLAP=on` override still wins. Defaults to `false`. + */ + readonly concurrentScan?: 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; +} + +/** + * Hooks the caller participates in without owning the orchestration. + * Today the CLI uses `beforeLint` to render the project-detection + * block before lint runs; `afterLint` is invoked once lint (and any + * downstream dead-code) finishes so the caller can attach side-effects + * keyed on whether lint failed. Per-phase spinner reporting is owned + * by the `Progress` service — the caller provides `Progress.layerOra` + * or `Progress.layerNoop` rather than threading spinner handles + * through hooks. + */ +export interface InspectHooks { + readonly beforeLint?: ( + project: ProjectInfo, + lintIncludePaths: ReadonlyArray | undefined, + ) => Effect.Effect; + readonly afterLint?: (didFail: boolean) => Effect.Effect; +} diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index d639d3391b..ba4fb6744b 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -1,47 +1,20 @@ import * as path from "node:path"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; 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 { assignFixGroups } from "./utils/assign-fix-groups.js"; -import { dedupeRelatedDiagnostics } from "./utils/dedupe-related-diagnostics.js"; -import { sortDiagnosticsStable } from "./utils/sort-diagnostics-stable.js"; +import type { Diagnostic } from "./types/index.js"; +import { assembleInspectOutput, type InspectOutput } from "./assemble-inspect-output.js"; +import { startBackgroundAnalyzerExecution } from "./background-analyzer-execution.js"; +import { buildLintExecution } from "./build-lint-execution.js"; import { buildDiagnosticPipeline } from "./build-diagnostic-pipeline.js"; -import { checkExpoProject } from "./check-expo-project.js"; -import { checkPnpmHardening } from "./check-pnpm-hardening.js"; -import { checkReactNativeProject } from "./check-react-native-project.js"; -import { checkReactServerComponentsAdvisory } from "./check-react-server-components-advisory.js"; -import { checkReducedMotion } from "./check-reduced-motion.js"; -import { checkSecurityScanCooperative } from "./check-security-scan.js"; -import { - DEAD_CODE_OVERLAP_PARSE_SHARE, - DEAD_CODE_PHASE_TIMEOUT_MS, - DEFAULT_SHOW_WARNINGS, - MILLISECONDS_PER_SECOND, - MIN_DEAD_CODE_PARSE_CONCURRENCY, - MIN_SCAN_CONCURRENCY, -} from "./constants.js"; +import { MILLISECONDS_PER_SECOND } from "./constants.js"; +import { buildDeadCodePlan } from "./build-dead-code-plan.js"; +import { type DeadCodeFailureState, startDeadCodeExecution } from "./dead-code-execution.js"; import { highlighter } from "./highlighter.js"; -import { computeExplicitLintIncludePaths } from "./explicit-lint-include-paths.js"; -import { deadCodeMaySurfaceWhenWarningsHidden } from "./utils/dead-code-may-surface.js"; -import { - NoReactDependency, - type OxlintUnavailable, - ReactDoctorError, - type ReactDoctorErrorReason, - ScanDeadlineExceeded, -} from "./errors.js"; -import { filterDiagnosticsForSurface } from "./filter-for-surface.js"; +import { NoReactDependency, ReactDoctorError, ScanDeadlineExceeded } from "./errors.js"; +import { finalizeDiagnosticOutput } from "./finalize-diagnostic-output.js"; import { isAnalyzableProject } from "./project-info/index.js"; import { DeadCodeOverlap, @@ -49,268 +22,28 @@ import { LintPhaseTimeoutMs, OxlintConcurrency, ScanDeadlineMs, - SupplyChainOverlapTimeoutMs, } from "./refs.js"; -import { remainingDeadlineBudgetMs } from "./utils/remaining-deadline-budget-ms.js"; -import { resolveDeadCodeTimeout } from "./utils/resolve-dead-code-timeout.js"; -import { resolveLintIncludePaths } from "./resolve-lint-include-paths.js"; +import { resolveInspectScanSettings } from "./resolve-inspect-scan-settings.js"; +import type { InspectHooks, InspectInput } from "./run-inspect-types.js"; +import { type LintFailureState, runLintPhase } from "./run-lint-phase.js"; +import { startScoreMetadataExecution } from "./score-metadata-execution.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"; -import { type LintFileCoverage, LintPartialFailures, Linter } from "./services/linter.js"; +import { LintPartialFailures, Linter } from "./services/linter.js"; import { Progress } from "./services/progress.js"; import { Project } from "./services/project.js"; +import { ProjectChecks } from "./services/project-checks.js"; import { Reporter } from "./services/reporter.js"; import { Score } from "./services/score.js"; import { SupplyChain } from "./services/supply-chain.js"; -import type { ScoreRequestMetadata } from "./calculate-score.js"; -import { resolveGithubActionsScoreMetadata } from "./utils/resolve-github-actions-score-metadata.js"; +import { resolveScanCompletion } from "./utils/resolve-scan-completion.js"; import { resolveScanConcurrency } from "./utils/resolve-scan-concurrency.js"; -import { toNormalizedRelativePath } from "./utils/to-normalized-relative-path.js"; +import { resolveScanFileCoverage } from "./utils/resolve-scan-file-coverage.js"; -export interface InspectInput { - readonly directory: string; - 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; - /** - * Set when this scan runs concurrently with sibling scans in one process - * (the CLI's multi-project pool). Such a scan can't safely reason about the - * shared memory budget from its own available-memory reading — N concurrent - * scans each reading "plenty available" would each fork a dead-code worker - * and sum past the single-scan budget — so the dead-code overlap memory gate - * (`"auto"`) stays sequential for concurrent members. An explicit - * `REACT_DOCTOR_DEAD_CODE_OVERLAP=on` override still wins. Defaults to `false`. - */ - readonly concurrentScan?: 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; -} - -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 - * (the memory gate opened, or overlap was forced via - * `REACT_DOCTOR_DEAD_CODE_OVERLAP`). `false` for the strictly-sequential - * path: diff/staged/`--no-warnings` runs that skip dead-code, a closed - * memory gate, or `overlap=off`. 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 (a non-ignorable fs error - * escaping the cooperative walk) and failed open to no diagnostics — - * surfaced for telemetry and skipped-check accounting so a failed 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; - /** - * 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; -} - -/** - * The settled result of the background supply-chain fiber: its collected - * diagnostics, plus whether the fork-relative overlap timeout fired (in which - * case `diagnostics` is empty — the fail-open outcome). - */ -interface SupplyChainForkResult { - readonly diagnostics: ReadonlyArray; - readonly timedOut: boolean; -} - -/** - * Hooks the caller participates in without owning the orchestration. - * Today the CLI uses `beforeLint` to render the project-detection - * block before lint runs; `afterLint` is invoked once lint (and any - * downstream dead-code) finishes so the caller can attach side-effects - * keyed on whether lint failed. Per-phase spinner reporting is owned - * by the `Progress` service — the caller provides `Progress.layerOra` - * or `Progress.layerNoop` rather than threading spinner handles - * through hooks. - */ -export interface InspectHooks { - readonly beforeLint?: ( - project: ProjectInfo, - lintIncludePaths: ReadonlyArray | undefined, - ) => Effect.Effect; - readonly afterLint?: (didFail: boolean) => Effect.Effect; -} +export type { InspectOutput } from "./assemble-inspect-output.js"; +export type { InspectHooks, InspectInput } from "./run-inspect-types.js"; const NO_HOOKS: Required> = { beforeLint: () => Effect.void, @@ -332,21 +65,6 @@ const fileReader = return lines === null ? null : [...lines]; }; -const LINT_FAIL_TEXT = "Scanning failed (lint, non-fatal)."; -const LINT_NATIVE_BINDING_FAIL_TEXT = (nodeVersion: string): string => - `Scanning failed — oxlint native binding not found (Node ${nodeVersion}).`; -const DEAD_CODE_FAIL_TEXT = "Scanning failed (dead-code analysis, non-fatal)."; - -const formatLintFailText = ( - reasonTag: ReactDoctorErrorReason["_tag"] | null, - nodeVersion: string, -): string => { - if (reasonTag === "OxlintUnavailable" || reasonTag === "OxlintSpawnFailed") { - return LINT_NATIVE_BINDING_FAIL_TEXT(nodeVersion); - } - return LINT_FAIL_TEXT; -}; - /** * The full inspect orchestration as a single composable Effect. * @@ -413,6 +131,7 @@ export const runInspect = ( | Reporter | Score | SupplyChain + | ProjectChecks | HooksR > => Effect.gen(function* () { @@ -426,6 +145,7 @@ export const runInspect = ( const supplyChainService = yield* SupplyChain; const gitService = yield* Git; const progressService = yield* Progress; + const projectChecksService = yield* ProjectChecks; const partialFailuresRef = yield* LintPartialFailures; const resolvedConfig: ResolvedConfig = yield* configService.resolve(input.directory); @@ -437,41 +157,29 @@ export const runInspect = ( reason: new NoReactDependency({ directory: scanDirectory }), }); } - const [repo, sha, defaultBranch] = yield* Effect.all( - [ - gitService - .githubRepo(scanDirectory) - .pipe(Effect.orElseSucceed(() => null as string | null)), - gitService.headSha(scanDirectory).pipe(Effect.orElseSucceed(() => null as string | null)), - gitService - .defaultBranch(scanDirectory) - .pipe(Effect.orElseSucceed(() => null as string | null)), - ], - { concurrency: 3 }, - ); - const githubActionsScoreMetadata = input.isCi ? resolveGithubActionsScoreMetadata() : {}; - const githubViewerPermissionFiber = yield* Effect.forkChild( - input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null - ? gitService - .githubViewerPermission({ directory: scanDirectory, repo }) - .pipe(Effect.orElseSucceed(() => null as string | null)) - : Effect.succeed(null as string | null), - ); + const scoreMetadataExecution = yield* startScoreMetadataExecution({ + gitService, + directory: scanDirectory, + project, + isCi: input.isCi, + shouldResolveLocalGithubViewerPermission: input.resolveLocalGithubViewerPermission === true, + doctorVersion: input.doctorVersion, + runId: input.runId, + }); - const explicitLintIncludePaths = input.skipExplicitIncludePathFilter - ? input.includePaths.length > 0 - ? [...input.includePaths] - : undefined - : computeExplicitLintIncludePaths([...input.includePaths]); - const lintIncludePaths = - explicitLintIncludePaths ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config); + const scanSettings = resolveInspectScanSettings({ + input, + rootDirectory: scanDirectory, + userConfig: resolvedConfig.config, + }); + const { lintIncludePaths, isDiffMode, showWarnings, shouldRunSupplyChain } = scanSettings; // Absolute paths of the exact file set the linter scans, captured ONLY // for the multi-project summary (the sole consumer), which signals via // `suppressScanSummary`. Gating avoids a redundant full-tree walk on // every single-project / `diagnose()` run — for a full scan the linter // already enumerates the same files, so we'd otherwise list twice. - const fallbackScannedFilePaths = input.suppressScanSummary + const fallbackScannedFilePaths = scanSettings.shouldCollectFallbackScannedFilePaths ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map( (relativePath) => path.resolve(scanDirectory, relativePath), ) @@ -481,10 +189,6 @@ export const runInspect = ( const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint; yield* beforeLint(project, lintIncludePaths ?? undefined); - const isDiffMode = input.includePaths.length > 0; - - const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? DEFAULT_SHOW_WARNINGS; - const transform = buildDiagnosticPipeline({ rootDirectory: scanDirectory, userConfig: resolvedConfig.config, @@ -501,132 +205,27 @@ export const runInspect = ( Stream.tap((diagnostic) => reporterService.emit(diagnostic)), ); - // ── Phase: environment checks ────────────────────────────────── - // The project-shape checks below are sub-millisecond; the security scan - // (whole-tree content pass) is heavy and forks separately just below. - const environmentDiagnostics: ReadonlyArray = isDiffMode - ? [] - : [ - ...checkReducedMotion(scanDirectory), - ...checkPnpmHardening(scanDirectory), - ...checkReactServerComponentsAdvisory(scanDirectory, project), - ...checkExpoProject(scanDirectory, project), - ...checkReactNativeProject(scanDirectory, project), - ]; - const envCollected = yield* Stream.runCollect( - applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)), - ); - - // ── Phase: security scan (content-regex over the whole tree) ─── - // Registry rules carrying a `scan` run here, not via oxlint — over shipped - // artifacts / dotenv / SQL that lint never parses. It's the heaviest CPU - // phase on real repos (~O(rules × files × content)) and previously ran - // SYNCHRONOUSLY before lint, blocking the event loop the whole time. Fork it - // here (before lint) and join it just before the concat so its main-thread - // CPU overlaps the subprocess-bound lint pass; `checkSecurityScanCooperative` - // hands the event loop back on a per-slice time budget so it can't starve - // lint's subprocess spawning/draining or sibling projects. Skipped in - // diff/staged mode like the env checks. The final stable sort makes the - // concat order irrelevant, so output stays byte-identical to the serial path. - const securityScanFailedRef = yield* Ref.make(false); - const securityScanFiber = yield* Effect.forkChild( - Stream.runCollect( - applyPerElementPipeline( - isDiffMode - ? (Stream.empty as Stream.Stream) - : Stream.unwrap( - // Fail-open like every other analyzer: a non-ignorable fs - // error escaping the cooperative walk (fd exhaustion under - // concurrent oxlint workers, EIO) must skip the pass, not - // defect through the unconditional `Fiber.join` and sink an - // otherwise-successful scan. The skip is recorded on - // `securityScanFailed` so telemetry can tell a failed pass - // from a clean one — mirroring `supplyChainOverlapTimedOut`. - Effect.tryPromise(() => - checkSecurityScanCooperative(scanDirectory, { - project, - ignoredTags: input.ignoredTags, - includedTags: input.includedTags, - includeTagDefaults: input.includeTagDefaults, - }), - ).pipe( - Effect.map((diagnostics) => Stream.fromIterable(diagnostics)), - Effect.catch(() => - Ref.set(securityScanFailedRef, true).pipe( - Effect.as(Stream.empty as Stream.Stream), - ), - ), - ), - ), - ), - ).pipe(Effect.withSpan("SecurityScan.run")), - ); - - // ── Phase: supply-chain score check (Socket.dev, opt-in) ─────── - // Whole-project (package.json) property, so a plain diff/staged scan - // skips it like the environment checks above — but a diff that edits - // the scanned project's `package.json` (e.g. a PR adding/bumping a - // dependency) still runs it via `supplyChainManifestChanged`, so the - // change is scored where it matters. Enablement is decided by the - // provided layer (`SupplyChain.layerOf([])` when disabled). The stream - // is fail-open — per-package timeouts / network failures are recovered - // to "skip" inside the check — so a Socket API outage never sinks the scan. - // - // The check is ~100% network-bound and the lint pass below is ~100% - // CPU/subprocess-bound, so we fork it onto a child fiber here and join it - // just before the diagnostic concat — its wall-clock overlaps lint instead - // of running serially before it. `forkChild` is structured: any - // error/interrupt in the orchestrator tears this fiber down with it, so it - // never leaks. The collect can't fail (the stream has no error channel), so - // the only failure is the `Effect.timeout` deadline, which we fold into a - // 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.) - // When skipped, the fork takes the empty branch so the join below stays - // unconditional (mirroring the viewer-permission fiber above). - const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false); - const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs; - const supplyChainFiber = yield* Effect.forkChild( - shouldRunSupplyChain - ? Stream.runCollect( - applyPerElementPipeline( - supplyChainService.run({ - rootDirectory: scanDirectory, - userConfig: resolvedConfig.config, - }), - ), - ).pipe( - Effect.map( - (diagnostics): SupplyChainForkResult => ({ - diagnostics, - timedOut: false, - }), - ), - Effect.timeout(supplyChainOverlapTimeout), - Effect.orElseSucceed( - (): SupplyChainForkResult => ({ diagnostics: [], timedOut: true }), - ), - ) - : Effect.succeed({ - diagnostics: [], - timedOut: false, - }), - ); + const backgroundAnalyzerExecution = yield* startBackgroundAnalyzerExecution({ + projectChecksService, + supplyChainService, + rootDirectory: scanDirectory, + project, + userConfig: resolvedConfig.config, + isDiffMode, + shouldRunSupplyChain, + ignoredTags: input.ignoredTags, + includedTags: input.includedTags, + includeTagDefaults: input.includeTagDefaults, + processDiagnostics: applyPerElementPipeline, + }); - const lintFailure = yield* Ref.make<{ - didFail: boolean; - reason: string | null; - reasonTag: ReactDoctorErrorReason["_tag"] | null; - reasonKind: OxlintUnavailable["kind"] | null; - }>({ didFail: false, reason: null, reasonTag: null, reasonKind: null }); - const deadCodeFailure = yield* Ref.make<{ - didFail: boolean; - reason: string | null; - }>({ + const lintFailure = yield* Ref.make({ + didFail: false, + reason: null, + reasonTag: null, + reasonKind: null, + }); + const deadCodeFailure = yield* Ref.make({ didFail: false, reason: null, }); @@ -639,24 +238,8 @@ export const runInspect = ( const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency); const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs; const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs; - // The dead-code phase timeout normally tracks the file-count-scaled worker - // timeout (`resolveDeadCodeTimeout`), so a large repo's legitimately-long - // pass isn't reclaimed before it finishes. But an EXPLICIT override (an env - // value or a test `Layer` that sets it off its default) is honored verbatim - // — tests pin it low to exercise the skip path, and that intent must win - // over the scaling. - const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs: number): number => - deadCodePhaseTimeoutMs === DEAD_CODE_PHASE_TIMEOUT_MS - ? scaledPhaseTimeoutMs - : deadCodePhaseTimeoutMs; const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : ""; - // Caps a phase timeout to what's left of the `--max-duration` budget; - // identity when no deadline was set. - const capToDeadline = (phaseTimeoutMs: number): number => - input.deadlineEpochMs === undefined - ? phaseTimeoutMs - : Math.min(phaseTimeoutMs, remainingDeadlineBudgetMs(input.deadlineEpochMs)); // ── Dead-code plan ──────────────────────────────────────────────── // Dead-code (deslop reachability) emits only `"warning"`-severity @@ -665,10 +248,6 @@ export const runInspect = ( // out entirely before any surface or the score, making the expensive pass // pure wasted work — so skip it then, unless a severity override restamps // dead-code findings so they survive the global hide. - const shouldRunDeadCode = - input.runDeadCode && - !isDiffMode && - (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config)); // Dead-code runs SEQUENTIALLY (after lint, with the full core budget) by // default. deslop's parse pass is CPU-bound, so overlapping it with the // equally CPU-bound oxlint pool can't shrink wall-clock — there are no spare @@ -682,201 +261,59 @@ export const runInspect = ( // capped (`parseConcurrency`) and lint shrinks to the remainder — so they // sum to the cores instead of doubling them. const deadCodeOverlapMode = yield* DeadCodeOverlap; - const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on"; - const deadCodeParseConcurrency = shouldOverlapDeadCode - ? Math.max( - MIN_DEAD_CODE_PARSE_CONCURRENCY, - Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE), - ) - : undefined; - const lintConcurrency = - deadCodeParseConcurrency === undefined - ? scanConcurrency - : Math.max(MIN_SCAN_CONCURRENCY, scanConcurrency - deadCodeParseConcurrency); + const deadCodePlan = buildDeadCodePlan({ + runDeadCode: input.runDeadCode, + isDiffMode, + showWarnings, + userConfig: resolvedConfig.config, + overlapMode: deadCodeOverlapMode, + scanConcurrency, + }); - // Runs either forked (overlap) or inline (sequential) with the same pipeline - // + failure Ref. The timeout is a parameter because it scales with the repo's - // source-file count — known accurately only after lint, on the sequential - // path. Building this is side-effect-free; the worker spawns only when the - // effect runs. - const buildCollectDeadCode = (deadCodeTimeout: { - workerTimeoutMs: number; - phaseTimeoutMs: number; - }) => - Stream.runCollect( - applyPerElementPipeline( - deadCodeService - .run({ - rootDirectory: scanDirectory, - parseConcurrency: deadCodeParseConcurrency, - workerTimeoutMs: deadCodeTimeout.workerTimeoutMs, - onCacheOutcome: (didHitCache) => { - deadCodeCacheHit = didHitCache; - }, - onSummaryCacheStats: (stats) => { - deadCodeSummaryCacheHits = stats.hits; - deadCodeSummaryCacheMisses = stats.misses; - }, - }) - .pipe( - Stream.catchTag("ReactDoctorError", (error: ReactDoctorError) => - Stream.unwrap( - Effect.gen(function* () { - yield* Ref.set(deadCodeFailure, { - didFail: true, - reason: error.message, - }); - return Stream.empty as Stream.Stream; - }), - ), - ), - ), - ), - ).pipe( - // Dead-code phase cap (Effect-side): sits ABOVE the in-worker SIGKILL - // timer as a runtime-independent backstop for a starved event loop. On - // timeout, fold into the existing dead-code skip contract and yield an - // empty chunk so the scan still completes. - Effect.timeoutOption(deadCodeTimeout.phaseTimeoutMs), - Effect.flatMap( - Option.match({ - onNone: () => - Ref.set(deadCodeFailure, { - didFail: true, - reason: `Dead-code analysis exceeded ${Math.round( - deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND, - )}s and was skipped.`, - }).pipe(Effect.as([])), - onSome: Effect.succeed, - }), - ), - ); - // The overlap fork happens BEFORE lint, so the lint-reported file count isn't - // known yet — scale the timeout off the project's discovered source count and - // the reduced core share. (`forkChild`, not `startImmediately`: the lint - // `Stream.runCollect` below blocks the parent on async oxlint spawns, yielding - // the runtime to this child so it runs DURING lint. Auto-supervised — - // interrupted if the parent dies.) - const overlapDeadCodeTimeout = resolveDeadCodeTimeout({ - sourceFileCount: project.sourceFileCount, - deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency, - fullConcurrency: scanConcurrency, + const deadCodeExecution = yield* startDeadCodeExecution({ + deadCodeService, + failureRef: deadCodeFailure, + plan: deadCodePlan, + rootDirectory: scanDirectory, + discoveredSourceFileCount: project.sourceFileCount, + scanConcurrency, + configuredPhaseTimeoutMs: deadCodePhaseTimeoutMs, + deadlineEpochMs: input.deadlineEpochMs, + processDiagnostics: applyPerElementPipeline, }); - const deadCodeFiber = shouldOverlapDeadCode - ? yield* Effect.forkChild( - buildCollectDeadCode({ - workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs, - phaseTimeoutMs: capToDeadline( - resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs), - ), - }), - ) - : null; const scanProgress = yield* progressService.start("Scanning..."); const scanStartTime = Date.now(); - let lastReportedTotalFileCount = 0; - // `null` until the cache path reports — stays `null` when the cache is off - // or bypassed so the wide event can tell "no cache" from "0% hit". - let lintCacheHitFileCount: number | null = null; - 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 - .run({ - rootDirectory: scanDirectory, - project, - includePaths: lintIncludePaths ?? undefined, - nodeBinaryPath: input.nodeBinaryPath, - customRulesOnly: input.customRulesOnly, - respectInlineDisables: input.respectInlineDisables, - adoptExistingLintConfig: input.adoptExistingLintConfig, - ignoredTags: input.ignoredTags, - includedTags: input.includedTags, - includeTagDefaults: input.includeTagDefaults, - userConfig: resolvedConfig.config ?? undefined, - configSourceDirectory: resolvedConfig.configSourceDirectory ?? undefined, - onFileProgress: (scannedFileCount, totalFileCount) => { - lastReportedTotalFileCount = totalFileCount; - Effect.runSync( - scanProgress.update( - `Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`, - ), - ); - }, - onFileCoverage: (coverage) => { - lintFileCoverageState.value = coverage; - }, - onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => { - lintCacheHitFileCount = cacheHitFileCount; - lintCacheTotalFileCount = totalConsideredFileCount; - }, - onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => { - lintSidecarReplayedFileCount = sidecarReplayedFileCount; - lintSidecarTotalFileCount = sidecarConsideredFileCount; - }, - deadlineEpochMs: input.deadlineEpochMs, - }) - .pipe( - Stream.catchTag("ReactDoctorError", (error: ReactDoctorError) => - Stream.unwrap( - Effect.gen(function* () { - yield* Ref.set(lintFailure, { - didFail: true, - reason: error.message, - reasonTag: error.reason._tag, - reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null, - }); - return Stream.empty as Stream.Stream; - }), + const lintExecution = buildLintExecution({ + rootDirectory: scanDirectory, + project, + includePaths: lintIncludePaths, + options: input, + resolvedConfig, + reportFileProgress: (scannedFileCount, totalFileCount) => { + Effect.runSync( + scanProgress.update( + `Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`, ), - ), - ); - // When dead-code is overlapped (opt-in `DeadCodeOverlap="on"`), lint runs on - // the reduced share of the core budget so the two CPU-bound pools sum to the - // cores instead of oversubscribing. The default (sequential) path leaves lint - // untouched at the full budget. - const rawLintStream = shouldOverlapDeadCode - ? baseLintStream.pipe(Stream.provideService(OxlintConcurrency, lintConcurrency)) - : baseLintStream; - - // Lint phase cap (Effect-side, runtime-independent of the per-batch - // spawn timeout and the bounded split cascade): on timeout, fold into - // the existing lint-failure contract (score becomes null) with an - // `OxlintBatchExceeded`-tagged reason so renderers dispatch on it, and - // yield an empty chunk so the rest of the scan still completes. - const filteredLintDiagnostics = yield* Stream.runCollect( - filterPerElementPipeline(rawLintStream), - ).pipe( - Effect.timeoutOption(lintPhaseTimeoutMs), - Effect.flatMap( - Option.match({ - onNone: () => - Ref.set(lintFailure, { - didFail: true, - reason: `Lint analysis exceeded ${ - lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND - }s and was skipped.`, - reasonTag: "OxlintBatchExceeded", - reasonKind: null, - }).pipe(Effect.as([])), - onSome: Effect.succeed, - }), - ), - ); - const lintCollected = dedupeRelatedDiagnostics(filteredLintDiagnostics); - yield* Effect.forEach(lintCollected, reporterService.emit, { discard: true }); - const lintFailureState = yield* Ref.get(lintFailure); - yield* afterLint(lintFailureState.didFail); + ); + }, + }); - if (lintFailureState.didFail) { - yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version)); - } + const lintResult = yield* runLintPhase({ + linterService, + lintInput: lintExecution.input, + failureRef: lintFailure, + shouldOverrideLintConcurrency: deadCodePlan.shouldOverlap, + lintConcurrency: deadCodePlan.lintConcurrency, + phaseTimeoutMs: lintPhaseTimeoutMs, + filterDiagnostics: filterPerElementPipeline, + reporterService, + afterLint, + progress: scanProgress, + nodeVersion: process.version, + }); + const lintCollected = lintResult.diagnostics; + const lintFailureState = lintResult.failure; // ora throttles renders to its frame interval, so the final `(N, N)` // progress frame the linter emits on its last batch is overwritten by the @@ -884,207 +321,115 @@ export const runInspect = ( // short of N even though every file was scanned (issue #815). Resolve the // full total now and carry it into the dead-code label so "scanned N files" // stays visible for the whole (longer) dead-code pass. - const candidateFiles = - lintFileCoverageState.value === null - ? [] - : [ - ...new Set( - lintFileCoverageState.value.candidateFiles.map((filePath) => - toNormalizedRelativePath(filePath, scanDirectory), - ), - ), - ]; - const analyzedFiles = - lintFileCoverageState.value === null - ? [] - : [ - ...new Set( - lintFileCoverageState.value.analyzedFiles.map((filePath) => - toNormalizedRelativePath(filePath, scanDirectory), - ), - ), - ].sort(); - const totalFileCount = - candidateFiles.length || - lastReportedTotalFileCount || - (lintIncludePaths?.length ?? project.sourceFileCount); - const scannedFilePaths = input.suppressScanSummary - ? candidateFiles.length > 0 - ? candidateFiles.map((filePath) => path.resolve(scanDirectory, filePath)) - : fallbackScannedFilePaths - : []; + const { + analyzedFiles, + scannedFileCount: totalFileCount, + scannedFilePaths, + } = resolveScanFileCoverage({ + rootDirectory: scanDirectory, + lintFileCoverage: lintExecution.state.fileCoverage, + lastReportedTotalFileCount: lintExecution.state.lastReportedTotalFileCount, + lintIncludePathCount: lintIncludePaths?.length ?? null, + discoveredSourceFileCount: project.sourceFileCount, + includeScannedFilePaths: input.suppressScanSummary === true, + fallbackScannedFilePaths, + }); const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`; - // Resolve dead-code now that lint has settled. Three paths: - // • lint failed → no score, so dead-code is wasted: interrupt the forked - // fiber (its AbortSignal SIGKILLs the worker) / skip the inline run, and - // discard any result — preserving the pre-overlap short-circuit. - // • overlapped → the fiber has been running during lint; just join it. - // • sequential → run it inline after the "analyzing dead code" label. - // The spinner label stays sequential (lint counter, then "analyzing dead - // code") for clean output even though an overlapped fiber is often already - // done by the time we get here — purely cosmetic. - let deadCodeCollected: ReadonlyArray = []; - if (lintFailureState.didFail) { - if (deadCodeFiber !== null) yield* Fiber.interrupt(deadCodeFiber); - } else if (shouldRunDeadCode) { - const isDeadlineSpent = - input.deadlineEpochMs !== undefined && - remainingDeadlineBudgetMs(input.deadlineEpochMs) === 0; - if (isDeadlineSpent) { - // Max-duration budget spent on lint — skip dead-code so a truncated - // run nulls the score consistently whether the pass would have run - // sequentially or was overlapped with lint. Interrupt an overlap - // fiber rather than joining it past the budget. - if (deadCodeFiber !== null) yield* Fiber.interrupt(deadCodeFiber); - yield* Ref.set(deadCodeFailure, { - didFail: true, - reason: "Dead-code analysis skipped — max scan duration reached.", - }); - } else { - yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`); - // Sequential path: deslop gets the full core budget, and lint has already - // reported the true file count — scale the timeout to it so a large repo's - // legitimately-long pass isn't reclaimed before it finishes. - const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({ - sourceFileCount: totalFileCount, - deadCodeConcurrency: scanConcurrency, - fullConcurrency: scanConcurrency, - }); - deadCodeCollected = - deadCodeFiber !== null - ? yield* Fiber.join(deadCodeFiber) - : yield* buildCollectDeadCode({ - workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs, - phaseTimeoutMs: capToDeadline( - resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs), - ), - }); - } - } - // On lint failure dead-code is discarded entirely, so a failure the forked - // fiber may have recorded before we interrupted it must not leak into the - // output — preserve the "lint failed ⇒ didDeadCodeFail: false" contract. - const deadCodeFailureState = lintFailureState.didFail - ? { didFail: false, reason: null } - : yield* Ref.get(deadCodeFailure); - + const deadCodeResult = yield* deadCodeExecution.settle({ + lintDidFail: lintFailureState.didFail, + totalFileCount, + scannedFilesLabel, + progress: scanProgress, + }); const scanElapsedMilliseconds = Date.now() - scanStartTime; - const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1); - - if (!lintFailureState.didFail) { - if (deadCodeFailureState.didFail) { - yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT); - } else if (input.suppressScanSummary) { - yield* scanProgress.stop(); - } else { - yield* scanProgress.succeed( - `Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`, - ); - } + const scanCompletion = resolveScanCompletion({ + lintDidFail: lintFailureState.didFail, + deadCodeFailure: deadCodeResult.failure, + suppressScanSummary: Boolean(input.suppressScanSummary), + scannedFilesLabel, + scanElapsedMilliseconds, + workerCountSuffix, + }); + const deadCodeFailureState = scanCompletion.deadCodeFailure; + + if (scanCompletion.progress.action === "fail" && scanCompletion.progress.text !== null) { + yield* scanProgress.fail(scanCompletion.progress.text); + } else if (scanCompletion.progress.action === "stop") { + yield* scanProgress.stop(); + } else if ( + scanCompletion.progress.action === "succeed" && + scanCompletion.progress.text !== null + ) { + yield* scanProgress.succeed(scanCompletion.progress.text); } - // Join the background supply-chain fiber now that lint + dead-code have - // run, so its network time overlapped the lint pass. This lands BEFORE - // `reporterService.finalize` so every supply-chain `Reporter.emit` from the - // forked stream has flushed before a stateful reporter (e.g. NDJSON) closes - // its sink. Fail-open + the fork-relative timeout are already folded into - // the fiber result, so the join never fails; `timedOut` records whether the - // overlap budget fired (the rare hung-socket guard) for telemetry. - const supplyChainResult = yield* Fiber.join(supplyChainFiber); - const supplyChainCollected = supplyChainResult.diagnostics; - // Join the forked security scan (it overlapped lint). Its diagnostics are - // kept regardless of lint outcome, mirroring the other environment checks. - const securityScanCollected = yield* Fiber.join(securityScanFiber); + const backgroundAnalyzerResult = yield* backgroundAnalyzerExecution.join; yield* reporterService.finalize; - // Stamp shared `fixGroupId`s once on the finalized list (post-collection, - // pre-output), then sort into a total, content-stable order. The score - // below runs on a surface-filtered COPY and ignores the field + is - // set-based, so this stays score-neutral while the canonical order rides - // into the wire report, the on-disk diagnostics dump, the agent handoff, - // the Sentry wide event, and the scan-result cache — making all of them - // reproducible run-to-run, independent of the (parallel, cost-reordered) - // lint arrival order. - const finalDiagnostics: ReadonlyArray = sortDiagnosticsStable( - assignFixGroups([ - ...envCollected, - ...securityScanCollected, - ...supplyChainCollected, - ...lintCollected, - ...deadCodeCollected, - ]), - ); + const { diagnostics: finalDiagnostics, scoreDiagnostics } = finalizeDiagnosticOutput({ + environmentDiagnostics: backgroundAnalyzerResult.environmentDiagnostics, + securityDiagnostics: backgroundAnalyzerResult.securityDiagnostics, + supplyChainDiagnostics: backgroundAnalyzerResult.supplyChainDiagnostics, + lintDiagnostics: lintCollected, + deadCodeDiagnostics: deadCodeResult.diagnostics, + scoreSurface: input.scoreSurface ?? "score", + userConfig: resolvedConfig.config, + }); - const githubViewerPermission = yield* Fiber.join(githubViewerPermissionFiber); - const scoreMetadata: ScoreRequestMetadata = { - ...(repo !== null ? { repo } : {}), - ...(sha !== null ? { sha } : {}), - framework: project.framework, - ...(project.reactVersion !== null ? { reactVersion: project.reactVersion } : {}), - sourceFileCount: project.sourceFileCount, - ...(defaultBranch !== null ? { defaultBranch } : {}), - ...(input.doctorVersion !== undefined ? { doctorVersion: input.doctorVersion } : {}), - ...(input.runId !== undefined ? { runId: input.runId } : {}), - ...githubActionsScoreMetadata, - ...(githubViewerPermission !== null ? { githubViewerPermission } : {}), - }; + const scoreMetadata = yield* scoreMetadataExecution.join; - const scoreSurface: DiagnosticSurface = input.scoreSurface ?? "score"; - const scoreDiagnostics = filterDiagnosticsForSurface( - [...finalDiagnostics], - scoreSurface, - resolvedConfig.config, - ); // Dead-code findings feed the scored set, so a failed or deadline-skipped // dead-code pass would leave the score computed over an incomplete set — // overstating health. Null it like a lint failure; a pass that was merely // disabled never sets `didFail`, so `--no-deslop` scans keep their score. - const score = - lintFailureState.didFail || deadCodeFailureState.didFail - ? null - : yield* scoreService.compute({ - diagnostics: scoreDiagnostics, - isCi: input.isCi, - metadata: scoreMetadata, - }); + const score = scanCompletion.shouldComputeScore + ? yield* scoreService.compute({ + diagnostics: scoreDiagnostics, + isCi: input.isCi, + metadata: scoreMetadata, + }) + : null; const lintPartialFailures = yield* Ref.get(partialFailuresRef); - const securityScanFailed = yield* Ref.get(securityScanFailedRef); - return { + return assembleInspectOutput({ project, userConfig: resolvedConfig.config, resolvedDirectory: scanDirectory, diagnostics: finalDiagnostics, score, scoreMetadata, - didLintFail: lintFailureState.didFail, - lintFailureReason: lintFailureState.reason, - lintFailureReasonTag: lintFailureState.reasonTag, - lintFailureReasonKind: lintFailureState.reasonKind, - lintPartialFailures, - didDeadCodeFail: deadCodeFailureState.didFail, - deadCodeFailureReason: deadCodeFailureState.reason, - deadCodeOverlapped: shouldOverlapDeadCode, - scannedFileCount: totalFileCount, - scannedFilePaths, - analyzedFiles, - scanElapsedMilliseconds, - scanConcurrency, - supplyChainOverlapTimedOut: supplyChainResult.timedOut, - securityScanFailed, - lintCacheHitFileCount, - lintCacheTotalFileCount, - lintSidecarReplayedFileCount, - lintSidecarTotalFileCount, - // Lint failure discards the dead-code pass entirely (see - // `deadCodeFailureState` above), so its cache outcomes must not leak. - deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit, - deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits, - deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses, + lint: { + didFail: lintFailureState.didFail, + failureReason: lintFailureState.reason, + failureReasonTag: lintFailureState.reasonTag, + failureReasonKind: lintFailureState.reasonKind, + partialFailures: lintPartialFailures, + analyzedFiles, + cacheHitFileCount: lintExecution.state.cacheHitFileCount, + cacheTotalFileCount: lintExecution.state.cacheTotalFileCount, + sidecarReplayedFileCount: lintExecution.state.sidecarReplayedFileCount, + sidecarTotalFileCount: lintExecution.state.sidecarTotalFileCount, + }, + deadCode: { + didFail: deadCodeFailureState.didFail, + failureReason: deadCodeFailureState.reason, + didOverlap: deadCodePlan.shouldOverlap, + cacheHit: deadCodeResult.cacheHit, + summaryCacheHits: deadCodeResult.summaryCacheHits, + summaryCacheMisses: deadCodeResult.summaryCacheMisses, + }, + scan: { + scannedFileCount: totalFileCount, + scannedFilePaths, + elapsedMilliseconds: scanElapsedMilliseconds, + concurrency: scanConcurrency, + }, + supplyChainOverlapTimedOut: backgroundAnalyzerResult.supplyChainOverlapTimedOut, + securityScanFailed: backgroundAnalyzerResult.securityScanFailed, suppressedRuleCounts: transform.summarizeSuppressions(), - }; + }); }).pipe( Effect.withSpan("runInspect", { attributes: { diff --git a/packages/core/src/run-lint-phase.ts b/packages/core/src/run-lint-phase.ts new file mode 100644 index 0000000000..e5d582e2ca --- /dev/null +++ b/packages/core/src/run-lint-phase.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { MILLISECONDS_PER_SECOND } from "./constants.js"; +import { type OxlintUnavailable, ReactDoctorError, type ReactDoctorErrorReason } from "./errors.js"; +import { OxlintConcurrency } from "./refs.js"; +import type { LintInput, LintPartialFailures, Linter } from "./services/linter.js"; +import type { ProgressHandle } from "./services/progress.js"; +import type { Reporter } from "./services/reporter.js"; +import type { Diagnostic } from "./types/index.js"; +import { dedupeRelatedDiagnostics } from "./utils/dedupe-related-diagnostics.js"; + +export interface LintFailureState { + readonly didFail: boolean; + readonly reason: string | null; + readonly reasonTag: ReactDoctorErrorReason["_tag"] | null; + readonly reasonKind: OxlintUnavailable["kind"] | null; +} + +interface RunLintPhaseInput { + readonly linterService: Linter["Service"]; + readonly lintInput: LintInput; + readonly failureRef: Ref.Ref; + readonly shouldOverrideLintConcurrency: boolean; + readonly lintConcurrency: number; + readonly phaseTimeoutMs: number; + readonly filterDiagnostics: ( + stream: Stream.Stream, + ) => Stream.Stream; + readonly reporterService: Reporter["Service"]; + readonly afterLint: (didFail: boolean) => Effect.Effect; + readonly progress: ProgressHandle; + readonly nodeVersion: string; +} + +interface LintPhaseResult { + readonly diagnostics: ReadonlyArray; + readonly failure: LintFailureState; +} + +const LINT_FAIL_TEXT = "Scanning failed (lint, non-fatal)."; +const formatLintFailure = ( + reasonTag: ReactDoctorErrorReason["_tag"] | null, + nodeVersion: string, +): string => + reasonTag === "OxlintUnavailable" || reasonTag === "OxlintSpawnFailed" + ? `Scanning failed — oxlint native binding not found (Node ${nodeVersion}).` + : LINT_FAIL_TEXT; + +export const runLintPhase = ( + input: RunLintPhaseInput, +): Effect.Effect => + Effect.gen(function* () { + const baseLintStream = input.linterService.run(input.lintInput).pipe( + Stream.catchTag("ReactDoctorError", (error: ReactDoctorError) => + Stream.unwrap( + Ref.set(input.failureRef, { + didFail: true, + reason: error.message, + reasonTag: error.reason._tag, + reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null, + }).pipe(Effect.as(Stream.empty)), + ), + ), + ); + const rawLintStream = input.shouldOverrideLintConcurrency + ? baseLintStream.pipe(Stream.provideService(OxlintConcurrency, input.lintConcurrency)) + : baseLintStream; + const filteredDiagnostics = yield* Stream.runCollect( + input.filterDiagnostics(rawLintStream), + ).pipe( + Effect.timeoutOption(input.phaseTimeoutMs), + Effect.flatMap( + Option.match({ + onNone: () => + Ref.set(input.failureRef, { + didFail: true, + reason: `Lint analysis exceeded ${ + input.phaseTimeoutMs / MILLISECONDS_PER_SECOND + }s and was skipped.`, + reasonTag: "OxlintBatchExceeded", + reasonKind: null, + }).pipe(Effect.as([])), + onSome: Effect.succeed, + }), + ), + ); + const diagnostics = dedupeRelatedDiagnostics(filteredDiagnostics); + yield* Effect.forEach(diagnostics, input.reporterService.emit, { discard: true }); + const failure = yield* Ref.get(input.failureRef); + yield* input.afterLint(failure.didFail); + + if (failure.didFail) { + yield* input.progress.fail(formatLintFailure(failure.reasonTag, input.nodeVersion)); + } + + return { diagnostics, failure }; + }); diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts index 24ef540bb5..e14ae75b02 100644 --- a/packages/core/src/run-oxlint.ts +++ b/packages/core/src/run-oxlint.ts @@ -16,6 +16,7 @@ import { collectIgnorePatterns } from "./collect-ignore-patterns.js"; import { detectUserLintConfigPaths } from "./detect-user-lint-config.js"; import { ReactDoctorError } from "./errors.js"; import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js"; +import { getDiscoveredPackageGraph } from "./project-info/discover-project.js"; import { computeRulesetHash } from "./runners/oxlint/compute-ruleset-hash.js"; import { createOxlintConfig } from "./runners/oxlint/config.js"; import { collectUnpluginAutoImportGlobalScopes } from "./runners/oxlint/collect-unplugin-auto-import-global-scopes.js"; @@ -28,6 +29,7 @@ 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 { @@ -56,6 +58,8 @@ interface RunOxlintOptions { ignoredTags?: ReadonlySet; includedTags?: ReadonlySet; includeTagDefaults?: boolean; + enablePackageContext?: boolean; + enablePackageCapabilityGates?: boolean; /** * Optional react-doctor user config (already-loaded * `react-doctor.config.json` or `package.json#reactDoctor`). When @@ -133,6 +137,7 @@ interface RunOxlintOptions { * 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 @@ -377,6 +382,8 @@ export const runOxlint = async (options: RunOxlintOptions): Promise(), includedTags = new Set(), includeTagDefaults = false, + enablePackageContext = false, + enablePackageCapabilityGates = false, userConfig, configSourceDirectory = rootDirectory, onPartialFailure, @@ -418,6 +425,10 @@ export const runOxlint = async (options: RunOxlintOptions): Promise; + readonly dependencies: ReadonlyArray; +} + +const resolveRealDirectory = (directory: string): string => { + if (!fs.existsSync(directory)) return directory; + return fs.realpathSync(directory); +}; + +export const buildPackageContextSettings = ( + packageGraph: PackageGraph, + settingsRootDirectory: string, +): ReadonlyArray => { + const packageVersionByDirectory = new Map( + packageGraph.packages.map((packageNode) => [packageNode.directory, packageNode.version]), + ); + return packageGraph.packages + .map((packageNode): OxlintPackageContextSetting => { + const packageCapabilities = + packageGraph.getCapabilities(packageNode.directory) ?? new Set(); + return { + relativeDirectory: path + .relative(settingsRootDirectory, resolveRealDirectory(packageNode.directory)) + .replaceAll("\\", "/"), + capabilities: [...packageCapabilities].sort(), + dependencies: packageNode.dependencyDeclarations + .map( + (dependencyDeclaration): OxlintPackageDependencySetting => ({ + name: dependencyDeclaration.packageName, + section: dependencyDeclaration.section, + rawSpecifier: dependencyDeclaration.rawSpecifier, + resolvedSpecifier: + dependencyDeclaration.workspaceTargetPackageDirectory === null + ? dependencyDeclaration.resolvedSpecifier + : (packageVersionByDirectory.get( + dependencyDeclaration.workspaceTargetPackageDirectory, + ) ?? dependencyDeclaration.resolvedSpecifier), + }), + ) + .toSorted((leftDependency, rightDependency) => + leftDependency.name.localeCompare(rightDependency.name), + ), + }; + }) + .toSorted((leftPackage, rightPackage) => + leftPackage.relativeDirectory.localeCompare(rightPackage.relativeDirectory), + ); +}; diff --git a/packages/core/src/runners/oxlint/config.ts b/packages/core/src/runners/oxlint/config.ts index 362a91c44c..ab0e38a6e1 100644 --- a/packages/core/src/runners/oxlint/config.ts +++ b/packages/core/src/runners/oxlint/config.ts @@ -4,7 +4,7 @@ import reactDoctorPlugin, { REACT_COMPILER_RULES, REACT_DOCTOR_RULES, } from "oxlint-plugin-react-doctor"; -import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor"; +import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor/contracts"; import type { ProjectInfo, RuleSeverityControls } from "../../types/index.js"; import { resolveRuleSeverityOverride } from "../../resolve-rule-severity-override.js"; import { COMPILER_CLEANUP_BUCKET, COMPILER_CLEANUP_RULE_KEYS } from "../../constants.js"; @@ -13,6 +13,8 @@ import { filterRulesToAvailable, resolveReactHooksJsPlugin } from "./plugin-reso import type { JsPluginEntry, ResolvedUserPlugin } from "./plugin-resolution.js"; import { shouldEnableRuleByDefaultStatus } from "../../utils/should-enable-rule-by-default-status.js"; import type { UnpluginAutoImportGlobalScope } from "./collect-unplugin-auto-import-global-scopes.js"; +import { buildPackageContextSettings } from "./build-package-context-settings.js"; +import type { PackageGraph } from "../../project-info/package-graph.js"; export interface OxlintConfigOptions { pluginPath: string; @@ -62,6 +64,9 @@ export interface OxlintConfigOptions { * for other selections. */ sidecarRuleIdFilter?: ReadonlySet; + packageGraph?: PackageGraph; + enablePackageContext?: boolean; + enablePackageCapabilityGates?: boolean; } const resolveSettingsRootDirectory = (rootDirectory: string): string => { @@ -135,6 +140,9 @@ export const createOxlintConfig = ({ disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter, + packageGraph, + enablePackageContext = false, + enablePackageCapabilityGates = false, }: OxlintConfigOptions) => { const hasIncludedTags = includedTags.size > 0; // The sidecar carries only cross-file react-doctor rules — the React @@ -162,6 +170,21 @@ export const createOxlintConfig = ({ const capabilities = getCapabilities(project); const settingsRootDirectory = resolveSettingsRootDirectory(project.rootDirectory); + const shouldBuildPackageContext = enablePackageContext || enablePackageCapabilityGates; + const packageContexts = + shouldBuildPackageContext && packageGraph !== undefined + ? buildPackageContextSettings(packageGraph, settingsRootDirectory) + : []; + let candidateCapabilities = capabilities; + if (enablePackageCapabilityGates) { + const packageCandidateCapabilities = new Set(capabilities); + for (const packageContext of packageContexts) { + for (const capability of packageContext.capabilities) { + packageCandidateCapabilities.add(capability); + } + } + candidateCapabilities = packageCandidateCapabilities; + } const enabledReactDoctorRules: Record = {}; for (const registryEntry of REACT_DOCTOR_RULES) { @@ -191,9 +214,9 @@ export const createOxlintConfig = ({ !shouldEnableRule( rule.requires, rule.tags, - capabilities, + candidateCapabilities, ignoredTags, - rule.disabledWhen, + enablePackageCapabilityGates ? undefined : rule.disabledWhen, includedTags, ) ) @@ -273,6 +296,8 @@ export const createOxlintConfig = ({ // `hasCapability`. Sorted so equivalent projects hash identically // (this bag feeds the ruleset cache key). capabilities: [...capabilities].sort(), + ...(packageContexts.length > 0 ? { packageContexts, packageContextEnabled: true } : {}), + ...(enablePackageCapabilityGates ? { packageCapabilityGates: true } : {}), ...(runtimeGlobals && runtimeGlobals.length > 0 ? { runtimeGlobals: [...runtimeGlobals] } : {}), diff --git a/packages/core/src/runners/oxlint/plugin-resolution.ts b/packages/core/src/runners/oxlint/plugin-resolution.ts index 35296a5c36..bb8bc5b04c 100644 --- a/packages/core/src/runners/oxlint/plugin-resolution.ts +++ b/packages/core/src/runners/oxlint/plugin-resolution.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import * as path from "node:path"; -import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor"; +import type { OxlintRuleSeverity } from "oxlint-plugin-react-doctor/contracts"; import { messageFromUnknown } from "../../utils/message-from-unknown.js"; import { warnConfigIssue } from "../../utils/warn-config-issue.js"; diff --git a/packages/core/src/runners/oxlint/spawn-batches.ts b/packages/core/src/runners/oxlint/spawn-batches.ts index 2005165235..ab6c0cd5ad 100644 --- a/packages/core/src/runners/oxlint/spawn-batches.ts +++ b/packages/core/src/runners/oxlint/spawn-batches.ts @@ -14,6 +14,7 @@ import { dedupeDiagnostics } from "../../utils/dedupe-diagnostics.js"; import { mapWithConcurrency } from "../../utils/map-with-concurrency.js"; import { remainingDeadlineBudgetMs } from "../../utils/remaining-deadline-budget-ms.js"; import { resolveScanConcurrency } from "../../utils/resolve-scan-concurrency.js"; +import type { WorkerSlots } from "../../utils/create-worker-slots.js"; import { parseOxlintOutput } from "./parse-output.js"; import { spawnOxlint } from "./spawn-oxlint.js"; @@ -91,6 +92,7 @@ export interface SpawnLintBatchesInput { * resource error replays once with a single worker. */ readonly concurrency?: number; + readonly spawnSlots?: WorkerSlots; } interface BatchPassOutcome { @@ -109,6 +111,13 @@ interface BatchPassOutcome { readonly firstNonOomDropReason: string | null; } +interface BatchState { + deadlineMs: number | null; + deadlineSkippedFileCount: number; + didStart: boolean; + initialFileCount: number; +} + /** * Runs every prebuilt file batch through oxlint, with binary-split * retry on the splittable error classes (timeout / output-too-large / @@ -216,7 +225,7 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise => { // Past the --max-duration budget: skip instead of spawning, even inside a // binary-split retry, so a batch that started just before the deadline @@ -228,14 +237,31 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise => { + if (isPastDeadline()) { + deadlineSkippedFiles.push(...batch); + batchState.deadlineSkippedFileCount += batch.length; + return Promise.resolve(null); + } + return spawnOxlint( + batchArgs, + rootDirectory, + nodeBinaryPath, + spawnTimeoutMs, + outputMaxBytes, + signal, + () => { + if (batchState.didStart) return; + batchState.didStart = true; + startedFileCount += batchState.initialFileCount; + }, + ); + }; + const stdout = + input.spawnSlots === undefined + ? await spawnBatch() + : await input.spawnSlots.run(spawnBatch, signal); + if (stdout === null) return []; return parseOxlintOutput(stdout, project, rootDirectory, sourcePathByLintPath); } catch (error) { if (!isSplittableReactDoctorError(error)) throw error; @@ -302,16 +328,17 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise void, ): Promise => new Promise((resolve, reject) => { if (abortSignal?.aborted) { @@ -52,6 +53,7 @@ export const spawnOxlint = ( ); return; } + onSpawn?.(); const child = spawn( nodeBinaryPath, buildProfiledNodeArguments({ diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 7e6ad5c39e..5283317d57 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { FRAMEWORK_TOKENS } from "oxlint-plugin-react-doctor"; +import { FRAMEWORK_TOKENS } from "oxlint-plugin-react-doctor/contracts"; import * as Schema from "effect/Schema"; export const Severity = Schema.Literals(["error", "warning"]); diff --git a/packages/core/src/score-metadata-execution.ts b/packages/core/src/score-metadata-execution.ts new file mode 100644 index 0000000000..2ef5f4634a --- /dev/null +++ b/packages/core/src/score-metadata-execution.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import type { ScoreRequestMetadata } from "./calculate-score.js"; +import type { Git } from "./services/git.js"; +import type { ProjectInfo } from "./types/index.js"; +import { buildScoreRequestMetadata } from "./utils/build-score-request-metadata.js"; +import { resolveGithubActionsScoreMetadata } from "./utils/resolve-github-actions-score-metadata.js"; + +interface StartScoreMetadataExecutionInput { + readonly gitService: Git["Service"]; + readonly directory: string; + readonly project: ProjectInfo; + readonly isCi: boolean; + readonly shouldResolveLocalGithubViewerPermission: boolean; + readonly doctorVersion: string | undefined; + readonly runId: string | undefined; +} + +interface ScoreMetadataExecution { + readonly join: Effect.Effect; +} + +export const startScoreMetadataExecution = ( + input: StartScoreMetadataExecutionInput, +): Effect.Effect => + Effect.gen(function* () { + const [repo, sha, defaultBranch] = yield* Effect.all( + [ + input.gitService + .githubRepo(input.directory) + .pipe(Effect.orElseSucceed((): string | null => null)), + input.gitService + .headSha(input.directory) + .pipe(Effect.orElseSucceed((): string | null => null)), + input.gitService + .defaultBranch(input.directory) + .pipe(Effect.orElseSucceed((): string | null => null)), + ], + { concurrency: 3 }, + ); + const githubActionsScoreMetadata = input.isCi ? resolveGithubActionsScoreMetadata() : {}; + const githubViewerPermissionFiber = yield* Effect.forkChild( + input.shouldResolveLocalGithubViewerPermission && !input.isCi && repo !== null + ? input.gitService + .githubViewerPermission({ + directory: input.directory, + repo, + }) + .pipe(Effect.orElseSucceed((): string | null => null)) + : Effect.succeed(null), + ); + + return { + join: Effect.gen(function* () { + const githubViewerPermission = yield* Fiber.join(githubViewerPermissionFiber); + return buildScoreRequestMetadata({ + project: input.project, + repo, + sha, + defaultBranch, + doctorVersion: input.doctorVersion, + runId: input.runId, + githubActionsScoreMetadata, + githubViewerPermission, + }); + }), + }; + }); diff --git a/packages/core/src/services/git-command-executor.ts b/packages/core/src/services/git-command-executor.ts new file mode 100644 index 0000000000..9c462dcda2 --- /dev/null +++ b/packages/core/src/services/git-command-executor.ts @@ -0,0 +1,153 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + SPAWN_ARGS_MAX_LENGTH_CHARS, + SPAWN_ARGS_MAX_LENGTH_CHARS_DARWIN, + SPAWN_ARGS_MAX_LENGTH_CHARS_POSIX, +} from "../constants.js"; +import { GitInvocationFailed, ReactDoctorError } from "../errors.js"; +import { isDirectory } from "../project-info/fs-utils.js"; + +export interface GitCommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface GitCommandInput { + 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 GitCommandExecutor { + (input: GitCommandInput): Effect.Effect; +} + +// The Windows cap would reject legitimately long `--scope lines` diffs +// (`git diff -- `) that other platforms handle fine, +// silently degrading the scope — so the guard is platform-sized. Darwin gets +// its own cap because macOS ARG_MAX sits below the Linux one (rationale on +// each constant). +const resolveSpawnArgsLengthCap = (): number => { + if (process.platform === "win32") return SPAWN_ARGS_MAX_LENGTH_CHARS; + if (process.platform === "darwin") return SPAWN_ARGS_MAX_LENGTH_CHARS_DARWIN; + return SPAWN_ARGS_MAX_LENGTH_CHARS_POSIX; +}; + +export const makeGitCommandExecutor = ( + spawner: ChildProcessSpawner["Service"], +): GitCommandExecutor => { + const runCommand: GitCommandExecutor = (input) => { + // Shared by the async `PlatformError` path and the synchronous-spawn + // defect path so both spawn-failure shapes resolve identically: a + // non-`git` command degrades to a non-zero result the caller already + // handles; `git` fails with the tagged `GitInvocationFailed` its + // degradation paths recover from. + const foldSpawnFailure = (cause: unknown): Effect.Effect => + input.command !== "git" + ? Effect.succeed({ status: 127, stdout: "", stderr: String(cause) }) + : Effect.fail( + new ReactDoctorError({ + reason: new GitInvocationFailed({ + args: [...input.args], + directory: input.directory, + cause, + }), + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + // `child_process.spawn` throws synchronously when the cwd isn't a + // directory or argv exceeds the OS limit, bypassing Effect's failure + // channel. Guard both predictable cases before spawning. + if (!isDirectory(input.directory)) { + return yield* foldSpawnFailure( + `spawn ENOTDIR (cwd is not a directory: ${input.directory})`, + ); + } + const argvLengthChars = + input.command.length + + 1 + + input.args.reduce((total, argument) => total + argument.length + 1, 0); + const spawnArgsLengthCap = resolveSpawnArgsLengthCap(); + if (argvLengthChars > spawnArgsLengthCap) { + return yield* foldSpawnFailure( + `spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${spawnArgsLengthCap})`, + ); + } + const handle = yield* spawner.spawn( + // HACK: `extendEnv: true` is required for spawned commands + // to inherit `process.env.PATH` — without it Effect's + // `ChildProcess` defaults to an empty env and `spawn` + // immediately fails with `ENOENT` even when the binary is + // on the user's PATH. (`spawnSync` inherited PATH by + // default; ChildProcess's option flips the polarity.) + ChildProcess.make(input.command, [...input.args], { + cwd: input.directory, + env: input.env, + extendEnv: true, + }), + ); + // Count raw stdout bytes as they stream and fail before buffering an + // oversized staged blob or grep result in memory. + const maxStdoutBytes = input.maxStdoutBytes; + const stdoutByteCount = yield* Ref.make(0); + const stdoutStream = + maxStdoutBytes === undefined + ? handle.stdout + : handle.stdout.pipe( + Stream.tap((chunk) => + Ref.updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe( + Effect.flatMap((total) => + total > maxStdoutBytes + ? Effect.fail( + new ReactDoctorError({ + reason: new GitInvocationFailed({ + args: [...input.args], + directory: input.directory, + cause: new Error(`git stdout exceeded ${maxStdoutBytes} bytes`), + }), + }), + ) + : Effect.void, + ), + ), + ), + ); + const [stdout, stderr, status] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(stdoutStream)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: 3 }, + ); + return { status, stdout, stderr } satisfies GitCommandResult; + }), + ).pipe( + Effect.catchTag("PlatformError", foldSpawnFailure), + // Full args can contain scanned paths, so traces record only the + // command and first subcommand. + Effect.withSpan("git.exec", { + attributes: { + "git.command": input.command, + "git.subcommand": input.args[0] ?? "", + }, + }), + ); + }; + + return runCommand; +}; diff --git a/packages/core/src/services/git-output.ts b/packages/core/src/services/git-output.ts new file mode 100644 index 0000000000..2158648f51 --- /dev/null +++ b/packages/core/src/services/git-output.ts @@ -0,0 +1,53 @@ +import type { GitBaselineDiffPlan } from "./git-types.js"; + +export const trimGitOutputOrNull = (value: string): string | null => { + const trimmedValue = value.trim(); + return trimmedValue.length === 0 ? null : trimmedValue; +}; + +export const parseGithubRemoteRepository = (remoteUrl: string): string | null => { + const withoutGitSuffix = remoteUrl.trim().replace(/\.git$/, ""); + const sshMatch = /^git@github\.com:([^/\s]+)\/([^/\s]+)$/.exec(withoutGitSuffix); + if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`; + + const urlMatch = + /^(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+)$/.exec( + withoutGitSuffix, + ); + return urlMatch ? `${urlMatch[1]}/${urlMatch[2]}` : null; +}; + +export const parseGithubViewerPermission = (stdout: string): string | null => { + const value = trimGitOutputOrNull(stdout); + if (value === null || value === "null") return null; + return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : null; +}; + +export const splitNullSeparatedGitOutput = (value: string): ReadonlyArray => + value.split("\0").filter((entry) => entry.length > 0); + +export const parseGitBaselineDiffPlan = (value: string): GitBaselineDiffPlan | null => { + const entries = splitNullSeparatedGitOutput(value); + const baseFiles = new Set(); + const headFiles = new Set(); + for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 2) { + const status = entries[entryIndex]; + const filePath = entries[entryIndex + 1]; + if (status === undefined || filePath === undefined || status.length !== 1) return null; + if (status === "A") { + headFiles.add(filePath); + continue; + } + if (status === "D") { + baseFiles.add(filePath); + continue; + } + if (status === "M" || status === "T") { + baseFiles.add(filePath); + headFiles.add(filePath); + continue; + } + return null; + } + return { baseFiles: [...baseFiles], headFiles: [...headFiles], untrackedFiles: [] }; +}; diff --git a/packages/core/src/services/git-revision-policy.ts b/packages/core/src/services/git-revision-policy.ts new file mode 100644 index 0000000000..f589b4b610 --- /dev/null +++ b/packages/core/src/services/git-revision-policy.ts @@ -0,0 +1,38 @@ +const SAFE_GIT_REVISION_PATTERN = /^[A-Za-z0-9_./-]+$/; +const DIFF_RANGE_OPERATOR = ".."; +const SYMMETRIC_DIFF_RANGE_OPERATOR = "..."; + +export const GIT_REF_NAME_RULE = "must match [A-Za-z0-9_./-] without leading '-', '..', or '@{'"; + +export interface GitDiffRange { + readonly base: string; + readonly head: string; + readonly symmetric: boolean; +} + +export const isSafeGitRevision = (candidate: string): boolean => { + if (candidate.length === 0) return false; + if (candidate.startsWith("-")) return false; + if (candidate.startsWith(".") || candidate.endsWith(".")) return false; + if (candidate.includes("..") || candidate.includes("@{")) return false; + return SAFE_GIT_REVISION_PATTERN.test(candidate); +}; + +export const parseGitDiffRange = (value: string): GitDiffRange | null => { + const symmetricIndex = value.indexOf(SYMMETRIC_DIFF_RANGE_OPERATOR); + if (symmetricIndex !== -1) { + return { + base: value.slice(0, symmetricIndex), + head: value.slice(symmetricIndex + SYMMETRIC_DIFF_RANGE_OPERATOR.length), + symmetric: true, + }; + } + + const rangeIndex = value.indexOf(DIFF_RANGE_OPERATOR); + if (rangeIndex === -1) return null; + return { + base: value.slice(0, rangeIndex), + head: value.slice(rangeIndex + DIFF_RANGE_OPERATOR.length), + symmetric: false, + }; +}; diff --git a/packages/core/src/services/git-types.ts b/packages/core/src/services/git-types.ts new file mode 100644 index 0000000000..65f7747755 --- /dev/null +++ b/packages/core/src/services/git-types.ts @@ -0,0 +1,111 @@ +import * as Effect from "effect/Effect"; +import type { ReactDoctorError } from "../errors.js"; +import type { ChangedFileLineRanges } from "../types/index.js"; + +export interface GitBaselineDiffPlan { + readonly baseFiles: ReadonlyArray; + readonly headFiles: ReadonlyArray; + readonly untrackedFiles: ReadonlyArray; +} + +export interface GitDiffSelection { + /** `null` when `HEAD` is detached. */ + readonly currentBranch: string | null; + readonly baseBranch: string; + /** Commit the changed-file diff was computed against. Absent for working-tree selections. */ + readonly diffBaseRef?: string; + readonly changedFiles: ReadonlyArray; + readonly isCurrentChanges: boolean; +} + +export interface GitDiffSelectionInput { + readonly directory: string; + readonly explicitBaseBranch?: string; + /** Include ordinary untracked files in working-tree selections. */ + readonly includeUntracked?: boolean; +} + +export interface GitShowOptions { + /** Maximum stdout bytes before the content read fails open. */ + 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 or index diffs. */ + readonly baseRef?: string; + /** Diff the index instead of the working tree. */ + readonly cached?: boolean; + readonly files: ReadonlyArray; + /** Treat ordinary untracked files as fully changed. Ignored for cached diffs. */ + readonly includeUntracked?: boolean; +} + +export interface GitService { + /** Current branch, or `null` on detached HEAD or invocation failure. */ + readonly currentBranch: (directory: string) => Effect.Effect; + /** Best-effort origin default branch, then `main` or `master`. */ + readonly defaultBranch: (directory: string) => Effect.Effect; + /** Current commit SHA, or `null` outside a Git worktree. */ + readonly headSha: (directory: string) => Effect.Effect; + /** GitHub owner/repository parsed from `remote.origin.url`. */ + readonly githubRepo: (directory: string) => Effect.Effect; + readonly githubViewerPermission: (input: { + readonly directory: string; + readonly repo: string; + }) => Effect.Effect; + readonly branchExists: ( + directory: string, + branch: string, + ) => Effect.Effect; + /** Merge-base of the ref and `HEAD`, or `null` when unavailable. */ + readonly mergeBase: (input: { + readonly directory: string; + readonly ref: string; + }) => Effect.Effect; + readonly diffSelection: ( + input: GitDiffSelectionInput, + ) => Effect.Effect; + /** Side-aware changed paths with rename detection disabled. */ + readonly baselineDiffPlan: (input: { + readonly directory: string; + readonly ref: string; + }) => Effect.Effect; + readonly stagedFilePaths: ( + directory: string, + ) => Effect.Effect, ReactDoctorError>; + /** Index contents for a project-relative path. */ + readonly showStagedContent: ( + directory: string, + relativePath: string, + options?: GitShowOptions, + ) => Effect.Effect; + /** File contents at a ref for a project-relative path. */ + readonly showRefContent: (input: { + readonly directory: string; + readonly ref: string; + readonly relativePath: string; + readonly options?: GitShowOptions; + }) => Effect.Effect; + /** Git grep result, or `null` when the caller should use a filesystem fallback. */ + readonly grep: (input: GitGrepInput) => Effect.Effect; + /** New-side changed line ranges, or `null` when the caller should use file-level scope. */ + readonly changedLineRanges: ( + input: GitChangedLineRangesInput, + ) => Effect.Effect | null, ReactDoctorError>; +} diff --git a/packages/core/src/services/git.ts b/packages/core/src/services/git.ts index c81f378983..1c8b832ec1 100644 --- a/packages/core/src/services/git.ts +++ b/packages/core/src/services/git.ts @@ -5,146 +5,33 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; -import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { - DEFAULT_BRANCH_CANDIDATES, - GITHUB_VIEWER_PERMISSION_TIMEOUT_MS, - SPAWN_ARGS_MAX_LENGTH_CHARS, - SPAWN_ARGS_MAX_LENGTH_CHARS_DARWIN, - SPAWN_ARGS_MAX_LENGTH_CHARS_POSIX, -} from "../constants.js"; -import { - GitBaseBranchInvalid, - GitBaseBranchMissing, - GitInvocationFailed, - ReactDoctorError, -} from "../errors.js"; +import { DEFAULT_BRANCH_CANDIDATES, GITHUB_VIEWER_PERMISSION_TIMEOUT_MS } from "../constants.js"; +import { GitBaseBranchInvalid, GitBaseBranchMissing, ReactDoctorError } from "../errors.js"; 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; -} - -const trimOrNull = (value: string): string | null => { - const trimmed = value.trim(); - return trimmed.length === 0 ? null : trimmed; -}; - -/** - * Defense against `--diff ` git-flag injection (CVE-2018-17456 - * shape). `git rev-parse --verify ` and `git merge-base - * HEAD` take the next positional as a refname — but a value starting - * with `-` (e.g. `--upload-pack=evil`) is parsed as an option - * instead. The composite-action `case "$DIFF_BASE" in -*)` guard - * already blocks the most common CI shape; this hardens the library - * boundary so local-CLI callers and other consumers don't have to - * re-implement the check. - * - * Rejects: empty, leading `-`, leading/trailing `.`, embedded `..`, - * `@{` reflog suffix, or any character outside `[A-Za-z0-9_./-]`. - */ -const SAFE_GIT_REVISION_PATTERN = /^[A-Za-z0-9_./-]+$/; - -/** Human-readable summary of the `isSafeGitRevision` contract, reused across error details. */ -const GIT_REF_NAME_RULE = "must match [A-Za-z0-9_./-] without leading '-', '..', or '@{'"; - -/** git's two range operators: two-dot (direct) and three-dot (merge-base). */ -const DIFF_RANGE_OPERATOR = ".."; -const SYMMETRIC_DIFF_RANGE_OPERATOR = "..."; - -const isSafeGitRevision = (candidate: string): boolean => { - if (candidate.length === 0) return false; - if (candidate.startsWith("-")) return false; - if (candidate.startsWith(".") || candidate.endsWith(".")) return false; - if (candidate.includes("..") || candidate.includes("@{")) return false; - return SAFE_GIT_REVISION_PATTERN.test(candidate); -}; - -// The Windows cap would reject legitimately long `--scope lines` diffs -// (`git diff -- `) that other platforms handle fine, -// silently degrading the scope — so the guard is platform-sized. Darwin gets -// its own cap because macOS ARG_MAX sits below the Linux one (rationale on -// each constant). -const resolveSpawnArgsLengthCap = (): number => { - if (process.platform === "win32") return SPAWN_ARGS_MAX_LENGTH_CHARS; - if (process.platform === "darwin") return SPAWN_ARGS_MAX_LENGTH_CHARS_DARWIN; - 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` - * when `value` carries no range operator so the caller falls back to - * single-base resolution. - * - * Only the first operator is split on; any leftover `..` stays inside an - * endpoint so `isSafeGitRevision` rejects malformed input like - * `A..B..C` instead of silently guessing which pair the user meant. - */ -const parseGitDiffRange = (value: string): GitDiffRange | null => { - const symmetricIndex = value.indexOf(SYMMETRIC_DIFF_RANGE_OPERATOR); - if (symmetricIndex !== -1) { - return { - base: value.slice(0, symmetricIndex), - head: value.slice(symmetricIndex + SYMMETRIC_DIFF_RANGE_OPERATOR.length), - symmetric: true, - }; - } - const rangeIndex = value.indexOf(DIFF_RANGE_OPERATOR); - if (rangeIndex !== -1) { - return { - base: value.slice(0, rangeIndex), - head: value.slice(rangeIndex + DIFF_RANGE_OPERATOR.length), - symmetric: false, - }; - } - return null; -}; - -const parseGithubRepoFromRemoteUrl = (remoteUrl: string): string | null => { - const withoutGitSuffix = remoteUrl.trim().replace(/\.git$/, ""); - const sshMatch = /^git@github\.com:([^/\s]+)\/([^/\s]+)$/.exec(withoutGitSuffix); - if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`; - - const urlMatch = - /^(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+)$/.exec( - withoutGitSuffix, - ); - return urlMatch ? `${urlMatch[1]}/${urlMatch[2]}` : null; -}; +import { makeGitCommandExecutor, type GitCommandResult } from "./git-command-executor.js"; +import { + GIT_REF_NAME_RULE, + isSafeGitRevision, + parseGitDiffRange, + type GitDiffRange, +} from "./git-revision-policy.js"; +import { + parseGitBaselineDiffPlan, + parseGithubRemoteRepository, + parseGithubViewerPermission, + splitNullSeparatedGitOutput, + trimGitOutputOrNull, +} from "./git-output.js"; +import type { + GitBaselineDiffPlan, + GitDiffSelection, + GitGrepResult, + GitService, +} from "./git-types.js"; + +export type { GitBaselineDiffPlan, GitDiffSelection } from "./git-types.js"; const parseGithubRepo = (repo: string): { owner: string; name: string } | null => { const [owner, name, ...extraParts] = repo.split("/"); @@ -153,356 +40,33 @@ const parseGithubRepo = (repo: string): { owner: string; name: string } | null = return { owner, name }; }; -const parseGithubViewerPermission = (stdout: string): string | null => { - const value = trimOrNull(stdout); - if (value === null || value === "null") return null; - return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : 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(); - const headFiles = new Set(); - for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 2) { - const status = entries[entryIndex]; - const filePath = entries[entryIndex + 1]; - if (status === undefined || filePath === undefined || status.length !== 1) return null; - if (status === "A") { - headFiles.add(filePath); - continue; - } - if (status === "D") { - baseFiles.add(filePath); - continue; - } - if (status === "M" || status === "T") { - baseFiles.add(filePath); - headFiles.add(filePath); - continue; - } - return null; - } - return { baseFiles: [...baseFiles], headFiles: [...headFiles], untrackedFiles: [] }; -}; - // An untracked file has no base to diff against, so `--scope lines` treats // 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`) - * runs commands through Effect's `ChildProcessSpawner` + `ChildProcess.make` - * (from `effect/unstable/process`), so spawning, stdio draining, - * scope-bound cleanup, and error tagging all live inside the - * Effect runtime — no `node:child_process` imports outside this - * file. Tests swap in `layerOf({ ... })` for a deterministic snapshot. + * delegates to `git-command-executor.ts`, which runs commands through + * Effect's `ChildProcessSpawner` + `ChildProcess.make`. Spawning, stdio + * draining, scope-bound cleanup, and error tagging therefore stay inside + * the Effect runtime. Tests swap in `layerOf({ ... })` for a deterministic + * snapshot. * * All methods fail with `ReactDoctorError`; "git ran but produced * no matches" still resolves successfully (with `null` / `[]`). */ -export class Git extends Context.Service< - Git, - { - /** `null` when on detached HEAD or `rev-parse` fails. */ - readonly currentBranch: (directory: string) => Effect.Effect; - /** Best-effort default branch: `origin/HEAD` symref, then `main`/`master`. */ - readonly defaultBranch: (directory: string) => Effect.Effect; - /** Current commit SHA, or null when the directory is not a git worktree. */ - readonly headSha: (directory: string) => Effect.Effect; - /** GitHub owner/repo parsed from remote.origin.url, or null for non-GitHub remotes. */ - readonly githubRepo: (directory: string) => Effect.Effect; - readonly githubViewerPermission: (input: { - readonly directory: string; - readonly repo: string; - }) => Effect.Effect; - readonly branchExists: ( - directory: string, - branch: string, - ) => Effect.Effect; - /** - * `git merge-base HEAD` — the commit a baseline scan should read - * file content from, so "issues introduced" is measured against the - * branch point rather than the (possibly advanced) base tip. `null` when - * `ref` is unsafe / missing or no merge-base exists. - */ - readonly mergeBase: (input: { - readonly directory: string; - readonly ref: string; - }) => Effect.Effect; - /** - * High-level diff selection: resolves current branch + base - * branch + changed file list with the same semantics as the - * legacy `getDiffInfo` helper. `null` when no diff is detectable - * (detached HEAD without explicit base, no default branch, etc.). - */ - readonly diffSelection: ( - input: GitDiffSelectionInput, - ) => Effect.Effect; - /** - * Side-aware paths between `ref` and the worktree. Rename and copy - * detection is disabled so path identity never depends on Git heuristics: - * a rename is represented as one base deletion plus one head addition. - * Returns `null` for unmerged or otherwise unsupported diff states. - */ - readonly baselineDiffPlan: (input: { - readonly directory: string; - readonly ref: string; - }) => Effect.Effect; - /** Files staged for commit (null-separated, `--diff-filter=ACMR`). */ - readonly stagedFilePaths: ( - directory: string, - ) => Effect.Effect, ReactDoctorError>; - /** `git show :` contents; `null` when the file isn't in the index. */ - readonly showStagedContent: ( - directory: string, - relativePath: string, - options?: GitShowOptions, - ) => Effect.Effect; - /** - * `git show :` contents — the file as it existed at `ref`. - * `null` when the ref is unsafe / missing or the path didn't exist there - * (e.g. a file added by the PR). Used to materialize a base-branch tree - * for baseline diffing. - */ - readonly showRefContent: (input: { - readonly directory: string; - readonly ref: string; - readonly relativePath: string; - readonly options?: GitShowOptions; - }) => Effect.Effect; - /** - * `git grep -l` (default). Returns `null` when git itself isn't - * available or the directory isn't a repository so callers can - * fall back to a filesystem walk. - */ - readonly grep: (input: GitGrepInput) => Effect.Effect; - /** - * Per-file changed line ranges for the `lines` scope. Runs - * `git diff --unified=0` (optionally `--cached`, optionally against - * `baseRef`) limited to `files`, and parses the new-side hunks. Returns - * `null` when the ranges can't be computed (unsafe `baseRef`, or git - * exited non-zero) so the caller degrades to file-level scope instead of - * hiding every finding behind empty ranges; an empty array means git - * succeeded but the files added no lines. - */ - readonly changedLineRanges: ( - input: GitChangedLineRangesInput, - ) => Effect.Effect | null, ReactDoctorError>; - } ->()("react-doctor/Git") { +export class Git extends Context.Service()("react-doctor/Git") { static readonly layerNode: Layer.Layer = Layer.effect( Git, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner; - - /** - * Spawns `git ` via Effect's `ChildProcess` + the - * captured `ChildProcessSpawner`. Drains stdout / stderr / - * exitCode in parallel so the pipe never blocks on a full - * buffer, and folds any `PlatformError` (binary missing, - * ENOENT, EACCES, …) into the tagged `ReactDoctorError({ - * reason: GitInvocationFailed })` so the rest of the codebase - * sees a single failure channel. - */ - const runCommand = ( - input: CommandInvocationInput, - ): Effect.Effect => { - // Shared by the async `PlatformError` path and the synchronous-spawn - // defect path so both spawn-failure shapes resolve identically: a - // non-`git` command degrades to a non-zero result the caller already - // handles; `git` fails with the tagged `GitInvocationFailed` its - // degradation paths (currentBranch → null, branchExists → false, - // changedLineRanges → null) recover from. - const foldSpawnFailure = ( - cause: unknown, - ): Effect.Effect => - input.command !== "git" - ? Effect.succeed({ status: 127, stdout: "", stderr: String(cause) }) - : Effect.fail( - new ReactDoctorError({ - reason: new GitInvocationFailed({ - args: [...input.args], - directory: input.directory, - cause, - }), - }), - ); - return Effect.scoped( - Effect.gen(function* () { - // `child_process.spawn` THROWS synchronously — escaping Effect's - // failure channel as an uncatchable runtime exception, because - // Effect's `Async` register isn't guarded — when the cwd isn't a - // directory (`ENOTDIR`) or the argv exceeds the OS command-line - // limit (`ENAMETOOLONG`, e.g. `git diff -- <1k files>` under - // `--scope lines` on Windows). The 'error' event (which becomes a - // catchable `PlatformError`) never fires. Both conditions are - // predictable, so fail them on the typed channel up front; the - // degradation paths (currentBranch → null, branchExists → false, - // changedLineRanges → null) then recover instead of the whole scan - // crashing and reporting to Sentry (REACT-DOCTOR-1E / 1P / 20). - if (!isDirectory(input.directory)) { - return yield* foldSpawnFailure( - `spawn ENOTDIR (cwd is not a directory: ${input.directory})`, - ); - } - const argvLengthChars = - input.command.length + - 1 + - input.args.reduce((total, arg) => total + arg.length + 1, 0); - const spawnArgsLengthCap = resolveSpawnArgsLengthCap(); - if (argvLengthChars > spawnArgsLengthCap) { - return yield* foldSpawnFailure( - `spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${spawnArgsLengthCap})`, - ); - } - const handle = yield* spawner.spawn( - // HACK: `extendEnv: true` is required for spawned commands - // to inherit `process.env.PATH` — without it Effect's - // `ChildProcess` defaults to an empty env and `spawn` - // immediately fails with `ENOENT` even when the binary is - // on the user's PATH. (`spawnSync` inherited PATH by - // default; ChildProcess's option flips the polarity.) - ChildProcess.make(input.command, [...input.args], { - cwd: input.directory, - env: input.env, - extendEnv: true, - }), - ); - // Optional hard cap on stdout bytes (e.g. `git show` of a - // huge staged blob): count raw bytes as they stream and fail - // fast once the budget is crossed so the caller can skip the - // file instead of buffering it whole. - const maxStdoutBytes = input.maxStdoutBytes; - const stdoutByteCount = yield* Ref.make(0); - const stdoutStream = - maxStdoutBytes === undefined - ? handle.stdout - : handle.stdout.pipe( - Stream.tap((chunk) => - Ref.updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe( - Effect.flatMap((total) => - total > maxStdoutBytes - ? Effect.fail( - new ReactDoctorError({ - reason: new GitInvocationFailed({ - args: [...input.args], - directory: input.directory, - cause: new Error(`git stdout exceeded ${maxStdoutBytes} bytes`), - }), - }), - ) - : Effect.void, - ), - ), - ), - ); - const [stdout, stderr, status] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(stdoutStream)), - Stream.mkString(Stream.decodeText(handle.stderr)), - handle.exitCode, - ], - { concurrency: 3 }, - ); - return { status, stdout, stderr } satisfies GitInvocationResult; - }), - ).pipe( - Effect.catchTag("PlatformError", foldSpawnFailure), - // One span per actual subprocess invocation. The subcommand - // (`args[0]`) is safe to attribute; full args/paths are omitted so - // no scanned path leaks into an exported trace. - Effect.withSpan("git.exec", { - attributes: { - "git.command": input.command, - "git.subcommand": input.args[0] ?? "", - }, - }), - ); - }; + const runCommand = makeGitCommandExecutor(spawner); const runGit = ( directory: string, args: ReadonlyArray, - ): Effect.Effect => + ): Effect.Effect => runCommand({ command: "git", args, directory }); const listUntrackedFilePaths = ( @@ -516,7 +80,9 @@ export class Git extends Context.Service< "--exclude-standard", ...(includePaths.length > 0 ? ["--", ...includePaths] : []), ]).pipe( - Effect.map((result) => (result.status === 0 ? splitNullSeparated(result.stdout) : null)), + Effect.map((result) => + result.status === 0 ? splitNullSeparatedGitOutput(result.stdout) : null, + ), ); // Unions opted-in untracked files into a working-tree selection. Untracked @@ -541,7 +107,7 @@ export class Git extends Context.Service< runGit(directory, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( Effect.map((result) => { if (result.status !== 0) return null; - const branch = trimOrNull(result.stdout); + const branch = trimGitOutputOrNull(result.stdout); return branch === "HEAD" ? null : branch; }), // Best-effort branch read: a non-zero exit already maps to null, but a @@ -556,7 +122,7 @@ export class Git extends Context.Service< Effect.gen(function* () { const symref = yield* runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]); if (symref.status === 0) { - const trimmed = trimOrNull(symref.stdout); + const trimmed = trimGitOutputOrNull(symref.stdout); if (trimmed !== null) return trimmed.replace("refs/remotes/origin/", ""); } const candidateRefs = DEFAULT_BRANCH_CANDIDATES.map( @@ -568,7 +134,7 @@ export class Git extends Context.Service< ...candidateRefs, ]); if (candidates.status !== 0) return null; - return trimOrNull(candidates.stdout.split("\n")[0] ?? ""); + return trimGitOutputOrNull(candidates.stdout.split("\n")[0] ?? ""); }).pipe(Effect.withSpan("Git.defaultBranch")); const branchExists = ( @@ -586,7 +152,7 @@ export class Git extends Context.Service< const headSha = (directory: string): Effect.Effect => runGit(directory, ["rev-parse", "HEAD"]).pipe( - Effect.map((result) => (result.status === 0 ? trimOrNull(result.stdout) : null)), + Effect.map((result) => (result.status === 0 ? trimGitOutputOrNull(result.stdout) : null)), ); const mergeBase = (input: { @@ -595,14 +161,16 @@ export class Git extends Context.Service< }): Effect.Effect => isSafeGitRevision(input.ref) ? runGit(input.directory, ["merge-base", input.ref, "HEAD"]).pipe( - Effect.map((result) => (result.status === 0 ? trimOrNull(result.stdout) : null)), + Effect.map((result) => + result.status === 0 ? trimGitOutputOrNull(result.stdout) : null, + ), ) : Effect.succeed(null); const githubRepo = (directory: string): Effect.Effect => runGit(directory, ["config", "--get", "remote.origin.url"]).pipe( Effect.map((result) => - result.status === 0 ? parseGithubRepoFromRemoteUrl(result.stdout) : null, + result.status === 0 ? parseGithubRemoteRepository(result.stdout) : null, ), ); @@ -704,7 +272,7 @@ export class Git extends Context.Service< if (input.range.symmetric) { const mergeBase = yield* runGit(input.directory, ["merge-base", baseRef, headRef]); if (mergeBase.status !== 0) return null; - const mergeBaseRef = trimOrNull(mergeBase.stdout); + const mergeBaseRef = trimGitOutputOrNull(mergeBase.stdout); if (mergeBaseRef === null) return null; diffBaseRef = mergeBaseRef; } @@ -729,7 +297,7 @@ export class Git extends Context.Service< currentBranch: resolvedCurrentBranch, baseBranch: baseRef, diffBaseRef, - changedFiles: splitNullSeparated(diff.stdout), + changedFiles: splitNullSeparatedGitOutput(diff.stdout), isCurrentChanges: false, } satisfies GitDiffSelection; }); @@ -765,7 +333,7 @@ export class Git extends Context.Service< input.ref, ]); if (result.status !== 0) return null; - const plan = parseBaselineDiffPlan(result.stdout); + const plan = parseGitBaselineDiffPlan(result.stdout); if (plan === null) return null; const untracked = yield* runGit(input.directory, [ "ls-files", @@ -777,7 +345,7 @@ export class Git extends Context.Service< return { baseFiles: plan.baseFiles, headFiles: plan.headFiles, - untrackedFiles: splitNullSeparated(untracked.stdout), + untrackedFiles: splitNullSeparatedGitOutput(untracked.stdout), } satisfies GitBaselineDiffPlan; }).pipe( Effect.catch(() => Effect.succeed(null)), @@ -855,7 +423,7 @@ export class Git extends Context.Service< if (uncommitted.status !== 0) return null; const files = yield* mergeUntracked( directory, - splitNullSeparated(uncommitted.stdout), + splitNullSeparatedGitOutput(uncommitted.stdout), includeUntracked, ); if (files.length === 0) return null; @@ -869,7 +437,7 @@ export class Git extends Context.Service< const mergeBase = yield* runGit(directory, ["merge-base", baseBranch, "HEAD"]); if (mergeBase.status !== 0) return null; - const mergeBaseRef = trimOrNull(mergeBase.stdout); + const mergeBaseRef = trimGitOutputOrNull(mergeBase.stdout); if (mergeBaseRef === null) return null; const diff = yield* runGit(directory, [ @@ -884,7 +452,7 @@ export class Git extends Context.Service< if (diff.status !== 0) return null; const changedFiles = yield* mergeUntracked( directory, - splitNullSeparated(diff.stdout), + splitNullSeparatedGitOutput(diff.stdout), includeUntracked, ); return { @@ -907,7 +475,7 @@ export class Git extends Context.Service< ]).pipe( Effect.map((result) => { if (result.status !== 0) return [] as ReadonlyArray; - return splitNullSeparated(result.stdout); + return splitNullSeparatedGitOutput(result.stdout); }), ), showStagedContent: (directory, relativePath, options) => diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index e2cec40dcc..86a89a7090 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -9,6 +9,7 @@ import { LintBatchOrdering, OxlintConcurrency, OxlintOutputMaxBytes, + OxlintSpawnSlots, OxlintSpawnTimeoutMs, PerFileLintCacheEnabled, SidecarLintCacheEnabled, @@ -128,6 +129,7 @@ export class Linter extends Context.Service< // async runner so the override is actually load-bearing. const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs; const outputMaxBytes = yield* OxlintOutputMaxBytes; + const spawnSlots = yield* OxlintSpawnSlots; const concurrency = yield* OxlintConcurrency; const lintBatchOrdering = yield* LintBatchOrdering; const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled; @@ -162,6 +164,7 @@ export class Linter extends Context.Service< onSidecarStats: input.onSidecarStats, spawnTimeoutMs, outputMaxBytes, + spawnSlots: spawnSlots ?? undefined, concurrency, signal, lintBatchOrdering, diff --git a/packages/core/src/services/project-checks.ts b/packages/core/src/services/project-checks.ts new file mode 100644 index 0000000000..f36d1ce0ab --- /dev/null +++ b/packages/core/src/services/project-checks.ts @@ -0,0 +1,44 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { checkExpoProject } from "../check-expo-project.js"; +import { checkPnpmHardening } from "../check-pnpm-hardening.js"; +import { checkReactNativeProject } from "../check-react-native-project.js"; +import { checkReactServerComponentsAdvisory } from "../check-react-server-components-advisory.js"; +import { checkReducedMotion } from "../check-reduced-motion.js"; +import type { Diagnostic, ProjectInfo } from "../types/index.js"; + +export interface ProjectChecksInput { + readonly rootDirectory: string; + readonly project: ProjectInfo; +} + +export class ProjectChecks extends Context.Service< + ProjectChecks, + { + readonly run: (input: ProjectChecksInput) => Effect.Effect>; + } +>()("react-doctor/ProjectChecks") { + static readonly layerNode = Layer.succeed( + ProjectChecks, + ProjectChecks.of({ + run: Effect.fn("ProjectChecks.run")((input: ProjectChecksInput) => + Effect.sync(() => [ + ...checkReducedMotion(input.rootDirectory), + ...checkPnpmHardening(input.rootDirectory), + ...checkReactServerComponentsAdvisory(input.rootDirectory, input.project), + ...checkExpoProject(input.rootDirectory, input.project), + ...checkReactNativeProject(input.rootDirectory, input.project), + ]), + ), + }), + ); + + static readonly layerOf = (diagnostics: ReadonlyArray): Layer.Layer => + Layer.succeed( + ProjectChecks, + ProjectChecks.of({ + run: () => Effect.succeed(diagnostics), + }), + ); +} diff --git a/packages/core/src/types/project-info.ts b/packages/core/src/types/project-info.ts index e70bd2ac02..e81d73a91b 100644 --- a/packages/core/src/types/project-info.ts +++ b/packages/core/src/types/project-info.ts @@ -1,4 +1,4 @@ -import type { FrameworkToken } from "oxlint-plugin-react-doctor"; +import type { FrameworkToken } from "oxlint-plugin-react-doctor/contracts"; // Aliased to the plugin's capability vocabulary: `buildCapabilities` emits // `project.framework` as a capability token, so the two unions must be one. diff --git a/packages/core/src/utils/build-score-request-metadata.ts b/packages/core/src/utils/build-score-request-metadata.ts new file mode 100644 index 0000000000..4e9ab79851 --- /dev/null +++ b/packages/core/src/utils/build-score-request-metadata.ts @@ -0,0 +1,37 @@ +import type { ScoreRequestMetadata } from "../calculate-score.js"; +import type { ProjectInfo } from "../types/index.js"; +import type { GitHubActionsScoreMetadata } from "./resolve-github-actions-score-metadata.js"; + +interface ScoreMetadataProject { + readonly framework: ProjectInfo["framework"]; + readonly reactVersion: string | null; + readonly sourceFileCount: number; +} + +interface BuildScoreRequestMetadataInput { + readonly project: ScoreMetadataProject; + readonly repo: string | null; + readonly sha: string | null; + readonly defaultBranch: string | null; + readonly doctorVersion?: string; + readonly runId?: string; + readonly githubActionsScoreMetadata: GitHubActionsScoreMetadata; + readonly githubViewerPermission: string | null; +} + +export const buildScoreRequestMetadata = ( + input: BuildScoreRequestMetadataInput, +): ScoreRequestMetadata => ({ + ...(input.repo !== null ? { repo: input.repo } : {}), + ...(input.sha !== null ? { sha: input.sha } : {}), + framework: input.project.framework, + ...(input.project.reactVersion !== null ? { reactVersion: input.project.reactVersion } : {}), + sourceFileCount: input.project.sourceFileCount, + ...(input.defaultBranch !== null ? { defaultBranch: input.defaultBranch } : {}), + ...(input.doctorVersion !== undefined ? { doctorVersion: input.doctorVersion } : {}), + ...(input.runId !== undefined ? { runId: input.runId } : {}), + ...input.githubActionsScoreMetadata, + ...(input.githubViewerPermission !== null + ? { githubViewerPermission: input.githubViewerPermission } + : {}), +}); diff --git a/packages/core/src/utils/create-oxlint-spawn-slots.ts b/packages/core/src/utils/create-oxlint-spawn-slots.ts new file mode 100644 index 0000000000..dcb37fdfee --- /dev/null +++ b/packages/core/src/utils/create-oxlint-spawn-slots.ts @@ -0,0 +1,15 @@ +import { OxlintSpawnFailed, ReactDoctorError } from "../errors.js"; +import { createWorkerSlots } from "./create-worker-slots.js"; +import type { WorkerSlots } from "./create-worker-slots.js"; +import { resolveScanConcurrency } from "./resolve-scan-concurrency.js"; + +const createLintPhaseAbortError = (): ReactDoctorError => + new ReactDoctorError({ + reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }), + }); + +export const createOxlintSpawnSlots = (concurrency: number): WorkerSlots => + createWorkerSlots({ + slotCount: resolveScanConcurrency(concurrency), + createAbortError: createLintPhaseAbortError, + }); diff --git a/packages/core/src/utils/create-worker-slots.ts b/packages/core/src/utils/create-worker-slots.ts new file mode 100644 index 0000000000..a2c20262b9 --- /dev/null +++ b/packages/core/src/utils/create-worker-slots.ts @@ -0,0 +1,65 @@ +export interface WorkerSlots { + readonly run: (task: () => Promise, abortSignal?: AbortSignal) => Promise; +} + +interface WorkerSlotWaiter { + readonly resolve: () => void; + readonly abortSignal: AbortSignal | undefined; + readonly onAbort: () => void; +} + +interface CreateWorkerSlotsInput { + readonly slotCount: number; + readonly createAbortError: () => Error; +} + +export const createWorkerSlots = (input: CreateWorkerSlotsInput): WorkerSlots => { + let availableSlotCount = input.slotCount; + const waiters: WorkerSlotWaiter[] = []; + + const releaseSlot = (): void => { + const nextWaiter = waiters.shift(); + if (nextWaiter === undefined) { + availableSlotCount += 1; + return; + } + nextWaiter.abortSignal?.removeEventListener("abort", nextWaiter.onAbort); + nextWaiter.resolve(); + }; + + const acquireSlot = async (abortSignal?: AbortSignal): Promise => { + if (abortSignal?.aborted) throw input.createAbortError(); + if (availableSlotCount > 0) { + availableSlotCount -= 1; + return; + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + const waiterIndex = waiters.indexOf(waiter); + if (waiterIndex !== -1) waiters.splice(waiterIndex, 1); + reject(input.createAbortError()); + }; + const waiter: WorkerSlotWaiter = { + resolve, + abortSignal, + onAbort, + }; + waiters.push(waiter); + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); + }; + + return { + run: async ( + task: () => Promise, + abortSignal?: AbortSignal, + ): Promise => { + await acquireSlot(abortSignal); + try { + return await task(); + } finally { + releaseSlot(); + } + }, + }; +}; diff --git a/packages/core/src/utils/is-local-module-specifier.ts b/packages/core/src/utils/is-local-module-specifier.ts new file mode 100644 index 0000000000..ea68ba79c6 --- /dev/null +++ b/packages/core/src/utils/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/utils/resolve-scan-completion.ts b/packages/core/src/utils/resolve-scan-completion.ts new file mode 100644 index 0000000000..0c18168499 --- /dev/null +++ b/packages/core/src/utils/resolve-scan-completion.ts @@ -0,0 +1,71 @@ +import { MILLISECONDS_PER_SECOND } from "../constants.js"; + +interface AnalysisFailureState { + readonly didFail: boolean; + readonly reason: string | null; +} + +interface ResolveScanCompletionInput { + readonly lintDidFail: boolean; + readonly deadCodeFailure: AnalysisFailureState; + readonly suppressScanSummary: boolean; + readonly scannedFilesLabel: string; + readonly scanElapsedMilliseconds: number; + readonly workerCountSuffix: string; +} + +interface ScanProgressCompletion { + readonly action: "unchanged" | "fail" | "stop" | "succeed"; + readonly text: string | null; +} + +interface ResolvedScanCompletion { + readonly deadCodeFailure: AnalysisFailureState; + readonly shouldComputeScore: boolean; + readonly progress: ScanProgressCompletion; +} + +const DEAD_CODE_FAIL_TEXT = "Scanning failed (dead-code analysis, non-fatal)."; + +export const resolveScanCompletion = ( + input: ResolveScanCompletionInput, +): ResolvedScanCompletion => { + const deadCodeFailure = input.lintDidFail + ? { didFail: false, reason: null } + : input.deadCodeFailure; + + if (input.lintDidFail) { + return { + deadCodeFailure, + shouldComputeScore: false, + progress: { action: "unchanged", text: null }, + }; + } + + if (deadCodeFailure.didFail) { + return { + deadCodeFailure, + shouldComputeScore: false, + progress: { action: "fail", text: DEAD_CODE_FAIL_TEXT }, + }; + } + + if (input.suppressScanSummary) { + return { + deadCodeFailure, + shouldComputeScore: true, + progress: { action: "stop", text: null }, + }; + } + + const scanElapsedSeconds = (input.scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1); + + return { + deadCodeFailure, + shouldComputeScore: true, + progress: { + action: "succeed", + text: `Scanned ${input.scannedFilesLabel} in ${scanElapsedSeconds}s${input.workerCountSuffix}`, + }, + }; +}; diff --git a/packages/core/src/utils/resolve-scan-file-coverage.ts b/packages/core/src/utils/resolve-scan-file-coverage.ts new file mode 100644 index 0000000000..d453123429 --- /dev/null +++ b/packages/core/src/utils/resolve-scan-file-coverage.ts @@ -0,0 +1,62 @@ +import * as path from "node:path"; +import { toNormalizedRelativePath } from "./to-normalized-relative-path.js"; + +interface ScanFileCoverage { + readonly candidateFiles: ReadonlyArray; + readonly analyzedFiles: ReadonlyArray; +} + +interface ResolveScanFileCoverageOptions { + readonly rootDirectory: string; + readonly lintFileCoverage: ScanFileCoverage | null; + readonly lastReportedTotalFileCount: number; + readonly lintIncludePathCount: number | null; + readonly discoveredSourceFileCount: number; + readonly includeScannedFilePaths: boolean; + readonly fallbackScannedFilePaths: ReadonlyArray; +} + +interface ResolvedScanFileCoverage { + readonly analyzedFiles: ReadonlyArray; + readonly scannedFileCount: number; + readonly scannedFilePaths: ReadonlyArray; +} + +const normalizeFilePaths = (filePaths: ReadonlyArray, rootDirectory: string): string[] => [ + ...new Set(filePaths.map((filePath) => toNormalizedRelativePath(filePath, rootDirectory))), +]; + +export const resolveScanFileCoverage = ({ + rootDirectory, + lintFileCoverage, + lastReportedTotalFileCount, + lintIncludePathCount, + discoveredSourceFileCount, + includeScannedFilePaths, + fallbackScannedFilePaths, +}: ResolveScanFileCoverageOptions): ResolvedScanFileCoverage => { + const candidateFiles = + lintFileCoverage === null + ? [] + : normalizeFilePaths(lintFileCoverage.candidateFiles, rootDirectory); + const analyzedFiles = + lintFileCoverage === null + ? [] + : normalizeFilePaths(lintFileCoverage.analyzedFiles, rootDirectory).sort(); + const scannedFileCount = + candidateFiles.length || + lastReportedTotalFileCount || + lintIncludePathCount || + discoveredSourceFileCount; + const scannedFilePaths = includeScannedFilePaths + ? candidateFiles.length > 0 + ? candidateFiles.map((filePath) => path.resolve(rootDirectory, filePath)) + : fallbackScannedFilePaths + : []; + + return { + analyzedFiles, + scannedFileCount, + scannedFilePaths, + }; +}; diff --git a/packages/core/tests/__snapshots__/project-info-package-graph-parity.test.ts.snap b/packages/core/tests/__snapshots__/project-info-package-graph-parity.test.ts.snap new file mode 100644 index 0000000000..f6ac72dd9c --- /dev/null +++ b/packages/core/tests/__snapshots__/project-info-package-graph-parity.test.ts.snap @@ -0,0 +1,742 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ProjectInfo PackageGraph parity > preserves basic-react 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "react:18", + "react:19", + "tanstack-query", + "typescript", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": true, + "hasThree": false, + "hasTypeScript": true, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "test-basic-react", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 25, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": "^5.0.0", + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves bun-multiple-grouped-catalogs 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "react:18", + "react:19", + "react:19.2", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "bun-multiple-grouped-catalogs", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "19.2.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves component-library 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "react:18", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "test-component-library", + "reactMajorVersion": 18, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^18.0.0 || ^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves mixed-rn-web-monorepo 1`] = ` +{ + "capabilities": [ + "client-only", + "expo", + "pre-es2023", + "react", + "react-native", + "react:17", + "react:18", + "react:19", + "typescript", + "vite", + ], + "projectInfo": { + "expoVersion": "^51.0.0", + "framework": "vite", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": true, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": true, + "isPreES2023Target": true, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "mixed-rn-web-monorepo", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 8, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves nextjs-app 1`] = ` +{ + "capabilities": [ + "nextjs", + "nextjs:15", + "react", + "react:17", + "react:18", + "react:19", + "server-actions", + "ssr", + "typescript", + ], + "projectInfo": { + "expoVersion": null, + "framework": "nextjs", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": true, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": 15, + "nextjsVersion": "^15.0.0", + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "test-nextjs-app", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 15, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves non-react 1`] = ` +{ + "capabilities": [ + "unknown", + "zod", + "zod:4", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "non-react", + "reactMajorVersion": null, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": null, + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": 4, + "zodVersion": "^4.0.0", + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves package-local-capabilities 1`] = ` +{ + "capabilities": [ + "client-only", + "expo", + "expo:54", + "react", + "react-native", + "react:17", + "react:18", + "tanstack-query", + "vite", + ], + "projectInfo": { + "expoVersion": "^54.0.0", + "framework": "vite", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": true, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": true, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "package-local-capabilities", + "reactMajorVersion": 18, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^18.2.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 4, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": "^5.66.0", + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves pnpm-catalog-workspace 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "react:18", + "react:19", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "pnpm-catalog-workspace", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves pnpm-named-catalog 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "react:18", + "react:19", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "pnpm-named-catalog", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves react-17 1`] = ` +{ + "capabilities": [ + "react", + "react:17", + "unknown", + ], + "projectInfo": { + "expoVersion": null, + "framework": "unknown", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": false, + "isPreES2023Target": false, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "react-17", + "reactMajorVersion": 17, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^17.0.2", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 0, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; + +exports[`ProjectInfo PackageGraph parity > preserves tanstack-start-app 1`] = ` +{ + "capabilities": [ + "pre-es2023", + "react", + "react:17", + "react:18", + "react:19", + "server-actions", + "ssr", + "tanstack-start", + "typescript", + ], + "projectInfo": { + "expoVersion": null, + "framework": "tanstack-start", + "hasI18nLibrary": false, + "hasMobxReact": false, + "hasMobxReactLite": false, + "hasMobxReactObserver": false, + "hasMobxStateTree": false, + "hasReactCompiler": false, + "hasReactCompilerLintPlugin": false, + "hasReactNativeWorkspace": false, + "hasReactRouterFramework": false, + "hasReactThreeFiber": false, + "hasReanimated": false, + "hasRemotion": false, + "hasSsrDependency": false, + "hasTanStackQuery": false, + "hasThree": false, + "hasTypeScript": true, + "isPreES2023Target": true, + "isStaticExport": false, + "mobxMajorVersion": null, + "mobxReactLiteVersion": null, + "mobxReactVersion": null, + "mobxVersion": null, + "nextjsMajorVersion": null, + "nextjsVersion": null, + "preactMajorVersion": null, + "preactVersion": null, + "projectName": "test-tanstack-start-app", + "reactMajorVersion": 19, + "reactRouterVersion": null, + "reactThreeFiberMajorVersion": null, + "reactThreeFiberVersion": null, + "reactVersion": "^19.0.0", + "reanimatedVersion": null, + "remotionMajorVersion": null, + "remotionVersion": null, + "rootDirectory": "", + "shopifyFlashListMajorVersion": null, + "shopifyFlashListVersion": null, + "sourceFileCount": 4, + "styledComponentsVersion": null, + "tailwindVersion": null, + "tanstackQueryVersion": null, + "threeRelease": null, + "threeVersion": null, + "valtioMajorVersion": null, + "valtioVersion": null, + "zodMajorVersion": null, + "zodVersion": null, + "zustandMajorVersion": null, + "zustandVersion": null, + }, +} +`; diff --git a/packages/core/tests/assemble-inspect-output.test.ts b/packages/core/tests/assemble-inspect-output.test.ts new file mode 100644 index 0000000000..4e01e8c62c --- /dev/null +++ b/packages/core/tests/assemble-inspect-output.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + assembleInspectOutput, + type AssembleInspectOutputInput, +} from "../src/assemble-inspect-output.js"; +import type { Diagnostic, ProjectInfo } from "../src/types/index.js"; + +const project: ProjectInfo = { + rootDirectory: "/repo", + projectName: "sample-app", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + preactVersion: null, + preactMajorVersion: null, + sourceFileCount: 2, +}; + +const diagnostic: Diagnostic = { + filePath: "/repo/src/app.tsx", + plugin: "react-doctor", + rule: "no-derived-state", + severity: "error", + message: "Avoid derived state.", + help: "Use the value directly.", + line: 1, + column: 1, + category: "Correctness", +}; + +const completeInput: AssembleInspectOutputInput = { + project, + userConfig: null, + resolvedDirectory: "/repo", + diagnostics: [diagnostic], + score: { score: 85, label: "Good" }, + scoreMetadata: { + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 2, + defaultBranch: "main", + }, + lint: { + didFail: false, + failureReason: null, + failureReasonTag: null, + failureReasonKind: null, + partialFailures: [], + analyzedFiles: ["src/app.tsx", "src/other.tsx"], + cacheHitFileCount: 1, + cacheTotalFileCount: 2, + sidecarReplayedFileCount: 1, + sidecarTotalFileCount: 1, + }, + deadCode: { + didFail: false, + failureReason: null, + didOverlap: true, + cacheHit: false, + summaryCacheHits: 4, + summaryCacheMisses: 2, + }, + scan: { + scannedFileCount: 2, + scannedFilePaths: ["/repo/src/app.tsx", "/repo/src/other.tsx"], + elapsedMilliseconds: 125, + concurrency: 4, + }, + supplyChainOverlapTimedOut: false, + securityScanFailed: false, + suppressedRuleCounts: [{ rule: "react-doctor/no-debugger", source: "inline", count: 3 }], +}; + +describe("assembleInspectOutput", () => { + it("assembles the exact completed scan contract", () => { + expect(assembleInspectOutput(completeInput)).toEqual({ + project, + userConfig: null, + resolvedDirectory: "/repo", + diagnostics: [diagnostic], + score: { score: 85, label: "Good" }, + scoreMetadata: { + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 2, + defaultBranch: "main", + }, + didLintFail: false, + lintFailureReason: null, + lintFailureReasonTag: null, + lintFailureReasonKind: null, + lintPartialFailures: [], + didDeadCodeFail: false, + deadCodeFailureReason: null, + deadCodeOverlapped: true, + scannedFileCount: 2, + scannedFilePaths: ["/repo/src/app.tsx", "/repo/src/other.tsx"], + analyzedFiles: ["src/app.tsx", "src/other.tsx"], + scanElapsedMilliseconds: 125, + scanConcurrency: 4, + supplyChainOverlapTimedOut: false, + securityScanFailed: false, + lintCacheHitFileCount: 1, + lintCacheTotalFileCount: 2, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 1, + deadCodeCacheHit: false, + deadCodeSummaryCacheHits: 4, + deadCodeSummaryCacheMisses: 2, + suppressedRuleCounts: [{ rule: "react-doctor/no-debugger", source: "inline", count: 3 }], + }); + }); + + it("preserves partial lint details and clears discarded dead-code cache outcomes", () => { + const output = assembleInspectOutput({ + ...completeInput, + diagnostics: [], + score: null, + lint: { + ...completeInput.lint, + didFail: true, + failureReason: "oxlint exited before every batch completed", + failureReasonTag: "OxlintSpawnFailed", + partialFailures: ["React Hooks rules were skipped"], + analyzedFiles: ["src/app.tsx"], + }, + deadCode: { + ...completeInput.deadCode, + didOverlap: false, + }, + supplyChainOverlapTimedOut: true, + securityScanFailed: true, + }); + + expect(output).toEqual({ + project, + userConfig: null, + resolvedDirectory: "/repo", + diagnostics: [], + score: null, + scoreMetadata: completeInput.scoreMetadata, + didLintFail: true, + lintFailureReason: "oxlint exited before every batch completed", + lintFailureReasonTag: "OxlintSpawnFailed", + lintFailureReasonKind: null, + lintPartialFailures: ["React Hooks rules were skipped"], + didDeadCodeFail: false, + deadCodeFailureReason: null, + deadCodeOverlapped: false, + scannedFileCount: 2, + scannedFilePaths: ["/repo/src/app.tsx", "/repo/src/other.tsx"], + analyzedFiles: ["src/app.tsx"], + scanElapsedMilliseconds: 125, + scanConcurrency: 4, + supplyChainOverlapTimedOut: true, + securityScanFailed: true, + lintCacheHitFileCount: 1, + lintCacheTotalFileCount: 2, + lintSidecarReplayedFileCount: 1, + lintSidecarTotalFileCount: 1, + deadCodeCacheHit: null, + deadCodeSummaryCacheHits: null, + deadCodeSummaryCacheMisses: null, + suppressedRuleCounts: [{ rule: "react-doctor/no-debugger", source: "inline", count: 3 }], + }); + }); +}); diff --git a/packages/core/tests/background-analyzer-execution.test.ts b/packages/core/tests/background-analyzer-execution.test.ts new file mode 100644 index 0000000000..e132da86ab --- /dev/null +++ b/packages/core/tests/background-analyzer-execution.test.ts @@ -0,0 +1,377 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { CheckSecurityScanOptions } from "../src/check-security-scan.js"; +import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "../src/types/index.js"; + +const checkSecurityScanCooperativeMock = vi.hoisted(() => + vi.fn<(rootDirectory: string, options?: CheckSecurityScanOptions) => Promise>(), +); + +vi.mock("../src/check-security-scan.js", async (importOriginal) => ({ + ...(await importOriginal()), + checkSecurityScanCooperative: checkSecurityScanCooperativeMock, +})); + +import { startBackgroundAnalyzerExecution } from "../src/background-analyzer-execution.js"; +import { SupplyChainOverlapTimeoutMs } from "../src/refs.js"; +import { ProjectChecks } from "../src/services/project-checks.js"; +import { SupplyChain } from "../src/services/supply-chain.js"; + +const ROOT_DIRECTORY = "/repo"; +const LONG_TIMEOUT_MS = 60_000; +const SHORT_TIMEOUT_MS = 20; + +const project = { + rootDirectory: ROOT_DIRECTORY, + projectName: "sample-app", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + preactVersion: null, + preactMajorVersion: null, + sourceFileCount: 1, +} satisfies ProjectInfo; + +const environmentDiagnostic: Diagnostic = { + filePath: "/repo/package.json", + plugin: "react-doctor", + rule: "environment-check", + severity: "warning", + message: "Environment finding", + help: "Fix the environment.", + line: 1, + column: 1, + category: "Maintainability", +}; + +const securityDiagnostic: Diagnostic = { + ...environmentDiagnostic, + filePath: "/repo/public/debug.log", + rule: "security-check", + message: "Security finding", + category: "Security", +}; + +const supplyChainDiagnostic: Diagnostic = { + ...environmentDiagnostic, + rule: "supply-chain-check", + message: "Supply-chain finding", + category: "Security", +}; + +beforeEach(() => { + checkSecurityScanCooperativeMock.mockReset(); + checkSecurityScanCooperativeMock.mockResolvedValue([]); +}); + +describe("startBackgroundAnalyzerExecution", () => { + it("processes environment checks before starting both background analyzers and joins before finalization", async () => { + const events: string[] = []; + const ignoredTags = new Set(["experimental"]); + const includedTags = new Set(["security"]); + const userConfig: ReactDoctorConfig = { + supplyChain: { enabled: true }, + }; + let resolveSecurityStart = (): void => undefined; + let releaseSecurity = (): void => undefined; + const securityStarted = new Promise((resolve) => { + resolveSecurityStart = resolve; + }); + const securityReleased = new Promise((resolve) => { + releaseSecurity = resolve; + }); + checkSecurityScanCooperativeMock.mockImplementation(async () => { + events.push("security:start"); + resolveSecurityStart(); + await securityReleased; + events.push("security:complete"); + return [securityDiagnostic]; + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const supplyStarted = yield* Deferred.make(); + const releaseSupply = yield* Deferred.make(); + let receivedUserConfig: ReactDoctorConfig | null | undefined; + const execution = yield* startBackgroundAnalyzerExecution({ + projectChecksService: ProjectChecks.of({ + run: () => + Effect.sync(() => { + events.push("project-checks"); + return [environmentDiagnostic]; + }), + }), + supplyChainService: SupplyChain.of({ + run: (input) => { + receivedUserConfig = input.userConfig; + events.push("supply-chain:run"); + return Stream.fromEffect( + Effect.gen(function* () { + events.push("supply-chain:start"); + yield* Deferred.succeed(supplyStarted, undefined); + yield* Deferred.await(releaseSupply); + events.push("supply-chain:complete"); + return supplyChainDiagnostic; + }), + ); + }, + }), + rootDirectory: ROOT_DIRECTORY, + project, + userConfig, + isDiffMode: false, + shouldRunSupplyChain: true, + ignoredTags, + includedTags, + includeTagDefaults: false, + processDiagnostics: (stream) => + stream.pipe( + Stream.tap((diagnostic) => + Effect.sync(() => { + events.push(`reporter:${diagnostic.rule}`); + }), + ), + ), + }); + + yield* Effect.promise(() => securityStarted); + yield* Deferred.await(supplyStarted); + events.push("lint:checkpoint"); + releaseSecurity(); + yield* Deferred.succeed(releaseSupply, undefined); + const result = yield* execution.join; + events.push("reporter:finalize"); + return { result, receivedUserConfig }; + }).pipe(Effect.provideService(SupplyChainOverlapTimeoutMs, LONG_TIMEOUT_MS)), + ); + + expect(checkSecurityScanCooperativeMock).toHaveBeenCalledWith( + ROOT_DIRECTORY, + expect.objectContaining({ + project, + ignoredTags, + includedTags, + includeTagDefaults: false, + }), + ); + expect(result.receivedUserConfig).toBe(userConfig); + expect(result.result).toEqual({ + environmentDiagnostics: [environmentDiagnostic], + securityDiagnostics: [securityDiagnostic], + supplyChainDiagnostics: [supplyChainDiagnostic], + securityScanFailed: false, + supplyChainOverlapTimedOut: false, + }); + expect(events.slice(0, 2)).toEqual(["project-checks", "reporter:environment-check"]); + expect(events.indexOf("security:start")).toBeLessThan(events.indexOf("lint:checkpoint")); + expect(events.indexOf("supply-chain:start")).toBeLessThan(events.indexOf("lint:checkpoint")); + expect(events.at(-1)).toBe("reporter:finalize"); + expect(events.indexOf("reporter:security-check")).toBeLessThan( + events.indexOf("reporter:finalize"), + ); + expect(events.indexOf("reporter:supply-chain-check")).toBeLessThan( + events.indexOf("reporter:finalize"), + ); + }); + + it("keeps project, security, and supply-chain analyzers idle when their gates are closed", async () => { + let projectCheckRunCount = 0; + let supplyChainRunCount = 0; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const execution = yield* startBackgroundAnalyzerExecution({ + projectChecksService: ProjectChecks.of({ + run: () => { + projectCheckRunCount += 1; + return Effect.succeed([environmentDiagnostic]); + }, + }), + supplyChainService: SupplyChain.of({ + run: () => { + supplyChainRunCount += 1; + return Stream.make(supplyChainDiagnostic); + }, + }), + rootDirectory: ROOT_DIRECTORY, + project, + userConfig: null, + isDiffMode: true, + shouldRunSupplyChain: false, + ignoredTags: new Set(), + includedTags: undefined, + includeTagDefaults: undefined, + processDiagnostics: (stream) => stream, + }); + return yield* execution.join; + }), + ); + + expect(projectCheckRunCount).toBe(0); + expect(supplyChainRunCount).toBe(0); + expect(checkSecurityScanCooperativeMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + environmentDiagnostics: [], + securityDiagnostics: [], + supplyChainDiagnostics: [], + securityScanFailed: false, + supplyChainOverlapTimedOut: false, + }); + }); + + it("fails an escaping security-scan rejection open and records its telemetry flag", async () => { + const events: string[] = []; + let resolveSecurityFailure = (): void => undefined; + const securityFailed = new Promise((resolve) => { + resolveSecurityFailure = resolve; + }); + checkSecurityScanCooperativeMock.mockImplementation(async () => { + events.push("security:failed"); + resolveSecurityFailure(); + throw new Error("EMFILE: too many open files"); + }); + + const { eventsBeforeSupplyRelease, result } = await Effect.runPromise( + Effect.gen(function* () { + const supplyStarted = yield* Deferred.make(); + const releaseSupply = yield* Deferred.make(); + const execution = yield* startBackgroundAnalyzerExecution({ + projectChecksService: ProjectChecks.of({ + run: () => Effect.succeed([]), + }), + supplyChainService: SupplyChain.of({ + run: () => + Stream.fromEffect( + Effect.gen(function* () { + events.push("supply-chain:start"); + yield* Deferred.succeed(supplyStarted, undefined); + yield* Deferred.await(releaseSupply); + events.push("supply-chain:complete"); + return supplyChainDiagnostic; + }), + ), + }), + rootDirectory: ROOT_DIRECTORY, + project, + userConfig: null, + isDiffMode: false, + shouldRunSupplyChain: true, + ignoredTags: new Set(), + includedTags: undefined, + includeTagDefaults: undefined, + processDiagnostics: (stream) => stream, + }); + yield* Effect.promise(() => securityFailed); + yield* Deferred.await(supplyStarted); + const joinFiber = yield* Effect.forkChild( + execution.join.pipe( + Effect.tap(() => + Effect.sync(() => { + events.push("join:complete"); + }), + ), + ), + ); + yield* Effect.yieldNow; + const eventsBeforeSupplyRelease = [...events]; + yield* Deferred.succeed(releaseSupply, undefined); + const result = yield* Fiber.join(joinFiber); + return { eventsBeforeSupplyRelease, result }; + }).pipe(Effect.provideService(SupplyChainOverlapTimeoutMs, LONG_TIMEOUT_MS)), + ); + + expect(eventsBeforeSupplyRelease).not.toContain("join:complete"); + expect(events).toEqual([ + "security:failed", + "supply-chain:start", + "supply-chain:complete", + "join:complete", + ]); + expect(result.securityDiagnostics).toEqual([]); + expect(result.securityScanFailed).toBe(true); + expect(result.supplyChainOverlapTimedOut).toBe(false); + }); + + it("measures the supply-chain timeout from the pre-lint fork and fails open", async () => { + const events: string[] = []; + let resolveSecurityStart = (): void => undefined; + let releaseSecurity = (): void => undefined; + const securityStarted = new Promise((resolve) => { + resolveSecurityStart = resolve; + }); + const securityReleased = new Promise((resolve) => { + releaseSecurity = resolve; + }); + checkSecurityScanCooperativeMock.mockImplementation(async () => { + events.push("security:start"); + resolveSecurityStart(); + await securityReleased; + events.push("security:complete"); + return []; + }); + + const { eventsBeforeSecurityRelease, result } = await Effect.runPromise( + Effect.gen(function* () { + const execution = yield* startBackgroundAnalyzerExecution({ + projectChecksService: ProjectChecks.of({ + run: () => Effect.succeed([]), + }), + supplyChainService: SupplyChain.of({ + run: () => Stream.never, + }), + rootDirectory: ROOT_DIRECTORY, + project, + userConfig: null, + isDiffMode: false, + shouldRunSupplyChain: true, + ignoredTags: new Set(), + includedTags: undefined, + includeTagDefaults: undefined, + processDiagnostics: (stream) => stream, + }); + yield* Effect.promise(() => securityStarted); + yield* Effect.sleep("40 millis"); + const joinFiber = yield* Effect.forkChild( + execution.join.pipe( + Effect.tap(() => + Effect.sync(() => { + events.push("join:complete"); + }), + ), + ), + ); + yield* Effect.yieldNow; + const eventsBeforeSecurityRelease = [...events]; + releaseSecurity(); + const result = yield* Fiber.join(joinFiber); + return { eventsBeforeSecurityRelease, result }; + }).pipe(Effect.provideService(SupplyChainOverlapTimeoutMs, SHORT_TIMEOUT_MS)), + ); + + expect(eventsBeforeSecurityRelease).toEqual(["security:start"]); + expect(events).toEqual(["security:start", "security:complete", "join:complete"]); + expect(result.supplyChainDiagnostics).toEqual([]); + expect(result.supplyChainOverlapTimedOut).toBe(true); + expect(result.securityScanFailed).toBe(false); + }); +}); diff --git a/packages/core/tests/build-dead-code-plan.test.ts b/packages/core/tests/build-dead-code-plan.test.ts new file mode 100644 index 0000000000..62d8538f85 --- /dev/null +++ b/packages/core/tests/build-dead-code-plan.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildDeadCodePlan } from "../src/build-dead-code-plan.js"; +import type { ReactDoctorConfig } from "../src/types/index.js"; + +interface PlanOverrides { + readonly runDeadCode?: boolean; + readonly isDiffMode?: boolean; + readonly showWarnings?: boolean; + readonly userConfig?: ReactDoctorConfig | null; + readonly overlapMode?: "auto" | "on" | "off"; + readonly scanConcurrency?: number; +} + +const buildPlan = (overrides: PlanOverrides = {}) => + buildDeadCodePlan({ + runDeadCode: overrides.runDeadCode ?? true, + isDiffMode: overrides.isDiffMode ?? false, + showWarnings: overrides.showWarnings ?? true, + userConfig: overrides.userConfig ?? null, + overlapMode: overrides.overlapMode ?? "off", + scanConcurrency: overrides.scanConcurrency ?? 10, + }); + +describe("buildDeadCodePlan", () => { + it("keeps disabled, diff, and hidden-warning scans out of the analyzer", () => { + expect(buildPlan({ runDeadCode: false, overlapMode: "on" })).toEqual({ + shouldRun: false, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 10, + }); + expect(buildPlan({ isDiffMode: true, overlapMode: "on" })).toEqual({ + shouldRun: false, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 10, + }); + expect(buildPlan({ showWarnings: false, overlapMode: "on" })).toEqual({ + shouldRun: false, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 10, + }); + }); + + it("runs hidden warnings when a dead-code severity override can surface them", () => { + expect( + buildPlan({ + showWarnings: false, + userConfig: { categories: { Maintainability: "error" } }, + }), + ).toEqual({ + shouldRun: true, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 10, + }); + expect( + buildPlan({ + showWarnings: false, + userConfig: { rules: { "deslop/unused-export": "warn" } }, + }), + ).toEqual({ + shouldRun: true, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 10, + }); + }); + + it("keeps auto and off modes sequential at the full lint concurrency", () => { + for (const overlapMode of ["auto", "off"] as const) { + expect(buildPlan({ overlapMode, scanConcurrency: 32 })).toEqual({ + shouldRun: true, + shouldOverlap: false, + parseConcurrency: undefined, + lintConcurrency: 32, + }); + } + }); + + it("preserves the overlap concurrency split at small and large worker counts", () => { + expect(buildPlan({ overlapMode: "on", scanConcurrency: 1 })).toEqual({ + shouldRun: true, + shouldOverlap: true, + parseConcurrency: 1, + lintConcurrency: 1, + }); + expect(buildPlan({ overlapMode: "on", scanConcurrency: 2 })).toEqual({ + shouldRun: true, + shouldOverlap: true, + parseConcurrency: 1, + lintConcurrency: 1, + }); + expect(buildPlan({ overlapMode: "on", scanConcurrency: 10 })).toEqual({ + shouldRun: true, + shouldOverlap: true, + parseConcurrency: 4, + lintConcurrency: 6, + }); + expect(buildPlan({ overlapMode: "on", scanConcurrency: 32 })).toEqual({ + shouldRun: true, + shouldOverlap: true, + parseConcurrency: 12, + lintConcurrency: 20, + }); + }); +}); diff --git a/packages/core/tests/build-score-request-metadata.test.ts b/packages/core/tests/build-score-request-metadata.test.ts new file mode 100644 index 0000000000..682420abf4 --- /dev/null +++ b/packages/core/tests/build-score-request-metadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildScoreRequestMetadata } from "../src/utils/build-score-request-metadata.js"; + +const project = { + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 42, +}; + +describe("buildScoreRequestMetadata", () => { + it("assembles every score metadata source in the legacy property order", () => { + const metadata = buildScoreRequestMetadata({ + project, + repo: "millionco/react-doctor", + sha: "abc123", + defaultBranch: "main", + doctorVersion: "1.2.3", + runId: "run-123", + githubActionsScoreMetadata: { + githubEventName: "pull_request", + githubActorAssociation: "MEMBER", + }, + githubViewerPermission: "maintain", + }); + + expect(JSON.stringify(metadata)).toBe( + '{"repo":"millionco/react-doctor","sha":"abc123","framework":"vite","reactVersion":"19.0.0","sourceFileCount":42,"defaultBranch":"main","doctorVersion":"1.2.3","runId":"run-123","githubEventName":"pull_request","githubActorAssociation":"MEMBER","githubViewerPermission":"maintain"}', + ); + }); + + it("omits only nullish optional values", () => { + expect( + buildScoreRequestMetadata({ + project: { + ...project, + reactVersion: null, + }, + repo: null, + sha: null, + defaultBranch: null, + githubActionsScoreMetadata: {}, + githubViewerPermission: null, + }), + ).toEqual({ + framework: "vite", + sourceFileCount: 42, + }); + }); + + it("preserves defined empty strings for exact compatibility", () => { + expect( + buildScoreRequestMetadata({ + project, + repo: "", + sha: "", + defaultBranch: "", + doctorVersion: "", + runId: "", + githubActionsScoreMetadata: {}, + githubViewerPermission: "", + }), + ).toEqual({ + repo: "", + sha: "", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 42, + defaultBranch: "", + doctorVersion: "", + runId: "", + githubViewerPermission: "", + }); + }); +}); diff --git a/packages/core/tests/create-worker-slots.test.ts b/packages/core/tests/create-worker-slots.test.ts new file mode 100644 index 0000000000..0c9f7b468d --- /dev/null +++ b/packages/core/tests/create-worker-slots.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createWorkerSlots } from "../src/utils/create-worker-slots.js"; + +interface Deferred { + readonly promise: Promise; + readonly resolve: () => void; +} + +const createDeferred = (): Deferred => { + let resolvePromise = (): void => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; + +const flushTasks = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +const createTestWorkerSlots = (slotCount: number) => + createWorkerSlots({ + slotCount, + createAbortError: () => new Error("aborted"), + }); + +describe("createWorkerSlots", () => { + it("enforces the peak slot count and admits queued tasks in FIFO order", async () => { + const workerSlots = createTestWorkerSlots(2); + const firstRelease = createDeferred(); + const secondRelease = createDeferred(); + const thirdRelease = createDeferred(); + const fourthRelease = createDeferred(); + const startedTasks: string[] = []; + let runningTaskCount = 0; + let peakRunningTaskCount = 0; + + const runTask = (name: string, release: Deferred): Promise => + workerSlots.run(async () => { + startedTasks.push(name); + runningTaskCount += 1; + peakRunningTaskCount = Math.max(peakRunningTaskCount, runningTaskCount); + await release.promise; + runningTaskCount -= 1; + return name; + }); + + const results = Promise.all([ + runTask("first", firstRelease), + runTask("second", secondRelease), + runTask("third", thirdRelease), + runTask("fourth", fourthRelease), + ]); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second"]); + + secondRelease.resolve(); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second", "third"]); + + firstRelease.resolve(); + await flushTasks(); + expect(startedTasks).toEqual(["first", "second", "third", "fourth"]); + + thirdRelease.resolve(); + fourthRelease.resolve(); + expect(await results).toEqual(["first", "second", "third", "fourth"]); + expect(peakRunningTaskCount).toBe(2); + }); + + it("releases slots after rejection", async () => { + const workerSlots = createTestWorkerSlots(1); + await expect( + workerSlots.run(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(workerSlots.run(async () => "after")).resolves.toBe("after"); + }); + + it("removes an aborted waiter without running it or leaking a slot", async () => { + const workerSlots = createTestWorkerSlots(1); + const heldRelease = createDeferred(); + const heldTask = workerSlots.run(() => heldRelease.promise); + await flushTasks(); + + const abortController = new AbortController(); + let didRunAbortedTask = false; + const abortedTask = workerSlots.run(async () => { + didRunAbortedTask = true; + }, abortController.signal); + abortController.abort(); + + await expect(abortedTask).rejects.toThrow("aborted"); + expect(didRunAbortedTask).toBe(false); + heldRelease.resolve(); + await heldTask; + await expect(workerSlots.run(async () => "after")).resolves.toBe("after"); + }); +}); diff --git a/packages/core/tests/cross-file-rule-ids.test.ts b/packages/core/tests/cross-file-rule-ids.test.ts index 8ddb294a0c..020931ee6a 100644 --- a/packages/core/tests/cross-file-rule-ids.test.ts +++ b/packages/core/tests/cross-file-rule-ids.test.ts @@ -8,7 +8,7 @@ import { UNBOUNDED_CROSS_FILE_RULE_IDS, } from "oxlint-plugin-react-doctor"; -// The staleness safety net. Reproduces the transitive import-graph analysis +// The staleness safety net. Reproduces the transitive runtime import-graph analysis // that classifies a rule as cross-file (its verdict can depend on the content // of OTHER files at lint time) and asserts the detected set EQUALS // `CROSS_FILE_RULE_IDS`. If a new rule starts reading other files without being @@ -58,6 +58,9 @@ const primitiveFileSet = new Set(CROSS_FILE_PRIMITIVE_FILES); const stripCommentsAndStrings = (source: string): string => source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +const stripTypeOnlyImportDeclarations = (source: string): string => + source.replace(/^\s*(?:import|export)\s+type\b[\s\S]*?\bfrom\s*["'][^"']+["'];?/gm, ""); + const resolveLocalImport = (fromFile: string, specifier: string): string | null => { if (!specifier.startsWith(".")) return null; const base = path.resolve(path.dirname(fromFile), specifier).replace(/\.js$/, ""); @@ -68,7 +71,9 @@ const resolveLocalImport = (fromFile: string, specifier: string): string | null }; const localImportsOf = (filePath: string): string[] => { - const source = stripCommentsAndStrings(fs.readFileSync(filePath, "utf8")); + const source = stripTypeOnlyImportDeclarations( + stripCommentsAndStrings(fs.readFileSync(filePath, "utf8")), + ); const matches = source.matchAll(/(?:import|export)[\s\S]*?from\s*["']([^"']+)["']/g); return [...matches] .map((match) => resolveLocalImport(filePath, match[1])) diff --git a/packages/core/tests/dead-code-execution.test.ts b/packages/core/tests/dead-code-execution.test.ts new file mode 100644 index 0000000000..04b2148846 --- /dev/null +++ b/packages/core/tests/dead-code-execution.test.ts @@ -0,0 +1,341 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { describe, expect, it } from "vite-plus/test"; +import { buildDeadCodePlan } from "../src/build-dead-code-plan.js"; +import { DEAD_CODE_PHASE_TIMEOUT_MS } from "../src/constants.js"; +import { type DeadCodeFailureState, startDeadCodeExecution } from "../src/dead-code-execution.js"; +import { DeadCodeAnalysisFailed, ReactDoctorError } from "../src/errors.js"; +import { DeadCode } from "../src/services/dead-code.js"; +import type { ProgressHandle } from "../src/services/progress.js"; +import type { Diagnostic } from "../src/types/index.js"; +import { resolveDeadCodeTimeout } from "../src/utils/resolve-dead-code-timeout.js"; + +const ROOT_DIRECTORY = "/repo"; +const SCAN_CONCURRENCY = 4; +const DISCOVERED_FILE_COUNT = 8; +const SHORT_TIMEOUT_MS = 20; + +const diagnostic: Diagnostic = { + filePath: "/repo/src/unused.ts", + plugin: "deslop", + rule: "unused-file", + severity: "warning", + message: "Unused file", + help: "Remove the unused file.", + line: 1, + column: 1, + category: "Maintainability", +}; + +const buildPlan = (overlapMode: "on" | "off") => + buildDeadCodePlan({ + runDeadCode: true, + isDiffMode: false, + showWarnings: true, + userConfig: null, + overlapMode, + scanConcurrency: SCAN_CONCURRENCY, + }); + +const makeProgress = (events: string[]): ProgressHandle => ({ + update: (text) => + Effect.sync(() => { + events.push(`progress:${text}`); + }), + succeed: () => Effect.void, + fail: () => Effect.void, + stop: () => Effect.void, +}); + +const makeFailure = (): Effect.Effect> => + Ref.make({ didFail: false, reason: null }); + +describe("startDeadCodeExecution", () => { + it("defers sequential work until settle and preserves callbacks, progress, and diagnostics", async () => { + const events: string[] = []; + let parseConcurrency: number | undefined; + let workerTimeoutMs: number | undefined; + const deadCodeService = DeadCode.of({ + run: (input) => { + events.push("run"); + parseConcurrency = input.parseConcurrency; + workerTimeoutMs = input.workerTimeoutMs; + input.onCacheOutcome?.(true); + input.onSummaryCacheStats?.({ hits: 5, misses: 2 }); + return Stream.fromIterable([diagnostic]); + }, + }); + + const { eventsBeforeSettle, result } = await Effect.runPromise( + Effect.gen(function* () { + const failure = yield* makeFailure(); + const execution = yield* startDeadCodeExecution({ + deadCodeService, + failureRef: failure, + plan: buildPlan("off"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: DEAD_CODE_PHASE_TIMEOUT_MS, + deadlineEpochMs: undefined, + processDiagnostics: (stream) => + stream.pipe( + Stream.tap(() => + Effect.sync(() => { + events.push("diagnostic"); + }), + ), + ), + }); + const eventsBeforeSettle = [...events]; + const result = yield* execution.settle({ + lintDidFail: false, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress(events), + }); + return { eventsBeforeSettle, result }; + }), + ); + + expect(eventsBeforeSettle).toEqual([]); + expect(events).toEqual([ + "progress:Scanned 3 files, analyzing dead code...", + "run", + "diagnostic", + ]); + expect(parseConcurrency).toBeUndefined(); + expect(workerTimeoutMs).toBe( + resolveDeadCodeTimeout({ + sourceFileCount: 3, + deadCodeConcurrency: SCAN_CONCURRENCY, + fullConcurrency: SCAN_CONCURRENCY, + }).workerTimeoutMs, + ); + expect(result).toEqual({ + diagnostics: [diagnostic], + failure: { didFail: false, reason: null }, + cacheHit: true, + summaryCacheHits: 5, + summaryCacheMisses: 2, + }); + }); + + it("starts overlap before settle and joins its existing result", async () => { + const events: string[] = []; + let parseConcurrency: number | undefined; + let workerTimeoutMs: number | undefined; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const failure = yield* makeFailure(); + const execution = yield* startDeadCodeExecution({ + deadCodeService: DeadCode.of({ + run: (input) => { + parseConcurrency = input.parseConcurrency; + workerTimeoutMs = input.workerTimeoutMs; + return Stream.fromEffect( + Effect.gen(function* () { + events.push("started"); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + events.push("completed"); + return diagnostic; + }), + ); + }, + }), + failureRef: failure, + plan: buildPlan("on"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: DEAD_CODE_PHASE_TIMEOUT_MS, + deadlineEpochMs: undefined, + processDiagnostics: (stream) => stream, + }); + + yield* Deferred.await(started); + events.push("lint-settled"); + yield* Deferred.succeed(release, undefined); + return yield* execution.settle({ + lintDidFail: false, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress(events), + }); + }), + ); + + expect(events).toEqual([ + "started", + "lint-settled", + "progress:Scanned 3 files, analyzing dead code...", + "completed", + ]); + const overlapPlan = buildPlan("on"); + expect(parseConcurrency).toBe(overlapPlan.parseConcurrency); + expect(workerTimeoutMs).toBe( + resolveDeadCodeTimeout({ + sourceFileCount: DISCOVERED_FILE_COUNT, + deadCodeConcurrency: overlapPlan.parseConcurrency ?? SCAN_CONCURRENCY, + fullConcurrency: SCAN_CONCURRENCY, + }).workerTimeoutMs, + ); + expect(result.diagnostics).toEqual([diagnostic]); + expect(result.failure).toEqual({ didFail: false, reason: null }); + }); + + it("interrupts overlap and lets lint failure override dead-code state", async () => { + const events: string[] = []; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const failure = yield* Ref.make({ + didFail: true, + reason: "Dead-code worker failed first.", + }); + const execution = yield* startDeadCodeExecution({ + deadCodeService: DeadCode.of({ + run: () => + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + ), + }), + failureRef: failure, + plan: buildPlan("on"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: DEAD_CODE_PHASE_TIMEOUT_MS, + deadlineEpochMs: undefined, + processDiagnostics: (stream) => stream, + }); + + yield* Deferred.await(started); + const result = yield* execution.settle({ + lintDidFail: true, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress(events), + }); + return result; + }), + ); + + expect(events).toEqual([]); + expect(result.diagnostics).toEqual([]); + expect(result.failure).toEqual({ didFail: false, reason: null }); + }); + + it("skips work at an exhausted deadline with the established failure reason", async () => { + let runCount = 0; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const failure = yield* makeFailure(); + const execution = yield* startDeadCodeExecution({ + deadCodeService: DeadCode.of({ + run: () => { + runCount += 1; + return Stream.fromIterable([diagnostic]); + }, + }), + failureRef: failure, + plan: buildPlan("off"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: DEAD_CODE_PHASE_TIMEOUT_MS, + deadlineEpochMs: 0, + processDiagnostics: (stream) => stream, + }); + return yield* execution.settle({ + lintDidFail: false, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress([]), + }); + }), + ); + + expect(runCount).toBe(0); + expect(result.diagnostics).toEqual([]); + expect(result.failure).toEqual({ + didFail: true, + reason: "Dead-code analysis skipped — max scan duration reached.", + }); + }); + + it("folds timeouts and service failures into the dead-code failure contract", async () => { + const serviceError = new ReactDoctorError({ + reason: new DeadCodeAnalysisFailed({ cause: "worker crash" }), + }); + + const [timeoutResult, failureResult] = await Promise.all([ + Effect.runPromise( + Effect.gen(function* () { + const failure = yield* makeFailure(); + const execution = yield* startDeadCodeExecution({ + deadCodeService: DeadCode.of({ run: () => Stream.never }), + failureRef: failure, + plan: buildPlan("off"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: SHORT_TIMEOUT_MS, + deadlineEpochMs: undefined, + processDiagnostics: (stream) => stream, + }); + return yield* execution.settle({ + lintDidFail: false, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress([]), + }); + }), + ), + Effect.runPromise( + Effect.gen(function* () { + const failure = yield* makeFailure(); + const execution = yield* startDeadCodeExecution({ + deadCodeService: DeadCode.of({ run: () => Stream.fail(serviceError) }), + failureRef: failure, + plan: buildPlan("off"), + rootDirectory: ROOT_DIRECTORY, + discoveredSourceFileCount: DISCOVERED_FILE_COUNT, + scanConcurrency: SCAN_CONCURRENCY, + configuredPhaseTimeoutMs: DEAD_CODE_PHASE_TIMEOUT_MS, + deadlineEpochMs: undefined, + processDiagnostics: (stream) => stream, + }); + return yield* execution.settle({ + lintDidFail: false, + totalFileCount: 3, + scannedFilesLabel: "3 files", + progress: makeProgress([]), + }); + }), + ), + ]); + + expect(timeoutResult.diagnostics).toEqual([]); + expect(timeoutResult.failure).toEqual({ + didFail: true, + reason: "Dead-code analysis exceeded 0s and was skipped.", + }); + expect(failureResult.diagnostics).toEqual([]); + expect(failureResult.failure).toEqual({ + didFail: true, + reason: serviceError.message, + }); + }); +}); diff --git a/packages/core/tests/detect-target-blank-opener-protection.test.ts b/packages/core/tests/detect-target-blank-opener-protection.test.ts index 04e1a9ad1c..bc9477a130 100644 --- a/packages/core/tests/detect-target-blank-opener-protection.test.ts +++ b/packages/core/tests/detect-target-blank-opener-protection.test.ts @@ -189,6 +189,40 @@ describe("detectTargetBlankOpenerProtection", () => { expect(capabilities.has("target-blank-needs-noreferrer")).toBe(true); }); + it.each([ + [ + "nested-browser-target", + { + name: "nested-browser-target", + dependencies: { react: "^18.0.0" }, + browserslist: ["chrome 80"], + }, + false, + ], + [ + "nested-electron-target", + { + name: "nested-electron-target", + dependencies: { react: "^18.0.0" }, + devDependencies: { electron: "^0.36.0" }, + }, + true, + ], + ])( + "inherits target-blank policy from the enclosing package for %s", + (caseName, packageJson, needsNoreferrer) => { + const projectDirectory = setupProject(caseName, packageJson); + const nestedDirectory = path.join(projectDirectory, "src", "components"); + fs.mkdirSync(nestedDirectory, { recursive: true }); + fs.writeFileSync(path.join(nestedDirectory, "button.tsx"), "export const Button = null;\n"); + + const capabilities = getCapabilities(discoverProject(nestedDirectory)); + + expect(capabilities.has("target-blank-needs-explicit-protection")).toBe(true); + expect(capabilities.has("target-blank-needs-noreferrer")).toBe(needsNoreferrer); + }, + ); + it("requires noopener once Electron adopted Chromium 49", () => { const packageJson: PackageJson = { name: "electron-0-37", diff --git a/packages/core/tests/diagnostic-postprocessing-parity.test.ts b/packages/core/tests/diagnostic-postprocessing-parity.test.ts new file mode 100644 index 0000000000..883c4aa95a --- /dev/null +++ b/packages/core/tests/diagnostic-postprocessing-parity.test.ts @@ -0,0 +1,319 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { describe, expect, it } from "vite-plus/test"; +import { buildDiagnosticPipeline } from "../src/build-diagnostic-pipeline.js"; +import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "../src/types/index.js"; +import { runInspect, type InspectInput } from "../src/run-inspect.js"; +import { DeadCodeOverlap } from "../src/refs.js"; +import { Config } from "../src/services/config.js"; +import { DeadCode } from "../src/services/dead-code.js"; +import { Files } from "../src/services/files.js"; +import { Git } from "../src/services/git.js"; +import { LintPartialFailures, Linter } from "../src/services/linter.js"; +import { Progress } from "../src/services/progress.js"; +import { Project } from "../src/services/project.js"; +import { ProjectChecks } from "../src/services/project-checks.js"; +import { Reporter, ReporterCapture } from "../src/services/reporter.js"; +import { Score } from "../src/services/score.js"; +import { SupplyChain } from "../src/services/supply-chain.js"; +import { dedupeDiagnostics } from "../src/utils/dedupe-diagnostics.js"; + +const project: ProjectInfo = { + rootDirectory: "/repo", + projectName: "postprocessing-parity", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasTanStackQuery: false, + tanstackQueryVersion: null, + valtioVersion: null, + valtioMajorVersion: null, + hasSsrDependency: false, + preactVersion: null, + preactMajorVersion: null, + hasReactNativeWorkspace: false, + nextjsVersion: null, + nextjsMajorVersion: null, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + reanimatedVersion: null, + isPreES2023Target: false, + isStaticExport: false, + sourceFileCount: 8, +}; + +const input: InspectInput = { + directory: "/repo", + includePaths: ["src/exact.tsx"], + customRulesOnly: false, + respectInlineDisables: true, + adoptExistingLintConfig: true, + ignoredTags: new Set(), + runDeadCode: false, + warnings: true, + isCi: false, +}; + +const userConfig: ReactDoctorConfig = { + rules: { + "react-doctor/suppressed-config": "off", + "react-doctor/no-derived-state": "error", + "react-doctor/restamped": "error", + }, + ignore: { + overrides: [ + { + files: ["src/override.tsx"], + rules: ["react-doctor/suppressed-override"], + }, + ], + }, + surfaces: { + score: { + excludeRules: ["react-doctor/score-hidden"], + }, + }, +}; + +const buildDiagnostic = (overrides: Partial): Diagnostic => ({ + filePath: "src/exact.tsx", + plugin: "react-doctor", + rule: "exact", + severity: "warning", + message: "Exact finding", + help: "Fix the finding.", + line: 1, + column: 1, + category: "Maintainability", + ...overrides, +}); + +const exactDiagnostic = buildDiagnostic({}); +const exactDuplicate = buildDiagnostic({ help: "A duplicate with different derived help." }); +const configSuppressedFirst = buildDiagnostic({ + filePath: "src/config.tsx", + rule: "suppressed-config", + message: "Config-suppressed finding", +}); +const configSuppressedSecond = buildDiagnostic({ + ...configSuppressedFirst, + line: 2, +}); +const overrideSuppressed = buildDiagnostic({ + filePath: "src/override.tsx", + rule: "suppressed-override", + message: "Override-suppressed finding", +}); +const inlineSuppressed = buildDiagnostic({ + filePath: "src/inline.tsx", + rule: "suppressed-inline", + message: "Inline-suppressed finding", + line: 2, +}); +const relatedFallback = buildDiagnostic({ + filePath: "src/related.tsx", + rule: "no-derived-state", + message: "Derived state write", + offset: 100, + length: 12, + line: 12, + column: 3, +}); +const relatedPreferred = buildDiagnostic({ + ...relatedFallback, + rule: "no-adjust-state-on-prop-change", +}); +const restampedWarning = buildDiagnostic({ + filePath: "src/restamped.tsx", + rule: "restamped", + message: "Severity-restamped finding", + line: 7, + column: 4, +}); +const restampedError = buildDiagnostic({ + ...restampedWarning, + severity: "error", +}); +const groupedLater = buildDiagnostic({ + filePath: "src/group.tsx", + rule: "no-derived-state-effect", + message: "State resets after every prop change.", + line: 30, +}); +const groupedEarlier = buildDiagnostic({ + ...groupedLater, + line: 20, +}); +const scoreHidden = buildDiagnostic({ + filePath: "src/a-score-hidden.tsx", + rule: "score-hidden", + message: "Visible outside the score.", + line: 5, +}); + +const rawDiagnostics = [ + exactDiagnostic, + exactDuplicate, + configSuppressedFirst, + configSuppressedSecond, + overrideSuppressed, + inlineSuppressed, + relatedFallback, + relatedPreferred, + restampedWarning, + restampedError, + groupedLater, + groupedEarlier, + scoreHidden, +]; + +const backendDiagnostics = dedupeDiagnostics(rawDiagnostics); +const relatedWinner = { ...relatedPreferred, severity: "error" }; +const restampedDuplicate = { ...restampedWarning, severity: "error" }; +const fixGroupId = "f88ca5c4b658d46a"; +const groupedEarlierFinal = { ...groupedEarlier, fixGroupId }; +const groupedLaterFinal = { ...groupedLater, fixGroupId }; + +const expectedReporterDiagnostics = [ + exactDiagnostic, + relatedWinner, + restampedDuplicate, + restampedDuplicate, + groupedLater, + groupedEarlier, + scoreHidden, +]; + +const expectedFinalDiagnostics = [ + scoreHidden, + exactDiagnostic, + groupedEarlierFinal, + groupedLaterFinal, + relatedWinner, + restampedDuplicate, + restampedDuplicate, +]; + +describe("diagnostic post-processing parity", () => { + it("freezes backend acceptance through reporter, score, and final output boundaries", async () => { + expect(backendDiagnostics).toEqual([ + exactDiagnostic, + configSuppressedFirst, + configSuppressedSecond, + overrideSuppressed, + inlineSuppressed, + relatedFallback, + relatedPreferred, + restampedWarning, + restampedError, + groupedLater, + groupedEarlier, + scoreHidden, + ]); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const scoreDiagnosticsRef = yield* Ref.make>([]); + const scoreLayer = Layer.succeed( + Score, + Score.of({ + compute: (scoreInput) => + Ref.set(scoreDiagnosticsRef, scoreInput.diagnostics).pipe( + Effect.as({ score: 85, label: "Good" }), + ), + }), + ); + const layers = Layer.mergeAll( + Project.layerOf(project), + ProjectChecks.layerOf([]), + Config.layerOf({ + config: userConfig, + resolvedDirectory: "/repo", + configSourceDirectory: null, + }), + Files.layerInMemory( + new Map([ + [ + "/repo/src/inline.tsx", + "// react-doctor-disable-next-line react-doctor/suppressed-inline\nconst value = 1;\n", + ], + ]), + ), + Linter.layerOf(backendDiagnostics), + LintPartialFailures.layerLive, + DeadCode.layerOf([]), + Git.layerOf({ + headSha: "abc123", + githubRepo: "millionco/postprocessing-parity", + defaultBranch: "main", + }), + scoreLayer, + SupplyChain.layerOf([]), + Progress.layerNoop, + Reporter.layerCapture, + Layer.succeed(DeadCodeOverlap, "off"), + ); + + return yield* Effect.gen(function* () { + const output = yield* runInspect(input); + const reporterCapture = yield* ReporterCapture; + return { + output, + reporterDiagnostics: yield* Ref.get(reporterCapture), + scoreDiagnostics: yield* Ref.get(scoreDiagnosticsRef), + }; + }).pipe(Effect.provide(layers)); + }), + ); + + expect(result.reporterDiagnostics).toEqual(expectedReporterDiagnostics); + expect(result.reporterDiagnostics.every((diagnostic) => !("fixGroupId" in diagnostic))).toBe( + true, + ); + expect(result.output.diagnostics).toEqual(expectedFinalDiagnostics); + expect(result.scoreDiagnostics).toEqual( + expectedFinalDiagnostics.filter((diagnostic) => diagnostic.rule !== "score-hidden"), + ); + expect(result.output.suppressedRuleCounts).toEqual([ + { rule: "react-doctor/suppressed-config", source: "config", count: 2 }, + { rule: "react-doctor/suppressed-override", source: "override", count: 1 }, + { rule: "react-doctor/suppressed-inline", source: "inline", count: 1 }, + ]); + }); + + it("orders suppression summaries by first-observed rule and source", () => { + const pipeline = buildDiagnosticPipeline({ + rootDirectory: "/repo", + userConfig, + readFileLinesSync: (filePath) => + filePath === "/repo/src/inline.tsx" + ? ["// react-doctor-disable-next-line react-doctor/suppressed-inline", "const value = 1;"] + : null, + respectInlineDisables: true, + showWarnings: true, + }); + + for (const diagnostic of [ + inlineSuppressed, + overrideSuppressed, + configSuppressedFirst, + configSuppressedSecond, + ]) { + expect(pipeline.apply(diagnostic)).toBeNull(); + } + + expect(pipeline.summarizeSuppressions()).toEqual([ + { rule: "react-doctor/suppressed-inline", source: "inline", count: 1 }, + { rule: "react-doctor/suppressed-override", source: "override", count: 1 }, + { rule: "react-doctor/suppressed-config", source: "config", count: 2 }, + ]); + }); +}); diff --git a/packages/core/tests/discover-project.test.ts b/packages/core/tests/discover-project.test.ts index 43754a64d6..082c16ecfa 100644 --- a/packages/core/tests/discover-project.test.ts +++ b/packages/core/tests/discover-project.test.ts @@ -969,6 +969,47 @@ describe("discoverProject", () => { expect(projectInfo.reactMajorVersion).toBe(19); }); + it("applies a monorepo React catalog when the root manifest does not declare React", () => { + const monorepoRoot = path.join(tempDirectory, "leaf-uses-root-only-react-catalog"); + const packageDirectory = path.join(monorepoRoot, "apps", "web"); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync( + path.join(monorepoRoot, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n\ncatalog:\n react: ^19.1.0\n", + ); + fs.writeFileSync(path.join(monorepoRoot, "package.json"), JSON.stringify({ name: "root" })); + fs.writeFileSync(path.join(packageDirectory, "package.json"), JSON.stringify({ name: "web" })); + + const projectInfo = discoverProject(packageDirectory); + expect(projectInfo.reactVersion).toBe("^19.1.0"); + expect(projectInfo.reactMajorVersion).toBe(19); + }); + + it("does not mask an unresolved leaf React declaration with the monorepo version", () => { + const monorepoRoot = path.join(tempDirectory, "leaf-react-declaration-isolation"); + const packageDirectory = path.join(monorepoRoot, "apps", "web"); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync( + path.join(monorepoRoot, "package.json"), + JSON.stringify({ + name: "root", + workspaces: ["apps/*"], + dependencies: { react: "^19.0.0" }, + }), + ); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ + name: "web", + dependencies: { react: "workspace:*" }, + }), + ); + + const projectInfo = discoverProject(packageDirectory); + expect(projectInfo.reactVersion).toBe("workspace:*"); + expect(projectInfo.reactMajorVersion).toBeNull(); + }); + it("uses monorepo React fallback for Next leaf packages without direct React declarations", () => { const monorepoRoot = path.join(tempDirectory, "next-leaf-uses-root-react-fallback"); fs.mkdirSync(path.join(monorepoRoot, "packages", "next-adapter"), { recursive: true }); diff --git a/packages/core/tests/discover-react-subprojects-package-graph-parity.test.ts b/packages/core/tests/discover-react-subprojects-package-graph-parity.test.ts new file mode 100644 index 0000000000..4a39f4a4a0 --- /dev/null +++ b/packages/core/tests/discover-react-subprojects-package-graph-parity.test.ts @@ -0,0 +1,106 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { discoverReactSubprojects, listWorkspacePackages } from "../src/project-info/index.js"; + +const FIXTURES_DIRECTORY = path.join(import.meta.dirname, "fixtures"); +const temporaryDirectories: string[] = []; + +const createTemporaryDirectory = (): string => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-subproject-parity-"), + ); + temporaryDirectories.push(temporaryDirectory); + return temporaryDirectory; +}; + +const writePackageJson = (directory: string, packageJson: object): void => { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify(packageJson)); +}; + +afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +describe("discoverReactSubprojects manifest parity", () => { + it.each([ + "nested-workspaces", + "pnpm-catalog-workspace", + "pnpm-named-catalog", + "bun-catalog-workspace", + ])("matches the legacy manifest package list for %s", (fixtureName) => { + const rootDirectory = path.join(FIXTURES_DIRECTORY, fixtureName); + + expect(discoverReactSubprojects(rootDirectory)).toEqual(listWorkspacePackages(rootDirectory)); + }); + + it("preserves root inclusion, workspace pattern order, and overlap deduplication", () => { + const rootDirectory = createTemporaryDirectory(); + const secondWorkspaceDirectory = path.join(rootDirectory, "packages", "second"); + const firstWorkspaceDirectory = path.join(rootDirectory, "packages", "first"); + + writePackageJson(rootDirectory, { + name: "workspace-root", + workspaces: ["packages/second", "packages/first", "packages/*"], + dependencies: { react: "^19.0.0" }, + }); + writePackageJson(secondWorkspaceDirectory, { + name: "second", + dependencies: { react: "catalog:" }, + }); + writePackageJson(firstWorkspaceDirectory, { + name: "first", + dependencies: { "react-native": "^0.80.0" }, + }); + + expect(discoverReactSubprojects(rootDirectory)).toEqual([ + { name: "workspace-root", directory: rootDirectory }, + { name: "second", directory: secondWorkspaceDirectory }, + { name: "first", directory: firstWorkspaceDirectory }, + ]); + }); + + it("keeps filesystem discovery when a root manifest has no workspace declaration", () => { + const rootDirectory = createTemporaryDirectory(); + const nestedDirectory = path.join(rootDirectory, "examples", "nested"); + + writePackageJson(rootDirectory, { + name: "standalone-root", + dependencies: { react: "^19.0.0" }, + }); + writePackageJson(nestedDirectory, { + name: "nested", + dependencies: { react: "^18.0.0" }, + }); + + expect(discoverReactSubprojects(rootDirectory)).toEqual([ + { name: "standalone-root", directory: rootDirectory }, + { name: "nested", directory: nestedDirectory }, + ]); + }); + + it("keeps package-less Nx discovery ahead of filesystem crawling", () => { + const rootDirectory = createTemporaryDirectory(); + const workspaceDirectory = path.join(rootDirectory, "apps", "web"); + const unlistedDirectory = path.join(rootDirectory, "examples", "preview"); + + fs.writeFileSync(path.join(rootDirectory, "nx.json"), "{}"); + writePackageJson(workspaceDirectory, { + name: "web", + dependencies: { react: "^19.0.0" }, + }); + fs.writeFileSync(path.join(workspaceDirectory, "project.json"), "{}"); + writePackageJson(unlistedDirectory, { + name: "preview", + dependencies: { react: "^19.0.0" }, + }); + + expect(discoverReactSubprojects(rootDirectory)).toEqual([ + { name: "web", directory: workspaceDirectory }, + ]); + }); +}); diff --git a/packages/core/tests/finalize-diagnostic-output.test.ts b/packages/core/tests/finalize-diagnostic-output.test.ts new file mode 100644 index 0000000000..7608a55081 --- /dev/null +++ b/packages/core/tests/finalize-diagnostic-output.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildDiagnosticPipeline } from "../src/build-diagnostic-pipeline.js"; +import { finalizeDiagnosticOutput } from "../src/finalize-diagnostic-output.js"; +import type { Diagnostic, ReactDoctorConfig } from "../src/types/index.js"; + +const buildDiagnostic = (overrides: Partial = {}): Diagnostic => ({ + filePath: "src/component.tsx", + plugin: "react-doctor", + rule: "test-rule", + severity: "warning", + message: "Test finding", + help: "Fix the finding.", + line: 1, + column: 1, + category: "Maintainability", + ...overrides, +}); + +describe("finalizeDiagnosticOutput", () => { + it("preserves the complete filtering, ordering, grouping, and scoring projection contract", () => { + const userConfig: ReactDoctorConfig = { + rules: { + "react-doctor/escalated": "error", + "react-doctor/suppressed": "off", + }, + surfaces: { + score: { + excludeRules: ["react-doctor/score-hidden"], + }, + }, + }; + const pipeline = buildDiagnosticPipeline({ + rootDirectory: "/repo", + userConfig, + readFileLinesSync: () => [], + respectInlineDisables: true, + showWarnings: true, + }); + const environmentDuplicate = buildDiagnostic({ + filePath: "src/d.tsx", + rule: "duplicate", + help: "Environment copy", + line: 4, + column: 2, + }); + const lintDuplicate = buildDiagnostic({ + ...environmentDuplicate, + help: "Lint copy", + }); + const groupMessage = "State resets after every prop change."; + const rawDiagnostics = [ + buildDiagnostic({ + filePath: "src/z.tsx", + rule: "suppressed", + }), + buildDiagnostic({ + filePath: "src/b.tsx", + rule: "escalated", + line: 10, + column: 5, + }), + buildDiagnostic({ + filePath: "src/a.tsx", + rule: "score-hidden", + line: 3, + column: 9, + }), + buildDiagnostic({ + filePath: "src/c.tsx", + rule: "severity-tie", + severity: "warning", + line: 7, + column: 4, + }), + buildDiagnostic({ + filePath: "src/c.tsx", + rule: "severity-tie", + severity: "error", + line: 7, + column: 4, + }), + buildDiagnostic({ + filePath: "src/group.tsx", + rule: "no-derived-state-effect", + message: groupMessage, + line: 20, + }), + buildDiagnostic({ + filePath: "src/group.tsx", + rule: "no-derived-state-effect", + message: groupMessage, + line: 10, + }), + ]; + const processedDiagnostics = rawDiagnostics.flatMap((diagnostic) => { + const processedDiagnostic = pipeline.apply(diagnostic); + return processedDiagnostic === null ? [] : [processedDiagnostic]; + }); + + const result = finalizeDiagnosticOutput({ + environmentDiagnostics: [environmentDuplicate], + securityDiagnostics: [], + supplyChainDiagnostics: [], + lintDiagnostics: [...processedDiagnostics, lintDuplicate], + deadCodeDiagnostics: [], + scoreSurface: "score", + userConfig, + }); + + expect( + result.diagnostics.map((diagnostic) => ({ + filePath: diagnostic.filePath, + line: diagnostic.line, + column: diagnostic.column, + severity: diagnostic.severity, + rule: diagnostic.rule, + help: diagnostic.help, + })), + ).toEqual([ + { + filePath: "src/a.tsx", + line: 3, + column: 9, + severity: "warning", + rule: "score-hidden", + help: "Fix the finding.", + }, + { + filePath: "src/b.tsx", + line: 10, + column: 5, + severity: "error", + rule: "escalated", + help: "Fix the finding.", + }, + { + filePath: "src/c.tsx", + line: 7, + column: 4, + severity: "error", + rule: "severity-tie", + help: "Fix the finding.", + }, + { + filePath: "src/c.tsx", + line: 7, + column: 4, + severity: "warning", + rule: "severity-tie", + help: "Fix the finding.", + }, + { + filePath: "src/d.tsx", + line: 4, + column: 2, + severity: "warning", + rule: "duplicate", + help: "Environment copy", + }, + { + filePath: "src/d.tsx", + line: 4, + column: 2, + severity: "warning", + rule: "duplicate", + help: "Lint copy", + }, + { + filePath: "src/group.tsx", + line: 10, + column: 1, + severity: "warning", + rule: "no-derived-state-effect", + help: "Fix the finding.", + }, + { + filePath: "src/group.tsx", + line: 20, + column: 1, + severity: "warning", + rule: "no-derived-state-effect", + help: "Fix the finding.", + }, + ]); + expect(result.diagnostics.filter((diagnostic) => diagnostic.rule === "suppressed")).toEqual([]); + const groupedDiagnostics = result.diagnostics.filter( + (diagnostic) => diagnostic.rule === "no-derived-state-effect", + ); + expect(groupedDiagnostics.every((diagnostic) => diagnostic.fixGroupId !== undefined)).toBe( + true, + ); + expect(new Set(groupedDiagnostics.map((diagnostic) => diagnostic.fixGroupId)).size).toBe(1); + expect(result.scoreDiagnostics).toEqual( + result.diagnostics.filter((diagnostic) => diagnostic.rule !== "score-hidden"), + ); + }); +}); diff --git a/packages/core/tests/fixtures/package-local-capabilities/package.json b/packages/core/tests/fixtures/package-local-capabilities/package.json new file mode 100644 index 0000000000..8f15c82cc9 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/package.json @@ -0,0 +1,13 @@ +{ + "name": "package-local-capabilities", + "private": true, + "workspaces": [ + "packages/*" + ], + "catalogs": { + "modern": { + "next": "^16.0.0", + "react": "^19.2.0" + } + } +} diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/package.json b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/package.json new file mode 100644 index 0000000000..4c5b21e80d --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/package.json @@ -0,0 +1,10 @@ +{ + "name": "legacy", + "dependencies": { + "react": "^18.2.0", + "vite": "^5.4.0" + }, + "browserslist": [ + "chrome 80" + ] +} diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/src/app.tsx b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/src/app.tsx new file mode 100644 index 0000000000..977e50cfc3 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/src/app.tsx @@ -0,0 +1,3 @@ +export const LegacyApp = () =>
() => undefined}>Legacy
; + +LegacyApp.defaultProps = { label: "Legacy" }; diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/tsconfig.json b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/tsconfig.json new file mode 100644 index 0000000000..bd4d6867c1 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/legacy/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "target": "ES2020" + } +} diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/package.json b/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/package.json new file mode 100644 index 0000000000..7f3e7176c8 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/package.json @@ -0,0 +1,8 @@ +{ + "name": "mobile", + "dependencies": { + "expo": "^54.0.0", + "react": "^19.1.0", + "react-native": "^0.81.0" + } +} diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/src/app.tsx b/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/src/app.tsx new file mode 100644 index 0000000000..16496a0eb8 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/mobile/src/app.tsx @@ -0,0 +1,3 @@ +export const MobileApp = () => null; + +MobileApp.defaultProps = { label: "Mobile" }; diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/modern/next.config.js b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/next.config.js new file mode 100644 index 0000000000..a9e9151ae0 --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/next.config.js @@ -0,0 +1,4 @@ +export default { + output: "export", + reactCompiler: true, +}; diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/modern/package.json b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/package.json new file mode 100644 index 0000000000..e792d8bf9f --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/package.json @@ -0,0 +1,8 @@ +{ + "name": "modern", + "dependencies": { + "@tanstack/react-query": "^5.66.0", + "next": "catalog:modern", + "react": "catalog:modern" + } +} diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/modern/src/app.tsx b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/src/app.tsx new file mode 100644 index 0000000000..1d2fa3304c --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/src/app.tsx @@ -0,0 +1,3 @@ +export const ModernApp = () =>
Modern
; + +ModernApp.defaultProps = { label: "Modern" }; diff --git a/packages/core/tests/fixtures/package-local-capabilities/packages/modern/tsconfig.json b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/tsconfig.json new file mode 100644 index 0000000000..678ff85dad --- /dev/null +++ b/packages/core/tests/fixtures/package-local-capabilities/packages/modern/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "target": "ESNext" + } +} diff --git a/packages/core/tests/git-output.test.ts b/packages/core/tests/git-output.test.ts new file mode 100644 index 0000000000..379cc8fbe0 --- /dev/null +++ b/packages/core/tests/git-output.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + parseGitBaselineDiffPlan, + parseGithubRemoteRepository, + parseGithubViewerPermission, + splitNullSeparatedGitOutput, + trimGitOutputOrNull, +} from "../src/services/git-output.js"; + +describe("git output parsing", () => { + it("normalizes empty and non-empty scalar output", () => { + expect(trimGitOutputOrNull(" \r\n ")).toBeNull(); + expect(trimGitOutputOrNull(" value\n")).toBe("value"); + }); + + it.each([ + ["git@github.com:owner/repository.git", "owner/repository"], + ["https://github.com/owner/repository.git", "owner/repository"], + ["http://github.com/owner/repository", "owner/repository"], + ["ssh://git@github.com/owner/repository.git", "owner/repository"], + [" git@github.com:owner/repository.git\n", "owner/repository"], + ["https://gitlab.com/owner/repository.git", null], + ["https://github.com/owner/repository/extra", null], + ["git@github.com:owner", null], + ])("parses GitHub remote %s", (remoteUrl, expectedRepository) => { + expect(parseGithubRemoteRepository(remoteUrl)).toBe(expectedRepository); + }); + + it.each([ + ["ADMIN\n", "admin"], + ["TRIAGE", "triage"], + ["WRITE_ACCESS", "write_access"], + ["null\n", null], + ["", null], + ["write", null], + ["READ ONLY", null], + ])("parses viewer permission %s", (stdout, expectedPermission) => { + expect(parseGithubViewerPermission(stdout)).toBe(expectedPermission); + }); + + it("splits null-separated output without changing entry contents", () => { + expect(splitNullSeparatedGitOutput("\0src/a.ts\0src/b\nname.ts\0\0")).toEqual([ + "src/a.ts", + "src/b\nname.ts", + ]); + }); + + it("maps supported status records to ordered base and head sets", () => { + expect( + parseGitBaselineDiffPlan( + "A\0src/added.ts\0D\0src/deleted.ts\0M\0src/modified.ts\0T\0src/type.ts\0M\0src/modified.ts\0", + ), + ).toEqual({ + baseFiles: ["src/deleted.ts", "src/modified.ts", "src/type.ts"], + headFiles: ["src/added.ts", "src/modified.ts", "src/type.ts"], + untrackedFiles: [], + }); + }); + + it.each(["A\0", "R\0src/old.ts\0src/new.ts\0", "R100\0src/old.ts\0", "U\0src/conflict.ts\0"])( + "rejects unsupported or incomplete baseline records", + (output) => { + expect(parseGitBaselineDiffPlan(output)).toBeNull(); + }, + ); +}); diff --git a/packages/core/tests/git-revision-policy.test.ts b/packages/core/tests/git-revision-policy.test.ts new file mode 100644 index 0000000000..340c14634e --- /dev/null +++ b/packages/core/tests/git-revision-policy.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; +import { isSafeGitRevision, parseGitDiffRange } from "../src/services/git-revision-policy.js"; + +describe("git revision policy", () => { + it.each(["HEAD", "main", "origin/main", "refs/heads/feature_1", "release/1.2.3", "abc-123"])( + "accepts safe revision %s", + (revision) => { + expect(isSafeGitRevision(revision)).toBe(true); + }, + ); + + it.each([ + "", + "--upload-pack=payload", + ".main", + "main.", + "main..feature", + "main@{1}", + "feature branch", + "refs:heads:main", + "main~1", + "main^", + "origin\\main", + ])("rejects unsafe revision %s", (revision) => { + expect(isSafeGitRevision(revision)).toBe(false); + }); + + it("parses direct and symmetric ranges without resolving default endpoints", () => { + expect(parseGitDiffRange("main..feature")).toEqual({ + base: "main", + head: "feature", + symmetric: false, + }); + expect(parseGitDiffRange("main...feature")).toEqual({ + base: "main", + head: "feature", + symmetric: true, + }); + expect(parseGitDiffRange("..feature")).toEqual({ + base: "", + head: "feature", + symmetric: false, + }); + expect(parseGitDiffRange("main..")).toEqual({ + base: "main", + head: "", + symmetric: false, + }); + expect(parseGitDiffRange("...")).toEqual({ + base: "", + head: "", + symmetric: true, + }); + }); + + it("leaves malformed trailing operators for endpoint validation", () => { + expect(parseGitDiffRange("main..feature..extra")).toEqual({ + base: "main", + head: "feature..extra", + symmetric: false, + }); + expect(isSafeGitRevision("feature..extra")).toBe(false); + }); + + it("returns null when no range operator is present", () => { + expect(parseGitDiffRange("origin/main")).toBeNull(); + }); +}); diff --git a/packages/core/tests/package-aware-oxlint-config.test.ts b/packages/core/tests/package-aware-oxlint-config.test.ts new file mode 100644 index 0000000000..f88939e3a1 --- /dev/null +++ b/packages/core/tests/package-aware-oxlint-config.test.ts @@ -0,0 +1,269 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + clearProjectCache, + discoverProject, + getDiscoveredPackageGraph, +} from "../src/project-info/discover-project.js"; +import { createOxlintConfig } from "../src/runners/oxlint/config.js"; + +const FIXTURE_DIRECTORY = path.join(import.meta.dirname, "fixtures", "package-local-capabilities"); +const PLUGIN_PATH = path.resolve( + import.meta.dirname, + "../../oxlint-plugin-react-doctor/dist/index.js", +); +const TEMPORARY_DIRECTORY_PREFIX = "react-doctor-package-context-"; +const OXLINT_THREAD_COUNT = "1"; +const PACKAGE_SOURCE_PATHS = [ + "packages/legacy/src/app.tsx", + "packages/mobile/src/app.tsx", + "packages/modern/src/app.tsx", +]; +const temporaryDirectories: string[] = []; +const esmRequire = createRequire(import.meta.url); +const oxlintMainPath = esmRequire.resolve("oxlint"); +const oxlintBinaryPath = path.join( + path.resolve(path.dirname(oxlintMainPath), ".."), + "bin", + "oxlint", +); + +interface OxlintPackageContextDiagnostic { + readonly code: string; + readonly filename: string; +} + +interface OxlintPackageContextOutput { + readonly diagnostics: ReadonlyArray; +} + +afterEach(() => { + clearProjectCache(); + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +const runOxlint = ( + config: ReturnType, +): ReadonlyArray => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), TEMPORARY_DIRECTORY_PREFIX)); + temporaryDirectories.push(temporaryDirectory); + const configPath = path.join(temporaryDirectory, "oxlintrc.json"); + fs.writeFileSync(configPath, JSON.stringify(config), "utf8"); + const result = spawnSync( + process.execPath, + [ + oxlintBinaryPath, + "--config", + configPath, + "--format", + "json", + "--threads", + OXLINT_THREAD_COUNT, + ...PACKAGE_SOURCE_PATHS, + ], + { + cwd: FIXTURE_DIRECTORY, + encoding: "utf8", + }, + ); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + const output: OxlintPackageContextOutput = JSON.parse(result.stdout); + return output.diagnostics; +}; + +const diagnosticIdentity = (diagnostic: OxlintPackageContextDiagnostic): string => + `${ + path.isAbsolute(diagnostic.filename) + ? path.relative(FIXTURE_DIRECTORY, diagnostic.filename) + : diagnostic.filename + }:${diagnostic.code}`.replaceAll("\\", "/"); + +describe("package-aware oxlint config", () => { + it("threads stable package ownership without changing legacy activation by default", () => { + const project = discoverProject(FIXTURE_DIRECTORY); + const packageGraph = getDiscoveredPackageGraph(FIXTURE_DIRECTORY); + expect(packageGraph).not.toBeNull(); + + const legacyConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project, + }); + const packageAwareConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project, + packageGraph: packageGraph ?? undefined, + }); + const optedInConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project, + packageGraph: packageGraph ?? undefined, + enablePackageContext: true, + }); + + expect(packageAwareConfig).toEqual(legacyConfig); + expect(packageAwareConfig.settings["react-doctor"]).not.toHaveProperty( + "packageCapabilityGates", + ); + expect(packageAwareConfig.settings["react-doctor"]).not.toHaveProperty("packageContexts"); + expect(optedInConfig.rules).toEqual(legacyConfig.rules); + expect(optedInConfig.settings["react-doctor"].packageContextEnabled).toBe(true); + expect(optedInConfig.settings["react-doctor"].packageContexts).toEqual([ + { + relativeDirectory: "", + capabilities: ["unknown"], + dependencies: [], + }, + { + relativeDirectory: "packages/legacy", + capabilities: [ + "client-only", + "pre-es2023", + "react", + "react:17", + "react:18", + "target-blank-needs-explicit-protection", + "typescript", + "vite", + ], + dependencies: [ + { + name: "react", + section: "dependencies", + rawSpecifier: "^18.2.0", + resolvedSpecifier: "^18.2.0", + }, + { + name: "vite", + section: "dependencies", + rawSpecifier: "^5.4.0", + resolvedSpecifier: "^5.4.0", + }, + ], + }, + { + relativeDirectory: "packages/mobile", + capabilities: [ + "client-only", + "expo", + "expo:54", + "react", + "react-native", + "react:17", + "react:18", + "react:19", + ], + dependencies: [ + { + name: "expo", + section: "dependencies", + rawSpecifier: "^54.0.0", + resolvedSpecifier: "^54.0.0", + }, + { + name: "react", + section: "dependencies", + rawSpecifier: "^19.1.0", + resolvedSpecifier: "^19.1.0", + }, + { + name: "react-native", + section: "dependencies", + rawSpecifier: "^0.81.0", + resolvedSpecifier: "^0.81.0", + }, + ], + }, + { + relativeDirectory: "packages/modern", + capabilities: [ + "nextjs", + "nextjs:15", + "nextjs:16", + "nextjs:static-export", + "react", + "react-compiler", + "react:17", + "react:18", + "react:19", + "react:19.2", + "ssr", + "tanstack-query", + "typescript", + ], + dependencies: [ + { + name: "@tanstack/react-query", + section: "dependencies", + rawSpecifier: "^5.66.0", + resolvedSpecifier: "^5.66.0", + }, + { + name: "next", + section: "dependencies", + rawSpecifier: "catalog:modern", + resolvedSpecifier: "^16.0.0", + }, + { + name: "react", + section: "dependencies", + rawSpecifier: "catalog:modern", + resolvedSpecifier: "^19.2.0", + }, + ], + }, + ]); + }); + + it("registers the package capability union only for the private opt-in", () => { + const project = discoverProject(FIXTURE_DIRECTORY); + const packageGraph = getDiscoveredPackageGraph(FIXTURE_DIRECTORY); + const legacyConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project, + packageGraph: packageGraph ?? undefined, + }); + const gatedConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project, + packageGraph: packageGraph ?? undefined, + enablePackageCapabilityGates: true, + }); + + expect(legacyConfig.rules).not.toHaveProperty("react-doctor/no-default-props"); + expect(gatedConfig.rules).toHaveProperty("react-doctor/no-default-props"); + expect(gatedConfig.settings["react-doctor"].packageCapabilityGates).toBe(true); + }); + + it("gates actual diagnostics by the package owning each file", () => { + const project = discoverProject(FIXTURE_DIRECTORY); + const packageGraph = getDiscoveredPackageGraph(FIXTURE_DIRECTORY); + expect(packageGraph).not.toBeNull(); + const legacyConfig = createOxlintConfig({ + pluginPath: PLUGIN_PATH, + project, + packageGraph: packageGraph ?? undefined, + }); + const gatedConfig = createOxlintConfig({ + pluginPath: PLUGIN_PATH, + project, + packageGraph: packageGraph ?? undefined, + enablePackageCapabilityGates: true, + }); + + expect(runOxlint(legacyConfig).map(diagnosticIdentity).sort()).toEqual([ + "packages/legacy/src/app.tsx:react-doctor(no-ref-callback-cleanup-before-react-19)", + ]); + expect(runOxlint(gatedConfig).map(diagnosticIdentity).sort()).toEqual([ + "packages/legacy/src/app.tsx:react-doctor(no-ref-callback-cleanup-before-react-19)", + "packages/mobile/src/app.tsx:react-doctor(no-default-props)", + "packages/modern/src/app.tsx:react-doctor(no-default-props)", + ]); + }); +}); diff --git a/packages/core/tests/package-graph.test.ts b/packages/core/tests/package-graph.test.ts new file mode 100644 index 0000000000..632bfc45bb --- /dev/null +++ b/packages/core/tests/package-graph.test.ts @@ -0,0 +1,561 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { PackageJson } from "../src/types/index.js"; +import { collectWorkspaceFacts } from "../src/project-info/collect-project-facts.js"; +import { buildPackageGraph } from "../src/project-info/package-graph.js"; +import type { PackageGraph } from "../src/project-info/package-graph.js"; +import { readPackageJson } from "../src/project-info/package-json.js"; + +const FIXTURES_DIRECTORY = path.join(import.meta.dirname, "fixtures"); +const temporaryDirectories: string[] = []; + +interface TemporaryWorkspacePackage { + readonly relativeDirectory: string; + readonly packageJson: PackageJson; +} + +interface BuildTemporaryGraphOptions { + readonly rootPackageJson: PackageJson; + readonly workspacePackages?: ReadonlyArray; +} + +const buildFixtureGraph = (fixtureName: string): PackageGraph => { + const rootDirectory = path.join(FIXTURES_DIRECTORY, fixtureName); + const rootPackageJson = readPackageJson(path.join(rootDirectory, "package.json")); + return buildPackageGraph(rootDirectory, rootPackageJson); +}; + +const buildTemporaryGraph = ({ + rootPackageJson, + workspacePackages = [], +}: BuildTemporaryGraphOptions): PackageGraph => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-package-graph-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(rootPackageJson)); + + for (const workspacePackage of workspacePackages) { + const workspaceDirectory = path.join(rootDirectory, workspacePackage.relativeDirectory); + fs.mkdirSync(workspaceDirectory, { recursive: true }); + fs.writeFileSync( + path.join(workspaceDirectory, "package.json"), + JSON.stringify(workspacePackage.packageJson), + ); + } + + return buildPackageGraph(rootDirectory, rootPackageJson); +}; + +afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +describe("package graph", () => { + it("derives exact dependency and framework capabilities for each owning package", () => { + const graph = buildFixtureGraph("package-local-capabilities"); + const legacyDirectory = path.join(graph.rootDirectory, "packages", "legacy"); + const mobileDirectory = path.join(graph.rootDirectory, "packages", "mobile"); + const modernDirectory = path.join(graph.rootDirectory, "packages", "modern"); + + expect(graph.getCapabilities(legacyDirectory)).toEqual( + new Set([ + "vite", + "react", + "react:17", + "react:18", + "client-only", + "typescript", + "pre-es2023", + "target-blank-needs-explicit-protection", + ]), + ); + expect(graph.getCapabilities(mobileDirectory)).toEqual( + new Set([ + "expo", + "react", + "react-native", + "expo:54", + "react:17", + "react:18", + "react:19", + "client-only", + ]), + ); + expect(graph.getCapabilities(modernDirectory)).toEqual( + new Set([ + "nextjs", + "react", + "ssr", + "nextjs:static-export", + "nextjs:15", + "nextjs:16", + "react:17", + "react:18", + "react:19", + "react:19.2", + "react-compiler", + "tanstack-query", + "typescript", + ]), + ); + expect(graph.getCapabilitiesForFile(path.join(modernDirectory, "src", "app.tsx"))).toBe( + graph.getCapabilities(modernDirectory), + ); + expect(graph.getCapabilities(path.join(graph.rootDirectory, "packages", "missing"))).toBeNull(); + expect(graph.getCapabilitiesForFile(path.join(FIXTURES_DIRECTORY, "outside.ts"))).toBeNull(); + }); + + it("keeps workspace package order stable across filesystem enumeration order", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "stable-order", + workspaces: ["packages/*"], + }, + workspacePackages: [ + { + relativeDirectory: "packages/z-last", + packageJson: { name: "z-last" }, + }, + { + relativeDirectory: "packages/a-first", + packageJson: { name: "a-first" }, + }, + ], + }); + + expect(graph.packages.map((packageNode) => packageNode.name)).toEqual([ + "stable-order", + "a-first", + "z-last", + ]); + }); + + it("does not invent version capabilities for unresolved catalog or workspace declarations", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "unresolved-capabilities", + dependencies: { + next: "catalog:missing", + react: "workspace:*", + }, + }, + }); + + expect(graph.getCapabilities(graph.rootDirectory)).toEqual( + new Set(["nextjs", "react", "server-actions", "ssr"]), + ); + }); + + it.each([ + ["react dependency", { dependencies: { react: "^19.0.0" } }, true], + ["React Native dev dependency", { devDependencies: { "react-native": "^0.81.0" } }, true], + ["Next peer dependency", { peerDependencies: { next: "^15.0.0" } }, true], + ["Preact dependency", { dependencies: { preact: "^10.0.0" } }, true], + ["React optional dependency", { optionalDependencies: { react: "^19.0.0" } }, false], + ["non-React package", { dependencies: { vue: "^3.0.0" } }, false], + ])("pins the %s project classification", (_caseName, dependencies, expected) => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "classification", + ...dependencies, + }, + }); + + expect(graph.rootPackage.hasReactDependency).toBe(expected); + }); + + it("retains package boundaries and finds the deepest owning package", () => { + const graph = buildFixtureGraph("nested-workspaces"); + const clientDirectory = path.join( + FIXTURES_DIRECTORY, + "nested-workspaces", + "apps", + "my-app", + "ClientApp", + ); + const packageDirectory = path.join(FIXTURES_DIRECTORY, "nested-workspaces", "packages", "ui"); + + expect(graph.rootPackage).toBe(graph.packages[0]); + expect(graph.workspacePatterns).toEqual(["apps/*/ClientApp", "packages/*"]); + expect( + graph.packages.map((packageNode) => ({ + directory: packageNode.directory, + manifestPath: packageNode.manifestPath, + name: packageNode.name, + isRoot: packageNode.isRoot, + })), + ).toEqual([ + { + directory: graph.rootDirectory, + manifestPath: path.join(graph.rootDirectory, "package.json"), + name: "nested-workspaces-fixture", + isRoot: true, + }, + { + directory: clientDirectory, + manifestPath: path.join(clientDirectory, "package.json"), + name: "my-app-client", + isRoot: false, + }, + { + directory: packageDirectory, + manifestPath: path.join(packageDirectory, "package.json"), + name: "ui", + isRoot: false, + }, + ]); + expect(graph.findOwningPackage(path.join(clientDirectory, "src", "App.tsx"))?.name).toBe( + "my-app-client", + ); + expect(graph.findOwningPackage(path.join(graph.rootDirectory, "README.md"))?.name).toBe( + "nested-workspaces-fixture", + ); + expect(graph.findOwningPackage(path.join(FIXTURES_DIRECTORY, "outside.ts"))).toBeNull(); + }); + + it("finds the deepest owning package that satisfies a package predicate", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "predicate-owner", + workspaces: ["packages/web", "packages/web/tools/generator"], + }, + workspacePackages: [ + { + relativeDirectory: "packages/web", + packageJson: { + name: "web", + dependencies: { react: "^19.0.0" }, + }, + }, + { + relativeDirectory: "packages/web/tools/generator", + packageJson: { + name: "generator", + }, + }, + ], + }); + const reactPackageDirectory = path.join(graph.rootDirectory, "packages", "web"); + const nestedToolDirectory = path.join(reactPackageDirectory, "tools", "generator"); + const generatedFilePath = path.join(nestedToolDirectory, "src", "index.ts"); + + expect(graph.findOwningPackage(generatedFilePath)?.name).toBe("generator"); + expect( + graph.findOwningPackage(generatedFilePath, (packageNode) => packageNode.hasReactDependency) + ?.name, + ).toBe("web"); + }); + + it("retains every dependency section in declaration precedence order", () => { + const rootDirectory = path.join(FIXTURES_DIRECTORY, "dependency-sections"); + const packageJson: PackageJson = { + name: "dependency-sections", + dependencies: { react: "19.1.0" }, + devDependencies: { react: "19.2.0" }, + peerDependencies: { react: "^18.3.0" }, + optionalDependencies: { react: "^17.0.0" }, + }; + const graph = buildPackageGraph(rootDirectory, packageJson); + + expect(graph.getDependency(rootDirectory, "react")).toMatchObject({ + section: "dependencies", + rawSpecifier: "19.1.0", + resolvedSpecifier: "19.1.0", + resolutionSource: "manifest", + resolutionSourceDirectory: rootDirectory, + }); + expect( + graph.getDependency(rootDirectory, "react", ["peerDependencies", "devDependencies"]), + ).toMatchObject({ + section: "peerDependencies", + rawSpecifier: "^18.3.0", + }); + expect(graph.getDependencyDeclarations(rootDirectory, "react")).toEqual([ + { + declaringPackageDirectory: rootDirectory, + packageName: "react", + section: "dependencies", + rawSpecifier: "19.1.0", + resolvedSpecifier: "19.1.0", + catalogReference: null, + resolutionSource: "manifest", + resolutionSourceDirectory: rootDirectory, + workspaceTargetPackageDirectory: null, + }, + { + declaringPackageDirectory: rootDirectory, + packageName: "react", + section: "devDependencies", + rawSpecifier: "19.2.0", + resolvedSpecifier: "19.2.0", + catalogReference: null, + resolutionSource: "manifest", + resolutionSourceDirectory: rootDirectory, + workspaceTargetPackageDirectory: null, + }, + { + declaringPackageDirectory: rootDirectory, + packageName: "react", + section: "peerDependencies", + rawSpecifier: "^18.3.0", + resolvedSpecifier: "^18.3.0", + catalogReference: null, + resolutionSource: "manifest", + resolutionSourceDirectory: rootDirectory, + workspaceTargetPackageDirectory: null, + }, + { + declaringPackageDirectory: rootDirectory, + packageName: "react", + section: "optionalDependencies", + rawSpecifier: "^17.0.0", + resolvedSpecifier: "^17.0.0", + catalogReference: null, + resolutionSource: "manifest", + resolutionSourceDirectory: rootDirectory, + workspaceTargetPackageDirectory: null, + }, + ]); + }); + + it("resolves workspace protocol declarations into package edges", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "workspace-root", + workspaces: ["packages/*"], + }, + workspacePackages: [ + { + relativeDirectory: "packages/app", + packageJson: { + name: "@fixture/app", + dependencies: { + "@fixture/shared": "workspace:*", + "@fixture/unversioned": "workspace:^", + }, + }, + }, + { + relativeDirectory: "packages/shared", + packageJson: { name: "@fixture/shared", version: "1.4.2" }, + }, + { + relativeDirectory: "packages/unversioned", + packageJson: { name: "@fixture/unversioned" }, + }, + ], + }); + const appDirectory = path.join(graph.rootDirectory, "packages", "app"); + const sharedDirectory = path.join(graph.rootDirectory, "packages", "shared"); + const unversionedDirectory = path.join(graph.rootDirectory, "packages", "unversioned"); + + expect(graph.getDependency(appDirectory, "@fixture/shared")).toMatchObject({ + rawSpecifier: "workspace:*", + resolvedSpecifier: "workspace:*", + workspaceTargetPackageDirectory: sharedDirectory, + }); + expect(graph.workspaceEdges).toEqual([ + { + sourcePackageDirectory: appDirectory, + targetPackageDirectory: sharedDirectory, + targetPackageVersion: "1.4.2", + dependencyName: "@fixture/shared", + section: "dependencies", + workspaceSpecifier: "workspace:*", + }, + { + sourcePackageDirectory: appDirectory, + targetPackageDirectory: unversionedDirectory, + targetPackageVersion: null, + dependencyName: "@fixture/unversioned", + section: "dependencies", + workspaceSpecifier: "workspace:^", + }, + ]); + expect(graph.hasDependency(appDirectory, "@fixture/shared")).toBe(true); + expect(graph.hasDependency(appDirectory, "@fixture/shared", ">=1 <2")).toBe(true); + expect(graph.hasDependency(appDirectory, "@fixture/shared", ">=2")).toBe(false); + expect(graph.hasDependency(appDirectory, "@fixture/unversioned")).toBe(true); + expect(graph.hasDependency(appDirectory, "@fixture/unversioned", ">=1")).toBe(false); + expect(graph.hasDependency(sharedDirectory, "@fixture/shared")).toBe(false); + }); + + it("uses declaration precedence and rejects invalid range evidence", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "workspace-root", + workspaces: ["packages/*"], + }, + workspacePackages: [ + { + relativeDirectory: "packages/app", + packageJson: { + name: "@fixture/app", + dependencies: { + "@fixture/shared": "workspace:*", + "invalid-tag": "latest", + "invalid-source": "git+https://example.com/repository.git", + }, + devDependencies: { "@fixture/shared": "^2.0.0" }, + peerDependencies: { "@fixture/shared": "workspace:^1.0.0" }, + }, + }, + { + relativeDirectory: "packages/shared", + packageJson: { name: "@fixture/shared", version: "1.5.0" }, + }, + ], + }); + const appDirectory = path.join(graph.rootDirectory, "packages", "app"); + + expect( + graph + .getDependencyDeclarations(appDirectory, "@fixture/shared") + .map((declaration) => declaration.section), + ).toEqual(["dependencies", "devDependencies", "peerDependencies"]); + expect(graph.workspaceEdges).toHaveLength(2); + expect(graph.hasDependency(appDirectory, "@fixture/shared", ">=1 <2")).toBe(true); + expect(graph.hasDependency(appDirectory, "@fixture/shared", ">=2")).toBe(false); + expect(graph.hasDependency(appDirectory, "invalid-tag")).toBe(true); + expect(graph.hasDependency(appDirectory, "invalid-tag", ">=1")).toBe(false); + expect(graph.hasDependency(appDirectory, "invalid-source", ">=1")).toBe(false); + expect(graph.hasDependency(appDirectory, "@fixture/shared", "not-a-range")).toBe(false); + expect(graph.hasDependency(appDirectory, "@fixture/shared", "")).toBe(false); + expect(graph.hasDependency(appDirectory, "missing")).toBe(false); + }); + + it("retains default pnpm catalog provenance", () => { + const graph = buildFixtureGraph("pnpm-catalog-workspace"); + const workspaceDirectory = path.join(graph.rootDirectory, "packages", "ui"); + + expect(graph.getDependency(workspaceDirectory, "react")).toEqual({ + declaringPackageDirectory: workspaceDirectory, + packageName: "react", + section: "dependencies", + rawSpecifier: "catalog:", + resolvedSpecifier: "^19.0.0", + catalogReference: null, + resolutionSource: "workspace-root-catalog", + resolutionSourceDirectory: graph.rootDirectory, + workspaceTargetPackageDirectory: null, + }); + expect(graph.hasDependency(workspaceDirectory, "react", ">=19 <20")).toBe(true); + expect(graph.hasDependency(workspaceDirectory, "react", ">=20")).toBe(false); + }); + + it("preserves unresolved catalog declarations without treating them as semver", () => { + const graph = buildTemporaryGraph({ + rootPackageJson: { + name: "unresolved-catalog", + dependencies: { react: "catalog:missing" }, + }, + }); + + expect(graph.getDependency(graph.rootDirectory, "react")).toMatchObject({ + rawSpecifier: "catalog:missing", + resolvedSpecifier: "catalog:missing", + catalogReference: "missing", + resolutionSource: "unresolved-catalog", + workspaceTargetPackageDirectory: null, + }); + expect(graph.hasDependency(graph.rootDirectory, "react")).toBe(true); + expect(graph.hasDependency(graph.rootDirectory, "react", ">=19")).toBe(false); + }); + + it("retains named pnpm and grouped Bun catalog references", () => { + const pnpmGraph = buildFixtureGraph("pnpm-named-catalog"); + const pnpmWorkspaceDirectory = path.join(pnpmGraph.rootDirectory, "packages", "app"); + const bunGraph = buildFixtureGraph("bun-multiple-grouped-catalogs"); + const bunWorkspaceDirectory = path.join(bunGraph.rootDirectory, "apps", "web"); + + expect(pnpmGraph.getDependency(pnpmWorkspaceDirectory, "react")).toMatchObject({ + rawSpecifier: "catalog:react_v19_current", + resolvedSpecifier: "^19.0.0", + catalogReference: "react_v19_current", + resolutionSource: "workspace-root-catalog", + }); + expect(bunGraph.getDependency(bunWorkspaceDirectory, "react")).toMatchObject({ + rawSpecifier: "catalog:react19", + resolvedSpecifier: "19.2.0", + catalogReference: "react19", + resolutionSource: "workspace-root-catalog", + }); + }); + + it("records enclosing-monorepo provenance for a leaf graph", () => { + const monorepoDirectory = path.join(FIXTURES_DIRECTORY, "pnpm-catalog-workspace"); + const leafDirectory = path.join(monorepoDirectory, "packages", "ui"); + const leafPackageJson = readPackageJson(path.join(leafDirectory, "package.json")); + const graph = buildPackageGraph(leafDirectory, leafPackageJson); + + expect(graph.getDependency(leafDirectory, "react")).toMatchObject({ + rawSpecifier: "catalog:", + resolvedSpecifier: "^19.0.0", + resolutionSource: "monorepo-root-catalog", + resolutionSourceDirectory: monorepoDirectory, + }); + }); + + it("feeds enclosing monorepo catalog values into nested workspace aggregation", () => { + const monorepoDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-nested-package-graph-"), + ); + temporaryDirectories.push(monorepoDirectory); + const leafDirectory = path.join(monorepoDirectory, "packages", "app"); + const nestedWorkspaceDirectory = path.join(leafDirectory, "modules", "ui"); + fs.mkdirSync(nestedWorkspaceDirectory, { recursive: true }); + fs.writeFileSync( + path.join(monorepoDirectory, "package.json"), + JSON.stringify({ + name: "monorepo", + private: true, + workspaces: ["packages/*"], + catalog: { react: "^19.0.0" }, + }), + ); + const leafPackageJson: PackageJson = { + name: "app", + private: true, + workspaces: ["modules/*"], + }; + fs.writeFileSync(path.join(leafDirectory, "package.json"), JSON.stringify(leafPackageJson)); + fs.writeFileSync( + path.join(nestedWorkspaceDirectory, "package.json"), + JSON.stringify({ + name: "ui", + dependencies: { react: "catalog:" }, + }), + ); + + const graph = buildPackageGraph(leafDirectory, leafPackageJson); + + expect(graph.getDependency(nestedWorkspaceDirectory, "react")).toMatchObject({ + resolvedSpecifier: "^19.0.0", + resolutionSource: "monorepo-root-catalog", + resolutionSourceDirectory: monorepoDirectory, + }); + expect( + collectWorkspaceFacts(graph, { + collectReactGroup: true, + }).reactVersion, + ).toBe("^19.0.0"); + }); + + it("feeds legacy workspace aggregation without changing catalog facts", () => { + const graph = buildFixtureGraph("pnpm-named-catalog"); + + expect( + collectWorkspaceFacts(graph, { + collectReactGroup: true, + }), + ).toMatchObject({ + reactVersion: "^19.0.0", + framework: "unknown", + hasReactNativeAwarePackage: false, + hasReanimatedAwarePackage: false, + }); + }); +}); diff --git a/packages/core/tests/project-info-package-graph-parity.test.ts b/packages/core/tests/project-info-package-graph-parity.test.ts new file mode 100644 index 0000000000..996e3fdb20 --- /dev/null +++ b/packages/core/tests/project-info-package-graph-parity.test.ts @@ -0,0 +1,85 @@ +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 { buildCapabilities } from "../src/project-info/capabilities.js"; +import { clearProjectCache, discoverProject } from "../src/project-info/discover-project.js"; +import type { PackageJson } from "../src/types/index.js"; + +const FIXTURES_DIRECTORY = path.join(import.meta.dirname, "fixtures"); +const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-project-info-parity-"), +); + +interface ProjectFixture { + readonly name: string; + readonly directory: string; +} + +interface CreatePackageFixtureOptions { + readonly name: string; + readonly packageJson: PackageJson; +} + +const createPackageFixture = ({ + name, + packageJson, +}: CreatePackageFixtureOptions): ProjectFixture => { + const directory = path.join(temporaryDirectory, name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify(packageJson)); + return { name, directory }; +}; + +const fixtures: ReadonlyArray = [ + ...[ + "basic-react", + "component-library", + "nextjs-app", + "tanstack-start-app", + "mixed-rn-web-monorepo", + "pnpm-catalog-workspace", + "pnpm-named-catalog", + "bun-multiple-grouped-catalogs", + "package-local-capabilities", + ].map((name) => ({ + name, + directory: path.join(FIXTURES_DIRECTORY, name), + })), + createPackageFixture({ + name: "react-17", + packageJson: { + name: "react-17", + dependencies: { react: "^17.0.2", "react-dom": "^17.0.2" }, + }, + }), + createPackageFixture({ + name: "non-react", + packageJson: { + name: "non-react", + dependencies: { typescript: "^5.9.0", zod: "^4.0.0" }, + }, + }), +]; + +afterAll(() => { + clearProjectCache(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +}); + +describe("ProjectInfo PackageGraph parity", () => { + for (const fixture of fixtures) { + it(`preserves ${fixture.name}`, () => { + clearProjectCache(); + const projectInfo = discoverProject(fixture.directory); + + expect({ + projectInfo: { + ...projectInfo, + rootDirectory: ``, + }, + capabilities: [...buildCapabilities(projectInfo)].toSorted(), + }).toMatchSnapshot(); + }); + } +}); diff --git a/packages/core/tests/resolve-inspect-scan-settings.test.ts b/packages/core/tests/resolve-inspect-scan-settings.test.ts new file mode 100644 index 0000000000..fe78ea087e --- /dev/null +++ b/packages/core/tests/resolve-inspect-scan-settings.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveInspectScanSettings } from "../src/resolve-inspect-scan-settings.js"; +import type { InspectInput } from "../src/run-inspect.js"; + +const buildInput = (overrides: Partial = {}): InspectInput => ({ + directory: "/project", + includePaths: [], + customRulesOnly: false, + respectInlineDisables: true, + adoptExistingLintConfig: false, + ignoredTags: new Set(), + runDeadCode: true, + isCi: false, + ...overrides, +}); + +describe("resolveInspectScanSettings", () => { + it("returns the exact full-scan defaults", () => { + expect( + resolveInspectScanSettings({ + input: buildInput(), + rootDirectory: "/project", + userConfig: null, + }), + ).toEqual({ + lintIncludePaths: undefined, + isDiffMode: false, + showWarnings: true, + shouldCollectFallbackScannedFilePaths: false, + shouldRunSupplyChain: true, + }); + }); + + it("filters explicit paths and disables whole-project checks in diff mode", () => { + expect( + resolveInspectScanSettings({ + input: buildInput({ + includePaths: ["src/app.tsx", "README.md"], + }), + rootDirectory: "/project", + userConfig: { warnings: false }, + }), + ).toEqual({ + lintIncludePaths: ["src/app.tsx"], + isDiffMode: true, + showWarnings: false, + shouldCollectFallbackScannedFilePaths: false, + shouldRunSupplyChain: false, + }); + }); + + it("preserves exact editor paths through a fresh array", () => { + const includePaths = ["src/app.tsx", "README.md"]; + const settings = resolveInspectScanSettings({ + input: buildInput({ + includePaths, + skipExplicitIncludePathFilter: true, + }), + rootDirectory: "/project", + userConfig: null, + }); + + expect(settings.lintIncludePaths).toEqual(includePaths); + expect(settings.lintIncludePaths).not.toBe(includePaths); + }); + + it("preserves input precedence and diff manifest supply-chain behavior", () => { + expect( + resolveInspectScanSettings({ + input: buildInput({ + includePaths: ["src/app.tsx"], + warnings: true, + suppressScanSummary: true, + supplyChainManifestChanged: true, + }), + rootDirectory: "/project", + userConfig: { warnings: false }, + }), + ).toMatchObject({ + showWarnings: true, + shouldCollectFallbackScannedFilePaths: true, + shouldRunSupplyChain: true, + }); + }); + + it("keeps empty editor selections on the full-scan fallback path", () => { + expect( + resolveInspectScanSettings({ + input: buildInput({ + skipExplicitIncludePathFilter: true, + }), + rootDirectory: "/project", + userConfig: null, + }).lintIncludePaths, + ).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/resolve-scan-completion.test.ts b/packages/core/tests/resolve-scan-completion.test.ts new file mode 100644 index 0000000000..7d8279c21a --- /dev/null +++ b/packages/core/tests/resolve-scan-completion.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveScanCompletion } from "../src/utils/resolve-scan-completion.js"; + +const successfulDeadCode = { + didFail: false, + reason: null, +}; + +describe("resolveScanCompletion", () => { + it("preserves the legacy property order and successful progress text", () => { + const completion = resolveScanCompletion({ + lintDidFail: false, + deadCodeFailure: successfulDeadCode, + suppressScanSummary: false, + scannedFilesLabel: "2 files", + scanElapsedMilliseconds: 1_250, + workerCountSuffix: " [~4 workers]", + }); + + expect(JSON.stringify(completion)).toBe( + '{"deadCodeFailure":{"didFail":false,"reason":null},"shouldComputeScore":true,"progress":{"action":"succeed","text":"Scanned 2 files in 1.3s [~4 workers]"}}', + ); + expect(completion.deadCodeFailure).toBe(successfulDeadCode); + }); + + it("preserves singular labels, zero duration, and an empty worker suffix", () => { + expect( + resolveScanCompletion({ + lintDidFail: false, + deadCodeFailure: successfulDeadCode, + suppressScanSummary: false, + scannedFilesLabel: "1 file", + scanElapsedMilliseconds: 0, + workerCountSuffix: "", + }).progress, + ).toEqual({ + action: "succeed", + text: "Scanned 1 file in 0.0s", + }); + }); + + it("stops successful scans whose caller owns the summary", () => { + expect( + resolveScanCompletion({ + lintDidFail: false, + deadCodeFailure: successfulDeadCode, + suppressScanSummary: true, + scannedFilesLabel: "0 files", + scanElapsedMilliseconds: 999, + workerCountSuffix: "", + }), + ).toEqual({ + deadCodeFailure: successfulDeadCode, + shouldComputeScore: true, + progress: { action: "stop", text: null }, + }); + }); + + it("retains a deadline-derived dead-code failure and fails progress without scoring", () => { + const deadlineFailure = { + didFail: true, + reason: "Dead-code analysis skipped — max scan duration reached.", + }; + + const completion = resolveScanCompletion({ + lintDidFail: false, + deadCodeFailure: deadlineFailure, + suppressScanSummary: true, + scannedFilesLabel: "3 files", + scanElapsedMilliseconds: 5_000, + workerCountSuffix: " [~2 workers]", + }); + + expect(completion).toEqual({ + deadCodeFailure: deadlineFailure, + shouldComputeScore: false, + progress: { + action: "fail", + text: "Scanning failed (dead-code analysis, non-fatal).", + }, + }); + expect(completion.deadCodeFailure).toBe(deadlineFailure); + }); + + it("lets lint failure override dead-code failure and leaves progress unchanged", () => { + expect( + resolveScanCompletion({ + lintDidFail: true, + deadCodeFailure: { + didFail: true, + reason: "Dead-code worker crashed.", + }, + suppressScanSummary: false, + scannedFilesLabel: "9 files", + scanElapsedMilliseconds: 750, + workerCountSuffix: "", + }), + ).toEqual({ + deadCodeFailure: { + didFail: false, + reason: null, + }, + shouldComputeScore: false, + progress: { + action: "unchanged", + text: null, + }, + }); + }); + + it("does not normalize a non-failing dead-code state when lint succeeds", () => { + const recordedState = { + didFail: false, + reason: "preserved nullable field value", + }; + + const completion = resolveScanCompletion({ + lintDidFail: false, + deadCodeFailure: recordedState, + suppressScanSummary: true, + scannedFilesLabel: "4 files", + scanElapsedMilliseconds: 1_000, + workerCountSuffix: "", + }); + + expect(completion.deadCodeFailure).toBe(recordedState); + expect(completion.deadCodeFailure.reason).toBe("preserved nullable field value"); + }); +}); diff --git a/packages/core/tests/resolve-scan-file-coverage.test.ts b/packages/core/tests/resolve-scan-file-coverage.test.ts new file mode 100644 index 0000000000..28d27d39d4 --- /dev/null +++ b/packages/core/tests/resolve-scan-file-coverage.test.ts @@ -0,0 +1,101 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveScanFileCoverage } from "../src/utils/resolve-scan-file-coverage.js"; + +const ROOT_DIRECTORY = path.resolve("/workspace/project"); + +describe("resolveScanFileCoverage", () => { + it("normalizes and deduplicates linter coverage", () => { + expect( + resolveScanFileCoverage({ + rootDirectory: ROOT_DIRECTORY, + lintFileCoverage: { + candidateFiles: [ + path.join(ROOT_DIRECTORY, "src", "second.ts"), + path.join(ROOT_DIRECTORY, "src", "first.ts"), + path.join(ROOT_DIRECTORY, "src", "second.ts"), + ], + analyzedFiles: [ + path.join(ROOT_DIRECTORY, "src", "second.ts"), + path.join(ROOT_DIRECTORY, "src", "first.ts"), + path.join(ROOT_DIRECTORY, "src", "second.ts"), + ], + }, + lastReportedTotalFileCount: 10, + lintIncludePathCount: 8, + discoveredSourceFileCount: 6, + includeScannedFilePaths: true, + fallbackScannedFilePaths: [], + }), + ).toEqual({ + analyzedFiles: ["src/first.ts", "src/second.ts"], + scannedFileCount: 2, + scannedFilePaths: [ + path.join(ROOT_DIRECTORY, "src", "second.ts"), + path.join(ROOT_DIRECTORY, "src", "first.ts"), + ], + }); + }); + + it("preserves the existing file-count fallback order", () => { + const baseOptions = { + rootDirectory: ROOT_DIRECTORY, + lintFileCoverage: null, + includeScannedFilePaths: false, + fallbackScannedFilePaths: [], + }; + + expect( + resolveScanFileCoverage({ + ...baseOptions, + lastReportedTotalFileCount: 5, + lintIncludePathCount: 4, + discoveredSourceFileCount: 3, + }).scannedFileCount, + ).toBe(5); + expect( + resolveScanFileCoverage({ + ...baseOptions, + lastReportedTotalFileCount: 0, + lintIncludePathCount: 4, + discoveredSourceFileCount: 3, + }).scannedFileCount, + ).toBe(4); + expect( + resolveScanFileCoverage({ + ...baseOptions, + lastReportedTotalFileCount: 0, + lintIncludePathCount: null, + discoveredSourceFileCount: 3, + }).scannedFileCount, + ).toBe(3); + }); + + it("uses fallback paths only when coverage is unavailable and paths are requested", () => { + const fallbackScannedFilePaths = [path.join(ROOT_DIRECTORY, "src", "fallback.ts")]; + + expect( + resolveScanFileCoverage({ + rootDirectory: ROOT_DIRECTORY, + lintFileCoverage: null, + lastReportedTotalFileCount: 1, + lintIncludePathCount: null, + discoveredSourceFileCount: 1, + includeScannedFilePaths: true, + fallbackScannedFilePaths, + }).scannedFilePaths, + ).toEqual(fallbackScannedFilePaths); + + expect( + resolveScanFileCoverage({ + rootDirectory: ROOT_DIRECTORY, + lintFileCoverage: null, + lastReportedTotalFileCount: 1, + lintIncludePathCount: null, + discoveredSourceFileCount: 1, + includeScannedFilePaths: false, + fallbackScannedFilePaths, + }).scannedFilePaths, + ).toEqual([]); + }); +}); diff --git a/packages/core/tests/run-inspect-security-scan.test.ts b/packages/core/tests/run-inspect-security-scan.test.ts index afa07e1904..d6cb6a28eb 100644 --- a/packages/core/tests/run-inspect-security-scan.test.ts +++ b/packages/core/tests/run-inspect-security-scan.test.ts @@ -21,6 +21,7 @@ import { Git } from "../src/services/git.js"; import { LintPartialFailures, Linter } from "../src/services/linter.js"; import { Progress } from "../src/services/progress.js"; import { Project } from "../src/services/project.js"; +import { ProjectChecks } from "../src/services/project-checks.js"; import { Reporter } from "../src/services/reporter.js"; import { Score } from "../src/services/score.js"; import { SupplyChain } from "../src/services/supply-chain.js"; @@ -79,6 +80,7 @@ const baseInput: InspectInput = { const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([lintDiagnostic]), diff --git a/packages/core/tests/run-inspect.test.ts b/packages/core/tests/run-inspect.test.ts index d77ec66977..9417418064 100644 --- a/packages/core/tests/run-inspect.test.ts +++ b/packages/core/tests/run-inspect.test.ts @@ -1,13 +1,16 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { afterAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, describe, expect, it, vi } from "vite-plus/test"; import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "@react-doctor/core"; +import type { ScoreRequestMetadata } from "../src/calculate-score.js"; import { DeadCodeAnalysisFailed, GitInvocationFailed, @@ -27,9 +30,10 @@ import { Config } from "../src/services/config.js"; import { DeadCode } from "../src/services/dead-code.js"; import { Files } from "../src/services/files.js"; import { Git } from "../src/services/git.js"; -import { LintPartialFailures, Linter } from "../src/services/linter.js"; +import { type LintInput, LintPartialFailures, Linter } from "../src/services/linter.js"; import { Progress, ProgressCapture } from "../src/services/progress.js"; import { Project } from "../src/services/project.js"; +import { ProjectChecks } from "../src/services/project-checks.js"; import { Reporter, ReporterCapture } from "../src/services/reporter.js"; import { Score } from "../src/services/score.js"; import { SupplyChain } from "../src/services/supply-chain.js"; @@ -117,14 +121,31 @@ const supplyChainDiagnostic: Diagnostic = { category: "Security", }; +const projectCheckDiagnostic: Diagnostic = { + filePath: "package.json", + plugin: "react-doctor", + rule: "synthetic-project-check", + severity: "warning", + message: "Synthetic project check", + help: "Fix the project configuration.", + line: 0, + column: 0, + category: "Maintainability", +}; + const layersOf = (config: { diagnostics?: ReadonlyArray; + projectChecks?: ReadonlyArray; + projectChecksLayer?: Layer.Layer; linter?: Layer.Layer; deadCode?: ReadonlyArray; supplyChain?: ReadonlyArray; githubViewerPermission?: string | null; + gitLayer?: Layer.Layer; reactDoctorConfig?: ReactDoctorConfig | null; + configSourceDirectory?: string | null; scoreLayer?: Layer.Layer; + reporterLayer?: Layer.Layer; // Pins the dead-code/lint overlap mode. Defaults to "off" so emit-order // assertions stay deterministic regardless of the test box's free memory // (the "auto" gate reads `os.freemem()`); overlap tests opt into "on". @@ -132,25 +153,27 @@ const layersOf = (config: { }) => Layer.mergeAll( Project.layerOf(sampleProject), + config.projectChecksLayer ?? ProjectChecks.layerOf(config.projectChecks ?? []), Config.layerOf({ config: config.reactDoctorConfig ?? null, resolvedDirectory: "/repo", - configSourceDirectory: null, + configSourceDirectory: config.configSourceDirectory ?? null, }), Files.layerInMemory(new Map()), config.linter ?? Linter.layerOf(config.diagnostics ?? []), LintPartialFailures.layerLive, DeadCode.layerOf(config.deadCode ?? []), - Git.layerOf({ - headSha: "abc123", - githubRepo: "millionco/sample-app", - defaultBranch: "main", - githubViewerPermission: config.githubViewerPermission, - }), + config.gitLayer ?? + Git.layerOf({ + headSha: "abc123", + githubRepo: "millionco/sample-app", + defaultBranch: "main", + githubViewerPermission: config.githubViewerPermission, + }), config.scoreLayer ?? Score.layerOf({ score: 85, label: "Good" }), SupplyChain.layerOf(config.supplyChain ?? []), Progress.layerNoop, - Reporter.layerCapture, + config.reporterLayer ?? Reporter.layerCapture, Layer.succeed(DeadCodeOverlap, config.deadCodeOverlap ?? "off"), ); @@ -164,6 +187,7 @@ describe("runInspect — phase timeouts & overall deadline", () => { }) => Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), overrides.linter, @@ -282,9 +306,11 @@ const overlapLayersOf = (config: { linter?: Layer.Layer; diagnostics?: ReadonlyArray; deadCode?: ReadonlyArray; + reporter?: Layer.Layer; }) => Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), config.linter ?? Linter.layerOf(config.diagnostics ?? []), @@ -294,11 +320,111 @@ const overlapLayersOf = (config: { Score.layerOf({ score: 85, label: "Good" }), config.supplyChain, Progress.layerNoop, - Reporter.layerNoop, + config.reporter ?? Reporter.layerNoop, Layer.succeed(SupplyChainOverlapTimeoutMs, config.overlapTimeoutMs), ); describe("runInspect — happy path", () => { + it("assembles the exact Linter request without changing optional-value semantics", async () => { + const ignoredTags = new Set(["experimental"]); + const includedTags = new Set(["correctness"]); + const reactDoctorConfig: ReactDoctorConfig = { warnings: false }; + const capturedLintInputs: LintInput[] = []; + const linter = Layer.mock(Linter, { + run: (lintInput) => { + capturedLintInputs.push(lintInput); + lintInput.onFileProgress?.(1, 2); + lintInput.onFileCoverage?.({ + candidateFiles: ["src/App.tsx", "src/Skipped.tsx"], + analyzedFiles: ["src/App.tsx"], + }); + lintInput.onCacheStats?.(3, 5); + lintInput.onSidecarStats?.(7, 11); + return Stream.empty; + }, + }); + + const output = await Effect.runPromise( + runInspect({ + ...baseInput, + includePaths: ["src/App.tsx", "README.md"], + customRulesOnly: true, + respectInlineDisables: false, + adoptExistingLintConfig: false, + ignoredTags, + includedTags, + includeTagDefaults: false, + nodeBinaryPath: "/opt/node/bin/node", + suppressScanSummary: true, + skipExplicitIncludePathFilter: true, + deadlineEpochMs: 1_900_000_000_000, + }).pipe( + Effect.provide( + layersOf({ + linter, + reactDoctorConfig, + configSourceDirectory: "/repo/config", + }), + ), + ), + ); + + expect(capturedLintInputs).toHaveLength(1); + const lintInput = capturedLintInputs[0]; + expect(Object.keys(lintInput)).toEqual([ + "rootDirectory", + "project", + "includePaths", + "nodeBinaryPath", + "customRulesOnly", + "respectInlineDisables", + "adoptExistingLintConfig", + "ignoredTags", + "includedTags", + "includeTagDefaults", + "userConfig", + "configSourceDirectory", + "onFileProgress", + "onFileCoverage", + "onCacheStats", + "onSidecarStats", + "deadlineEpochMs", + ]); + expect(lintInput).toMatchObject({ + rootDirectory: "/repo", + project: sampleProject, + includePaths: ["src/App.tsx", "README.md"], + nodeBinaryPath: "/opt/node/bin/node", + customRulesOnly: true, + respectInlineDisables: false, + adoptExistingLintConfig: false, + includeTagDefaults: false, + userConfig: reactDoctorConfig, + configSourceDirectory: "/repo/config", + deadlineEpochMs: 1_900_000_000_000, + }); + expect(lintInput.ignoredTags).toBe(ignoredTags); + expect(lintInput.includedTags).toBe(includedTags); + expect(output.analyzedFiles).toEqual(["src/App.tsx"]); + expect(output.scannedFileCount).toBe(2); + expect(output.lintCacheHitFileCount).toBe(3); + expect(output.lintCacheTotalFileCount).toBe(5); + expect(output.lintSidecarReplayedFileCount).toBe(7); + expect(output.lintSidecarTotalFileCount).toBe(11); + + await Effect.runPromise(runInspect(baseInput).pipe(Effect.provide(layersOf({ linter })))); + + expect(capturedLintInputs).toHaveLength(2); + const defaultLintInput = capturedLintInputs[1]; + expect(defaultLintInput).toHaveProperty("includePaths", undefined); + expect(defaultLintInput).toHaveProperty("nodeBinaryPath", undefined); + expect(defaultLintInput).toHaveProperty("includedTags", undefined); + expect(defaultLintInput).toHaveProperty("includeTagDefaults", undefined); + expect(defaultLintInput).toHaveProperty("userConfig", undefined); + expect(defaultLintInput).toHaveProperty("configSourceDirectory", undefined); + expect(defaultLintInput).toHaveProperty("deadlineEpochMs", undefined); + }); + it("collects diagnostics from Linter, DeadCode, and emits them through Reporter", async () => { const result = await Effect.runPromise( Effect.gen(function* () { @@ -344,6 +470,51 @@ describe("runInspect — happy path", () => { expect(output.didDeadCodeFail).toBe(false); }); + it("collects project-check diagnostics through the shared pipeline before lint", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const output = yield* runInspect(baseInput); + const reporterCapture = yield* ReporterCapture; + const captured = yield* Ref.get(reporterCapture); + return { output, captured }; + }).pipe( + Effect.provide( + layersOf({ + projectChecks: [projectCheckDiagnostic], + diagnostics: [lintDiagnostic], + }), + ), + ), + ); + + expect(result.output.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual( + expect.arrayContaining(["synthetic-project-check", "no-derived-state"]), + ); + expect(result.output.diagnostics).toHaveLength(2); + expect(result.captured.map((diagnostic) => diagnostic.rule)).toEqual([ + "synthetic-project-check", + "no-derived-state", + ]); + }); + + it("does not invoke project checks for a diff scan", async () => { + const projectChecksRun = vi.fn(() => Effect.succeed([projectCheckDiagnostic])); + const projectChecksLayer = Layer.mock(ProjectChecks, { run: projectChecksRun }); + const output = await Effect.runPromise( + runInspect({ ...baseInput, includePaths: ["src/App.tsx"] }).pipe( + Effect.provide( + layersOf({ + diagnostics: [lintDiagnostic], + projectChecksLayer, + }), + ), + ), + ); + + expect(projectChecksRun).not.toHaveBeenCalled(); + expect(output.diagnostics).toEqual([lintDiagnostic]); + }); + it("adds local authenticated GitHub viewer permission to score metadata", async () => { const output = await Effect.runPromise( runInspect({ ...baseInput, resolveLocalGithubViewerPermission: true }).pipe( @@ -387,6 +558,7 @@ describe("runInspect — happy path", () => { }); const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([]), @@ -412,6 +584,120 @@ describe("runInspect — happy path", () => { }); expect(output.scoreMetadata).not.toHaveProperty("githubViewerPermission"); }); + + it("joins viewer permission after scan finalization and reuses the metadata object for scoring", async () => { + const events: string[] = []; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const viewerStarted = yield* Deferred.make(); + const releaseViewer = yield* Deferred.make(); + const lintCompleted = yield* Deferred.make(); + const reporterFinalized = yield* Deferred.make(); + let scoredMetadata: ScoreRequestMetadata | undefined; + + const gitLayer = Layer.mock(Git, { + githubRepo: () => Effect.succeed("millionco/sample-app"), + headSha: () => Effect.succeed("abc123"), + defaultBranch: () => Effect.succeed("main"), + githubViewerPermission: () => + Effect.gen(function* () { + events.push("viewer:start"); + yield* Deferred.succeed(viewerStarted, undefined); + yield* Deferred.await(releaseViewer); + events.push("viewer:complete"); + return "maintain"; + }), + }); + const linter = Layer.succeed( + Linter, + Linter.of({ + run: () => + Stream.unwrap( + Effect.gen(function* () { + yield* Deferred.await(viewerStarted); + events.push("lint:complete"); + yield* Deferred.succeed(lintCompleted, undefined); + return Stream.empty; + }), + ), + }), + ); + const reporterLayer = Layer.succeed( + Reporter, + Reporter.of({ + emit: () => Effect.void, + finalize: Effect.gen(function* () { + events.push("reporter:finalize"); + yield* Deferred.succeed(reporterFinalized, undefined); + }), + }), + ); + const scoreLayer = Layer.succeed( + Score, + Score.of({ + compute: (input) => + Effect.sync(() => { + events.push("score:compute"); + scoredMetadata = input.metadata; + return { score: 85, label: "Good" }; + }), + }), + ); + + const inspectFiber = yield* Effect.forkChild( + runInspect({ + ...baseInput, + resolveLocalGithubViewerPermission: true, + }).pipe( + Effect.provide( + layersOf({ + gitLayer, + linter, + reporterLayer, + scoreLayer, + }), + ), + ), + ); + + yield* Deferred.await(lintCompleted); + yield* Deferred.await(reporterFinalized); + const eventsBeforeViewerRelease = [...events]; + yield* Deferred.succeed(releaseViewer, undefined); + const output = yield* Fiber.join(inspectFiber); + + return { + eventsBeforeViewerRelease, + output, + scoredMetadata, + }; + }), + ); + + expect(result.eventsBeforeViewerRelease).toEqual([ + "viewer:start", + "lint:complete", + "reporter:finalize", + ]); + expect(events).toEqual([ + "viewer:start", + "lint:complete", + "reporter:finalize", + "viewer:complete", + "score:compute", + ]); + expect(result.scoredMetadata).toBe(result.output.scoreMetadata); + expect(result.output.scoreMetadata).toEqual({ + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 1, + defaultBranch: "main", + githubViewerPermission: "maintain", + }); + }); }); describe("runInspect — deterministic diagnostic ordering", () => { @@ -545,6 +831,7 @@ describe("runInspect — missing React dependency", () => { const projectWithoutReact: ProjectInfo = { ...sampleProject, reactVersion: null }; const layers = Layer.mergeAll( Project.layerOf(projectWithoutReact), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([]), @@ -568,6 +855,7 @@ describe("runInspect — missing React dependency", () => { new ReactDoctorError({ reason: new NoReactDependency({ directory: "/repo" }) }), ), }), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([]), @@ -609,6 +897,7 @@ describe("runInspect — mid-stream lint failure", () => { }); const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), failingLinter, @@ -645,6 +934,7 @@ describe("runInspect — dead-code failure", () => { }); const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([lintDiagnostic]), @@ -734,6 +1024,7 @@ describe("runInspect — dead-code/lint overlap", () => { }); const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), failingLinter, @@ -845,6 +1136,7 @@ describe("runInspect — scan progress phases", () => { }); const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), trackingLinter, @@ -948,6 +1240,7 @@ describe("runInspect — diff mode skips dead-code", () => { }); const layers = Layer.mergeAll( Project.layerOf(nextProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), Files.layerInMemory(new Map()), reportingLinter, @@ -1012,6 +1305,7 @@ describe("runInspect — Reporter sees post-filter diagnostics", () => { }; const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: { ignore: { files: ["src/ignored.*"] } } as never, resolvedDirectory: "/repo", @@ -1079,6 +1373,7 @@ describe("runInspect — related-diagnostic dedupe on the production lint path", it("preserves the compiler finding when config suppresses the native rule", async () => { const layers = Layer.mergeAll( Project.layerOf(sampleProject), + ProjectChecks.layerOf([]), Config.layerOf({ config: { ignore: { rules: ["react-doctor/rules-of-hooks"] } }, resolvedDirectory: "/repo", @@ -1162,6 +1457,39 @@ describe("runInspect — supply-chain lint overlap", () => { expect(output.securityScanFailed).toBe(false); }); + it("finalizes Reporter immediately after the combined background join", async () => { + const events: string[] = []; + const reporter = Layer.succeed( + Reporter, + Reporter.of({ + emit: (diagnostic) => + Effect.sync(() => { + events.push(`emit:${diagnostic.rule}`); + }), + finalize: Effect.sync(() => { + events.push("finalize"); + }), + }), + ); + + await Effect.runPromise( + runInspect(baseInput).pipe( + Effect.provide( + overlapLayersOf({ + supplyChain: SupplyChain.layerOf([supplyChainDiagnostic]), + overlapTimeoutMs: 90_000, + diagnostics: [lintDiagnostic], + reporter, + }), + ), + ), + ); + + expect(events).toContain("emit:low-supply-chain-score"); + expect(events).toContain("emit:no-derived-state"); + expect(events.at(-1)).toBe("finalize"); + }); + it("keeps the score unchanged on the healthy overlap path", async () => { const output = await Effect.runPromise( runInspect(baseInput).pipe( @@ -1344,6 +1672,7 @@ describe("runInspect — security scan rules in the environment-checks phase", ( const scanRuleLayersOf = (rootDirectory: string, config: ReactDoctorConfig | null = null) => Layer.mergeAll( Project.layerOf({ ...sampleProject, rootDirectory }), + ProjectChecks.layerOf([]), Config.layerOf({ config, resolvedDirectory: rootDirectory, configSourceDirectory: null }), Files.layerInMemory(new Map()), Linter.layerOf([]), diff --git a/packages/core/tests/run-lint-phase.test.ts b/packages/core/tests/run-lint-phase.test.ts new file mode 100644 index 0000000000..1eb3dd093c --- /dev/null +++ b/packages/core/tests/run-lint-phase.test.ts @@ -0,0 +1,325 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { describe, expect, it } from "vite-plus/test"; +import { OxlintSpawnFailed, OxlintUnavailable, ReactDoctorError } from "../src/errors.js"; +import { OxlintConcurrency } from "../src/refs.js"; +import { type LintFailureState, runLintPhase } from "../src/run-lint-phase.js"; +import { type LintInput, LintPartialFailures, Linter } from "../src/services/linter.js"; +import type { ProgressHandle } from "../src/services/progress.js"; +import { Reporter } from "../src/services/reporter.js"; +import type { Diagnostic, ProjectInfo } from "../src/types/index.js"; + +const ROOT_DIRECTORY = "/repo"; +const AMBIENT_CONCURRENCY = 9; +const OVERLAP_CONCURRENCY = 3; +const SHORT_TIMEOUT_MS = 20; +const LONG_TIMEOUT_MS = 60_000; +const NODE_VERSION = "v22.1.0"; + +const project = { + rootDirectory: ROOT_DIRECTORY, + projectName: "sample-app", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + preactVersion: null, + preactMajorVersion: null, + sourceFileCount: 1, +} satisfies ProjectInfo; + +const reactDoctorDiagnostic: Diagnostic = { + filePath: "/repo/src/app.tsx", + plugin: "react-doctor", + rule: "rules-of-hooks", + severity: "error", + message: "React Doctor hook finding", + help: "Fix the hook.", + line: 2, + column: 3, + category: "Correctness", +}; + +const compilerDiagnostic: Diagnostic = { + ...reactDoctorDiagnostic, + plugin: "react-hooks-js", + rule: "hooks", + message: "Compiler hook finding", +}; + +const filteredDiagnostic: Diagnostic = { + ...reactDoctorDiagnostic, + rule: "filtered-rule", + message: "Filtered finding", +}; + +const initialFailure: LintFailureState = { + didFail: false, + reason: null, + reasonTag: null, + reasonKind: null, +}; + +const makeProgress = (events: string[]): ProgressHandle => ({ + update: () => Effect.void, + succeed: () => Effect.void, + fail: (text) => + Effect.sync(() => { + events.push(`progress:${text}`); + }), + stop: () => Effect.void, +}); + +const makeReporter = (events: string[]): Reporter["Service"] => + Reporter.of({ + emit: (diagnostic) => + Effect.sync(() => { + events.push(`reporter:${diagnostic.rule}`); + }), + finalize: Effect.void, + }); + +describe("runLintPhase", () => { + it("preserves the LintInput reference while filtering, deduping, emitting, and overriding overlap concurrency", async () => { + const events: string[] = []; + let observedConcurrency: number | undefined; + let receivedInput: LintInput | undefined; + const lintInput: LintInput = { + rootDirectory: ROOT_DIRECTORY, + project, + includePaths: ["src/app.tsx"], + onFileProgress: (scannedFileCount, totalFileCount) => { + events.push(`files:${scannedFileCount}/${totalFileCount}`); + }, + }; + const ownKeys = Object.keys(lintInput); + const partialFailuresRef = Effect.runSync(Ref.make>([])); + const failureRef = Effect.runSync(Ref.make(initialFailure)); + const linterService = Linter.of({ + run: (input) => { + receivedInput = input; + input.onFileProgress?.(1, 1); + return Stream.unwrap( + Effect.gen(function* () { + observedConcurrency = yield* OxlintConcurrency; + const partialFailures = yield* LintPartialFailures; + yield* Ref.update(partialFailures, (existing) => [ + ...existing, + "one batch failed softly", + ]); + return Stream.fromIterable([ + compilerDiagnostic, + filteredDiagnostic, + reactDoctorDiagnostic, + ]); + }), + ); + }, + }); + + const result = await Effect.runPromise( + runLintPhase({ + linterService, + lintInput, + failureRef, + shouldOverrideLintConcurrency: true, + lintConcurrency: OVERLAP_CONCURRENCY, + phaseTimeoutMs: LONG_TIMEOUT_MS, + filterDiagnostics: (stream) => + stream.pipe(Stream.filter((diagnostic) => diagnostic.rule !== filteredDiagnostic.rule)), + reporterService: makeReporter(events), + afterLint: (didFail) => + Effect.sync(() => { + events.push(`afterLint:${didFail}`); + }), + progress: makeProgress(events), + nodeVersion: NODE_VERSION, + }).pipe( + Effect.provideService(LintPartialFailures, partialFailuresRef), + Effect.provideService(OxlintConcurrency, AMBIENT_CONCURRENCY), + ), + ); + + expect(receivedInput).toBe(lintInput); + expect(Object.keys(lintInput)).toEqual(ownKeys); + expect(observedConcurrency).toBe(OVERLAP_CONCURRENCY); + expect(await Effect.runPromise(Ref.get(partialFailuresRef))).toEqual([ + "one batch failed softly", + ]); + expect(result).toEqual({ + diagnostics: [reactDoctorDiagnostic], + failure: initialFailure, + }); + expect(result.failure).toBe(initialFailure); + expect(events).toEqual(["files:1/1", "reporter:rules-of-hooks", "afterLint:false"]); + }); + + it("retains ambient concurrency when overlap is disabled", async () => { + let observedConcurrency: number | undefined; + const partialFailuresRef = Effect.runSync(Ref.make>([])); + const failureRef = Effect.runSync(Ref.make(initialFailure)); + + await Effect.runPromise( + runLintPhase({ + linterService: Linter.of({ + run: () => + Stream.fromEffect( + Effect.map(OxlintConcurrency, (concurrency) => { + observedConcurrency = concurrency; + return reactDoctorDiagnostic; + }), + ), + }), + lintInput: { rootDirectory: ROOT_DIRECTORY, project }, + failureRef, + shouldOverrideLintConcurrency: false, + lintConcurrency: OVERLAP_CONCURRENCY, + phaseTimeoutMs: LONG_TIMEOUT_MS, + filterDiagnostics: (stream) => stream, + reporterService: makeReporter([]), + afterLint: () => Effect.void, + progress: makeProgress([]), + nodeVersion: NODE_VERSION, + }).pipe( + Effect.provideService(LintPartialFailures, partialFailuresRef), + Effect.provideService(OxlintConcurrency, AMBIENT_CONCURRENCY), + ), + ); + + expect(observedConcurrency).toBe(AMBIENT_CONCURRENCY); + }); + + it("folds a mid-stream native-binding failure after emitting prior diagnostics", async () => { + const events: string[] = []; + const partialFailuresRef = Effect.runSync(Ref.make>([])); + const failureRef = Effect.runSync(Ref.make(initialFailure)); + const error = new ReactDoctorError({ + reason: new OxlintUnavailable({ + kind: "native-binding-missing", + detail: "unsupported ABI", + }), + }); + + const result = await Effect.runPromise( + runLintPhase({ + linterService: Linter.of({ + run: () => Stream.concat(Stream.make(reactDoctorDiagnostic), Stream.fail(error)), + }), + lintInput: { rootDirectory: ROOT_DIRECTORY, project }, + failureRef, + shouldOverrideLintConcurrency: false, + lintConcurrency: OVERLAP_CONCURRENCY, + phaseTimeoutMs: LONG_TIMEOUT_MS, + filterDiagnostics: (stream) => stream, + reporterService: makeReporter(events), + afterLint: (didFail) => + Effect.sync(() => { + events.push(`afterLint:${didFail}`); + }), + progress: makeProgress(events), + nodeVersion: NODE_VERSION, + }).pipe(Effect.provideService(LintPartialFailures, partialFailuresRef)), + ); + + expect(result.diagnostics).toEqual([reactDoctorDiagnostic]); + expect(result.failure).toEqual({ + didFail: true, + reason: error.message, + reasonTag: "OxlintUnavailable", + reasonKind: "native-binding-missing", + }); + expect(events).toEqual([ + "reporter:rules-of-hooks", + "afterLint:true", + `progress:Scanning failed — oxlint native binding not found (Node ${NODE_VERSION}).`, + ]); + }); + + it("folds timeout into the established failure state before hook and progress reporting", async () => { + const events: string[] = []; + const partialFailuresRef = Effect.runSync(Ref.make>([])); + const failureRef = Effect.runSync(Ref.make(initialFailure)); + + const result = await Effect.runPromise( + runLintPhase({ + linterService: Linter.of({ run: () => Stream.never }), + lintInput: { rootDirectory: ROOT_DIRECTORY, project }, + failureRef, + shouldOverrideLintConcurrency: false, + lintConcurrency: OVERLAP_CONCURRENCY, + phaseTimeoutMs: SHORT_TIMEOUT_MS, + filterDiagnostics: (stream) => stream, + reporterService: makeReporter(events), + afterLint: (didFail) => + Effect.sync(() => { + events.push(`afterLint:${didFail}`); + }), + progress: makeProgress(events), + nodeVersion: NODE_VERSION, + }).pipe(Effect.provideService(LintPartialFailures, partialFailuresRef)), + ); + + expect(result).toEqual({ + diagnostics: [], + failure: { + didFail: true, + reason: "Lint analysis exceeded 0.02s and was skipped.", + reasonTag: "OxlintBatchExceeded", + reasonKind: null, + }, + }); + expect(events).toEqual(["afterLint:true", "progress:Scanning failed (lint, non-fatal)."]); + }); + + it("keeps spawn failures on the native-binding progress branch", async () => { + const events: string[] = []; + const partialFailuresRef = Effect.runSync(Ref.make>([])); + const failureRef = Effect.runSync(Ref.make(initialFailure)); + const error = new ReactDoctorError({ + reason: new OxlintSpawnFailed({ cause: "spawn failed" }), + }); + + const result = await Effect.runPromise( + runLintPhase({ + linterService: Linter.of({ run: () => Stream.fail(error) }), + lintInput: { rootDirectory: ROOT_DIRECTORY, project }, + failureRef, + shouldOverrideLintConcurrency: false, + lintConcurrency: OVERLAP_CONCURRENCY, + phaseTimeoutMs: LONG_TIMEOUT_MS, + filterDiagnostics: (stream) => stream, + reporterService: makeReporter(events), + afterLint: (didFail) => + Effect.sync(() => { + events.push(`afterLint:${didFail}`); + }), + progress: makeProgress(events), + nodeVersion: NODE_VERSION, + }).pipe(Effect.provideService(LintPartialFailures, partialFailuresRef)), + ); + + expect(result.failure.reasonTag).toBe("OxlintSpawnFailed"); + expect(result.failure.reasonKind).toBeNull(); + expect(events).toEqual([ + "afterLint:true", + `progress:Scanning failed — oxlint native binding not found (Node ${NODE_VERSION}).`, + ]); + }); +}); diff --git a/packages/core/tests/score-metadata-execution.test.ts b/packages/core/tests/score-metadata-execution.test.ts new file mode 100644 index 0000000000..e7f5c01c6d --- /dev/null +++ b/packages/core/tests/score-metadata-execution.test.ts @@ -0,0 +1,375 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ProjectInfo } from "../src/types/index.js"; + +const resolveGithubActionsScoreMetadataMock = vi.hoisted(() => vi.fn()); + +vi.mock("../src/utils/resolve-github-actions-score-metadata.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../src/utils/resolve-github-actions-score-metadata.js") + >()), + resolveGithubActionsScoreMetadata: resolveGithubActionsScoreMetadataMock, +})); + +import { GitInvocationFailed, ReactDoctorError } from "../src/errors.js"; +import { startScoreMetadataExecution } from "../src/score-metadata-execution.js"; +import { Git } from "../src/services/git.js"; + +const ROOT_DIRECTORY = "/repo"; + +const project = { + rootDirectory: ROOT_DIRECTORY, + projectName: "sample-app", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + preactVersion: null, + preactMajorVersion: null, + sourceFileCount: 17, +} satisfies ProjectInfo; + +const gitFailure = (args: ReadonlyArray): ReactDoctorError => + new ReactDoctorError({ + reason: new GitInvocationFailed({ + args, + directory: ROOT_DIRECTORY, + cause: new Error("git unavailable"), + }), + }); + +beforeEach(() => { + resolveGithubActionsScoreMetadataMock.mockReset(); + resolveGithubActionsScoreMetadataMock.mockReturnValue({}); +}); + +describe("startScoreMetadataExecution", () => { + it("reads Git metadata in parallel, starts the gated viewer lookup, and joins it late", async () => { + const events: string[] = []; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const repoStarted = yield* Deferred.make(); + const shaStarted = yield* Deferred.make(); + const defaultBranchStarted = yield* Deferred.make(); + const releaseGitReads = yield* Deferred.make(); + const viewerStarted = yield* Deferred.make(); + const releaseViewer = yield* Deferred.make(); + + const gitLayer = Layer.mock(Git, { + githubRepo: (directory) => { + events.push(`repo:called:${directory}`); + return Effect.gen(function* () { + events.push("repo:started"); + yield* Deferred.succeed(repoStarted, undefined); + yield* Deferred.await(releaseGitReads); + events.push("repo:complete"); + return "millionco/sample-app"; + }); + }, + headSha: (directory) => { + events.push(`sha:called:${directory}`); + return Effect.gen(function* () { + events.push("sha:started"); + yield* Deferred.succeed(shaStarted, undefined); + yield* Deferred.await(releaseGitReads); + events.push("sha:complete"); + return "abc123"; + }); + }, + defaultBranch: (directory) => { + events.push(`branch:called:${directory}`); + return Effect.gen(function* () { + events.push("branch:started"); + yield* Deferred.succeed(defaultBranchStarted, undefined); + yield* Deferred.await(releaseGitReads); + events.push("branch:complete"); + return "main"; + }); + }, + githubViewerPermission: (input) => { + events.push(`viewer:called:${input.directory}:${input.repo}`); + return Effect.gen(function* () { + events.push("viewer:started"); + yield* Deferred.succeed(viewerStarted, undefined); + yield* Deferred.await(releaseViewer); + events.push("viewer:complete"); + return "maintain"; + }); + }, + }); + + let eventsBeforeGitRelease: string[] = []; + const gitReadController = yield* Effect.forkChild( + Effect.gen(function* () { + yield* Deferred.await(repoStarted); + yield* Deferred.await(shaStarted); + yield* Deferred.await(defaultBranchStarted); + eventsBeforeGitRelease = [...events]; + yield* Deferred.succeed(releaseGitReads, undefined); + }), + ); + + const execution = yield* Effect.gen(function* () { + const gitService = yield* Git; + return yield* startScoreMetadataExecution({ + gitService, + directory: ROOT_DIRECTORY, + project, + isCi: false, + shouldResolveLocalGithubViewerPermission: true, + doctorVersion: "0.9.1", + runId: "run-123", + }); + }).pipe(Effect.provide(gitLayer)); + yield* Fiber.join(gitReadController); + yield* Deferred.await(viewerStarted); + events.push("lint:checkpoint"); + + const joinFiber = yield* Effect.forkChild( + execution.join.pipe( + Effect.tap(() => + Effect.sync(() => { + events.push("metadata:complete"); + }), + ), + ), + ); + yield* Effect.yieldNow; + const eventsBeforeViewerRelease = [...events]; + yield* Deferred.succeed(releaseViewer, undefined); + const metadata = yield* Fiber.join(joinFiber); + + return { + eventsBeforeGitRelease, + eventsBeforeViewerRelease, + metadata, + }; + }), + ); + + expect(result.eventsBeforeGitRelease).toEqual( + expect.arrayContaining(["repo:started", "sha:started", "branch:started"]), + ); + expect(result.eventsBeforeGitRelease).not.toContain("repo:complete"); + expect(result.eventsBeforeGitRelease).not.toContain("sha:complete"); + expect(result.eventsBeforeGitRelease).not.toContain("branch:complete"); + expect(events.slice(0, 3)).toEqual([ + "repo:called:/repo", + "sha:called:/repo", + "branch:called:/repo", + ]); + expect(events.indexOf("viewer:started")).toBeLessThan(events.indexOf("lint:checkpoint")); + expect(result.eventsBeforeViewerRelease).not.toContain("metadata:complete"); + expect(events.indexOf("viewer:complete")).toBeLessThan(events.indexOf("metadata:complete")); + expect(result.metadata).toEqual({ + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 17, + defaultBranch: "main", + doctorVersion: "0.9.1", + runId: "run-123", + githubViewerPermission: "maintain", + }); + expect(resolveGithubActionsScoreMetadataMock).not.toHaveBeenCalled(); + }); + + it("fails Git reads open to null metadata and does not query a viewer without a repo", async () => { + const githubViewerPermission = vi.fn(() => Effect.succeed("maintain")); + const gitLayer = Layer.mock(Git, { + githubRepo: () => Effect.fail(gitFailure(["config", "--get", "remote.origin.url"])), + headSha: () => Effect.fail(gitFailure(["rev-parse", "HEAD"])), + defaultBranch: () => Effect.fail(gitFailure(["symbolic-ref", "origin/HEAD"])), + githubViewerPermission, + }); + + const metadata = await Effect.runPromise( + Effect.gen(function* () { + const gitService = yield* Git; + const execution = yield* startScoreMetadataExecution({ + gitService, + directory: ROOT_DIRECTORY, + project, + isCi: false, + shouldResolveLocalGithubViewerPermission: true, + doctorVersion: undefined, + runId: undefined, + }); + return yield* execution.join; + }).pipe(Effect.provide(gitLayer)), + ); + + expect(metadata).toEqual({ + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 17, + }); + expect(githubViewerPermission).not.toHaveBeenCalled(); + }); + + it("resolves GitHub Actions metadata after Git and never starts a local viewer lookup in CI", async () => { + const events: string[] = []; + resolveGithubActionsScoreMetadataMock.mockImplementation(() => { + events.push("github-actions:resolve"); + return { + githubEventName: "pull_request", + githubActorAssociation: "MEMBER", + }; + }); + const githubViewerPermission = vi.fn(() => Effect.succeed("maintain")); + const gitLayer = Layer.mock(Git, { + githubRepo: () => + Effect.sync(() => { + events.push("repo:complete"); + return "millionco/sample-app"; + }), + headSha: () => + Effect.sync(() => { + events.push("sha:complete"); + return "abc123"; + }), + defaultBranch: () => + Effect.sync(() => { + events.push("branch:complete"); + return "main"; + }), + githubViewerPermission, + }); + + const metadata = await Effect.runPromise( + Effect.gen(function* () { + const gitService = yield* Git; + const execution = yield* startScoreMetadataExecution({ + gitService, + directory: ROOT_DIRECTORY, + project, + isCi: true, + shouldResolveLocalGithubViewerPermission: true, + doctorVersion: undefined, + runId: undefined, + }); + return yield* execution.join; + }).pipe(Effect.provide(gitLayer)), + ); + + expect(events.indexOf("repo:complete")).toBeLessThan(events.indexOf("github-actions:resolve")); + expect(events.indexOf("sha:complete")).toBeLessThan(events.indexOf("github-actions:resolve")); + expect(events.indexOf("branch:complete")).toBeLessThan( + events.indexOf("github-actions:resolve"), + ); + expect(githubViewerPermission).not.toHaveBeenCalled(); + expect(metadata).toEqual({ + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 17, + defaultBranch: "main", + githubEventName: "pull_request", + githubActorAssociation: "MEMBER", + }); + }); + + it("fails an escaping viewer-permission error open at join", async () => { + const gitLayer = Layer.mock(Git, { + githubRepo: () => Effect.succeed("millionco/sample-app"), + headSha: () => Effect.succeed("abc123"), + defaultBranch: () => Effect.succeed("main"), + githubViewerPermission: () => Effect.fail(gitFailure(["api", "graphql"])), + }); + + const metadata = await Effect.runPromise( + Effect.gen(function* () { + const gitService = yield* Git; + const execution = yield* startScoreMetadataExecution({ + gitService, + directory: ROOT_DIRECTORY, + project, + isCi: false, + shouldResolveLocalGithubViewerPermission: true, + doctorVersion: undefined, + runId: undefined, + }); + return yield* execution.join; + }).pipe(Effect.provide(gitLayer)), + ); + + expect(metadata).toEqual({ + repo: "millionco/sample-app", + sha: "abc123", + framework: "vite", + reactVersion: "19.0.0", + sourceFileCount: 17, + defaultBranch: "main", + }); + }); + + it("interrupts an unfinished viewer child when its parent exits", async () => { + let resolveViewerStarted = (): void => undefined; + let resolveViewerInterrupted = (): void => undefined; + const viewerStarted = new Promise((resolve) => { + resolveViewerStarted = resolve; + }); + const viewerInterrupted = new Promise((resolve) => { + resolveViewerInterrupted = resolve; + }); + const gitLayer = Layer.mock(Git, { + githubRepo: () => Effect.succeed("millionco/sample-app"), + headSha: () => Effect.succeed("abc123"), + defaultBranch: () => Effect.succeed("main"), + githubViewerPermission: () => + Effect.gen(function* () { + resolveViewerStarted(); + return yield* Effect.never; + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + resolveViewerInterrupted(); + }), + ), + ), + }); + + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const gitService = yield* Git; + yield* startScoreMetadataExecution({ + gitService, + directory: ROOT_DIRECTORY, + project, + isCi: false, + shouldResolveLocalGithubViewerPermission: true, + doctorVersion: undefined, + runId: undefined, + }); + yield* Effect.promise(() => viewerStarted); + return yield* Effect.die(new Error("synthetic post-fork defect")); + }).pipe(Effect.provide(gitLayer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + await viewerInterrupted; + }); +}); diff --git a/packages/core/tests/services/git-command-executor.test.ts b/packages/core/tests/services/git-command-executor.test.ts new file mode 100644 index 0000000000..084f3b73a5 --- /dev/null +++ b/packages/core/tests/services/git-command-executor.test.ts @@ -0,0 +1,121 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner"; +import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem"; +import * as NodePath from "@effect/platform-node-shared/NodePath"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { + type GitCommandInput, + makeGitCommandExecutor, +} from "../../src/services/git-command-executor.js"; + +const temporaryDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-git-command-")), +); +const fileAsDirectory = path.join(temporaryDirectory, "not-a-directory"); +fs.writeFileSync(fileAsDirectory, "file"); + +afterAll(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true })); + +const childProcessLayer = NodeChildProcessSpawner.layer.pipe( + Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +); + +const executeCommand = (input: GitCommandInput) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner; + return yield* makeGitCommandExecutor(spawner)(input); + }); + +const runCommand = (input: GitCommandInput) => + Effect.runPromise(executeCommand(input).pipe(Effect.provide(childProcessLayer))); + +describe("makeGitCommandExecutor", () => { + it("preserves argv order, cwd, environment inheritance, stderr, and exit status", async () => { + const result = await runCommand({ + command: process.execPath, + args: [ + "-e", + [ + "process.stdout.write(JSON.stringify({", + "argv: process.argv.slice(1),", + "cwd: process.cwd(),", + "explicitEnv: process.env.REACT_DOCTOR_GIT_BOUNDARY,", + "hasInheritedPath: typeof process.env.PATH === 'string'", + "}));", + "process.stderr.write('stderr-value');", + "process.exitCode = 7;", + ].join(""), + "first argument", + "--second", + ], + directory: temporaryDirectory, + env: { REACT_DOCTOR_GIT_BOUNDARY: "exact" }, + }); + + expect(result).toEqual({ + status: 7, + stdout: JSON.stringify({ + argv: ["first argument", "--second"], + cwd: temporaryDirectory, + explicitEnv: "exact", + hasInheritedPath: true, + }), + stderr: "stderr-value", + }); + }); + + it("enforces maxStdoutBytes against raw UTF-8 bytes", async () => { + const error = await Effect.runPromise( + executeCommand({ + command: process.execPath, + args: ["-e", "process.stdout.write('éé')"], + directory: temporaryDirectory, + maxStdoutBytes: 3, + }).pipe(Effect.provide(childProcessLayer), Effect.flip), + ); + + expect(error.reason._tag).toBe("GitInvocationFailed"); + if (error.reason._tag !== "GitInvocationFailed") { + throw new Error(`Expected GitInvocationFailed, received ${error.reason._tag}`); + } + expect(error.reason.args).toEqual(["-e", "process.stdout.write('éé')"]); + expect(error.reason.directory).toBe(temporaryDirectory); + expect(error.message).toContain("git stdout exceeded 3 bytes"); + }); + + it("maps a non-Git preflight failure to status 127", async () => { + const result = await runCommand({ + command: "gh", + args: ["api", "graphql"], + directory: fileAsDirectory, + }); + + expect(result.status).toBe(127); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("spawn ENOTDIR"); + expect(result.stderr).toContain(fileAsDirectory); + }); + + it("maps a Git preflight failure to GitInvocationFailed", async () => { + const error = await Effect.runPromise( + executeCommand({ + command: "git", + args: ["rev-parse", "HEAD"], + directory: fileAsDirectory, + }).pipe(Effect.provide(childProcessLayer), Effect.flip), + ); + + expect(error.reason._tag).toBe("GitInvocationFailed"); + if (error.reason._tag !== "GitInvocationFailed") { + throw new Error(`Expected GitInvocationFailed, received ${error.reason._tag}`); + } + expect(error.reason.args).toEqual(["rev-parse", "HEAD"]); + expect(error.reason.directory).toBe(fileAsDirectory); + expect(error.message).toContain("spawn ENOTDIR"); + }); +}); diff --git a/packages/core/tests/services/project-checks.test.ts b/packages/core/tests/services/project-checks.test.ts new file mode 100644 index 0000000000..34f9da71b4 --- /dev/null +++ b/packages/core/tests/services/project-checks.test.ts @@ -0,0 +1,122 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Diagnostic, ProjectInfo } from "../../src/types/index.js"; + +const checkReducedMotionSpy = vi.hoisted(() => vi.fn()); +const checkPnpmHardeningSpy = vi.hoisted(() => vi.fn()); +const checkReactServerComponentsAdvisorySpy = vi.hoisted(() => vi.fn()); +const checkExpoProjectSpy = vi.hoisted(() => vi.fn()); +const checkReactNativeProjectSpy = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/check-reduced-motion.js", () => ({ + checkReducedMotion: checkReducedMotionSpy, +})); +vi.mock("../../src/check-pnpm-hardening.js", () => ({ + checkPnpmHardening: checkPnpmHardeningSpy, +})); +vi.mock("../../src/check-react-server-components-advisory.js", () => ({ + checkReactServerComponentsAdvisory: checkReactServerComponentsAdvisorySpy, +})); +vi.mock("../../src/check-expo-project.js", () => ({ + checkExpoProject: checkExpoProjectSpy, +})); +vi.mock("../../src/check-react-native-project.js", () => ({ + checkReactNativeProject: checkReactNativeProjectSpy, +})); + +import { ProjectChecks } from "../../src/services/project-checks.js"; + +const sampleProject: ProjectInfo = { + rootDirectory: "/repo", + projectName: "sample-app", + reactVersion: "19.0.0", + reactMajorVersion: 19, + tailwindVersion: null, + zodVersion: null, + zodMajorVersion: null, + framework: "vite", + hasTypeScript: true, + hasReactCompiler: false, + hasI18nLibrary: false, + tanstackQueryVersion: null, + mobxVersion: null, + styledComponentsVersion: null, + nextjsVersion: null, + nextjsMajorVersion: null, + hasReactNativeWorkspace: false, + expoVersion: null, + shopifyFlashListVersion: null, + shopifyFlashListMajorVersion: null, + hasReanimated: false, + isPreES2023Target: false, + preactVersion: null, + preactMajorVersion: null, + sourceFileCount: 1, +}; + +const buildDiagnostic = (rule: string): Diagnostic => ({ + filePath: "package.json", + plugin: "react-doctor", + rule, + severity: "warning", + message: rule, + help: rule, + line: 0, + column: 0, + category: "Maintainability", +}); + +const runProjectChecks = (layer: Layer.Layer) => + Effect.runPromise( + Effect.gen(function* () { + const projectChecks = yield* ProjectChecks; + return yield* projectChecks.run({ + rootDirectory: "/repo", + project: sampleProject, + }); + }).pipe(Effect.provide(layer)), + ); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ProjectChecks.layerNode", () => { + it("runs every synchronous project check in the established diagnostic order", async () => { + checkReducedMotionSpy.mockReturnValue([buildDiagnostic("reduced-motion")]); + checkPnpmHardeningSpy.mockReturnValue([buildDiagnostic("pnpm-hardening")]); + checkReactServerComponentsAdvisorySpy.mockReturnValue([buildDiagnostic("rsc-advisory")]); + checkExpoProjectSpy.mockReturnValue([buildDiagnostic("expo")]); + checkReactNativeProjectSpy.mockReturnValue([buildDiagnostic("react-native")]); + + const diagnostics = await runProjectChecks(ProjectChecks.layerNode); + + expect(diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ + "reduced-motion", + "pnpm-hardening", + "rsc-advisory", + "expo", + "react-native", + ]); + expect(checkReducedMotionSpy).toHaveBeenCalledWith("/repo"); + expect(checkPnpmHardeningSpy).toHaveBeenCalledWith("/repo"); + expect(checkReactServerComponentsAdvisorySpy).toHaveBeenCalledWith("/repo", sampleProject); + expect(checkExpoProjectSpy).toHaveBeenCalledWith("/repo", sampleProject); + expect(checkReactNativeProjectSpy).toHaveBeenCalledWith("/repo", sampleProject); + }); +}); + +describe("ProjectChecks.layerOf", () => { + it("returns the supplied diagnostics without running Node project checks", async () => { + const diagnostics = [buildDiagnostic("synthetic-project-check")]; + const result = await runProjectChecks(ProjectChecks.layerOf(diagnostics)); + + expect(result).toBe(diagnostics); + expect(checkReducedMotionSpy).not.toHaveBeenCalled(); + expect(checkPnpmHardeningSpy).not.toHaveBeenCalled(); + expect(checkReactServerComponentsAdvisorySpy).not.toHaveBeenCalled(); + expect(checkExpoProjectSpy).not.toHaveBeenCalled(); + expect(checkReactNativeProjectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/spawn-batches-oom-rescue.test.ts b/packages/core/tests/spawn-batches-oom-rescue.test.ts index 0be51cdbd3..95d39ef03a 100644 --- a/packages/core/tests/spawn-batches-oom-rescue.test.ts +++ b/packages/core/tests/spawn-batches-oom-rescue.test.ts @@ -22,6 +22,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import type { ProjectInfo } from "@react-doctor/core"; import { spawnLintBatches } from "../src/runners/oxlint/spawn-batches.js"; +import { createOxlintSpawnSlots } from "../src/utils/create-oxlint-spawn-slots.js"; const project: ProjectInfo = { rootDirectory: "/tmp/app", @@ -132,6 +133,7 @@ const runBatches = ( nodeBinaryPath: process.execPath, project, concurrency, + spawnSlots: createOxlintSpawnSlots(concurrency), onPartialFailure, }); @@ -173,6 +175,7 @@ describe("spawnLintBatches — OOM rescue pass", () => { nodeBinaryPath: process.execPath, project, concurrency: 2, + spawnSlots: createOxlintSpawnSlots(2), onPartialFailure: (reason) => partialFailures.push(reason), }); @@ -199,6 +202,7 @@ describe("spawnLintBatches — OOM rescue pass", () => { nodeBinaryPath: process.execPath, project, concurrency: 2, + spawnSlots: createOxlintSpawnSlots(2), onPartialFailure: (reason) => partialFailures.push(reason), }); diff --git a/packages/core/tests/spawn-batches-serial-fallback.test.ts b/packages/core/tests/spawn-batches-serial-fallback.test.ts index 90f56a51c0..9470adc197 100644 --- a/packages/core/tests/spawn-batches-serial-fallback.test.ts +++ b/packages/core/tests/spawn-batches-serial-fallback.test.ts @@ -52,6 +52,7 @@ vi.mock("node:child_process", async (importOriginal) => { }); import { spawnLintBatches } from "../src/runners/oxlint/spawn-batches.js"; +import { createOxlintSpawnSlots } from "../src/utils/create-oxlint-spawn-slots.js"; const project: ProjectInfo = { rootDirectory: "/tmp/app", @@ -89,6 +90,7 @@ const runWithConcurrency = (concurrency: number) => nodeBinaryPath: process.execPath, project, concurrency, + spawnSlots: createOxlintSpawnSlots(concurrency), }); beforeEach(() => { diff --git a/packages/core/tests/spawn-batches.test.ts b/packages/core/tests/spawn-batches.test.ts index 85039bd482..37d9aa4c05 100644 --- a/packages/core/tests/spawn-batches.test.ts +++ b/packages/core/tests/spawn-batches.test.ts @@ -17,6 +17,7 @@ import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import type { ProjectInfo } from "@react-doctor/core"; import { spawnLintBatches } from "../src/runners/oxlint/spawn-batches.js"; +import { createOxlintSpawnSlots } from "../src/utils/create-oxlint-spawn-slots.js"; const project: ProjectInfo = { rootDirectory: "/tmp/app", @@ -155,6 +156,64 @@ describe("spawnLintBatches concurrency", () => { const peak = await runMarkedBatches(4, 1); expect(peak).toBe(1); }); + + it("shares one subprocess cap across concurrent project batch runners", async () => { + const markDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-parallel-")); + const markFile = path.join(markDirectory, "marks.txt"); + fs.writeFileSync(markFile, ""); + const script = [ + 'const fs = require("fs");', + `const markFile = ${JSON.stringify(markFile)};`, + 'fs.appendFileSync(markFile, "+");', + "const files = process.argv.slice(1);", + "setTimeout(() => {", + ' fs.appendFileSync(markFile, "-");', + " const diagnostics = files.map((filename) => ({", + ' message: "Array index used as a key",', + ' code: "react-doctor(no-array-index-as-key)",', + ' severity: "warning",', + ' causes: [], url: "", help: "",', + " filename,", + ' labels: [{ label: "", span: { offset: 0, length: 1, line: 1, column: 1 } }],', + " related: [],", + " }));", + " process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));", + `}, ${SLEEP_MS});`, + ].join("\n"); + const spawnSlots = createOxlintSpawnSlots(2); + const runProjectBatches = (projectName: string) => + spawnLintBatches({ + baseArgs: ["-e", script], + fileBatches: Array.from({ length: 3 }, (_unused, index) => [ + `src/${projectName}-${index}.tsx`, + ]), + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + concurrency: 3, + spawnSlots, + }); + + try { + const [firstDiagnostics, secondDiagnostics] = await Promise.all([ + runProjectBatches("first"), + runProjectBatches("second"), + ]); + expect(computePeakConcurrency(fs.readFileSync(markFile, "utf8"))).toBe(2); + expect(firstDiagnostics.map((diagnostic) => diagnostic.filePath)).toEqual([ + "src/first-0.tsx", + "src/first-1.tsx", + "src/first-2.tsx", + ]); + expect(secondDiagnostics.map((diagnostic) => diagnostic.filePath)).toEqual([ + "src/second-0.tsx", + "src/second-1.tsx", + "src/second-2.tsx", + ]); + } finally { + fs.rmSync(markDirectory, { recursive: true, force: true }); + } + }); }); /** @@ -178,6 +237,81 @@ const EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT = [ "process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));", ].join("\n"); +describe("spawnLintBatches shared slot timing", () => { + it("starts the subprocess timeout only after a queued slot is acquired", async () => { + const spawnSlots = createOxlintSpawnSlots(1); + const progressUpdates: Array = []; + let releaseHeldSlot = (): void => {}; + const heldSlot = spawnSlots.run( + () => + new Promise((resolve) => { + releaseHeldSlot = resolve; + }), + ); + await Promise.resolve(); + + const diagnosticsPromise = spawnLintBatches({ + baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], + fileBatches: [["src/queued-a.tsx", "src/queued-b.tsx"]], + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + spawnTimeoutMs: 2_000, + spawnSlots, + onFileProgress: (scannedFileCount, totalFileCount) => { + progressUpdates.push([scannedFileCount, totalFileCount]); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 2_200)); + expect(progressUpdates).toEqual([]); + releaseHeldSlot(); + await heldSlot; + + await expect(diagnosticsPromise).resolves.toMatchObject([ + { filePath: "src/queued-a.tsx" }, + { filePath: "src/queued-b.tsx" }, + ]); + expect(progressUpdates.at(-1)).toEqual([2, 2]); + }); + + it("rechecks the scan deadline after a queued slot is acquired", async () => { + const spawnSlots = createOxlintSpawnSlots(1); + let releaseHeldSlot = (): void => {}; + const heldSlot = spawnSlots.run( + () => + new Promise((resolve) => { + releaseHeldSlot = resolve; + }), + ); + await Promise.resolve(); + const partialFailures: string[] = []; + const progressUpdates: Array = []; + + const diagnosticsPromise = spawnLintBatches({ + baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], + fileBatches: [["src/deadline-a.tsx", "src/deadline-b.tsx"]], + rootDirectory: process.cwd(), + nodeBinaryPath: process.execPath, + project, + deadlineEpochMs: Date.now() + 50, + spawnSlots, + onPartialFailure: (reason) => partialFailures.push(reason), + onFileProgress: (scannedFileCount, totalFileCount) => { + progressUpdates.push([scannedFileCount, totalFileCount]); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + releaseHeldSlot(); + await heldSlot; + + await expect(diagnosticsPromise).resolves.toEqual([]); + expect(partialFailures).toHaveLength(1); + expect(partialFailures[0]).toContain("2 file(s) skipped"); + expect(partialFailures[0]).toContain("max scan duration reached"); + expect(progressUpdates).toEqual([]); + }); +}); + const lintFileBatches = (fileBatches: string[][]) => spawnLintBatches({ baseArgs: ["-e", EMIT_ONE_DIAGNOSTIC_PER_FILE_SCRIPT], diff --git a/packages/fuzz/src/ast-equivalent-fuzz-variants.ts b/packages/fuzz/src/ast-equivalent-fuzz-variants.ts index f783504699..c0bd31a4d3 100644 --- a/packages/fuzz/src/ast-equivalent-fuzz-variants.ts +++ b/packages/fuzz/src/ast-equivalent-fuzz-variants.ts @@ -1,10 +1,12 @@ -import { parseFixture } from "../../oxlint-plugin-react-doctor/src/test-utils/parse-fixture.js"; -import type { EsTreeNode } from "../../oxlint-plugin-react-doctor/src/plugin/utils/es-tree-node.js"; -import { isFunctionLike } from "../../oxlint-plugin-react-doctor/src/plugin/utils/is-function-like.js"; -import { isHookCall } from "../../oxlint-plugin-react-doctor/src/plugin/utils/is-hook-call.js"; -import { isNodeOfType } from "../../oxlint-plugin-react-doctor/src/plugin/utils/is-node-of-type.js"; -import { stripParenExpression } from "../../oxlint-plugin-react-doctor/src/plugin/utils/strip-paren-expression.js"; -import { walkAst } from "../../oxlint-plugin-react-doctor/src/plugin/utils/walk-ast.js"; +import { + isFunctionLike, + isHookCall, + isNodeOfType, + parseFixture, + stripParenExpression, + walkAst, +} from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; +import type { EsTreeNode } from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; import type { EquivalentVariant } from "./equivalent-fuzz-variants.js"; interface SpannedNode { diff --git a/packages/fuzz/src/fuzz-rule.ts b/packages/fuzz/src/fuzz-rule.ts index 8df51d3175..5e5e2aac9a 100644 --- a/packages/fuzz/src/fuzz-rule.ts +++ b/packages/fuzz/src/fuzz-rule.ts @@ -1,8 +1,12 @@ -import type { Rule } from "../../oxlint-plugin-react-doctor/src/plugin/utils/rule.js"; -import { parseFixture } from "../../oxlint-plugin-react-doctor/src/test-utils/parse-fixture.js"; -import type { ParseFixtureResult } from "../../oxlint-plugin-react-doctor/src/test-utils/parse-fixture.js"; -import { runRuleOnParsedFixture } from "../../oxlint-plugin-react-doctor/src/test-utils/run-rule.js"; -import { runScanRule } from "../../oxlint-plugin-react-doctor/src/test-utils/run-scan-rule.js"; +import { + parseFixture, + runRuleOnParsedFixture, + runScanRule, +} from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; +import type { + ParseFixtureResult, + Rule, +} from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; import { CORPUS_PROGRAM_PROBABILITY, DEFAULT_FUZZ_ITERATIONS, diff --git a/packages/fuzz/src/verdict-preserving-variants.ts b/packages/fuzz/src/verdict-preserving-variants.ts index dd9aa94450..0ea46f7d18 100644 --- a/packages/fuzz/src/verdict-preserving-variants.ts +++ b/packages/fuzz/src/verdict-preserving-variants.ts @@ -1,7 +1,9 @@ -import { parseFixture } from "../../oxlint-plugin-react-doctor/src/test-utils/parse-fixture.js"; -import { walkAst } from "../../oxlint-plugin-react-doctor/src/plugin/utils/walk-ast.js"; -import { isNodeOfType } from "../../oxlint-plugin-react-doctor/src/plugin/utils/is-node-of-type.js"; -import type { EsTreeNode } from "../../oxlint-plugin-react-doctor/src/plugin/utils/es-tree-node.js"; +import { + isNodeOfType, + parseFixture, + walkAst, +} from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; +import type { EsTreeNode } from "../../oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.js"; import { MAX_VERDICT_VARIANT_ANCHORS } from "./constants.js"; // The mutation-robustness catalog: rewrites that change the SOURCE SHAPE of diff --git a/packages/oxlint-plugin-react-doctor/package.json b/packages/oxlint-plugin-react-doctor/package.json index c5ad11b336..2bd813fa7e 100644 --- a/packages/oxlint-plugin-react-doctor/package.json +++ b/packages/oxlint-plugin-react-doctor/package.json @@ -39,6 +39,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./contracts": { + "types": "./dist/contracts.d.ts", + "default": "./dist/contracts.js" } }, "scripts": { diff --git a/packages/oxlint-plugin-react-doctor/src/contracts.test.ts b/packages/oxlint-plugin-react-doctor/src/contracts.test.ts new file mode 100644 index 0000000000..bfaead038d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/contracts.test.ts @@ -0,0 +1,28 @@ +import * as fs from "node:fs"; +import { describe, expect, it } from "vite-plus/test"; +import { FRAMEWORK_TOKENS, MOTION_LIBRARY_PACKAGES } from "./contracts.js"; +import { + FRAMEWORK_TOKENS as LEGACY_FRAMEWORK_TOKENS, + MOTION_LIBRARY_PACKAGES as LEGACY_MOTION_LIBRARY_PACKAGES, +} from "./index.js"; + +describe("shared vocabulary entry", () => { + it("exposes the shared runtime vocabulary", () => { + expect(FRAMEWORK_TOKENS).toContain("react-native"); + expect(FRAMEWORK_TOKENS).toContain("unknown"); + expect(MOTION_LIBRARY_PACKAGES).toEqual(new Set(["framer-motion", "motion"])); + expect(LEGACY_FRAMEWORK_TOKENS).toBe(FRAMEWORK_TOKENS); + expect(LEGACY_MOTION_LIBRARY_PACKAGES).toBe(MOTION_LIBRARY_PACKAGES); + }); + + it("depends only on side-effect-free modules", () => { + const source = fs.readFileSync(new URL("./contracts.ts", import.meta.url), "utf8"); + const importSpecifiers = [...source.matchAll(/from "([^"]+)"/g)].map((match) => match[1]); + + expect(importSpecifiers).toEqual([ + "./plugin/constants/motion-library-packages.js", + "./plugin/utils/capability.js", + "./types.js", + ]); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/contracts.ts b/packages/oxlint-plugin-react-doctor/src/contracts.ts new file mode 100644 index 0000000000..a2180be6b1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/contracts.ts @@ -0,0 +1,8 @@ +export { MOTION_LIBRARY_PACKAGES } from "./plugin/constants/motion-library-packages.js"; +export { + FRAMEWORK_TOKENS, + type Capability, + type CapabilityQuery, + type FrameworkToken, +} from "./plugin/utils/capability.js"; +export type { OxlintRuleSeverity } from "./types.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/index.ts b/packages/oxlint-plugin-react-doctor/src/index.ts index 569f99104f..acab771372 100644 --- a/packages/oxlint-plugin-react-doctor/src/index.ts +++ b/packages/oxlint-plugin-react-doctor/src/index.ts @@ -18,7 +18,7 @@ export { TANSTACK_START_RULES, } from "./rules.js"; -export { MOTION_LIBRARY_PACKAGES } from "./plugin/constants/style.js"; +export { MOTION_LIBRARY_PACKAGES } from "./plugin/constants/motion-library-packages.js"; export { CROSS_FILE_RULE_IDS } from "./plugin/constants/cross-file-rule-ids.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/attach-source-locations.ts b/packages/oxlint-plugin-react-doctor/src/internal/attach-source-locations.ts new file mode 100644 index 0000000000..8dd6de7cfa --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/attach-source-locations.ts @@ -0,0 +1,74 @@ +import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; +import { isAstNode } from "../plugin/utils/is-ast-node.js"; + +interface SourcePosition { + readonly line: number; + readonly column: number; +} + +interface NodeWithOffsets { + readonly start?: number; + readonly end?: number; + range?: [number, number]; + loc?: { + readonly start: SourcePosition; + readonly end: SourcePosition; + }; +} + +const buildLineStartOffsets = (sourceText: string): ReadonlyArray => { + const lineStartOffsets = [0]; + for (let sourceIndex = 0; sourceIndex < sourceText.length; sourceIndex++) { + if (sourceText[sourceIndex] === "\n") lineStartOffsets.push(sourceIndex + 1); + } + return lineStartOffsets; +}; + +const getSourcePosition = ( + offset: number, + lineStartOffsets: ReadonlyArray, +): SourcePosition => { + let lowerIndex = 0; + let upperIndex = lineStartOffsets.length - 1; + while (lowerIndex <= upperIndex) { + const middleIndex = Math.floor((lowerIndex + upperIndex) / 2); + if (lineStartOffsets[middleIndex] <= offset) { + lowerIndex = middleIndex + 1; + } else { + upperIndex = middleIndex - 1; + } + } + const lineIndex = Math.max(0, upperIndex); + return { + line: lineIndex + 1, + column: offset - lineStartOffsets[lineIndex], + }; +}; + +export const attachSourceLocations = (root: EsTreeNode, sourceText: string): void => { + const lineStartOffsets = buildLineStartOffsets(sourceText); + const visitNode = (node: EsTreeNode): void => { + const nodeWithOffsets = node as NodeWithOffsets; + if (typeof nodeWithOffsets.start === "number" && typeof nodeWithOffsets.end === "number") { + nodeWithOffsets.loc = { + start: getSourcePosition(nodeWithOffsets.start, lineStartOffsets), + end: getSourcePosition(nodeWithOffsets.end, lineStartOffsets), + }; + nodeWithOffsets.range ??= [nodeWithOffsets.start, nodeWithOffsets.end]; + } + + const nodeRecord = node as unknown as Record; + for (const key of Object.keys(nodeRecord)) { + if (key === "parent") continue; + const child = nodeRecord[key]; + if (Array.isArray(child)) { + for (const childNode of child) { + if (isAstNode(childNode)) visitNode(childNode); + } + } else if (isAstNode(child)) { + visitNode(child); + } + } + }; + visitNode(root); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/create-oxlint-suppression-index.ts b/packages/oxlint-plugin-react-doctor/src/internal/create-oxlint-suppression-index.ts new file mode 100644 index 0000000000..dfac355bae --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/create-oxlint-suppression-index.ts @@ -0,0 +1,132 @@ +import type { Comment } from "oxc-parser"; +import { getSourcePosition } from "./get-source-position.js"; + +interface OxlintSuppressionIndex { + readonly isSuppressed: (ruleId: string, sourceStart: number, sourceEnd: number) => boolean; +} + +interface OxlintSuppressionDirective { + readonly action: "disable" | "enable"; + readonly scope: "region" | "line" | "next-line"; + readonly rules: ReadonlySet | null; + readonly comment: Comment; + readonly startLine: number; + readonly endLine: number; +} + +interface CreateOxlintSuppressionIndexInput { + readonly sourceText: string; + readonly comments: ReadonlyArray; +} + +const DIRECTIVE_PATTERN = + /^\s*(?:eslint|oxlint)-(disable-next-line|disable-line|disable|enable)\b([\s\S]*)$/; +const DESCRIPTION_SEPARATOR_PATTERN = /\s+--(?:\s|$)/; +const RULE_SEPARATOR_PATTERN = /[\s,]+/; + +const parseRules = (ruleListText: string): ReadonlySet | null => { + const ruleSegment = ruleListText.split(DESCRIPTION_SEPARATOR_PATTERN, 1)[0]?.trim() ?? ""; + if (ruleSegment.length === 0) return null; + return new Set(ruleSegment.split(RULE_SEPARATOR_PATTERN).filter(Boolean)); +}; + +const parseDirective = ( + sourceText: string, + comment: Comment, +): OxlintSuppressionDirective | null => { + const match = DIRECTIVE_PATTERN.exec(comment.value); + if (!match) return null; + const directiveKind = match[1]; + if (!directiveKind) return null; + const action = directiveKind === "enable" ? "enable" : "disable"; + let scope: OxlintSuppressionDirective["scope"] = "region"; + if (directiveKind === "disable-line") scope = "line"; + if (directiveKind === "disable-next-line") scope = "next-line"; + return { + action, + scope, + rules: parseRules(match[2] ?? ""), + comment, + startLine: getSourcePosition(sourceText, comment.start).line, + endLine: getSourcePosition(sourceText, comment.end).line, + }; +}; + +const namesRule = (directive: OxlintSuppressionDirective, ruleId: string): boolean => + directive.rules === null || directive.rules.has(`react-doctor/${ruleId}`); + +const hasUnboundedNextLineBoundary = (sourceText: string, comment: Comment): boolean => { + if (comment.type !== "Line") return false; + const boundaryCharacter = sourceText[comment.end]; + // HACK: Oxlint 1.74 suppresses the rest of the file when a line directive + // ends at a lone CR, U+2028, or U+2029 instead of LF or CRLF. + return ( + boundaryCharacter === "\u2028" || + boundaryCharacter === "\u2029" || + (boundaryCharacter === "\r" && sourceText[comment.end + 1] !== "\n") + ); +}; + +const isRegionSuppressed = ( + directives: ReadonlyArray, + ruleId: string, + sourceStart: number, + sourceEnd: number, +): boolean => { + let intervalStart = 0; + let isGloballyDisabled = false; + let isRuleDisabled = false; + for (const directive of directives) { + if (directive.scope !== "region") continue; + if ( + (isGloballyDisabled || isRuleDisabled) && + intervalStart < sourceEnd && + directive.comment.end > sourceStart + ) { + return true; + } + intervalStart = directive.comment.end; + if (directive.rules === null) { + isGloballyDisabled = directive.action === "disable"; + } else if (namesRule(directive, ruleId)) { + isRuleDisabled = directive.action === "disable"; + } + } + return (isGloballyDisabled || isRuleDisabled) && intervalStart < sourceEnd; +}; + +export const createOxlintSuppressionIndex = ( + input: CreateOxlintSuppressionIndexInput, +): OxlintSuppressionIndex => { + const directives = input.comments + .map((comment) => parseDirective(input.sourceText, comment)) + .filter((directive): directive is OxlintSuppressionDirective => directive !== null); + + return { + isSuppressed: (ruleId, sourceStart, sourceEnd) => { + const diagnosticStartLine = getSourcePosition(input.sourceText, sourceStart).line; + for (const directive of directives) { + if (!namesRule(directive, ruleId)) continue; + if ( + directive.scope === "line" && + diagnosticStartLine === directive.startLine && + sourceStart < directive.comment.start + ) { + return true; + } + if ( + directive.scope === "next-line" && + ((hasUnboundedNextLineBoundary(input.sourceText, directive.comment) && + sourceStart >= directive.comment.end) || + diagnosticStartLine === directive.endLine + 1 || + (directive.comment.type === "Block" && + diagnosticStartLine === directive.endLine && + sourceStart >= directive.comment.end)) + ) { + return true; + } + } + return isRegionSuppressed(directives, ruleId, sourceStart, sourceEnd); + }, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-browser-guard-project.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-browser-guard-project.test.ts new file mode 100644 index 0000000000..6b2b2eb39f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-browser-guard-project.test.ts @@ -0,0 +1,210 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { evaluateProject, evaluateSource, evaluateVirtualProject } from "./evaluate-source.js"; +import { createRealFilesystemResourceHost } from "./resource-host/real-resource-host.js"; + +const RULE_ID = "no-unguarded-browser-global-at-module-scope"; +const WINDOW_DIAGNOSTIC_MESSAGE = + 'Reading `window` here crashes with "ReferenceError: window is not defined" the instant this module is imported during SSR — move the read inside a function or effect, or guard it with `typeof window !== "undefined"`.'; +const NAVIGATOR_DIAGNOSTIC_MESSAGE = + 'Reading `navigator` here crashes with "ReferenceError: navigator is not defined" the instant this module is imported during SSR — move the read inside a function or effect, or guard it with `typeof navigator !== "undefined"`.'; +const LOCAL_STORAGE_DIAGNOSTIC_MESSAGE = + 'Reading `localStorage` here crashes with "ReferenceError: localStorage is not defined" the instant this module is imported during SSR — move the read inside a function or effect, or guard it with `typeof localStorage !== "undefined"`.'; + +const PROJECT_FILES = new Map([ + [ + "src/environment.ts", + `export const browserReady = typeof window !== "undefined"; +export const isServer = typeof window === "undefined"; +export const browserReadyFunction = () => typeof window !== "undefined"; +export const configuredBrowser = true;`, + ], + [ + "src/guards/index.ts", + `export { + browserReady, + browserReadyFunction, + configuredBrowser, + isServer, +} from "../environment";`, + ], + [ + "src/guarded.ts", + `import { browserReady } from "./guards"; + +export const initialWidth = browserReady ? window.innerWidth : 0;`, + ], + [ + "src/false-guard.ts", + `import { configuredBrowser as canUseDOM } from "./guards"; + +export const initialWidth = canUseDOM ? window.innerWidth : 0;`, + ], + [ + "src/server-branch.ts", + `import { isServer } from "./guards"; + +if (isServer) consume(window.innerWidth);`, + ], + [ + "src/browser-branch.ts", + `import { isServer } from "./guards"; + +if (!isServer) consume(window.innerWidth);`, + ], + [ + "src/function-guard.ts", + `import { browserReadyFunction } from "./guards"; + +if (browserReadyFunction()) consume(window.innerWidth);`, + ], + [ + "src/missing-flag.ts", + `import { IS_SPECIAL_BUILD } from "./missing-environment"; + +export const language = IS_SPECIAL_BUILD ? navigator.language : "";`, + ], + [ + "src/direct-read.ts", + `"🛰️";\r +export const savedTheme = localStorage.getItem("theme");`, + ], + ["src/invalid.ts", "export const ="], +]); + +const temporaryDirectories: string[] = []; + +describe("browser guard project evaluation", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it("keeps imported guard polarity exactly aligned", () => { + const temporaryRootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-evaluate-browser-guard-"), + ); + temporaryDirectories.push(temporaryRootDirectory); + for (const [filename, sourceText] of PROJECT_FILES) { + const absoluteFilename = path.join(temporaryRootDirectory, filename); + fs.mkdirSync(path.dirname(absoluteFilename), { recursive: true }); + fs.writeFileSync(absoluteFilename, sourceText, "utf8"); + } + + const realResult = evaluateProject({ + files: PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }), + ruleIds: [RULE_ID], + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-browser-guard-project", + files: PROJECT_FILES, + ruleIds: [RULE_ID], + }); + + expect(virtualResult).toEqual(realResult); + expect( + virtualResult.diagnostics.map( + ({ filePath, rule, message, line, column, offset, length, endLine, endColumn }) => ({ + filePath, + rule, + message, + line, + column, + offset, + length, + endLine, + endColumn, + }), + ), + ).toEqual([ + { + filePath: "src/false-guard.ts", + rule: RULE_ID, + message: WINDOW_DIAGNOSTIC_MESSAGE, + line: 3, + column: 41, + offset: 100, + length: 6, + endLine: 3, + endColumn: 47, + }, + { + filePath: "src/server-branch.ts", + rule: RULE_ID, + message: WINDOW_DIAGNOSTIC_MESSAGE, + line: 3, + column: 23, + offset: 60, + length: 6, + endLine: 3, + endColumn: 29, + }, + { + filePath: "src/missing-flag.ts", + rule: RULE_ID, + message: NAVIGATOR_DIAGNOSTIC_MESSAGE, + line: 3, + column: 44, + offset: 102, + length: 9, + endLine: 3, + endColumn: 53, + }, + { + filePath: "src/direct-read.ts", + rule: RULE_ID, + message: LOCAL_STORAGE_DIAGNOSTIC_MESSAGE, + line: 2, + column: 27, + offset: 38, + length: 12, + endLine: 2, + endColumn: 39, + }, + ]); + expect(virtualResult.failures).toEqual([ + { + kind: "parse", + filePath: "src/invalid.ts", + message: "Unexpected token", + line: 1, + column: 14, + offset: 13, + length: 1, + }, + ]); + expect( + virtualResult.diagnostics.filter((diagnostic) => + ["src/guarded.ts", "src/browser-branch.ts", "src/function-guard.ts"].includes( + diagnostic.filePath, + ), + ), + ).toEqual([]); + }); + + it("keeps source-only evaluation explicitly unsupported", () => { + expect( + evaluateSource({ + sourceText: `export const width = window.innerWidth;`, + filename: "src/component.ts", + ruleIds: [RULE_ID], + }), + ).toEqual({ + diagnostics: [], + failures: [ + { + kind: "unsupported-rule", + filePath: "src/component.ts", + rule: RULE_ID, + message: `Rule requires a project host: ${RULE_ID}`, + }, + ], + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-create-ref-project.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-create-ref-project.test.ts new file mode 100644 index 0000000000..b02582cb4b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-create-ref-project.test.ts @@ -0,0 +1,211 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { evaluateProject, evaluateSource, evaluateVirtualProject } from "./evaluate-source.js"; +import { createRealFilesystemResourceHost } from "./resource-host/real-resource-host.js"; + +const RULE_ID = "no-create-ref-in-function-component"; +const CREATE_REF_DIAGNOSTIC_MESSAGE = + "`createRef()` may escape or be observed beyond the render that created it, so a later render can replace the ref object and detach the observed one. Hoist a `useRef()` call to the component's unconditional top level instead."; + +const PROJECT_FILES = new Map([ + [ + "src/use-forward-focus.ts", + `import { useImperativeHandle, useRef } from "react"; + +export default function useForwardFocus(mainRef) { + const controlRef = useRef(null); + useImperativeHandle( + mainRef, + () => ({ focus: () => controlRef.current?.focus() }), + [controlRef], + ); + return controlRef; +}`, + ], + [ + "src/internal-button.tsx", + `import React from "react"; +import useForwardFocus from "./use-forward-focus"; + +const InternalButtonImplementation = (props, ref) => { + const controlRef = useForwardFocus(ref); + return ; +};`, + ], + ["src/invalid.tsx", "export const ="], +]); + +const temporaryDirectories: string[] = []; + +describe("createRef project evaluation", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it("keeps imported ref flow exactly aligned", () => { + const temporaryRootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-evaluate-create-ref-"), + ); + temporaryDirectories.push(temporaryRootDirectory); + for (const [filename, sourceText] of PROJECT_FILES) { + const absoluteFilename = path.join(temporaryRootDirectory, filename); + fs.mkdirSync(path.dirname(absoluteFilename), { recursive: true }); + fs.writeFileSync(absoluteFilename, sourceText, "utf8"); + } + + const realResult = evaluateProject({ + files: PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }), + ruleIds: [RULE_ID], + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-create-ref-project", + files: PROJECT_FILES, + ruleIds: [RULE_ID], + }); + + expect(virtualResult).toEqual(realResult); + expect( + virtualResult.diagnostics.map( + ({ filePath, rule, message, line, column, offset, length, endLine, endColumn }) => ({ + filePath, + rule, + message, + line, + column, + offset, + length, + endLine, + endColumn, + }), + ), + ).toEqual([ + { + filePath: "src/observed-adapter.tsx", + rule: RULE_ID, + message: CREATE_REF_DIAGNOSTIC_MESSAGE, + line: 6, + column: 18, + offset: 158, + length: 11, + endLine: 6, + endColumn: 29, + }, + { + filePath: "src/missing-adapter.tsx", + rule: RULE_ID, + message: CREATE_REF_DIAGNOSTIC_MESSAGE, + line: 5, + column: 18, + offset: 149, + length: 11, + endLine: 5, + endColumn: 29, + }, + ]); + expect(virtualResult.failures).toEqual([ + { + kind: "parse", + filePath: "src/invalid.tsx", + message: "Unexpected token", + line: 1, + column: 14, + offset: 13, + length: 1, + }, + ]); + expect( + virtualResult.diagnostics.filter((diagnostic) => + ["src/safe-adapter.tsx", "src/intrinsic-adapter.tsx"].includes(diagnostic.filePath), + ), + ).toEqual([]); + }); + + it("keeps source-only evaluation explicitly unsupported", () => { + expect( + evaluateSource({ + sourceText: `export const Component = () =>
;`, + filename: "src/component.tsx", + ruleIds: [RULE_ID], + }), + ).toEqual({ + diagnostics: [], + failures: [ + { + kind: "unsupported-rule", + filePath: "src/component.tsx", + rule: RULE_ID, + message: `Rule requires a project host: ${RULE_ID}`, + }, + ], + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-derived-state-project.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-derived-state-project.test.ts new file mode 100644 index 0000000000..cafec010e9 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-derived-state-project.test.ts @@ -0,0 +1,270 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { evaluateProject, evaluateSource, evaluateVirtualProject } from "./evaluate-source.js"; +import { createRealFilesystemResourceHost } from "./resource-host/real-resource-host.js"; + +const DERIVED_STATE_RULE_IDS = [ + "no-adjust-state-on-prop-change", + "no-derived-state", + "no-derived-state-effect", + "no-initialize-state", +]; + +const PROJECT_FILES = new Map([ + [ + "src/helpers/derive-label.ts", + `export const deriveLabel = (value) => value.trim(); +export const selectVisible = (versions) => + versions.filter((version) => version.visible);`, + ], + ["src/helpers/index.ts", `export { deriveLabel, selectVisible } from "./derive-label";`], + [ + "src/effect-copy.tsx", + `import { deriveLabel } from "./helpers";\r +\r +export const EffectCopy = ({ value }) => {\r + "🧭";\r + const [label, setLabel] = useState("");\r + useEffect(() => {\r + setLabel(deriveLabel(value));\r + }, [value]);\r + return {label};\r +};`, + ], + [ + "src/mount-copy.tsx", + `import { deriveLabel } from "./helpers"; + +export const MountCopy = ({ value }) => { + const [label, setLabel] = useState(""); + useEffect(() => { + setLabel(deriveLabel(value)); + }, []); + return {label}; +};`, + ], + [ + "src/selection-repair.tsx", + `import { selectVisible } from "./helpers"; + +export const SelectionRepair = ({ versions }) => { + const visibleVersions = useMemo(() => selectVisible(versions), [versions]); + const [selectedVersionId, setSelectedVersionId] = useState(""); + useEffect(() => { + if (visibleVersions.some((version) => version.id === selectedVersionId)) return; + setSelectedVersionId(visibleVersions[0].id); + }, [visibleVersions]); + return ( + + ); +};`, + ], + [ + "src/prop-adjustment.tsx", + `export const PropAdjustment = ({ versions }) => { + const visibleVersions = useMemo( + () => versions.filter((version) => version.visible), + [versions], + ); + const [selectedVersionId, setSelectedVersionId] = useState(""); + useEffect(() => { + if (visibleVersions.some((version) => version.id === selectedVersionId)) return; + setSelectedVersionId(visibleVersions[0].id); + }, [visibleVersions]); + return ( + + ); +};`, + ], + [ + "src/external-value.tsx", + `import { deriveExternal } from "./unsafe-helper"; + +export const ExternalValue = ({ value }) => { + const [label, setLabel] = useState(""); + useEffect(() => setLabel(deriveExternal(value)), [value]); + return {label}; +};`, + ], + ["src/unsafe-helper.ts", `export const deriveExternal = (value) => readExternal(value);`], + [ + "src/missing-helper.tsx", + `import { deriveMissing } from "./absent-helper"; + +export const MissingHelper = ({ value }) => { + const [label, setLabel] = useState(""); + useEffect(() => setLabel(deriveMissing(value)), [value]); + return {label}; +};`, + ], + ["src/invalid.tsx", "export const ="], +]); + +const temporaryDirectories: string[] = []; + +describe("derived-state project evaluation", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it("keeps imported helper provenance exactly aligned", () => { + const temporaryRootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-evaluate-derived-state-"), + ); + temporaryDirectories.push(temporaryRootDirectory); + for (const [filename, sourceText] of PROJECT_FILES) { + const absoluteFilename = path.join(temporaryRootDirectory, filename); + fs.mkdirSync(path.dirname(absoluteFilename), { recursive: true }); + fs.writeFileSync(absoluteFilename, sourceText, "utf8"); + } + + const realResult = evaluateProject({ + files: PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }), + ruleIds: DERIVED_STATE_RULE_IDS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-derived-state-project", + files: PROJECT_FILES, + ruleIds: DERIVED_STATE_RULE_IDS, + }); + + expect(virtualResult).toEqual(realResult); + expect( + virtualResult.diagnostics.map( + ({ filePath, rule, message, line, column, offset, length, endLine, endColumn }) => ({ + filePath, + rule, + message, + line, + column, + offset, + length, + endLine, + endColumn, + }), + ), + ).toEqual([ + { + filePath: "src/effect-copy.tsx", + rule: "no-derived-state-effect", + message: "You pay an extra render for state you can derive from other values.", + line: 6, + column: 3, + offset: 144, + length: 67, + endLine: 8, + endColumn: 14, + }, + { + filePath: "src/effect-copy.tsx", + rule: "no-derived-state", + message: + 'Storing "label" in state when you can derive it from other values costs an extra render.', + line: 7, + column: 5, + offset: 167, + length: 28, + endLine: 7, + endColumn: 33, + }, + { + filePath: "src/mount-copy.tsx", + rule: "no-derived-state-effect", + message: "You pay an extra render for state you can derive from other values.", + line: 5, + column: 3, + offset: 128, + length: 60, + endLine: 7, + endColumn: 9, + }, + { + filePath: "src/mount-copy.tsx", + rule: "no-derived-state", + message: + 'Storing "label" in state when you can derive it from other values costs an extra render.', + line: 6, + column: 5, + offset: 150, + length: 28, + endLine: 6, + endColumn: 33, + }, + { + filePath: "src/mount-copy.tsx", + rule: "no-initialize-state", + message: + 'Your users see an extra render with empty "label" because a useEffect sets its starting value.', + line: 6, + column: 5, + offset: 150, + length: 28, + endLine: 6, + endColumn: 33, + }, + { + filePath: "src/prop-adjustment.tsx", + rule: "no-adjust-state-on-prop-change", + message: + "This effect adjusts state after a prop changes, so users briefly see the stale value.", + line: 9, + column: 5, + offset: 338, + length: 43, + endLine: 9, + endColumn: 48, + }, + ]); + expect(virtualResult.failures).toEqual([ + { + kind: "parse", + filePath: "src/invalid.tsx", + message: "Unexpected token", + line: 1, + column: 14, + offset: 13, + length: 1, + }, + ]); + expect( + virtualResult.diagnostics.filter((diagnostic) => + ["src/selection-repair.tsx", "src/external-value.tsx", "src/missing-helper.tsx"].includes( + diagnostic.filePath, + ), + ), + ).toEqual([]); + }); + + it("keeps source-only evaluation explicitly unsupported", () => { + expect( + evaluateSource({ + sourceText: `const label = deriveLabel(value);`, + filename: "src/component.tsx", + ruleIds: DERIVED_STATE_RULE_IDS, + }), + ).toEqual({ + diagnostics: [], + failures: DERIVED_STATE_RULE_IDS.map((rule) => ({ + kind: "unsupported-rule", + filePath: "src/component.tsx", + rule, + message: `Rule requires a project host: ${rule}`, + })), + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-nextjs-search-params-project.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-nextjs-search-params-project.test.ts new file mode 100644 index 0000000000..a5bafdace2 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-nextjs-search-params-project.test.ts @@ -0,0 +1,209 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { evaluateProject, evaluateSource, evaluateVirtualProject } from "./evaluate-source.js"; +import { createRealFilesystemResourceHost } from "./resource-host/real-resource-host.js"; + +const PROJECT_FILES = new Map([ + [ + "src/search/index.ts", + `export { SearchPanel } from "./search-panel"; +export { Header } from "./widgets";`, + ], + [ + "src/search/search-panel.tsx", + `export const SearchPanel = () => { + const searchParameters = useSearchParams(); + return {searchParameters.toString()}; +};`, + ], + [ + "src/search/widgets.tsx", + `export const Header = () =>

Search

; +export const HiddenSearchPanel = () => { + const searchParameters = useSearchParams(); + return {searchParameters.toString()}; +};`, + ], + [ + "app/direct/page.tsx", + `"use client";\r +import { useSearchParams } from "next/navigation";\r +\r +export default function DirectPage() {\r + "🔎";\r + const searchParameters = useSearchParams();\r + return {searchParameters.toString()};\r +}`, + ], + [ + "app/imported/page.tsx", + `import { SearchPanel } from "../../src/search"; + +export default function ImportedPage() { + return ; +}`, + ], + [ + "app/covered/layout.tsx", + `import { Suspense } from "react"; + +export default function CoveredLayout({ children }) { + return {children}; +}`, + ], + [ + "app/covered/page.tsx", + `import { useSearchParams } from "next/navigation"; + +export default function CoveredPage() { + const searchParameters = useSearchParams(); + return {searchParameters.toString()}; +}`, + ], + [ + "app/bounded/page.tsx", + `import { Suspense } from "react"; +import { SearchPanel } from "../../src/search"; + +export default function BoundedPage() { + return ; +}`, + ], + [ + "app/unrelated/page.tsx", + `import { Header } from "../../src/search"; + +export default function UnrelatedPage() { + return
; +}`, + ], + [ + "app/missing/page.tsx", + `import { MissingPanel } from "./missing-panel"; + +export default function MissingPage() { + return ; +}`, + ], + ["app/invalid/page.tsx", "export default const ="], +]); + +const temporaryDirectories: string[] = []; + +describe("nextjs search params project evaluation", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it("keeps ancestor, barrel, and negative resolution behavior exactly aligned", () => { + const temporaryRootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-evaluate-search-params-"), + ); + temporaryDirectories.push(temporaryRootDirectory); + for (const [filename, sourceText] of PROJECT_FILES) { + const absoluteFilename = path.join(temporaryRootDirectory, filename); + fs.mkdirSync(path.dirname(absoluteFilename), { recursive: true }); + fs.writeFileSync(absoluteFilename, sourceText, "utf8"); + } + + const realResult = evaluateProject({ + files: PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }), + ruleIds: ["nextjs-no-use-search-params-without-suspense"], + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-search-params-project", + files: PROJECT_FILES, + ruleIds: ["nextjs-no-use-search-params-without-suspense"], + }); + + expect(virtualResult).toEqual(realResult); + expect( + virtualResult.diagnostics.map( + ({ filePath, rule, message, line, column, offset, length, endLine, endColumn }) => ({ + filePath, + rule, + message, + line, + column, + offset, + length, + endLine, + endColumn, + }), + ), + ).toEqual([ + { + filePath: "app/direct/page.tsx", + rule: "nextjs-no-use-search-params-without-suspense", + message: + "useSearchParams() without a boundary forces the whole page into client-side rendering.", + line: 6, + column: 28, + offset: 147, + length: 17, + endLine: 6, + endColumn: 45, + }, + { + filePath: "app/imported/page.tsx", + rule: "nextjs-no-use-search-params-without-suspense", + message: + " uses useSearchParams() outside , so this page falls back to client-side rendering.", + line: 4, + column: 10, + offset: 99, + length: 15, + endLine: 4, + endColumn: 25, + }, + ]); + expect(virtualResult.failures).toEqual([ + { + kind: "parse", + filePath: "app/invalid/page.tsx", + message: "Unexpected token", + line: 1, + column: 16, + offset: 15, + length: 5, + }, + ]); + expect( + virtualResult.diagnostics.filter((diagnostic) => + [ + "app/covered/page.tsx", + "app/bounded/page.tsx", + "app/unrelated/page.tsx", + "app/missing/page.tsx", + ].includes(diagnostic.filePath), + ), + ).toEqual([]); + }); + + it("keeps source-only evaluation explicitly unsupported", () => { + expect( + evaluateSource({ + sourceText: `const searchParameters = useSearchParams();`, + filename: "app/page.tsx", + ruleIds: ["nextjs-no-use-search-params-without-suspense"], + }), + ).toEqual({ + diagnostics: [], + failures: [ + { + kind: "unsupported-rule", + filePath: "app/page.tsx", + rule: "nextjs-no-use-search-params-without-suspense", + message: "Rule requires a project host: nextjs-no-use-search-params-without-suspense", + }, + ], + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-project.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-project.test.ts new file mode 100644 index 0000000000..dccefb9d95 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-project.test.ts @@ -0,0 +1,383 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { evaluateProject, evaluateSource, evaluateVirtualProject } from "./evaluate-source.js"; +import { createRealFilesystemResourceHost } from "./resource-host/real-resource-host.js"; + +const PROJECT_FILES = new Map([ + [ + "src/component.tsx", + `import { useReducer } from "react"; +import { counterReducer } from "./counter-reducer"; + +export const Component = () => { + const [state] = useReducer(counterReducer, { count: 0 }); + return {state.count}; +};`, + ], + [ + "src/counter-reducer.ts", + `export const counterReducer = (state: { count: number }) => { + state.count += 1; + return state; +};`, + ], +]); + +const temporaryDirectories: string[] = []; + +const writeProjectFiles = ( + rootDirectory: string, + projectFiles: ReadonlyMap = PROJECT_FILES, +): void => { + for (const [filename, sourceText] of projectFiles) { + const absoluteFilename = path.join(rootDirectory, filename); + fs.mkdirSync(path.dirname(absoluteFilename), { recursive: true }); + fs.writeFileSync(absoluteFilename, sourceText, "utf8"); + } +}; + +describe("project evaluator", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it("keeps real and in-memory cross-file rule execution exactly aligned", () => { + const temporaryRootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-evaluate-project-"), + ); + temporaryDirectories.push(temporaryRootDirectory); + writeProjectFiles(temporaryRootDirectory); + + const realResult = evaluateProject({ + files: PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }), + ruleIds: ["no-mutating-reducer-state"], + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-project", + files: PROJECT_FILES, + ruleIds: ["no-mutating-reducer-state"], + }); + + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(virtualResult.diagnostics).toHaveLength(1); + expect(virtualResult.diagnostics[0]).toMatchObject({ + filePath: "src/component.tsx", + rule: "no-mutating-reducer-state", + message: + "This reducer changes state in place, so your update is silently skipped. (mutation in imported reducer at `./counter-reducer`)", + }); + }); + + it("keeps barrel exports and ancestor metadata exactly aligned", () => { + const projectFiles = new Map([ + [ + "app/page.tsx", + `import { PrimaryButton } from "../components"; + +export default function Page() { + return ; +}`, + ], + [ + "app/covered/page.tsx", + `export default function CoveredPage() { + return
Covered
; +}`, + ], + [ + "app/covered/layout.mjs", + `export const metadata = { title: "Covered", description: "Covered page" };`, + ], + [ + "app/non-metadata/page.tsx", + `export default function UncoveredPage() { + return
Uncovered
; +}`, + ], + ["app/non-metadata/layout.tsx", `export const viewport = { width: "device-width" };`], + [ + "app/plain.tsx", + `import { value } from "../components/not-a-barrel"; + +export const Plain = () => {value};`, + ], + [ + "components/index.ts", + `export { Button as PrimaryButton } from "./button.tsx"; +export * from "./card.mjs";`, + ], + ["components/button.tsx", `export const Button = () => ;`; + +describe("evaluateSource", () => { + it("resolves stable rule IDs into canonical diagnostics with UTF-8 byte spans", () => { + expect( + evaluateSource({ + sourceText: SOURCE_TEXT, + filename: "src/component.tsx", + ruleIds: ["button-has-type"], + }), + ).toEqual({ + diagnostics: [ + { + filePath: "src/component.tsx", + plugin: "react-doctor", + rule: "button-has-type", + severity: "warning", + title: "Button missing explicit type", + message: + "Your users can submit the form by accident because a `;"; + + expect( + evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["button-has-type"], + }), + ).toEqual({ diagnostics: [], failures: [] }); + expect( + runRule(buttonHasType, sourceText, { + filename: "src/component.tsx", + }).diagnostics, + ).toHaveLength(1); + }); + + it("forwards the existing rule settings bag unchanged", () => { + const sourceText = `export const Component = () => ;`; + const defaultResult = evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["button-has-type"], + }); + const configuredResult = evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["button-has-type"], + settings: { + "react-doctor": { + buttonHasType: { button: false }, + }, + }, + }); + + expect(defaultResult).toEqual({ diagnostics: [], failures: [] }); + expect(configuredResult.diagnostics).toHaveLength(1); + expect(configuredResult.diagnostics[0]?.rule).toBe("button-has-type"); + expect(configuredResult.failures).toEqual([]); + }); + + it("uses capability settings for conditional recommendations", () => { + const result = evaluateSource({ + sourceText: `"use client";\nconst apiToken = "sk_live_${"1".repeat(24)}";`, + filename: "src/config.tsx", + ruleIds: ["no-secrets-in-client-code"], + settings: { + "react-doctor": { + capabilities: ["vite"], + }, + }, + }); + + expect(result.diagnostics[0]?.help).toBe( + "Move secrets to server-only code. In Vite, only `VITE_*` env vars are exposed to the browser, and they must not contain secrets", + ); + expect(result.failures).toEqual([]); + }); + + it("preserves default framework tokens while honoring explicit React Native settings", () => { + const sourceText = `import { Image } from "react-native"; +export const Component = () => Caption;`; + const reactNativeSettings = { + "react-doctor": { + capabilities: ["react", "react-native"], + framework: "react-native", + }, + }; + const webSettings = { + "react-doctor": { + capabilities: ["react", "vite"], + framework: "vite", + }, + }; + const defaultResult = evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["rn-no-image-children"], + }); + const reactNativeResult = evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["rn-no-image-children"], + settings: reactNativeSettings, + }); + const webResult = evaluateSource({ + sourceText, + filename: "src/component.tsx", + ruleIds: ["rn-no-image-children"], + settings: webSettings, + }); + const webExtensionResult = evaluateSource({ + sourceText, + filename: "src/component.web.tsx", + ruleIds: ["rn-no-image-children"], + settings: reactNativeSettings, + }); + + expect(defaultResult.failures).toEqual([]); + expect(defaultResult.diagnostics).toHaveLength(1); + expect(reactNativeResult).toEqual(defaultResult); + expect(webResult).toEqual({ diagnostics: [], failures: [] }); + expect(webExtensionResult).toEqual({ diagnostics: [], failures: [] }); + }); + + it("returns Oxlint source order while preserving duplicate rule executions", () => { + const result = evaluateSource({ + sourceText: `export const Component = () => ;`, + filename: "src/component.tsx", + ruleIds: ["no-access-key", "button-has-type", "no-access-key"], + }); + + expect(result.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ + "button-has-type", + "no-access-key", + "no-access-key", + ]); + expect(result.failures).toEqual([]); + }); + + it("returns explicit unknown, unsupported, and parse failures without executing rules", () => { + expect( + evaluateSource({ + sourceText: "const =", + filename: "src/broken.ts", + ruleIds: ["toString", "active-static-asset", "no-barrel-import", "button-has-type"], + }), + ).toEqual({ + diagnostics: [], + failures: [ + { + kind: "unknown-rule", + filePath: "src/broken.ts", + rule: "toString", + message: "Unknown React Doctor rule: toString", + }, + { + kind: "unsupported-rule", + filePath: "src/broken.ts", + rule: "active-static-asset", + message: "Rule requires a project host: active-static-asset", + }, + { + kind: "unsupported-rule", + filePath: "src/broken.ts", + rule: "no-barrel-import", + message: "Rule requires a project host: no-barrel-import", + }, + { + kind: "parse", + filePath: "src/broken.ts", + message: "Unexpected token", + line: 1, + column: 7, + offset: 6, + length: 1, + }, + ], + }); + expect(runRule(buttonHasType, "const =", { filename: "src/broken.ts" }).parseErrors).toEqual([ + { message: "Unexpected token" }, + ]); + }); + + it("isolates a crashing rule and continues evaluating later rules", () => { + const settings = Object.defineProperty({}, "react-doctor", { + get: () => { + throw new Error("settings unavailable"); + }, + }); + + expect( + evaluateSource({ + sourceText: SOURCE_TEXT, + filename: "src/component.tsx", + ruleIds: ["button-has-type", "button-has-type"], + settings, + }), + ).toEqual({ + diagnostics: [], + failures: [ + { + kind: "rule-crash", + filePath: "src/component.tsx", + rule: "button-has-type", + message: "settings unavailable", + }, + { + kind: "rule-crash", + filePath: "src/component.tsx", + rule: "button-has-type", + message: "settings unavailable", + }, + ], + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluate-source.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-source.ts new file mode 100644 index 0000000000..745dfc6af7 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluate-source.ts @@ -0,0 +1,301 @@ +import { CROSS_FILE_RULE_IDS } from "../plugin/constants/cross-file-rule-ids.js"; +import reactDoctorPlugin from "../plugin/react-doctor-plugin.js"; +import { hasCapability } from "../plugin/utils/get-react-doctor-setting.js"; +import { getNodeEndIndex } from "../plugin/utils/get-node-end-index.js"; +import { getNodeStartIndex } from "../plugin/utils/get-node-start-index.js"; +import type { Rule } from "../plugin/utils/rule.js"; +import { + NO_CROSS_FILE_RULE_IDS, + VIRTUAL_PROJECT_CROSS_FILE_RULE_IDS, +} from "./evaluator-constants.js"; +import { createOxlintSuppressionIndex } from "./create-oxlint-suppression-index.js"; +import { executeRule } from "./execute-rule.js"; +import type { ExecutedRuleDiagnostic } from "./execute-rule.js"; +import { getSourcePosition } from "./get-source-position.js"; +import { parseSource } from "./parse-source.js"; +import type { ParseSourceError } from "./parse-source.js"; +import { createInMemoryResourceHost } from "./resource-host/in-memory-resource-host.js"; +import type { InMemoryResourcePackageInput, ResourceHost } from "./resource-host/resource-host.js"; + +interface EvaluateRulesInput { + readonly ruleIds: ReadonlyArray; + readonly settings?: Readonly>; + readonly forceJsx?: boolean; +} + +export interface EvaluateSourceInput extends EvaluateRulesInput { + readonly sourceText: string; + readonly filename: string; +} + +export interface EvaluateProjectInput extends EvaluateRulesInput { + readonly files: ReadonlyMap; + readonly resourceHost: ResourceHost; +} + +export interface EvaluateVirtualProjectInput extends EvaluateRulesInput { + readonly rootDirectory: string; + readonly files: ReadonlyMap; + readonly packages?: ReadonlyArray; +} + +export interface EvaluatorDiagnostic { + readonly filePath: string; + readonly plugin: "react-doctor"; + readonly rule: string; + readonly severity: "error" | "warning"; + readonly title?: string; + readonly message: string; + readonly help: string; + readonly line: number; + readonly column: number; + readonly offset?: number; + readonly length?: number; + readonly endLine?: number; + readonly endColumn?: number; + readonly category: string; + readonly matchByOccurrence?: boolean; +} + +export interface EvaluatorFailure { + readonly kind: "parse" | "unknown-rule" | "unsupported-rule" | "rule-crash"; + readonly filePath: string; + readonly message: string; + readonly rule?: string; + readonly line?: number; + readonly column?: number; + readonly offset?: number; + readonly length?: number; +} + +export interface EvaluateSourceResult { + readonly diagnostics: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +interface EvaluatorRule { + readonly ruleId: string; + readonly rule: Rule; +} + +interface EvaluateFileInput { + readonly sourceText: string; + readonly displayFilename: string; + readonly runtimeFilename: string; + readonly ruleIds: ReadonlyArray; + readonly settings?: Readonly>; + readonly forceJsx?: boolean; + readonly resourceHost?: ResourceHost; + readonly supportedCrossFileRuleIds: ReadonlySet; +} + +const byteOffsetAt = (sourceText: string, sourceIndex: number): number => + Buffer.byteLength(sourceText.slice(0, sourceIndex)); + +const describeThrownValue = (thrownValue: unknown): string => + thrownValue instanceof Error ? thrownValue.message : String(thrownValue); + +const resolveRuleRecommendation = (rule: Rule, settings: EvaluateFileInput["settings"]): string => { + const conditionalRecommendation = rule.recommendationFor?.((capability) => + hasCapability(settings, capability), + ); + return conditionalRecommendation ?? rule.recommendation ?? ""; +}; + +const resolveEvaluatorRule = ( + ruleId: string, + filename: string, + supportedCrossFileRuleIds: ReadonlySet, +): EvaluatorRule | EvaluatorFailure => { + if (!Object.hasOwn(reactDoctorPlugin.rules, ruleId)) { + return { + kind: "unknown-rule", + filePath: filename, + rule: ruleId, + message: `Unknown React Doctor rule: ${ruleId}`, + }; + } + const rule = reactDoctorPlugin.rules[ruleId]; + if (rule.scan || (CROSS_FILE_RULE_IDS.has(ruleId) && !supportedCrossFileRuleIds.has(ruleId))) { + return { + kind: "unsupported-rule", + filePath: filename, + rule: ruleId, + message: `Rule requires a project host: ${ruleId}`, + }; + } + return { ruleId, rule }; +}; + +const isEvaluatorFailure = ( + resolvedRule: EvaluatorRule | EvaluatorFailure, +): resolvedRule is EvaluatorFailure => "kind" in resolvedRule; + +const buildParseFailure = ( + parseError: ParseSourceError, + sourceText: string, + filename: string, +): EvaluatorFailure => { + if (parseError.start === undefined || parseError.end === undefined) { + return { + kind: "parse", + filePath: filename, + message: parseError.message, + }; + } + const location = getSourcePosition(sourceText, parseError.start); + return { + kind: "parse", + filePath: filename, + message: parseError.message, + line: location.line, + column: location.column, + offset: byteOffsetAt(sourceText, parseError.start), + length: Buffer.byteLength(sourceText.slice(parseError.start, parseError.end)), + }; +}; + +const buildEvaluatorDiagnostic = ( + diagnostic: ExecutedRuleDiagnostic, + ruleId: string, + rule: Rule, + input: EvaluateFileInput, +): EvaluatorDiagnostic => { + const node = diagnostic.node; + const category = rule.category ?? "Bugs"; + const nodeStart = getNodeStartIndex(node); + const nodeEnd = getNodeEndIndex(node); + const startPosition = getSourcePosition(input.sourceText, nodeStart); + const endPosition = getSourcePosition(input.sourceText, nodeEnd); + const matchByOccurrence = category === "Accessibility" || Boolean(rule.matchByOccurrence); + return { + filePath: input.displayFilename, + plugin: "react-doctor", + rule: ruleId, + severity: rule.severity === "warn" ? "warning" : "error", + ...(rule.title ? { title: rule.title } : {}), + message: diagnostic.message, + help: resolveRuleRecommendation(rule, input.settings), + line: startPosition.line, + column: startPosition.column, + offset: byteOffsetAt(input.sourceText, nodeStart), + length: Buffer.byteLength(input.sourceText.slice(nodeStart, nodeEnd)), + endLine: endPosition.line, + endColumn: endPosition.column, + category, + ...(matchByOccurrence ? { matchByOccurrence: true } : {}), + }; +}; + +const evaluateFile = (input: EvaluateFileInput): EvaluateSourceResult => { + const resolvedRules = input.ruleIds.map((ruleId) => + resolveEvaluatorRule(ruleId, input.displayFilename, input.supportedCrossFileRuleIds), + ); + const failures = resolvedRules.filter(isEvaluatorFailure); + const evaluatorRules = resolvedRules.filter( + (resolvedRule): resolvedRule is EvaluatorRule => !isEvaluatorFailure(resolvedRule), + ); + const parsedSource = parseSource(input.sourceText, { + filename: input.runtimeFilename, + forceJsx: input.forceJsx, + }); + if (parsedSource.errors.length > 0) { + return { + diagnostics: [], + failures: [ + ...failures, + ...parsedSource.errors.map((parseError) => + buildParseFailure(parseError, input.sourceText, input.displayFilename), + ), + ], + }; + } + + const diagnostics: EvaluatorDiagnostic[] = []; + const suppressionIndex = createOxlintSuppressionIndex({ + sourceText: input.sourceText, + comments: parsedSource.comments, + }); + for (const evaluatorRule of evaluatorRules) { + try { + const result = executeRule(evaluatorRule.rule, input.sourceText, parsedSource, { + filename: input.runtimeFilename, + resourceHost: input.resourceHost, + settings: input.settings, + forceJsx: input.forceJsx, + }); + diagnostics.push( + ...result.diagnostics + .filter((diagnostic) => { + const nodeStart = getNodeStartIndex(diagnostic.node); + const nodeEnd = getNodeEndIndex(diagnostic.node); + return !suppressionIndex.isSuppressed(evaluatorRule.ruleId, nodeStart, nodeEnd); + }) + .map((diagnostic) => + buildEvaluatorDiagnostic(diagnostic, evaluatorRule.ruleId, evaluatorRule.rule, input), + ), + ); + } catch (thrownValue) { + failures.push({ + kind: "rule-crash", + filePath: input.displayFilename, + rule: evaluatorRule.ruleId, + message: describeThrownValue(thrownValue), + }); + } + } + diagnostics.sort( + (firstDiagnostic, secondDiagnostic) => + firstDiagnostic.line - secondDiagnostic.line || + firstDiagnostic.column - secondDiagnostic.column, + ); + return { diagnostics, failures }; +}; + +export const evaluateSource = (input: EvaluateSourceInput): EvaluateSourceResult => + evaluateFile({ + sourceText: input.sourceText, + displayFilename: input.filename, + runtimeFilename: input.filename, + ruleIds: input.ruleIds, + settings: input.settings, + forceJsx: input.forceJsx, + supportedCrossFileRuleIds: NO_CROSS_FILE_RULE_IDS, + }); + +export const evaluateProject = (input: EvaluateProjectInput): EvaluateSourceResult => { + const diagnostics: EvaluatorDiagnostic[] = []; + const failures: EvaluatorFailure[] = []; + for (const [filename, sourceText] of input.files) { + const result = evaluateFile({ + sourceText, + displayFilename: filename, + runtimeFilename: input.resourceHost.normalizePath(filename), + ruleIds: input.ruleIds, + settings: input.settings, + forceJsx: input.forceJsx, + resourceHost: input.resourceHost, + supportedCrossFileRuleIds: VIRTUAL_PROJECT_CROSS_FILE_RULE_IDS, + }); + diagnostics.push(...result.diagnostics); + failures.push(...result.failures); + } + return { diagnostics, failures }; +}; + +export const evaluateVirtualProject = ( + input: EvaluateVirtualProjectInput, +): EvaluateSourceResult => { + const resourceHost = createInMemoryResourceHost({ + rootDirectory: input.rootDirectory, + files: input.files, + packages: input.packages, + }); + return evaluateProject({ + files: input.files, + resourceHost, + ruleIds: input.ruleIds, + settings: input.settings, + forceJsx: input.forceJsx, + }); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluator-constants.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluator-constants.ts new file mode 100644 index 0000000000..d0a5f50f5d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluator-constants.ts @@ -0,0 +1,33 @@ +import { REACT_ROUTER_RULE_IDS } from "../plugin/constants/react-router.js"; + +export const NO_CROSS_FILE_RULE_IDS: ReadonlySet = new Set(); + +export const VIRTUAL_PROJECT_CROSS_FILE_RULE_IDS: ReadonlySet = new Set([ + ...REACT_ROUTER_RULE_IDS, + "exhaustive-deps", + "nextjs-async-dynamic-api-not-awaited", + "nextjs-missing-metadata", + "nextjs-no-img-element", + "nextjs-no-use-search-params-without-suspense", + "no-adjust-state-on-prop-change", + "no-barrel-import", + "no-create-ref-in-function-component", + "no-derived-state", + "no-derived-state-effect", + "no-dynamic-import-path", + "no-full-lodash-import", + "no-hydration-branch-on-browser-global", + "no-indeterminate-attribute", + "no-initialize-state", + "no-match-media-in-state-initializer", + "no-mutating-reducer-state", + "no-unguarded-browser-global-at-module-scope", + "no-unguarded-browser-global-in-render-or-hook-init", + "rendering-hydration-mismatch-time", + "rn-no-legacy-shadow-styles", + "rn-no-raw-text", + "rn-prefer-expo-image", + "rn-style-prefer-boxshadow", + "rules-of-hooks", + "window-open-without-noopener", +]); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/evaluator-differential-fixtures.ts b/packages/oxlint-plugin-react-doctor/src/internal/evaluator-differential-fixtures.ts new file mode 100644 index 0000000000..bc6748cb95 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/evaluator-differential-fixtures.ts @@ -0,0 +1,470 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { OxcFixture } from "../test-utils/run-fixtures.js"; +import { + failCases as ariaRoleFailCases, + passCases as ariaRolePassCases, +} from "../plugin/rules/a11y/__fixtures__/aria-role.fixtures.js"; +import { + failCases as imgRedundantAltFailCases, + passCases as imgRedundantAltPassCases, +} from "../plugin/rules/a11y/__fixtures__/img-redundant-alt.fixtures.js"; +import { + failCases as noAccessKeyFailCases, + passCases as noAccessKeyPassCases, +} from "../plugin/rules/a11y/__fixtures__/no-access-key.fixtures.js"; +import { + failCases as tabindexNoPositiveFailCases, + passCases as tabindexNoPositivePassCases, +} from "../plugin/rules/a11y/__fixtures__/tabindex-no-positive.fixtures.js"; +import { TRANSLATORS } from "../plugin/rules/a11y/__fixtures__/oxc-settings-translators.js"; + +export interface DifferentialFixtureCase { + readonly name: string; + readonly provenance: string; + readonly sourceText: string; + readonly expectedDiagnosticCount: number; +} + +export interface DifferentialFixtureGroup { + readonly ruleId: string; + readonly severity: "error" | "warn"; + readonly evaluationMode: "source" | "virtual"; + readonly settings?: Readonly>; + readonly cases: ReadonlyArray; +} + +export interface DifferentialVirtualProjectCase { + readonly name: string; + readonly provenance: string; + readonly ruleId: string; + readonly severity: "error" | "warn"; + readonly files: ReadonlyMap; + readonly expectedDiagnosticCountByFile: ReadonlyMap; +} + +interface UpstreamReactHooksCase { + readonly code: string; + readonly errorCount?: number; +} + +interface UpstreamReactHooksFixture { + readonly valid: ReadonlyArray; + readonly invalid: ReadonlyArray; +} + +interface OxcFixtureSelection { + readonly kind: "pass" | "fail"; + readonly index: number; + readonly suppress?: boolean; +} + +interface ReactHooksFixtureSelection { + readonly kind: "valid" | "invalid"; + readonly index: number; +} + +const buildOxcCases = ( + fixtureName: string, + passCases: ReadonlyArray, + failCases: ReadonlyArray, + selections: ReadonlyArray, +): ReadonlyArray => + selections.map((selection) => { + const fixtureCases = selection.kind === "pass" ? passCases : failCases; + const fixture = fixtureCases[selection.index]; + if (!fixture) { + throw new Error(`Missing ${fixtureName} ${selection.kind}[${selection.index}] fixture`); + } + const suppressionPrefix = selection.suppress + ? `// oxlint-disable-next-line react-doctor/${fixtureName}\n` + : ""; + return { + name: `${selection.kind}[${selection.index}]${selection.suppress ? " suppressed" : ""}`, + provenance: `OXC ${fixtureName} ${selection.kind}[${selection.index}]`, + sourceText: `${suppressionPrefix}${fixture.code}`, + expectedDiagnosticCount: selection.kind === "fail" && selection.suppress !== true ? 1 : 0, + }; + }); + +const readReactHooksFixture = (fixtureName: string): UpstreamReactHooksFixture => { + const fixturePath = path.join( + import.meta.dirname, + "../plugin/rules/react-builtins/__upstream-fixtures__", + `${fixtureName}.json`, + ); + return JSON.parse(fs.readFileSync(fixturePath, "utf8")) as UpstreamReactHooksFixture; +}; + +const buildReactHooksCases = ( + fixtureName: string, + selections: ReadonlyArray, +): ReadonlyArray => { + const fixture = readReactHooksFixture(fixtureName); + return selections.map((selection) => { + const fixtureCases = fixture[selection.kind]; + const fixtureCase = fixtureCases[selection.index]; + if (!fixtureCase) { + throw new Error( + `Missing eslint-plugin-react-hooks ${fixtureName} ${selection.kind}[${selection.index}] fixture`, + ); + } + return { + name: `${selection.kind}[${selection.index}]`, + provenance: `eslint-plugin-react-hooks ${fixtureName} ${selection.kind}[${selection.index}]`, + sourceText: fixtureCase.code, + expectedDiagnosticCount: selection.kind === "valid" ? 0 : (fixtureCase.errorCount ?? 1), + }; + }); +}; + +const customImgRedundantAltFixture = imgRedundantAltFailCases[3]; +if (!customImgRedundantAltFixture) { + throw new Error("Missing OXC img-redundant-alt fail[3] fixture"); +} +const standardImgRedundantAltFixture = imgRedundantAltPassCases[0]; +if (!standardImgRedundantAltFixture) { + throw new Error("Missing OXC img-redundant-alt pass[0] fixture"); +} +const customImgRedundantAltSettings = TRANSLATORS["img-redundant-alt"]( + customImgRedundantAltFixture, +); +if (!customImgRedundantAltSettings) { + throw new Error("Missing translated OXC img-redundant-alt fail[3] settings"); +} + +const oxcGroups: ReadonlyArray = [ + { + ruleId: "no-access-key", + severity: "warn", + evaluationMode: "source", + cases: buildOxcCases("no-access-key", noAccessKeyPassCases, noAccessKeyFailCases, [ + { kind: "pass", index: 0 }, + { kind: "pass", index: 2 }, + { kind: "fail", index: 0 }, + { kind: "fail", index: 5 }, + { kind: "fail", index: 1, suppress: true }, + ]), + }, + { + ruleId: "aria-role", + severity: "error", + evaluationMode: "source", + cases: buildOxcCases("aria-role", ariaRolePassCases, ariaRoleFailCases, [ + { kind: "pass", index: 0 }, + { kind: "pass", index: 5 }, + { kind: "fail", index: 0 }, + { kind: "fail", index: 5 }, + ]), + }, + { + ruleId: "img-redundant-alt", + severity: "warn", + evaluationMode: "source", + cases: buildOxcCases("img-redundant-alt", imgRedundantAltPassCases, imgRedundantAltFailCases, [ + { kind: "pass", index: 0 }, + { kind: "pass", index: 11 }, + { kind: "fail", index: 0 }, + { kind: "fail", index: 16 }, + ]), + }, + { + ruleId: "img-redundant-alt", + severity: "warn", + evaluationMode: "source", + settings: customImgRedundantAltSettings, + cases: [ + { + name: "custom words pass[0]", + provenance: "OXC img-redundant-alt pass[0] with translated fail[3] options", + sourceText: standardImgRedundantAltFixture.code, + expectedDiagnosticCount: 0, + }, + { + name: "custom words fail[3]", + provenance: "OXC img-redundant-alt fail[3] translated options", + sourceText: customImgRedundantAltFixture.code, + expectedDiagnosticCount: 1, + }, + ], + }, + { + ruleId: "tabindex-no-positive", + severity: "warn", + evaluationMode: "source", + cases: buildOxcCases( + "tabindex-no-positive", + tabindexNoPositivePassCases, + tabindexNoPositiveFailCases, + [ + { kind: "pass", index: 6 }, + { kind: "pass", index: 15 }, + { kind: "fail", index: 0 }, + { kind: "fail", index: 4 }, + ], + ), + }, +]; + +const reactHooksGroups: ReadonlyArray = [ + { + ruleId: "rules-of-hooks", + severity: "error", + evaluationMode: "virtual", + cases: buildReactHooksCases("rules-of-hooks", [ + { kind: "valid", index: 0 }, + { kind: "valid", index: 30 }, + { kind: "invalid", index: 10 }, + { kind: "invalid", index: 50 }, + ]), + }, + { + ruleId: "exhaustive-deps", + severity: "warn", + evaluationMode: "virtual", + cases: buildReactHooksCases("exhaustive-deps", [ + { kind: "valid", index: 10 }, + { kind: "valid", index: 20 }, + { kind: "invalid", index: 3 }, + { kind: "invalid", index: 30 }, + ]), + }, +]; + +const regressionGroups: ReadonlyArray = [ + { + ruleId: "no-eval", + severity: "error", + evaluationMode: "source", + cases: [ + { + name: "globalThis polyfill", + provenance: "security/no-eval.regressions globalThis polyfill", + sourceText: `const globalObject = new Function("return this")();`, + expectedDiagnosticCount: 0, + }, + { + name: "dynamic Function input", + provenance: "security/no-eval.regressions dynamic Function input", + sourceText: `const fn = new Function("value", userExpression);`, + expectedDiagnosticCount: 1, + }, + { + name: "computed global eval", + provenance: "security/no-eval.regressions computed global form", + sourceText: `globalThis["eval"](payload);`, + expectedDiagnosticCount: 1, + }, + { + name: "shadowed eval", + provenance: "security/no-eval.regressions shadowed lookalike", + sourceText: `const eval = (value: string) => value; eval(payload);`, + expectedDiagnosticCount: 0, + }, + ], + }, + { + ruleId: "no-unsafe-json-parse", + severity: "warn", + evaluationMode: "source", + cases: [ + { + name: "cast member access", + provenance: "correctness/no-unsafe-json-parse cast regression", + sourceText: `const value = (JSON.parse(raw) as Payload).error;`, + expectedDiagnosticCount: 1, + }, + { + name: "enclosing try", + provenance: "correctness/no-unsafe-json-parse try regression", + sourceText: `try { const value = JSON.parse(raw).error; } catch (error) { handle(error); }`, + expectedDiagnosticCount: 0, + }, + { + name: "callback escapes try", + provenance: "correctness/no-unsafe-json-parse callback regression", + sourceText: `try { socket.onmessage = (event) => JSON.parse(event.data).items; } catch (error) { handle(error); }`, + expectedDiagnosticCount: 1, + }, + { + name: "shadowed JSON", + provenance: "correctness/no-unsafe-json-parse shadowing regression", + sourceText: `function read(raw) { const JSON = { parse: () => ({ value: 1 }) }; return JSON.parse(raw).value; }`, + expectedDiagnosticCount: 0, + }, + ], + }, + { + ruleId: "no-direct-state-mutation", + severity: "warn", + evaluationMode: "source", + cases: [ + { + name: "lazy array mutation", + provenance: "state-and-effects/no-direct-state-mutation Bugbot regression", + sourceText: + "function List() { const [items, setItems] = useState(() => []); const add = (item) => { items.push(item); }; return ; }", + expectedDiagnosticCount: 1, + }, + { + name: "opaque instance mutation", + provenance: "state-and-effects/no-direct-state-mutation third-party instance regression", + sourceText: + "function Playlist() { const [queue] = useState(() => new TrackQueue()); queue.push(track); return null; }", + expectedDiagnosticCount: 0, + }, + ], + }, + { + ruleId: "no-set-state-in-render", + severity: "warn", + evaluationMode: "source", + cases: [ + { + name: "top-level setter", + provenance: "state-and-effects/no-set-state-in-render unconditional regression", + sourceText: + "function Counter() { const [count, setCount] = useState(0); setCount(1); return count; }", + expectedDiagnosticCount: 1, + }, + { + name: "event setter", + provenance: "state-and-effects/no-set-state-in-render event regression", + sourceText: + "function Counter() { const [count, setCount] = useState(0); const onClick = () => setCount(count + 1); return
;", + }, + { + filename: "access-key.tsx", + ruleId: "no-access-key", + severity: "warn", + sourceText: `export const Shortcuts = () => ( +
+ +
+);`, + }, + { + filename: "list.tsx", + ruleId: "no-array-index-as-key", + severity: "warn", + sourceText: `interface ListProps { + readonly items: ReadonlyArray; +} + +export const List = ({ items }: ListProps) => + items.map((item, itemIndex) => {item});`, + }, + { + filename: "json.ts", + ruleId: "no-unsafe-json-parse", + severity: "warn", + sourceText: `const label = "😀"; +export const readName = (sourceText: string) => JSON.parse(sourceText).name;`, + }, + { + filename: "dynamic-code.ts", + ruleId: "no-eval", + severity: "error", + sourceText: "export const runCode = (sourceText: string) => eval(sourceText);", + }, + { + filename: "line-terminators.ts", + ruleId: "no-eval", + severity: "error", + sourceText: "const first = 1;\reval(first);\u2028eval(second);\u2029eval(third);", + }, +]; + +const REACT_NATIVE_RULE_ID = "rn-no-raw-text"; +const REACT_NATIVE_SETTINGS = { + "react-doctor": { + capabilities: ["react", "react-native"], + framework: "react-native", + packageCapabilityGates: true, + }, +}; +const MISSING_REACT_NATIVE_CAPABILITY_SETTINGS = { + "react-doctor": { + capabilities: ["react"], + framework: "react-native", + packageCapabilityGates: true, + }, +}; +const WEB_SETTINGS = { + "react-doctor": { + capabilities: ["react", "vite"], + framework: "vite", + }, +}; +const PACKAGE_MANIFEST_SETTINGS = { + "react-doctor": { + capabilities: ["react", "react-native"], + framework: "expo", + packageCapabilityGates: true, + }, +}; +const REACT_ROUTER_ROUTE_SOURCE_TEXT = `import { useNavigate } from "react-router"; + +export const Route = () => { + const navigate = useNavigate(); + navigate("/next"); + return null; +};`; +const REACT_ROUTER_VERSION = { major: 7, minor: 9 }; +const REACT_ROUTER_VERSION_SPECIFIER = "7.9.0"; +const REACT_ROUTER_CAPABILITIES = [ + "react-router", + ...REACT_ROUTER_CAPABILITY_THRESHOLDS.filter((threshold) => + isMajorMinorAtLeast(REACT_ROUTER_VERSION, threshold), + ).map((threshold) => threshold.capability), + "react-router-framework", +]; +const REACT_ROUTER_PROJECT_FILES = new Map([ + ["src/route.tsx", REACT_ROUTER_ROUTE_SOURCE_TEXT], +]); +const REACT_ROUTER_MANIFEST = { + name: "react-router-project", + dependencies: { + "@react-router/dev": REACT_ROUTER_VERSION_SPECIFIER, + react: "19.1.0", + "react-router": REACT_ROUTER_VERSION_SPECIFIER, + }, +}; +const REACT_ROUTER_RESOURCE_FILES = new Map([ + ["package.json", JSON.stringify(REACT_ROUTER_MANIFEST)], +]); +const REACT_ROUTER_VIRTUAL_PACKAGES: ReadonlyArray = [ + { + directoryPath: ".", + manifest: REACT_ROUTER_MANIFEST, + installedDependencyVersions: { + "@react-router/dev": REACT_ROUTER_VERSION_SPECIFIER, + react: "19.1.0", + "react-router": REACT_ROUTER_VERSION_SPECIFIER, + }, + }, +]; +const REACT_ROUTER_RULES: ReadonlyArray = REACT_ROUTER_RULE_IDS.map( + (ruleId) => ({ + ruleId, + severity: ruleRegistry[ruleId]?.severity === "warn" ? "warn" : "error", + }), +); +const createReactRouterSettings = (rootDirectory: string): Readonly> => ({ + "react-doctor": { + rootDirectory, + capabilities: ["react", "react:19", ...REACT_ROUTER_CAPABILITIES], + }, +}); +const REACT_NATIVE_PROJECT_FILES = new Map([ + [ + "packages/native/src/app.tsx", + `import { Card, Label } from "./wrappers"; + +export const App = () => ( + <> + 😀 Crash + + Direct crash + +);`, + ], + [ + "packages/native/src/wrappers.tsx", + `export const Card = ({ children }) => {children}; +export const Label = ({ children }) => {children};`, + ], + [ + "packages/native/src/dom-component.tsx", + `"use dom"; +export const DomComponent = () => DOM text;`, + ], + [ + "packages/native/src/platform.web.tsx", + `export const WebComponent = () => Web text;`, + ], + [ + "packages/web/src/component.tsx", + `export const WebComponent = () => Web package text;`, + ], + [ + "packages/web/src/component.native.tsx", + `export const NativeComponent = () => Native override text;`, + ], + ["src/loose.tsx", `export const LooseComponent = () => Framework text;`], +]); +const REACT_NATIVE_RESOURCE_FILES = new Map([ + [ + "packages/native/package.json", + JSON.stringify({ + name: "native-package", + dependencies: { "react-native": "0.80.0" }, + }), + ], + [ + "packages/web/package.json", + JSON.stringify({ + name: "web-package", + dependencies: { "react-dom": "19.1.0" }, + }), + ], +]); +const REACT_NATIVE_VIRTUAL_PACKAGES: ReadonlyArray = [ + { + directoryPath: "packages/native", + manifest: { + name: "native-package", + dependencies: { "react-native": "0.80.0" }, + }, + installedDependencyVersions: { "react-native": "0.80.0" }, + }, + { + directoryPath: "packages/web", + manifest: { + name: "web-package", + dependencies: { "react-dom": "19.1.0" }, + }, + installedDependencyVersions: { "react-dom": "19.1.0" }, + }, +]; +const PACKAGE_MANIFEST_RULES: ReadonlyArray = [ + { ruleId: "no-full-lodash-import", severity: "warn" }, + { ruleId: "rn-prefer-expo-image", severity: "warn" }, + { ruleId: "rn-no-legacy-shadow-styles", severity: "warn" }, + { ruleId: "rn-style-prefer-boxshadow", severity: "warn" }, +]; +const PACKAGE_MANIFEST_PROJECT_FILES = new Map([ + [ + "packages/web/src/lodash.ts", + `const marker = "😀";\r +import lodash from "lodash";\r +export const chunks = lodash.chunk([1, 2, 3], 2);`, + ], + [ + "packages/cli/src/lodash.ts", + `import lodash from "lodash"; +export const chunks = lodash.chunk([1, 2, 3], 2);`, + ], + [ + "packages/library/src/demo.page.tsx", + `import lodash from "lodash"; +export const Demo = () => {lodash.chunk([1, 2, 3], 2).length};`, + ], + [ + "packages/expo/src/image.tsx", + `const marker = "😀";\r +import { Image as NativeImage } from "react-native";\r +export const Avatar = ({ uri }) => ;`, + ], + [ + "packages/native/src/image.tsx", + `import { Image } from "react-native"; +export const Avatar = ({ uri }) => ;`, + ], + [ + "packages/expo/src/image.web.tsx", + `import { Image } from "react-native"; +export const Avatar = ({ uri }) => ;`, + ], + [ + "packages/modern/src/shadow.tsx", + `export const Card = () => ;`, + ], + [ + "packages/old/src/shadow.tsx", + `export const Card = () => ;`, + ], + [ + "packages/disabled/src/shadow.tsx", + `export const Card = () => ;`, + ], + [ + "packages/disabled/android/gradle.properties", + `hermesEnabled=true +newArchEnabled=false`, + ], + [ + "packages/web/src/shadow.native.tsx", + `export const Card = () => ;`, + ], + [ + "packages/expo/src/shadow.web.tsx", + `export const Card = () => ;`, + ], + [ + "src/loose-image.tsx", + `import { Image } from "react-native"; +export const Avatar = ({ uri }) => ;`, + ], +]); +const PACKAGE_MANIFEST_VIRTUAL_PACKAGES: ReadonlyArray = [ + { + directoryPath: "packages/web", + manifest: { + name: "web-package", + private: true, + dependencies: { "react-dom": "19.1.0", react: "19.1.0" }, + }, + }, + { + directoryPath: "packages/cli", + manifest: { + name: "cli-package", + bin: "./dist/cli.js", + dependencies: { lodash: "4.17.21" }, + }, + }, + { + directoryPath: "packages/library", + manifest: { + name: "library-package", + peerDependencies: { react: "^19.0.0" }, + }, + }, + { + directoryPath: "packages/expo", + manifest: { + name: "expo-package", + dependencies: { expo: "53.0.0", "react-native": "0.80.0" }, + }, + installedDependencyVersions: { expo: "53.0.0", "react-native": "0.80.0" }, + }, + { + directoryPath: "packages/native", + manifest: { + name: "native-package", + dependencies: { "react-native": "0.80.0" }, + }, + installedDependencyVersions: { "react-native": "0.80.0" }, + }, + { + directoryPath: "packages/modern", + manifest: { + name: "modern-native-package", + dependencies: { "react-native": "0.80.0" }, + }, + installedDependencyVersions: { "react-native": "0.80.0" }, + }, + { + directoryPath: "packages/old", + manifest: { + name: "old-native-package", + dependencies: { "react-native": "0.75.4" }, + }, + installedDependencyVersions: { "react-native": "0.75.4" }, + }, + { + directoryPath: "packages/disabled", + manifest: { + name: "disabled-native-package", + dependencies: { "react-native": "0.80.0" }, + }, + installedDependencyVersions: { "react-native": "0.80.0" }, + }, +]; +const PACKAGE_MANIFEST_RESOURCE_FILES = new Map( + PACKAGE_MANIFEST_VIRTUAL_PACKAGES.map((projectPackage): [string, string] => [ + `${projectPackage.directoryPath}/package.json`, + JSON.stringify(projectPackage.manifest), + ]), +); +const BROWSER_HYDRATION_RULES: ReadonlyArray = [ + { ruleId: "no-hydration-branch-on-browser-global", severity: "error" }, + { ruleId: "no-match-media-in-state-initializer", severity: "warn" }, + { ruleId: "no-unguarded-browser-global-in-render-or-hook-init", severity: "error" }, + { ruleId: "rendering-hydration-mismatch-time", severity: "warn" }, + { ruleId: "window-open-without-noopener", severity: "warn" }, +]; +const BROWSER_HYDRATION_SETTINGS = { + "react-doctor": { + capabilities: ["react", "ssr"], + framework: "nextjs", + packageCapabilityGates: true, + }, +}; +const BROWSER_HYDRATION_PROJECT_FILES = new Map([ + [ + "packages/web/src/hydration-branch.tsx", + `"use client";\r +const marker = "😀";\r +export const HydrationBranch = () =>\r + typeof window === "undefined" ? : ;\r +export const StableBranch = () =>\r + typeof document === "undefined" ? same : same;`, + ], + [ + "packages/web/src/browser-read.tsx", + `import { useClientReady, useReadyOnServer } from "./hydration-hooks"; + +export const SafeBrowserRead = () => { + const hydrated = useClientReady(); + return hydrated && {document.title}; +}; + +export const UnsafeBrowserRead = () => { + const readyOnServer = useReadyOnServer(); + return readyOnServer && {window.innerWidth}; +};`, + ], + [ + "packages/web/src/hooks.ts", + `import { useSyncExternalStore } from "react"; + +const subscribe = () => () => {}; +const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false); +const useServerReady = () => useSyncExternalStore(subscribe, () => true, () => true); + +export { useHydrated, useServerReady };`, + ], + [ + "packages/web/src/hydration-hooks/index.ts", + `export { + useHydrated as useClientReady, + useServerReady as useReadyOnServer, +} from "../hooks";`, + ], + [ + "packages/web/src/media.tsx", + `import { useEffect, useState } from "react"; + +export const Media = () => { + const [isCompact] = useState(() => window.matchMedia("(max-width: 48rem)").matches); + useEffect(() => { + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + }, []); + return {String(isCompact)}; +};`, + ], + [ + "packages/web/src/time.tsx", + `export const CurrentTime = () => ; +export const IntentionalTime = () => ;`, + ], + [ + "packages/web/src/open-window.ts", + `import { + externalDownloadPage, + localDownloadTarget, +} from "./url-paths"; +import { missingDownloadPage } from "./missing-paths"; + +window.open(localDownloadTarget, "_blank"); +window.open(externalDownloadPage, "_blank"); +window.open(missingDownloadPage, "_blank"); +window.open("https://example.com", "_blank", "noopener");`, + ], + [ + "packages/web/src/open-window-helper.ts", + `import { + buildExternalUrl as buildExternalDownloadUrl, + buildInternalUrl as buildLocalDownloadUrl, +} from "./route-paths"; + +window.open(buildLocalDownloadUrl(), "_blank"); +window.open(buildExternalDownloadUrl(), "_blank");`, + ], + [ + "packages/web/src/route-paths.ts", + `export const internalDownloadPath = "/downloads/latest"; +export const externalPage = "https://downloads.example.com/latest"; +export const buildInternalUrl = () => "/downloads/archive"; +export const buildExternalUrl = () => "https://downloads.example.com/archive";`, + ], + [ + "packages/web/src/url-paths/index.ts", + `export { + externalPage as externalDownloadPage, + internalDownloadPath as localDownloadTarget, +} from "../route-paths";`, + ], + [ + "packages/native/src/skipped.tsx", + `import { useState } from "react"; + +export const NativeScreen = () => { + const [compact] = useState(matchMedia("(max-width: 48rem)").matches); + return typeof window === "undefined" + ? {Date.now()} + : {document.title}{String(compact)}; +};`, + ], + [ + "packages/native/src/active.web.tsx", + `export const WebOverride = () => ;`, + ], + [ + "packages/web/src/skipped.native.tsx", + `export const NativeOverride = () => ;`, + ], +]); +const BROWSER_HYDRATION_VIRTUAL_PACKAGES: ReadonlyArray = [ + { + directoryPath: "packages/web", + manifest: { + name: "web-package", + dependencies: { next: "15.4.0", react: "19.1.0", "react-dom": "19.1.0" }, + }, + }, + { + directoryPath: "packages/native", + manifest: { + name: "native-package", + dependencies: { next: "15.4.0", react: "19.1.0", "react-native": "0.80.0" }, + }, + }, +]; +const BROWSER_HYDRATION_RESOURCE_FILES = new Map( + BROWSER_HYDRATION_VIRTUAL_PACKAGES.map((projectPackage): [string, string] => [ + `${projectPackage.directoryPath}/package.json`, + JSON.stringify(projectPackage.manifest), + ]), +); +const NEXT_RULES: ReadonlyArray = [ + { ruleId: "nextjs-async-dynamic-api-not-awaited", severity: "error" }, + { ruleId: "nextjs-no-img-element", severity: "warn" }, +]; +const NEXT_PROJECT_FILES = new Map([ + [ + "packages/next15/app/page.tsx", + `"use client";\r +import { cookies } from "next/headers";\r +const marker = "😀";\r +export default function Page({ params }) {\r + const locale = params.locale;\r + return
{marker}{cookies().get("session")?.value}{locale}
;\r +}`, + ], + [ + "packages/next15/pages/legacy.tsx", + `import * as NextHeaders from "next/headers"; +export default function Legacy() { + return {NextHeaders.headers().get("host")}; +}`, + ], + [ + "packages/next15/lib/card.tsx", + `export const Card = () => ;`, + ], + ["packages/next15/lib/index.ts", `export { Card as SocialCard } from "./card";`], + [ + "packages/next15/lib/forwarded-card.tsx", + `import { SocialCard } from "./index"; +export const ForwardedCard = () => ;`, + ], + [ + "packages/next15/app/api/card/route.tsx", + `import { ImageResponse as OgResponse } from "next/og"; +import { ForwardedCard as Card } from "../../../lib/forwarded-card"; +export const GET = () => new OgResponse();`, + ], + [ + "packages/next14/app/page.tsx", + `import { cookies } from "next/headers"; +export default function Page() { + return
{cookies().get("session")}
; +}`, + ], + [ + "packages/web/src/component.tsx", + `import { cookies } from "next/headers"; +export const Component = () =>
{cookies().get("session")}
;`, + ], + [ + "packages/next15/app/unresolved.ts", + `import { missing } from "./missing"; +export const unresolved = missing;`, + ], +]); +const NEXT_VIRTUAL_PACKAGES: ReadonlyArray = [ + { + directoryPath: "packages/next15", + manifest: { + name: "next-15-app", + dependencies: { next: "15.4.0", react: "19.1.0", "react-dom": "19.1.0" }, + }, + }, + { + directoryPath: "packages/next14", + manifest: { + name: "next-14-app", + dependencies: { next: "14.2.0", react: "18.3.0", "react-dom": "18.3.0" }, + }, + }, + { + directoryPath: "packages/web", + manifest: { + name: "web-package", + dependencies: { react: "19.1.0", "react-dom": "19.1.0", vite: "7.0.0" }, + }, + }, +]; +const NEXT_RESOURCE_FILES = new Map( + NEXT_VIRTUAL_PACKAGES.map((projectPackage): [string, string] => [ + `${projectPackage.directoryPath}/package.json`, + JSON.stringify(projectPackage.manifest), + ]), +); +const createRulePackageDependency = ( + name: string, + resolvedSpecifier: string, +): Readonly> => ({ + name, + section: "dependencies", + rawSpecifier: resolvedSpecifier, + resolvedSpecifier, +}); +const createNextSettings = (rootDirectory: string): Readonly> => ({ + "react-doctor": { + rootDirectory, + capabilities: ["react", "nextjs", "nextjs:15", "nextjs:16"], + framework: "nextjs", + packageCapabilityGates: true, + packageContextEnabled: true, + packageContexts: [ + { + relativeDirectory: "packages/next15", + capabilities: ["react", "react:19", "nextjs", "nextjs:15"], + dependencies: [ + createRulePackageDependency("next", "15.4.0"), + createRulePackageDependency("react", "19.1.0"), + ], + }, + { + relativeDirectory: "packages/next14", + capabilities: ["react", "nextjs"], + dependencies: [ + createRulePackageDependency("next", "14.2.0"), + createRulePackageDependency("react", "18.3.0"), + ], + }, + { + relativeDirectory: "packages/web", + capabilities: ["react", "react:19", "vite"], + dependencies: [ + createRulePackageDependency("react", "19.1.0"), + createRulePackageDependency("vite", "7.0.0"), + ], + }, + ], + }, +}); + +const temporaryDirectories: string[] = []; +const esmRequire = createRequire(import.meta.url); +const oxlintMainPath = esmRequire.resolve("oxlint"); +const oxlintBinaryPath = path.join( + path.resolve(path.dirname(oxlintMainPath), ".."), + "bin", + "oxlint", +); +const pluginPath = path.resolve(import.meta.dirname, "../../dist/index.js"); + +const runOxlintProject = (input: OxlintProjectParityInput): OxlintRunResult => { + const temporaryDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-evaluator-parity-")), + ); + temporaryDirectories.push(temporaryDirectory); + const configPath = path.join(temporaryDirectory, "oxlintrc.json"); + const settings = + typeof input.settings === "function" ? input.settings(temporaryDirectory) : input.settings; + const projectResources = new Map(input.resourceFiles); + for (const [filename, sourceText] of input.files) { + projectResources.set(filename, sourceText); + } + for (const [filename, sourceText] of projectResources) { + const sourcePath = path.join(temporaryDirectory, filename); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, sourceText, "utf8"); + } + fs.writeFileSync( + configPath, + JSON.stringify({ + categories: { + correctness: "off", + suspicious: "off", + pedantic: "off", + perf: "off", + restriction: "off", + style: "off", + nursery: "off", + }, + plugins: [], + jsPlugins: [pluginPath], + ...(settings === undefined ? {} : { settings }), + rules: Object.fromEntries( + input.rules.map(({ ruleId, severity }) => [`react-doctor/${ruleId}`, severity]), + ), + }), + "utf8", + ); + + const oxlintResult = spawnSync( + process.execPath, + [ + oxlintBinaryPath, + "--config", + configPath, + "--format", + "json", + "--threads", + "1", + ...input.files.keys(), + ], + { + cwd: temporaryDirectory, + encoding: "utf8", + }, + ); + expect(oxlintResult.error).toBeUndefined(); + return { + output: JSON.parse(oxlintResult.stdout), + rootDirectory: temporaryDirectory, + status: oxlintResult.status, + stderr: oxlintResult.stderr, + }; +}; + +const runOxlint = (parityCase: EvaluatorParityCase): OxlintRunResult => + runOxlintProject({ + files: new Map([[parityCase.filename, parityCase.sourceText]]), + rules: [{ ruleId: parityCase.ruleId, severity: parityCase.severity }], + }); + +const normalizeOxlintDiagnostics = ( + result: OxlintRunResult, +): ReadonlyArray => + result.output.diagnostics.map((diagnostic) => ({ + filePath: path + .relative(result.rootDirectory, path.resolve(result.rootDirectory, diagnostic.filename)) + .replaceAll("\\", "/"), + rule: diagnostic.code.replace(/^react-doctor\((.+)\)$/, "$1"), + severity: diagnostic.severity, + message: diagnostic.message, + line: diagnostic.labels[0]?.span.line, + column: diagnostic.labels[0]?.span.column, + offset: diagnostic.labels[0]?.span.offset, + length: diagnostic.labels[0]?.span.length, + })); + +const normalizeEvaluatorDiagnostics = ( + diagnostics: ReadonlyArray, +): ReadonlyArray => + diagnostics.map((diagnostic) => ({ + filePath: diagnostic.filePath, + rule: diagnostic.rule, + severity: diagnostic.severity, + message: diagnostic.message, + line: diagnostic.line, + column: diagnostic.column, + offset: diagnostic.offset, + length: diagnostic.length, + })); + +const slugifyFixtureName = (fixtureName: string): string => + fixtureName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +const buildDifferentialFixtureFiles = ( + group: DifferentialFixtureGroup, +): ReadonlyMap => + new Map( + group.cases.map((fixtureCase, caseIndex) => [ + `corpus/${group.ruleId}/${String(caseIndex).padStart(2, "0")}-${slugifyFixtureName(fixtureCase.name)}.tsx`, + fixtureCase.sourceText, + ]), + ); + +const mergeEvaluatorResults = ( + results: ReadonlyArray, +): EvaluateSourceResult => ({ + diagnostics: results.flatMap((result) => result.diagnostics), + failures: results.flatMap((result) => result.failures), +}); + +const evaluateSourceFixtureGroup = ( + group: DifferentialFixtureGroup, + files: ReadonlyMap, +): EvaluateSourceResult => + mergeEvaluatorResults( + [...files].map(([filename, sourceText]) => + evaluateSource({ + sourceText, + filename, + ruleIds: [group.ruleId], + settings: group.settings, + }), + ), + ); + +const assertExpectedDifferentialCounts = ( + cases: ReadonlyArray, + files: ReadonlyMap, + diagnostics: ReadonlyArray, +): void => { + const filenames = [...files.keys()]; + for (const [caseIndex, fixtureCase] of cases.entries()) { + const filename = filenames[caseIndex]; + expect(filename).toBeDefined(); + expect( + diagnostics.filter((diagnostic) => diagnostic.filePath === filename), + fixtureCase.provenance, + ).toHaveLength(fixtureCase.expectedDiagnosticCount); + } +}; + +const evaluateComparableDiagnostics = ( + parityCase: EvaluatorParityCase, +): ReadonlyArray => { + const evaluatorResult = evaluateSource({ + sourceText: parityCase.sourceText, + filename: parityCase.filename, + ruleIds: [parityCase.ruleId], + }); + expect(evaluatorResult.failures).toEqual([]); + return normalizeEvaluatorDiagnostics(evaluatorResult.diagnostics); +}; + +afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +describe("in-process evaluator and Oxlint parity", () => { + for (const parityCase of PARITY_CORPUS) { + it(`matches Oxlint exactly for ${parityCase.ruleId}`, () => { + const oxlintResult = runOxlint(parityCase); + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(parityCase.severity === "error" ? 1 : 0); + expect(evaluateComparableDiagnostics(parityCase)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + }); + } + + describe("deterministic differential fixture corpus", () => { + for (const group of DIFFERENTIAL_FIXTURE_GROUPS) { + it(`matches ${group.cases.length} ${group.ruleId} fixtures across repeated state`, () => { + const files = buildDifferentialFixtureFiles(group); + const oxlintResult = runOxlintProject({ + files, + rules: [{ ruleId: group.ruleId, severity: group.severity }], + settings: group.settings, + }); + const expectedDiagnosticCount = group.cases.reduce( + (total, fixtureCase) => total + fixtureCase.expectedDiagnosticCount, + 0, + ); + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe( + group.severity === "error" && expectedDiagnosticCount > 0 ? 1 : 0, + ); + + let evaluatorResult: EvaluateSourceResult; + let repeatedEvaluatorResult: EvaluateSourceResult; + if (group.evaluationMode === "source") { + evaluatorResult = evaluateSourceFixtureGroup(group, files); + repeatedEvaluatorResult = evaluateSourceFixtureGroup(group, files); + } else { + const realResourceHost = createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }); + const realResult = evaluateProject({ + files, + resourceHost: realResourceHost, + ruleIds: [group.ruleId], + settings: group.settings, + }); + const repeatedRealResult = evaluateProject({ + files, + resourceHost: realResourceHost, + ruleIds: [group.ruleId], + settings: group.settings, + }); + const virtualRootDirectory = `/virtual-differential-${group.ruleId}`; + const virtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files, + ruleIds: [group.ruleId], + settings: group.settings, + }); + const repeatedVirtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files, + ruleIds: [group.ruleId], + settings: group.settings, + }); + + expect(repeatedRealResult).toEqual(realResult); + expect(virtualResult).toEqual(realResult); + expect(repeatedVirtualResult).toEqual(virtualResult); + evaluatorResult = virtualResult; + repeatedEvaluatorResult = repeatedVirtualResult; + } + + expect(repeatedEvaluatorResult).toEqual(evaluatorResult); + expect(evaluatorResult.failures).toEqual([]); + assertExpectedDifferentialCounts(group.cases, files, evaluatorResult.diagnostics); + expect(normalizeEvaluatorDiagnostics(evaluatorResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + }); + } + + for (const [projectIndex, projectCase] of DIFFERENTIAL_VIRTUAL_PROJECT_CASES.entries()) { + it(`matches virtual project: ${projectCase.name}`, () => { + const oxlintResult = runOxlintProject({ + files: projectCase.files, + rules: [{ ruleId: projectCase.ruleId, severity: projectCase.severity }], + }); + const realResourceHost = createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }); + const realResult = evaluateProject({ + files: projectCase.files, + resourceHost: realResourceHost, + ruleIds: [projectCase.ruleId], + }); + const repeatedRealResult = evaluateProject({ + files: projectCase.files, + resourceHost: realResourceHost, + ruleIds: [projectCase.ruleId], + }); + const virtualRootDirectory = `/virtual-differential-project-${projectIndex}`; + const virtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files: projectCase.files, + ruleIds: [projectCase.ruleId], + }); + const repeatedVirtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files: projectCase.files, + ruleIds: [projectCase.ruleId], + }); + const expectedDiagnosticCount = [ + ...projectCase.expectedDiagnosticCountByFile.values(), + ].reduce((total, count) => total + count, 0); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe( + projectCase.severity === "error" && expectedDiagnosticCount > 0 ? 1 : 0, + ); + expect(repeatedRealResult).toEqual(realResult); + expect(virtualResult).toEqual(realResult); + expect(repeatedVirtualResult).toEqual(virtualResult); + expect(virtualResult.failures, projectCase.provenance).toEqual([]); + for (const filename of projectCase.files.keys()) { + expect( + virtualResult.diagnostics.filter((diagnostic) => diagnostic.filePath === filename), + projectCase.provenance, + ).toHaveLength(projectCase.expectedDiagnosticCountByFile.get(filename) ?? 0); + } + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + }); + } + + it("keeps unsupported, unknown, and parse failures exact across repeated state", () => { + const input = { + sourceText: 'const marker = "😀";\r\nconst broken = ;', + filename: "src/broken.ts", + ruleIds: ["missing-differential-rule", "no-barrel-import", "no-eval"], + }; + const firstResult = evaluateSource(input); + const repeatedResult = evaluateSource(input); + + expect(repeatedResult).toEqual(firstResult); + expect(firstResult).toEqual({ + diagnostics: [], + failures: [ + { + kind: "unknown-rule", + filePath: "src/broken.ts", + rule: "missing-differential-rule", + message: "Unknown React Doctor rule: missing-differential-rule", + }, + { + kind: "unsupported-rule", + filePath: "src/broken.ts", + rule: "no-barrel-import", + message: "Rule requires a project host: no-barrel-import", + }, + { + kind: "parse", + filePath: "src/broken.ts", + message: "Unexpected token", + line: 2, + column: 16, + offset: 39, + length: 1, + }, + ], + }); + }); + + it("serializes rule crashes in deterministic file and rule order across repeated state", () => { + const crashingRuleSettings = new Proxy>( + {}, + { + get: (_target, property) => { + if (property === "buttonHasType") { + throw new Error("button settings unavailable"); + } + return undefined; + }, + }, + ); + const input = { + rootDirectory: "/virtual-rule-crash-project", + files: new Map([ + [ + "src/z-last.tsx", + `eval(first); +export const Last = () => ; +eval(second);`, + ], + [ + "src/a-first.tsx", + `export const First = () => ; +eval(third);`, + ], + ]), + ruleIds: [ + "missing-differential-rule", + "active-static-asset", + "button-has-type", + "no-eval", + "button-has-type", + ], + settings: { + "react-doctor": crashingRuleSettings, + }, + }; + const firstResult = evaluateVirtualProject(input); + const repeatedResult = evaluateVirtualProject(input); + + expect(repeatedResult).toEqual(firstResult); + expect( + firstResult.diagnostics.map(({ filePath, rule, line }) => ({ filePath, rule, line })), + ).toEqual([ + { filePath: "src/z-last.tsx", rule: "no-eval", line: 1 }, + { filePath: "src/z-last.tsx", rule: "no-eval", line: 3 }, + { filePath: "src/a-first.tsx", rule: "no-eval", line: 2 }, + ]); + expect(firstResult.failures).toEqual([ + { + kind: "unknown-rule", + filePath: "src/z-last.tsx", + rule: "missing-differential-rule", + message: "Unknown React Doctor rule: missing-differential-rule", + }, + { + kind: "unsupported-rule", + filePath: "src/z-last.tsx", + rule: "active-static-asset", + message: "Rule requires a project host: active-static-asset", + }, + { + kind: "rule-crash", + filePath: "src/z-last.tsx", + rule: "button-has-type", + message: "button settings unavailable", + }, + { + kind: "rule-crash", + filePath: "src/z-last.tsx", + rule: "button-has-type", + message: "button settings unavailable", + }, + { + kind: "unknown-rule", + filePath: "src/a-first.tsx", + rule: "missing-differential-rule", + message: "Unknown React Doctor rule: missing-differential-rule", + }, + { + kind: "unsupported-rule", + filePath: "src/a-first.tsx", + rule: "active-static-asset", + message: "Rule requires a project host: active-static-asset", + }, + { + kind: "rule-crash", + filePath: "src/a-first.tsx", + rule: "button-has-type", + message: "button settings unavailable", + }, + { + kind: "rule-crash", + filePath: "src/a-first.tsx", + rule: "button-has-type", + message: "button settings unavailable", + }, + ]); + }); + }); + + it("preserves exact multi-file and multi-rule ordering across repeated evaluations", () => { + const files = new Map([ + [ + "src/z-last.tsx", + `import { useState } from "react"; + +export const Last = ({ enabled }: { enabled: boolean }) => { + if (enabled) useState(0); + return ; +}; +eval(lastSource);`, + ], + [ + "src/a-first.tsx", + `eval(firstSource); +export const First = () => ;`, + ], + ]); + const rules: ReadonlyArray = [ + { ruleId: "no-access-key", severity: "warn" }, + { ruleId: "button-has-type", severity: "warn" }, + { ruleId: "rules-of-hooks", severity: "error" }, + { ruleId: "no-eval", severity: "error" }, + ]; + const firstOxlintResult = runOxlintProject({ files, rules }); + const secondOxlintResult = runOxlintProject({ files, rules }); + expect(firstOxlintResult.stderr).toBe(""); + expect(secondOxlintResult.stderr).toBe(""); + expect(firstOxlintResult.status).toBe(1); + expect(secondOxlintResult.status).toBe(1); + + const resourceHost = createRealFilesystemResourceHost({ + rootDirectory: firstOxlintResult.rootDirectory, + }); + const evaluatorInput = { + files, + resourceHost, + ruleIds: rules.map(({ ruleId }) => ruleId), + }; + const firstEvaluatorResult = evaluateProject(evaluatorInput); + const poisoningResult = evaluateSource({ + sourceText: `import { useState } from "react"; + +export const Poison = ({ enabled }: { enabled: boolean }) => { + if (enabled) useState(0); + return null; +};`, + filename: "src/poison.tsx", + ruleIds: ["rules-of-hooks"], + }); + const secondEvaluatorResult = evaluateProject(evaluatorInput); + + expect(poisoningResult.failures).toEqual([]); + expect(poisoningResult.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ + "rules-of-hooks", + ]); + expect(firstEvaluatorResult.failures).toEqual([]); + expect(secondEvaluatorResult).toEqual(firstEvaluatorResult); + expect(normalizeOxlintDiagnostics(secondOxlintResult)).toEqual( + normalizeOxlintDiagnostics(firstOxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(firstEvaluatorResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(firstOxlintResult), + ); + }); + + it("matches the complete React Router family across real, virtual, and built hosts", () => { + const oxlintResult = runOxlintProject({ + files: REACT_ROUTER_PROJECT_FILES, + resourceFiles: REACT_ROUTER_RESOURCE_FILES, + rules: REACT_ROUTER_RULES, + settings: createReactRouterSettings, + }); + const realResult = evaluateProject({ + files: REACT_ROUTER_PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: REACT_ROUTER_RULE_IDS, + settings: createReactRouterSettings(oxlintResult.rootDirectory), + }); + const virtualRootDirectory = "/virtual-react-router-project"; + const virtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files: REACT_ROUTER_PROJECT_FILES, + packages: REACT_ROUTER_VIRTUAL_PACKAGES, + ruleIds: REACT_ROUTER_RULE_IDS, + settings: createReactRouterSettings(virtualRootDirectory), + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(1); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "src/route.tsx", + rule: "react-router-no-navigate-in-render", + severity: "error", + message: + "navigate() runs during render and can cause navigation loops or hydration divergence.", + line: 5, + column: 3, + offset: 110, + length: 17, + }, + ]); + }); + + it("keeps the complete React Router family quiet outside React Router packages", () => { + const files = new Map([ + ["packages/web/src/route.tsx", REACT_ROUTER_ROUTE_SOURCE_TEXT], + ]); + const resourceFiles = new Map([ + [ + "packages/web/package.json", + JSON.stringify({ + name: "vite-project", + dependencies: { react: "19.1.0", vite: "7.0.0" }, + }), + ], + ]); + const oxlintResult = runOxlintProject({ + files, + resourceFiles, + rules: REACT_ROUTER_RULES, + settings: createReactRouterSettings, + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: REACT_ROUTER_RULE_IDS, + settings: createReactRouterSettings(oxlintResult.rootDirectory), + }); + const virtualRootDirectory = "/virtual-non-react-router-project"; + const virtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files, + packages: [ + { + directoryPath: "packages/web", + manifest: { + name: "vite-project", + dependencies: { react: "19.1.0", vite: "7.0.0" }, + }, + installedDependencyVersions: { react: "19.1.0", vite: "7.0.0" }, + }, + ], + ruleIds: REACT_ROUTER_RULE_IDS, + settings: createReactRouterSettings(virtualRootDirectory), + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(normalizeOxlintDiagnostics(oxlintResult)).toEqual([]); + expect(realResult).toEqual({ diagnostics: [], failures: [] }); + expect(virtualResult).toEqual(realResult); + }); + + it("keeps the React Router family unsupported without a project host", () => { + const result = evaluateSource({ + sourceText: REACT_ROUTER_ROUTE_SOURCE_TEXT, + filename: "src/route.tsx", + ruleIds: REACT_ROUTER_RULE_IDS, + settings: createReactRouterSettings("/virtual-react-router-project"), + }); + + expect(result.diagnostics).toEqual([]); + expect(result.failures).toEqual( + REACT_ROUTER_RULE_IDS.map((ruleId) => ({ + kind: "unsupported-rule", + filePath: "src/route.tsx", + rule: ruleId, + message: `Rule requires a project host: ${ruleId}`, + })), + ); + }); + + it("matches React Native framework gating across real, virtual, and built hosts", () => { + const oxlintResult = runOxlintProject({ + files: REACT_NATIVE_PROJECT_FILES, + resourceFiles: REACT_NATIVE_RESOURCE_FILES, + rules: [{ ruleId: REACT_NATIVE_RULE_ID, severity: "error" }], + settings: REACT_NATIVE_SETTINGS, + }); + const realResult = evaluateProject({ + files: REACT_NATIVE_PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: [REACT_NATIVE_RULE_ID], + settings: REACT_NATIVE_SETTINGS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-react-native-project", + files: REACT_NATIVE_PROJECT_FILES, + packages: REACT_NATIVE_VIRTUAL_PACKAGES, + ruleIds: [REACT_NATIVE_RULE_ID], + settings: REACT_NATIVE_SETTINGS, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(1); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "packages/native/src/app.tsx", + rule: REACT_NATIVE_RULE_ID, + severity: "error", + message: + 'Your users hit a crash when raw "😀 Crash" renders outside a component on React Native.', + line: 5, + column: 11, + offset: 85, + length: 10, + }, + { + filePath: "packages/native/src/app.tsx", + rule: REACT_NATIVE_RULE_ID, + severity: "error", + message: + 'Your users hit a crash when raw "Direct crash" renders outside a component on React Native.', + line: 7, + column: 11, + offset: 137, + length: 12, + }, + { + filePath: "packages/web/src/component.native.tsx", + rule: REACT_NATIVE_RULE_ID, + severity: "error", + message: + 'Your users hit a crash when raw "Native override text" renders outside a component on React Native.', + line: 1, + column: 44, + offset: 43, + length: 20, + }, + { + filePath: "src/loose.tsx", + rule: REACT_NATIVE_RULE_ID, + severity: "error", + message: + 'Your users hit a crash when raw "Framework text" renders outside a component on React Native.', + line: 1, + column: 43, + offset: 42, + length: 14, + }, + ]); + expect( + virtualResult.diagnostics.map( + ({ filePath, line, column, endLine, endColumn, offset, length }) => ({ + filePath, + line, + column, + endLine, + endColumn, + offset, + length, + }), + ), + ).toEqual([ + { + filePath: "packages/native/src/app.tsx", + line: 5, + column: 11, + endLine: 5, + endColumn: 21, + offset: 85, + length: 10, + }, + { + filePath: "packages/native/src/app.tsx", + line: 7, + column: 11, + endLine: 7, + endColumn: 23, + offset: 137, + length: 12, + }, + { + filePath: "packages/web/src/component.native.tsx", + line: 1, + column: 44, + endLine: 1, + endColumn: 64, + offset: 43, + length: 20, + }, + { + filePath: "src/loose.tsx", + line: 1, + column: 43, + endLine: 1, + endColumn: 57, + offset: 42, + length: 14, + }, + ]); + }); + + it("matches an explicit web framework gate across real, virtual, and built hosts", () => { + const files = new Map([ + ["src/ambiguous.tsx", `export const Component = () => Web text;`], + ]); + const oxlintResult = runOxlintProject({ + files, + rules: [{ ruleId: REACT_NATIVE_RULE_ID, severity: "error" }], + settings: WEB_SETTINGS, + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: [REACT_NATIVE_RULE_ID], + settings: WEB_SETTINGS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-web-project", + files, + ruleIds: [REACT_NATIVE_RULE_ID], + settings: WEB_SETTINGS, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(normalizeOxlintDiagnostics(oxlintResult)).toEqual([]); + expect(realResult).toEqual({ diagnostics: [], failures: [] }); + expect(virtualResult).toEqual(realResult); + }); + + it("matches an explicit missing React Native capability across all hosts", () => { + const files = new Map([ + ["src/ambiguous.tsx", `export const Component = () => Native text;`], + ]); + const oxlintResult = runOxlintProject({ + files, + rules: [{ ruleId: REACT_NATIVE_RULE_ID, severity: "error" }], + settings: MISSING_REACT_NATIVE_CAPABILITY_SETTINGS, + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: [REACT_NATIVE_RULE_ID], + settings: MISSING_REACT_NATIVE_CAPABILITY_SETTINGS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-missing-react-native-capability-project", + files, + ruleIds: [REACT_NATIVE_RULE_ID], + settings: MISSING_REACT_NATIVE_CAPABILITY_SETTINGS, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(normalizeOxlintDiagnostics(oxlintResult)).toEqual([]); + expect(realResult).toEqual({ diagnostics: [], failures: [] }); + expect(virtualResult).toEqual(realResult); + }); + + it("matches Next.js package, version, cross-file ownership, and UTF-8 spans across hosts", () => { + const oxlintResult = runOxlintProject({ + files: NEXT_PROJECT_FILES, + resourceFiles: NEXT_RESOURCE_FILES, + rules: NEXT_RULES, + settings: createNextSettings, + }); + const realSettings = createNextSettings(oxlintResult.rootDirectory); + const realResult = evaluateProject({ + files: NEXT_PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: NEXT_RULES.map(({ ruleId }) => ruleId), + settings: realSettings, + }); + const virtualRootDirectory = "virtual-next-project"; + const virtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files: NEXT_PROJECT_FILES, + packages: NEXT_VIRTUAL_PACKAGES, + ruleIds: NEXT_RULES.map(({ ruleId }) => ruleId), + settings: createNextSettings(virtualRootDirectory), + }); + const repeatedVirtualResult = evaluateVirtualProject({ + rootDirectory: virtualRootDirectory, + files: NEXT_PROJECT_FILES, + packages: NEXT_VIRTUAL_PACKAGES, + ruleIds: NEXT_RULES.map(({ ruleId }) => ruleId), + settings: createNextSettings(virtualRootDirectory), + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(1); + expect(virtualResult).toEqual(realResult); + expect(repeatedVirtualResult).toEqual(virtualResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "packages/next15/app/page.tsx", + rule: "nextjs-async-dynamic-api-not-awaited", + severity: "error", + message: + "This Next.js request API returns a Promise. Synchronous property access warns in Next.js 15 and is removed in Next.js 16; await it or unwrap it with React `use()`.", + line: 5, + column: 18, + offset: 141, + length: 6, + }, + { + filePath: "packages/next15/app/page.tsx", + rule: "nextjs-async-dynamic-api-not-awaited", + severity: "error", + message: + "This Next.js request API returns a Promise. Synchronous property access warns in Next.js 15 and is removed in Next.js 16; await it or unwrap it with React `use()`.", + line: 6, + column: 25, + offset: 181, + length: 9, + }, + { + filePath: "packages/next15/app/page.tsx", + rule: "nextjs-no-img-element", + severity: "warning", + message: "Plain ships unoptimized, oversized images.", + line: 6, + column: 65, + offset: 221, + length: 23, + }, + { + filePath: "packages/next15/pages/legacy.tsx", + rule: "nextjs-async-dynamic-api-not-awaited", + severity: "error", + message: + "This Next.js request API returns a Promise. Synchronous property access warns in Next.js 15 and is removed in Next.js 16; await it or unwrap it with React `use()`.", + line: 3, + column: 17, + offset: 96, + length: 21, + }, + { + filePath: "packages/next15/pages/legacy.tsx", + rule: "nextjs-no-img-element", + severity: "warning", + message: "Plain ships unoptimized, oversized images.", + line: 3, + column: 51, + offset: 130, + length: 25, + }, + { + filePath: "packages/next14/app/page.tsx", + rule: "nextjs-no-img-element", + severity: "warning", + message: "Plain ships unoptimized, oversized images.", + line: 3, + column: 42, + offset: 114, + length: 26, + }, + ]); + }); + + it("fails closed identically when a generated-image consumer graph cannot parse", () => { + const files = new Map([ + [ + "packages/next15/lib/card.tsx", + `export const Card = () => ;`, + ], + ]); + const resourceFiles = new Map([ + ...NEXT_RESOURCE_FILES, + [ + "packages/next15/app/api/card/route.tsx", + `import { ImageResponse } from "next/og"; +import { Card } from "../../../lib/card"; +export const GET = () => new ImageResponse();`, + ], + ["packages/next15/app/broken.tsx", `export const broken = ;`], + ]); + const oxlintResult = runOxlintProject({ + files, + resourceFiles, + rules: [{ ruleId: "nextjs-no-img-element", severity: "warn" }], + settings: createNextSettings, + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: ["nextjs-no-img-element"], + settings: createNextSettings(oxlintResult.rootDirectory), + }); + const virtualResources = new Map(resourceFiles); + for (const [filename, sourceText] of files) virtualResources.set(filename, sourceText); + const virtualRootDirectory = "/virtual-malformed-next-project"; + const virtualResult = evaluateProject({ + files, + resourceHost: createInMemoryResourceHost({ + rootDirectory: virtualRootDirectory, + files: virtualResources, + packages: NEXT_VIRTUAL_PACKAGES, + }), + ruleIds: ["nextjs-no-img-element"], + settings: createNextSettings(virtualRootDirectory), + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "packages/next15/lib/card.tsx", + rule: "nextjs-no-img-element", + severity: "warning", + message: "Plain ships unoptimized, oversized images.", + line: 1, + column: 27, + offset: 26, + length: 37, + }, + ]); + }); + + it("matches package-manifest and architecture gates across all hosts", () => { + const oxlintResult = runOxlintProject({ + files: PACKAGE_MANIFEST_PROJECT_FILES, + resourceFiles: PACKAGE_MANIFEST_RESOURCE_FILES, + rules: PACKAGE_MANIFEST_RULES, + settings: PACKAGE_MANIFEST_SETTINGS, + }); + const realResult = evaluateProject({ + files: PACKAGE_MANIFEST_PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: PACKAGE_MANIFEST_RULES.map(({ ruleId }) => ruleId), + settings: PACKAGE_MANIFEST_SETTINGS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-package-manifest-project", + files: PACKAGE_MANIFEST_PROJECT_FILES, + packages: PACKAGE_MANIFEST_VIRTUAL_PACKAGES, + ruleIds: PACKAGE_MANIFEST_RULES.map(({ ruleId }) => ruleId), + settings: PACKAGE_MANIFEST_SETTINGS, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "packages/web/src/lodash.ts", + rule: "no-full-lodash-import", + severity: "warning", + message: + "Importing all of lodash ships the whole library to your users & slows page load. Import from 'lodash/functionName' instead.", + line: 2, + column: 1, + offset: 24, + length: 28, + }, + { + filePath: "packages/expo/src/image.tsx", + rule: "rn-prefer-expo-image", + severity: "warning", + message: + "Your users watch images reload often because Image from react-native has no caching.", + line: 2, + column: 10, + offset: 33, + length: 20, + }, + { + filePath: "packages/modern/src/shadow.tsx", + rule: "rn-no-legacy-shadow-styles", + severity: "warning", + message: + 'Shadow style "shadowOpacity" only work on one platform, so your users on the other see no shadow.', + line: 1, + column: 40, + offset: 39, + length: 22, + }, + { + filePath: "packages/modern/src/shadow.tsx", + rule: "rn-style-prefer-boxshadow", + severity: "warning", + message: "Your users on the other platform see no shadow when you use shadowOpacity.", + line: 1, + column: 42, + offset: 41, + length: 18, + }, + { + filePath: "packages/web/src/shadow.native.tsx", + rule: "rn-no-legacy-shadow-styles", + severity: "warning", + message: + 'Shadow style "elevation" only work on one platform, so your users on the other see no shadow.', + line: 1, + column: 40, + offset: 39, + length: 16, + }, + { + filePath: "packages/web/src/shadow.native.tsx", + rule: "rn-style-prefer-boxshadow", + severity: "warning", + message: "Your users on the other platform see no shadow when you use elevation.", + line: 1, + column: 42, + offset: 41, + length: 12, + }, + { + filePath: "src/loose-image.tsx", + rule: "rn-prefer-expo-image", + severity: "warning", + message: + "Your users watch images reload often because Image from react-native has no caching.", + line: 1, + column: 10, + offset: 9, + length: 5, + }, + ]); + expect( + virtualResult.diagnostics.map( + ({ filePath, rule, line, column, endLine, endColumn, offset, length }) => ({ + filePath, + rule, + line, + column, + endLine, + endColumn, + offset, + length, + }), + ), + ).toEqual([ + { + filePath: "packages/web/src/lodash.ts", + rule: "no-full-lodash-import", + line: 2, + column: 1, + endLine: 2, + endColumn: 29, + offset: 24, + length: 28, + }, + { + filePath: "packages/expo/src/image.tsx", + rule: "rn-prefer-expo-image", + line: 2, + column: 10, + endLine: 2, + endColumn: 30, + offset: 33, + length: 20, + }, + { + filePath: "packages/modern/src/shadow.tsx", + rule: "rn-no-legacy-shadow-styles", + line: 1, + column: 40, + endLine: 1, + endColumn: 62, + offset: 39, + length: 22, + }, + { + filePath: "packages/modern/src/shadow.tsx", + rule: "rn-style-prefer-boxshadow", + line: 1, + column: 42, + endLine: 1, + endColumn: 60, + offset: 41, + length: 18, + }, + { + filePath: "packages/web/src/shadow.native.tsx", + rule: "rn-no-legacy-shadow-styles", + line: 1, + column: 40, + endLine: 1, + endColumn: 56, + offset: 39, + length: 16, + }, + { + filePath: "packages/web/src/shadow.native.tsx", + rule: "rn-style-prefer-boxshadow", + line: 1, + column: 42, + endLine: 1, + endColumn: 54, + offset: 41, + length: 12, + }, + { + filePath: "src/loose-image.tsx", + rule: "rn-prefer-expo-image", + line: 1, + column: 10, + endLine: 1, + endColumn: 15, + offset: 9, + length: 5, + }, + ]); + }); + + it("matches browser and hydration rules across real, virtual, and built hosts", () => { + const oxlintResult = runOxlintProject({ + files: BROWSER_HYDRATION_PROJECT_FILES, + resourceFiles: BROWSER_HYDRATION_RESOURCE_FILES, + rules: BROWSER_HYDRATION_RULES, + settings: BROWSER_HYDRATION_SETTINGS, + }); + const realResult = evaluateProject({ + files: BROWSER_HYDRATION_PROJECT_FILES, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: BROWSER_HYDRATION_RULES.map(({ ruleId }) => ruleId), + settings: BROWSER_HYDRATION_SETTINGS, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-browser-hydration-project", + files: BROWSER_HYDRATION_PROJECT_FILES, + packages: BROWSER_HYDRATION_VIRTUAL_PACKAGES, + ruleIds: BROWSER_HYDRATION_RULES.map(({ ruleId }) => ruleId), + settings: BROWSER_HYDRATION_SETTINGS, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(1); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "packages/web/src/hydration-branch.tsx", + rule: "no-hydration-branch-on-browser-global", + severity: "error", + message: + "`typeof window` selects different rendered output on the server and during hydration. Render the same initial output, then switch after mount.", + line: 4, + column: 3, + offset: 79, + length: 29, + }, + { + filePath: "packages/web/src/browser-read.tsx", + rule: "no-unguarded-browser-global-in-render-or-hook-init", + severity: "error", + message: + "`window` is read while React is rendering on the server, where browser globals are unavailable. Move the read into an effect or event, or provide a stable server snapshot.", + line: 10, + column: 34, + offset: 321, + length: 6, + }, + { + filePath: "packages/web/src/media.tsx", + rule: "no-match-media-in-state-initializer", + severity: "warning", + message: + "`matchMedia()` in a useState initializer can cause an SSR crash or seed different server and hydration state. Prefer CSS media queries for layout, or use `useSyncExternalStore` with a stable server snapshot.", + line: 4, + column: 38, + offset: 112, + length: 39, + }, + { + filePath: "packages/web/src/media.tsx", + rule: "no-unguarded-browser-global-in-render-or-hook-init", + severity: "error", + message: + "`window` is read while React is rendering on the server, where browser globals are unavailable. Move the read into an effect or event, or provide a stable server snapshot.", + line: 4, + column: 38, + offset: 112, + length: 6, + }, + { + filePath: "packages/web/src/time.tsx", + rule: "rendering-hydration-mismatch-time", + severity: "warning", + message: + "This can cause a hydration mismatch because Date.now() in JSX gives a different value on the server than in the browser. Move it into useEffect+useState to run only in the browser, or add suppressHydrationWarning to the parent if it's on purpose.", + line: 1, + column: 40, + offset: 39, + length: 12, + }, + { + filePath: "packages/web/src/open-window.ts", + rule: "window-open-without-noopener", + severity: "warning", + message: + "This `window.open` call leaves the opened page able to redirect your tab via `window.opener`, so pass `'noopener'` in the features argument.", + line: 8, + column: 1, + offset: 178, + length: 43, + }, + { + filePath: "packages/web/src/open-window.ts", + rule: "window-open-without-noopener", + severity: "warning", + message: + "This `window.open` call leaves the opened page able to redirect your tab via `window.opener`, so pass `'noopener'` in the features argument.", + line: 9, + column: 1, + offset: 223, + length: 42, + }, + { + filePath: "packages/web/src/open-window-helper.ts", + rule: "window-open-without-noopener", + severity: "warning", + message: + "This `window.open` call leaves the opened page able to redirect your tab via `window.opener`, so pass `'noopener'` in the features argument.", + line: 7, + column: 1, + offset: 175, + length: 49, + }, + { + filePath: "packages/native/src/active.web.tsx", + rule: "rendering-hydration-mismatch-time", + severity: "warning", + message: + "This can cause a hydration mismatch because Date.now() in JSX gives a different value on the server than in the browser. Move it into useEffect+useState to run only in the browser, or add suppressHydrationWarning to the parent if it's on purpose.", + line: 1, + column: 40, + offset: 39, + length: 12, + }, + ]); + }); + + it("fails closed identically when an imported browser destination cannot parse", () => { + const files = new Map([ + [ + "src/open-window.ts", + `import { downloadPage } from "./invalid-paths"; +window.open(downloadPage, "_blank");`, + ], + ]); + const resourceFiles = new Map([["src/invalid-paths.ts", "export const downloadPage = ;"]]); + const oxlintResult = runOxlintProject({ + files, + resourceFiles, + rules: [{ ruleId: "window-open-without-noopener", severity: "warn" }], + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: ["window-open-without-noopener"], + }); + const virtualResources = new Map(resourceFiles); + for (const [filename, sourceText] of files) { + virtualResources.set(filename, sourceText); + } + const virtualResult = evaluateProject({ + files, + resourceHost: createInMemoryResourceHost({ + rootDirectory: "/virtual-invalid-browser-destination-project", + files: virtualResources, + }), + ruleIds: ["window-open-without-noopener"], + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(virtualResult).toEqual(realResult); + expect(virtualResult.failures).toEqual([]); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + expect(normalizeEvaluatorDiagnostics(virtualResult.diagnostics)).toEqual([ + { + filePath: "src/open-window.ts", + rule: "window-open-without-noopener", + severity: "warning", + message: + "This `window.open` call leaves the opened page able to redirect your tab via `window.opener`, so pass `'noopener'` in the features argument.", + line: 2, + column: 1, + offset: 48, + length: 35, + }, + ]); + }); + + it("matches the missing SSR capability gate across all hosts", () => { + const files = new Map([ + [ + "src/hydration.tsx", + `"use client"; +export const Hydration = () => + typeof window === "undefined" ? : ;`, + ], + ]); + const settings = { + "react-doctor": { + capabilities: ["react"], + framework: "nextjs", + packageCapabilityGates: true, + }, + }; + const oxlintResult = runOxlintProject({ + files, + rules: BROWSER_HYDRATION_RULES, + settings, + }); + const realResult = evaluateProject({ + files, + resourceHost: createRealFilesystemResourceHost({ + rootDirectory: oxlintResult.rootDirectory, + }), + ruleIds: BROWSER_HYDRATION_RULES.map(({ ruleId }) => ruleId), + settings, + }); + const virtualResult = evaluateVirtualProject({ + rootDirectory: "/virtual-missing-ssr-capability-project", + files, + ruleIds: BROWSER_HYDRATION_RULES.map(({ ruleId }) => ruleId), + settings, + }); + + expect(oxlintResult.stderr).toBe(""); + expect(oxlintResult.status).toBe(0); + expect(normalizeOxlintDiagnostics(oxlintResult)).toEqual([]); + expect(realResult).toEqual({ diagnostics: [], failures: [] }); + expect(virtualResult).toEqual(realResult); + }); + + it("rejects project-host and project-scan rules instead of claiming parity", () => { + const result = evaluateSource({ + sourceText: "export const value = true;", + filename: "src/value.ts", + ruleIds: [ + "nextjs-async-dynamic-api-not-awaited", + "nextjs-no-img-element", + "no-barrel-import", + "active-static-asset", + ], + }); + + expect(result.diagnostics).toEqual([]); + expect(result.failures).toEqual([ + { + kind: "unsupported-rule", + filePath: "src/value.ts", + rule: "nextjs-async-dynamic-api-not-awaited", + message: "Rule requires a project host: nextjs-async-dynamic-api-not-awaited", + }, + { + kind: "unsupported-rule", + filePath: "src/value.ts", + rule: "nextjs-no-img-element", + message: "Rule requires a project host: nextjs-no-img-element", + }, + { + kind: "unsupported-rule", + filePath: "src/value.ts", + rule: "no-barrel-import", + message: "Rule requires a project host: no-barrel-import", + }, + { + kind: "unsupported-rule", + filePath: "src/value.ts", + rule: "active-static-asset", + message: "Rule requires a project host: active-static-asset", + }, + ]); + }); + + const inlineSuppressionCases: ReadonlyArray = [ + { + name: "global disable and enable regions", + sourceText: "// oxlint-disable\neval(first);\n// oxlint-enable\neval(second);", + expectedDiagnosticLines: [4], + }, + { + name: "plugin-prefixed rule disable and enable regions", + sourceText: + "// oxlint-disable react-doctor/no-eval\neval(first);\n// oxlint-enable react-doctor/no-eval\neval(second);", + expectedDiagnosticLines: [4], + }, + { + name: "bare rule IDs that Oxlint does not bind to a plugin rule", + sourceText: + "// oxlint-disable no-eval\neval(first);\n// oxlint-enable no-eval\neval(second);", + expectedDiagnosticLines: [2, 4], + }, + { + name: "global disable-line", + sourceText: "eval(first); // oxlint-disable-line\neval(second);", + expectedDiagnosticLines: [2], + }, + { + name: "plugin-prefixed disable-line with a UTF-8 boundary", + sourceText: + 'const emoji = "😀";\neval(first); // oxlint-disable-line react-doctor/no-eval\neval(second);', + expectedDiagnosticLines: [3], + }, + { + name: "global disable-next-line across CRLF boundaries", + sourceText: "// oxlint-disable-next-line\r\neval(first);\r\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "disable-next-line with lone CR compatibility", + sourceText: "// oxlint-disable-next-line\reval(first);\reval(second);", + expectedDiagnosticLines: [], + }, + { + name: "disable-next-line with U+2028 compatibility", + sourceText: "// oxlint-disable-next-line\u2028eval(first);\u2028eval(second);", + expectedDiagnosticLines: [], + }, + { + name: "disable-next-line with U+2029 compatibility", + sourceText: "// oxlint-disable-next-line\u2029eval(first);\u2029eval(second);", + expectedDiagnosticLines: [], + }, + { + name: "plugin-prefixed disable-next-line", + sourceText: "// oxlint-disable-next-line react-doctor/no-eval\neval(first);\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "disable-line before a diagnostic on the same line", + sourceText: "/* oxlint-disable-line react-doctor/no-eval */ eval(first);\neval(second);", + expectedDiagnosticLines: [1, 2], + }, + { + name: "block disable-next-line before same-line and following-line diagnostics", + sourceText: + "/* oxlint-disable-next-line react-doctor/no-eval */ eval(first);\neval(second);\neval(third);", + expectedDiagnosticLines: [3], + }, + { + name: "multiple comma-separated rule IDs", + sourceText: + "// oxlint-disable-next-line react-doctor/button-has-type, react-doctor/no-eval\neval(first);\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "multiple whitespace-separated rule IDs", + sourceText: + "// oxlint-disable-next-line react-doctor/button-has-type react-doctor/no-eval\neval(first);\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "rule list followed by a description", + sourceText: + "// oxlint-disable-next-line react-doctor/no-eval -- intentional fixture\neval(first);\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "global disable followed by rule-specific enable", + sourceText: + "// oxlint-disable\neval(first);\n// oxlint-enable react-doctor/no-eval\neval(second);\n// oxlint-enable\neval(third);", + expectedDiagnosticLines: [6], + }, + { + name: "rule-specific disable followed by global enable", + sourceText: + "// oxlint-disable react-doctor/no-eval\neval(first);\n// oxlint-enable\neval(second);\n// oxlint-enable react-doctor/no-eval\neval(third);", + expectedDiagnosticLines: [6], + }, + { + name: "multiline block disable-next-line", + sourceText: + "/* oxlint-disable-next-line react-doctor/no-eval\nbecause this fixture is intentional */\neval(first);\neval(second);", + expectedDiagnosticLines: [4], + }, + { + name: "ESLint-compatible disable-next-line", + sourceText: "// eslint-disable-next-line react-doctor/no-eval\neval(first);\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "region directives sharing a line with diagnostics", + sourceText: "/* oxlint-disable */ eval(first); /* oxlint-enable */ eval(second);", + expectedDiagnosticLines: [1], + }, + { + name: "region directive inside a diagnostic span", + sourceText: + "eval(/* oxlint-disable react-doctor/no-eval */ first);\n// oxlint-enable react-doctor/no-eval\neval(second);", + expectedDiagnosticLines: [3], + }, + { + name: "directive text inside strings and templates", + sourceText: + 'const line = "// oxlint-disable";\nconst block = `/* oxlint-disable react-doctor/no-eval */`;\neval(first);', + expectedDiagnosticLines: [3], + }, + ]; + + for (const suppressionCase of inlineSuppressionCases) { + it(`matches Oxlint for ${suppressionCase.name}`, () => { + const parityCase: EvaluatorParityCase = { + filename: "inline-suppression.ts", + ruleId: "no-eval", + severity: "error", + sourceText: suppressionCase.sourceText, + }; + const oxlintResult = runOxlint(parityCase); + expect(oxlintResult.stderr).toBe(""); + expect( + oxlintResult.output.diagnostics.map((diagnostic) => diagnostic.labels[0]?.span.line), + ).toEqual(suppressionCase.expectedDiagnosticLines); + expect(evaluateComparableDiagnostics(parityCase)).toEqual( + normalizeOxlintDiagnostics(oxlintResult), + ); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/execute-rule.ts b/packages/oxlint-plugin-react-doctor/src/internal/execute-rule.ts new file mode 100644 index 0000000000..533445b716 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/execute-rule.ts @@ -0,0 +1,101 @@ +import { runWithResourceHost } from "./resource-host/resource-host-context.js"; +import type { ResourceHost } from "./resource-host/resource-host.js"; +import { analyzeControlFlow } from "../plugin/semantic/control-flow-graph.js"; +import type { ControlFlowAnalysis } from "../plugin/semantic/control-flow-graph.js"; +import { analyzeScopes } from "../plugin/semantic/scope-analysis.js"; +import type { ScopeAnalysis } from "../plugin/semantic/scope-analysis.js"; +import { attachParentReferences } from "../plugin/utils/attach-parent-references.js"; +import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; +import { isAstNode } from "../plugin/utils/is-ast-node.js"; +import type { ReportDescriptor } from "../plugin/utils/report-descriptor.js"; +import type { Rule } from "../plugin/utils/rule.js"; +import type { RuleContext } from "../plugin/utils/rule-context.js"; +import type { RuleVisitors } from "../plugin/utils/rule-visitors.js"; +import { attachSourceLocations } from "./attach-source-locations.js"; +import type { ParseSourceResult } from "./parse-source.js"; + +export interface ExecuteRuleOptions { + readonly filename?: string; + readonly settings?: Readonly>; + readonly resourceHost?: ResourceHost; + readonly forceJsx?: boolean; +} + +export interface ExecutedRuleDiagnostic { + readonly message: string; + readonly node: EsTreeNode; +} + +export interface ExecuteRuleResult { + readonly diagnostics: ReadonlyArray; + readonly parseErrors: ReadonlyArray<{ readonly message: string }>; +} + +const dispatchRuleVisitors = (root: EsTreeNode, visitors: RuleVisitors): void => { + const visitNode = (node: EsTreeNode): void => { + const enterHandler = visitors[node.type]; + if (typeof enterHandler === "function") enterHandler(node); + const nodeRecord = node as unknown as Record; + for (const key of Object.keys(nodeRecord)) { + if (key === "parent") continue; + const child = nodeRecord[key]; + if (Array.isArray(child)) { + for (const childNode of child) { + if (isAstNode(childNode)) visitNode(childNode); + } + } else if (isAstNode(child)) { + visitNode(child); + } + } + const exitHandler = visitors[`${node.type}:exit`]; + if (typeof exitHandler === "function") exitHandler(node); + }; + visitNode(root); +}; + +export const executeRule = ( + rule: Rule, + sourceText: string, + parsedSource: ParseSourceResult, + options: ExecuteRuleOptions = {}, +): ExecuteRuleResult => { + attachParentReferences(parsedSource.program); + attachSourceLocations(parsedSource.program, sourceText); + + const diagnostics: ExecutedRuleDiagnostic[] = []; + let scopes: ScopeAnalysis | undefined; + let controlFlow: ControlFlowAnalysis | undefined; + const context: RuleContext = { + report: (descriptor: ReportDescriptor) => { + diagnostics.push({ + message: descriptor.message, + node: descriptor.node, + }); + }, + filename: "filename" in options ? options.filename : "fixture.tsx", + settings: options.settings, + get scopes() { + scopes ??= analyzeScopes(parsedSource.program); + return scopes; + }, + get cfg() { + controlFlow ??= analyzeControlFlow(parsedSource.program); + return controlFlow; + }, + }; + + const runRule = (): void => { + const visitors = rule.create(context); + dispatchRuleVisitors(parsedSource.program, visitors); + }; + if (options.resourceHost) { + runWithResourceHost(options.resourceHost, runRule); + } else { + runRule(); + } + + return { + diagnostics, + parseErrors: parsedSource.errors.map((parseError) => ({ message: parseError.message })), + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/get-source-position.ts b/packages/oxlint-plugin-react-doctor/src/internal/get-source-position.ts new file mode 100644 index 0000000000..bec99b920d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/get-source-position.ts @@ -0,0 +1,26 @@ +interface SourcePosition { + readonly line: number; + readonly column: number; +} + +export const getSourcePosition = (sourceText: string, sourceIndex: number): SourcePosition => { + const boundedSourceIndex = Math.max(0, Math.min(sourceIndex, sourceText.length)); + let line = 1; + let lineStartIndex = 0; + for (let index = 0; index < boundedSourceIndex; index++) { + const character = sourceText[index]; + if (character === "\n") { + line++; + lineStartIndex = index + 1; + continue; + } + if (character === "\r" && sourceText[index + 1] !== "\n") { + line++; + lineStartIndex = index + 1; + } + } + return { + line, + column: Buffer.byteLength(sourceText.slice(lineStartIndex, boundedSourceIndex)) + 1, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/parse-source.ts b/packages/oxlint-plugin-react-doctor/src/internal/parse-source.ts new file mode 100644 index 0000000000..a0e8a93476 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/parse-source.ts @@ -0,0 +1,61 @@ +import * as path from "node:path"; +import { parseSync } from "oxc-parser"; +import type { Comment } from "oxc-parser"; +import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; + +export interface ParseSourceOptions { + readonly filename?: string; + readonly forceJsx?: boolean; +} + +export interface ParseSourceError { + readonly message: string; + readonly start?: number; + readonly end?: number; +} + +export interface ParseSourceResult { + readonly program: EsTreeNode; + readonly comments: ReadonlyArray; + readonly errors: ReadonlyArray; +} + +const FILENAME_TO_LANGUAGE: Record = { + ".ts": "ts", + ".tsx": "tsx", + ".js": "js", + ".jsx": "jsx", + ".mjs": "js", + ".cjs": "js", + ".mts": "ts", + ".cts": "ts", +}; + +const resolveLanguage = (filename: string): "ts" | "tsx" | "js" | "jsx" => { + const extension = path.extname(filename).toLowerCase(); + return FILENAME_TO_LANGUAGE[extension] ?? "tsx"; +}; + +export const parseSource = ( + sourceText: string, + options: ParseSourceOptions = {}, +): ParseSourceResult => { + const filename = options.filename ?? "fixture.tsx"; + const language = options.forceJsx ? "tsx" : resolveLanguage(filename); + const parseResult = parseSync(filename, sourceText, { + astType: "ts", + lang: language, + preserveParens: false, + }); + return { + program: parseResult.program as unknown as EsTreeNode, + comments: parseResult.comments, + errors: parseResult.errors.map((parseError) => { + const primaryLabel = parseError.labels[0]; + return { + message: parseError.message, + ...(primaryLabel ? { start: primaryLabel.start, end: primaryLabel.end } : {}), + }; + }), + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/constants.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/constants.ts new file mode 100644 index 0000000000..78efbdf014 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/constants.ts @@ -0,0 +1,8 @@ +import type { ResourceDependency } from "./resource-host.js"; + +export const RESOURCE_DEPENDENCY_SECTIONS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] satisfies ReadonlyArray; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/create-resource-host.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/create-resource-host.ts new file mode 100644 index 0000000000..337a51a759 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/create-resource-host.ts @@ -0,0 +1,41 @@ +import { parseResourceManifest } from "./parse-resource-manifest.js"; +import { findOwningResourcePackage, getResourceDependency } from "./resolve-resource-package.js"; +import { + resolveResourceModuleFileFromAbsolutePath, + resolveResourceImport, + resolveResourceRelativeImport, + resolveResourceTsconfigAlias, +} from "./resolve-resource-module.js"; +import type { ResourceHost, ResourceHostBackend } from "./resource-host.js"; + +export const createResourceHost = (backend: ResourceHostBackend): ResourceHost => ({ + rootDirectory: backend.rootDirectory, + normalizePath: backend.normalizePath, + readSource: (filePath) => backend.readText(backend.normalizePath(filePath)), + readManifest: (manifestPath) => + parseResourceManifest(backend.readText(backend.normalizePath(manifestPath))), + getPathKind: (resourcePath) => backend.getPathKind(backend.normalizePath(resourcePath)), + fileExists: (filePath) => backend.getPathKind(backend.normalizePath(filePath)) === "file", + directoryExists: (directoryPath) => + backend.getPathKind(backend.normalizePath(directoryPath)) === "directory", + listDirectory: (directoryPath, maximumEntries) => { + const boundedMaximumEntries = Number.isFinite(maximumEntries) + ? Math.max(0, Math.floor(maximumEntries)) + : 0; + const directoryEntries = backend.readDirectory(backend.normalizePath(directoryPath)); + return { + entries: directoryEntries.slice(0, boundedMaximumEntries), + didReachLimit: directoryEntries.length > boundedMaximumEntries, + }; + }, + resolveModuleFile: (absoluteModulePath) => + resolveResourceModuleFileFromAbsolutePath(backend, backend.normalizePath(absoluteModulePath)), + resolveRelativeImport: (fromFilename, source) => + resolveResourceRelativeImport(backend, fromFilename, source), + resolveTsconfigAlias: (fromFilename, source) => + resolveResourceTsconfigAlias(backend, fromFilename, source), + resolveImport: (fromFilename, source) => resolveResourceImport(backend, fromFilename, source), + findOwningPackage: (filePath) => findOwningResourcePackage(backend, filePath), + getDependency: (filePath, dependencyName) => + getResourceDependency(backend, filePath, dependencyName), +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host-backend.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host-backend.ts new file mode 100644 index 0000000000..b93984cfa1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host-backend.ts @@ -0,0 +1,90 @@ +import * as path from "node:path"; +import { isResourceWithinRoot } from "./is-resource-within-root.js"; +import { normalizeResourcePath } from "./normalize-resource-path.js"; +import type { + InMemoryResourceHostInput, + ResourceDirectoryEntry, + ResourceHostBackend, +} from "./resource-host.js"; + +const addDirectoryAndAncestors = ( + directories: Set, + rootDirectory: string, + directoryPath: string, +): void => { + let currentDirectory = directoryPath; + while (true) { + directories.add(currentDirectory); + if (currentDirectory === rootDirectory) return; + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return; + currentDirectory = parentDirectory; + } +}; + +export const createInMemoryResourceHostBackend = ({ + rootDirectory, + files, + directories: inputDirectories = [], +}: InMemoryResourceHostInput): ResourceHostBackend => { + const normalizedRootDirectory = normalizeResourcePath(process.cwd(), rootDirectory); + const normalizePath = (resourcePath: string): string => + normalizeResourcePath(normalizedRootDirectory, resourcePath); + const normalizedFiles = new Map(); + const directories = new Set([normalizedRootDirectory]); + + for (const [filePath, sourceText] of files) { + const normalizedFilePath = normalizePath(filePath); + if (!isResourceWithinRoot(normalizedRootDirectory, normalizedFilePath)) continue; + normalizedFiles.set(normalizedFilePath, sourceText); + addDirectoryAndAncestors( + directories, + normalizedRootDirectory, + path.dirname(normalizedFilePath), + ); + } + for (const directoryPath of inputDirectories) { + const normalizedDirectoryPath = normalizePath(directoryPath); + if (!isResourceWithinRoot(normalizedRootDirectory, normalizedDirectoryPath)) continue; + addDirectoryAndAncestors(directories, normalizedRootDirectory, normalizedDirectoryPath); + } + + return { + rootDirectory: normalizedRootDirectory, + normalizePath, + readText: (filePath) => normalizedFiles.get(normalizePath(filePath)) ?? null, + getPathKind: (resourcePath) => { + const normalizedPath = normalizePath(resourcePath); + if (normalizedFiles.has(normalizedPath)) return "file"; + if (directories.has(normalizedPath)) return "directory"; + return null; + }, + readDirectory: (directoryPath) => { + const normalizedDirectoryPath = normalizePath(directoryPath); + if (!directories.has(normalizedDirectoryPath)) return []; + const entriesByName = new Map(); + for (const filePath of normalizedFiles.keys()) { + if (path.dirname(filePath) !== normalizedDirectoryPath) continue; + const name = path.basename(filePath); + entriesByName.set(name, { name, path: filePath, kind: "file" }); + } + for (const childDirectoryPath of directories) { + if ( + childDirectoryPath === normalizedDirectoryPath || + path.dirname(childDirectoryPath) !== normalizedDirectoryPath + ) { + continue; + } + const name = path.basename(childDirectoryPath); + entriesByName.set(name, { + name, + path: childDirectoryPath, + kind: "directory", + }); + } + return [...entriesByName.values()].toSorted((firstEntry, secondEntry) => + firstEntry.name.localeCompare(secondEntry.name), + ); + }, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host.ts new file mode 100644 index 0000000000..497f430878 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/in-memory-resource-host.ts @@ -0,0 +1,120 @@ +import * as path from "node:path"; +import { RESOURCE_DEPENDENCY_SECTIONS } from "./constants.js"; +import { createResourceHost } from "./create-resource-host.js"; +import { createInMemoryResourceHostBackend } from "./in-memory-resource-host-backend.js"; +import { isResourceWithinRoot } from "./is-resource-within-root.js"; +import type { + InMemoryResourceHostInput, + InMemoryResourcePackageInput, + ResourceHost, + ResourcePackage, +} from "./resource-host.js"; + +interface NormalizedInMemoryResourcePackage { + readonly resourcePackage: ResourcePackage; + readonly installedDependencyVersions: Readonly>; +} + +const normalizePackage = ( + resourceHost: ResourceHost, + inputPackage: InMemoryResourcePackageInput, +): NormalizedInMemoryResourcePackage => { + const directoryPath = resourceHost.normalizePath(inputPackage.directoryPath); + return { + resourcePackage: { + directoryPath, + manifestPath: resourceHost.normalizePath(path.join(directoryPath, "package.json")), + manifest: inputPackage.manifest, + }, + installedDependencyVersions: inputPackage.installedDependencyVersions ?? {}, + }; +}; + +const findOwningPackage = ( + resourceHost: ResourceHost, + packages: ReadonlyArray, + filePath: string, +): NormalizedInMemoryResourcePackage | null => { + const normalizedFilePath = resourceHost.normalizePath(filePath); + let closestPackage: NormalizedInMemoryResourcePackage | null = null; + for (const candidatePackage of packages) { + if (!isResourceWithinRoot(candidatePackage.resourcePackage.directoryPath, normalizedFilePath)) { + continue; + } + if ( + closestPackage === null || + candidatePackage.resourcePackage.directoryPath.length > + closestPackage.resourcePackage.directoryPath.length + ) { + closestPackage = candidatePackage; + } + } + return closestPackage; +}; + +export const createInMemoryResourceHost = (input: InMemoryResourceHostInput): ResourceHost => { + const baseResourceHost = createResourceHost(createInMemoryResourceHostBackend(input)); + const packages: NormalizedInMemoryResourcePackage[] = []; + for (const inputPackage of input.packages ?? []) { + const normalizedPackage = normalizePackage(baseResourceHost, inputPackage); + if ( + isResourceWithinRoot( + baseResourceHost.rootDirectory, + normalizedPackage.resourcePackage.directoryPath, + ) + ) { + packages.push(normalizedPackage); + } + } + if (packages.length === 0) return baseResourceHost; + + const findPackageDescription = (filePath: string): NormalizedInMemoryResourcePackage | null => + findOwningPackage(baseResourceHost, packages, filePath); + const findPackage = (filePath: string): ResourcePackage | null => { + const describedPackage = findPackageDescription(filePath)?.resourcePackage ?? null; + const fileBasedPackage = baseResourceHost.findOwningPackage(filePath); + if (!describedPackage) return fileBasedPackage; + if (!fileBasedPackage) return describedPackage; + return describedPackage.directoryPath.length >= fileBasedPackage.directoryPath.length + ? describedPackage + : fileBasedPackage; + }; + + return { + ...baseResourceHost, + readManifest: (manifestPath) => { + const normalizedManifestPath = baseResourceHost.normalizePath(manifestPath); + const matchingPackage = packages.find( + (candidatePackage) => + candidatePackage.resourcePackage.manifestPath === normalizedManifestPath, + ); + return ( + matchingPackage?.resourcePackage.manifest ?? baseResourceHost.readManifest(manifestPath) + ); + }, + findOwningPackage: findPackage, + getDependency: (filePath, dependencyName) => { + const describedPackage = findPackageDescription(filePath); + const owningPackage = findPackage(filePath); + if ( + !describedPackage || + !owningPackage || + describedPackage.resourcePackage.directoryPath !== owningPackage.directoryPath + ) { + return baseResourceHost.getDependency(filePath, dependencyName); + } + for (const section of RESOURCE_DEPENDENCY_SECTIONS) { + const rawSpecifier = owningPackage.manifest[section]?.[dependencyName]; + if (typeof rawSpecifier !== "string") continue; + return { + name: dependencyName, + packageDirectory: owningPackage.directoryPath, + section, + rawSpecifier, + installedVersion: describedPackage.installedDependencyVersions[dependencyName] ?? null, + }; + } + return null; + }, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/is-resource-within-root.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/is-resource-within-root.ts new file mode 100644 index 0000000000..f412b34951 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/is-resource-within-root.ts @@ -0,0 +1,6 @@ +import * as path from "node:path"; + +export const isResourceWithinRoot = (rootDirectory: string, resourcePath: string): boolean => { + const relativePath = path.relative(rootDirectory, resourcePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/normalize-resource-path.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/normalize-resource-path.ts new file mode 100644 index 0000000000..95101848f3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/normalize-resource-path.ts @@ -0,0 +1,5 @@ +import * as path from "node:path"; +import { normalizeFilename } from "../../plugin/utils/normalize-filename.js"; + +export const normalizeResourcePath = (rootDirectory: string, resourcePath: string): string => + normalizeFilename(path.resolve(rootDirectory, normalizeFilename(resourcePath))); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/parse-resource-manifest.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/parse-resource-manifest.ts new file mode 100644 index 0000000000..aa76754b9d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/parse-resource-manifest.ts @@ -0,0 +1,14 @@ +import type { PackageManifest } from "../../plugin/utils/read-nearest-package-manifest.js"; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const parseResourceManifest = (sourceText: string | null): PackageManifest | null => { + if (sourceText === null) return null; + try { + const parsedValue: unknown = JSON.parse(sourceText); + return isObjectRecord(parsedValue) ? parsedValue : null; + } catch { + return null; + } +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host-backend.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host-backend.ts new file mode 100644 index 0000000000..d17db88e4d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host-backend.ts @@ -0,0 +1,84 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + recordContentProbe, + recordExistenceProbe, +} from "../../plugin/utils/cross-file-probe-recorder.js"; +import { isResourceWithinRoot } from "./is-resource-within-root.js"; +import { normalizeResourcePath } from "./normalize-resource-path.js"; +import type { + RealFilesystemResourceHostInput, + ResourceDirectoryEntry, + ResourceHostBackend, +} from "./resource-host.js"; + +const getDirectoryEntryKind = (directoryEntry: fs.Dirent): ResourceDirectoryEntry["kind"] => { + if (directoryEntry.isFile()) return "file"; + if (directoryEntry.isDirectory()) return "directory"; + return "other"; +}; + +export const createRealResourceHostBackend = ({ + rootDirectory, +}: RealFilesystemResourceHostInput): ResourceHostBackend => { + const normalizedRootDirectory = normalizeResourcePath(process.cwd(), rootDirectory); + const normalizePath = (resourcePath: string): string => + normalizeResourcePath(normalizedRootDirectory, resourcePath); + + return { + rootDirectory: normalizedRootDirectory, + normalizePath, + readText: (filePath) => { + const normalizedFilePath = normalizePath(filePath); + if (!isResourceWithinRoot(normalizedRootDirectory, normalizedFilePath)) return null; + recordContentProbe(normalizedFilePath); + try { + return fs.readFileSync(normalizedFilePath, "utf8"); + } catch { + return null; + } + }, + getPathKind: (resourcePath) => { + const normalizedPath = normalizePath(resourcePath); + if (!isResourceWithinRoot(normalizedRootDirectory, normalizedPath)) return null; + recordExistenceProbe(normalizedPath); + try { + const resourceStat = fs.statSync(normalizedPath); + if (resourceStat.isFile()) return "file"; + if (resourceStat.isDirectory()) return "directory"; + return "other"; + } catch { + return null; + } + }, + readDirectory: (directoryPath) => { + const normalizedDirectoryPath = normalizePath(directoryPath); + if (!isResourceWithinRoot(normalizedRootDirectory, normalizedDirectoryPath)) return []; + recordExistenceProbe(normalizedDirectoryPath); + try { + return fs + .readdirSync(normalizedDirectoryPath, { withFileTypes: true }) + .map((directoryEntry) => ({ + name: directoryEntry.name, + path: normalizePath(path.join(normalizedDirectoryPath, directoryEntry.name)), + kind: getDirectoryEntryKind(directoryEntry), + })) + .toSorted((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name)); + } catch { + return []; + } + }, + }; +}; + +const realResourceHostBackendsByRoot = new Map(); + +export const getRealResourceHostBackend = (resourcePath: string): ResourceHostBackend => { + const absoluteResourcePath = path.resolve(resourcePath); + const rootDirectory = path.parse(absoluteResourcePath).root; + const cachedBackend = realResourceHostBackendsByRoot.get(rootDirectory); + if (cachedBackend) return cachedBackend; + const backend = createRealResourceHostBackend({ rootDirectory }); + realResourceHostBackendsByRoot.set(rootDirectory, backend); + return backend; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host.ts new file mode 100644 index 0000000000..3d725f6aa1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/real-resource-host.ts @@ -0,0 +1,7 @@ +import { createResourceHost } from "./create-resource-host.js"; +import { createRealResourceHostBackend } from "./real-resource-host-backend.js"; +import type { RealFilesystemResourceHostInput, ResourceHost } from "./resource-host.js"; + +export const createRealFilesystemResourceHost = ( + input: RealFilesystemResourceHostInput, +): ResourceHost => createResourceHost(createRealResourceHostBackend(input)); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-module.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-module.ts new file mode 100644 index 0000000000..fb8927517c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-module.ts @@ -0,0 +1,342 @@ +import * as path from "node:path"; +import { + CROSS_FILE_DIRECTORY_WALK_MAX_LEVELS, + TSCONFIG_EXTENDS_MAX_DEPTH, +} from "../../plugin/constants/thresholds.js"; +import { isResourceWithinRoot } from "./is-resource-within-root.js"; +import type { ResourceHostBackend } from "./resource-host.js"; + +interface ResolvedResourceTsconfig { + readonly baseAbsolutePath: string; + readonly hasExplicitBaseUrl: boolean; + readonly paths: ReadonlyMap; +} + +const MODULE_FILE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]; +const PACKAGE_EXPORT_CONDITIONS = ["import", "default", "module", "browser", "require"]; +const PACKAGE_ENTRY_FIELDS = ["module", "main", "browser"]; +const TSCONFIG_FILE_NAMES = ["tsconfig.json", "jsconfig.json"]; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const getConditionalExportEntry = (exportEntry: unknown): string | null => { + if (typeof exportEntry === "string") return exportEntry; + if (Array.isArray(exportEntry)) { + for (const fallbackEntry of exportEntry) { + const resolvedFallbackEntry = getConditionalExportEntry(fallbackEntry); + if (resolvedFallbackEntry) return resolvedFallbackEntry; + } + return null; + } + if (!isObjectRecord(exportEntry)) return null; + + for (const condition of PACKAGE_EXPORT_CONDITIONS) { + const nestedEntry = getConditionalExportEntry(exportEntry[condition]); + if (nestedEntry) return nestedEntry; + } + return null; +}; + +const getPackageExportEntry = (packageManifest: Record): string | null => { + const exportsField = packageManifest.exports; + if (!exportsField) return null; + const directExportEntry = getConditionalExportEntry(exportsField); + if (directExportEntry) return directExportEntry; + return isObjectRecord(exportsField) ? getConditionalExportEntry(exportsField["."]) : null; +}; + +const getModuleFilePathCandidates = (modulePath: string): ReadonlyArray => { + const extension = path.extname(modulePath); + if (!extension) { + return MODULE_FILE_EXTENSIONS.map((moduleExtension) => `${modulePath}${moduleExtension}`); + } + + const modulePathWithoutExtension = modulePath.slice(0, -extension.length); + if (extension === ".js") { + return [ + modulePath, + `${modulePathWithoutExtension}.ts`, + `${modulePathWithoutExtension}.tsx`, + `${modulePathWithoutExtension}.jsx`, + ]; + } + if (extension === ".jsx") return [modulePath, `${modulePathWithoutExtension}.tsx`]; + if (extension === ".mjs") return [modulePath, `${modulePathWithoutExtension}.mts`]; + if (extension === ".cjs") return [modulePath, `${modulePathWithoutExtension}.cts`]; + return [modulePath]; +}; + +const resolveModuleFilePath = (backend: ResourceHostBackend, modulePath: string): string | null => { + const normalizedModulePath = backend.normalizePath(modulePath); + if (backend.getPathKind(normalizedModulePath) === "file") return normalizedModulePath; + for (const candidateFilePath of getModuleFilePathCandidates(normalizedModulePath)) { + if (backend.getPathKind(candidateFilePath) === "file") { + return backend.normalizePath(candidateFilePath); + } + } + return null; +}; + +const resolveModulePathWithIndexFallback = ( + backend: ResourceHostBackend, + modulePath: string, +): string | null => + resolveModuleFilePath(backend, modulePath) ?? + resolveModuleFilePath(backend, path.join(modulePath, "index")); + +const resolvePackageDirectoryEntry = ( + backend: ResourceHostBackend, + directoryPath: string, +): string | null => { + const normalizedDirectoryPath = backend.normalizePath(directoryPath); + if (backend.getPathKind(normalizedDirectoryPath) !== "directory") return null; + const packageSourceText = backend.readText(path.join(normalizedDirectoryPath, "package.json")); + if (packageSourceText === null) return null; + + try { + const packageManifest: unknown = JSON.parse(packageSourceText); + if (!isObjectRecord(packageManifest)) return null; + const packageEntry = + getPackageExportEntry(packageManifest) ?? + PACKAGE_ENTRY_FIELDS.map((fieldName) => packageManifest[fieldName]).find( + (fieldValue): fieldValue is string => typeof fieldValue === "string", + ); + return packageEntry + ? resolveModulePathWithIndexFallback( + backend, + backend.normalizePath(path.resolve(normalizedDirectoryPath, packageEntry)), + ) + : null; + } catch { + return null; + } +}; + +export const resolveResourceModuleFileFromAbsolutePath = ( + backend: ResourceHostBackend, + importPath: string, +): string | null => + resolveModuleFilePath(backend, importPath) ?? + resolvePackageDirectoryEntry(backend, importPath) ?? + resolveModuleFilePath(backend, path.join(importPath, "index")); + +const stripJsonComments = (sourceText: string): string => { + let output = ""; + let isInsideString = false; + let isInsideLineComment = false; + let isInsideBlockComment = false; + for (let index = 0; index < sourceText.length; index++) { + const character = sourceText[index]; + const nextCharacter = sourceText[index + 1]; + if (isInsideLineComment) { + if (character === "\n") { + isInsideLineComment = false; + output += character; + } + continue; + } + if (isInsideBlockComment) { + if (character === "*" && nextCharacter === "/") { + isInsideBlockComment = false; + index++; + } + continue; + } + if (isInsideString) { + output += character; + if (character === "\\") { + output += nextCharacter ?? ""; + index++; + } else if (character === '"') { + isInsideString = false; + } + continue; + } + if (character === '"') { + isInsideString = true; + output += character; + continue; + } + if (character === "/" && nextCharacter === "/") { + isInsideLineComment = true; + index++; + continue; + } + if (character === "/" && nextCharacter === "*") { + isInsideBlockComment = true; + index++; + continue; + } + output += character; + } + return output.replace(/,(\s*[}\]])/g, "$1"); +}; + +const parsePathsField = (pathsField: unknown): ReadonlyMap => { + const paths = new Map(); + if (!isObjectRecord(pathsField)) return paths; + for (const [pattern, targets] of Object.entries(pathsField)) { + if (!Array.isArray(targets)) continue; + const stringTargets = targets.filter( + (targetValue): targetValue is string => typeof targetValue === "string", + ); + if (stringTargets.length > 0) paths.set(pattern, stringTargets); + } + return paths; +}; + +const resolveExtendsPath = ( + backend: ResourceHostBackend, + extendsValue: string, + fromConfigDirectory: string, +): string => { + const withExtension = extendsValue.endsWith(".json") ? extendsValue : `${extendsValue}.json`; + return backend.normalizePath( + extendsValue.startsWith("./") || extendsValue.startsWith("../") + ? path.resolve(fromConfigDirectory, withExtension) + : path.join(fromConfigDirectory, "node_modules", withExtension), + ); +}; + +const readResolvedTsconfig = ( + backend: ResourceHostBackend, + configFilePath: string, + extendsDepth: number, +): ResolvedResourceTsconfig | null => { + const sourceText = backend.readText(configFilePath); + if (sourceText === null) return null; + + try { + const parsedConfig: unknown = JSON.parse(stripJsonComments(sourceText)); + if (!isObjectRecord(parsedConfig)) return null; + const configDirectory = path.dirname(configFilePath); + const compilerOptions = isObjectRecord(parsedConfig.compilerOptions) + ? parsedConfig.compilerOptions + : {}; + const baseUrlValue = + typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : null; + const hasExplicitBaseUrl = baseUrlValue !== null; + const baseAbsolutePath = backend.normalizePath( + baseUrlValue === null ? configDirectory : path.resolve(configDirectory, baseUrlValue), + ); + + if (isObjectRecord(compilerOptions.paths)) { + return { + baseAbsolutePath, + hasExplicitBaseUrl, + paths: parsePathsField(compilerOptions.paths), + }; + } + if (typeof parsedConfig.extends === "string" && extendsDepth < TSCONFIG_EXTENDS_MAX_DEPTH) { + const inheritedConfig = readResolvedTsconfig( + backend, + resolveExtendsPath(backend, parsedConfig.extends, configDirectory), + extendsDepth + 1, + ); + if (inheritedConfig) return inheritedConfig; + } + return hasExplicitBaseUrl ? { baseAbsolutePath, hasExplicitBaseUrl, paths: new Map() } : null; + } catch { + return null; + } +}; + +const findNearestTsconfig = ( + backend: ResourceHostBackend, + fromDirectory: string, +): ResolvedResourceTsconfig | null => { + let currentDirectory = backend.normalizePath(fromDirectory); + for (let level = 0; level < CROSS_FILE_DIRECTORY_WALK_MAX_LEVELS; level++) { + if (!isResourceWithinRoot(backend.rootDirectory, currentDirectory)) return null; + for (const fileName of TSCONFIG_FILE_NAMES) { + const candidateConfig = readResolvedTsconfig( + backend, + backend.normalizePath(path.join(currentDirectory, fileName)), + 0, + ); + if (candidateConfig) return candidateConfig; + } + if (currentDirectory === backend.rootDirectory) return null; + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return null; + currentDirectory = parentDirectory; + } + return null; +}; + +const matchPathPattern = (source: string, pattern: string): string | null => { + const starIndex = pattern.indexOf("*"); + if (starIndex === -1) return source === pattern ? "" : null; + const prefix = pattern.slice(0, starIndex); + const suffix = pattern.slice(starIndex + 1); + return source.length >= prefix.length + suffix.length && + source.startsWith(prefix) && + source.endsWith(suffix) + ? source.slice(prefix.length, source.length - suffix.length) + : null; +}; + +export const resolveResourceRelativeImport = ( + backend: ResourceHostBackend, + fromFilename: string, + source: string, +): string | null => + resolveResourceModuleFileFromAbsolutePath( + backend, + backend.normalizePath(path.resolve(path.dirname(backend.normalizePath(fromFilename)), source)), + ); + +export const resolveResourceTsconfigAlias = ( + backend: ResourceHostBackend, + fromFilename: string, + source: string, +): string | null => { + const resolvedConfig = findNearestTsconfig( + backend, + path.dirname(backend.normalizePath(fromFilename)), + ); + if (!resolvedConfig) return null; + + let bestPattern: string | null = null; + let bestCapture = ""; + let bestPrefixLength = -1; + for (const pattern of resolvedConfig.paths.keys()) { + const capture = matchPathPattern(source, pattern); + if (capture === null) continue; + const starIndex = pattern.indexOf("*"); + const prefixLength = starIndex === -1 ? pattern.length : starIndex; + if (prefixLength <= bestPrefixLength) continue; + bestPattern = pattern; + bestCapture = capture; + bestPrefixLength = prefixLength; + } + + if (bestPattern) { + for (const target of resolvedConfig.paths.get(bestPattern) ?? []) { + const substitutedTarget = target.replaceAll("*", bestCapture); + const resolvedTarget = resolveResourceModuleFileFromAbsolutePath( + backend, + backend.normalizePath(path.resolve(resolvedConfig.baseAbsolutePath, substitutedTarget)), + ); + if (resolvedTarget) return resolvedTarget; + } + } + return resolvedConfig.hasExplicitBaseUrl + ? resolveResourceModuleFileFromAbsolutePath( + backend, + backend.normalizePath(path.resolve(resolvedConfig.baseAbsolutePath, source)), + ) + : null; +}; + +export const resolveResourceImport = ( + backend: ResourceHostBackend, + fromFilename: string, + source: string, +): string | null => { + if (path.isAbsolute(source)) return null; + return source.startsWith(".") + ? resolveResourceRelativeImport(backend, fromFilename, source) + : resolveResourceTsconfigAlias(backend, fromFilename, source); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-package.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-package.ts new file mode 100644 index 0000000000..4e65293132 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resolve-resource-package.ts @@ -0,0 +1,88 @@ +import * as path from "node:path"; +import { RESOURCE_DEPENDENCY_SECTIONS } from "./constants.js"; +import { isResourceWithinRoot } from "./is-resource-within-root.js"; +import { parseResourceManifest } from "./parse-resource-manifest.js"; +import type { ResourceDependency, ResourceHostBackend, ResourcePackage } from "./resource-host.js"; + +const readResourcePackage = ( + backend: ResourceHostBackend, + packageDirectory: string, +): ResourcePackage | null => { + const normalizedPackageDirectory = backend.normalizePath(packageDirectory); + const manifestPath = backend.normalizePath(path.join(normalizedPackageDirectory, "package.json")); + const manifest = parseResourceManifest(backend.readText(manifestPath)); + return manifest + ? { + directoryPath: normalizedPackageDirectory, + manifestPath, + manifest, + } + : null; +}; + +export const findOwningResourcePackage = ( + backend: ResourceHostBackend, + filePath: string, +): ResourcePackage | null => { + let currentDirectory = path.dirname(backend.normalizePath(filePath)); + while (isResourceWithinRoot(backend.rootDirectory, currentDirectory)) { + const manifestPath = backend.normalizePath(path.join(currentDirectory, "package.json")); + if (backend.getPathKind(manifestPath) === "file") { + return readResourcePackage(backend, currentDirectory); + } + if (currentDirectory === backend.rootDirectory) return null; + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return null; + currentDirectory = parentDirectory; + } + return null; +}; + +const findInstalledDependencyVersion = ( + backend: ResourceHostBackend, + packageDirectory: string, + dependencyName: string, +): string | null => { + let currentDirectory = backend.normalizePath(packageDirectory); + while (isResourceWithinRoot(backend.rootDirectory, currentDirectory)) { + const installedManifest = parseResourceManifest( + backend.readText( + backend.normalizePath( + path.join(currentDirectory, "node_modules", dependencyName, "package.json"), + ), + ), + ); + if (typeof installedManifest?.version === "string") return installedManifest.version; + if (currentDirectory === backend.rootDirectory) return null; + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return null; + currentDirectory = parentDirectory; + } + return null; +}; + +export const getResourceDependency = ( + backend: ResourceHostBackend, + filePath: string, + dependencyName: string, +): ResourceDependency | null => { + const owningPackage = findOwningResourcePackage(backend, filePath); + if (!owningPackage) return null; + + for (const dependencySection of RESOURCE_DEPENDENCY_SECTIONS) { + const rawSpecifier = owningPackage.manifest[dependencySection]?.[dependencyName]; + if (typeof rawSpecifier !== "string") continue; + return { + name: dependencyName, + packageDirectory: owningPackage.directoryPath, + section: dependencySection, + rawSpecifier, + installedVersion: findInstalledDependencyVersion( + backend, + owningPackage.directoryPath, + dependencyName, + ), + }; + } + return null; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host-context.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host-context.ts new file mode 100644 index 0000000000..b4eef069b8 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host-context.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ResourceHost } from "./resource-host.js"; + +const resourceHostStorage = new AsyncLocalStorage(); + +export const getCurrentResourceHost = (): ResourceHost | null => + resourceHostStorage.getStore() ?? null; + +export const readCurrentResourceSource = (filename: string): string | null | undefined => + resourceHostStorage.getStore()?.readSource(filename); + +export const runWithResourceHost = ( + resourceHost: ResourceHost, + operation: () => Result, +): Result => resourceHostStorage.run(resourceHost, operation); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.test.ts new file mode 100644 index 0000000000..8c38dee095 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.test.ts @@ -0,0 +1,329 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { + resolveModuleFileFromAbsolutePath, + resolveRelativeImportPath, +} from "../../plugin/utils/resolve-relative-import-path.js"; +import { normalizeFilename } from "../../plugin/utils/normalize-filename.js"; +import { resolveTsconfigAliasPath } from "../../plugin/utils/resolve-tsconfig-alias.js"; +import { createInMemoryResourceHost } from "./in-memory-resource-host.js"; +import { createRealFilesystemResourceHost } from "./real-resource-host.js"; +import type { ResourceHost } from "./resource-host.js"; + +interface ResourceHostContractSnapshot { + readonly normalizedPath: string; + readonly sourceText: string | null; + readonly manifestName: unknown; + readonly invalidManifest: unknown; + readonly fileKind: string | null; + readonly directoryKind: string | null; + readonly missingKind: string | null; + readonly hasFile: boolean; + readonly hasDirectory: boolean; + readonly boundedEntries: ReadonlyArray; + readonly didReachDirectoryLimit: boolean; + readonly zeroLimitEntries: ReadonlyArray; + readonly didReachZeroLimit: boolean; + readonly missingDirectoryEntries: ReadonlyArray; + readonly relativeModulePath: string | null; + readonly absoluteModuleFilePath: string | null; + readonly directoryModulePath: string | null; + readonly aliasModulePath: string | null; + readonly genericRelativeModulePath: string | null; + readonly genericAliasModulePath: string | null; + readonly absoluteModulePath: string | null; + readonly owningPackageDirectory: string | null; + readonly owningPackageName: unknown; + readonly runtimeDependency: unknown; + readonly developmentDependency: unknown; + readonly missingDependency: unknown; +} + +const VIRTUAL_ROOT_DIRECTORY = "/virtual-project"; + +const FIXTURE_FILES = new Map([ + [ + "package.json", + JSON.stringify({ + name: "workspace", + private: true, + dependencies: { react: "^19.0.0" }, + }), + ], + ["node_modules/react/package.json", JSON.stringify({ name: "react", version: "19.1.1" })], + [ + "packages/app/package.json", + JSON.stringify({ + name: "@fixture/app", + dependencies: { react: "workspace:^" }, + devDependencies: { vitest: "^3.0.0" }, + }), + ], + [ + "packages/app/tsconfig.base.json", + `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + }, + }`, + ], + ["packages/app/tsconfig.json", JSON.stringify({ extends: "./tsconfig.base.json" })], + ["packages/app/src/page.tsx", "export const Page = () => null;\n"], + ["packages/app/src/components/card.tsx", "export const Card = () => null;\n"], + ["packages/app/src/widgets/package.json", JSON.stringify({ exports: "./entry.js" })], + ["packages/app/src/widgets/entry.ts", "export const Widget = () => null;\n"], + ["packages/app/src/list/a.ts", "export {};\n"], + ["packages/app/src/list/b.ts", "export {};\n"], + ["packages/app/src/list/c.ts", "export {};\n"], + ["packages/app/src/invalid-package.json", "{ invalid json\n"], +]); + +const writeFixtureFiles = (rootDirectory: string): void => { + for (const [relativeFilePath, sourceText] of FIXTURE_FILES) { + const absoluteFilePath = path.join(rootDirectory, relativeFilePath); + fs.mkdirSync(path.dirname(absoluteFilePath), { recursive: true }); + fs.writeFileSync(absoluteFilePath, sourceText, "utf8"); + } + fs.mkdirSync(path.join(rootDirectory, "packages/app/src/empty"), { recursive: true }); +}; + +const toProjectRelativePath = ( + resourceHost: ResourceHost, + resourcePath: string | null, +): string | null => + resourcePath === null + ? null + : resourceHost + .normalizePath(resourcePath) + .slice(resourceHost.rootDirectory.length) + .replace(/^\/+/, ""); + +const toRequiredProjectRelativePath = (resourceHost: ResourceHost, resourcePath: string): string => + resourceHost + .normalizePath(resourcePath) + .slice(resourceHost.rootDirectory.length) + .replace(/^\/+/, ""); + +const captureResourceHostContract = (resourceHost: ResourceHost): ResourceHostContractSnapshot => { + const sourceFilePath = "packages\\app\\src\\page.tsx"; + const owningPackage = resourceHost.findOwningPackage(sourceFilePath); + const runtimeDependency = resourceHost.getDependency(sourceFilePath, "react"); + const developmentDependency = resourceHost.getDependency(sourceFilePath, "vitest"); + const boundedListing = resourceHost.listDirectory("packages/app/src/list", 2); + const zeroLimitListing = resourceHost.listDirectory("packages/app/src/list", 0); + const normalizedRuntimeDependency = runtimeDependency + ? { + ...runtimeDependency, + packageDirectory: toProjectRelativePath(resourceHost, runtimeDependency.packageDirectory), + } + : null; + const normalizedDevelopmentDependency = developmentDependency + ? { + ...developmentDependency, + packageDirectory: toProjectRelativePath( + resourceHost, + developmentDependency.packageDirectory, + ), + } + : null; + + return { + normalizedPath: toRequiredProjectRelativePath( + resourceHost, + resourceHost.normalizePath(sourceFilePath), + ), + sourceText: resourceHost.readSource(sourceFilePath), + manifestName: resourceHost.readManifest("packages/app/package.json")?.name, + invalidManifest: resourceHost.readManifest("packages/app/src/invalid-package.json"), + fileKind: resourceHost.getPathKind(sourceFilePath), + directoryKind: resourceHost.getPathKind("packages/app/src/empty"), + missingKind: resourceHost.getPathKind("packages/app/src/missing.ts"), + hasFile: resourceHost.fileExists(sourceFilePath), + hasDirectory: resourceHost.directoryExists("packages/app/src/empty"), + boundedEntries: boundedListing.entries.map((directoryEntry) => directoryEntry.name), + didReachDirectoryLimit: boundedListing.didReachLimit, + zeroLimitEntries: zeroLimitListing.entries.map((directoryEntry) => directoryEntry.name), + didReachZeroLimit: zeroLimitListing.didReachLimit, + missingDirectoryEntries: resourceHost + .listDirectory("packages/app/src/missing", 2) + .entries.map((directoryEntry) => directoryEntry.name), + relativeModulePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveRelativeImport(sourceFilePath, "./components/card"), + ), + absoluteModuleFilePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveModuleFile( + resourceHost.normalizePath("packages/app/src/components/card"), + ), + ), + directoryModulePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveRelativeImport(sourceFilePath, "./widgets"), + ), + aliasModulePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveTsconfigAlias(sourceFilePath, "@/components/card"), + ), + genericRelativeModulePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveImport(sourceFilePath, "./components/card"), + ), + genericAliasModulePath: toProjectRelativePath( + resourceHost, + resourceHost.resolveImport(sourceFilePath, "@/components/card"), + ), + absoluteModulePath: resourceHost.resolveImport(sourceFilePath, "/absolute/module"), + owningPackageDirectory: toProjectRelativePath( + resourceHost, + owningPackage?.directoryPath ?? null, + ), + owningPackageName: owningPackage?.manifest.name, + runtimeDependency: normalizedRuntimeDependency, + developmentDependency: normalizedDevelopmentDependency, + missingDependency: resourceHost.getDependency(sourceFilePath, "missing"), + }; +}; + +describe("ResourceHost", () => { + let temporaryRootDirectory: string; + let realFilesystemHost: ResourceHost; + let inMemoryHost: ResourceHost; + + beforeAll(() => { + temporaryRootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "resource-host-")); + writeFixtureFiles(temporaryRootDirectory); + realFilesystemHost = createRealFilesystemResourceHost({ + rootDirectory: temporaryRootDirectory, + }); + inMemoryHost = createInMemoryResourceHost({ + rootDirectory: VIRTUAL_ROOT_DIRECTORY, + files: FIXTURE_FILES, + directories: ["packages/app/src/empty"], + }); + }); + + afterAll(() => { + fs.rmSync(temporaryRootDirectory, { recursive: true, force: true }); + fs.rmSync(`${temporaryRootDirectory}-outside.ts`, { force: true }); + }); + + it("keeps real and in-memory resource semantics exactly aligned", () => { + expect(captureResourceHostContract(realFilesystemHost)).toEqual( + captureResourceHostContract(inMemoryHost), + ); + }); + + it("pins the complete normalized resource contract", () => { + expect(captureResourceHostContract(inMemoryHost)).toEqual({ + normalizedPath: "packages/app/src/page.tsx", + sourceText: "export const Page = () => null;\n", + manifestName: "@fixture/app", + invalidManifest: null, + fileKind: "file", + directoryKind: "directory", + missingKind: null, + hasFile: true, + hasDirectory: true, + boundedEntries: ["a.ts", "b.ts"], + didReachDirectoryLimit: true, + zeroLimitEntries: [], + didReachZeroLimit: true, + missingDirectoryEntries: [], + relativeModulePath: "packages/app/src/components/card.tsx", + absoluteModuleFilePath: "packages/app/src/components/card.tsx", + directoryModulePath: "packages/app/src/widgets/entry.ts", + aliasModulePath: "packages/app/src/components/card.tsx", + genericRelativeModulePath: "packages/app/src/components/card.tsx", + genericAliasModulePath: "packages/app/src/components/card.tsx", + absoluteModulePath: null, + owningPackageDirectory: "packages/app", + owningPackageName: "@fixture/app", + runtimeDependency: { + name: "react", + packageDirectory: "packages/app", + section: "dependencies", + rawSpecifier: "workspace:^", + installedVersion: "19.1.1", + }, + developmentDependency: { + name: "vitest", + packageDirectory: "packages/app", + section: "devDependencies", + rawSpecifier: "^3.0.0", + installedVersion: null, + }, + missingDependency: null, + }); + }); + + it("preserves the production relative-import wrapper exactly", () => { + const sourceFilePath = path.join(temporaryRootDirectory, "packages/app/src/page.tsx"); + const unresolvedTargetPath = path.join( + temporaryRootDirectory, + "packages/app/src/components/card", + ); + expect( + normalizeFilename(resolveRelativeImportPath(sourceFilePath, "./components/card") ?? ""), + ).toBe( + normalizeFilename( + realFilesystemHost.resolveRelativeImport(sourceFilePath, "./components/card") ?? "", + ), + ); + expect(normalizeFilename(resolveModuleFileFromAbsolutePath(unresolvedTargetPath) ?? "")).toBe( + normalizeFilename( + realFilesystemHost.resolveRelativeImport(sourceFilePath, "./components/card") ?? "", + ), + ); + expect( + normalizeFilename(resolveTsconfigAliasPath(sourceFilePath, "@/components/card") ?? ""), + ).toBe( + normalizeFilename( + realFilesystemHost.resolveTsconfigAlias(sourceFilePath, "@/components/card") ?? "", + ), + ); + }); + + it("does not read resources outside the configured root", () => { + const outsideFilePath = `${temporaryRootDirectory}-outside.ts`; + fs.writeFileSync(outsideFilePath, "export const secret = true;\n", "utf8"); + expect(realFilesystemHost.readSource(outsideFilePath)).toBeNull(); + expect(realFilesystemHost.fileExists(outsideFilePath)).toBe(false); + }); + + it("accepts explicit package data without manifest resources", () => { + const resourceHost = createInMemoryResourceHost({ + rootDirectory: "/described-project", + files: new Map([["packages/app/src/index.ts", "export {};\n"]]), + packages: [ + { + directoryPath: "packages/app", + manifest: { + name: "@fixture/described-app", + dependencies: { react: "workspace:^" }, + }, + installedDependencyVersions: { react: "19.1.1" }, + }, + ], + }); + + expect(resourceHost.fileExists("packages/app/package.json")).toBe(false); + expect(resourceHost.findOwningPackage("packages/app/src/index.ts")).toMatchObject({ + directoryPath: resourceHost.normalizePath("packages/app"), + manifestPath: resourceHost.normalizePath("packages/app/package.json"), + manifest: { name: "@fixture/described-app" }, + }); + expect(resourceHost.getDependency("packages/app/src/index.ts", "react")).toEqual({ + name: "react", + packageDirectory: resourceHost.normalizePath("packages/app"), + section: "dependencies", + rawSpecifier: "workspace:^", + installedVersion: "19.1.1", + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.ts b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.ts new file mode 100644 index 0000000000..f8b466d5ae --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/resource-host/resource-host.ts @@ -0,0 +1,75 @@ +import type { PackageManifest } from "../../plugin/utils/read-nearest-package-manifest.js"; + +export interface ResourceDirectoryEntry { + readonly name: string; + readonly path: string; + readonly kind: "file" | "directory" | "other"; +} + +export interface ResourceDirectoryListing { + readonly entries: ReadonlyArray; + readonly didReachLimit: boolean; +} + +export interface ResourcePackage { + readonly directoryPath: string; + readonly manifestPath: string; + readonly manifest: PackageManifest; +} + +export interface ResourceDependency { + readonly name: string; + readonly packageDirectory: string; + readonly section: + | "dependencies" + | "devDependencies" + | "peerDependencies" + | "optionalDependencies"; + readonly rawSpecifier: string; + readonly installedVersion: string | null; +} + +export interface ResourceHost { + readonly rootDirectory: string; + readonly normalizePath: (resourcePath: string) => string; + readonly readSource: (filePath: string) => string | null; + readonly readManifest: (manifestPath: string) => PackageManifest | null; + readonly getPathKind: (resourcePath: string) => "file" | "directory" | "other" | null; + readonly fileExists: (filePath: string) => boolean; + readonly directoryExists: (directoryPath: string) => boolean; + readonly listDirectory: ( + directoryPath: string, + maximumEntries: number, + ) => ResourceDirectoryListing; + readonly resolveModuleFile: (absoluteModulePath: string) => string | null; + readonly resolveRelativeImport: (fromFilename: string, source: string) => string | null; + readonly resolveTsconfigAlias: (fromFilename: string, source: string) => string | null; + readonly resolveImport: (fromFilename: string, source: string) => string | null; + readonly findOwningPackage: (filePath: string) => ResourcePackage | null; + readonly getDependency: (filePath: string, dependencyName: string) => ResourceDependency | null; +} + +export interface ResourceHostBackend { + readonly rootDirectory: string; + readonly normalizePath: (resourcePath: string) => string; + readonly readText: (filePath: string) => string | null; + readonly getPathKind: (resourcePath: string) => "file" | "directory" | "other" | null; + readonly readDirectory: (directoryPath: string) => ReadonlyArray; +} + +export interface RealFilesystemResourceHostInput { + readonly rootDirectory: string; +} + +export interface InMemoryResourceHostInput { + readonly rootDirectory: string; + readonly files: ReadonlyMap; + readonly directories?: ReadonlyArray; + readonly packages?: ReadonlyArray; +} + +export interface InMemoryResourcePackageInput { + readonly directoryPath: string; + readonly manifest: PackageManifest; + readonly installedDependencyVersions?: Readonly>; +} diff --git a/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.test.ts b/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.test.ts new file mode 100644 index 0000000000..c511f6bf9c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseFixture, runRule, runRuleOnParsedFixture } from "./rule-engine-testkit.js"; +import type { Rule } from "./rule-engine-testkit.js"; + +const SOURCE_TEXT = `const Component = () => { + return ; +};`; + +const buildRule = (visitorOrder: string[]): Rule => ({ + id: "rule-engine-testkit-parity", + severity: "warn", + create: (context) => ({ + Program: (node) => { + visitorOrder.push(`enter:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + }, + ArrowFunctionExpression: (node) => { + visitorOrder.push(`enter:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + const functionScope = context.scopes.ownScopeFor(node); + const controlFlow = context.cfg.cfgFor(node); + context.report({ + node, + message: `${functionScope?.kind}:${controlFlow !== null}`, + }); + }, + JSXElement: (node) => { + visitorOrder.push(`enter:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + context.report({ + node, + message: `${node.loc.start.line}:${node.loc.start.column}`, + }); + }, + "JSXElement:exit": (node) => { + visitorOrder.push(`exit:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + }, + "ArrowFunctionExpression:exit": (node) => { + visitorOrder.push(`exit:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + }, + "Program:exit": (node) => { + visitorOrder.push(`exit:${node.type}@${node.loc.start.line}:${node.loc.start.column}`); + }, + }), +}); + +describe("rule engine testkit", () => { + it("preserves parsed and source runner diagnostics, visitor ordering, and locations", () => { + const sourceVisitorOrder: string[] = []; + const parsedVisitorOrder: string[] = []; + const sourceResult = runRule(buildRule(sourceVisitorOrder), SOURCE_TEXT, { + filename: "fixture.tsx", + }); + const parsed = parseFixture(SOURCE_TEXT, { filename: "fixture.tsx" }); + const parsedResult = runRuleOnParsedFixture( + buildRule(parsedVisitorOrder), + SOURCE_TEXT, + parsed, + { filename: "fixture.tsx" }, + ); + + expect(sourceResult).toEqual({ + diagnostics: [ + { + message: "arrow-function:true", + nodeType: "ArrowFunctionExpression", + }, + { + message: "2:9", + nodeType: "JSXElement", + }, + ], + parseErrors: [], + }); + expect(parsedResult).toEqual(sourceResult); + expect(sourceVisitorOrder).toEqual([ + "enter:Program@1:0", + "enter:ArrowFunctionExpression@1:18", + "enter:JSXElement@2:9", + "exit:JSXElement@2:9", + "exit:ArrowFunctionExpression@1:18", + "exit:Program@1:0", + ]); + expect(parsedVisitorOrder).toEqual(sourceVisitorOrder); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.ts b/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.ts new file mode 100644 index 0000000000..58f4447aa0 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/internal/rule-engine-testkit.ts @@ -0,0 +1,13 @@ +export { isFunctionLike } from "../plugin/utils/is-function-like.js"; +export { isHookCall } from "../plugin/utils/is-hook-call.js"; +export { isNodeOfType } from "../plugin/utils/is-node-of-type.js"; +export { stripParenExpression } from "../plugin/utils/strip-paren-expression.js"; +export { walkAst } from "../plugin/utils/walk-ast.js"; +export type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; +export type { Rule } from "../plugin/utils/rule.js"; +export { parseFixture } from "../test-utils/parse-fixture.js"; +export type { ParseFixtureResult } from "../test-utils/parse-fixture.js"; +export { runRule, runRuleOnParsedFixture } from "../test-utils/run-rule.js"; +export type { RuleDiagnostic, RunRuleOptions, RunRuleResult } from "../test-utils/run-rule.js"; +export { runScanRule } from "../test-utils/run-scan-rule.js"; +export type { ScanFileInput } from "../test-utils/run-scan-rule.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/motion-library-packages.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/motion-library-packages.ts new file mode 100644 index 0000000000..8e1d8f9d27 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/motion-library-packages.ts @@ -0,0 +1 @@ +export const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]); 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 1edd284aa1..28af12f331 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -1,4 +1,4 @@ -import { FETCH_CALLEE_NAMES, FETCH_MEMBER_OBJECTS } from "./library.js"; +import { FETCH_CALLEE_NAMES } from "./library.js"; import { TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES } from "./dom.js"; export const INDEX_PARAMETER_NAMES = new Set(["index", "idx", "i"]); @@ -88,28 +88,6 @@ export const TRIVIAL_CONSTRUCTOR_NAMES: ReadonlySet = new Set([ "AbortController", ]); -// 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]/; @@ -271,25 +249,6 @@ export const EXTERNAL_SYNC_MEMBER_METHOD_NAMES = new Set([ "patch", ]); -// HACK: `get`, `head`, `options` are HTTP verbs but ALSO names of -// universal data-structure methods (`Map.get`, `URLSearchParams.get`, -// `FormData.get`, `Headers.get`, `WeakMap.get`, `Set.has`, etc.). We -// only treat them as external-sync calls when the receiver is a -// recognized HTTP-client-shaped name. Lets the `axios.get(...)` -// cascade case work without false-classifying `params.get('id')` as -// external sync. -// -// Layered on top of `FETCH_MEMBER_OBJECTS` (the canonical HTTP-client -// receiver list used by `containsFetchCall`) so adding a new client -// name in one place propagates to both detectors. -export const EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS = new Set([ - ...FETCH_MEMBER_OBJECTS, - "api", - "client", - "http", - "fetcher", -]); - export const EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES = new Set([ "get", "head", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts index 21c663a66f..d2076658e2 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/style.ts @@ -65,6 +65,5 @@ export const MOTION_ANIMATE_PROPS = new Set([ export const LARGE_BLUR_THRESHOLD_PX = 10; export const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/; export const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]); -export const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]); export const BOUNCE_ANIMATION_NAMES = new Set(["bounce", "elastic", "wobble", "jiggle", "spring"]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index d9bcbb9839..c61861c84c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -1,5 +1,4 @@ export const GIANT_COMPONENT_LINE_THRESHOLD = 300; -export const CASCADING_SET_STATE_THRESHOLD = 3; export const RELATED_USE_STATE_THRESHOLD = 5; export const DEEP_NESTING_THRESHOLD = 3; export const DUPLICATE_STORAGE_READ_THRESHOLD = 2; 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 15623b409f..11d7844ecf 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 @@ -8,6 +8,8 @@ import { collectCrossFileDependencyProbes, } from "./cross-file-dependencies.js"; import { CROSS_FILE_RULE_IDS } from "./constants/cross-file-rule-ids.js"; +import { REACT_ROUTER_RULE_IDS } from "./constants/react-router.js"; +import { normalizeFilename } from "./utils/normalize-filename.js"; import { __clearParseSourceFileCacheForTests } from "./utils/parse-source-file.js"; import { resetManifestCaches } from "./utils/read-nearest-package-manifest.js"; import { __clearTsconfigAliasCacheForTests } from "./utils/resolve-tsconfig-alias.js"; @@ -73,6 +75,40 @@ describe("collectCrossFileDependencyProbes — driver", () => { }); }); +describe("React Router cache dependencies", () => { + it("bounds every rule to the owning package manifest", () => { + writeFixtureFile( + "package.json", + `{ "dependencies": { "@react-router/dev": "7.9.0", "react-router": "7.9.0" } }\n`, + ); + const routePath = writeFixtureFile( + "src/route.tsx", + `import { useNavigate } from "react-router"; +export const Route = () => { + const navigate = useNavigate(); + navigate("/next"); + return null; +};\n`, + ); + + for (const ruleId of REACT_ROUTER_RULE_IDS) { + const trace = collectFor(routePath, [ruleId]); + expect(trace, ruleId).not.toBeNull(); + expect(trace?.existencePaths.has(fixturePath("src/package.json")), ruleId).toBe(true); + expect(trace?.contentPaths, ruleId).toEqual(new Set([fixturePath("package.json")])); + } + }); + + it("keeps project-wide rules without a sound dependency bound unfingerprintable", () => { + const routePath = writeFixtureFile("src/route.tsx", "export const Route = () => null;\n"); + + for (const ruleId of ["nextjs-no-img-element", "window-open-without-noopener"]) { + expect(UNBOUNDED_CROSS_FILE_RULE_IDS.has(ruleId), ruleId).toBe(true); + expect(collectFor(routePath, [ruleId]), ruleId).toBeNull(); + } + }); +}); + describe("no-barrel-import collector", () => { const setupBarrelFixture = (): string => { writeFixtureFile("src/components/Button.tsx", "export const Button = () => null;\n"); @@ -91,11 +127,17 @@ describe("no-barrel-import collector", () => { // The barrel's content is read; a NAMED re-export target is only // resolved (existence) — the rule never reads its content. expect(trace?.contentPaths.has(fixturePath("src/components/index.ts"))).toBe(true); - expect(trace?.existencePaths.has(fixturePath("src/components/Button.tsx"))).toBe(true); + expect( + trace?.existencePaths.has(normalizeFilename(fixturePath("src/components/Button.tsx"))), + ).toBe(true); // The extension candidates probed (and absent) BEFORE the directory // resolution — a file appearing at one of them shadows the barrel. - expect(trace?.existencePaths.has(fixturePath("src/components.ts"))).toBe(true); - expect(trace?.existencePaths.has(fixturePath("src/components.tsx"))).toBe(true); + expect(trace?.existencePaths.has(normalizeFilename(fixturePath("src/components.ts")))).toBe( + true, + ); + expect(trace?.existencePaths.has(normalizeFilename(fixturePath("src/components.tsx")))).toBe( + true, + ); // An unrelated sibling is NOT a dependency. expect(trace?.contentPaths.has(fixturePath("src/unrelated.tsx"))).toBe(false); expect(trace?.existencePaths.has(fixturePath("src/unrelated.tsx"))).toBe(false); @@ -325,7 +367,112 @@ describe("forwarded Hook dependency collectors", () => { }); }); +describe("browser render guard collector", () => { + it("records package, alias, re-export, and imported Hook content on repeat collections", () => { + writeFixtureFile( + "package.json", + `{ "dependencies": { "next": "^15.0.0", "react": "^19.0.0" } }\n`, + ); + writeFixtureFile( + "tsconfig.json", + `{ "compilerOptions": { "baseUrl": ".", "paths": { "@hooks": ["src/hooks/index"] } } }\n`, + ); + writeFixtureFile( + "src/use-hydrated.ts", + `import { useSyncExternalStore } from "react"; +const subscribe = () => () => {}; +export const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false); +`, + ); + writeFixtureFile( + "src/hooks/index.ts", + `export { useHydrated as useClientReady } from "../use-hydrated";\n`, + ); + writeFixtureFile("src/nested-unrelated.ts", "export const nestedUnrelated = true;\n"); + writeFixtureFile( + "src/unrelated.ts", + `import { nestedUnrelated } from "./nested-unrelated"; +export const unrelated = nestedUnrelated; +`, + ); + const appPath = writeFixtureFile( + "src/App.tsx", + `import { useClientReady as useHydrated } from "@hooks"; +import { unrelated } from "./unrelated"; +export const App = () => { + const hydrated = useHydrated(); + return hydrated && {document.title}{String(unrelated)}; +}; +`, + ); + const expectedContentPaths = [ + fixturePath("package.json"), + fixturePath("tsconfig.json"), + fixturePath("src/hooks/index.ts"), + fixturePath("src/use-hydrated.ts"), + ]; + + for (const trace of [ + collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]), + collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]), + ]) { + for (const expectedPath of expectedContentPaths) { + expect(trace?.contentPaths.has(expectedPath)).toBe(true); + } + expect(trace?.contentPaths.has(fixturePath("src/unrelated.ts"))).toBe(true); + expect(trace?.contentPaths.has(fixturePath("src/nested-unrelated.ts"))).toBe(false); + } + }); + + it("records unresolved candidates and terminates on cyclic re-exports", () => { + writeFixtureFile("src/cycle-a.ts", `export { useHydrated } from "./cycle-b";\n`); + writeFixtureFile("src/cycle-b.ts", `export { useHydrated } from "./cycle-a";\n`); + const appPath = writeFixtureFile( + "src/App.tsx", + `import { useHydrated } from "./cycle-a"; +import { useMissingHydration } from "./missing-hydration"; +export const App = () => { + const hydrated = useHydrated() || useMissingHydration(); + return hydrated && {window.innerWidth}; +}; +`, + ); + const trace = collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]); + + expect(trace).not.toBeNull(); + expect(trace?.contentPaths.has(fixturePath("src/cycle-a.ts"))).toBe(true); + expect(trace?.contentPaths.has(fixturePath("src/cycle-b.ts"))).toBe(true); + expect( + trace?.existencePaths.has(normalizeFilename(fixturePath("src/missing-hydration.ts"))), + ).toBe(true); + }); +}); + describe("nextjs collectors", () => { + it("records the owning package manifest for the async dynamic API wrapper gate", () => { + writeFixtureFile( + "package.json", + `{ "dependencies": { "next": "^15.0.0", "react": "^19.0.0" } }\n`, + ); + const pagePath = writeFixtureFile( + "app/page.tsx", + `import { cookies } from "next/headers"; +export default function Page() { + return cookies().get("session"); +} +`, + ); + + for (const trace of [ + collectFor(pagePath, ["nextjs-async-dynamic-api-not-awaited"]), + collectFor(pagePath, ["nextjs-async-dynamic-api-not-awaited"]), + ]) { + expect(trace).not.toBeNull(); + expect(trace?.contentPaths.has(fixturePath("package.json"))).toBe(true); + expect(trace?.existencePaths.has(fixturePath("app/package.json"))).toBe(true); + } + }); + it("records ancestor layout probes for a page file only", () => { writeFixtureFile("app/layout.tsx", "export default ({ children }) => children;\n"); const pagePath = writeFixtureFile("app/products/page.tsx", "export default () =>
;\n"); 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 bb88610023..1f7f3cd15c 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 @@ -19,6 +19,7 @@ import { hasAncestorMetadataLayout } from "./utils/find-ancestor-metadata-layout import { hasAncestorSuspenseLayout } from "./utils/find-ancestor-suspense-layout.js"; import { isBarrelIndexModule } from "./utils/is-barrel-index-module.js"; import { isLegacyArchReactNativeFile } from "./utils/is-legacy-arch-react-native-file.js"; +import { isFunctionLike } from "./utils/is-function-like.js"; import { resolveInkVersion } from "./utils/resolve-ink-version.js"; import { isNodeOfType } from "./utils/is-node-of-type.js"; import { isReactApiCall } from "./utils/is-react-api-call.js"; @@ -30,6 +31,7 @@ import { resolveCrossFileFunctionExport, resolveCrossFileValueExportWithFilePath, } from "./utils/resolve-cross-file-function-export.js"; +import type { ResolvedCrossFileValueExport } from "./utils/resolve-cross-file-function-export.js"; import { resolveRelativeImportPath } from "./utils/resolve-relative-import-path.js"; import { stripParenExpression } from "./utils/strip-paren-expression.js"; import { walkAst } from "./utils/walk-ast.js"; @@ -220,10 +222,12 @@ const flattenProgramImportEntries = (program: EsTreeNode): ImportEntryName[] => return entries; }; -const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ - absoluteFilePath, - staticImports, -}) => { +const collectFunctionExportDependencies = ( + { absoluteFilePath, staticImports }: CrossFileDependencyCollectorInput, + maximumForwardDepth: number, + shouldTraverseResolvedExport: (resolved: ResolvedCrossFileValueExport) => boolean = () => true, + shouldTraverseFilePath: (filePath: string) => boolean = () => true, +): void => { const greatestTraversedDepthByFilePath = new Map(); const collectProgramDependencies = ( @@ -241,7 +245,14 @@ const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ entry.source, entry.exportedName, ); - if (!resolved || remainingDepth === 0) continue; + if ( + !resolved || + remainingDepth === 0 || + !shouldTraverseResolvedExport(resolved) || + !shouldTraverseFilePath(resolved.filePath) + ) { + continue; + } collectProgramDependencies(resolved.filePath, resolved.programNode, remainingDepth - 1); } }; @@ -252,15 +263,21 @@ const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ entry.source, entry.exportedName, ); - if (!resolved) continue; - collectProgramDependencies( - resolved.filePath, - resolved.programNode, - CUSTOM_HOOK_DEPENDENCY_FORWARD_DEPTH, - ); + if ( + !resolved || + !shouldTraverseResolvedExport(resolved) || + !shouldTraverseFilePath(resolved.filePath) + ) { + continue; + } + collectProgramDependencies(resolved.filePath, resolved.programNode, maximumForwardDepth); } }; +const collectForwardedHookDependencies: CrossFileDependencyCollector = (input) => { + collectFunctionExportDependencies(input, CUSTOM_HOOK_DEPENDENCY_FORWARD_DEPTH); +}; + const collectCreateRefDependencies: CrossFileDependencyCollector = ({ absoluteFilePath, program, @@ -384,6 +401,7 @@ const collectRnNoRawTextDependencies: CrossFileDependencyCollector = ({ // no-dynamic-import-path / no-full-lodash-import (`is-inside-node-cli-package`), // prefer-dynamic-import (`is-published-library-package`), +// nextjs-async-dynamic-api-not-awaited (`wrapNextjsRule`), // no-indeterminate-attribute / rendering-hydration-mismatch-time / // no-locale-format-in-render / no-match-media-in-state-initializer // (`classifyReactNativeFileTarget`), and @@ -397,6 +415,24 @@ const collectNearestManifestDependencies: CrossFileDependencyCollector = ({ abso classifyPackagePlatform(absoluteFilePath); }; +// no-unguarded-browser-global-in-render-or-hook-init reads the nearest +// manifest for its React Native gate, then can follow an imported zero-arg +// Hook (including aliases and barrel re-exports) whose return value forwards +// through more imported Hooks to useSyncExternalStore. The rule has no +// forwarding-depth limit; traversing every resolvable function import in the +// reached module graph is the finite, cycle-guarded superset. The rule parses +// a tsconfig alias into node_modules to reject it, but never follows that +// module's imports, so the collector stops there too. +const collectBrowserRenderGuardDependencies: CrossFileDependencyCollector = (input) => { + collectNearestManifestDependencies(input); + collectFunctionExportDependencies( + input, + Number.POSITIVE_INFINITY, + (resolved) => isFunctionLike(resolved.exportedNode), + (filePath) => !filePath.split(/[\\/]/).includes("node_modules"), + ); +}; + // rn-no-legacy-shadow-styles / rn-style-prefer-boxshadow gate on // `isLegacyArchReactNativeFile`, which reads the nearest manifest plus // `android/gradle.properties` and the Expo app-config files. The helper @@ -427,6 +463,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap = new Set([ - "nextjs-async-dynamic-api-not-awaited", "nextjs-no-img-element", "no-loading-flag-reset-outside-finally", "only-export-components", 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 d21ae29da1..0000000000 --- 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 c86c3d2feb..0000000000 --- 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/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 63a4311293..47f6f51f1f 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 @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { readCurrentResourceSource } from "../../../internal/resource-host/resource-host-context.js"; import { defineRule } from "../../utils/define-rule.js"; import type { RuleContext } from "../../utils/rule-context.js"; import { findVariableInitializer } from "../../utils/find-variable-initializer.js"; @@ -68,6 +69,13 @@ const annotatedFileTextCache = new Map(); const readAnnotatedFileText = (filename: string | undefined): string | null => { if (!filename) return null; + const currentResourceSource = readCurrentResourceSource(filename); + if (currentResourceSource !== undefined) { + return currentResourceSource !== null && + BUNDLER_IGNORE_ANNOTATION_PATTERN.test(currentResourceSource) + ? currentResourceSource + : null; + } const cached = annotatedFileTextCache.get(filename); if (cached !== undefined) return cached === false ? null : cached; let annotatedText: string | false = false; @@ -89,10 +97,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 9de5195ca8..be8dcf5436 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/react-builtins/exhaustive-deps-suppression.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps-suppression.ts index 5f3620b180..069fcd7fab 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,5 @@ import { readFileSync } from "node:fs"; +import { readCurrentResourceSource } from "../../../internal/resource-host/resource-host-context.js"; // Codebases that migrated from eslint-plugin-react-hooks carry // `eslint-disable-next-line react-hooks/exhaustive-deps` comments on @@ -77,6 +78,10 @@ const lineForOffset = (offset: number, newlineOffsets: ReadonlyArray): n const getSuppressionIndex = (filename: string | undefined): SuppressionIndex | null => { if (!filename) return null; + const currentResourceSource = readCurrentResourceSource(filename); + if (currentResourceSource !== undefined) { + return currentResourceSource === null ? null : buildSuppressionIndex(currentResourceSource); + } const cached = suppressionIndexCache.get(filename); if (cached !== undefined) return cached; let index: SuppressionIndex | null = null; 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 e20a85c577..fcce306024 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,5 @@ import { readFileSync } from "node:fs"; +import { readCurrentResourceSource } from "../../../internal/resource-host/resource-host-context.js"; // Codebases that migrated from eslint-plugin-react-hooks carry // `eslint-disable-next-line react-hooks/rules-of-hooks` comments on @@ -77,6 +78,10 @@ const lineForOffset = (offset: number, newlineOffsets: ReadonlyArray): n const getSuppressionIndex = (filename: string | undefined): SuppressionIndex | null => { if (!filename) return null; + const currentResourceSource = readCurrentResourceSource(filename); + if (currentResourceSource !== undefined) { + return currentResourceSource === null ? null : buildSuppressionIndex(currentResourceSource); + } const cached = suppressionIndexCache.get(filename); if (cached !== undefined) return cached; let index: SuppressionIndex | null = null; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-legacy-shadow-styles.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-legacy-shadow-styles.regressions.test.ts index 54c2a6c2cf..5a5fd19c4e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-legacy-shadow-styles.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-legacy-shadow-styles.regressions.test.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { createInMemoryResourceHost } from "../../../internal/resource-host/in-memory-resource-host.js"; import { runRule } from "../../../test-utils/run-rule.js"; import { resetManifestCaches } from "../../utils/read-nearest-package-manifest.js"; import { rnNoLegacyShadowStyles } from "./rn-no-legacy-shadow-styles.js"; @@ -101,6 +102,53 @@ describe("react-native/rn-no-legacy-shadow-styles — regressions", () => { expect(result.diagnostics.length).toBeGreaterThan(0); }); + it("reads static Expo architecture settings from an in-memory project", () => { + const resourceHost = createInMemoryResourceHost({ + rootDirectory: "/virtual-static-expo-config", + files: new Map([["app.json", JSON.stringify({ expo: { newArchEnabled: false } })]]), + packages: [ + { + directoryPath: ".", + manifest: { + dependencies: { "react-native": "0.80.0" }, + }, + }, + ], + }); + const result = runRule(rnNoLegacyShadowStyles, shadowStyleCode, { + filename: resourceHost.normalizePath("src/App.tsx"), + resourceHost, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); + + it("honors in-memory dynamic Expo config precedence", () => { + const resourceHost = createInMemoryResourceHost({ + rootDirectory: "/virtual-dynamic-expo-config", + files: new Map([ + ["app.config.ts", "export default {};"], + ["app.json", JSON.stringify({ expo: { newArchEnabled: false } })], + ]), + packages: [ + { + directoryPath: ".", + manifest: { + dependencies: { "react-native": "0.80.0" }, + }, + }, + ], + }); + const result = runRule(rnNoLegacyShadowStyles, shadowStyleCode, { + filename: resourceHost.normalizePath("src/App.tsx"), + resourceHost, + }); + + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + it("still fires on a modern react-native package with no opt-out", () => { const result = runRule(rnNoLegacyShadowStyles, shadowStyleCode, { filename: createPackageFilename({ reactNativeVersion: "0.79.5" }), diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security/window-open-without-noopener.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security/window-open-without-noopener.test.ts index 78620f7e82..9b2dbaa741 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security/window-open-without-noopener.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security/window-open-without-noopener.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { attachParentReferences } from "../../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "../../utils/attach-parent-references.js"; import { parseFixture } from "../../../test-utils/parse-fixture.js"; import { runRule } from "../../../test-utils/run-rule.js"; import { walkAst } from "../../utils/walk-ast.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-contracts.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-behavior.test.ts similarity index 99% rename from packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-contracts.test.ts rename to packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-behavior.test.ts index 74dfa68a7e..9cdde114b8 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-contracts.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-state-write-behavior.test.ts @@ -11,7 +11,7 @@ const expectDerivedStateDiagnostics = (code: string, diagnosticCount: number): v expect(result.diagnostics).toHaveLength(diagnosticCount); }; -describe("derived-state effect-write contract", () => { +describe("derived-state effect-write behavior", () => { it("reports direct aliases and branch-local copies from props or state", () => { expectDerivedStateDiagnostics( `function Example({ value, enabled }) { @@ -346,7 +346,7 @@ describe("derived-state effect-write contract", () => { }); }); -describe("derived-state family contracts", () => { +describe("derived-state family behavior", () => { const code = `function Example({ value }) { const [mirror, setMirror] = useState(null); useEffect(() => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md index fd93b8b7ac..8fe48dae5b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md @@ -118,7 +118,7 @@ builds one lazily per `Program` with `eslint-scope` in `setUncontrolledOpen(nextOpen)` in an `onOpenChange` handler), the state holds the user's live edits and only re-syncs to the controlled prop. It is not a value derivable while rendering — a `useMemo` would erase the - edits — so it is skipped (`isControlledPropMirror`). The upstream + edits — so it is skipped (`hasUserInputSetterWriter`). The upstream "derived" corpus never mirrors a bare prop while also writing the same state elsewhere, so parity is retained (all 54 invalid cases still fire, including the dead-wrapper double-call-site fixture whose argument is an diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.regressions.test.ts index 2b6bc8ab14..1c45e0be17 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.regressions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noDerivedState } from "./no-derived-state.js"; -// split/state PR #990's isControlledPropMirror exempted a prop->state mirror +// split/state PR #990 exempted a prop-to-state mirror // effect whenever the // setter had ANY second call site in a handler — which is exactly the rule's // canonical positive (the mined codecov SearchField bug). These regressions diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-effect-chain.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-effect-chain.regressions.test.ts index 5544122970..1583f89bbf 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-effect-chain.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-effect-chain.regressions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { analyzeScopes } from "../../semantic/scope-analysis.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { walkAst } from "../../utils/walk-ast.js"; -import { attachParentReferences } from "../../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "../../utils/attach-parent-references.js"; import { parseFixture } from "../../../test-utils/parse-fixture.js"; import type { RunRuleResult } from "../../../test-utils/run-rule.js"; import { runRule } from "../../../test-utils/run-rule.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-contracts.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-behavior.test.ts similarity index 99% rename from packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-contracts.test.ts rename to packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-behavior.test.ts index 45fff89d97..9f35462573 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-contracts.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-event-handler-behavior.test.ts @@ -9,7 +9,7 @@ const expectEventHandlerDiagnostics = (code: string, diagnosticCount: number): v expect(result.diagnostics).toHaveLength(diagnosticCount); }; -describe("no-event-handler event-source contract", () => { +describe("no-event-handler event-source behavior", () => { it("reports handler-proven state that guards transferable effect work", () => { expectEventHandlerDiagnostics( `function Form({ onSubmit }) { 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 fe9484b589..f1f080bf8c 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 @@ -48,9 +48,9 @@ const SAME_REFERENCE_ARRAY_RETURN_METHODS = new Set(["copyWithin", "fill", "reve // `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. +// bodies are parsed with the cached `parseSourceFile` and resolved by +// `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/no-pass-data-to-parent.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-pass-data-to-parent.ts index a92751e6f8..79405c72e8 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-pass-data-to-parent.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-pass-data-to-parent.ts @@ -58,7 +58,7 @@ import { // 1:1 port of upstream `src/rules/no-pass-data-to-parent.js`, narrowed to // DIRECT parent-callback call sites. The verification run showed the -// eventual-call chain walk (`isPropCall`) misidentifying local utilities as +// eventual-call chain walk misidentifying local utilities as // parent callbacks: `setValue` destructured from `useForm(...)`, wrapper // functions that mention a prop somewhere in their body, and useState // setters seeded from a prop. The rule now requires the callee itself to 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 fdc590dc70..c6f9276d42 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 a9f5a902c9..6f9a2e6268 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 @@ -481,9 +481,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 d6a5cdbbdf..6f34844e91 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. @@ -119,32 +86,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 816b4cd1f5..4755a290ec 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/semantic/closure-captures.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts index acfa0659d0..bfe72158c9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@voidzero-dev/vite-plus-test"; import { closureCaptures } from "./closure-captures.js"; import { analyzeScopes } from "./scope-analysis.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "../utils/attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "../utils/es-tree-node.js"; import { isFunctionLike } from "../utils/is-function-like.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/control-flow-graph.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/control-flow-graph.test.ts index 8e1a7b0e38..ef1662f14c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/control-flow-graph.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/control-flow-graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@voidzero-dev/vite-plus-test"; import { analyzeControlFlow } from "./control-flow-graph.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "../utils/attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "../utils/es-tree-node.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.test.ts index 12f4c229ea..802b466e1d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@voidzero-dev/vite-plus-test"; import { analyzeScopes } from "./scope-analysis.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "../utils/attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "../utils/es-tree-node.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/build-source-project-index.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/build-source-project-index.ts index f51a225f2f..44a7c90920 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/build-source-project-index.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/build-source-project-index.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; +import type { ResourceDirectoryEntry } from "../../internal/resource-host/resource-host.js"; import { analyzeScopes } from "../semantic/scope-analysis.js"; import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; import type { EsTreeNode } from "./es-tree-node.js"; @@ -29,6 +31,7 @@ export interface SourceProjectIndex { const SOURCE_PROJECT_FILE_PATTERN = /\.[cm]?[jt]sx?$/i; const SOURCE_PROJECT_DECLARATION_FILE_PATTERN = /\.d\.[cm]?[jt]s$/i; const SOURCE_PROJECT_MDX_FILE_PATTERN = /\.mdx$/i; +const MAXIMUM_SOURCE_PROJECT_DIRECTORY_ENTRIES = Number.MAX_SAFE_INTEGER; const SOURCE_PROJECT_IGNORED_DIRECTORY_NAMES: ReadonlySet = new Set([ ".angular", ".astro", @@ -52,6 +55,10 @@ const SOURCE_PROJECT_IGNORED_DIRECTORY_NAMES: ReadonlySet = new Set([ ]); const sourceProjectScopeCache = new WeakMap, ScopeAnalysis>(); +interface SourceProjectDirectoryEntry extends ResourceDirectoryEntry { + readonly isSymbolicLink: boolean; +} + const getSourceProjectModuleScopes = (programNode: EsTreeNodeOfType<"Program">): ScopeAnalysis => { const cachedScopes = sourceProjectScopeCache.get(programNode); if (cachedScopes) return cachedScopes; @@ -63,6 +70,7 @@ const getSourceProjectModuleScopes = (programNode: EsTreeNodeOfType<"Program">): const listProductionSourceFiles = ( rootDirectory: string, ): { sourceFilePaths: ReadonlyArray; hasOpaqueMdxConsumerSurface: boolean } | null => { + const resourceHost = getCurrentResourceHost(); const sourceFilePaths: string[] = []; const pendingDirectories = [rootDirectory]; let hasOpaqueMdxConsumerSurface = false; @@ -70,25 +78,42 @@ const listProductionSourceFiles = ( while (pendingDirectories.length > 0) { const currentDirectory = pendingDirectories.pop(); if (!currentDirectory) continue; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(currentDirectory, { withFileTypes: true }); - } catch { - return null; + let entries: ReadonlyArray; + if (resourceHost) { + const directoryListing = resourceHost.listDirectory( + currentDirectory, + MAXIMUM_SOURCE_PROJECT_DIRECTORY_ENTRIES, + ); + if (directoryListing.didReachLimit) return null; + entries = directoryListing.entries.map((entry) => ({ + ...entry, + isSymbolicLink: entry.kind === "other", + })); + } else { + try { + entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).map((entry) => ({ + name: entry.name, + path: path.join(currentDirectory, entry.name), + kind: entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "other", + isSymbolicLink: entry.isSymbolicLink(), + })); + } catch { + return null; + } } for (const entry of entries) { - const absolutePath = path.join(currentDirectory, entry.name); + const absolutePath = entry.path; const isIgnoredDirectoryName = SOURCE_PROJECT_IGNORED_DIRECTORY_NAMES.has(entry.name) || (entry.name.startsWith(".") && entry.name !== ".dumi" && entry.name !== ".storybook"); - if (entry.isSymbolicLink() && isIgnoredDirectoryName) continue; - if (entry.isSymbolicLink()) return null; - if (entry.isDirectory()) { + if (entry.isSymbolicLink && isIgnoredDirectoryName) continue; + if (entry.isSymbolicLink) return null; + if (entry.kind === "directory") { if (isIgnoredDirectoryName) continue; pendingDirectories.push(absolutePath); continue; } - if (!entry.isFile() || isTestlikeFilename(absolutePath)) continue; + if (entry.kind !== "file" || isTestlikeFilename(absolutePath)) continue; if (SOURCE_PROJECT_MDX_FILE_PATTERN.test(entry.name)) { hasOpaqueMdxConsumerSurface = true; continue; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-non-deterministic-source.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-non-deterministic-source.ts deleted file mode 100644 index 1e4985816a..0000000000 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-non-deterministic-source.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { EsTreeNode } from "./es-tree-node.js"; -import { isFunctionLike } from "./is-function-like.js"; -import { isNodeOfType } from "./is-node-of-type.js"; -import { walkAst } from "./walk-ast.js"; - -// `.()` calls whose result is non-deterministic — it differs -// per call and is unavailable / inconsistent during SSR, so it can NOT be -// passed as a deterministic `useState(initial)` argument. Seeding such a value -// from a mount effect is the CORRECT pattern, not a "you might not need an -// effect" smell. -const NON_DETERMINISTIC_MEMBER_CALLS: ReadonlySet = new Set([ - "Math.random", - "Date.now", - "performance.now", - "crypto.randomUUID", - "crypto.getRandomValues", -]); - -// Bare id-generator calls (`nanoid()`, `uuid()`, …). Each produces a fresh, -// non-deterministic value, so the same SSR-safety reasoning applies. -const NON_DETERMINISTIC_ID_GENERATOR_NAMES: ReadonlySet = new Set([ - "nanoid", - "uuid", - "cuid", - "ulid", - "createId", -]); - -// True when the subtree invokes any non-deterministic source. Scans the whole -// subtree because the value often flows through a local (`const id = nanoid(); -// setId(id)`) rather than being the direct setter argument — but never -// descends into function expressions: a stored callback -// (`setCallback(() => Date.now())`) is itself a deterministic value. -// `new Date()` with no arguments captures the current instant, so it is as -// non-deterministic as `Date.now()`; `new Date(value)` stays deterministic. -const isZeroArgDateConstruction = (node: EsTreeNode): boolean => - isNodeOfType(node, "NewExpression") && - isNodeOfType(node.callee, "Identifier") && - node.callee.name === "Date" && - (node.arguments?.length ?? 0) === 0; - -export const containsNonDeterministicSource = (root: EsTreeNode): boolean => { - let found = false; - walkAst(root, (child: EsTreeNode): boolean | void => { - if (found) return false; - if (isFunctionLike(child)) return false; - if (isZeroArgDateConstruction(child)) { - found = true; - return false; - } - if (!isNodeOfType(child, "CallExpression")) return; - const callee = child.callee; - if ( - isNodeOfType(callee, "Identifier") && - NON_DETERMINISTIC_ID_GENERATOR_NAMES.has(callee.name) - ) { - found = true; - return false; - } - if ( - isNodeOfType(callee, "MemberExpression") && - isNodeOfType(callee.object, "Identifier") && - isNodeOfType(callee.property, "Identifier") && - NON_DETERMINISTIC_MEMBER_CALLS.has(`${callee.object.name}.${callee.property.name}`) - ) { - found = true; - return false; - } - }); - return found; -}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-module-export-name.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-module-export-name.ts index 6ea7571f96..89a2847f71 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-module-export-name.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-module-export-name.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { readCurrentResourceSource } from "../../internal/resource-host/resource-host-context.js"; import { recordContentProbe } from "./cross-file-probe-recorder.js"; import { parseExportSpecifiers } from "./parse-export-specifiers.js"; import { stripJsComments } from "./strip-js-comments.js"; @@ -46,6 +47,12 @@ export const doesModuleExportName = (filePath: string, exportedName: string): bo // this one file's content (see cross-file-probe-recorder.ts). An absent // file lands in the same probe: its content answer is "absent". recordContentProbe(filePath); + const currentResourceSource = readCurrentResourceSource(filePath); + if (currentResourceSource !== undefined) { + if (currentResourceSource === null) return false; + return collectSourceTextExportNames(currentResourceSource).has(exportedName); + } + try { const fileStat = fs.statSync(filePath); const cached = exportNamesCache.get(filePath); 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 8b0f225de9..c92e5dcfc3 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/function-returns-collection-at-path.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-collection-at-path.test.ts index 5eb96b2b6c..e70e8230dd 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-collection-at-path.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-collection-at-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import { analyzeScopes } from "../semantic/scope-analysis.js"; import type { EsTreeNode } from "./es-tree-node.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-matching-expression.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-matching-expression.test.ts index 638f70f9bb..5a985495c0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-matching-expression.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/function-returns-matching-expression.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { analyzeControlFlow } from "../semantic/control-flow-graph.js"; import { analyzeScopes } from "../semantic/scope-analysis.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { attachSourceLocations } from "../../test-utils/attach-source-locations.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "./es-tree-node.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.test.ts index 5432723c97..78d77c632b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-jsx-prop-static-string-values.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-react-doctor-setting.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-react-doctor-setting.ts index 68a0f7acaf..e9443a3546 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-react-doctor-setting.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-react-doctor-setting.ts @@ -1,5 +1,6 @@ import type { Capability } from "./capability.js"; import type { RuleContext } from "./rule-context.js"; +import { readOwnPropertyValue } from "./read-own-property-value.js"; // Extracted helpers for reading typed entries out of the `react-doctor` // settings bag that core writes into the oxlint config (see @@ -24,16 +25,19 @@ const readReactDoctorSettingsBag = (settings: RuleContext["settings"]): object | return reactDoctorSettings; }; -const readOwnPropertyValue = (bag: object, settingName: string): unknown => - Object.getOwnPropertyDescriptor(bag, settingName)?.value; +export const getReactDoctorSetting = ( + settings: RuleContext["settings"], + settingName: string, +): unknown => { + const bag = readReactDoctorSettingsBag(settings); + return bag === null ? undefined : readOwnPropertyValue(bag, settingName); +}; export const getReactDoctorStringSetting = ( settings: RuleContext["settings"], settingName: string, ): string | undefined => { - const bag = readReactDoctorSettingsBag(settings); - if (!bag) return undefined; - const settingValue = readOwnPropertyValue(bag, settingName); + const settingValue = getReactDoctorSetting(settings, settingName); return typeof settingValue === "string" ? settingValue : undefined; }; @@ -41,21 +45,25 @@ export const getReactDoctorNumberSetting = ( settings: RuleContext["settings"], settingName: string, ): number | undefined => { - const bag = readReactDoctorSettingsBag(settings); - if (!bag) return undefined; - const settingValue = readOwnPropertyValue(bag, settingName); + const settingValue = getReactDoctorSetting(settings, settingName); return typeof settingValue === "number" && Number.isFinite(settingValue) ? settingValue : undefined; }; +export const getReactDoctorBooleanSetting = ( + settings: RuleContext["settings"], + settingName: string, +): boolean | undefined => { + const settingValue = getReactDoctorSetting(settings, settingName); + return typeof settingValue === "boolean" ? settingValue : undefined; +}; + export const getReactDoctorStringArraySetting = ( settings: RuleContext["settings"], settingName: string, ): ReadonlyArray => { - const bag = readReactDoctorSettingsBag(settings); - if (!bag) return []; - const settingValue = readOwnPropertyValue(bag, settingName); + const settingValue = getReactDoctorSetting(settings, settingName); if (!Array.isArray(settingValue)) return []; return settingValue.filter( (entry): entry is string => typeof entry === "string" && entry.length > 0, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-rule-package-capability.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-rule-package-capability.ts new file mode 100644 index 0000000000..4c34fecf63 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-rule-package-capability.ts @@ -0,0 +1,13 @@ +import type { Capability } from "./capability.js"; +import { hasCapability } from "./get-react-doctor-setting.js"; +import type { RuleContext } from "./rule-context.js"; +import type { RulePackageContext } from "./rule-package-context.js"; + +export const hasRulePackageCapability = ( + packageContext: RulePackageContext | null, + settings: RuleContext["settings"], + capability: Capability, +): boolean => { + if (packageContext === null) return hasCapability(settings, capability); + return packageContext.hasCapability(capability); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-barrel-index-module.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-barrel-index-module.ts index 568f8bdcc5..a4c1f4adf3 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-barrel-index-module.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-barrel-index-module.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { readCurrentResourceSource } from "../../internal/resource-host/resource-host-context.js"; import { recordContentProbe } from "./cross-file-probe-recorder.js"; import { parseExportSpecifiers } from "./parse-export-specifiers.js"; import { stripJsComments } from "./strip-js-comments.js"; @@ -235,6 +236,12 @@ export const getBarrelIndexModuleInfo = (filePath: string): BarrelIndexModuleInf // dependency while the cache stays warm (see cross-file-probe-recorder.ts). recordContentProbe(filePath); + const currentResourceSource = readCurrentResourceSource(filePath); + if (currentResourceSource !== undefined) { + if (currentResourceSource === null) return createNonBarrelInfo(); + return classifyBarrelModule(currentResourceSource); + } + let fileStat: fs.Stats | null; try { fileStat = fs.statSync(filePath); 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 2e3644556b..0e65f54f4f 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 @@ -1,3 +1,5 @@ +import * as path from "node:path"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; import { buildSourceProjectIndex, @@ -367,10 +369,16 @@ const hasOpaqueWorkspacePackageConsumer = ( }; export const createExportedJsxGeneratedImageOwnershipAnalyzer = (context: RuleContext) => { - const filename = context.filename ? normalizeFilename(context.filename) : ""; + const resourceHost = getCurrentResourceHost(); + const filename = context.filename + ? (resourceHost?.normalizePath(context.filename) ?? normalizeFilename(context.filename)) + : ""; const rootDirectorySetting = getReactDoctorStringSetting(context.settings, "rootDirectory"); const rootDirectory = rootDirectorySetting - ? normalizeFilename(rootDirectorySetting).replace(/\/$/, "") + ? ( + resourceHost?.normalizePath(path.resolve(rootDirectorySetting)) ?? + normalizeFilename(rootDirectorySetting) + ).replace(/\/$/, "") : ""; const isFileInsideRoot = Boolean(filename && rootDirectory) && diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-legacy-arch-react-native-file.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-legacy-arch-react-native-file.ts index 603e6f9a69..a48aa2bf43 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-legacy-arch-react-native-file.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-legacy-arch-react-native-file.ts @@ -1,5 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { + getCurrentResourceHost, + readCurrentResourceSource, +} from "../../internal/resource-host/resource-host-context.js"; import { recordContentProbe, recordExistenceProbe } from "./cross-file-probe-recorder.js"; import type { PackageManifest } from "./read-nearest-package-manifest.js"; import { @@ -47,6 +51,8 @@ const recordFilesystemProbes = (packageDirectory: string): void => { }; const readTextFileOrNull = (absolutePath: string): string | null => { + const resourceSource = readCurrentResourceSource(absolutePath); + if (resourceSource !== undefined) return resourceSource; try { return fs.readFileSync(absolutePath, "utf-8"); } catch { @@ -72,8 +78,12 @@ const isNewArchDisabledInGradleProperties = (packageDirectory: string): boolean }; const isNewArchDisabledInStaticExpoConfig = (packageDirectory: string): boolean => { + const currentResourceHost = getCurrentResourceHost(); for (const dynamicFilename of DYNAMIC_EXPO_CONFIG_FILENAMES) { - if (fs.existsSync(path.join(packageDirectory, dynamicFilename))) return false; + const dynamicConfigPath = path.join(packageDirectory, dynamicFilename); + if (currentResourceHost?.fileExists(dynamicConfigPath) ?? fs.existsSync(dynamicConfigPath)) { + return false; + } } for (const staticFilename of STATIC_EXPO_CONFIG_FILENAMES) { const contents = readTextFileOrNull(path.join(packageDirectory, staticFilename)); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-package-within-project-root.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-package-within-project-root.ts index f5db51da83..e22219ceec 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-package-within-project-root.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-package-within-project-root.ts @@ -1,17 +1,13 @@ -import * as fs from "node:fs"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; import { normalizeFilename } from "./normalize-filename.js"; +import { resolveRealPath } from "./resolve-real-path.js"; const cachedRealDirectoryByDirectory = new Map(); const resolveRealDirectory = (directory: string): string => { const cached = cachedRealDirectoryByDirectory.get(directory); if (cached !== undefined) return cached; - let realDirectory: string; - try { - realDirectory = fs.realpathSync(directory); - } catch { - realDirectory = directory; - } + const realDirectory = resolveRealPath(directory); cachedRealDirectoryByDirectory.set(directory, realDirectory); return realDirectory; }; @@ -22,6 +18,16 @@ export const isPackageWithinProjectRoot = ( includeRootDirectory: boolean, ): boolean => { if (rootDirectory === undefined || rootDirectory.length === 0) return false; + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + const normalizedPackageDirectory = currentResourceHost.normalizePath(packageDirectory); + const normalizedRootDirectory = currentResourceHost.normalizePath(rootDirectory); + if (includeRootDirectory && normalizedPackageDirectory === normalizedRootDirectory) return true; + const rootPrefix = normalizedRootDirectory.endsWith("/") + ? normalizedRootDirectory + : `${normalizedRootDirectory}/`; + return normalizedPackageDirectory.startsWith(rootPrefix); + } const realPackageDirectory = normalizeFilename(resolveRealDirectory(packageDirectory)); const normalizedRootDirectory = normalizeFilename(rootDirectory); if (includeRootDirectory && realPackageDirectory === normalizedRootDirectory) return true; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-api-call.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-api-call.test.ts index 85c964a9ca..0ea2f7b444 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-api-call.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-react-api-call.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { analyzeScopes } from "../semantic/scope-analysis.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNode } from "./es-tree-node.js"; import { isNodeOfType } from "./is-node-of-type.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/package-aware-rule-context.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/package-aware-rule-context.test.ts new file mode 100644 index 0000000000..8ac531c583 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/package-aware-rule-context.test.ts @@ -0,0 +1,230 @@ +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 type { Rule } from "./rule.js"; +import type { RulePackageContext } from "./rule-package-context.js"; +import { wrapWithSemanticContext } from "./wrap-with-semantic-context.js"; + +const packageContexts = [ + { + relativeDirectory: "packages/legacy", + capabilities: [ + "vite", + "react", + "react:17", + "react:18", + "target-blank-needs-explicit-protection", + ], + dependencies: [ + { + name: "react", + section: "dependencies", + rawSpecifier: "^18.2.0", + resolvedSpecifier: "^18.2.0", + }, + ], + }, + { + relativeDirectory: "packages/modern", + capabilities: ["nextjs", "nextjs:15", "react", "react:17", "react:18", "react:19"], + dependencies: [ + { + name: "next", + section: "dependencies", + rawSpecifier: "^15.0.0", + resolvedSpecifier: "^15.0.0", + }, + { + name: "react", + section: "dependencies", + rawSpecifier: "^19.0.0", + resolvedSpecifier: "^19.0.0", + }, + ], + }, +]; + +const buildSettings = (packageCapabilityGates: boolean) => ({ + "react-doctor": { + rootDirectory: "/workspace", + capabilities: ["vite", "react", "react:17", "react:18"], + packageContexts, + packageContextEnabled: true, + ...(packageCapabilityGates ? { packageCapabilityGates: true } : {}), + }, +}); + +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "package-rule-context-")); + +afterAll(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +}); + +const didCreateRule = ( + rule: Rule, + filename: string, + settings: Readonly>, +): boolean => { + let didCreate = false; + wrapWithSemanticContext({ + ...rule, + create: (context) => { + didCreate = true; + return rule.create(context); + }, + }).create({ + report: () => {}, + filename, + settings, + }); + return didCreate; +}; + +describe("package-aware rule context", () => { + it("resolves exact owning-package dependencies without enabling per-file gates", () => { + let packageContext: RulePackageContext | null | undefined; + const probeRule: Rule = { + id: "package-context-probe", + severity: "warn", + create: (context) => { + packageContext = context.packageContext; + return {}; + }, + }; + + wrapWithSemanticContext(probeRule).create({ + report: () => {}, + filename: "/workspace/packages/modern/src/app.tsx", + settings: buildSettings(false), + }); + + expect(packageContext?.relativeDirectory).toBe("packages/modern"); + expect(packageContext?.hasDependency("next")).toBe(true); + expect(packageContext?.getDependency("react")).toEqual({ + name: "react", + section: "dependencies", + rawSpecifier: "^19.0.0", + resolvedSpecifier: "^19.0.0", + }); + expect(packageContext?.hasCapability("react:19")).toBe(true); + }); + + it("keeps legacy activation exact when the opt-in setting is absent", () => { + const rule: Rule = { + id: "requires-react-19", + severity: "warn", + requires: ["react:19"], + create: () => ({}), + }; + + expect( + didCreateRule(rule, "/workspace/packages/legacy/src/app.tsx", buildSettings(false)), + ).toBe(true); + }); + + it("gates required and disabled capabilities by the owning package when opted in", () => { + const requiresReact19: Rule = { + id: "requires-react-19", + severity: "warn", + requires: ["react:19"], + create: () => ({}), + }; + const disabledForNext: Rule = { + id: "disabled-for-next", + severity: "warn", + disabledWhen: ["nextjs"], + create: () => ({}), + }; + const settings = buildSettings(true); + + expect(didCreateRule(requiresReact19, "/workspace/packages/legacy/src/app.tsx", settings)).toBe( + false, + ); + expect(didCreateRule(requiresReact19, "/workspace/packages/modern/src/app.tsx", settings)).toBe( + true, + ); + expect(didCreateRule(disabledForNext, "/workspace/packages/legacy/src/app.tsx", settings)).toBe( + true, + ); + expect(didCreateRule(disabledForNext, "/workspace/packages/modern/src/app.tsx", settings)).toBe( + false, + ); + }); + + it("keeps target-blank capabilities scoped to the owning package", () => { + const rule: Rule = { + id: "target-blank-disabled", + severity: "warn", + disabledWhen: ["target-blank-needs-explicit-protection"], + create: () => ({}), + }; + const settings = buildSettings(true); + settings["react-doctor"].capabilities.push("target-blank-needs-explicit-protection"); + + expect(didCreateRule(rule, "/workspace/packages/legacy/src/app.tsx", settings)).toBe(false); + expect(didCreateRule(rule, "/workspace/packages/modern/src/app.tsx", settings)).toBe(true); + }); + + it("does not leak package-derived project capabilities into an owning package", () => { + const compilerDisabledRule: Rule = { + id: "compiler-disabled", + severity: "warn", + disabledWhen: ["react-compiler"], + create: () => ({}), + }; + const requiresTypeScriptRule: Rule = { + id: "requires-typescript", + severity: "warn", + requires: ["typescript"], + create: () => ({}), + }; + const settings = buildSettings(true); + settings["react-doctor"].capabilities.push("react-compiler", "typescript"); + const filename = "/workspace/packages/modern/src/app.tsx"; + + expect(didCreateRule(compilerDisabledRule, filename, settings)).toBe(true); + expect(didCreateRule(requiresTypeScriptRule, filename, settings)).toBe(false); + }); + + it("falls back to project capabilities when no owning package is available", () => { + const rule: Rule = { + id: "requires-react-18", + severity: "warn", + requires: ["react:18"], + create: () => ({}), + }; + + expect(didCreateRule(rule, "/outside/file.tsx", buildSettings(true))).toBe(true); + }); + + it.skipIf(process.platform === "win32")( + "resolves package ownership through a symlinked scan root", + () => { + const realProjectDirectory = path.join(temporaryDirectory, "real-project"); + const linkedProjectDirectory = path.join(temporaryDirectory, "linked-project"); + const sourceFile = path.join(realProjectDirectory, "packages", "modern", "src", "app.tsx"); + fs.mkdirSync(path.dirname(sourceFile), { recursive: true }); + fs.writeFileSync(sourceFile, ""); + fs.symlinkSync(realProjectDirectory, linkedProjectDirectory); + const settings = buildSettings(true); + settings["react-doctor"].rootDirectory = fs.realpathSync(realProjectDirectory); + let packageContext: RulePackageContext | null | undefined; + + wrapWithSemanticContext({ + id: "symlink-package-context", + severity: "warn", + create: (context) => { + packageContext = context.packageContext; + return {}; + }, + }).create({ + report: () => {}, + filename: path.join(linkedProjectDirectory, "packages", "modern", "src", "app.tsx"), + settings, + }); + + expect(packageContext?.relativeDirectory).toBe("packages/modern"); + }, + ); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts index a26ffff869..4a5b58e3da 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { parseSync } from "oxc-parser"; import { CROSS_FILE_PARSE_MAX_BYTES } from "../constants/thresholds.js"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; import { attachParentReferences } from "./attach-parent-references.js"; import { recordContentProbe } from "./cross-file-probe-recorder.js"; import type { EsTreeNode } from "./es-tree-node.js"; @@ -83,6 +84,19 @@ export const parseSourceFile = (absoluteFilePath: string): EsTreeNode | null => absoluteFilePath.endsWith(".d.cts"); if (!isDeclarationFile) recordContentProbe(absoluteFilePath); + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + if (isDeclarationFile) return null; + const sourceText = currentResourceHost.readSource(absoluteFilePath); + if (sourceText === null || Buffer.byteLength(sourceText) > CROSS_FILE_PARSE_MAX_BYTES) { + return null; + } + return parseSourceText({ + filename: absoluteFilePath, + sourceText, + }); + } + let fileStat: fs.Stats; try { fileStat = fs.statSync(absoluteFilePath); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-initial-state-boolean.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-initial-state-boolean.test.ts index d59b1c7c12..ecd5290a8f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-initial-state-boolean.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-initial-state-boolean.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { analyzeScopes } from "../semantic/scope-analysis.js"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { readInitialStateBoolean } from "./read-initial-state-boolean.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts index 878d09e281..9ae0da92fd 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts @@ -5,6 +5,7 @@ import { recordContentProbe, recordExistenceProbe, } from "./cross-file-probe-recorder.js"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; // The single owner of the nearest-package.json machinery every manifest // consumer (`classify-package-platform`, `is-inside-node-cli-package`, @@ -56,6 +57,10 @@ export const resetManifestCaches = (): void => { export const findNearestPackageDirectory = (filename: string): string | null => { if (!filename) return null; + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + return currentResourceHost.findOwningPackage(filename)?.directoryPath ?? null; + } // The walk's outcome depends on EVERY ancestor probe (a package.json // appearing closer to the file re-anchors the classification), so a memo @@ -111,6 +116,10 @@ export const readNearestPackageManifest = (filename: string): PackageManifest | }; export const readPackageManifest = (packageDirectory: string): PackageManifest | null => { + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + return currentResourceHost.readManifest(path.join(packageDirectory, "package.json")); + } const packageJsonPath = path.join(packageDirectory, "package.json"); // Recorded BEFORE the memo lookup — every consumer's verdict is a pure diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-own-property-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-own-property-value.ts new file mode 100644 index 0000000000..fbc9ddb53a --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-own-property-value.ts @@ -0,0 +1,2 @@ +export const readOwnPropertyValue = (value: object, propertyName: string): unknown => + Object.getOwnPropertyDescriptor(value, propertyName)?.value; 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 27cb397127..5edc0e66ef 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/oxlint-plugin-react-doctor/src/plugin/utils/resolve-jsx-element-type.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-jsx-element-type.test.ts index c22e688cea..3fe9f12ef8 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-jsx-element-type.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-jsx-element-type.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { isNodeOfType } from "./is-node-of-type.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-real-path.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-real-path.ts new file mode 100644 index 0000000000..fee8e8c6a6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-real-path.ts @@ -0,0 +1,9 @@ +import * as fs from "node:fs"; + +export const resolveRealPath = (filePath: string): string => { + try { + return fs.realpathSync(filePath); + } catch { + return filePath; + } +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-relative-import-path.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-relative-import-path.ts index c99d9dba09..8b03ec68c8 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-relative-import-path.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-relative-import-path.ts @@ -1,139 +1,36 @@ -import * as fs from "node:fs"; import * as path from "node:path"; -import { recordContentProbe, recordExistenceProbe } from "./cross-file-probe-recorder.js"; +import { + resolveResourceModuleFileFromAbsolutePath, + resolveResourceRelativeImport, +} from "../../internal/resource-host/resolve-resource-module.js"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; +import { getRealResourceHostBackend } from "../../internal/resource-host/real-resource-host-backend.js"; -const MODULE_FILE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]; -const PACKAGE_EXPORT_CONDITIONS = ["import", "default", "module", "browser", "require"]; -const PACKAGE_ENTRY_FIELDS = ["module", "main", "browser"]; - -const getExistingFilePath = (filePath: string): string | null => { - recordExistenceProbe(filePath); - try { - return fs.statSync(filePath).isFile() ? filePath : null; - } catch { - return null; - } -}; - -const getExistingDirectoryPath = (directoryPath: string): string | null => { - recordExistenceProbe(directoryPath); - try { - return fs.statSync(directoryPath).isDirectory() ? directoryPath : null; - } catch { - return null; - } -}; - -const getModuleFilePathCandidates = (modulePath: string): string[] => { - const extension = path.extname(modulePath); - if (!extension) { - return MODULE_FILE_EXTENSIONS.map((moduleExtension) => `${modulePath}${moduleExtension}`); - } - - const modulePathWithoutExtension = modulePath.slice(0, -extension.length); - if (extension === ".js") { - return [ - modulePath, - `${modulePathWithoutExtension}.ts`, - `${modulePathWithoutExtension}.tsx`, - `${modulePathWithoutExtension}.jsx`, - ]; - } - if (extension === ".jsx") return [modulePath, `${modulePathWithoutExtension}.tsx`]; - if (extension === ".mjs") return [modulePath, `${modulePathWithoutExtension}.mts`]; - if (extension === ".cjs") return [modulePath, `${modulePathWithoutExtension}.cts`]; - - return [modulePath]; -}; - -const isObjectRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const getConditionalExportEntry = (exportEntry: unknown): string | null => { - if (typeof exportEntry === "string") return exportEntry; - if (Array.isArray(exportEntry)) { - for (const fallbackEntry of exportEntry) { - const resolvedFallbackEntry = getConditionalExportEntry(fallbackEntry); - if (resolvedFallbackEntry) return resolvedFallbackEntry; - } - return null; - } - if (!isObjectRecord(exportEntry)) return null; - - for (const condition of PACKAGE_EXPORT_CONDITIONS) { - const nestedEntry = getConditionalExportEntry(exportEntry[condition]); - if (nestedEntry) return nestedEntry; - } - - return null; -}; - -const getPackageExportEntry = (packageJson: Record): string | null => { - const exportsField = packageJson.exports; - if (!exportsField) return null; - - const directExportEntry = getConditionalExportEntry(exportsField); - if (directExportEntry) return directExportEntry; - - if (!isObjectRecord(exportsField)) return null; - return getConditionalExportEntry(exportsField["."]); -}; - -const resolveModulePathWithIndexFallback = (modulePath: string): string | null => { - const filePath = resolveModuleFilePath(modulePath); - if (filePath) return filePath; - - return resolveModuleFilePath(path.join(modulePath, "index")); -}; - -const resolvePackageDirectoryEntry = (directoryPath: string): string | null => { - const existingDirectoryPath = getExistingDirectoryPath(directoryPath); - if (!existingDirectoryPath) return null; - - const packageJsonPath = path.join(existingDirectoryPath, "package.json"); - recordContentProbe(packageJsonPath); - try { - const packageJson: Record = JSON.parse( - fs.readFileSync(packageJsonPath, "utf8"), - ); - const packageEntry = - getPackageExportEntry(packageJson) ?? - PACKAGE_ENTRY_FIELDS.map((fieldName) => packageJson[fieldName]).find( - (value): value is string => typeof value === "string", - ); - if (!packageEntry) return null; - - return resolveModulePathWithIndexFallback(path.resolve(existingDirectoryPath, packageEntry)); - } catch { - return null; - } -}; - -const resolveModuleFilePath = (modulePath: string): string | null => { - const exactFilePath = getExistingFilePath(modulePath); - if (exactFilePath) return exactFilePath; - - for (const candidateFilePath of getModuleFilePathCandidates(modulePath)) { - const filePath = getExistingFilePath(candidateFilePath); - if (filePath) return filePath; - } - - return null; -}; - -// Resolves an already-absolute module path to a concrete file, trying -// the path itself + extension candidates, then a package directory -// entry (package.json exports/main), then an `index.*` fallback. Shared -// by relative resolution and tsconfig-alias resolution. export const resolveModuleFileFromAbsolutePath = (importPath: string): string | null => { - const directFilePath = resolveModuleFilePath(importPath); - if (directFilePath) return directFilePath; - - const packageEntryFilePath = resolvePackageDirectoryEntry(importPath); - if (packageEntryFilePath) return packageEntryFilePath; - - return resolveModuleFilePath(path.join(importPath, "index")); + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + const resolvedFilePath = currentResourceHost.resolveModuleFile(importPath); + return resolvedFilePath === null ? null : path.normalize(resolvedFilePath); + } + const absoluteImportPath = path.resolve(importPath); + const resolvedFilePath = resolveResourceModuleFileFromAbsolutePath( + getRealResourceHostBackend(absoluteImportPath), + absoluteImportPath, + ); + return resolvedFilePath === null ? null : path.normalize(resolvedFilePath); +}; + +export const resolveRelativeImportPath = (filename: string, source: string): string | null => { + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + const resolvedFilePath = currentResourceHost.resolveRelativeImport(filename, source); + return resolvedFilePath === null ? null : path.normalize(resolvedFilePath); + } + const absoluteFilename = path.resolve(filename); + const resolvedFilePath = resolveResourceRelativeImport( + getRealResourceHostBackend(absoluteFilename), + absoluteFilename, + source, + ); + return resolvedFilePath === null ? null : path.normalize(resolvedFilePath); }; - -export const resolveRelativeImportPath = (filename: string, source: string): string | null => - resolveModuleFileFromAbsolutePath(path.resolve(path.dirname(filename), source)); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-rule-package-context.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-rule-package-context.ts new file mode 100644 index 0000000000..247a189e81 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-rule-package-context.ts @@ -0,0 +1,125 @@ +import * as path from "node:path"; +import type { RuleContext } from "./rule-context.js"; +import type { RulePackageContext, RulePackageDependency } from "./rule-package-context.js"; +import { getReactDoctorSetting, getReactDoctorStringSetting } from "./get-react-doctor-setting.js"; +import { normalizeFilename } from "./normalize-filename.js"; +import { readOwnPropertyValue } from "./read-own-property-value.js"; +import { resolveRealPath } from "./resolve-real-path.js"; + +interface RulePackageContextIndex { + readonly packagesByDescendingDirectoryLength: ReadonlyArray; + readonly packageByFilename: Map; +} + +const packageContextIndexes = new WeakMap(); + +const isDependencySection = (value: unknown): value is RulePackageDependency["section"] => + value === "dependencies" || + value === "devDependencies" || + value === "peerDependencies" || + value === "optionalDependencies"; + +const parseDependency = (value: unknown): RulePackageDependency | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const name = readOwnPropertyValue(value, "name"); + const section = readOwnPropertyValue(value, "section"); + const rawSpecifier = readOwnPropertyValue(value, "rawSpecifier"); + const resolvedSpecifier = readOwnPropertyValue(value, "resolvedSpecifier"); + if ( + typeof name !== "string" || + !isDependencySection(section) || + typeof rawSpecifier !== "string" || + typeof resolvedSpecifier !== "string" + ) { + return null; + } + return { name, section, rawSpecifier, resolvedSpecifier }; +}; + +const parsePackageContext = (value: unknown, rootDirectory: string): RulePackageContext | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const relativeDirectory = readOwnPropertyValue(value, "relativeDirectory"); + const capabilityValues = readOwnPropertyValue(value, "capabilities"); + const dependencyValues = readOwnPropertyValue(value, "dependencies"); + if ( + typeof relativeDirectory !== "string" || + !Array.isArray(capabilityValues) || + !Array.isArray(dependencyValues) + ) { + return null; + } + const capabilities = new Set( + capabilityValues.filter( + (capability): capability is string => typeof capability === "string" && capability.length > 0, + ), + ); + const dependencies = dependencyValues + .map(parseDependency) + .filter((dependency): dependency is RulePackageDependency => dependency !== null); + const dependenciesByName = new Map(); + for (const dependency of dependencies) { + if (!dependenciesByName.has(dependency.name)) { + dependenciesByName.set(dependency.name, dependency); + } + } + return { + directory: normalizeFilename(path.resolve(rootDirectory, relativeDirectory)), + relativeDirectory, + capabilities, + dependencies, + hasCapability: (capability) => capabilities.has(capability), + hasDependency: (dependencyName) => dependenciesByName.has(dependencyName), + getDependency: (dependencyName) => dependenciesByName.get(dependencyName) ?? null, + }; +}; + +const buildPackageContextIndex = ( + settings: RuleContext["settings"], +): RulePackageContextIndex | null => { + const rootDirectory = getReactDoctorStringSetting(settings, "rootDirectory"); + const packageContextValues = getReactDoctorSetting(settings, "packageContexts"); + if (rootDirectory === undefined || !Array.isArray(packageContextValues)) return null; + const packages = packageContextValues + .map((packageContextValue) => parsePackageContext(packageContextValue, rootDirectory)) + .filter((packageContext): packageContext is RulePackageContext => packageContext !== null) + .toSorted( + (leftPackage, rightPackage) => rightPackage.directory.length - leftPackage.directory.length, + ); + return { + packagesByDescendingDirectoryLength: packages, + packageByFilename: new Map(), + }; +}; + +const isNormalizedPathInsideDirectory = (filePath: string, directory: string): boolean => + filePath === directory || + filePath.startsWith(directory.endsWith("/") ? directory : `${directory}/`); + +export const resolveRulePackageContext = ( + settings: RuleContext["settings"], + filename: string | undefined, +): RulePackageContext | null => { + if (settings === undefined || filename === undefined || filename.length === 0) return null; + let packageContextIndex = packageContextIndexes.get(settings); + if (packageContextIndex === undefined) { + packageContextIndex = buildPackageContextIndex(settings); + packageContextIndexes.set(settings, packageContextIndex); + } + if (packageContextIndex === null) return null; + const normalizedFilename = normalizeFilename(path.resolve(filename)); + const cachedPackageContext = packageContextIndex.packageByFilename.get(normalizedFilename); + if (cachedPackageContext !== undefined) return cachedPackageContext; + const realFilename = normalizeFilename(resolveRealPath(normalizedFilename)); + const cachedRealPackageContext = packageContextIndex.packageByFilename.get(realFilename); + if (cachedRealPackageContext !== undefined) { + packageContextIndex.packageByFilename.set(normalizedFilename, cachedRealPackageContext); + return cachedRealPackageContext; + } + const packageContext = + packageContextIndex.packagesByDescendingDirectoryLength.find((packageContext) => + isNormalizedPathInsideDirectory(realFilename, packageContext.directory), + ) ?? null; + packageContextIndex.packageByFilename.set(normalizedFilename, packageContext); + packageContextIndex.packageByFilename.set(realFilename, packageContext); + return packageContext; +}; 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 560bae1125..eed536fd51 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 @@ -4,6 +4,7 @@ import { CROSS_FILE_DIRECTORY_WALK_MAX_LEVELS, TSCONFIG_EXTENDS_MAX_DEPTH, } from "../constants/thresholds.js"; +import { getCurrentResourceHost } from "../../internal/resource-host/resource-host-context.js"; import { recordContentProbe } from "./cross-file-probe-recorder.js"; import { resolveModuleFileFromAbsolutePath } from "./resolve-relative-import-path.js"; @@ -233,6 +234,10 @@ const matchPathPattern = (source: string, pattern: string): string | null => { // concrete file on disk, or null when no alias matches. Relative // imports are NOT handled here — callers resolve those first. export const resolveTsconfigAliasPath = (fromFilename: string, source: string): string | null => { + const currentResourceHost = getCurrentResourceHost(); + if (currentResourceHost) { + return currentResourceHost.resolveTsconfigAlias(fromFilename, source); + } const config = findNearestTsconfig(path.dirname(fromFilename)); if (!config) return null; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-zustand-api.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-zustand-api.test.ts index 708961beee..95ec964e99 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-zustand-api.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-zustand-api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { attachParentReferences } from "./attach-parent-references.js"; import { parseFixture } from "../../test-utils/parse-fixture.js"; import { analyzeScopes } from "../semantic/scope-analysis.js"; import type { EsTreeNode } from "./es-tree-node.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-context.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-context.ts index d078fa2f16..ae5d6648a4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-context.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-context.ts @@ -1,6 +1,7 @@ import type { ReportDescriptor } from "./report-descriptor.js"; import type { ControlFlowAnalysis } from "../semantic/control-flow-graph.js"; import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { RulePackageContext } from "./rule-package-context.js"; // The "base" context the host (oxlint at runtime, ESLint via the // adapter, our test harness) hands to a rule. Pure I/O surface — the @@ -27,6 +28,7 @@ export interface BaseRuleContext { // directly. `scopes` / `cfg` are guaranteed non-null because every rule is // wrapped at plugin load time. Tests pass a fully-built context via run-rule.ts. export interface RuleContext extends Omit { + readonly packageContext?: RulePackageContext | null; readonly scopes: ScopeAnalysis; readonly cfg: ControlFlowAnalysis; } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-package-context.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-package-context.ts new file mode 100644 index 0000000000..109512a7b3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule-package-context.ts @@ -0,0 +1,22 @@ +import type { CapabilityQuery } from "./capability.js"; + +export interface RulePackageDependency { + readonly name: string; + readonly section: + | "dependencies" + | "devDependencies" + | "peerDependencies" + | "optionalDependencies"; + readonly rawSpecifier: string; + readonly resolvedSpecifier: string; +} + +export interface RulePackageContext { + readonly directory: string; + readonly relativeDirectory: string; + readonly capabilities: ReadonlySet; + readonly dependencies: ReadonlyArray; + readonly hasCapability: CapabilityQuery; + readonly hasDependency: (dependencyName: string) => boolean; + readonly getDependency: (dependencyName: string) => RulePackageDependency | null; +} diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.test.ts index 72cb6b6558..8caf609861 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.test.ts @@ -3,6 +3,25 @@ import { wrapWithSemanticContext } from "./wrap-with-semantic-context.js"; import type { Rule } from "./rule.js"; describe("wrapWithSemanticContext", () => { + it("keeps filename resolution lazy without package-context settings", () => { + let filenameReadCount = 0; + const rule: Rule = { + id: "lazy-filename", + severity: "error", + create: () => ({}), + }; + + wrapWithSemanticContext(rule).create({ + report: () => {}, + getFilename: () => { + filenameReadCount += 1; + return "/tmp/example.js"; + }, + }); + + expect(filenameReadCount).toBe(0); + }); + it("preserves the bound getFilename fallback and adds the root-capture Program visitor", () => { let resolvedFilename: string | undefined; const callExpressionHandler = (): void => {}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.ts index b82aefb9f0..c18af3e18c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-with-semantic-context.ts @@ -7,6 +7,10 @@ import { analyzeScopes } from "../semantic/scope-analysis.js"; import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; import { analyzeControlFlow } from "../semantic/control-flow-graph.js"; import type { ControlFlowAnalysis } from "../semantic/control-flow-graph.js"; +import { resolveRulePackageContext } from "./resolve-rule-package-context.js"; +import { getReactDoctorBooleanSetting } from "./get-react-doctor-setting.js"; +import { hasRulePackageCapability } from "./has-rule-package-capability.js"; +import type { Capability } from "./capability.js"; // Wraps a rule so `context.scopes` and `context.cfg` exist at runtime // even when oxlint's host context doesn't pre-build them. We build the @@ -56,6 +60,7 @@ const FALLBACK_CFG: ControlFlowAnalysis = { const scopesByProgram = new WeakMap(); const cfgByProgram = new WeakMap(); +const EMPTY_VISITORS: RuleVisitors = {}; export const wrapWithSemanticContext = (rule: Rule): HostRule => ({ ...rule, @@ -86,12 +91,24 @@ export const wrapWithSemanticContext = (rule: Rule): HostRule => ({ // its deprecated `getFilename()` invoked ON the host (so a `this`-bound // class method keeps its binding — forwarding a bare reference dropped // `this` and returned `undefined` under ESLint 9, crashing rules). + const shouldApplyPackageCapabilityGates = + getReactDoctorBooleanSetting(baseContext.settings, "packageCapabilityGates") === true; + const shouldResolvePackageContext = + shouldApplyPackageCapabilityGates || + getReactDoctorBooleanSetting(baseContext.settings, "packageContextEnabled") === true; + const filename = shouldResolvePackageContext + ? (baseContext.filename ?? baseContext.getFilename?.()) + : undefined; + const packageContext = shouldResolvePackageContext + ? resolveRulePackageContext(baseContext.settings, filename) + : null; const enrichedContext: RuleContext = { report: baseContext.report, get filename() { return baseContext.filename ?? baseContext.getFilename?.(); }, settings: baseContext.settings, + packageContext, get scopes() { return getScopes(); }, @@ -100,7 +117,17 @@ export const wrapWithSemanticContext = (rule: Rule): HostRule => ({ }, }; - const visitors = rule.create(enrichedContext); + const hasContextCapability = (capability: Capability): boolean => + hasRulePackageCapability(packageContext, baseContext.settings, capability); + const isMissingRequiredCapability = + shouldApplyPackageCapabilityGates && + rule.requires?.some((capability) => !hasContextCapability(capability)) === true; + const hasDisabledCapability = + shouldApplyPackageCapabilityGates && rule.disabledWhen?.some(hasContextCapability) === true; + const visitors = + isMissingRequiredCapability || hasDisabledCapability + ? EMPTY_VISITORS + : rule.create(enrichedContext); // Program enter fires before every other visitor, so capturing the root // there is enough — wrapping every visitor of every rule in a // capture-then-forward closure added a call per (node × rule) for diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/attach-parent-references.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/attach-parent-references.ts deleted file mode 100644 index 17611a839f..0000000000 --- a/packages/oxlint-plugin-react-doctor/src/test-utils/attach-parent-references.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Re-exported from the production utility — see -// `plugin/utils/attach-parent-references.ts` for the canonical -// implementation. Cross-file parsing in production code (e.g. -// `no-mutating-reducer-state` following an imported reducer) needs -// the same parent-attachment pass; consolidating in one place -// avoids the drift two copies would invite. -export { attachParentReferences } from "../plugin/utils/attach-parent-references.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/attach-source-locations.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/attach-source-locations.ts index 7700ec2d10..59191e6d64 100644 --- a/packages/oxlint-plugin-react-doctor/src/test-utils/attach-source-locations.ts +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/attach-source-locations.ts @@ -1,80 +1 @@ -import { isAstNode } from "../plugin/utils/is-ast-node.js"; -import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; - -interface SourcePosition { - line: number; - column: number; -} - -interface NodeWithOffsets { - start?: number; - end?: number; - range?: [number, number]; - loc?: { - start: SourcePosition; - end: SourcePosition; - }; -} - -const buildLineStartOffsets = (sourceText: string): number[] => { - const lineStartOffsets = [0]; - for (let sourceIndex = 0; sourceIndex < sourceText.length; sourceIndex++) { - if (sourceText[sourceIndex] === "\n") lineStartOffsets.push(sourceIndex + 1); - } - return lineStartOffsets; -}; - -const offsetToSourcePosition = ( - offset: number, - lineStartOffsets: ReadonlyArray, -): SourcePosition => { - let lowIndex = 0; - let highIndex = lineStartOffsets.length - 1; - while (lowIndex <= highIndex) { - const middleIndex = Math.floor((lowIndex + highIndex) / 2); - if (lineStartOffsets[middleIndex] <= offset) { - lowIndex = middleIndex + 1; - } else { - highIndex = middleIndex - 1; - } - } - const lineIndex = Math.max(0, highIndex); - return { - line: lineIndex + 1, - column: offset - lineStartOffsets[lineIndex], - }; -}; - -export const attachSourceLocations = (root: EsTreeNode, sourceText: string): void => { - const lineStartOffsets = buildLineStartOffsets(sourceText); - const visit = (node: EsTreeNode): void => { - const nodeWithOffsets = node as NodeWithOffsets; - if (typeof nodeWithOffsets.start === "number" && typeof nodeWithOffsets.end === "number") { - nodeWithOffsets.loc = { - start: offsetToSourcePosition(nodeWithOffsets.start, lineStartOffsets), - end: offsetToSourcePosition(nodeWithOffsets.end, lineStartOffsets), - }; - // `range` mirrors oxlint's runtime AST. The `getProgramAnalysis` - // (eslint-scope) effect rules dereference `node.range` / `block.range`, - // so without this they silently resolve no scopes (or crash) under the - // unit harness — only integration fixtures exercised them before. - if (!nodeWithOffsets.range) { - nodeWithOffsets.range = [nodeWithOffsets.start, nodeWithOffsets.end]; - } - } - - const nodeRecord = node as unknown as Record; - for (const key of Object.keys(nodeRecord)) { - if (key === "parent") continue; - const child = nodeRecord[key]; - if (Array.isArray(child)) { - for (const item of child) { - if (isAstNode(item)) visit(item); - } - } else if (isAstNode(child)) { - visit(child); - } - } - }; - visit(root); -}; +export { attachSourceLocations } from "../internal/attach-source-locations.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/parse-fixture.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/parse-fixture.ts index edf7b53978..9bf2419ea4 100644 --- a/packages/oxlint-plugin-react-doctor/src/test-utils/parse-fixture.ts +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/parse-fixture.ts @@ -1,53 +1,17 @@ -import * as path from "node:path"; -import { parseSync } from "oxc-parser"; -import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; +import { parseSource } from "../internal/parse-source.js"; +import type { + ParseSourceError, + ParseSourceOptions, + ParseSourceResult, +} from "../internal/parse-source.js"; -interface ParseFixtureOptions { - filename?: string; - // HACK: The filename normally drives `lang` derivation, but tests for - // rules like jsx-filename-extension want to parse JSX in a file named - // `Foo.js` to reproduce the diagnostic. Setting `forceJsx: true` - // overrides the lang derivation to always parse with TSX. - forceJsx?: boolean; -} +interface ParseFixtureOptions extends ParseSourceOptions {} -export interface ParseFixtureResult { - program: EsTreeNode; - errors: ReadonlyArray<{ message: string }>; -} +export interface ParseFixtureError extends ParseSourceError {} -const FILENAME_TO_LANG: Record = { - ".ts": "ts", - ".tsx": "tsx", - ".js": "js", - ".jsx": "jsx", - ".mjs": "js", - ".cjs": "js", - ".mts": "ts", - ".cts": "ts", -}; +export interface ParseFixtureResult extends ParseSourceResult {} -const resolveLang = (filename: string): "ts" | "tsx" | "js" | "jsx" => { - const extension = path.extname(filename).toLowerCase(); - return FILENAME_TO_LANG[extension] ?? "tsx"; -}; - -// Parses a code fixture using oxc-parser (the same engine oxlint uses at -// runtime) with `astType: "ts"` so the returned AST is TSESTree-shaped — -// matching the type universe our `@typescript-eslint/types`-typed rule -// visitors operate on — and `preserveParens: false` so `(a ? b : c)` never -// carries the ParenthesizedExpression wrapper production oxlint never -// emits. The default filename ends in `.tsx` so JSX always parses; pass an -// explicit filename to test `.ts` / `.js` paths. export const parseFixture = ( - code: string, + sourceText: string, options: ParseFixtureOptions = {}, -): ParseFixtureResult => { - const filename = options.filename ?? "fixture.tsx"; - const lang = options.forceJsx ? "tsx" : resolveLang(filename); - const result = parseSync(filename, code, { astType: "ts", lang, preserveParens: false }); - return { - program: result.program as unknown as EsTreeNode, - errors: result.errors.map((parseError) => ({ message: parseError.message })), - }; -}; +): ParseFixtureResult => parseSource(sourceText, options); diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/run-rule.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/run-rule.ts index e019ae2abc..19c7104a7d 100644 --- a/packages/oxlint-plugin-react-doctor/src/test-utils/run-rule.ts +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/run-rule.ts @@ -1,103 +1,48 @@ -import { attachParentReferences } from "./attach-parent-references.js"; -import { attachSourceLocations } from "./attach-source-locations.js"; +import { executeRule } from "../internal/execute-rule.js"; +import type { + ExecutedRuleDiagnostic, + ExecuteRuleOptions, + ExecuteRuleResult, +} from "../internal/execute-rule.js"; import { parseFixture } from "./parse-fixture.js"; import type { ParseFixtureResult } from "./parse-fixture.js"; -import { isAstNode } from "../plugin/utils/is-ast-node.js"; -import type { EsTreeNode } from "../plugin/utils/es-tree-node.js"; -import type { ReportDescriptor } from "../plugin/utils/report-descriptor.js"; import type { Rule } from "../plugin/utils/rule.js"; -import type { RuleContext } from "../plugin/utils/rule-context.js"; -import type { RuleVisitors } from "../plugin/utils/rule-visitors.js"; -import { analyzeScopes } from "../plugin/semantic/scope-analysis.js"; -import type { ScopeAnalysis } from "../plugin/semantic/scope-analysis.js"; -import { analyzeControlFlow } from "../plugin/semantic/control-flow-graph.js"; -import type { ControlFlowAnalysis } from "../plugin/semantic/control-flow-graph.js"; -export interface RunRuleOptions { - filename?: string; - settings?: Readonly>; - // Parse the fixture with TSX even when the filename suggests `.js`/`.ts`. - // Useful for tests that want a non-JSX-friendly extension on the rule - // context but still need JSX in the source. - forceJsx?: boolean; -} +export interface RunRuleOptions extends ExecuteRuleOptions {} export interface RuleDiagnostic { - message: string; - nodeType: string; + readonly message: string; + readonly nodeType: string; } +export interface CapturedRuleDiagnostic extends ExecutedRuleDiagnostic {} + export interface RunRuleResult { - diagnostics: RuleDiagnostic[]; - parseErrors: ReadonlyArray<{ message: string }>; + readonly diagnostics: RuleDiagnostic[]; + readonly parseErrors: ReadonlyArray<{ readonly message: string }>; } -const dispatchTreeWalk = (root: EsTreeNode, visitors: RuleVisitors): void => { - const visit = (node: EsTreeNode): void => { - const enterHandler = visitors[node.type]; - if (typeof enterHandler === "function") enterHandler(node); - const nodeRecord = node as unknown as Record; - for (const key of Object.keys(nodeRecord)) { - if (key === "parent") continue; - const child = nodeRecord[key]; - if (Array.isArray(child)) { - for (const item of child) { - if (isAstNode(item)) visit(item); - } - } else if (isAstNode(child)) { - visit(child); - } - } - const exitHandler = visitors[`${node.type}:exit`]; - if (typeof exitHandler === "function") exitHandler(node); - }; - visit(root); -}; +export const executeRuleOnParsedFixture = ( + rule: Rule, + sourceText: string, + parsedSource: ParseFixtureResult, + options: RunRuleOptions = {}, +): ExecuteRuleResult => executeRule(rule, sourceText, parsedSource, options); -// Pure-TS rule runner mirroring what oxlint does at runtime: parse code, -// attach `parent` references, build a fake `RuleContext`, dispatch each -// `node.type` visitor pre-order and each `${node.type}:exit` visitor -// post-order (exactly the enter/exit pairs oxlint compiles), and collect -// every `report({...})` call as a `RuleDiagnostic`. Used by every -// `.test.ts` to assert pass/fail semantics ported from OXC's -// `Tester::new(...).pass / .fail`. export const runRuleOnParsedFixture = ( rule: Rule, code: string, parsed: ParseFixtureResult, options: RunRuleOptions = {}, ): RunRuleResult => { - attachParentReferences(parsed.program); - attachSourceLocations(parsed.program, code); - - const diagnostics: RuleDiagnostic[] = []; - let scopes: ScopeAnalysis | undefined; - let controlFlow: ControlFlowAnalysis | undefined; - const context: RuleContext = { - report: (descriptor: ReportDescriptor) => { - diagnostics.push({ - message: descriptor.message, - nodeType: descriptor.node.type, - }); - }, - // `in` (not `?? "fixture.tsx"`) so a test can pass `{ filename: undefined }` - // to exercise a host with no filename. - filename: "filename" in options ? options.filename : "fixture.tsx", - settings: options.settings, - get scopes() { - scopes ??= analyzeScopes(parsed.program); - return scopes; - }, - get cfg() { - controlFlow ??= analyzeControlFlow(parsed.program); - return controlFlow; - }, + const result = executeRuleOnParsedFixture(rule, code, parsed, options); + return { + diagnostics: result.diagnostics.map((diagnostic) => ({ + message: diagnostic.message, + nodeType: diagnostic.node.type, + })), + parseErrors: result.parseErrors, }; - - const visitors = rule.create(context); - dispatchTreeWalk(parsed.program, visitors); - - return { diagnostics, parseErrors: parsed.errors }; }; export const runRule = (rule: Rule, code: string, options: RunRuleOptions = {}): RunRuleResult => { diff --git a/packages/oxlint-plugin-react-doctor/vite.config.ts b/packages/oxlint-plugin-react-doctor/vite.config.ts index 05d4d325f2..7329ddfec9 100644 --- a/packages/oxlint-plugin-react-doctor/vite.config.ts +++ b/packages/oxlint-plugin-react-doctor/vite.config.ts @@ -12,7 +12,10 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.j export default defineConfig({ pack: [ { - entry: { index: "./src/index.ts" }, + entry: { + contracts: "./src/contracts.ts", + index: "./src/index.ts", + }, deps: { // HACK: oxc-parser loads a platform-specific NAPI binding via // require("@oxc-parser/binding-"). Rollup inlines the diff --git a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts index 6f82e87a0f..95a32b4a1d 100644 --- a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts +++ b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts @@ -10,6 +10,7 @@ import { OxlintConcurrency, Progress, Project, + ProjectChecks, Reporter, Score, SupplyChain, @@ -145,6 +146,7 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { const baseLayers = Layer.mergeAll( projectLayer, + ProjectChecks.layerNode, configLayer, Files.layerNode, Git.layerNode, diff --git a/tsconfig.json b/tsconfig.json index 77540b3d6a..2b0f84fd0b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,11 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "paths": { + "oxlint-plugin-react-doctor/contracts": [ + "./packages/oxlint-plugin-react-doctor/src/contracts.ts" + ] + }, "declaration": true, "esModuleInterop": true, "skipLibCheck": true,