diff --git a/packages/api/src/core-api.ts b/packages/api/src/core-api.ts new file mode 100644 index 0000000000..b3e142d4d8 --- /dev/null +++ b/packages/api/src/core-api.ts @@ -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"; diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index cae39b8083..267e43dd32 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -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, @@ -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 @@ -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" @@ -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, @@ -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( @@ -165,6 +175,8 @@ const diagnoseDirectory = async ( config: scanTarget.userConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, }), ), Effect.provide(layerOtlp), @@ -192,6 +204,8 @@ const diagnoseProject = async ( projectDefinition: ProjectDefinition, baseOptions: DiagnoseOptions, batchConfig: ReactDoctorConfig | undefined, + oxlintConcurrency: number, + oxlintSpawnSlots: WorkerSlots, ): Promise => { const startTime = globalThis.performance.now(); @@ -222,6 +236,8 @@ const diagnoseProject = async ( config: effectiveConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, configOverrideTarget: { resolvedDirectory: scanTarget.resolvedDirectory, configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory, @@ -231,6 +247,8 @@ const diagnoseProject = async ( config: effectiveConfig, shouldRunLint, shouldRunDeadCode, + oxlintConcurrency, + oxlintSpawnSlots, }; const layer = buildDiagnoseLayer(diagnoseLayerInput); @@ -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); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 06950f41c7..ae56492297 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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, @@ -14,7 +14,7 @@ export type { ProjectResultOk, ReactDoctorConfig, ScoreResult, -} from "@react-doctor/core"; +} from "./core-api.js"; export { ReactDoctorError, ProjectNotFoundError, @@ -23,4 +23,4 @@ export { NotADirectoryError, AmbiguousProjectError, isReactDoctorError, -} from "@react-doctor/core"; +} from "./core-api.js"; diff --git a/packages/api/tests/core-api-boundary.test.ts b/packages/api/tests/core-api-boundary.test.ts new file mode 100644 index 0000000000..4a4454d2b2 --- /dev/null +++ b/packages/api/tests/core-api-boundary.test.ts @@ -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); + }); +}); diff --git a/packages/language-server/src/constants.ts b/packages/language-server/src/constants.ts index 2fd5d78b5f..574c88dcd3 100644 --- a/packages/language-server/src/constants.ts +++ b/packages/language-server/src/constants.ts @@ -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"; diff --git a/packages/language-server/src/core/core-api.ts b/packages/language-server/src/core/core-api.ts new file mode 100644 index 0000000000..05cbe5c1e1 --- /dev/null +++ b/packages/language-server/src/core/core-api.ts @@ -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"; diff --git a/packages/language-server/src/core/lint-cache.ts b/packages/language-server/src/core/lint-cache.ts index 6b58abd182..43fd38dd3c 100644 --- a/packages/language-server/src/core/lint-cache.ts +++ b/packages/language-server/src/core/lint-cache.ts @@ -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, diff --git a/packages/language-server/src/core/overlay.ts b/packages/language-server/src/core/overlay.ts index 54b8873934..bd9a0babc4 100644 --- a/packages/language-server/src/core/overlay.ts +++ b/packages/language-server/src/core/overlay.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { ADOPTABLE_LINT_CONFIG_FILENAMES, STAGED_FILES_PROJECT_CONFIG_FILENAMES, -} from "@react-doctor/core"; +} from "./core-api.js"; import type { TextProvider } from "../types.js"; import { toProjectRelative } from "../utils/to-project-relative.js"; @@ -27,7 +27,7 @@ export interface OverlaySnapshot { readonly cleanup: () => void; } -export interface MaterializeOverlayInput { +interface MaterializeOverlayInput { /** Absolute project root. */ readonly projectDirectory: string; /** Absolute target file paths to overlay (the open buffers). */ diff --git a/packages/language-server/src/core/project-graph.ts b/packages/language-server/src/core/project-graph.ts index 5ee524f327..05b47997b5 100644 --- a/packages/language-server/src/core/project-graph.ts +++ b/packages/language-server/src/core/project-graph.ts @@ -1,15 +1,6 @@ import path from "node:path"; -import { - clearAutoSuppressionCaches, - clearConfigCache, - clearIgnorePatternsCache, - clearMinifiedFileCache, - clearPackageJsonCache, - clearProjectCache, - discoverReactSubprojects, - messageFromUnknown, -} from "@react-doctor/core"; import { SILENT_LOGGER, type Logger, type ProjectGraph, type WorkspaceProject } from "../types.js"; +import { clearCoreCaches, discoverReactSubprojects, messageFromUnknown } from "./core-api.js"; export interface ProjectGraphOptions { /** Absolute workspace root directories (LSP workspace folders). */ @@ -76,12 +67,7 @@ export const createProjectGraph = (options: ProjectGraphOptions): ProjectGraph = projects = discover(); }, invalidate: () => { - clearProjectCache(); - clearConfigCache(); - clearPackageJsonCache(); - clearIgnorePatternsCache(); - clearAutoSuppressionCaches(); - clearMinifiedFileCache(); + clearCoreCaches(); projects = null; }, }; diff --git a/packages/language-server/src/core/scan-runner.ts b/packages/language-server/src/core/scan-runner.ts index 34bc5788cd..119a191d39 100644 --- a/packages/language-server/src/core/scan-runner.ts +++ b/packages/language-server/src/core/scan-runner.ts @@ -3,8 +3,8 @@ import { computeConfigFingerprint, hashFileContents, runEditorScan, - type Diagnostic as CoreDiagnostic, -} from "@react-doctor/core"; + type CoreDiagnostic, +} from "./core-api.js"; import { SILENT_LOGGER, type CancellationToken, @@ -19,7 +19,7 @@ import { toProjectRelative } from "../utils/to-project-relative.js"; import { createLintCache, type FileIdentity, type LintCache } from "./lint-cache.js"; import { materializeOverlay, type OverlaySnapshot } from "./overlay.js"; -export interface ScanRunnerOptions { +interface ScanRunnerOptions { /** Node binary able to load the oxlint native binding, or `null`. */ readonly nodeBinaryPath: string | null; /** Reads live file text (open buffer first, then disk) for overlays. */ diff --git a/packages/language-server/src/diagnostics/manager.ts b/packages/language-server/src/diagnostics/manager.ts index 63cde3fa9a..e28b7264f9 100644 --- a/packages/language-server/src/diagnostics/manager.ts +++ b/packages/language-server/src/diagnostics/manager.ts @@ -4,7 +4,7 @@ import { isPositionInRange } from "../text/positions.js"; import { fsPathToUri, uriToFsPath } from "../text/uri.js"; import { toLspDiagnostic } from "./mapper.js"; -export interface DiagnosticsManagerOptions { +interface DiagnosticsManagerOptions { /** Sends the authoritative diagnostic set for a URI to the client. */ readonly publish: (uri: string, diagnostics: LspDiagnostic[]) => void; /** Resolves current file text (open buffer or disk) for precise ranges. */ diff --git a/packages/language-server/src/diagnostics/mapper.ts b/packages/language-server/src/diagnostics/mapper.ts index 3817eeacfc..367dd0394a 100644 --- a/packages/language-server/src/diagnostics/mapper.ts +++ b/packages/language-server/src/diagnostics/mapper.ts @@ -1,5 +1,3 @@ -import { buildDiagnosticIdentity, getRuleMetadata } from "@react-doctor/core"; -import type { Diagnostic as CoreDiagnostic, DiagnosticRelatedLocation } from "@react-doctor/core"; import { DiagnosticSeverity, DiagnosticTag, @@ -9,10 +7,12 @@ import { } from "vscode-languageserver"; import { URI } from "vscode-uri"; import { DIAGNOSTIC_SOURCE } from "../constants.js"; +import { buildDiagnosticIdentity, getRuleMetadata } from "../core/core-api.js"; +import type { CoreDiagnostic, DiagnosticRelatedLocation } from "../core/core-api.js"; import type { ReactDoctorDiagnosticData } from "../types.js"; import { rangeFromByteSpan, rangeFromLineColumn } from "../text/positions.js"; -export interface MapDiagnosticInput { +interface MapDiagnosticInput { readonly diagnostic: CoreDiagnostic; /** Absolute fs path of the file this diagnostic belongs to. */ readonly fsPath: string; diff --git a/packages/language-server/src/features/code-actions.ts b/packages/language-server/src/features/code-actions.ts index 23f6bdd694..87bdcdfe13 100644 --- a/packages/language-server/src/features/code-actions.ts +++ b/packages/language-server/src/features/code-actions.ts @@ -25,7 +25,7 @@ import { */ export const SUPPRESS_ALL_CODE_ACTION_KIND = "source.suppressAll.reactDoctor"; -export interface BuildCodeActionsInput { +interface BuildCodeActionsInput { readonly uri: string; readonly fsPath: string; readonly documentText: string | null; diff --git a/packages/language-server/src/features/hover.ts b/packages/language-server/src/features/hover.ts index 4169abddff..f562bd4066 100644 --- a/packages/language-server/src/features/hover.ts +++ b/packages/language-server/src/features/hover.ts @@ -1,5 +1,5 @@ -import { getRuleMetadata } from "@react-doctor/core"; import { MarkupKind, type Diagnostic as LspDiagnostic, type Hover } from "vscode-languageserver"; +import { getRuleMetadata } from "../core/core-api.js"; import type { ReactDoctorDiagnosticData } from "../types.js"; import { readDiagnosticData } from "../utils/read-diagnostic-data.js"; import { severityLabel } from "../utils/severity-label.js"; diff --git a/packages/language-server/src/features/suppress.ts b/packages/language-server/src/features/suppress.ts index e0abc319b2..a1e8280705 100644 --- a/packages/language-server/src/features/suppress.ts +++ b/packages/language-server/src/features/suppress.ts @@ -1,6 +1,6 @@ import type { TextEdit } from "vscode-languageserver"; -export interface SuppressionEditInput { +interface SuppressionEditInput { /** Full document text (needed for indentation + JSX heuristics). */ readonly documentText: string | null; /** Absolute fs path (drives the comment style for `.tsx` / `.jsx`). */ diff --git a/packages/language-server/src/runtime/scan-telemetry.ts b/packages/language-server/src/runtime/scan-telemetry.ts index f405d19c87..0f965119dc 100644 --- a/packages/language-server/src/runtime/scan-telemetry.ts +++ b/packages/language-server/src/runtime/scan-telemetry.ts @@ -25,7 +25,7 @@ interface ActiveBurst { * `accumulate` for `background` outcomes), so the event reflects the workspace * audit rather than per-keystroke activity. */ -export interface ScanTelemetry { +interface ScanTelemetry { /** Start a burst, discarding any prior partial (e.g. a rescan supersedes it). */ readonly begin: (trigger: WorkspaceScanTrigger, projectCount: number) => void; /** Fold one completed background scan outcome into the active burst. */ diff --git a/packages/language-server/src/runtime/scheduler.ts b/packages/language-server/src/runtime/scheduler.ts index d534994734..0ec05fed63 100644 --- a/packages/language-server/src/runtime/scheduler.ts +++ b/packages/language-server/src/runtime/scheduler.ts @@ -1,4 +1,4 @@ -import { messageFromUnknown } from "@react-doctor/core"; +import { messageFromUnknown } from "../core/core-api.js"; import { DOCUMENT_CHANGE_DEBOUNCE_MS, MIN_SCAN_CONCURRENCY } from "../constants.js"; import { SILENT_LOGGER, diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index e69110f102..059305ab0c 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { listSourceFiles, messageFromUnknown, resolveNodeForOxlint } from "@react-doctor/core"; import { CodeActionKind, CodeActionTriggerKind, @@ -56,6 +55,7 @@ import { buildFalsePositiveIssueUrl, type FalsePositiveReport } from "./features import { buildSuppressAllTextEdits } from "./features/suppress.js"; import { createProjectGraph } from "./core/project-graph.js"; import { createScanRunner, type ScanRunner } from "./core/scan-runner.js"; +import { listSourceFiles, messageFromUnknown, resolveNodeForOxlint } from "./core/core-api.js"; import { createScheduler } from "./runtime/scheduler.js"; import { createScanTelemetry } from "./runtime/scan-telemetry.js"; import { chunk } from "./utils/chunk.js"; diff --git a/packages/language-server/src/types.ts b/packages/language-server/src/types.ts index 7fb9558e69..406289fdf5 100644 --- a/packages/language-server/src/types.ts +++ b/packages/language-server/src/types.ts @@ -1,4 +1,4 @@ -import type { Diagnostic as CoreDiagnostic, ProjectInfo } from "@react-doctor/core"; +import type { CoreDiagnostic, ProjectInfo } from "./core/core-api.js"; /** * Minimal logging seam so modules don't depend on the LSP connection diff --git a/packages/language-server/tests/unit/core-api-boundary.test.ts b/packages/language-server/tests/unit/core-api-boundary.test.ts new file mode 100644 index 0000000000..7ab3c3eba4 --- /dev/null +++ b/packages/language-server/tests/unit/core-api-boundary.test.ts @@ -0,0 +1,51 @@ +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 coreApi from "../../src/core/core-api.js"; + +const SOURCE_DIRECTORY = fileURLToPath(new URL("../../src", import.meta.url)); +const CORE_API_RELATIVE_PATH = "core/core-api.ts"; +const CORE_PACKAGE_SPECIFIER_PATTERN = /["']@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("language-server core API 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("keeps the runtime facade limited to the language server's owned capabilities", () => { + expect(Object.keys(coreApi).sort()).toEqual( + [ + "ADOPTABLE_LINT_CONFIG_FILENAMES", + "buildDiagnosticIdentity", + "clearCoreCaches", + "computeConfigFingerprint", + "CONFIG_FINGERPRINT_FILENAMES", + "discoverReactSubprojects", + "getRuleMetadata", + "hashFileContents", + "listSourceFiles", + "messageFromUnknown", + "resolveNodeForOxlint", + "runEditorScan", + "STAGED_FILES_PROJECT_CONFIG_FILENAMES", + ].sort(), + ); + }); +}); diff --git a/packages/language-server/tests/unit/project-graph-invalidate.test.ts b/packages/language-server/tests/unit/project-graph-invalidate.test.ts index f01ff37ff4..770411c933 100644 --- a/packages/language-server/tests/unit/project-graph-invalidate.test.ts +++ b/packages/language-server/tests/unit/project-graph-invalidate.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { + classifyPackageRole, clearMinifiedFileCache, isLargeMinifiedFile, MINIFIED_MAX_LINE_LENGTH_CHARS, @@ -41,4 +42,26 @@ describe("createProjectGraph invalidate", () => { graph.invalidate(); expect(isLargeMinifiedFile(bundlePath)).toBe(false); }); + + it("clears the package-role memo when the project graph is invalidated", () => { + const graph = createProjectGraph({ roots: [workspaceRoot] }); + const packageDirectory = path.join(workspaceRoot, "package"); + const sourcePath = path.join(packageDirectory, "src", "button.tsx"); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, "export const button = null;\n"); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "@scope/ui", exports: { ".": "./index.js" } }), + ); + expect(classifyPackageRole(sourcePath)).toBe("library"); + + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "@scope/ui", private: true }), + ); + expect(classifyPackageRole(sourcePath)).toBe("library"); + + graph.invalidate(); + expect(classifyPackageRole(sourcePath)).toBe("unknown"); + }); }); diff --git a/packages/language-server/tests/unit/project-graph-ownership.test.ts b/packages/language-server/tests/unit/project-graph-ownership.test.ts new file mode 100644 index 0000000000..d4c8d28d17 --- /dev/null +++ b/packages/language-server/tests/unit/project-graph-ownership.test.ts @@ -0,0 +1,61 @@ +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 { createProjectGraph } from "../../src/core/project-graph.js"; + +const temporaryDirectories: string[] = []; + +const createTemporaryDirectory = (): string => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-project-ownership-"), + ); + 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("createProjectGraph ownership", () => { + it("selects the deepest discovered React workspace for a file", () => { + const rootDirectory = createTemporaryDirectory(); + const appDirectory = path.join(rootDirectory, "packages", "app"); + const featureDirectory = path.join(appDirectory, "features", "admin"); + + writePackageJson(rootDirectory, { + name: "root", + workspaces: ["packages/app", "packages/app/features/admin"], + dependencies: { react: "^19.0.0" }, + }); + writePackageJson(appDirectory, { + name: "app", + dependencies: { react: "^19.0.0" }, + }); + writePackageJson(featureDirectory, { + name: "admin", + dependencies: { react: "^19.0.0" }, + }); + + const projectGraph = createProjectGraph({ roots: [rootDirectory] }); + const featureFilePath = path.join(featureDirectory, "src", "page.tsx"); + const normalizedRootDirectory = rootDirectory.replaceAll(path.sep, "/"); + const normalizedAppDirectory = appDirectory.replaceAll(path.sep, "/"); + const normalizedFeatureDirectory = featureDirectory.replaceAll(path.sep, "/"); + + expect(projectGraph.listProjects()).toEqual([ + { name: "admin", directory: normalizedFeatureDirectory }, + { name: "app", directory: normalizedAppDirectory }, + { name: "root", directory: normalizedRootDirectory }, + ]); + expect(projectGraph.resolveOwningProject(featureFilePath)).toBe(normalizedFeatureDirectory); + }); +}); diff --git a/packages/react-doctor/src/core/core-configuration.ts b/packages/react-doctor/src/core/core-configuration.ts new file mode 100644 index 0000000000..f3302c8650 --- /dev/null +++ b/packages/react-doctor/src/core/core-configuration.ts @@ -0,0 +1,27 @@ +import { + clearConfigCache, + COMPILER_CLEANUP_BUCKET, + COMPILER_CLEANUP_RULE_KEYS, + DEFAULT_SHOW_WARNINGS, + defineConfig, + findLegacyConfig, + LEGACY_CONFIG_FILENAME, + loadConfigWithSource, + mergeReactDoctorConfigs, + validateConfigTypes, +} from "@react-doctor/core"; +import type { ReactDoctorConfig, RuleSeverityOverride } from "@react-doctor/core"; + +export { + clearConfigCache, + COMPILER_CLEANUP_BUCKET, + COMPILER_CLEANUP_RULE_KEYS, + DEFAULT_SHOW_WARNINGS, + defineConfig, + findLegacyConfig, + LEGACY_CONFIG_FILENAME, + loadConfigWithSource, + mergeReactDoctorConfigs, + validateConfigTypes, +}; +export type { ReactDoctorConfig, RuleSeverityOverride }; diff --git a/packages/react-doctor/src/core/core-diagnostic-semantics.ts b/packages/react-doctor/src/core/core-diagnostic-semantics.ts new file mode 100644 index 0000000000..9473f7266d --- /dev/null +++ b/packages/react-doctor/src/core/core-diagnostic-semantics.ts @@ -0,0 +1,23 @@ +import { + canonicalizeUserRuleKey, + computeDiagnosticDelta, + DIAGNOSTIC_CATEGORY_BUCKETS, + filterDiagnosticsForSurface, + getDiagnosticRuleIdentity, + getEquivalentRuleKeys, + groupBy, + isSameRuleKey, + summarizeDiagnostics, +} from "@react-doctor/core"; + +export { + canonicalizeUserRuleKey, + computeDiagnosticDelta, + DIAGNOSTIC_CATEGORY_BUCKETS, + filterDiagnosticsForSurface, + getDiagnosticRuleIdentity, + getEquivalentRuleKeys, + groupBy, + isSameRuleKey, + summarizeDiagnostics, +}; diff --git a/packages/react-doctor/src/core/core-errors.ts b/packages/react-doctor/src/core/core-errors.ts new file mode 100644 index 0000000000..83d74b6686 --- /dev/null +++ b/packages/react-doctor/src/core/core-errors.ts @@ -0,0 +1,31 @@ +import { + AmbiguousProjectError, + formatErrorChain, + formatReactDoctorError, + isErrnoException, + isProjectDiscoveryError, + isReactDoctorError, + messageFromUnknown, + NoReactDependencyError, + NotADirectoryError, + PackageJsonNotFoundError, + ProjectNotFoundError, + ReactDoctorError, + restoreLegacyThrow, +} from "@react-doctor/core"; + +export { + AmbiguousProjectError, + formatErrorChain, + formatReactDoctorError, + isErrnoException, + isProjectDiscoveryError, + isReactDoctorError, + messageFromUnknown, + NoReactDependencyError, + NotADirectoryError, + PackageJsonNotFoundError, + ProjectNotFoundError, + ReactDoctorError, + restoreLegacyThrow, +}; diff --git a/packages/react-doctor/src/core/core-presentation.ts b/packages/react-doctor/src/core/core-presentation.ts new file mode 100644 index 0000000000..e5740fdcda --- /dev/null +++ b/packages/react-doctor/src/core/core-presentation.ts @@ -0,0 +1,33 @@ +import { + CODE_FRAME_BATCH_MAX_SPAN_LINES, + CODE_FRAME_LINES_ABOVE, + CODE_FRAME_LINES_BELOW, + CODE_FRAME_MAX_LINE_LENGTH_CHARS, + createNodeReadFileLinesSync, + getCategoryImpact, + hasPublishedFixRecipe, + highlighter, + MIGRATION_SCALE_RULE_FILE_COUNT, + MIN_SHARED_FIX_SITE_COUNT, + OUTPUT_MEASURE_WIDTH_CHARS, + SCORE_BAR_WIDTH_CHARS, + setColorEnabled, + SPINNER_INDENT_CHARS, +} from "@react-doctor/core"; + +export { + CODE_FRAME_BATCH_MAX_SPAN_LINES, + CODE_FRAME_LINES_ABOVE, + CODE_FRAME_LINES_BELOW, + CODE_FRAME_MAX_LINE_LENGTH_CHARS, + createNodeReadFileLinesSync, + getCategoryImpact, + hasPublishedFixRecipe, + highlighter, + MIGRATION_SCALE_RULE_FILE_COUNT, + MIN_SHARED_FIX_SITE_COUNT, + OUTPUT_MEASURE_WIDTH_CHARS, + SCORE_BAR_WIDTH_CHARS, + setColorEnabled, + SPINNER_INDENT_CHARS, +}; diff --git a/packages/react-doctor/src/core/core-primitives.ts b/packages/react-doctor/src/core/core-primitives.ts new file mode 100644 index 0000000000..3a0bccae3d --- /dev/null +++ b/packages/react-doctor/src/core/core-primitives.ts @@ -0,0 +1,8 @@ +import { + isPlainObject, + redactSensitiveText, + scrubSensitivePaths, + toRelativePath, +} from "@react-doctor/core"; + +export { isPlainObject, redactSensitiveText, scrubSensitivePaths, toRelativePath }; diff --git a/packages/react-doctor/src/core/core-product.ts b/packages/react-doctor/src/core/core-product.ts new file mode 100644 index 0000000000..8ddb965417 --- /dev/null +++ b/packages/react-doctor/src/core/core-product.ts @@ -0,0 +1,25 @@ +import { + buildRuleDocsUrl, + CANONICAL_DISCORD_URL, + CANONICAL_GITHUB_URL, + CI_URL, + CONFIG_SCHEMA_URL, + DOCS_URL, + ENTERPRISE_CONTACT_URL, + GITHUB_ACTIONS_SETUP_URL, + SHARE_BASE_URL, + SKILL_NAME, +} from "@react-doctor/core"; + +export { + buildRuleDocsUrl, + CANONICAL_DISCORD_URL, + CANONICAL_GITHUB_URL, + CI_URL, + CONFIG_SCHEMA_URL, + DOCS_URL, + ENTERPRISE_CONTACT_URL, + GITHUB_ACTIONS_SETUP_URL, + SHARE_BASE_URL, + SKILL_NAME, +}; diff --git a/packages/react-doctor/src/core/core-project-discovery.ts b/packages/react-doctor/src/core/core-project-discovery.ts new file mode 100644 index 0000000000..20224e5754 --- /dev/null +++ b/packages/react-doctor/src/core/core-project-discovery.ts @@ -0,0 +1,29 @@ +import { + buildPackageGraph, + discoverReactSubprojects, + filterSourceFiles, + hasReactRuntime, + HTML_FILE_PATTERN, + isDirectory, + isFile, + isMonorepoRoot, + JSX_FILE_PATTERN, + listSourceFiles, + readPackageJson, + resolveScanTarget, +} from "@react-doctor/core"; + +export { + buildPackageGraph, + discoverReactSubprojects, + filterSourceFiles, + hasReactRuntime, + HTML_FILE_PATTERN, + isDirectory, + isFile, + isMonorepoRoot, + JSX_FILE_PATTERN, + listSourceFiles, + readPackageJson, + resolveScanTarget, +}; diff --git a/packages/react-doctor/src/core/core-reporting.ts b/packages/react-doctor/src/core/core-reporting.ts new file mode 100644 index 0000000000..7b2c674b9e --- /dev/null +++ b/packages/react-doctor/src/core/core-reporting.ts @@ -0,0 +1,23 @@ +import { + buildJsonReport, + buildJsonReportError, + buildSkippedChecks, + isScanComplete, +} from "@react-doctor/core"; + +export type { + JsonReport, + JsonReportDiagnosticV3, + JsonReportDiffInfo, + JsonReportError, + JsonReportMode, + JsonReportProjectEntry, + JsonReportProjectEntryV3, + JsonReportSummary, + JsonReportV1, + JsonReportV2, + JsonReportV3, +} from "@react-doctor/core"; +export type { Diagnostic as LiveDiagnostic } from "@react-doctor/core/schemas"; + +export { buildJsonReport, buildJsonReportError, buildSkippedChecks, isScanComplete }; diff --git a/packages/react-doctor/src/core/core-runtime.ts b/packages/react-doctor/src/core/core-runtime.ts new file mode 100644 index 0000000000..ec18e61849 --- /dev/null +++ b/packages/react-doctor/src/core/core-runtime.ts @@ -0,0 +1,61 @@ +import { + Config, + createOxlintSpawnSlots, + DeadCode, + DEFAULT_PROJECT_SCAN_CONCURRENCY, + detectAiTrainingEnvironment, + Files, + Git, + layerOtlp, + Linter, + LintPartialFailures, + mapWithConcurrency, + MILLISECONDS_PER_SECOND, + MIN_SCAN_CONCURRENCY, + NodeResolver, + OxlintConcurrency, + OXLINT_NODE_REQUIREMENT, + OXLINT_RECOMMENDED_NODE_MAJOR, + OxlintSpawnSlots, + PerFileLintCacheEnabled, + Progress, + Project, + ProjectChecks, + Reporter, + resolveScanConcurrency, + runInspect, + SidecarLintCacheEnabled, + StagedFiles, + SupplyChain, +} from "@react-doctor/core"; + +export { + Config, + createOxlintSpawnSlots, + DeadCode, + DEFAULT_PROJECT_SCAN_CONCURRENCY, + detectAiTrainingEnvironment, + Files, + Git, + layerOtlp, + Linter, + LintPartialFailures, + mapWithConcurrency, + MILLISECONDS_PER_SECOND, + MIN_SCAN_CONCURRENCY, + NodeResolver, + OxlintConcurrency, + OXLINT_NODE_REQUIREMENT, + OXLINT_RECOMMENDED_NODE_MAJOR, + OxlintSpawnSlots, + PerFileLintCacheEnabled, + Progress, + Project, + ProjectChecks, + Reporter, + resolveScanConcurrency, + runInspect, + SidecarLintCacheEnabled, + StagedFiles, + SupplyChain, +}; diff --git a/packages/react-doctor/src/core/core-scan-cache.ts b/packages/react-doctor/src/core/core-scan-cache.ts new file mode 100644 index 0000000000..4921462284 --- /dev/null +++ b/packages/react-doctor/src/core/core-scan-cache.ts @@ -0,0 +1,15 @@ +import { + clearCoreCaches, + computeConfigFingerprint, + hashFileContents, + resolveLintBatchOrdering, + resolveReactDoctorCacheDir, +} from "@react-doctor/core"; + +export { + clearCoreCaches, + computeConfigFingerprint, + hashFileContents, + resolveLintBatchOrdering, + resolveReactDoctorCacheDir, +}; diff --git a/packages/react-doctor/src/core/core-score.ts b/packages/react-doctor/src/core/core-score.ts new file mode 100644 index 0000000000..52a3fd4a85 --- /dev/null +++ b/packages/react-doctor/src/core/core-score.ts @@ -0,0 +1,19 @@ +import { + calculateScore, + PERFECT_SCORE, + resolveGithubActionsScoreMetadata, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + Score, + TOP_ERRORS_DISPLAY_COUNT, +} from "@react-doctor/core"; + +export { + calculateScore, + PERFECT_SCORE, + resolveGithubActionsScoreMetadata, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + Score, + TOP_ERRORS_DISPLAY_COUNT, +}; diff --git a/packages/react-doctor/src/core/core-types.ts b/packages/react-doctor/src/core/core-types.ts new file mode 100644 index 0000000000..0e1308e3ad --- /dev/null +++ b/packages/react-doctor/src/core/core-types.ts @@ -0,0 +1,36 @@ +export type { + BlockingLevel, + ChangedFileLineRanges, + Diagnostic, + DiagnosticSurface, + DiagnoseOptions, + DiagnoseProjectsInput, + DiagnoseProjectsResult, + DiagnoseResult, + DiffInfo, + GitBaselineDiffPlan, + HandleErrorOptions, + InspectOptions, + InspectOutput, + InspectResult, + LegacyConfigLocation, + MaterializedTree, + Progress, + ProgressHandle, + ProjectDefinition, + ProjectInfo, + ProjectResult, + ProjectResultError, + ProjectResultOk, + PromptMultiselectChoiceState, + PromptMultiselectContext, + ReactDoctorConfigFormat, + Reporter, + ResolvedScanTarget, + ScopeValue, + ScoreResult, + StagedSnapshot, + SuppressedRuleCount, + WorkerSlots, + WorkspacePackage, +} from "@react-doctor/core"; diff --git a/packages/react-doctor/src/core/core-version-control.ts b/packages/react-doctor/src/core/core-version-control.ts new file mode 100644 index 0000000000..0b71964bb5 --- /dev/null +++ b/packages/react-doctor/src/core/core-version-control.ts @@ -0,0 +1,17 @@ +import { + getBaselineDiffPlan, + getChangedLineRanges, + getDiffInfo, + GIT_SHOW_MAX_BUFFER_BYTES, + materializeSourceTree, + STAGED_FILES_PROJECT_CONFIG_FILENAMES, +} from "@react-doctor/core"; + +export { + getBaselineDiffPlan, + getChangedLineRanges, + getDiffInfo, + GIT_SHOW_MAX_BUFFER_BYTES, + materializeSourceTree, + STAGED_FILES_PROJECT_CONFIG_FILENAMES, +};