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
60 changes: 60 additions & 0 deletions packages/api/src/core-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// HACK: `$1` aliases preserve the byte-identical built bindings that separate
// direct imports produced before this facade centralized the core boundary.
export {
AmbiguousProjectError,
buildSkippedChecks,
Config,
createOxlintSpawnSlots,
DeadCode,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
defineConfig,
detectAiTrainingEnvironment,
Files,
Git,
hasReactRuntime,
hasReactRuntime as hasReactRuntime$1,
isReactDoctorError,
layerOtlp,
Linter,
LintPartialFailures,
mapWithConcurrency,
mergeReactDoctorConfigs,
NoReactDependencyError,
NotADirectoryError,
OxlintConcurrency,
OxlintSpawnSlots,
PackageJsonNotFoundError,
Progress,
Project,
ProjectChecks,
ProjectNotFoundError,
ReactDoctorError,
Reporter,
resolveScanTarget,
restoreLegacyThrow,
runInspect,
Score,
SupplyChain,
} from "@react-doctor/core";
export type {
DiagnoseOptions,
DiagnoseOptions as DiagnoseOptions$1,
DiagnoseProjectsInput,
DiagnoseProjectsInput as DiagnoseProjectsInput$1,
DiagnoseProjectsResult,
DiagnoseProjectsResult as DiagnoseProjectsResult$1,
DiagnoseResult,
DiagnoseResult as DiagnoseResult$1,
Diagnostic,
InspectOutput,
ProjectDefinition,
ProjectInfo,
ProjectResult,
ProjectResultError,
ProjectResultOk,
ReactDoctorConfig,
ResolvedScanTarget,
ScoreResult,
WorkerSlots,
} from "@react-doctor/core";
43 changes: 35 additions & 8 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import * as Layer from "effect/Layer";
import {
buildSkippedChecks,
Config,
createOxlintSpawnSlots,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
detectAiTrainingEnvironment,
Files,
Git,
hasReactRuntime,
hasReactRuntime$1 as hasReactRuntime,
layerOtlp,
Linter,
LintPartialFailures,
mapWithConcurrency,
mergeReactDoctorConfigs,
OxlintConcurrency,
OxlintSpawnSlots,
Progress,
Project,
ProjectChecks,
Expand All @@ -26,17 +29,18 @@ import {
SupplyChain,
type InspectOutput,
type ResolvedScanTarget,
} from "@react-doctor/core";
type WorkerSlots,
} from "./core-api.js";
import type {
DiagnoseOptions,
DiagnoseProjectsInput,
DiagnoseProjectsResult,
DiagnoseResult,
DiagnoseOptions$1 as DiagnoseOptions,
DiagnoseProjectsInput$1 as DiagnoseProjectsInput,
DiagnoseProjectsResult$1 as DiagnoseProjectsResult,
DiagnoseResult$1 as DiagnoseResult,
ProjectDefinition,
ProjectResult,
ReactDoctorConfig,
ScoreResult,
} from "@react-doctor/core";
} from "./core-api.js";

// The CLI carries the richer warning (logger + telemetry); the library only
// has stdout, so it warns once per process via console.warn when a scan runs
Expand All @@ -54,6 +58,8 @@ interface DiagnoseLayerInput {
readonly config: ReactDoctorConfig | null;
readonly shouldRunLint: boolean;
readonly shouldRunDeadCode: boolean;
readonly oxlintConcurrency: number;
readonly oxlintSpawnSlots: WorkerSlots;
readonly configOverrideTarget?: Pick<
ResolvedScanTarget,
"resolvedDirectory" | "configSourceDirectory"
Expand Down Expand Up @@ -88,6 +94,8 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => {
Git.layerNode,
input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]),
LintPartialFailures.layerLive,
Layer.succeed(OxlintConcurrency, input.oxlintConcurrency),
Layer.succeed(OxlintSpawnSlots, input.oxlintSpawnSlots),
Progress.layerNoop,
Reporter.layerNoop,
Score.layerHttp,
Expand Down Expand Up @@ -156,6 +164,8 @@ const diagnoseDirectory = async (
const program = buildInspectProgram(scanTarget, options);
const shouldRunLint = resolveShouldRunLint(options, scanTarget.userConfig);
const shouldRunDeadCode = resolveShouldRunDeadCode(options, scanTarget.userConfig);
const oxlintConcurrency = Effect.runSync(OxlintConcurrency);
const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency);

