From 1f710b71eee8b55d026ef1e3a0836b2530bc6e5b Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Mon, 23 Mar 2026 11:34:27 +0100 Subject: [PATCH 1/2] Add setup diagnostic presets Introduce preset-based diagnostic configuration in the setup CLI so recommended rule bundles can be applied and customized without storing preset inheritance in user config. --- .changeset/tiny-rats-rule.md | 5 + packages/harness-effect-v4/package.json | 2 +- packages/language-service/package.json | 4 +- .../src/cli/setup/diagnostic-info.ts | 11 ++ .../src/cli/setup/target-prompt.ts | 57 +++---- .../src/cli/setup/tsconfig-prompt.ts | 28 ++-- packages/language-service/src/metadata.json | 13 ++ packages/language-service/src/presets.ts | 139 ++++++++++++++++++ .../language-service/test/metadata.test.ts | 2 + .../language-service/test/presets.test.ts | 48 ++++++ pnpm-lock.yaml | 40 ++--- 11 files changed, 290 insertions(+), 59 deletions(-) create mode 100644 .changeset/tiny-rats-rule.md create mode 100644 packages/language-service/src/presets.ts create mode 100644 packages/language-service/test/presets.test.ts diff --git a/.changeset/tiny-rats-rule.md b/.changeset/tiny-rats-rule.md new file mode 100644 index 00000000..377bb641 --- /dev/null +++ b/.changeset/tiny-rats-rule.md @@ -0,0 +1,5 @@ +--- +"@effect/language-service": minor +--- + +Add setup CLI preset management for diagnostic severities, including preset metadata and preset-aware customization. diff --git a/packages/harness-effect-v4/package.json b/packages/harness-effect-v4/package.json index 921cf453..e31bd458 100644 --- a/packages/harness-effect-v4/package.json +++ b/packages/harness-effect-v4/package.json @@ -6,6 +6,6 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "effect": "^4.0.0-beta.27" + "effect": "^4.0.0-beta.37" } } diff --git a/packages/language-service/package.json b/packages/language-service/package.json index bda3c3b1..228bdaed 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -43,10 +43,10 @@ "perf": "tsx test/perf.ts" }, "devDependencies": { - "@effect/platform-node": "^4.0.0-beta.27", + "@effect/platform-node": "^4.0.0-beta.37", "@types/pako": "^2.0.4", "@typescript-eslint/project-service": "^8.52.0", - "effect": "^4.0.0-beta.27", + "effect": "^4.0.0-beta.37", "pako": "^2.1.0", "ts-patch": "^3.3.0" } diff --git a/packages/language-service/src/cli/setup/diagnostic-info.ts b/packages/language-service/src/cli/setup/diagnostic-info.ts index 5102b897..b9dc2c66 100644 --- a/packages/language-service/src/cli/setup/diagnostic-info.ts +++ b/packages/language-service/src/cli/setup/diagnostic-info.ts @@ -32,9 +32,16 @@ export interface DiagnosticMetadataRule { interface DiagnosticMetadata { readonly groups: ReadonlyArray + readonly presets: ReadonlyArray readonly rules: ReadonlyArray } +export interface DiagnosticPresetMetadata { + readonly name: string + readonly description: string + readonly diagnosticSeverity: Readonly> +} + const diagnosticMetadata = metadataJson as unknown as DiagnosticMetadata /** @@ -57,6 +64,10 @@ export function getDiagnosticMetadataRules(): ReadonlyArray { + return diagnosticMetadata.presets +} + /** * Get all available diagnostics with their metadata */ diff --git a/packages/language-service/src/cli/setup/target-prompt.ts b/packages/language-service/src/cli/setup/target-prompt.ts index 1eeba6d5..2e5e5976 100644 --- a/packages/language-service/src/cli/setup/target-prompt.ts +++ b/packages/language-service/src/cli/setup/target-prompt.ts @@ -1,8 +1,9 @@ import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Prompt from "effect/unstable/cli/Prompt" +import { applyPresetDiagnosticSeverities, type DiagnosticPresetName, isPresetEnabled } from "../../presets" import type { Assessment } from "./assessment" -import { getAllDiagnostics } from "./diagnostic-info" +import { getAllDiagnostics, getDiagnosticPresets } from "./diagnostic-info" import { createDiagnosticPrompt } from "./diagnostic-prompt" import type { Editor } from "./target" @@ -66,38 +67,44 @@ export const gatherTargetState = ( } } - // Diagnostic Configuration - const shouldCustomizeDiagnostics = yield* Prompt.select({ - message: "Would you like to customize the diagnostics that the language service will provide?", + const allDiagnostics = getAllDiagnostics() + const currentDiagnosticSeverities = Option.match(assessment.tsconfig.currentOptions, { + onNone: () => ({}), + onSome: (options) => options.diagnosticSeverity + }) + + const selectedDiagnosticModes = yield* Prompt.multiSelect({ + message: "Which diagnostic presets would you like to use?", choices: [ { - title: "Yes", - description: "Manually review and select which diagnostics to enable", - value: true, - selected: true + title: "Custom", + description: "Review and adjust individual diagnostic severities after presets are applied", + value: "custom" as const }, - { - title: "No", - description: "Keep the defaults provided by the language service", - value: false, - selected: false - } + ...getDiagnosticPresets().map((preset) => ({ + title: preset.name, + description: preset.description, + value: preset.name as DiagnosticPresetName, + selected: isPresetEnabled(preset.name as DiagnosticPresetName, currentDiagnosticSeverities) + })) ] }) - const allDiagnostics = getAllDiagnostics() - const initialSeverities = Option.match(assessment.tsconfig.currentOptions, { - onNone: () => ({}), - onSome: (options) => options.diagnosticSeverity - }) + const shouldCustomizeDiagnostics = selectedDiagnosticModes.includes("custom") + const selectedPresetNames = selectedDiagnosticModes.filter((value): value is DiagnosticPresetName => + value !== "custom" + ) + const initialSeverities = applyPresetDiagnosticSeverities(currentDiagnosticSeverities, selectedPresetNames) - const diagnosticSeverities = shouldCustomizeDiagnostics - ? Option.some( - yield* createDiagnosticPrompt( - allDiagnostics, - initialSeverities - ) + const diagnosticSeveritiesRecord = shouldCustomizeDiagnostics + ? yield* createDiagnosticPrompt( + allDiagnostics, + initialSeverities ) + : initialSeverities + + const diagnosticSeverities = Object.keys(diagnosticSeveritiesRecord).length > 0 + ? Option.some(diagnosticSeveritiesRecord) : Option.none() // Prepare Script Configuration diff --git a/packages/language-service/src/cli/setup/tsconfig-prompt.ts b/packages/language-service/src/cli/setup/tsconfig-prompt.ts index 0ae2b55a..9cb7277b 100644 --- a/packages/language-service/src/cli/setup/tsconfig-prompt.ts +++ b/packages/language-service/src/cli/setup/tsconfig-prompt.ts @@ -9,6 +9,11 @@ import { FileReadError, TsConfigNotFoundError } from "./errors" /** * Find tsconfig files in a directory */ +const isTsConfigFile = (file: string) => { + const fileName = file.toLowerCase() + return fileName.endsWith(".json") || fileName.endsWith(".jsonc") +} + const findTsConfigFiles = ( currentDir: string ): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => @@ -17,14 +22,19 @@ const findTsConfigFiles = ( const path = yield* Path.Path const files = yield* fs.readDirectory(currentDir) - const tsconfigFiles = Array.filter(files, (file) => { - const fileName = file.toLowerCase() - return (fileName.startsWith("tsconfig") && (fileName.endsWith(".json") || fileName.endsWith(".jsonc"))) - }).map((file) => path.join(currentDir, file)) + const tsconfigFiles = Array.filter(files, isTsConfigFile).map((file) => path.join(currentDir, file)) return tsconfigFiles }) +const promptForTsConfigPath = (currentDir: string) => + Prompt.file({ + type: "file", + message: "Select tsconfig to configure", + startingPath: currentDir, + filter: (file) => file === ".." || !file.includes(".") || isTsConfigFile(file) + }) + /** * Prompt user to select a tsconfig file and read its contents */ @@ -40,10 +50,8 @@ export const selectTsConfigFile = ( let selectedTsconfigPath: string if (tsconfigFiles.length === 0) { - // No tsconfig files found - go directly to manual entry - selectedTsconfigPath = yield* Prompt.text({ - message: "Enter path to your tsconfig.json file" - }) + // No tsconfig files found - go directly to file picker + selectedTsconfigPath = yield* promptForTsConfigPath(currentDir) } else { // Show selection menu with found files + manual option const choices = [ @@ -63,9 +71,7 @@ export const selectTsConfigFile = ( }) if (selected === "__manual__") { - selectedTsconfigPath = yield* Prompt.text({ - message: "Enter path to your tsconfig.json file" - }) + selectedTsconfigPath = yield* promptForTsConfigPath(currentDir) } else { selectedTsconfigPath = selected } diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json index f490f0d9..cb0ecb43 100644 --- a/packages/language-service/src/metadata.json +++ b/packages/language-service/src/metadata.json @@ -21,6 +21,19 @@ "description": "Cleanup, consistency, and idiomatic Effect code." } ], + "presets": [ + { + "name": "effect-native", + "description": "Enable all Effect-native diagnostics at warning level.", + "diagnosticSeverity": { + "instanceOfSchema": "warning", + "globalFetch": "warning", + "preferSchemaOverJson": "warning", + "extendsNativeError": "warning", + "nodeBuiltinImport": "warning" + } + } + ], "rules": [ { "name": "anyUnknownInErrorContext", diff --git a/packages/language-service/src/presets.ts b/packages/language-service/src/presets.ts new file mode 100644 index 00000000..d8b05e58 --- /dev/null +++ b/packages/language-service/src/presets.ts @@ -0,0 +1,139 @@ +import type { DiagnosticGroup } from "./core/DiagnosticGroup.js" +import type { DiagnosticSeverity } from "./core/LanguageServicePluginOptions.js" +import { diagnostics } from "./diagnostics.js" + +export type RuleSeverity = DiagnosticSeverity | "off" + +export interface DiagnosticPreset { + readonly name: string + readonly description: string + readonly diagnosticSeverity: Readonly> +} + +const severityRank: Record = { + off: 0, + suggestion: 1, + message: 2, + warning: 3, + error: 4 +} + +const diagnosticDefaultSeverities: Readonly> = Object.fromEntries( + diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.severity]) +) + +const diagnosticNamesByLowerCase: Readonly> = Object.fromEntries( + diagnostics.map((diagnostic) => [diagnostic.name.toLowerCase(), diagnostic.name]) +) + +const diagnosticGroupsByName: Readonly> = Object.fromEntries( + diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.group]) +) + +const buildGroupPreset = ( + group: DiagnosticGroup, + severity: RuleSeverity +): Readonly> => + Object.fromEntries( + diagnostics + .filter((diagnostic) => diagnostic.group === group) + .map((diagnostic) => [diagnostic.name, severity]) + ) + +export const presets = [{ + name: "effect-native", + description: "Enable all Effect-native diagnostics at warning level.", + diagnosticSeverity: buildGroupPreset("effectNative", "warning") +}] as const satisfies ReadonlyArray + +export type DiagnosticPresetName = (typeof presets)[number]["name"] + +const presetsByName: Readonly> = Object.fromEntries( + presets.map((preset) => [preset.name, preset]) +) as Record + +export function compareRuleSeverity(left: RuleSeverity, right: RuleSeverity): number { + return severityRank[left] - severityRank[right] +} + +export function maxRuleSeverity(left: RuleSeverity, right: RuleSeverity): RuleSeverity { + return compareRuleSeverity(left, right) >= 0 ? left : right +} + +export function normalizeDiagnosticSeverities( + severities: Readonly> +): Record { + const canonicalSeverities = Object.fromEntries( + Object.entries(severities).map(( + [name, severity] + ) => [diagnosticNamesByLowerCase[name.toLowerCase()] ?? name, severity]) + ) + + return Object.fromEntries( + Object.entries(canonicalSeverities).flatMap(([name, severity]) => { + const defaultSeverity = diagnosticDefaultSeverities[name] + if (defaultSeverity !== undefined && defaultSeverity === severity) { + return [] + } + return [[name, severity]] + }) + ) +} + +export function resolveDiagnosticSeverity( + name: string, + severities: Readonly> +): RuleSeverity { + const canonicalName = diagnosticNamesByLowerCase[name.toLowerCase()] ?? name + return severities[canonicalName] ?? severities[name] ?? diagnosticDefaultSeverities[canonicalName] ?? "off" +} + +export function mergePresetDiagnosticSeverities( + presetNames: ReadonlyArray +): Record { + const merged: Record = {} + + for (const presetName of presetNames) { + const preset = presetsByName[presetName] + for (const [ruleName, severity] of Object.entries(preset.diagnosticSeverity)) { + merged[ruleName] = ruleName in merged ? maxRuleSeverity(merged[ruleName]!, severity) : severity + } + } + + return merged +} + +export function applyPresetDiagnosticSeverities( + currentSeverities: Readonly>, + presetNames: ReadonlyArray +): Record { + const mergedPresetSeverities = mergePresetDiagnosticSeverities(presetNames) + const nextSeverities = normalizeDiagnosticSeverities(currentSeverities) + + for (const [ruleName, requiredSeverity] of Object.entries(mergedPresetSeverities)) { + const currentSeverity = resolveDiagnosticSeverity(ruleName, nextSeverities) + if (compareRuleSeverity(currentSeverity, requiredSeverity) < 0) { + nextSeverities[ruleName] = requiredSeverity + } + } + + return normalizeDiagnosticSeverities(nextSeverities) +} + +export function isPresetEnabled( + presetName: DiagnosticPresetName, + severities: Readonly> +): boolean { + const preset = presetsByName[presetName] + return Object.entries(preset.diagnosticSeverity).every(([ruleName, requiredSeverity]) => + compareRuleSeverity(resolveDiagnosticSeverity(ruleName, severities), requiredSeverity) >= 0 + ) +} + +export function getDiagnosticGroupPresetNames(group: DiagnosticGroup): ReadonlyArray { + return presets + .filter((preset) => + Object.keys(preset.diagnosticSeverity).every((ruleName) => diagnosticGroupsByName[ruleName] === group) + ) + .map((preset) => preset.name) +} diff --git a/packages/language-service/test/metadata.test.ts b/packages/language-service/test/metadata.test.ts index dcc5eec5..33cc9ed5 100644 --- a/packages/language-service/test/metadata.test.ts +++ b/packages/language-service/test/metadata.test.ts @@ -14,6 +14,7 @@ import * as fs from "node:fs" import * as path from "node:path" import * as ts from "typescript" import { describe, expect, it } from "vitest" +import { presets } from "../src/presets" import { getExamplesDirForVersion, getHarnessDirForVersion, getHarnessVersion } from "./utils/harness" import { configFromSourceComment, createServicesWithMockedVFS } from "./utils/mocks" @@ -165,6 +166,7 @@ describe.skipIf(getHarnessVersion() !== "v4")("Metadata", () => { const metadata = { groups: diagnosticGroups, + presets, rules } diff --git a/packages/language-service/test/presets.test.ts b/packages/language-service/test/presets.test.ts new file mode 100644 index 00000000..d94be3b8 --- /dev/null +++ b/packages/language-service/test/presets.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { + applyPresetDiagnosticSeverities, + compareRuleSeverity, + isPresetEnabled, + mergePresetDiagnosticSeverities, + presets +} from "../src/presets" + +describe("diagnostic presets", () => { + it("merges the selected preset severities", () => { + expect(mergePresetDiagnosticSeverities(["effect-native"])).toEqual( + presets.find((preset) => preset.name === "effect-native")!.diagnosticSeverity + ) + }) + + it("applies preset severities as minimums on top of existing config", () => { + const merged = applyPresetDiagnosticSeverities( + { + globalFetch: "error" + }, + ["effect-native"] + ) + + expect(merged.globalFetch).toBe("error") + expect(merged.extendsNativeError).toBe("warning") + expect(merged.preferSchemaOverJson).toBe("warning") + }) + + it("normalizes existing diagnostic keys before applying presets", () => { + const merged = applyPresetDiagnosticSeverities( + { + globalfetch: "error" + }, + ["effect-native"] + ) + + expect(merged.globalFetch).toBe("error") + expect(merged.globalfetch).toBeUndefined() + }) + + it("computes enablement using effective severities", () => { + const merged = applyPresetDiagnosticSeverities({}, ["effect-native"]) + + expect(isPresetEnabled("effect-native", merged)).toBe(true) + expect(compareRuleSeverity(merged.globalFetch!, "warning")).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0cf65e3..89bec4a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,14 +120,14 @@ importers: specifier: ^1.1.0 version: 1.1.0 effect: - specifier: ^4.0.0-beta.27 - version: 4.0.0-beta.27 + specifier: ^4.0.0-beta.37 + version: 4.0.0-beta.37 packages/language-service: devDependencies: '@effect/platform-node': - specifier: ^4.0.0-beta.27 - version: 4.0.0-beta.27(effect@4.0.0-beta.27)(ioredis@5.10.0) + specifier: ^4.0.0-beta.37 + version: 4.0.0-beta.37(effect@4.0.0-beta.37)(ioredis@5.10.0) '@types/pako': specifier: ^2.0.4 version: 2.0.4 @@ -135,8 +135,8 @@ importers: specifier: ^8.52.0 version: 8.52.0(typescript@5.9.3) effect: - specifier: ^4.0.0-beta.27 - version: 4.0.0-beta.27 + specifier: ^4.0.0-beta.37 + version: 4.0.0-beta.37 pako: specifier: ^2.1.0 version: 2.1.0 @@ -567,32 +567,32 @@ packages: uuid: 11.1.0 dev: false - /@effect/platform-node-shared@4.0.0-beta.27(effect@4.0.0-beta.27): - resolution: {integrity: sha512-prRa5MIWGAvq2g6FgVP5RFfPwI+DvcXCaEZetvPMK70wdkTR1+jKQqJ9QDKWNIP+2d4llE/cF21SvcpBJWq0gw==} + /@effect/platform-node-shared@4.0.0-beta.37(effect@4.0.0-beta.37): + resolution: {integrity: sha512-yY7C4pAP9/5yLsitxwR11smcX1osDOBmWq0ZhTp8R8IG5ac0CiHUwDS7ebo/1AwdW4r2AVne3WGo8fWgyuWCEA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.27 + effect: ^4.0.0-beta.37 dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.27 + effect: 4.0.0-beta.37 ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /@effect/platform-node@4.0.0-beta.27(effect@4.0.0-beta.27)(ioredis@5.10.0): - resolution: {integrity: sha512-UPZMtFVnDIM5nb6A2pEPo229VnfgMdvl/9sGiQaYRB9qEVHerk0elxcoWOb2szoivBYePYFOsPKIWpR6jCldtQ==} + /@effect/platform-node@4.0.0-beta.37(effect@4.0.0-beta.37)(ioredis@5.10.0): + resolution: {integrity: sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.27 + effect: ^4.0.0-beta.37 ioredis: ^5.7.0 dependencies: - '@effect/platform-node-shared': 4.0.0-beta.27(effect@4.0.0-beta.27) - effect: 4.0.0-beta.27 + '@effect/platform-node-shared': 4.0.0-beta.37(effect@4.0.0-beta.37) + effect: 4.0.0-beta.37 ioredis: 5.10.0 mime: 4.1.0 - undici: 7.22.0 + undici: 7.24.5 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2827,8 +2827,8 @@ packages: fast-check: 3.23.2 dev: false - /effect@4.0.0-beta.27: - resolution: {integrity: sha512-bNQEF3vaVGF8jAJ+HW1A4DaxVNmujgEu89sO+VNW1AvUYtPGMtKPTBU15K9inu1rG+hkxNFFRlVvLAJsdaE2mg==} + /effect@4.0.0-beta.37: + resolution: {integrity: sha512-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA==} dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.5.3 @@ -6007,8 +6007,8 @@ packages: engines: {node: '>=20.18.1'} dev: true - /undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + /undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} engines: {node: '>=20.18.1'} dev: true From caef4c44f5a57fb7eae67a3b8375f13b07147960 Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Mon, 23 Mar 2026 11:41:25 +0100 Subject: [PATCH 2/2] fix check --- packages/language-service/src/cli/setup/diagnostic-prompt.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/language-service/src/cli/setup/diagnostic-prompt.ts b/packages/language-service/src/cli/setup/diagnostic-prompt.ts index 461b59ec..ade190d7 100644 --- a/packages/language-service/src/cli/setup/diagnostic-prompt.ts +++ b/packages/language-service/src/cli/setup/diagnostic-prompt.ts @@ -372,9 +372,8 @@ function isPrintableInput(input: Terminal.UserInput): boolean { return ( !input.key.ctrl && !input.key.meta && - input.input !== undefined && - input.input.length > 0 && - printablePattern.test(input.input) + input.input.valueOrUndefined !== undefined && + printablePattern.test(input.input.valueOrUndefined) ) }