From ed2b020b00fa9f9b149120aaa6f41a1ef4eae002 Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Mon, 23 Mar 2026 16:05:08 +0100 Subject: [PATCH 1/2] Add config CLI command Let users jump straight into diagnostic severity editing for an existing tsconfig without rerunning the full setup flow. Reuse the setup assessment and change application pipeline so config updates only touch rule severity configuration. --- .changeset/tiny-rats-rule.md | 2 +- README.md | 3 + packages/language-service/src/cli.ts | 3 +- packages/language-service/src/cli/config.ts | 40 ++++ packages/language-service/src/cli/setup.ts | 200 +----------------- .../src/cli/setup/assessment.ts | 102 +++++++++ .../language-service/src/cli/setup/changes.ts | 77 +++++++ .../src/cli/setup/diagnostic-prompt.ts | 20 +- .../language-service/src/cli/setup/target.ts | 30 ++- .../language-service/test/config-cli.test.ts | 91 ++++++++ 10 files changed, 369 insertions(+), 199 deletions(-) create mode 100644 packages/language-service/src/cli/config.ts create mode 100644 packages/language-service/test/config-cli.test.ts diff --git a/.changeset/tiny-rats-rule.md b/.changeset/tiny-rats-rule.md index 377bb641..f178b1d3 100644 --- a/.changeset/tiny-rats-rule.md +++ b/.changeset/tiny-rats-rule.md @@ -2,4 +2,4 @@ "@effect/language-service": minor --- -Add setup CLI preset management for diagnostic severities, including preset metadata and preset-aware customization. +Add setup CLI preset management for diagnostic severities, including preset metadata, preset-aware customization, and a dedicated `config` command for adjusting rule severities without rerunning full setup. diff --git a/README.md b/README.md index 15e75ba6..8f430764 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,9 @@ The effect language service plugin comes with a builtin CLI tool that can be use ### `effect-language-service setup` Runs through a wizard to setup/update some basic functionalities of the LSP in an interactive way. This also keeps the `tsconfig.json` `$schema` aligned with the published Effect Language Service schema. +### `effect-language-service config` +After selecting a tsconfig.json file, jumps to the interactive configuration of rules severities. + ### `effect-language-service codegen` Automatically updates Effect codegens in your TypeScript files. This command scans files for `@effect-codegens` directives and applies the necessary code transformations. Use `--file` to update a specific file, or `--project` with a tsconfig file to update an entire project. The `--verbose` flag provides detailed output about which files are being processed and updated. diff --git a/packages/language-service/src/cli.ts b/packages/language-service/src/cli.ts index 7a23fee2..c04afe11 100644 --- a/packages/language-service/src/cli.ts +++ b/packages/language-service/src/cli.ts @@ -9,6 +9,7 @@ import { Command } from "effect/unstable/cli" import packageJson from "../package.json" import { check } from "./cli/check" import { codegen } from "./cli/codegen" +import { config } from "./cli/config" import { diagnostics } from "./cli/diagnostics" import { layerInfo } from "./cli/layerinfo" import { overview } from "./cli/overview" @@ -25,7 +26,7 @@ const cliCommand = Command.make( ).pipe(Command.withSubcommands([ { group: "Getting started", - commands: [setup] + commands: [setup, config] }, { group: "Diagnostics at compile-time", diff --git a/packages/language-service/src/cli/config.ts b/packages/language-service/src/cli/config.ts new file mode 100644 index 00000000..0ffaa961 --- /dev/null +++ b/packages/language-service/src/cli/config.ts @@ -0,0 +1,40 @@ +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Path from "effect/Path" +import { Command } from "effect/unstable/cli" +import * as Assessment from "./setup/assessment" +import * as Changes from "./setup/changes" +import { getAllDiagnostics } from "./setup/diagnostic-info" +import { createDiagnosticPrompt } from "./setup/diagnostic-prompt" +import * as Target from "./setup/target" +import { selectTsConfigFile } from "./setup/tsconfig-prompt" + +export const config = Command.make( + "config", + {}, + () => + Effect.gen(function*() { + const path = yield* Path.Path + const currentDir = path.resolve(process.cwd()) + const tsconfigInput = yield* selectTsConfigFile(currentDir) + const assessmentInput = yield* Assessment.createAssessmentInput(currentDir, tsconfigInput) + const assessmentState = yield* Assessment.assess(assessmentInput) + + const allDiagnostics = getAllDiagnostics() + const currentDiagnosticSeverities = Option.match(assessmentState.tsconfig.currentOptions, { + onNone: () => ({}), + onSome: (options) => options.diagnosticSeverity + }) + + const diagnosticSeverities = yield* createDiagnosticPrompt(allDiagnostics, currentDiagnosticSeverities) + const targetState = Target.withDiagnosticSeverities(Target.fromAssessment(assessmentState), diagnosticSeverities) + const result = yield* Changes.computeChanges(assessmentState, targetState) + + yield* Changes.reviewAndApplyChanges(result, assessmentState, { + confirmMessage: "Apply diagnostic configuration changes?", + cancelMessage: "Configuration cancelled. No changes were made." + }) + }) +).pipe( + Command.withDescription("Configure diagnostic severities for an existing tsconfig using the interactive rule picker.") +) diff --git a/packages/language-service/src/cli/setup.ts b/packages/language-service/src/cli/setup.ts index 1f09115c..22fe872f 100644 --- a/packages/language-service/src/cli/setup.ts +++ b/packages/language-service/src/cli/setup.ts @@ -1,126 +1,11 @@ -import * as Console from "effect/Console" import * as Effect from "effect/Effect" -import * as FileSystem from "effect/FileSystem" -import * as Option from "effect/Option" import * as Path from "effect/Path" -import type * as PlatformError from "effect/PlatformError" import { Command } from "effect/unstable/cli" -import * as Prompt from "effect/unstable/cli/Prompt" import packageJson from "../../package.json" -import { assess, type Assessment } from "./setup/assessment" -import { computeChanges } from "./setup/changes" -import { renderCodeActions } from "./setup/diff-renderer" -import { FileReadError, PackageJsonNotFoundError } from "./setup/errors" +import * as Assessment from "./setup/assessment" +import * as Changes from "./setup/changes" import { gatherTargetState } from "./setup/target-prompt" import { selectTsConfigFile } from "./setup/tsconfig-prompt" -import { type FileInput } from "./utils" - -/** - * Read files from file system and create assessment input - */ -const createAssessmentInput = ( - currentDir: string, - tsconfigInput: FileInput -): Effect.Effect< - Assessment.Input, - PackageJsonNotFoundError | FileReadError | PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path -> => - Effect.gen(function*() { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - // Check package.json - const packageJsonPath = path.join(currentDir, "package.json") - const packageJsonExists = yield* fs.exists(packageJsonPath) - - if (!packageJsonExists) { - return yield* new PackageJsonNotFoundError({ path: packageJsonPath }) - } - - const packageJsonText = yield* fs.readFileString(packageJsonPath).pipe( - Effect.mapError((cause) => new FileReadError({ path: packageJsonPath, cause })) - ) - const packageJsonInput: FileInput = { - fileName: packageJsonPath, - text: packageJsonText - } - - // Check .vscode/settings.json (optional) - const vscodeSettingsPath = path.join(currentDir, ".vscode", "settings.json") - const vscodeSettingsExists = yield* fs.exists(vscodeSettingsPath) - - let vscodeSettingsInput = Option.none() - if (vscodeSettingsExists) { - const vscodeSettingsText = yield* fs.readFileString(vscodeSettingsPath).pipe( - Effect.mapError((cause) => new FileReadError({ path: vscodeSettingsPath, cause })) - ) - vscodeSettingsInput = Option.some({ - fileName: vscodeSettingsPath, - text: vscodeSettingsText - }) - } - - // Check agents.md / AGENTS.md (optional, case-insensitive, skip symlinks) - const agentsMdLowerPath = path.join(currentDir, "agents.md") - const agentsMdUpperPath = path.join(currentDir, "AGENTS.md") - const agentsMdLowerExists = yield* fs.exists(agentsMdLowerPath) - const agentsMdUpperExists = yield* fs.exists(agentsMdUpperPath) - const agentsMdPath = agentsMdUpperExists ? agentsMdUpperPath : agentsMdLowerPath - const agentsMdExists = agentsMdUpperExists || agentsMdLowerExists - - let agentsMdInput = Option.none() - if (agentsMdExists) { - // Check if it's a symlink - skip if it is - const agentsMdStat = yield* fs.stat(agentsMdPath).pipe(Effect.option) - const isAgentsMdSymlink = Option.isSome(agentsMdStat) && - agentsMdStat.value.type === "SymbolicLink" - - if (!isAgentsMdSymlink) { - const agentsMdText = yield* fs.readFileString(agentsMdPath).pipe( - Effect.mapError((cause) => new FileReadError({ path: agentsMdPath, cause })) - ) - agentsMdInput = Option.some({ - fileName: agentsMdPath, - text: agentsMdText - }) - } - } - - // Check claude.md / CLAUDE.md (optional, case-insensitive, skip symlinks) - const claudeMdLowerPath = path.join(currentDir, "claude.md") - const claudeMdUpperPath = path.join(currentDir, "CLAUDE.md") - const claudeMdLowerExists = yield* fs.exists(claudeMdLowerPath) - const claudeMdUpperExists = yield* fs.exists(claudeMdUpperPath) - const claudeMdPath = claudeMdUpperExists ? claudeMdUpperPath : claudeMdLowerPath - const claudeMdExists = claudeMdUpperExists || claudeMdLowerExists - - let claudeMdInput = Option.none() - if (claudeMdExists) { - // Check if it's a symlink - skip if it is - const claudeMdStat = yield* fs.stat(claudeMdPath).pipe(Effect.option) - const isClaudeMdSymlink = Option.isSome(claudeMdStat) && - claudeMdStat.value.type === "SymbolicLink" - - if (!isClaudeMdSymlink) { - const claudeMdText = yield* fs.readFileString(claudeMdPath).pipe( - Effect.mapError((cause) => new FileReadError({ path: claudeMdPath, cause })) - ) - claudeMdInput = Option.some({ - fileName: claudeMdPath, - text: claudeMdText - }) - } - } - - return { - packageJson: packageJsonInput, - tsconfig: tsconfigInput, - vscodeSettings: vscodeSettingsInput, - agentsMd: agentsMdInput, - claudeMd: claudeMdInput - } - }) /** * Main setup command @@ -142,13 +27,13 @@ export const setup = Command.make( // Phase 2: Read files and create assessment input // ======================================================================== - const assessmentInput = yield* createAssessmentInput(currentDir, tsconfigInput) + const assessmentInput = yield* Assessment.createAssessmentInput(currentDir, tsconfigInput) // ======================================================================== // Phase 3: Perform assessment // ======================================================================== - const assessmentState = yield* assess(assessmentInput) + const assessmentState = yield* Assessment.assess(assessmentInput) // ======================================================================== // Phase 4: Gather target state from user @@ -160,86 +45,15 @@ export const setup = Command.make( // ======================================================================== // Phase 5: Compute changes // ======================================================================== - const result = yield* computeChanges(assessmentState, targetState) + const result = yield* Changes.computeChanges(assessmentState, targetState) // ======================================================================== // Phase 6: Review changes // ======================================================================== - yield* renderCodeActions(result, assessmentState) - - if (result.codeActions.length === 0) { - return - } - const shouldProceed = yield* Prompt.confirm({ - message: "Apply all changes?", - initial: true + yield* Changes.reviewAndApplyChanges(result, assessmentState, { + cancelMessage: "Setup cancelled. No changes were made." }) - - if (!shouldProceed) { - yield* Console.log("Setup cancelled. No changes were made.") - return - } - - // ======================================================================== - // Phase 7: Apply changes - // ======================================================================== - yield* Console.log("") - yield* Console.log("Applying changes...") - - const fs = yield* FileSystem.FileSystem - - // Apply each code action - for (const codeAction of result.codeActions) { - for (const fileChange of codeAction.changes) { - const fileName = fileChange.fileName - - // Check if file exists or if this is a new file - const fileExists = yield* fs.exists(fileName) - - if (!fileExists && fileChange.isNewFile) { - // Create new file - ensure directory exists first - const dirName = path.dirname(fileName) - yield* fs.makeDirectory(dirName, { recursive: true }).pipe( - Effect.ignore // Ignore error if directory already exists - ) - - // For new files, just write the newText from the first change - // (assumption: new files have a single TextChange spanning the entire file) - const newContent = fileChange.textChanges.length > 0 - ? fileChange.textChanges[0].newText - : "" - - yield* fs.writeFileString(fileName, newContent) - } else if (fileExists) { - // Read existing file - const existingContent = yield* fs.readFileString(fileName) - - // Apply all text changes to the file - // Sort changes in reverse order by position to avoid offset issues - const sortedChanges = [...fileChange.textChanges].sort((a, b) => b.span.start - a.span.start) - - let newContent = existingContent - for (const textChange of sortedChanges) { - const start = textChange.span.start - const end = start + textChange.span.length - - newContent = newContent.slice(0, start) + textChange.newText + newContent.slice(end) - } - - // Write the modified content back - yield* fs.writeFileString(fileName, newContent) - } - } - } - - yield* Console.log("Changes applied successfully!") - yield* Console.log("") - - // Display any additional messages (e.g., editor setup instructions) - for (const message of result.messages) { - yield* Console.log(message) - } }) ).pipe( Command.withDescription("Setup the effect-language-service for the given project using an interactive cli.") diff --git a/packages/language-service/src/cli/setup/assessment.ts b/packages/language-service/src/cli/setup/assessment.ts index 800c1f2c..ba7d5123 100644 --- a/packages/language-service/src/cli/setup/assessment.ts +++ b/packages/language-service/src/cli/setup/assessment.ts @@ -1,10 +1,14 @@ import * as Array from "effect/Array" import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" import * as Option from "effect/Option" +import * as Path from "effect/Path" +import type * as PlatformError from "effect/PlatformError" import type * as ts from "typescript" import type { LanguageServicePluginOptions } from "../../core/LanguageServicePluginOptions" import { parse as parseOptions } from "../../core/LanguageServicePluginOptions" import { type FileInput, TypeScriptContext } from "../utils" +import { FileReadError, PackageJsonNotFoundError } from "./errors" /** * Markers used to identify the effect-language-service section in markdown files @@ -233,6 +237,104 @@ const assessMarkdownFromInput = ( } } +export const createAssessmentInput = ( + currentDir: string, + tsconfigInput: FileInput +): Effect.Effect< + Assessment.Input, + PackageJsonNotFoundError | FileReadError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const packageJsonPath = path.join(currentDir, "package.json") + const packageJsonExists = yield* fs.exists(packageJsonPath) + + if (!packageJsonExists) { + return yield* new PackageJsonNotFoundError({ path: packageJsonPath }) + } + + const packageJsonText = yield* fs.readFileString(packageJsonPath).pipe( + Effect.mapError((cause) => new FileReadError({ path: packageJsonPath, cause })) + ) + const packageJsonInput: FileInput = { + fileName: packageJsonPath, + text: packageJsonText + } + + const vscodeSettingsPath = path.join(currentDir, ".vscode", "settings.json") + const vscodeSettingsExists = yield* fs.exists(vscodeSettingsPath) + + let vscodeSettingsInput = Option.none() + if (vscodeSettingsExists) { + const vscodeSettingsText = yield* fs.readFileString(vscodeSettingsPath).pipe( + Effect.mapError((cause) => new FileReadError({ path: vscodeSettingsPath, cause })) + ) + vscodeSettingsInput = Option.some({ + fileName: vscodeSettingsPath, + text: vscodeSettingsText + }) + } + + const agentsMdLowerPath = path.join(currentDir, "agents.md") + const agentsMdUpperPath = path.join(currentDir, "AGENTS.md") + const agentsMdLowerExists = yield* fs.exists(agentsMdLowerPath) + const agentsMdUpperExists = yield* fs.exists(agentsMdUpperPath) + const agentsMdPath = agentsMdUpperExists ? agentsMdUpperPath : agentsMdLowerPath + const agentsMdExists = agentsMdUpperExists || agentsMdLowerExists + + let agentsMdInput = Option.none() + if (agentsMdExists) { + const agentsMdStat = yield* fs.stat(agentsMdPath).pipe(Effect.option) + const isAgentsMdSymlink = Option.isSome(agentsMdStat) && + agentsMdStat.value.type === "SymbolicLink" + + if (!isAgentsMdSymlink) { + const agentsMdText = yield* fs.readFileString(agentsMdPath).pipe( + Effect.mapError((cause) => new FileReadError({ path: agentsMdPath, cause })) + ) + agentsMdInput = Option.some({ + fileName: agentsMdPath, + text: agentsMdText + }) + } + } + + const claudeMdLowerPath = path.join(currentDir, "claude.md") + const claudeMdUpperPath = path.join(currentDir, "CLAUDE.md") + const claudeMdLowerExists = yield* fs.exists(claudeMdLowerPath) + const claudeMdUpperExists = yield* fs.exists(claudeMdUpperPath) + const claudeMdPath = claudeMdUpperExists ? claudeMdUpperPath : claudeMdLowerPath + const claudeMdExists = claudeMdUpperExists || claudeMdLowerExists + + let claudeMdInput = Option.none() + if (claudeMdExists) { + const claudeMdStat = yield* fs.stat(claudeMdPath).pipe(Effect.option) + const isClaudeMdSymlink = Option.isSome(claudeMdStat) && + claudeMdStat.value.type === "SymbolicLink" + + if (!isClaudeMdSymlink) { + const claudeMdText = yield* fs.readFileString(claudeMdPath).pipe( + Effect.mapError((cause) => new FileReadError({ path: claudeMdPath, cause })) + ) + claudeMdInput = Option.some({ + fileName: claudeMdPath, + text: claudeMdText + }) + } + } + + return { + packageJson: packageJsonInput, + tsconfig: tsconfigInput, + vscodeSettings: vscodeSettingsInput, + agentsMd: agentsMdInput, + claudeMd: claudeMdInput + } + }) + /** * Perform assessment from input data */ diff --git a/packages/language-service/src/cli/setup/changes.ts b/packages/language-service/src/cli/setup/changes.ts index 6051c30e..7062f9a6 100644 --- a/packages/language-service/src/cli/setup/changes.ts +++ b/packages/language-service/src/cli/setup/changes.ts @@ -1,8 +1,13 @@ +import * as Console from "effect/Console" import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" import * as Option from "effect/Option" +import * as Path from "effect/Path" +import * as Prompt from "effect/unstable/cli/Prompt" import type * as ts from "typescript" import { type TypeScriptApi, TypeScriptContext } from "../utils" import { type Assessment, MARKDOWN_DEFAULT_CONTENT, MARKDOWN_END_MARKER, MARKDOWN_START_MARKER } from "./assessment" +import { renderCodeActions } from "./diff-renderer" import type { Target } from "./target" /** @@ -975,3 +980,75 @@ const computeVSCodeSettingsChanges = ( } }) } + +export const reviewAndApplyChanges = ( + result: ComputeChangesResult, + assessmentState: Assessment.State, + options?: { + readonly confirmMessage?: string + readonly cancelMessage?: string + readonly applyMessage?: string + readonly successMessage?: string + } +) => + Effect.gen(function*() { + yield* renderCodeActions(result, assessmentState) + + if (result.codeActions.length === 0) { + return + } + + const shouldProceed = yield* Prompt.confirm({ + message: options?.confirmMessage ?? "Apply all changes?", + initial: true + }) + + if (!shouldProceed) { + yield* Console.log(options?.cancelMessage ?? "No changes were made.") + return + } + + yield* Console.log("") + yield* Console.log(options?.applyMessage ?? "Applying changes...") + + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + for (const codeAction of result.codeActions) { + for (const fileChange of codeAction.changes) { + const fileName = fileChange.fileName + const fileExists = yield* fs.exists(fileName) + + if (!fileExists && fileChange.isNewFile) { + const dirName = path.dirname(fileName) + yield* fs.makeDirectory(dirName, { recursive: true }).pipe(Effect.ignore) + + const newContent = fileChange.textChanges.length > 0 + ? fileChange.textChanges[0].newText + : "" + + yield* fs.writeFileString(fileName, newContent) + } else if (fileExists) { + const existingContent = yield* fs.readFileString(fileName) + const sortedChanges = [...fileChange.textChanges].sort((a, b) => b.span.start - a.span.start) + + let newContent = existingContent + for (const textChange of sortedChanges) { + const start = textChange.span.start + const end = start + textChange.span.length + + newContent = newContent.slice(0, start) + textChange.newText + newContent.slice(end) + } + + yield* fs.writeFileString(fileName, newContent) + } + } + } + + yield* Console.log(options?.successMessage ?? "Changes applied successfully!") + yield* Console.log("") + + for (const message of result.messages) { + yield* Console.log(message) + } + }) diff --git a/packages/language-service/src/cli/setup/diagnostic-prompt.ts b/packages/language-service/src/cli/setup/diagnostic-prompt.ts index ade190d7..b714d70d 100644 --- a/packages/language-service/src/cli/setup/diagnostic-prompt.ts +++ b/packages/language-service/src/cli/setup/diagnostic-prompt.ts @@ -1,5 +1,6 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" +import * as Option from "effect/Option" import * as Terminal from "effect/Terminal" import * as Prompt from "effect/unstable/cli/Prompt" import type { DiagnosticGroup } from "../../core/DiagnosticGroup" @@ -369,14 +370,27 @@ function normalizeStartIndex(length: number, startIndex: number): number { function isPrintableInput(input: Terminal.UserInput): boolean { const printablePattern = new RegExp(String.raw`^[^\u0000-\u001F\u007F]+$`, "u") + const inputText = getInputText(input) return ( !input.key.ctrl && !input.key.meta && - input.input.valueOrUndefined !== undefined && - printablePattern.test(input.input.valueOrUndefined) + inputText !== undefined && + inputText.length > 0 && + printablePattern.test(inputText) ) } +function getInputText(input: Terminal.UserInput): string | undefined { + const value = input.input as string | Option.Option | undefined + if (typeof value === "string") { + return value + } + if (value === undefined) { + return undefined + } + return Option.getOrUndefined(value) +} + function buildVisibleEntries( entries: ReadonlyArray, severities: Readonly>, @@ -554,7 +568,7 @@ function handleProcess(entries: ReadonlyArray) { })) default: if (!isPrintableInput(input)) return Effect.succeed(Action.Beep()) - return buildState(entries, 0, state.searchText + input.input, state.severities).pipe( + return buildState(entries, 0, state.searchText + getInputText(input)!, state.severities).pipe( Effect.map((nextState) => Action.NextFrame({ state: nextState })) ) } diff --git a/packages/language-service/src/cli/setup/target.ts b/packages/language-service/src/cli/setup/target.ts index 3f75c914..155f34a3 100644 --- a/packages/language-service/src/cli/setup/target.ts +++ b/packages/language-service/src/cli/setup/target.ts @@ -1,5 +1,6 @@ -import type * as Option from "effect/Option" +import * as Option from "effect/Option" import type { DiagnosticSeverity } from "../../core/LanguageServicePluginOptions" +import type * as Assesment from "./assessment" /** * Supported editor types @@ -45,3 +46,30 @@ export namespace Target { readonly editors: ReadonlyArray } } + +export const fromAssessment = (inputState: Assesment.Assessment.State): Target.State => ({ + packageJson: { + lspVersion: inputState.packageJson.lspVersion, + prepareScript: Option.map(inputState.packageJson.prepareScript, (_) => _.hasPatch).pipe( + Option.getOrElse(() => false) + ) + }, + tsconfig: { + diagnosticSeverities: Option.map(inputState.tsconfig.currentOptions, (_) => _.diagnosticSeverity) + }, + vscodeSettings: Option.map(inputState.vscodeSettings, (settings) => ({ + settings: settings.settings + })), + editors: [] +}) + +export const withDiagnosticSeverities = ( + state: Target.State, + diagnosticSeverities: Record +): Target.State => ({ + ...state, + tsconfig: { + ...state.tsconfig, + diagnosticSeverities: Option.some(diagnosticSeverities) + } +}) diff --git a/packages/language-service/test/config-cli.test.ts b/packages/language-service/test/config-cli.test.ts new file mode 100644 index 00000000..fe645d46 --- /dev/null +++ b/packages/language-service/test/config-cli.test.ts @@ -0,0 +1,91 @@ +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import { describe, expect, it } from "vitest" +import { assess, type Assessment } from "../src/cli/setup/assessment" +import { computeChanges } from "../src/cli/setup/changes" +import * as Target from "../src/cli/setup/target" +import { TypeScriptContext } from "../src/cli/utils" + +function createAssessmentInput( + packageJson: Record, + tsconfig: Record, + vscodeSettings?: Record +): Assessment.Input { + return { + packageJson: { + fileName: "package.json", + text: JSON.stringify(packageJson, null, 2) + }, + tsconfig: { + fileName: "tsconfig.json", + text: JSON.stringify(tsconfig, null, 2) + }, + vscodeSettings: vscodeSettings + ? Option.some({ + fileName: ".vscode/settings.json", + text: JSON.stringify(vscodeSettings, null, 2) + }) + : Option.none(), + agentsMd: Option.none(), + claudeMd: Option.none() + } +} + +describe("Config CLI", () => { + it("only targets diagnostic severities", async () => { + const assessmentInput = createAssessmentInput( + { + name: "test-project", + version: "1.0.0", + devDependencies: { + "@effect/language-service": "^0.1.0" + }, + scripts: { + prepare: "effect-language-service patch" + } + }, + { + compilerOptions: { + strict: true, + plugins: [{ + name: "@effect/language-service", + diagnosticSeverity: { + floatingeffect: "warning" + } + }] + } + }, + { + "editor.formatOnSave": true + } + ) + + const assessmentState = await Effect.runPromise( + assess(assessmentInput).pipe(Effect.provide(TypeScriptContext.live("."))) + ) + + const targetState = Target.withDiagnosticSeverities(Target.fromAssessment(assessmentState), { + floatingEffect: "error", + globalFetch: "warning" + }) + + expect(targetState.packageJson.lspVersion).toEqual(assessmentState.packageJson.lspVersion) + expect(targetState.packageJson.prepareScript).toBe(true) + expect(targetState.editors).toEqual([]) + expect(targetState.vscodeSettings).toEqual(Option.map(assessmentState.vscodeSettings, (settings) => ({ + settings: settings.settings + }))) + + const result = await Effect.runPromise( + computeChanges(assessmentState, targetState).pipe(Effect.provide(TypeScriptContext.live("."))) + ) + + expect(result.codeActions.some((action) => action.changes.some((change) => change.fileName === "tsconfig.json"))) + .toBe(true) + expect(result.codeActions.some((action) => action.changes.some((change) => change.fileName === "package.json"))) + .toBe(false) + expect( + result.codeActions.some((action) => action.changes.some((change) => change.fileName === ".vscode/settings.json")) + ).toBe(false) + }) +}) From 3b0ff9ea98201057f6b484286aade26b2a80227b Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Mon, 23 Mar 2026 19:19:36 +0100 Subject: [PATCH 2/2] Add changeset for config command Record the new config CLI flow as a minor release so the package changelog includes the dedicated diagnostic configuration command. --- .changeset/early-bottles-flash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/early-bottles-flash.md diff --git a/.changeset/early-bottles-flash.md b/.changeset/early-bottles-flash.md new file mode 100644 index 00000000..b5ac2cd7 --- /dev/null +++ b/.changeset/early-bottles-flash.md @@ -0,0 +1,5 @@ +--- +"@effect/language-service": minor +--- + +Add a `config` CLI command for updating diagnostic rule severities without rerunning the full setup flow.