const output: InspectOutput = await Effect.runPromise(
restoreLegacyThrow(
Expand All @@ -165,6 +175,8 @@ const diagnoseDirectory = async (
config: scanTarget.userConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
}),
),
Effect.provide(layerOtlp),
Expand Down Expand Up @@ -192,6 +204,8 @@ const diagnoseProject = async (
projectDefinition: ProjectDefinition,
baseOptions: DiagnoseOptions,
batchConfig: ReactDoctorConfig | undefined,
oxlintConcurrency: number,
oxlintSpawnSlots: WorkerSlots,
): Promise<ProjectResult> => {
const startTime = globalThis.performance.now();

Expand Down Expand Up @@ -222,6 +236,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
configOverrideTarget: {
resolvedDirectory: scanTarget.resolvedDirectory,
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
Expand All @@ -231,6 +247,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
};
const layer = buildDiagnoseLayer(diagnoseLayerInput);

Expand Down Expand Up @@ -258,13 +276,22 @@ const diagnoseProjectBatch = async (
warnIfAiTrainingEnvironment();
const startTime = globalThis.performance.now();
const { projects, concurrency, config: batchConfig, ...baseOptions } = input;
const oxlintConcurrency = Effect.runSync(OxlintConcurrency);
const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency);

// `diagnoseProject` never rejects (failures come back as `ok: false`),
// so the pool always drains every project.
const projectResults = await mapWithConcurrency(
projects,
concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY,
(projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig),
(projectDefinition) =>
diagnoseProject(
projectDefinition,
baseOptions,
batchConfig,
oxlintConcurrency,
oxlintSpawnSlots,
),
);

const succeededProjects = projectResults.filter((projectResult) => projectResult.ok);
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { diagnose } from "./diagnose.js";
export { defineConfig, hasReactRuntime } from "@react-doctor/core";
export { defineConfig, hasReactRuntime } from "./core-api.js";

export type {
DiagnoseOptions,
Expand All @@ -14,7 +14,7 @@ export type {
ProjectResultOk,
ReactDoctorConfig,
ScoreResult,
} from "@react-doctor/core";
} from "./core-api.js";
export {
ReactDoctorError,
ProjectNotFoundError,
Expand All @@ -23,4 +23,4 @@ export {
NotADirectoryError,
AmbiguousProjectError,
isReactDoctorError,
} from "@react-doctor/core";
} from "./core-api.js";
135 changes: 135 additions & 0 deletions packages/api/tests/core-api-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vite-plus/test";
import * as corePackage from "@react-doctor/core";
import * as publicApi from "../src/index.js";
import * as coreApi from "../src/core-api.js";

const SOURCE_DIRECTORY = fileURLToPath(new URL("../src", import.meta.url));
const CORE_API_RELATIVE_PATH = "core-api.ts";
const CORE_PACKAGE_SPECIFIER_PATTERN = /["']@react-doctor\/core(?:\/[^"']*)?["']/;
const TYPE_EXPORT_PATTERN = /export type\s*\{([\s\S]*?)\}\s*from\s*["']@react-doctor\/core["']/;

const collectTypeScriptFiles = (directory: string): string[] =>
fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return collectTypeScriptFiles(entryPath);
return entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))
? [entryPath]
: [];
});

