Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-contracts-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"oxlint-plugin-react-doctor": patch
---

Add a lightweight contracts entry for shared framework and capability metadata.
2 changes: 2 additions & 0 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
mergeReactDoctorConfigs,
Progress,
Project,
ProjectChecks,
Reporter,
resolveScanTarget,
restoreLegacyThrow,
Expand Down Expand Up @@ -80,6 +81,7 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => {
});
return Layer.mergeAll(
Project.layerNode,
ProjectChecks.layerNode,
configLayer,
input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]),
Files.layerNode,
Expand Down
139 changes: 139 additions & 0 deletions packages/core/src/assemble-inspect-output.ts
Original file line number Diff line number Diff line change
@@ -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<Diagnostic>;
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<string>;
/** `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<string>;
/** Project-relative POSIX paths the lint pass completed successfully. */
readonly analyzedFiles: ReadonlyArray<string>;
/** 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<SuppressedRuleCount>;
}

export interface InspectLintCompletion {
readonly didFail: boolean;
readonly failureReason: string | null;
readonly failureReasonTag: ReactDoctorErrorReason["_tag"] | null;
readonly failureReasonKind: OxlintUnavailable["kind"] | null;
readonly partialFailures: ReadonlyArray<string>;
readonly analyzedFiles: ReadonlyArray<string>;
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<string>;
readonly elapsedMilliseconds: number;
readonly concurrency: number;
}

export interface AssembleInspectOutputInput {
readonly project: ProjectInfo;
readonly userConfig: ReactDoctorConfig | null;
readonly resolvedDirectory: string;
readonly diagnostics: ReadonlyArray<Diagnostic>;
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<SuppressedRuleCount>;
}

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,
});
126 changes: 126 additions & 0 deletions packages/core/src/background-analyzer-execution.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
readonly includedTags: ReadonlySet<string> | undefined;
readonly includeTagDefaults: boolean | undefined;
readonly processDiagnostics: <StreamEnvironment>(
stream: Stream.Stream<Diagnostic, never, StreamEnvironment>,
) => Stream.Stream<Diagnostic, never, StreamEnvironment>;
}

interface SupplyChainForkResult {
readonly diagnostics: ReadonlyArray<Diagnostic>;
readonly timedOut: boolean;
}

interface BackgroundAnalyzerResult {
readonly environmentDiagnostics: ReadonlyArray<Diagnostic>;
readonly securityDiagnostics: ReadonlyArray<Diagnostic>;
readonly supplyChainDiagnostics: ReadonlyArray<Diagnostic>;
readonly securityScanFailed: boolean;
readonly supplyChainOverlapTimedOut: boolean;
}

interface BackgroundAnalyzerExecution {
readonly join: Effect.Effect<BackgroundAnalyzerResult>;
}

export const startBackgroundAnalyzerExecution = (
input: StartBackgroundAnalyzerExecutionInput,
): Effect.Effect<BackgroundAnalyzerExecution> =>
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<SupplyChainForkResult>({
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,
};
}),
};
});
47 changes: 47 additions & 0 deletions packages/core/src/build-dead-code-plan.ts
Original file line number Diff line number Diff line change
@@ -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),
};
};
Loading
Loading