describe("API core boundary", () => {
it("routes every production core package dependency through one local facade", () => {
const directCoreDependents = collectTypeScriptFiles(SOURCE_DIRECTORY).flatMap((filePath) => {
const sourceText = fs.readFileSync(filePath, "utf8");
return CORE_PACKAGE_SPECIFIER_PATTERN.test(sourceText)
? [path.relative(SOURCE_DIRECTORY, filePath).replaceAll(path.sep, "/")]
: [];
});

expect(directCoreDependents).toEqual([CORE_API_RELATIVE_PATH]);
});

it("freezes the facade runtime and type capabilities", () => {
expect(Object.keys(coreApi).sort()).toEqual(
[
"AmbiguousProjectError",
"buildSkippedChecks",
"Config",
"createOxlintSpawnSlots",
"DeadCode",
"DEFAULT_PROJECT_SCAN_CONCURRENCY",
"DEFAULT_SHOW_WARNINGS",
"defineConfig",
"detectAiTrainingEnvironment",
"Files",
"Git",
"hasReactRuntime",
"hasReactRuntime$1",
"isReactDoctorError",
"layerOtlp",
"Linter",
"LintPartialFailures",
"mapWithConcurrency",
"mergeReactDoctorConfigs",
"NoReactDependencyError",
"NotADirectoryError",
"OxlintConcurrency",
"OxlintSpawnSlots",
"PackageJsonNotFoundError",
"Progress",
"Project",
"ProjectChecks",
"ProjectNotFoundError",
"ReactDoctorError",
"Reporter",
"resolveScanTarget",
"restoreLegacyThrow",
"runInspect",
"Score",
"SupplyChain",
].sort(),
);

const facadeSource = fs.readFileSync(
path.join(SOURCE_DIRECTORY, CORE_API_RELATIVE_PATH),
"utf8",
);
const typeExportBindings = TYPE_EXPORT_PATTERN.exec(facadeSource)?.[1] ?? "";
const typeCapabilities = typeExportBindings
.split(",")
.map((binding) => binding.trim())
.filter((binding) => binding.length > 0)
.sort();
expect(typeCapabilities).toEqual(
[
"DiagnoseOptions",
"DiagnoseOptions as DiagnoseOptions$1",
"DiagnoseProjectsInput",
"DiagnoseProjectsInput as DiagnoseProjectsInput$1",
"DiagnoseProjectsResult",
"DiagnoseProjectsResult as DiagnoseProjectsResult$1",
"DiagnoseResult",
"DiagnoseResult as DiagnoseResult$1",
"Diagnostic",
"InspectOutput",
"ProjectDefinition",
"ProjectInfo",
"ProjectResult",
"ProjectResultError",
"ProjectResultOk",
"ReactDoctorConfig",
"ResolvedScanTarget",
"ScoreResult",
"WorkerSlots",
].sort(),
);
});

it("preserves every public runtime re-export by identity", () => {
expect({
AmbiguousProjectError: publicApi.AmbiguousProjectError,
defineConfig: publicApi.defineConfig,
hasReactRuntime: publicApi.hasReactRuntime,
isReactDoctorError: publicApi.isReactDoctorError,
NoReactDependencyError: publicApi.NoReactDependencyError,
NotADirectoryError: publicApi.NotADirectoryError,
PackageJsonNotFoundError: publicApi.PackageJsonNotFoundError,
ProjectNotFoundError: publicApi.ProjectNotFoundError,
ReactDoctorError: publicApi.ReactDoctorError,
}).toEqual({
AmbiguousProjectError: corePackage.AmbiguousProjectError,
defineConfig: corePackage.defineConfig,
hasReactRuntime: corePackage.hasReactRuntime,
isReactDoctorError: corePackage.isReactDoctorError,
NoReactDependencyError: corePackage.NoReactDependencyError,
NotADirectoryError: corePackage.NotADirectoryError,
PackageJsonNotFoundError: corePackage.PackageJsonNotFoundError,
ProjectNotFoundError: corePackage.ProjectNotFoundError,
ReactDoctorError: corePackage.ReactDoctorError,
});
expect(coreApi.hasReactRuntime$1).toBe(corePackage.hasReactRuntime);
});
});
2 changes: 1 addition & 1 deletion packages/language-server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,4 @@ export const SCANNABLE_EXTENSIONS = [
".html",
] as const;

export { CONFIG_FINGERPRINT_FILENAMES as CONFIG_WATCH_FILENAMES } from "@react-doctor/core";
export { CONFIG_FINGERPRINT_FILENAMES as CONFIG_WATCH_FILENAMES } from "./core/core-api.js";
20 changes: 20 additions & 0 deletions packages/language-server/src/core/core-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export {
ADOPTABLE_LINT_CONFIG_FILENAMES,
buildDiagnosticIdentity,
clearCoreCaches,
computeConfigFingerprint,
CONFIG_FINGERPRINT_FILENAMES,
discoverReactSubprojects,
getRuleMetadata,
hashFileContents,
listSourceFiles,
messageFromUnknown,
resolveNodeForOxlint,
runEditorScan,
STAGED_FILES_PROJECT_CONFIG_FILENAMES,
} from "@react-doctor/core";
export type {
Diagnostic as CoreDiagnostic,
DiagnosticRelatedLocation,
ProjectInfo,
} from "@react-doctor/core";
2 changes: 1 addition & 1 deletion packages/language-server/src/core/lint-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { messageFromUnknown, type Diagnostic as CoreDiagnostic } from "@react-doctor/core";
import { messageFromUnknown, type CoreDiagnostic } from "./core-api.js";
import {
CACHE_FILENAME_HASH_LENGTH_CHARS,
LINT_CACHE_PERSIST_DEBOUNCE_MS,
Expand Down
Loading
Loading