From 1f865fd8575fcbd0588e65488ca8d130a2ae8fd1 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 12:23:28 -0400 Subject: [PATCH 01/21] feat(cdk-explorer): add LspIoHost adapter for the Toolkit Adapts toolkit-lib's IIoHost to the LSP's connection.console so synth output flows into the editor's Output panel. `requestResponse` returns each request's `defaultResponse` since the LSP cannot prompt synchronously through the JSON-RPC console. This is acceptable for the synth path, which has no interactive prompts in normal flow. Not yet wired into main.ts; consumed by upcoming manual-synth work. --- .../@aws-cdk/cdk-explorer/lib/lsp/io-host.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts new file mode 100644 index 000000000..c836ca2e7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -0,0 +1,36 @@ +import type { IIoHost, IoMessage, IoRequest } from '@aws-cdk/toolkit-lib'; +import type { RemoteConsole } from 'vscode-languageserver/node'; + +/** + * IoHost for the LSP. Routes Toolkit messages into the editor's Output + * channel via the LSP connection's console. + * + * The LSP cannot prompt the user synchronously through `connection.console`, + * so `requestResponse` returns each message's `defaultResponse`. This is + * acceptable for `synth`, which has no interactive prompts. + */ +export class LspIoHost implements IIoHost { + public constructor(private readonly console: RemoteConsole) {} + + public async notify(msg: IoMessage): Promise { + switch (msg.level) { + case 'error': + this.console.error(msg.message); + break; + case 'warn': + this.console.warn(msg.message); + break; + case 'debug': + case 'trace': + // Suppress noisy levels; keeps the Output panel readable. + break; + default: + this.console.info(msg.message); + } + } + + public async requestResponse(msg: IoRequest): Promise { + await this.notify(msg); + return msg.defaultResponse; + } +} From 332ddb97fdc96e48f51442ac884db456bc5b5cf4 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 12:29:21 -0400 Subject: [PATCH 02/21] feat(cdk-explorer): add cdk-config reader Tiny utility that reads `/cdk.json` and returns the `app` command (or `undefined`). Never throws. Used by upcoming manual-synth work to discover the command CDK should run for synth. Treats missing files, malformed JSON, or wrong-typed fields as "not configured" so callers can fall back gracefully (e.g. disable the manual synth command and inform the user). --- .../cdk-explorer/lib/core/cdk-config.ts | 35 +++++++++++ .../cdk-explorer/test/core/cdk-config.test.ts | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts new file mode 100644 index 000000000..edcffd7e6 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The subset of `cdk.json` the explorer cares about. + * + * `app` is the command CDK runs to produce a cloud assembly (e.g. + * `npx ts-node bin/app.ts`). We need it to invoke `Toolkit.synth()` + * via `fromCdkApp`. + */ +export interface CdkConfig { + /** The `app` command, or `undefined` if missing/malformed. */ + readonly app: string | undefined; +} + +/** + * Reads `/cdk.json` and returns the parts the explorer uses. + * Never throws. Treats missing files, malformed JSON, or wrong-typed + * fields as "not configured" so callers can fall back gracefully + */ +export function readCdkConfig(projectDir: string): CdkConfig { + const configPath = path.join(projectDir, 'cdk.json'); + if (!fs.existsSync(configPath)) return { app: undefined }; + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + return { app: undefined }; + } + + if (parsed === null || typeof parsed !== 'object') return { app: undefined }; + const app = (parsed as { app?: unknown }).app; + return { app: typeof app === 'string' ? app : undefined }; +} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts new file mode 100644 index 000000000..55b009c62 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { readCdkConfig } from '../../lib/core/cdk-config'; + +function withTempDir(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-cdkconfig-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function writeCdkJson(dir: string, contents: string): void { + fs.writeFileSync(path.join(dir, 'cdk.json'), contents); +} + +describe('readCdkConfig', () => { + test('returns the app command when present', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ app: 'npx ts-node bin/app.ts' })); + expect(readCdkConfig(dir)).toEqual({ app: 'npx ts-node bin/app.ts' }); + }); + }); + + test('returns undefined when cdk.json is absent', () => { + withTempDir((dir) => { + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when cdk.json is malformed', () => { + withTempDir((dir) => { + writeCdkJson(dir, '{not valid json'); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when the app key is missing', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ context: {} })); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when the app value is not a string', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ app: 42 })); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when cdk.json contains a JSON null', () => { + withTempDir((dir) => { + writeCdkJson(dir, 'null'); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); +}); From 8aa4813b8ee0af55c026cbd6ee3b659d7d5d9bba Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 12:38:28 -0400 Subject: [PATCH 03/21] feat(cdk-explorer): add synth-runner with typed outcome Wraps `Toolkit.synth(fromCdkApp(...))` and immediately disposes the `CachedCloudAssembly` so the read lock is released before the next call. Holding the cached assembly between calls would cause the next synth to self-conflict with `ConcurrentReadLock`. Classifies failures into four outcomes: * `success`: `cdk.out` is on disk, the watcher will pick it up. * `app-failure`: the user's CDK app threw or did not compile (`AssemblyError`). * `lock-conflict`: `/cdk.out` is held by another process or by our own previous synth. Callers should not surface this as a hard error. * `error`: anything else, including a dispose failure after a successful synth. Note: `ToolkitError` stores its discriminating code in `name`, not in a `code` property. The classifier reads `err.name` for lock conflict detection. Not yet wired into the LSP; consumed by upcoming command dispatcher. --- .../cdk-explorer/lib/core/synth-runner.ts | 72 ++++++++++++ .../test/core/synth-runner.test.ts | 110 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts new file mode 100644 index 000000000..a1ca21654 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -0,0 +1,72 @@ +import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; + +/** + * The outcome of a single synth attempt. + * + * `success` means the assembly was written to disk (the watcher will see it). + * `app-failure` means the user's CDK app threw or did not compile. + * `lock-conflict` means another process holds `/cdk.out` (a `cdk + * synth` running in a terminal, a `cdk watch` loop, or our own previous synth + * not yet released). Callers should not surface this as a hard error. + * `error` is reserved for anything we did not classify, including failures + * during dispose. + */ +export type SynthRunResult = + | { status: 'success' } + | { status: 'app-failure'; message: string } + | { status: 'lock-conflict' } + | { status: 'error'; message: string }; + +export interface SynthRunnerOptions { + /** A configured Toolkit instance (its IoHost decides where messages go). */ + readonly toolkit: Toolkit; + /** Directory containing the user's `cdk.json`; also the synth working dir. */ + readonly projectDir: string; + /** The `app` command from `cdk.json` (e.g. `npx ts-node bin/app.ts`). */ + readonly app: string; +} + +/** + * Run a one-shot synth of the user's CDK app. Writes `/cdk.out` + * via `Toolkit.synth(fromCdkApp(...))`, then immediately disposes the cached + * assembly so the read lock is released before the next call. Holding the + * cached assembly between calls would cause the next acquireWrite to throw + * `ConcurrentReadLock` against ourselves. + */ +export async function runSynth(options: SynthRunnerOptions): Promise { + let cached; + try { + const cx = await options.toolkit.fromCdkApp(options.app, { + workingDirectory: options.projectDir, + }); + cached = await options.toolkit.synth(cx); + } catch (err) { + return classify(err); + } + + try { + await cached.dispose(); + } catch (err) { + // Synth succeeded and `cdk.out` is on disk, but we could not release the + // read lock. Surface as `error` so the caller can warn the user; without + // a lock release the next synth from this process would self-conflict. + return { status: 'error', message: (err as Error).message }; + } + + return { status: 'success' }; +} + +function classify(err: unknown): SynthRunResult { + if (ToolkitError.isToolkitError(err)) { + // ToolkitError stores its discriminating code in `name` (the constructor's + // first arg overrides the default Error name), not in a `code` property. + if (err.name === 'ConcurrentWriteLock' || err.name === 'ConcurrentReadLock') { + return { status: 'lock-conflict' }; + } + if (ToolkitError.isAssemblyError(err)) { + return { status: 'app-failure', message: err.message }; + } + return { status: 'error', message: err.message }; + } + return { status: 'error', message: (err as Error).message }; +} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts new file mode 100644 index 000000000..af77a3411 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts @@ -0,0 +1,110 @@ +import { AssemblyError, ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; +import { runSynth } from '../../lib/core/synth-runner'; + +interface FakeCachedAssembly { + dispose: jest.Mock; +} + +interface FakeToolkit { + fromCdkApp: jest.Mock; + synth: jest.Mock; +} + +function makeToolkit(opts: { + synthThrow?: unknown; + disposeThrow?: unknown; +}): { toolkit: FakeToolkit; cached: FakeCachedAssembly } { + const cached: FakeCachedAssembly = { + dispose: jest.fn().mockImplementation(() => + opts.disposeThrow ? Promise.reject(opts.disposeThrow) : Promise.resolve(), + ), + }; + const toolkit: FakeToolkit = { + fromCdkApp: jest.fn().mockResolvedValue({}), + synth: jest.fn().mockImplementation(() => + opts.synthThrow ? Promise.reject(opts.synthThrow) : Promise.resolve(cached), + ), + }; + return { toolkit, cached }; +} + +function run(toolkit: FakeToolkit) { + return runSynth({ + toolkit: toolkit as unknown as Toolkit, + projectDir: '/p', + app: 'npx ts-node bin/app.ts', + }); +} + +describe('runSynth', () => { + test('returns success and disposes the cached assembly', async () => { + const { toolkit, cached } = makeToolkit({}); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'success' }); + expect(toolkit.fromCdkApp).toHaveBeenCalledWith('npx ts-node bin/app.ts', { workingDirectory: '/p' }); + expect(cached.dispose).toHaveBeenCalledTimes(1); + }); + + test('classifies AssemblyError as app-failure with the error message', async () => { + const { toolkit } = makeToolkit({ + synthThrow: AssemblyError.withCause('Assembly builder failed', new Error('TypeError: foo')), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed') }); + }); + + test('classifies ConcurrentWriteLock as lock-conflict', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ToolkitError('ConcurrentWriteLock', 'another CLI synthing'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'lock-conflict' }); + }); + + test('classifies ConcurrentReadLock as lock-conflict', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ToolkitError('ConcurrentReadLock', 'another CLI reading'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'lock-conflict' }); + }); + + test('classifies an unknown ToolkitError as error', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ToolkitError('SomeUnexpected', 'unexpected'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'error', message: 'unexpected' }); + }); + + test('classifies a plain Error as error', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new Error('disk full'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'error', message: 'disk full' }); + }); + + test('returns error when dispose fails after a successful synth', async () => { + const { toolkit, cached } = makeToolkit({ + disposeThrow: new Error('lock release failed'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'error', message: 'lock release failed' }); + expect(cached.dispose).toHaveBeenCalledTimes(1); + }); +}); From 0c48235dc922cda3bb1111d7ff3e8ed49f621954 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 13:02:17 -0400 Subject: [PATCH 04/21] feat(cdk-explorer): command dispatcher for synthNow and refresh --- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 94 +++++++++++++ .../@aws-cdk/cdk-explorer/lib/lsp/io-host.ts | 3 +- .../cdk-explorer/test/lsp/commands.test.ts | 128 ++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts new file mode 100644 index 000000000..94afec6ac --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -0,0 +1,94 @@ +import type { SynthRunResult } from '../core/synth-runner'; + +/** Trigger a fresh synth of the user's CDK app. */ +export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; + +/** Re-read the existing `cdk.out` and republish diagnostics. */ +export const COMMAND_REFRESH = 'cdk.explorer.refresh'; + +/** All commands this LSP advertises via `executeCommandProvider`. */ +export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW, COMMAND_REFRESH] as const; + +/** + * UI sinks the dispatcher uses to communicate with the user. + * Implementations bridge to `connection.window.*` in the LSP layer. + */ +export interface NotifySink { + /** Show a non-error informational message. */ + info(message: string): void; + /** Show an error message. */ + error(message: string): void; + /** + * Run a long operation with a visible progress indicator. The implementation + * is responsible for ending the indicator regardless of success or failure. + */ + withProgress(message: string, fn: () => Promise): Promise; +} + +export interface CommandHandlerOptions { + /** Invokes a single synth. Resolves with the typed outcome; never rejects. */ + readonly synth: () => Promise; + /** Re-read `cdk.out` (no synth). */ + readonly refresh: () => void; + /** + * Whether `synth` can be invoked. False when `cdk.json` is missing or has + * no `app` key; the synth command is then unavailable to the user. + */ + readonly synthAvailable: boolean; + /** UI sinks for messages and progress. */ + readonly notify: NotifySink; +} + +const SYNTH_UNAVAILABLE_MESSAGE = "CDK synth unavailable: 'cdk.json' missing or has no 'app' key."; +const LOCK_CONFLICT_MESSAGE = 'Another synth is in progress. Results will refresh shortly.'; +const PROGRESS_MESSAGE = 'Synthesizing CDK app...'; + +/** + * Handle a `workspace/executeCommand` request. Synchronous commands return + * immediately; the synth command runs under a progress indicator and reports + * outcomes through the notify sinks. Unknown commands are silently ignored. + */ +export async function executeCommand( + command: string, + _args: unknown[], + options: CommandHandlerOptions, +): Promise { + switch (command) { + case COMMAND_REFRESH: + options.refresh(); + return; + + case COMMAND_SYNTH_NOW: + if (!options.synthAvailable) { + options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); + return; + } + { + const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); + handleSynthResult(result, options.notify); + } + return; + + default: + // Unknown commands are silently ignored. The LSP only advertises ones + // we handle, so this branch only fires if a client sends a stale name. + return; + } +} + +function handleSynthResult(result: SynthRunResult, notify: NotifySink): void { + switch (result.status) { + case 'success': + // Silent. The watcher refreshes the editor when `cdk.out` changes. + return; + case 'app-failure': + notify.error(`CDK synth failed: ${result.message}`); + return; + case 'lock-conflict': + notify.info(LOCK_CONFLICT_MESSAGE); + return; + case 'error': + notify.error(`CDK synth failed unexpectedly: ${result.message}`); + return; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts index c836ca2e7..f721b61fb 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -10,7 +10,8 @@ import type { RemoteConsole } from 'vscode-languageserver/node'; * acceptable for `synth`, which has no interactive prompts. */ export class LspIoHost implements IIoHost { - public constructor(private readonly console: RemoteConsole) {} + public constructor(private readonly console: RemoteConsole) { + } public async notify(msg: IoMessage): Promise { switch (msg.level) { diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts new file mode 100644 index 000000000..f9c5df6ad --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -0,0 +1,128 @@ +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { + COMMAND_REFRESH, + COMMAND_SYNTH_NOW, + executeCommand, + type CommandHandlerOptions, + type NotifySink, +} from '../../lib/lsp/commands'; + +interface CapturedNotify extends NotifySink { + info: jest.Mock; + error: jest.Mock; + withProgress: jest.Mock; + /** All progress message strings passed to withProgress, in order. */ + progressMessages: string[]; +} + +function createNotify(): CapturedNotify { + const progressMessages: string[] = []; + const info = jest.fn(); + const error = jest.fn(); + const withProgress = jest.fn(async (message: string, fn: () => Promise) => { + progressMessages.push(message); + return fn(); + }); + return { info, error, withProgress, progressMessages }; +} + +function makeOptions(overrides: Partial = {}): { + options: CommandHandlerOptions; + notify: CapturedNotify; + synth: jest.Mock; + refresh: jest.Mock; +} { + const notify = createNotify(); + const synth = jest.fn(async () => ({ status: 'success' } as SynthRunResult)); + const refresh = jest.fn(); + return { + notify, + synth, + refresh, + options: { + synth, + refresh, + synthAvailable: true, + notify, + ...overrides, + }, + }; +} + +describe('executeCommand', () => { + test('refresh calls refresh() and does not notify', async () => { + const { options, notify, refresh, synth } = makeOptions(); + + await executeCommand(COMMAND_REFRESH, [], options); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(synth).not.toHaveBeenCalled(); + expect(notify.info).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow shows info and skips synth when unavailable', async () => { + const { options, notify, synth } = makeOptions({ synthAvailable: false }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(synth).not.toHaveBeenCalled(); + expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('cdk.json')); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow runs synth under withProgress on success and notifies nothing', async () => { + const { options, notify, synth } = makeOptions(); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(synth).toHaveBeenCalledTimes(1); + expect(notify.progressMessages).toEqual([expect.stringContaining('Synthesizing')]); + expect(notify.info).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces app-failure as an error notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'app-failure', message: 'TypeError: x' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('TypeError: x')); + expect(notify.info).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces lock-conflict as an info notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'lock-conflict' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('in progress')); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces a generic error as an error notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'error', message: 'disk full' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('disk full')); + expect(notify.info).not.toHaveBeenCalled(); + }); + + test('unknown commands are silently ignored', async () => { + const { options, notify, refresh, synth } = makeOptions(); + + await executeCommand('cdk.explorer.bogus', [], options); + + expect(refresh).not.toHaveBeenCalled(); + expect(synth).not.toHaveBeenCalled(); + expect(notify.info).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); From 3dc68d2bfec77a16f5e8c7da7a722a1e33ab942e Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 13:21:49 -0400 Subject: [PATCH 05/21] feat(cdk-explorer): wire executeCommandProvider and onExecuteCommand in server --- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 66 +++++++++++++ .../cdk-explorer/test/lsp/server.test.ts | 96 +++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 04b24259f..6bc5870a1 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -16,12 +16,14 @@ import { type DefinitionParams, type DidSaveTextDocumentParams, type Diagnostic, + type ExecuteCommandParams, type InitializeParams, type InitializeResult, type Location, } from 'vscode-languageserver/node'; /* eslint-disable import/no-relative-packages */ import { codeLensesForFile } from './codelens'; +import { executeCommand, SUPPORTED_COMMANDS, type NotifySink } from './commands'; import { mapViolationsToDiagnostics } from './diagnostics'; import { offsetAtPosition } from './positions'; import { sourceTargetAtTemplateOffset } from './template-locator'; @@ -37,6 +39,7 @@ import { type AssemblyWatcher, type AssemblyWatcherOptions, } from '../core/assembly-watcher'; +import type { SynthRunResult } from '../core/synth-runner'; export interface LspHandlerOptions { /** Callback invoked on `didSave` for tracked source files. */ @@ -61,6 +64,12 @@ export interface LspHandlerOptions { * overridden in tests to drive refreshes deterministically. */ readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; + /** Runs a synth and returns its typed outcome. Injected by main.ts; omitted in tests that don't exercise synth. */ + readonly synthRunner?: () => Promise; + /** Whether cdk.json exists and has a valid `app` key. Controls whether synthNow is available. */ + readonly synthAvailable?: boolean; + /** User-facing notification sink. Injected by startServer; omitted in tests. */ + readonly notify?: NotifySink; } export interface LspServerOptions extends LspHandlerOptions { @@ -75,6 +84,7 @@ export interface LspHandlers { onDidSaveTextDocument(params: DidSaveTextDocumentParams): void; onCodeLens(params: CodeLensParams): CodeLens[]; onDefinition(params: DefinitionParams): Location | undefined; + onExecuteCommand(params: ExecuteCommandParams): Promise; onShutdown(): void; } @@ -104,9 +114,19 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers const onRefreshCodeLenses = options.onRefreshCodeLenses ?? (() => { }); const startWatcher = options.startAssemblyWatcher ?? defaultStartAssemblyWatcher; + const synthAvailable = options.synthAvailable ?? false; + const synthRunner = options.synthRunner; + const notify = options.notify ?? { + info: () => { + }, + error: () => { + }, + withProgress: (_msg: string, fn: () => Promise) => fn(), + }; let applicationDir: string | undefined; let shutdownRequested = false; + let synthInFlight = false; let shouldIgnore: (filePath: string) => boolean = () => false; let assemblyWatcher: AssemblyWatcher | undefined; // Latest index from readAssembly, served to CodeLens. Refreshed at startup @@ -180,6 +200,7 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers codeLensProvider: { resolveProvider: false }, // Go-to-definition from a synthesized template back to construct source. definitionProvider: true, + executeCommandProvider: { commands: [...SUPPORTED_COMMANDS] }, }, }; }, @@ -244,6 +265,28 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers const offset = offsetAtPosition(templateText, params.position); return sourceTargetAtTemplateOffset(cachedIndex, filePath, templateText, offset); }, + async onExecuteCommand(params) { + // Short-circuit duplicate clicks before they reach the Toolkit. + // Without this, a second call would still get lock-conflict from the + // RWLock, but only after fromCdkApp() has done setup work. This just + // makes the response instant for multiple synths from same LSP instance. + // The user message is the same either way. + const guardedSynth = async (): Promise => { + if (synthInFlight) return { status: 'lock-conflict' }; + synthInFlight = true; + try { + return await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + } finally { + synthInFlight = false; + } + }; + await executeCommand(params.command, params.arguments ?? [], { + synth: guardedSynth, + refresh: () => refreshFromAssembly(applicationDir ?? process.cwd()), + synthAvailable, + notify, + }); + }, onShutdown() { shutdownRequested = true; void assemblyWatcher?.close(); @@ -269,6 +312,28 @@ export function startServer(options: LspServerOptions): void { onRefreshCodeLenses: () => { void connection.sendRequest(CodeLensRefreshRequest.type); }, + synthRunner: options.synthRunner, + synthAvailable: options.synthAvailable, + notify: { + // Route to the Output panel (connection.console) rather than popups. + // showMessage creates a dismissable toast that interrupts the user's + // workflow; console writes are visible on demand in the Output panel. + info: (msg) => { + connection.console.info(msg); + }, + error: (msg) => { + connection.console.error(msg); + }, + withProgress: async (title, fn) => { + const progress = await connection.window.createWorkDoneProgress(); + progress.begin(title); + try { + return await fn(); + } finally { + progress.done(); + } + }, + }, }); connection.onInitialize((params) => handlers.onInitialize(params)); @@ -276,6 +341,7 @@ export function startServer(options: LspServerOptions): void { connection.onDidSaveTextDocument((params) => handlers.onDidSaveTextDocument(params)); connection.onCodeLens((params) => handlers.onCodeLens(params)); connection.onDefinition((params) => handlers.onDefinition(params)); + connection.onExecuteCommand((params) => handlers.onExecuteCommand(params)); connection.onShutdown(() => handlers.onShutdown()); connection.onExit(() => process.exit(0)); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 86da656ff..684000ca7 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -5,8 +5,22 @@ import { pathToFileURL } from 'url'; import type { Diagnostic, InitializeParams } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import type { AssemblyReadResult } from '../../lib'; +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { COMMAND_SYNTH_NOW, COMMAND_REFRESH, type NotifySink } from '../../lib/lsp/commands'; import { createLspHandlers, type LspHandlerOptions, type LspHandlers } from '../../lib/lsp/server'; +function makeNotifySink(): NotifySink & { infoMessages: string[]; errorMessages: string[] } { + const infoMessages: string[] = []; + const errorMessages: string[] = []; + return { + infoMessages, + errorMessages, + info: (msg) => infoMessages.push(msg), + error: (msg) => errorMessages.push(msg), + withProgress: async (_msg, fn) => fn(), + }; +} + interface CapturedClient { handlers: LspHandlers; published: Array<{ uri: string; diagnostics: Diagnostic[] }>; @@ -406,3 +420,85 @@ describe('LSP Server', () => { ).toBeUndefined(); }); }); + +describe('LSP Server -- executeCommand', () => { + function createCommandClient(opts: Partial = {}): { + handlers: LspHandlers; + notify: NotifySink & { infoMessages: string[]; errorMessages: string[] }; + } { + const notify = makeNotifySink(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + notify, + ...opts, + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); + handlers.onInitialized(); + return { handlers, notify }; + } + + test('onInitialize advertises executeCommandProvider with supported commands', () => { + const { handlers } = createCommandClient(); + const result = handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); + expect(result.capabilities.executeCommandProvider?.commands).toEqual( + expect.arrayContaining([COMMAND_SYNTH_NOW, COMMAND_REFRESH]), + ); + }); + + test('refresh command re-reads assembly without notifying', async () => { + let readCount = 0; + const { handlers, notify } = createCommandClient({ + readAssembly: () => { + readCount++; return { status: 'not-found' }; + }, + }); + const countBefore = readCount; + await handlers.onExecuteCommand({ command: COMMAND_REFRESH }); + expect(readCount).toBeGreaterThan(countBefore); + expect(notify.infoMessages).toHaveLength(0); + expect(notify.errorMessages).toHaveLength(0); + }); + + test('synthNow without synthAvailable notifies info', async () => { + const { handlers, notify } = createCommandClient({ synthAvailable: false }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.infoMessages.some((m) => m.includes('unavailable'))).toBe(true); + }); + + test('synthNow with success is silent', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.infoMessages).toHaveLength(0); + expect(notify.errorMessages).toHaveLength(0); + }); + + test('synthNow with app-failure notifies error', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile error' }); + const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.errorMessages.some((m) => m.includes('compile error'))).toBe(true); + }); + + test('synthNow in-flight latch: second call coalesces as lock-conflict', async () => { + let resolveFirst!: () => void; + const firstSynthDone = new Promise((res) => { + resolveFirst = res; + }); + const synthRunner = jest.fn, []>() + .mockImplementationOnce(() => firstSynthDone.then(() => ({ status: 'success' } as const))) + .mockResolvedValue({ status: 'success' }); + const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + + const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + // second call fires while first is still in progress + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + resolveFirst(); + await first; + + // second call hit the latch → notify.info with lock-conflict message + expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); + // synth runner was only called once + expect(synthRunner).toHaveBeenCalledTimes(1); + }); +}); From 2efedca38b24e74f3f265ee099f3ab40b62dd808 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 13:27:14 -0400 Subject: [PATCH 06/21] feat(cdk-explorer): prepend Synth now and Refresh header lenses on CDK source files --- .../@aws-cdk/cdk-explorer/lib/lsp/codelens.ts | 14 ++++- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 14 ++++- .../cdk-explorer/test/lsp/codelens.test.ts | 60 +++++++++++++------ .../cdk-explorer/test/lsp/server.test.ts | 6 +- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index bd07de5c0..d497dcf1d 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -1,6 +1,7 @@ import { pathToFileURL } from 'url'; import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node'; +import { COMMAND_REFRESH, COMMAND_SYNTH_NOW } from './commands'; import { resourceTarget, type ResourceTarget } from './template-locator'; import type { ConstructNode } from '../core/assembly-reader'; import type { SourceLocation } from '../core/source-resolver'; @@ -20,10 +21,21 @@ export function codeLensesForFile(index: ConstructIndex, fileUri: // Multiple resources can map to one line when an L2 construct fans out // (e.g. an L2 producing a primary resource + auxiliary resources). - return [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({ + const l1Lenses = [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({ range: lineRange(line), command: commandFor(group.map((m) => m.node)), })); + + if (l1Lenses.length === 0) return []; + + // Prepend header lenses at line 0 so users have a one-click synth/refresh + // surface at the top of every CDK source file that already has L1 lenses. + const header0: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + return [ + { range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } }, + { range: header0, command: { title: '↻ Refresh', command: COMMAND_REFRESH } }, + ...l1Lenses, + ]; } /** diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index 94afec6ac..20a0b4a8e 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -1,9 +1,19 @@ import type { SynthRunResult } from '../core/synth-runner'; -/** Trigger a fresh synth of the user's CDK app. */ +/** + * Runs the user's CDK app (`bin/app.ts`) via `Toolkit.synth()`, writes new + * CloudFormation templates to `cdk.out`, then the watcher picks up the change + * and republishes diagnostics automatically. Use when source code has changed. + * Takes seconds — it re-executes the app. + */ export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; -/** Re-read the existing `cdk.out` and republish diagnostics. */ +/** + * Re-reads whatever is already in `cdk.out` and republishes diagnostics and + * CodeLens. Does NOT re-execute the app. Use when `cdk.out` is already + * up to date (e.g. after running `cdk synth` in a terminal) but the LSP + * hasn't picked up the change. Fast — no app execution. + */ export const COMMAND_REFRESH = 'cdk.explorer.refresh'; /** All commands this LSP advertises via `executeCommandProvider`. */ diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts index c33e0c597..455568b53 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'url'; import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import type { ConstructNode } from '../../lib'; import { codeLensesForFile, OPEN_RESOURCE_COMMAND } from '../../lib/lsp/codelens'; +import { COMMAND_SYNTH_NOW, COMMAND_REFRESH } from '../../lib/lsp/commands'; const FILE = '/p/lib/stack.ts'; const URI = pathToFileURL(FILE).toString(); @@ -48,12 +49,12 @@ describe('codeLensesForFile', () => { })]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].range).toEqual({ + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 }, }); - expect(lenses[0].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('groups multiple resources on the same source line into one lens', () => { @@ -81,8 +82,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); + expect(lenses).toHaveLength(3); // 2 header + 1 grouped L1 + expect(lenses[2].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); }); test('emits separate lenses for resources on different lines', () => { @@ -102,8 +103,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(2); - expect(lenses.map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); + expect(lenses).toHaveLength(4); // 2 header + 2 L1 + expect(lenses.slice(2).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); }); test('filters out resources from other files', () => { @@ -123,14 +124,15 @@ describe('codeLensesForFile', () => { ]; const index = ConstructIndex.fromTree(tree); - // Each query returns only the resource defined in that file, which proves the - // URI filter selects by file rather than returning everything for any query. + // Each query returns the 2 header lenses plus only the resource defined in + // that file, which proves the URI filter selects by file rather than + // returning everything for any query. const onThisFile = codeLensesForFile(index, URI); - expect(onThisFile).toHaveLength(1); - expect(onThisFile[0].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(onThisFile).toHaveLength(3); // 2 header + 1 L1 + expect(onThisFile[2].command?.title).toBe('Creates AWS::S3::Bucket'); const onOtherFile = codeLensesForFile(index, OTHER_URI); - expect(onOtherFile).toHaveLength(1); - expect(onOtherFile[0].command?.title).toBe('Creates AWS::SQS::Queue'); + expect(onOtherFile).toHaveLength(3); // 2 header + 1 L1 + expect(onOtherFile[2].command?.title).toBe('Creates AWS::SQS::Queue'); }); test('walks descendants — finds resources nested under wrappers', () => { @@ -156,8 +158,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].command?.title).toContain('AWS::S3::Bucket'); + expect(lenses).toHaveLength(3); // 2 header + 1 + expect(lenses[2].command?.title).toContain('AWS::S3::Bucket'); }); test('omits resources without sourceLocation (non-TS apps)', () => { @@ -184,7 +186,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; expect(choices).toHaveLength(1); @@ -212,7 +214,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; const uri = pathToFileURL(templateFile).toString(); expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; @@ -235,7 +237,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; expect(lens.command?.command).toBe(''); expect(lens.command?.arguments).toBeUndefined(); }); @@ -253,10 +255,30 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; expect(lens.command?.command).toBe(''); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + + test('header lenses appear at line 0 with synthNow and refresh commands when L1 lenses are present', () => { + const tree = [node({ + path: 'Stack1/MyBucket/Resource', + logicalId: 'MyBucketF68F3FF0', + type: 'AWS::S3::Bucket', + sourceLocation: { file: FILE, line: 12, column: 5 }, + })]; + + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); + expect(lenses[0].range.start.line).toBe(0); + expect(lenses[0].command?.command).toBe(COMMAND_SYNTH_NOW); + expect(lenses[1].range.start.line).toBe(0); + expect(lenses[1].command?.command).toBe(COMMAND_REFRESH); + }); + + test('no header lenses on files with no L1 lenses', () => { + // File has no CDK resources → no lenses at all, including no header lenses + expect(codeLensesForFile(ConstructIndex.fromTree([]), URI)).toEqual([]); + }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 684000ca7..74123abc1 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -277,9 +277,9 @@ describe('LSP Server', () => { textDocument: { uri: stackUri }, }); - expect(lenses).toHaveLength(1); - expect(lenses[0].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 - expect(lenses[0].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('publishes nothing when assembly is not-found (pre-synth)', () => { From fa30f8a3c53dc7973f5bd4887134251c47d538fc Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 14:51:35 -0400 Subject: [PATCH 07/21] refactor(cdk-explorer): drop Refresh command, add auto-synth-on-save - Remove COMMAND_REFRESH and all associated dispatcher/test code - Hoist guardedSynth to closure level so didSave and onExecuteCommand share the same in-flight latch - Wire onDidSaveTextDocument to guardedSynth (auto-synth-on-save) when synthAvailable is true; errors logged to Output panel - Remove the legacy onSynthRequest seam (replaced by direct synth) - Drop the Refresh header lens; keep only Synth now --- .../@aws-cdk/cdk-explorer/lib/lsp/codelens.ts | 7 +- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 49 +--- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 61 +++-- .../cdk-explorer/test/lsp/codelens.test.ts | 42 ++-- .../cdk-explorer/test/lsp/commands.test.ts | 27 +- .../cdk-explorer/test/lsp/server.test.ts | 238 ++++++++---------- 6 files changed, 172 insertions(+), 252 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index d497dcf1d..d119d38f8 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from 'url'; import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node'; -import { COMMAND_REFRESH, COMMAND_SYNTH_NOW } from './commands'; +import { COMMAND_SYNTH_NOW } from './commands'; import { resourceTarget, type ResourceTarget } from './template-locator'; import type { ConstructNode } from '../core/assembly-reader'; import type { SourceLocation } from '../core/source-resolver'; @@ -28,12 +28,11 @@ export function codeLensesForFile(index: ConstructIndex, fileUri: if (l1Lenses.length === 0) return []; - // Prepend header lenses at line 0 so users have a one-click synth/refresh - // surface at the top of every CDK source file that already has L1 lenses. + // Prepend a header lens at line 0 so users have a one-click synth surface + // at the top of every CDK source file that already has L1 lenses. const header0: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; return [ { range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } }, - { range: header0, command: { title: '↻ Refresh', command: COMMAND_REFRESH } }, ...l1Lenses, ]; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index 20a0b4a8e..0f5f6c198 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -8,25 +8,17 @@ import type { SynthRunResult } from '../core/synth-runner'; */ export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; -/** - * Re-reads whatever is already in `cdk.out` and republishes diagnostics and - * CodeLens. Does NOT re-execute the app. Use when `cdk.out` is already - * up to date (e.g. after running `cdk synth` in a terminal) but the LSP - * hasn't picked up the change. Fast — no app execution. - */ -export const COMMAND_REFRESH = 'cdk.explorer.refresh'; - /** All commands this LSP advertises via `executeCommandProvider`. */ -export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW, COMMAND_REFRESH] as const; +export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW] as const; /** * UI sinks the dispatcher uses to communicate with the user. - * Implementations bridge to `connection.window.*` in the LSP layer. + * Implementations bridge to `connection.console` in the LSP layer. */ export interface NotifySink { - /** Show a non-error informational message. */ + /** Write a non-error informational message to the Output panel. */ info(message: string): void; - /** Show an error message. */ + /** Write an error message to the Output panel. */ error(message: string): void; /** * Run a long operation with a visible progress indicator. The implementation @@ -38,8 +30,6 @@ export interface NotifySink { export interface CommandHandlerOptions { /** Invokes a single synth. Resolves with the typed outcome; never rejects. */ readonly synth: () => Promise; - /** Re-read `cdk.out` (no synth). */ - readonly refresh: () => void; /** * Whether `synth` can be invoked. False when `cdk.json` is missing or has * no `app` key; the synth command is then unavailable to the user. @@ -54,36 +44,23 @@ const LOCK_CONFLICT_MESSAGE = 'Another synth is in progress. Results will refres const PROGRESS_MESSAGE = 'Synthesizing CDK app...'; /** - * Handle a `workspace/executeCommand` request. Synchronous commands return - * immediately; the synth command runs under a progress indicator and reports - * outcomes through the notify sinks. Unknown commands are silently ignored. + * Handle a `workspace/executeCommand` request. The synth command runs under a + * progress indicator and reports outcomes through the notify sinks. Unknown + * commands are silently ignored. */ export async function executeCommand( command: string, _args: unknown[], options: CommandHandlerOptions, ): Promise { - switch (command) { - case COMMAND_REFRESH: - options.refresh(); - return; + if (command !== COMMAND_SYNTH_NOW) return; - case COMMAND_SYNTH_NOW: - if (!options.synthAvailable) { - options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); - return; - } - { - const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); - handleSynthResult(result, options.notify); - } - return; - - default: - // Unknown commands are silently ignored. The LSP only advertises ones - // we handle, so this branch only fires if a client sends a stale name. - return; + if (!options.synthAvailable) { + options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); + return; } + const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); + handleSynthResult(result, options.notify); } function handleSynthResult(result: SynthRunResult, notify: NotifySink): void { diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 6bc5870a1..050673705 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -42,8 +42,6 @@ import { import type { SynthRunResult } from '../core/synth-runner'; export interface LspHandlerOptions { - /** Callback invoked on `didSave` for tracked source files. */ - readonly onSynthRequest?: (projectDir: string) => void; /** Override readAssembly for tests. Defaults to reading /cdk.out. */ readonly readAssembly?: (assemblyDir: string) => AssemblyReadResult; /** @@ -100,13 +98,26 @@ const NOOP_LOGGER: LogSink = { }, }; +/** Log auto-synth-on-save outcomes. Errors go to the Output panel; success is silent. */ +function handleSynthOnSave(result: SynthRunResult, log: LogSink): void { + switch (result.status) { + case 'success': + case 'lock-conflict': + return; // silent — watcher handles the update; lock means another synth is already running + case 'app-failure': + log.error(`Auto-synth failed: ${result.message}`); + return; + case 'error': + log.error(`Auto-synth failed unexpectedly: ${result.message}`); + return; + } +} + /** * Build the LSP message handlers as plain functions over closed-over state. * No streams, no JSON-RPC, no framework — testable in isolation. */ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers { - const onSynthRequest = options.onSynthRequest ?? (() => { - }); const readAssembly = options.readAssembly ?? defaultReadAssembly; const log = options.logger ?? NOOP_LOGGER; const onPublishDiagnostics = options.onPublishDiagnostics ?? (() => { @@ -183,6 +194,21 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers } } + // Shared synth invocation used by both the manual CodeLens command and + // auto-synth-on-save. The in-flight latch prevents concurrent synths from + // the same LSP instance. A second call while the first is running returns + // lock-conflict immediately (the Toolkit's RWLock would do the same, but + // this short-circuits before any setup work). + async function guardedSynth(): Promise { + if (synthInFlight) return { status: 'lock-conflict' }; + synthInFlight = true; + try { + return await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + } finally { + synthInFlight = false; + } + } + return { onInitialize(params) { applicationDir = params.initializationOptions?.applicationDir; @@ -234,12 +260,11 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (shutdownRequested) return; const filePath = fileURLToPath(params.textDocument.uri); if (shouldIgnore(filePath)) return; - const projectDir = applicationDir ?? process.cwd(); - try { - onSynthRequest(projectDir); - } catch (err) { - log.error(`Synth request failed: ${(err as Error).message}`); - } + if (!synthAvailable) return; + // Auto-synth on save: run the same guarded synth as the manual lens. + // Errors are logged to the Output panel; success is silent (the watcher + // picks up the cdk.out change and refreshes diagnostics). + void guardedSynth().then((result) => handleSynthOnSave(result, log)); }, onCodeLens(params) { return codeLensesForFile(cachedIndex, params.textDocument.uri); @@ -266,23 +291,8 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers return sourceTargetAtTemplateOffset(cachedIndex, filePath, templateText, offset); }, async onExecuteCommand(params) { - // Short-circuit duplicate clicks before they reach the Toolkit. - // Without this, a second call would still get lock-conflict from the - // RWLock, but only after fromCdkApp() has done setup work. This just - // makes the response instant for multiple synths from same LSP instance. - // The user message is the same either way. - const guardedSynth = async (): Promise => { - if (synthInFlight) return { status: 'lock-conflict' }; - synthInFlight = true; - try { - return await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); - } finally { - synthInFlight = false; - } - }; await executeCommand(params.command, params.arguments ?? [], { synth: guardedSynth, - refresh: () => refreshFromAssembly(applicationDir ?? process.cwd()), synthAvailable, notify, }); @@ -303,7 +313,6 @@ export function startServer(options: LspServerOptions): void { ); const handlers = createLspHandlers({ - onSynthRequest: options.onSynthRequest, readAssembly: options.readAssembly, logger: connection.console, onPublishDiagnostics: (uri, diagnostics) => { diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts index 455568b53..03acb357a 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from 'url'; import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import type { ConstructNode } from '../../lib'; import { codeLensesForFile, OPEN_RESOURCE_COMMAND } from '../../lib/lsp/codelens'; -import { COMMAND_SYNTH_NOW, COMMAND_REFRESH } from '../../lib/lsp/commands'; +import { COMMAND_SYNTH_NOW } from '../../lib/lsp/commands'; const FILE = '/p/lib/stack.ts'; const URI = pathToFileURL(FILE).toString(); @@ -49,12 +49,12 @@ describe('codeLensesForFile', () => { })]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(3); // 2 header + 1 L1 - expect(lenses[2].range).toEqual({ + expect(lenses).toHaveLength(2); // 1 header + 1 L1 + expect(lenses[1].range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 }, }); - expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses[1].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('groups multiple resources on the same source line into one lens', () => { @@ -82,8 +82,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(3); // 2 header + 1 grouped L1 - expect(lenses[2].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); + expect(lenses).toHaveLength(2); // 1 header + 1 grouped L1 + expect(lenses[1].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); }); test('emits separate lenses for resources on different lines', () => { @@ -103,8 +103,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(4); // 2 header + 2 L1 - expect(lenses.slice(2).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); + expect(lenses).toHaveLength(3); // 1 header + 2 L1 + expect(lenses.slice(1).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); }); test('filters out resources from other files', () => { @@ -124,15 +124,15 @@ describe('codeLensesForFile', () => { ]; const index = ConstructIndex.fromTree(tree); - // Each query returns the 2 header lenses plus only the resource defined in + // Each query returns the header lens plus only the resource defined in // that file, which proves the URI filter selects by file rather than // returning everything for any query. const onThisFile = codeLensesForFile(index, URI); - expect(onThisFile).toHaveLength(3); // 2 header + 1 L1 - expect(onThisFile[2].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(onThisFile).toHaveLength(2); // 1 header + 1 L1 + expect(onThisFile[1].command?.title).toBe('Creates AWS::S3::Bucket'); const onOtherFile = codeLensesForFile(index, OTHER_URI); - expect(onOtherFile).toHaveLength(3); // 2 header + 1 L1 - expect(onOtherFile[2].command?.title).toBe('Creates AWS::SQS::Queue'); + expect(onOtherFile).toHaveLength(2); // 1 header + 1 L1 + expect(onOtherFile[1].command?.title).toBe('Creates AWS::SQS::Queue'); }); test('walks descendants — finds resources nested under wrappers', () => { @@ -158,8 +158,8 @@ describe('codeLensesForFile', () => { ]; const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(3); // 2 header + 1 - expect(lenses[2].command?.title).toContain('AWS::S3::Bucket'); + expect(lenses).toHaveLength(2); // 1 header + 1 + expect(lenses[1].command?.title).toContain('AWS::S3::Bucket'); }); test('omits resources without sourceLocation (non-TS apps)', () => { @@ -186,7 +186,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; expect(choices).toHaveLength(1); @@ -214,7 +214,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; const uri = pathToFileURL(templateFile).toString(); expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; @@ -237,7 +237,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; expect(lens.command?.command).toBe(''); expect(lens.command?.arguments).toBeUndefined(); }); @@ -255,14 +255,14 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[2]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; expect(lens.command?.command).toBe(''); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); - test('header lenses appear at line 0 with synthNow and refresh commands when L1 lenses are present', () => { + test('header lens appears at line 0 with synthNow command when L1 lenses are present', () => { const tree = [node({ path: 'Stack1/MyBucket/Resource', logicalId: 'MyBucketF68F3FF0', @@ -273,8 +273,6 @@ describe('codeLensesForFile', () => { const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); expect(lenses[0].range.start.line).toBe(0); expect(lenses[0].command?.command).toBe(COMMAND_SYNTH_NOW); - expect(lenses[1].range.start.line).toBe(0); - expect(lenses[1].command?.command).toBe(COMMAND_REFRESH); }); test('no header lenses on files with no L1 lenses', () => { diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts index f9c5df6ad..4f6b53c29 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -1,6 +1,5 @@ import type { SynthRunResult } from '../../lib/core/synth-runner'; import { - COMMAND_REFRESH, COMMAND_SYNTH_NOW, executeCommand, type CommandHandlerOptions, @@ -11,7 +10,6 @@ interface CapturedNotify extends NotifySink { info: jest.Mock; error: jest.Mock; withProgress: jest.Mock; - /** All progress message strings passed to withProgress, in order. */ progressMessages: string[]; } @@ -30,37 +28,17 @@ function makeOptions(overrides: Partial = {}): { options: CommandHandlerOptions; notify: CapturedNotify; synth: jest.Mock; - refresh: jest.Mock; } { const notify = createNotify(); const synth = jest.fn(async () => ({ status: 'success' } as SynthRunResult)); - const refresh = jest.fn(); return { notify, synth, - refresh, - options: { - synth, - refresh, - synthAvailable: true, - notify, - ...overrides, - }, + options: { synth, synthAvailable: true, notify, ...overrides }, }; } describe('executeCommand', () => { - test('refresh calls refresh() and does not notify', async () => { - const { options, notify, refresh, synth } = makeOptions(); - - await executeCommand(COMMAND_REFRESH, [], options); - - expect(refresh).toHaveBeenCalledTimes(1); - expect(synth).not.toHaveBeenCalled(); - expect(notify.info).not.toHaveBeenCalled(); - expect(notify.error).not.toHaveBeenCalled(); - }); - test('synthNow shows info and skips synth when unavailable', async () => { const { options, notify, synth } = makeOptions({ synthAvailable: false }); @@ -116,11 +94,10 @@ describe('executeCommand', () => { }); test('unknown commands are silently ignored', async () => { - const { options, notify, refresh, synth } = makeOptions(); + const { options, notify, synth } = makeOptions(); await executeCommand('cdk.explorer.bogus', [], options); - expect(refresh).not.toHaveBeenCalled(); expect(synth).not.toHaveBeenCalled(); expect(notify.info).not.toHaveBeenCalled(); expect(notify.error).not.toHaveBeenCalled(); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 74123abc1..4ec7c0308 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -6,7 +6,7 @@ import type { Diagnostic, InitializeParams } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import type { AssemblyReadResult } from '../../lib'; import type { SynthRunResult } from '../../lib/core/synth-runner'; -import { COMMAND_SYNTH_NOW, COMMAND_REFRESH, type NotifySink } from '../../lib/lsp/commands'; +import { COMMAND_SYNTH_NOW, type NotifySink } from '../../lib/lsp/commands'; import { createLspHandlers, type LspHandlerOptions, type LspHandlers } from '../../lib/lsp/server'; function makeNotifySink(): NotifySink & { infoMessages: string[]; errorMessages: string[] } { @@ -27,26 +27,23 @@ interface CapturedClient { log: { warn: jest.Mock; error: jest.Mock }; refreshCodeLens: jest.Mock; watcherClosed: jest.Mock; - /** Fire the cdk.out watcher's onChange, as a real re-synth would. */ triggerWatcher: () => void; } -function createTestClient(opts?: Partial>): CapturedClient { +function createTestClient(opts?: Partial): CapturedClient { const published: Array<{ uri: string; diagnostics: Diagnostic[] }> = []; const log = { warn: jest.fn(), error: jest.fn() }; const refreshCodeLens = jest.fn(); const watcherClosed = jest.fn(); let watcherOnChange: (() => void) | undefined; const handlers = createLspHandlers({ - onSynthRequest: opts?.onSynthRequest, - // Default to "no assembly" so tests that don't care about diagnostics - // don't need a fake fixture. Tests that do care override this. readAssembly: opts?.readAssembly ?? (() => ({ status: 'not-found' })), + synthRunner: opts?.synthRunner, + synthAvailable: opts?.synthAvailable, + notify: opts?.notify, logger: log, onPublishDiagnostics: (uri, diagnostics) => published.push({ uri, diagnostics }), onRefreshCodeLenses: refreshCodeLens, - // Inject a fake watcher so unit tests never start a real chokidar instance; - // capture its onChange so tests can simulate a re-synth deterministically. startAssemblyWatcher: (watchOpts) => { watcherOnChange = watchOpts.onChange; return { @@ -107,66 +104,50 @@ function bucketViolationFixtures() { return { tree, violations }; } -/** - * A readAssembly that reports a violation on the first read and the same tree - * with the violation resolved on every read after, simulating a user fixing it - * and re-synthing. - */ function readAssemblyResolvingAfterFirst(): () => AssemblyReadResult { const { tree, violations } = bucketViolationFixtures(); let call = 0; return (): AssemblyReadResult => { call += 1; - return { - status: 'success', - data: call === 1 ? { warnings: [], tree, violations } : { warnings: [], tree }, - }; + return { status: 'success', data: call === 1 ? { warnings: [], tree, violations } : { warnings: [], tree } }; }; } describe('LSP Server', () => { test('initialize advertises codeLens, definition, and save-sync capabilities', () => { const client = createTestClient(); - const result = client.handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/tmp/test-project' }, }); - expect(result).toMatchObject({ capabilities: { - textDocumentSync: { - openClose: false, - change: 0, - save: { includeText: false }, - }, + textDocumentSync: { openClose: false, change: 0, save: { includeText: false } }, codeLensProvider: { resolveProvider: false }, definitionProvider: true, }, }); }); - test('didSave triggers onSynthRequest for source files', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave triggers auto-synth for non-ignored source files when synthAvailable', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); + // allow microtask queue to flush the void-wrapped promise + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual(['/tmp/test-project']); + expect(synthRunner).toHaveBeenCalledTimes(1); }); - test('didSave does not trigger for ignored files', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave does not trigger synth for ignored files', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); client.handlers.onDidSaveTextDocument({ @@ -175,81 +156,71 @@ describe('LSP Server', () => { client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/cdk.out/tree.json' }, }); + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual([]); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('didSave does not throw without onSynthRequest configured', () => { - const client = createTestClient(); + test('didSave skips synth when synthAvailable is false', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner, synthAvailable: false }); initializeClient(client, { applicationDir: '/tmp/test-project' }); - expect(() => client.handlers.onDidSaveTextDocument({ + client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - })).not.toThrow(); - - // Server should still be responsive after didSave with no callback - expect(() => client.handlers.onShutdown()).not.toThrow(); - }); - - test('shutdown completes without error', () => { - const client = createTestClient(); - initializeClient(client); + }); + await new Promise((r) => setTimeout(r, 0)); - expect(() => client.handlers.onShutdown()).not.toThrow(); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('didSave is ignored after shutdown', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave is ignored after shutdown', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); client.handlers.onShutdown(); - client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual([]); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('onSynthRequest errors are caught gracefully', () => { - const client = createTestClient({ - onSynthRequest: () => { - throw new Error('synth failed'); - }, - }); + test('auto-synth app-failure logs to output panel without throwing', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile err' }); + const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); expect(() => client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, })).not.toThrow(); + await new Promise((r) => setTimeout(r, 0)); + + expect(client.log.error).toHaveBeenCalledWith(expect.stringContaining('compile err')); + }); - // Server should still be responsive after the error + test('shutdown completes without error', () => { + const client = createTestClient(); + initializeClient(client); expect(() => client.handlers.onShutdown()).not.toThrow(); }); test('publishes diagnostics on initialized when assembly has violations', () => { const { tree, violations } = bucketViolationFixtures(); const client = createTestClient({ - readAssembly: () => ({ - status: 'success', - data: { warnings: [], tree, violations }, - }), + readAssembly: () => ({ status: 'success', data: { warnings: [], tree, violations } }), }); - initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(1); expect(client.published[0].uri).toContain('stack.ts'); expect(client.published[0].diagnostics).toHaveLength(1); }); - test('responds to codeLens with resources for the requested file', () => { + test('responds to codeLens with header + resource lenses for the requested file', () => { const stackTs = '/p/lib/stack.ts'; const stackUri = pathToFileURL(stackTs).toString(); - const client = createTestClient({ readAssembly: (): AssemblyReadResult => ({ status: 'success', @@ -270,39 +241,25 @@ describe('LSP Server', () => { }, }), }); - initializeClient(client, { applicationDir: '/p' }); - - const lenses = client.handlers.onCodeLens({ - textDocument: { uri: stackUri }, - }); - - expect(lenses).toHaveLength(3); // 2 header + 1 L1 - expect(lenses[2].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 - expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); + const lenses = client.handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(lenses).toHaveLength(2); // 1 header + 1 L1 + expect(lenses[1].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 + expect(lenses[1].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('publishes nothing when assembly is not-found (pre-synth)', () => { - const client = createTestClient({ - readAssembly: () => ({ status: 'not-found' }), - }); - + const client = createTestClient({ readAssembly: () => ({ status: 'not-found' }) }); initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(0); }); test('clears diagnostics for a violation resolved on a later refresh', () => { const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - initializeClient(client, { applicationDir: '/p' }); expect(client.published).toHaveLength(1); const violationUri = client.published[0].uri; - expect(client.published[0].diagnostics).toHaveLength(1); - - // Simulate a re-synth picked up by the cdk.out watcher. client.triggerWatcher(); - expect(client.published).toHaveLength(2); expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); }); @@ -314,11 +271,7 @@ describe('LSP Server', () => { data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, }), }); - - initializeClient(client, { applicationDir: '/p' }, { - workspace: { codeLens: { refreshSupport: true } }, - }); - + initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); }); @@ -329,25 +282,17 @@ describe('LSP Server', () => { data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, }), }); - initializeClient(client, { applicationDir: '/p' }); - expect(client.refreshCodeLens).not.toHaveBeenCalled(); }); test('a watcher-detected re-synth refreshes diagnostics and lenses', () => { const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - - initializeClient(client, { applicationDir: '/p' }, { - workspace: { codeLens: { refreshSupport: true } }, - }); + initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); expect(client.published).toHaveLength(1); const violationUri = client.published[0].uri; expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); - - // Simulate a re-synth picked up by the cdk.out watcher. client.triggerWatcher(); - expect(client.published).toHaveLength(2); expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); expect(client.refreshCodeLens).toHaveBeenCalledTimes(2); @@ -356,9 +301,7 @@ describe('LSP Server', () => { test('closes the cdk.out watcher on shutdown', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - client.handlers.onShutdown(); - expect(client.watcherClosed).toHaveBeenCalledTimes(1); }); @@ -387,13 +330,11 @@ describe('LSP Server', () => { }), }); initializeClient(client, { applicationDir: dir }); - const uri = pathToFileURL(templateFile).toString(); const position = TextDocument.create(uri, 'json', 0, text).positionAt(text.indexOf('AWS::S3::Bucket')); const target = client.handlers.onDefinition({ textDocument: { uri }, position }); - expect(target?.uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); - expect(target?.range.start).toEqual({ line: 4, character: 2 }); // 1-based (5,3) -> 0-based + expect(target?.range.start).toEqual({ line: 4, character: 2 }); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -402,22 +343,19 @@ describe('LSP Server', () => { test('onDefinition returns undefined for a non-template document', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - const target = client.handlers.onDefinition({ + expect(client.handlers.onDefinition({ textDocument: { uri: pathToFileURL('/p/lib/stack.ts').toString() }, position: { line: 0, character: 0 }, - }); - expect(target).toBeUndefined(); + })).toBeUndefined(); }); test('onDefinition returns undefined (does not throw) for a non-file URI', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - expect( - client.handlers.onDefinition({ - textDocument: { uri: 'untitled:Untitled-1' }, - position: { line: 0, character: 0 }, - }), - ).toBeUndefined(); + expect(client.handlers.onDefinition({ + textDocument: { uri: 'untitled:Untitled-1' }, + position: { line: 0, character: 0 }, + })).toBeUndefined(); }); }); @@ -430,6 +368,12 @@ describe('LSP Server -- executeCommand', () => { const handlers = createLspHandlers({ readAssembly: () => ({ status: 'not-found' }), notify, + startAssemblyWatcher: (o) => { + return { + close: async () => { + }, + }; + }, ...opts, }); handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); @@ -437,26 +381,10 @@ describe('LSP Server -- executeCommand', () => { return { handlers, notify }; } - test('onInitialize advertises executeCommandProvider with supported commands', () => { + test('onInitialize advertises executeCommandProvider with synthNow', () => { const { handlers } = createCommandClient(); const result = handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); - expect(result.capabilities.executeCommandProvider?.commands).toEqual( - expect.arrayContaining([COMMAND_SYNTH_NOW, COMMAND_REFRESH]), - ); - }); - - test('refresh command re-reads assembly without notifying', async () => { - let readCount = 0; - const { handlers, notify } = createCommandClient({ - readAssembly: () => { - readCount++; return { status: 'not-found' }; - }, - }); - const countBefore = readCount; - await handlers.onExecuteCommand({ command: COMMAND_REFRESH }); - expect(readCount).toBeGreaterThan(countBefore); - expect(notify.infoMessages).toHaveLength(0); - expect(notify.errorMessages).toHaveLength(0); + expect(result.capabilities.executeCommandProvider?.commands).toEqual([COMMAND_SYNTH_NOW]); }); test('synthNow without synthAvailable notifies info', async () => { @@ -491,14 +419,46 @@ describe('LSP Server -- executeCommand', () => { const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - // second call fires while first is still in progress await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); resolveFirst(); await first; - // second call hit the latch → notify.info with lock-conflict message expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); - // synth runner was only called once + expect(synthRunner).toHaveBeenCalledTimes(1); + }); + + test('didSave and executeCommand share the same in-flight latch', async () => { + let resolveFirst!: () => void; + const firstSynthDone = new Promise((res) => { + resolveFirst = res; + }); + const synthRunner = jest.fn, []>() + .mockImplementationOnce(() => firstSynthDone.then(() => ({ status: 'success' } as const))) + .mockResolvedValue({ status: 'success' }); + const notify = makeNotifySink(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthAvailable: true, + synthRunner, + notify, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + + // Start a save-triggered synth (first, holds the latch) + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + + // Manual command fires while save-synth is in progress + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + resolveFirst(); + await new Promise((r) => setTimeout(r, 0)); + + // The manual command hit the latch → lock-conflict info message + expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); expect(synthRunner).toHaveBeenCalledTimes(1); }); }); From 2b5a7f2912faaaebf3c0e7f5586f5a824ec022e6 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 14:56:38 -0400 Subject: [PATCH 08/21] feat(cdk-explorer): wire Toolkit, LspIoHost, and synth runner in main.ts --- packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts | 15 +++++++++++++++ packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index f1f09461f..ce6522590 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -1,9 +1,24 @@ +import { Toolkit } from '@aws-cdk/toolkit-lib'; +import { LspIoHost } from './io-host'; import { startServer } from './server'; +import { readCdkConfig } from '../core/cdk-config'; +import { runSynth } from '../core/synth-runner'; try { + const projectDir = process.cwd(); + const config = readCdkConfig(projectDir); + startServer({ readable: process.stdin, writable: process.stdout, + synthAvailable: config.app !== undefined, + // buildSynthRunner is called once after the LSP connection is established, + // so LspIoHost can receive a real connection.console to route Toolkit + // output to the editor's Output panel. + buildSynthRunner: config.app !== undefined ? (console) => { + const toolkit = new Toolkit({ ioHost: new LspIoHost(console) }); + return () => runSynth({ toolkit, projectDir, app: config.app! }); + } : undefined, }); } catch (err) { const e = err as Error; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 050673705..238473ab3 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -20,6 +20,7 @@ import { type InitializeParams, type InitializeResult, type Location, + type RemoteConsole, } from 'vscode-languageserver/node'; /* eslint-disable import/no-relative-packages */ import { codeLensesForFile } from './codelens'; @@ -73,6 +74,13 @@ export interface LspHandlerOptions { export interface LspServerOptions extends LspHandlerOptions { readonly readable: NodeJS.ReadableStream; readonly writable: NodeJS.WritableStream; + /** + * Optional factory called once after the connection is established. + * Receives `connection.console` so the returned runner can route Toolkit + * IO to the editor's Output panel. Takes precedence over `synthRunner` + * when both are provided. + */ + readonly buildSynthRunner?: (console: RemoteConsole) => (() => Promise); } /** Pure handler functions for LSP messages, extracted for direct unit testing. */ @@ -321,7 +329,9 @@ export function startServer(options: LspServerOptions): void { onRefreshCodeLenses: () => { void connection.sendRequest(CodeLensRefreshRequest.type); }, - synthRunner: options.synthRunner, + synthRunner: options.buildSynthRunner + ? options.buildSynthRunner(connection.console) + : options.synthRunner, synthAvailable: options.synthAvailable, notify: { // Route to the Output panel (connection.console) rather than popups. From ee4094423af64b0140ca105428d4132fba09638a Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 15:12:15 -0400 Subject: [PATCH 09/21] feat(cdk-explorer): add auto-synth toggle with Enable/Disable CodeLens Auto-synth starts disabled. Header lenses show: - Off: Synth now + Enable auto-synth - On: Disable auto-synth (save handles synth) Toggle commands trigger a CodeLens refresh so the header updates immediately. The in-flight latch is shared between save and command. --- .../@aws-cdk/cdk-explorer/lib/lsp/codelens.ts | 25 ++++--- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 33 +++++++-- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 12 ++-- .../cdk-explorer/test/lsp/codelens.test.ts | 71 ++++++++++--------- .../cdk-explorer/test/lsp/commands.test.ts | 18 ++++- .../cdk-explorer/test/lsp/server.test.ts | 37 ++++++---- 6 files changed, 127 insertions(+), 69 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index d119d38f8..c20ac9526 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from 'url'; import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node'; -import { COMMAND_SYNTH_NOW } from './commands'; +import { COMMAND_DISABLE_AUTO_SYNTH, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_SYNTH_NOW } from './commands'; import { resourceTarget, type ResourceTarget } from './template-locator'; import type { ConstructNode } from '../core/assembly-reader'; import type { SourceLocation } from '../core/source-resolver'; @@ -14,13 +14,15 @@ export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource'; * sourceLocation matches fileUri, group by line and emit one lens per line * summarising the CFN resources produced there. */ -export function codeLensesForFile(index: ConstructIndex, fileUri: string): CodeLens[] { +export function codeLensesForFile( + index: ConstructIndex, + fileUri: string, + autoSynthEnabled: boolean, +): CodeLens[] { const matches = [...index] .filter((node) => isResourceOnFile(node, fileUri)) .map((node) => ({ line: node.sourceLocation.line, node })); - // Multiple resources can map to one line when an L2 construct fans out - // (e.g. an L2 producing a primary resource + auxiliary resources). const l1Lenses = [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({ range: lineRange(line), command: commandFor(group.map((m) => m.node)), @@ -28,13 +30,16 @@ export function codeLensesForFile(index: ConstructIndex, fileUri: if (l1Lenses.length === 0) return []; - // Prepend a header lens at line 0 so users have a one-click synth surface - // at the top of every CDK source file that already has L1 lenses. const header0: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - return [ - { range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } }, - ...l1Lenses, - ]; + // When auto-synth is off, show "Synth now" + "Enable auto-synth". + // When auto-synth is on, show only "Disable auto-synth" (saves handle synth). + const headerLenses: CodeLens[] = autoSynthEnabled + ? [{ range: header0, command: { title: '⏹ Disable auto-synth', command: COMMAND_DISABLE_AUTO_SYNTH } }] + : [ + { range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } }, + { range: header0, command: { title: '▶ Enable auto-synth', command: COMMAND_ENABLE_AUTO_SYNTH } }, + ]; + return [...headerLenses, ...l1Lenses]; } /** diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index 0f5f6c198..822936316 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -7,9 +7,11 @@ import type { SynthRunResult } from '../core/synth-runner'; * Takes seconds — it re-executes the app. */ export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; +export const COMMAND_ENABLE_AUTO_SYNTH = 'cdk.explorer.enableAutoSynth'; +export const COMMAND_DISABLE_AUTO_SYNTH = 'cdk.explorer.disableAutoSynth'; /** All commands this LSP advertises via `executeCommandProvider`. */ -export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW] as const; +export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH] as const; /** * UI sinks the dispatcher uses to communicate with the user. @@ -35,6 +37,8 @@ export interface CommandHandlerOptions { * no `app` key; the synth command is then unavailable to the user. */ readonly synthAvailable: boolean; + /** Called with the new desired state when the user toggles auto-synth. */ + readonly toggleAutoSynth: (enabled: boolean) => void; /** UI sinks for messages and progress. */ readonly notify: NotifySink; } @@ -53,14 +57,29 @@ export async function executeCommand( _args: unknown[], options: CommandHandlerOptions, ): Promise { - if (command !== COMMAND_SYNTH_NOW) return; + switch (command) { + case COMMAND_ENABLE_AUTO_SYNTH: + options.toggleAutoSynth(true); + return; + + case COMMAND_DISABLE_AUTO_SYNTH: + options.toggleAutoSynth(false); + return; - if (!options.synthAvailable) { - options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); - return; + case COMMAND_SYNTH_NOW: + if (!options.synthAvailable) { + options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); + return; + } + { + const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); + handleSynthResult(result, options.notify); + } + return; + + default: + return; } - const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); - handleSynthResult(result, options.notify); } function handleSynthResult(result: SynthRunResult, notify: NotifySink): void { diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 238473ab3..4d242228d 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -146,6 +146,7 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers let applicationDir: string | undefined; let shutdownRequested = false; let synthInFlight = false; + let autoSynthEnabled = false; // off by default; user enables via the CodeLens toggle let shouldIgnore: (filePath: string) => boolean = () => false; let assemblyWatcher: AssemblyWatcher | undefined; // Latest index from readAssembly, served to CodeLens. Refreshed at startup @@ -268,14 +269,11 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (shutdownRequested) return; const filePath = fileURLToPath(params.textDocument.uri); if (shouldIgnore(filePath)) return; - if (!synthAvailable) return; - // Auto-synth on save: run the same guarded synth as the manual lens. - // Errors are logged to the Output panel; success is silent (the watcher - // picks up the cdk.out change and refreshes diagnostics). + if (!autoSynthEnabled || !synthAvailable) return; void guardedSynth().then((result) => handleSynthOnSave(result, log)); }, onCodeLens(params) { - return codeLensesForFile(cachedIndex, params.textDocument.uri); + return codeLensesForFile(cachedIndex, params.textDocument.uri, autoSynthEnabled); }, onDefinition(params) { // Only synthesized templates link back to source, and only file: URIs are @@ -302,6 +300,10 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers await executeCommand(params.command, params.arguments ?? [], { synth: guardedSynth, synthAvailable, + toggleAutoSynth: (enabled) => { + autoSynthEnabled = enabled; + onRefreshCodeLenses(); + }, notify, }); }, diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts index 03acb357a..c724b18db 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from 'url'; import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import type { ConstructNode } from '../../lib'; import { codeLensesForFile, OPEN_RESOURCE_COMMAND } from '../../lib/lsp/codelens'; -import { COMMAND_SYNTH_NOW } from '../../lib/lsp/commands'; +import { COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH } from '../../lib/lsp/commands'; const FILE = '/p/lib/stack.ts'; const URI = pathToFileURL(FILE).toString(); @@ -26,7 +26,7 @@ interface CommandChoice { describe('codeLensesForFile', () => { test('returns no lenses when tree is empty', () => { - expect(codeLensesForFile(ConstructIndex.fromTree([]), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); }); test('returns no lenses for non-resource wrapper nodes (no logicalId)', () => { @@ -37,7 +37,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, // logicalId/type intentionally omitted })]; - expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); }); test('emits one lens per resource on its source line', () => { @@ -48,13 +48,13 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(2); // 1 header + 1 L1 - expect(lenses[1].range).toEqual({ + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 }, }); - expect(lenses[1].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('groups multiple resources on the same source line into one lens', () => { @@ -81,9 +81,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(2); // 1 header + 1 grouped L1 - expect(lenses[1].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 grouped L1 + expect(lenses[2].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); }); test('emits separate lenses for resources on different lines', () => { @@ -102,9 +102,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(3); // 1 header + 2 L1 - expect(lenses.slice(1).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(4); // 2 header + 2 L1 + expect(lenses.slice(2).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); }); test('filters out resources from other files', () => { @@ -124,15 +124,15 @@ describe('codeLensesForFile', () => { ]; const index = ConstructIndex.fromTree(tree); - // Each query returns the header lens plus only the resource defined in + // Each query returns 2 header lenses plus only the resource defined in // that file, which proves the URI filter selects by file rather than // returning everything for any query. - const onThisFile = codeLensesForFile(index, URI); - expect(onThisFile).toHaveLength(2); // 1 header + 1 L1 - expect(onThisFile[1].command?.title).toBe('Creates AWS::S3::Bucket'); - const onOtherFile = codeLensesForFile(index, OTHER_URI); - expect(onOtherFile).toHaveLength(2); // 1 header + 1 L1 - expect(onOtherFile[1].command?.title).toBe('Creates AWS::SQS::Queue'); + const onThisFile = codeLensesForFile(index, URI, false); + expect(onThisFile).toHaveLength(3); // 2 header + 1 L1 + expect(onThisFile[2].command?.title).toBe('Creates AWS::S3::Bucket'); + const onOtherFile = codeLensesForFile(index, OTHER_URI, false); + expect(onOtherFile).toHaveLength(3); // 2 header + 1 L1 + expect(onOtherFile[2].command?.title).toBe('Creates AWS::SQS::Queue'); }); test('walks descendants — finds resources nested under wrappers', () => { @@ -157,9 +157,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(2); // 1 header + 1 - expect(lenses[1].command?.title).toContain('AWS::S3::Bucket'); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 + expect(lenses[2].command?.title).toContain('AWS::S3::Bucket'); }); test('omits resources without sourceLocation (non-TS apps)', () => { @@ -170,7 +170,7 @@ describe('codeLensesForFile', () => { // sourceLocation omitted — non-TS app })]; - expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); }); test('a single resource with a resolvable template gets a clickable openResource command', () => { @@ -186,7 +186,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; expect(choices).toHaveLength(1); @@ -214,7 +214,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; const uri = pathToFileURL(templateFile).toString(); expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; @@ -237,7 +237,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(''); expect(lens.command?.arguments).toBeUndefined(); }); @@ -255,7 +255,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[1]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(''); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -270,13 +270,20 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses[0].range.start.line).toBe(0); - expect(lenses[0].command?.command).toBe(COMMAND_SYNTH_NOW); + // auto-synth off: Synth now + Enable auto-synth + L1 + const lensesOff = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lensesOff[0].range.start.line).toBe(0); + expect(lensesOff[0].command?.command).toBe(COMMAND_SYNTH_NOW); + expect(lensesOff[1].command?.command).toBe(COMMAND_ENABLE_AUTO_SYNTH); + + // auto-synth on: Disable auto-synth + L1 (no Synth now) + const lensesOn = codeLensesForFile(ConstructIndex.fromTree(tree), URI, true); + expect(lensesOn).toHaveLength(2); // 1 header + 1 L1 + expect(lensesOn[0].command?.command).toBe(COMMAND_DISABLE_AUTO_SYNTH); }); test('no header lenses on files with no L1 lenses', () => { // File has no CDK resources → no lenses at all, including no header lenses - expect(codeLensesForFile(ConstructIndex.fromTree([]), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts index 4f6b53c29..47d64cd1e 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -1,6 +1,8 @@ import type { SynthRunResult } from '../../lib/core/synth-runner'; import { COMMAND_SYNTH_NOW, + COMMAND_ENABLE_AUTO_SYNTH, + COMMAND_DISABLE_AUTO_SYNTH, executeCommand, type CommandHandlerOptions, type NotifySink, @@ -28,17 +30,31 @@ function makeOptions(overrides: Partial = {}): { options: CommandHandlerOptions; notify: CapturedNotify; synth: jest.Mock; + toggleAutoSynth: jest.Mock; } { const notify = createNotify(); const synth = jest.fn(async () => ({ status: 'success' } as SynthRunResult)); + const toggleAutoSynth = jest.fn(); return { notify, synth, - options: { synth, synthAvailable: true, notify, ...overrides }, + toggleAutoSynth, + options: { synth, synthAvailable: true, toggleAutoSynth, notify, ...overrides }, }; } describe('executeCommand', () => { + test('enableAutoSynth calls toggleAutoSynth(true)', async () => { + const { options, toggleAutoSynth } = makeOptions(); + await executeCommand(COMMAND_ENABLE_AUTO_SYNTH, [], options); + expect(toggleAutoSynth).toHaveBeenCalledWith(true); + }); + + test('disableAutoSynth calls toggleAutoSynth(false)', async () => { + const { options, toggleAutoSynth } = makeOptions(); + await executeCommand(COMMAND_DISABLE_AUTO_SYNTH, [], options); + expect(toggleAutoSynth).toHaveBeenCalledWith(false); + }); test('synthNow shows info and skips synth when unavailable', async () => { const { options, notify, synth } = makeOptions({ synthAvailable: false }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 4ec7c0308..a4e219a49 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -131,43 +131,47 @@ describe('LSP Server', () => { }); }); - test('didSave triggers auto-synth for non-ignored source files when synthAvailable', async () => { + test('didSave triggers auto-synth for non-ignored source files when auto-synth is enabled', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + // Enable auto-synth via the toggle command first + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); - // allow microtask queue to flush the void-wrapped promise await new Promise((r) => setTimeout(r, 0)); expect(synthRunner).toHaveBeenCalledTimes(1); }); - test('didSave does not trigger synth for ignored files', async () => { + test('didSave does not trigger synth when auto-synth is disabled (default)', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + // auto-synth is off by default client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/node_modules/foo/index.ts' }, - }); - client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/cdk.out/tree.json' }, + textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); await new Promise((r) => setTimeout(r, 0)); expect(synthRunner).not.toHaveBeenCalled(); }); - test('didSave skips synth when synthAvailable is false', async () => { + test('didSave does not trigger synth for ignored files', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner, synthAvailable: false }); + const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, + textDocument: { uri: 'file:///tmp/test-project/node_modules/foo/index.ts' }, + }); + client.handlers.onDidSaveTextDocument({ + textDocument: { uri: 'file:///tmp/test-project/cdk.out/tree.json' }, }); await new Promise((r) => setTimeout(r, 0)); @@ -178,6 +182,7 @@ describe('LSP Server', () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); client.handlers.onShutdown(); client.handlers.onDidSaveTextDocument({ @@ -192,6 +197,7 @@ describe('LSP Server', () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile err' }); const client = createTestClient({ synthRunner, synthAvailable: true }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); expect(() => client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, @@ -243,9 +249,9 @@ describe('LSP Server', () => { }); initializeClient(client, { applicationDir: '/p' }); const lenses = client.handlers.onCodeLens({ textDocument: { uri: stackUri } }); - expect(lenses).toHaveLength(2); // 1 header + 1 L1 - expect(lenses[1].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 - expect(lenses[1].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('publishes nothing when assembly is not-found (pre-synth)', () => { @@ -384,7 +390,7 @@ describe('LSP Server -- executeCommand', () => { test('onInitialize advertises executeCommandProvider with synthNow', () => { const { handlers } = createCommandClient(); const result = handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); - expect(result.capabilities.executeCommandProvider?.commands).toEqual([COMMAND_SYNTH_NOW]); + expect(result.capabilities.executeCommandProvider?.commands).toEqual(expect.arrayContaining([COMMAND_SYNTH_NOW])); }); test('synthNow without synthAvailable notifies info', async () => { @@ -449,6 +455,9 @@ describe('LSP Server -- executeCommand', () => { handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); handlers.onInitialized(); + // Enable auto-synth so didSave triggers a synth + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + // Start a save-triggered synth (first, holds the latch) handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); From 9ab7df6eee9dfb3437e04871ddec3321e9b45f4c Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 15:18:58 -0400 Subject: [PATCH 10/21] fix(cdk-explorer): gate toggle refresh on codeLensRefreshSupport, add missing tests - Fix: onRefreshCodeLenses in startServer now gates on codeLensRefreshSupported (toggle previously sent refresh requests to clients that didn't advertise support) - Add test/lsp/io-host.test.ts: 5 tests covering level routing and requestResponse - Add server-level toggle tests: round-trip wiring, refresh fires, synthAvailable guard, save-path lock-conflict silence, save-path error logging --- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 14 +- .../cdk-explorer/test/lsp/io-host.test.ts | 60 ++++++++ .../cdk-explorer/test/lsp/server.test.ts | 128 ++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 4d242228d..e2d589cd1 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -322,6 +322,9 @@ export function startServer(options: LspServerOptions): void { new StreamMessageWriter(options.writable), ); + // Captured from onInitialize; used to gate CodeLens refresh requests. + let codeLensRefreshSupported = false; + const handlers = createLspHandlers({ readAssembly: options.readAssembly, logger: connection.console, @@ -329,7 +332,11 @@ export function startServer(options: LspServerOptions): void { void connection.sendDiagnostics({ uri, diagnostics }); }, onRefreshCodeLenses: () => { - void connection.sendRequest(CodeLensRefreshRequest.type); + // codeLensRefreshSupported is captured from onInitialize; gate here so + // the toggle and the watcher both respect the client's capability. + if (codeLensRefreshSupported) { + void connection.sendRequest(CodeLensRefreshRequest.type); + } }, synthRunner: options.buildSynthRunner ? options.buildSynthRunner(connection.console) @@ -357,7 +364,10 @@ export function startServer(options: LspServerOptions): void { }, }); - connection.onInitialize((params) => handlers.onInitialize(params)); + connection.onInitialize((params) => { + codeLensRefreshSupported = params.capabilities.workspace?.codeLens?.refreshSupport ?? false; + return handlers.onInitialize(params); + }); connection.onInitialized(() => handlers.onInitialized()); connection.onDidSaveTextDocument((params) => handlers.onDidSaveTextDocument(params)); connection.onCodeLens((params) => handlers.onCodeLens(params)); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts new file mode 100644 index 000000000..437864eee --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts @@ -0,0 +1,60 @@ +import type { IoMessage, IoRequest } from '@aws-cdk/toolkit-lib'; +import type { RemoteConsole } from 'vscode-languageserver/node'; +import { LspIoHost } from '../../lib/lsp/io-host'; + +function makeConsole(): jest.Mocked> { + return { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; +} + +function msg(level: string, message: string): IoMessage { + return { level, message, action: 'test', code: 'TEST_001', data: undefined, time: new Date() } as unknown as IoMessage; +} + +describe('LspIoHost', () => { + test('routes error level to console.error', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('error', 'something broke')); + expect(console.error).toHaveBeenCalledWith('something broke'); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); + }); + + test('routes warn level to console.warn', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('warn', 'a warning')); + expect(console.warn).toHaveBeenCalledWith('a warning'); + }); + + test('routes info and default levels to console.info', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('info', 'an info')); + await host.notify(msg('result', 'a result')); + expect(console.info).toHaveBeenCalledTimes(2); + }); + + test('suppresses debug and trace levels', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('debug', 'verbose')); + await host.notify(msg('trace', 'very verbose')); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); + }); + + test('requestResponse returns defaultResponse without blocking', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + const request = { + ...msg('info', 'MFA token?'), + defaultResponse: 'default-token', + } as unknown as IoRequest; + + const result = await host.requestResponse(request); + + expect(result).toBe('default-token'); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index a4e219a49..995ec63c2 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -471,3 +471,131 @@ describe('LSP Server -- executeCommand', () => { expect(synthRunner).toHaveBeenCalledTimes(1); }); }); + +describe('LSP Server -- auto-synth toggle', () => { + function createToggleClient(synthAvailable = true) { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const refreshCodeLens = jest.fn(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthAvailable, + synthRunner, + onRefreshCodeLenses: refreshCodeLens, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + return { handlers, synthRunner, refreshCodeLens }; + } + + const stackTs = '/p/lib/stack.ts'; + const stackUri = pathToFileURL(stackTs).toString(); + const treeWithResource = [{ + path: 'Stack1', + id: 'Stack1', + children: [{ + path: 'Stack1/R', + id: 'R', + logicalId: 'R1', + type: 'AWS::S3::Bucket', + sourceLocation: { file: stackTs, line: 5, column: 0 }, + children: [], + }], + }]; + + test('toggle round-trip: onCodeLens reflects new state after enableAutoSynth', async () => { + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'success', data: { warnings: [], tree: treeWithResource } }), + synthAvailable: true, + synthRunner: jest.fn, []>().mockResolvedValue({ status: 'success' }), + onRefreshCodeLenses: jest.fn(), + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + + // Before toggle: 2 header lenses (Synth now + Enable auto-synth) + const before = handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(before[0].command?.title).toBe('↻ Synth now'); + expect(before[1].command?.title).toBe('▶ Enable auto-synth'); + + // Toggle on + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + // After toggle: 1 header lens (Disable auto-synth) + const after = handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(after[0].command?.title).toBe('⏹ Disable auto-synth'); + expect(after).toHaveLength(2); // 1 header + 1 L1 + }); + + test('toggle fires onRefreshCodeLenses', async () => { + const { handlers, refreshCodeLens } = createToggleClient(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + expect(refreshCodeLens).toHaveBeenCalledTimes(1); + await handlers.onExecuteCommand({ command: 'cdk.explorer.disableAutoSynth' }); + expect(refreshCodeLens).toHaveBeenCalledTimes(2); + }); + + test('didSave does not trigger synth when synthAvailable=false even if auto-synth enabled', async () => { + const { handlers, synthRunner } = createToggleClient(false); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + expect(synthRunner).not.toHaveBeenCalled(); + }); + + test('save-path lock-conflict is silent (no log output)', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'lock-conflict' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthAvailable: true, + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + test('save-path error result is logged', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'error', message: 'unexpected failure' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthAvailable: true, + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('unexpected failure')); + }); +}); From 91f14051bca35674092003863f439dec480a0cd3 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 15:19:35 -0400 Subject: [PATCH 11/21] docs(cdk-explorer): clarify NonInteractiveIoHost exclusion and lock-code fragility --- packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts | 3 +++ packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index a1ca21654..0a1016187 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -60,6 +60,9 @@ function classify(err: unknown): SynthRunResult { if (ToolkitError.isToolkitError(err)) { // ToolkitError stores its discriminating code in `name` (the constructor's // first arg overrides the default Error name), not in a `code` property. + // These literals come from RWLock (toolkit-lib/lib/api/rwlock.ts). No + // named constants are exported; if upstream renames them, classification + // falls through to 'error' silently -- update here and in tests if so. if (err.name === 'ConcurrentWriteLock' || err.name === 'ConcurrentReadLock') { return { status: 'lock-conflict' }; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts index f721b61fb..13fbfc94a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -5,6 +5,11 @@ import type { RemoteConsole } from 'vscode-languageserver/node'; * IoHost for the LSP. Routes Toolkit messages into the editor's Output * channel via the LSP connection's console. * + * We do NOT reuse `NonInteractiveIoHost` from toolkit-lib even though its + * `requestResponse` is identical: that class writes to `process.stdout` / + * `process.stderr`, which are the JSON-RPC transport for this process. + * Writing Toolkit output there would corrupt the protocol stream. + * * The LSP cannot prompt the user synchronously through `connection.console`, * so `requestResponse` returns each message's `defaultResponse`. This is * acceptable for `synth`, which has no interactive prompts. From 020a03c592e72fa1b088b0000f2a042a066afd88 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 15:20:14 -0400 Subject: [PATCH 12/21] fix(cdk-explorer): catch unexpected guardedSynth rejection; document dispose-failure session poison --- packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts | 4 ++++ packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 0a1016187..9608b167b 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -50,6 +50,10 @@ export async function runSynth(options: SynthRunnerOptions): Promise handleSynthOnSave(result, log)); + void guardedSynth() + .then((result) => handleSynthOnSave(result, log)) + .catch((err: unknown) => log.error(`Auto-synth threw unexpectedly: ${(err as Error).message}`)); }, onCodeLens(params) { return codeLensesForFile(cachedIndex, params.textDocument.uri, autoSynthEnabled); From 8a26f7e405d57e08f08bd801f258d034402e3337 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Tue, 16 Jun 2026 15:21:24 -0400 Subject: [PATCH 13/21] docs(cdk-explorer): fix stale comments and JSDoc after code review - io-host.ts: clarify MFA prompt edge case in requestResponse comment - codelens.ts: update codeLensesForFile JSDoc to document header lenses and the new autoSynthEnabled parameter - commands.ts: move behavioral doc off the string constant to its right place; give each command a concise one-line doc instead --- packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts | 14 +++++++++++--- packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 8 ++++---- packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts | 7 +++++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index c20ac9526..5a22bda48 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -10,9 +10,17 @@ import type { SourceLocation } from '../core/source-resolver'; export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource'; /** - * Build CodeLens entries for a single source file. For every construct whose - * sourceLocation matches fileUri, group by line and emit one lens per line - * summarising the CFN resources produced there. + * Build CodeLens entries for a single source file. Returns an empty array if + * no CDK resources in the index map to `fileUri`. + * + * When resources are found, two header lenses are prepended at line 0: + * - `autoSynthEnabled = false`: "↻ Synth now" + "▶ Enable auto-synth" + * - `autoSynthEnabled = true`: "⏹ Disable auto-synth" (saves trigger synth) + * + * The remaining lenses are one per source line, each summarising the CFN + * resources produced there (multiple L2 fan-out resources are grouped). + * + * @param autoSynthEnabled - current toggle state; controls which header lenses appear */ export function codeLensesForFile( index: ConstructIndex, diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index 822936316..82fbbda8a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -1,13 +1,13 @@ import type { SynthRunResult } from '../core/synth-runner'; /** - * Runs the user's CDK app (`bin/app.ts`) via `Toolkit.synth()`, writes new - * CloudFormation templates to `cdk.out`, then the watcher picks up the change - * and republishes diagnostics automatically. Use when source code has changed. - * Takes seconds — it re-executes the app. + * Trigger a one-shot synth of the user's CDK app. Only shown when + * auto-synth is disabled (saves handle synth when it is enabled). */ export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; +/** Enable auto-synth-on-save. Replaces "Synth now" in the header lens. */ export const COMMAND_ENABLE_AUTO_SYNTH = 'cdk.explorer.enableAutoSynth'; +/** Disable auto-synth-on-save. Restores the "Synth now" header lens. */ export const COMMAND_DISABLE_AUTO_SYNTH = 'cdk.explorer.disableAutoSynth'; /** All commands this LSP advertises via `executeCommandProvider`. */ diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts index 13fbfc94a..ee6638792 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -11,8 +11,11 @@ import type { RemoteConsole } from 'vscode-languageserver/node'; * Writing Toolkit output there would corrupt the protocol stream. * * The LSP cannot prompt the user synchronously through `connection.console`, - * so `requestResponse` returns each message's `defaultResponse`. This is - * acceptable for `synth`, which has no interactive prompts. + * so `requestResponse` returns each message's `defaultResponse`. For synth, + * the only reachable interactive prompt is an MFA token (when the app has + * context lookups, no cached `cdk.context.json`, and an MFA-protected profile). + * Returning the default causes an auth failure, surfaced as `app-failure` with + * a clear message. All other prompts are on deploy/destroy paths we don't call. */ export class LspIoHost implements IIoHost { public constructor(private readonly console: RemoteConsole) { From bd8a9c0ed80b079a0c7e1ed98561d2d9bb81fcf8 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Wed, 17 Jun 2026 14:33:57 -0400 Subject: [PATCH 14/21] refactor(cdk-explorer): simplify synth option wiring Address review feedback (D1, D2) on the synth-triggering option types. - Remove the redundant synthAvailable flag and derive availability from whether a synthRunner was injected. main.ts builds a runner only when cdk.json has an app, so the flag and the runner could never diverge. - Make LspServerOptions a standalone type instead of extending Omit. buildSynthRunner is now the only synth input at the server boundary and synthRunner the only one at the core. The factory-to-runner conversion stays in startServer, where connection.console first exists, so no type carries both and the dead dual-injection path (and its precedence rule) is gone. - Drop the unused readAssembly forward in startServer; no caller sets it and createLspHandlers already defaults it. No behavior change. Build green, 135 tests pass. --- .../@aws-cdk/cdk-explorer/lib/lsp/main.ts | 1 - .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 26 ++++++------ .../cdk-explorer/test/lsp/server.test.ts | 40 +++++++++---------- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index ce6522590..547bff587 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -11,7 +11,6 @@ try { startServer({ readable: process.stdin, writable: process.stdout, - synthAvailable: config.app !== undefined, // buildSynthRunner is called once after the LSP connection is established, // so LspIoHost can receive a real connection.console to route Toolkit // output to the editor's Output panel. diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 7f8434e3f..118c8f9ac 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -63,22 +63,24 @@ export interface LspHandlerOptions { * overridden in tests to drive refreshes deterministically. */ readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; - /** Runs a synth and returns its typed outcome. Injected by main.ts; omitted in tests that don't exercise synth. */ + /** + * Runs a synth and returns its typed outcome. Injected by startServer (built + * from `buildSynthRunner`); omitted in tests that don't exercise synth. Its + * presence is the single source of truth for whether synth is available: + * `cdk.json` having an `app` is exactly what causes a runner to be built. + */ readonly synthRunner?: () => Promise; - /** Whether cdk.json exists and has a valid `app` key. Controls whether synthNow is available. */ - readonly synthAvailable?: boolean; /** User-facing notification sink. Injected by startServer; omitted in tests. */ readonly notify?: NotifySink; } -export interface LspServerOptions extends LspHandlerOptions { +export interface LspServerOptions { readonly readable: NodeJS.ReadableStream; readonly writable: NodeJS.WritableStream; /** - * Optional factory called once after the connection is established. - * Receives `connection.console` so the returned runner can route Toolkit - * IO to the editor's Output panel. Takes precedence over `synthRunner` - * when both are provided. + * Factory for the synth runner, invoked once in `startServer` when + * `connection.console` first exists. The console-free core + * (`createLspHandlers`) consumes the built `synthRunner` it returns. */ readonly buildSynthRunner?: (console: RemoteConsole) => (() => Promise); } @@ -133,8 +135,8 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers const onRefreshCodeLenses = options.onRefreshCodeLenses ?? (() => { }); const startWatcher = options.startAssemblyWatcher ?? defaultStartAssemblyWatcher; - const synthAvailable = options.synthAvailable ?? false; const synthRunner = options.synthRunner; + const synthAvailable = synthRunner !== undefined; const notify = options.notify ?? { info: () => { }, @@ -328,7 +330,6 @@ export function startServer(options: LspServerOptions): void { let codeLensRefreshSupported = false; const handlers = createLspHandlers({ - readAssembly: options.readAssembly, logger: connection.console, onPublishDiagnostics: (uri, diagnostics) => { void connection.sendDiagnostics({ uri, diagnostics }); @@ -340,10 +341,7 @@ export function startServer(options: LspServerOptions): void { void connection.sendRequest(CodeLensRefreshRequest.type); } }, - synthRunner: options.buildSynthRunner - ? options.buildSynthRunner(connection.console) - : options.synthRunner, - synthAvailable: options.synthAvailable, + synthRunner: options.buildSynthRunner?.(connection.console), notify: { // Route to the Output panel (connection.console) rather than popups. // showMessage creates a dismissable toast that interrupts the user's diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 995ec63c2..d02c63846 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -39,7 +39,6 @@ function createTestClient(opts?: Partial): CapturedClient { const handlers = createLspHandlers({ readAssembly: opts?.readAssembly ?? (() => ({ status: 'not-found' })), synthRunner: opts?.synthRunner, - synthAvailable: opts?.synthAvailable, notify: opts?.notify, logger: log, onPublishDiagnostics: (uri, diagnostics) => published.push({ uri, diagnostics }), @@ -133,7 +132,7 @@ describe('LSP Server', () => { test('didSave triggers auto-synth for non-ignored source files when auto-synth is enabled', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner, synthAvailable: true }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); // Enable auto-synth via the toggle command first @@ -149,7 +148,7 @@ describe('LSP Server', () => { test('didSave does not trigger synth when auto-synth is disabled (default)', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner, synthAvailable: true }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); // auto-synth is off by default @@ -163,7 +162,7 @@ describe('LSP Server', () => { test('didSave does not trigger synth for ignored files', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner, synthAvailable: true }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); @@ -180,7 +179,7 @@ describe('LSP Server', () => { test('didSave is ignored after shutdown', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner, synthAvailable: true }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); @@ -195,7 +194,7 @@ describe('LSP Server', () => { test('auto-synth app-failure logs to output panel without throwing', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile err' }); - const client = createTestClient({ synthRunner, synthAvailable: true }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); @@ -394,14 +393,14 @@ describe('LSP Server -- executeCommand', () => { }); test('synthNow without synthAvailable notifies info', async () => { - const { handlers, notify } = createCommandClient({ synthAvailable: false }); + const { handlers, notify } = createCommandClient(); await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); expect(notify.infoMessages.some((m) => m.includes('unavailable'))).toBe(true); }); test('synthNow with success is silent', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + const { handlers, notify } = createCommandClient({ synthRunner }); await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); expect(notify.infoMessages).toHaveLength(0); expect(notify.errorMessages).toHaveLength(0); @@ -409,7 +408,7 @@ describe('LSP Server -- executeCommand', () => { test('synthNow with app-failure notifies error', async () => { const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile error' }); - const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + const { handlers, notify } = createCommandClient({ synthRunner }); await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); expect(notify.errorMessages.some((m) => m.includes('compile error'))).toBe(true); }); @@ -422,7 +421,7 @@ describe('LSP Server -- executeCommand', () => { const synthRunner = jest.fn, []>() .mockImplementationOnce(() => firstSynthDone.then(() => ({ status: 'success' } as const))) .mockResolvedValue({ status: 'success' }); - const { handlers, notify } = createCommandClient({ synthAvailable: true, synthRunner }); + const { handlers, notify } = createCommandClient({ synthRunner }); const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); @@ -444,7 +443,6 @@ describe('LSP Server -- executeCommand', () => { const notify = makeNotifySink(); const handlers = createLspHandlers({ readAssembly: () => ({ status: 'not-found' }), - synthAvailable: true, synthRunner, notify, startAssemblyWatcher: () => ({ @@ -474,12 +472,15 @@ describe('LSP Server -- executeCommand', () => { describe('LSP Server -- auto-synth toggle', () => { function createToggleClient(synthAvailable = true) { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const synthRunner = synthAvailable + ? jest.fn, []>().mockResolvedValue({ status: 'success' }) + : undefined; + const log = { warn: jest.fn(), error: jest.fn() }; const refreshCodeLens = jest.fn(); const handlers = createLspHandlers({ readAssembly: () => ({ status: 'not-found' }), - synthAvailable, synthRunner, + logger: log, onRefreshCodeLenses: refreshCodeLens, startAssemblyWatcher: () => ({ close: async () => { @@ -488,7 +489,7 @@ describe('LSP Server -- auto-synth toggle', () => { }); handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); handlers.onInitialized(); - return { handlers, synthRunner, refreshCodeLens }; + return { handlers, synthRunner, refreshCodeLens, log }; } const stackTs = '/p/lib/stack.ts'; @@ -509,7 +510,6 @@ describe('LSP Server -- auto-synth toggle', () => { test('toggle round-trip: onCodeLens reflects new state after enableAutoSynth', async () => { const handlers = createLspHandlers({ readAssembly: () => ({ status: 'success', data: { warnings: [], tree: treeWithResource } }), - synthAvailable: true, synthRunner: jest.fn, []>().mockResolvedValue({ status: 'success' }), onRefreshCodeLenses: jest.fn(), startAssemblyWatcher: () => ({ @@ -542,14 +542,16 @@ describe('LSP Server -- auto-synth toggle', () => { expect(refreshCodeLens).toHaveBeenCalledTimes(2); }); - test('didSave does not trigger synth when synthAvailable=false even if auto-synth enabled', async () => { - const { handlers, synthRunner } = createToggleClient(false); + test('didSave does not trigger synth when synth is unavailable (no runner) even if auto-synth enabled', async () => { + const { handlers, log } = createToggleClient(false); await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); await new Promise((r) => setTimeout(r, 0)); - expect(synthRunner).not.toHaveBeenCalled(); + // No runner means the availability gate returns early; we never reach + // guardedSynth's "No synth runner configured" path, so nothing is logged. + expect(log.error).not.toHaveBeenCalled(); }); test('save-path lock-conflict is silent (no log output)', async () => { @@ -557,7 +559,6 @@ describe('LSP Server -- auto-synth toggle', () => { const log = { warn: jest.fn(), error: jest.fn() }; const handlers = createLspHandlers({ readAssembly: () => ({ status: 'not-found' }), - synthAvailable: true, synthRunner, logger: log, startAssemblyWatcher: () => ({ @@ -581,7 +582,6 @@ describe('LSP Server -- auto-synth toggle', () => { const log = { warn: jest.fn(), error: jest.fn() }; const handlers = createLspHandlers({ readAssembly: () => ({ status: 'not-found' }), - synthAvailable: true, synthRunner, logger: log, startAssemblyWatcher: () => ({ From 418a3734e5427b90e89bc5a7364416d91d602aa7 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Wed, 17 Jun 2026 14:43:04 -0400 Subject: [PATCH 15/21] refactor(cdk-explorer): name the synth runner factory; clarify suppress wording - Rename buildSynthRunner to synthRunnerFactory and extract a named SynthRunnerFactory type, so the factory is explicit in the API (addresses the 'call it a factory' review comment). - Reword the in-flight latch comment and test name: overlapping synths are suppressed (dropped, not queued), and the next save picks it up. No behavior change. Build green, 135 tests pass. --- packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts | 8 ++++---- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 17 ++++++++++------- .../cdk-explorer/test/lsp/server.test.ts | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index 547bff587..ea97afec9 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -11,10 +11,10 @@ try { startServer({ readable: process.stdin, writable: process.stdout, - // buildSynthRunner is called once after the LSP connection is established, - // so LspIoHost can receive a real connection.console to route Toolkit - // output to the editor's Output panel. - buildSynthRunner: config.app !== undefined ? (console) => { + // synthRunnerFactory: startServer invokes it once, after the LSP connection + // exists, so the runner it returns can route Toolkit output to the editor's + // Output panel via connection.console. Built only when cdk.json has an `app`. + synthRunnerFactory: config.app !== undefined ? (console) => { const toolkit = new Toolkit({ ioHost: new LspIoHost(console) }); return () => runSynth({ toolkit, projectDir, app: config.app! }); } : undefined, diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 118c8f9ac..f2a63bf48 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -65,7 +65,7 @@ export interface LspHandlerOptions { readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; /** * Runs a synth and returns its typed outcome. Injected by startServer (built - * from `buildSynthRunner`); omitted in tests that don't exercise synth. Its + * from `synthRunnerFactory`); omitted in tests that don't exercise synth. Its * presence is the single source of truth for whether synth is available: * `cdk.json` having an `app` is exactly what causes a runner to be built. */ @@ -74,6 +74,9 @@ export interface LspHandlerOptions { readonly notify?: NotifySink; } +/** Builds the synth runner once `connection.console` is available (in startServer). */ +export type SynthRunnerFactory = (console: RemoteConsole) => (() => Promise); + export interface LspServerOptions { readonly readable: NodeJS.ReadableStream; readonly writable: NodeJS.WritableStream; @@ -82,7 +85,7 @@ export interface LspServerOptions { * `connection.console` first exists. The console-free core * (`createLspHandlers`) consumes the built `synthRunner` it returns. */ - readonly buildSynthRunner?: (console: RemoteConsole) => (() => Promise); + readonly synthRunnerFactory?: SynthRunnerFactory; } /** Pure handler functions for LSP messages, extracted for direct unit testing. */ @@ -206,10 +209,10 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers } // Shared synth invocation used by both the manual CodeLens command and - // auto-synth-on-save. The in-flight latch prevents concurrent synths from - // the same LSP instance. A second call while the first is running returns - // lock-conflict immediately (the Toolkit's RWLock would do the same, but - // this short-circuits before any setup work). + // auto-synth-on-save. The in-flight latch suppresses overlapping synths from + // the same LSP instance: a second call while the first is running is dropped + // (not queued) and returns lock-conflict immediately. The Toolkit's RWLock + // would reject it anyway, but this short-circuits before any setup work. async function guardedSynth(): Promise { if (synthInFlight) return { status: 'lock-conflict' }; synthInFlight = true; @@ -341,7 +344,7 @@ export function startServer(options: LspServerOptions): void { void connection.sendRequest(CodeLensRefreshRequest.type); } }, - synthRunner: options.buildSynthRunner?.(connection.console), + synthRunner: options.synthRunnerFactory?.(connection.console), notify: { // Route to the Output panel (connection.console) rather than popups. // showMessage creates a dismissable toast that interrupts the user's diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index d02c63846..a706f9105 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -413,7 +413,7 @@ describe('LSP Server -- executeCommand', () => { expect(notify.errorMessages.some((m) => m.includes('compile error'))).toBe(true); }); - test('synthNow in-flight latch: second call coalesces as lock-conflict', async () => { + test('synthNow in-flight latch: second concurrent call is suppressed as lock-conflict', async () => { let resolveFirst!: () => void; const firstSynthDone = new Promise((res) => { resolveFirst = res; From b23b1b4a2694525a1c36aa5c24ef7144a74cebd0 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Wed, 17 Jun 2026 15:03:59 -0400 Subject: [PATCH 16/21] refactor(cdk-explorer): trim over-explained dispose-failure comment The dispose() catch only fires on a rare fs error deleting the read-lock file; the long 'permanent session poison' note over-explained it. Trimmed to two lines. Kept the try/catch so runSynth still always resolves to a typed SynthRunResult. --- .../@aws-cdk/cdk-explorer/lib/core/synth-runner.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 9608b167b..4877f6896 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -47,13 +47,9 @@ export async function runSynth(options: SynthRunnerOptions): Promise Date: Wed, 17 Jun 2026 15:29:32 -0400 Subject: [PATCH 17/21] feat(toolkit-lib): add LockError subclass and ToolkitError.isLockError RWLock now throws a symbol-tagged LockError (mirrors AuthenticationError and AssemblyError) instead of a plain ToolkitError, so consumers can detect lock conflicts via ToolkitError.isLockError() rather than matching error-name literals. cdk-explorer synth-runner uses isLockError() to classify lock-conflict and folds its two duplicate error returns into one (review comments). --- .../cdk-explorer/lib/core/synth-runner.ts | 18 ++++--------- .../test/core/synth-runner.test.ts | 6 ++--- .../@aws-cdk/toolkit-lib/lib/api/rwlock.ts | 6 ++--- .../toolkit-lib/lib/toolkit/toolkit-error.ts | 25 +++++++++++++++++++ .../test/toolkit/toolkit-error.test.ts | 12 ++++++++- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 4877f6896..13da2c0f2 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -57,19 +57,11 @@ export async function runSynth(options: SynthRunnerOptions): Promise { test('classifies ConcurrentWriteLock as lock-conflict', async () => { const { toolkit } = makeToolkit({ - synthThrow: new ToolkitError('ConcurrentWriteLock', 'another CLI synthing'), + synthThrow: new LockError('ConcurrentWriteLock', 'another CLI synthing'), }); const result = await run(toolkit); @@ -69,7 +69,7 @@ describe('runSynth', () => { test('classifies ConcurrentReadLock as lock-conflict', async () => { const { toolkit } = makeToolkit({ - synthThrow: new ToolkitError('ConcurrentReadLock', 'another CLI reading'), + synthThrow: new LockError('ConcurrentReadLock', 'another CLI reading'), }); const result = await run(toolkit); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts index f91f54315..cfbb18b5d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { ToolkitError } from '../toolkit/toolkit-error'; +import { LockError } from '../toolkit/toolkit-error'; /** * A single-writer/multi-reader lock on a directory @@ -34,7 +34,7 @@ export class RWLock { const readers = await this._currentReaders(); if (readers.length > 0) { - throw new ToolkitError('ConcurrentReadLock', `Other CLIs (PID=${readers}) are currently reading from ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); + throw new LockError('ConcurrentReadLock', `Other CLIs (PID=${readers}) are currently reading from ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); } await writeFileAtomic(this.writerFile, this.pidString); @@ -101,7 +101,7 @@ export class RWLock { private async assertNoOtherWriters() { const writer = await this._currentWriter(); if (writer) { - throw new ToolkitError('ConcurrentWriteLock', `Another CLI (PID=${writer}) is currently synthing to ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); + throw new LockError('ConcurrentWriteLock', `Another CLI (PID=${writer}) is currently synthing to ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts index 63987a47c..997a470dd 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts @@ -6,6 +6,7 @@ const DEPLOYMENT_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.DeploymentError const ASSEMBLY_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.AssemblyError'); const CONTEXT_PROVIDER_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.ContextProviderError'); const NO_RESULTS_FOUND_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.NoResultsFoundError'); +const LOCK_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.LockError'); /** * Represents a general toolkit error in the AWS CDK Toolkit. @@ -39,6 +40,13 @@ export class ToolkitError extends Error { return ToolkitError.isToolkitError(x) && ASSEMBLY_ERROR_SYMBOL in x; } + /** + * Determines if a given error is an instance of LockError. + */ + public static isLockError(x: any): x is LockError { + return ToolkitError.isToolkitError(x) && LOCK_ERROR_SYMBOL in x; + } + /** * Determines if a given error is an instance of ContextProviderError. */ @@ -95,6 +103,23 @@ export class AuthenticationError extends ToolkitError { } } +/** + * Represents a failure to acquire the read/write lock on the cloud assembly + * output directory, because another CLI is reading from or writing to it. + */ +export class LockError extends ToolkitError { + /** + * Denotes the source of the error as user. + */ + public readonly source = 'user'; + + constructor(errorCode: string, message: string) { + super(errorCode, message, 'lock'); + Object.setPrototypeOf(this, LockError.prototype); + Object.defineProperty(this, LOCK_ERROR_SYMBOL, { value: true }); + } +} + /** * Represents an error causes by cloud assembly synthesis * diff --git a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts index 5038918fd..a96f3f3bd 100644 --- a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts @@ -1,4 +1,4 @@ -import { AssemblyError, AuthenticationError, ContextProviderError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; +import { AssemblyError, AuthenticationError, ContextProviderError, LockError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; describe('toolkit error', () => { let toolkitError = new ToolkitError('TestError', 'Test toolkit error'); @@ -8,6 +8,7 @@ describe('toolkit error', () => { let assemblyError = AssemblyError.withStacks('Test authentication error', []); let assemblyCauseError = AssemblyError.withCause('Test authentication error', new Error('other error')); let noResultsError = new NoResultsFoundError('Test no results error'); + let lockError = new LockError('ConcurrentWriteLock', 'Test lock error'); test('types are correctly assigned', async () => { expect(toolkitError.type).toBe('toolkit'); @@ -16,6 +17,7 @@ describe('toolkit error', () => { expect(assemblyCauseError.type).toBe('assembly'); expect(contextProviderError.type).toBe('context-provider'); expect(noResultsError.type).toBe('context-provider'); + expect(lockError.type).toBe('lock'); }); test('isToolkitError works', () => { @@ -40,6 +42,14 @@ describe('toolkit error', () => { expect(ToolkitError.isAuthenticationError(authError)).toBe(true); }); + test('isLockError works', () => { + expect(lockError.source).toBe('user'); + + expect(ToolkitError.isLockError(lockError)).toBe(true); + expect(ToolkitError.isLockError(toolkitError)).toBe(false); + expect(ToolkitError.isLockError(authError)).toBe(false); + }); + describe('isAssemblyError works', () => { test('AssemblyError.fromStacks', () => { expect(assemblyError.source).toBe('user'); From c56f121d23826275f16099e7540ddb955ba5113a Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Wed, 17 Jun 2026 15:33:17 -0400 Subject: [PATCH 18/21] feat(cdk-explorer): log the auto-answered prompt response in LspIoHost requestResponse now logs the default response it auto-applies (the prompt text is already logged via notify), so the Output panel shows why a prompt was answered without input. Addresses the review comment on requestResponse. --- packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts | 1 + packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts index ee6638792..2ea1d1991 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -40,6 +40,7 @@ export class LspIoHost implements IIoHost { public async requestResponse(msg: IoRequest): Promise { await this.notify(msg); + this.console.info(`Auto-answered with default response: ${JSON.stringify(msg.defaultResponse)}`); return msg.defaultResponse; } } diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts index 437864eee..eb34d6f23 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts @@ -56,5 +56,6 @@ describe('LspIoHost', () => { const result = await host.requestResponse(request); expect(result).toBe('default-token'); + expect(console.info).toHaveBeenCalledWith(expect.stringContaining('default-token')); }); }); From 8ba9593cbd1a19af57bf1bfc6389f3e0a162495a Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Thu, 18 Jun 2026 15:00:08 -0400 Subject: [PATCH 19/21] feat(toolkit-lib): add ContextLookupsDisabledError; LSP synth runs with lookups disabled context-aware-source now throws a symbol-tagged ContextLookupsDisabledError (matching the LockError/isXxxError pattern) instead of a plain ToolkitError; its message is unchanged. ToolkitError.isContextLookupsDisabledError() detects it. cdk-explorer runs synth with lookups disabled so the LSP never makes background AWS calls (cached context still works). On the resulting error it surfaces an LSP-composed app-failure telling the user to run cdk synth in a terminal to populate cdk.context.json. --- .../cdk-explorer/lib/core/synth-runner.ts | 10 ++++++- .../test/core/synth-runner.test.ts | 14 ++++++++-- .../private/context-aware-source.ts | 5 ++-- .../toolkit-lib/lib/toolkit/toolkit-error.ts | 27 +++++++++++++++++++ .../test/toolkit/toolkit-error.test.ts | 11 +++++++- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 13da2c0f2..6aab21c54 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -4,7 +4,7 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; * The outcome of a single synth attempt. * * `success` means the assembly was written to disk (the watcher will see it). - * `app-failure` means the user's CDK app threw or did not compile. + * `app-failure` means the user's CDK app threw, did not compile, or needs uncached context lookups. * `lock-conflict` means another process holds `/cdk.out` (a `cdk * synth` running in a terminal, a `cdk watch` loop, or our own previous synth * not yet released). Callers should not surface this as a hard error. @@ -38,6 +38,7 @@ export async function runSynth(options: SynthRunnerOptions): Promise { const result = await run(toolkit); expect(result).toEqual({ status: 'success' }); - expect(toolkit.fromCdkApp).toHaveBeenCalledWith('npx ts-node bin/app.ts', { workingDirectory: '/p' }); + expect(toolkit.fromCdkApp).toHaveBeenCalledWith('npx ts-node bin/app.ts', { workingDirectory: '/p', lookups: false }); expect(cached.dispose).toHaveBeenCalledTimes(1); }); @@ -77,6 +77,16 @@ describe('runSynth', () => { expect(result).toEqual({ status: 'lock-conflict' }); }); + test('classifies ContextLookupsDisabledError as app-failure with the error message', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ContextLookupsDisabledError('Context lookups have been disabled. Run cdk synth in a terminal.'), + }); + + const result = await run(toolkit); + + expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('cdk.context.json') }); + }); + test('classifies an unknown ToolkitError as error', async () => { const { toolkit } = makeToolkit({ synthThrow: new ToolkitError('SomeUnexpected', 'unexpected'), diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts index 5f427417d..c2bcef2f9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts @@ -1,7 +1,7 @@ import { AsyncDisposableBox } from './disposable-box'; import * as contextproviders from '../../../context-providers'; import type { ToolkitServices } from '../../../toolkit/private'; -import { ToolkitError } from '../../../toolkit/toolkit-error'; +import { ContextLookupsDisabledError } from '../../../toolkit/toolkit-error'; import type { IoHelper } from '../../io/private'; import { IO } from '../../io/private'; import type { IContextStore } from '../context-store'; @@ -77,8 +77,7 @@ export class ContextAwareCloudAssemblySource implements ICloudAssemblySource { const missingKeys = Array.from(missingKeysSet); if (!this.canLookup) { - throw new ToolkitError( - 'ContextLookupsDisabled', + throw new ContextLookupsDisabledError( 'Context lookups have been disabled. ' + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' + `Missing context keys: '${missingKeys.join(', ')}'`); diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts index 997a470dd..8185fedc6 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts @@ -7,6 +7,7 @@ const ASSEMBLY_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.AssemblyError'); const CONTEXT_PROVIDER_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.ContextProviderError'); const NO_RESULTS_FOUND_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.NoResultsFoundError'); const LOCK_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.LockError'); +const CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.ContextLookupsDisabledError'); /** * Represents a general toolkit error in the AWS CDK Toolkit. @@ -47,6 +48,13 @@ export class ToolkitError extends Error { return ToolkitError.isToolkitError(x) && LOCK_ERROR_SYMBOL in x; } + /** + * Determines if a given error is an instance of ContextLookupsDisabledError. + */ + public static isContextLookupsDisabledError(x: any): x is ContextLookupsDisabledError { + return ToolkitError.isToolkitError(x) && CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL in x; + } + /** * Determines if a given error is an instance of ContextProviderError. */ @@ -120,6 +128,25 @@ export class LockError extends ToolkitError { } } +/** + * Represents synthesis that could not complete because the app needs context + * lookups that are not cached, and lookups are disabled. The fix is to run + * `cdk synth` in a terminal once (with AWS credentials) so the values are + * written to `cdk.context.json`. + */ +export class ContextLookupsDisabledError extends ToolkitError { + /** + * Denotes the source of the error as user. + */ + public readonly source = 'user'; + + constructor(message: string) { + super('ContextLookupsDisabled', message); + Object.setPrototypeOf(this, ContextLookupsDisabledError.prototype); + Object.defineProperty(this, CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL, { value: true }); + } +} + /** * Represents an error causes by cloud assembly synthesis * diff --git a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts index a96f3f3bd..69d946444 100644 --- a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts @@ -1,4 +1,4 @@ -import { AssemblyError, AuthenticationError, ContextProviderError, LockError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; +import { AssemblyError, AuthenticationError, ContextLookupsDisabledError, ContextProviderError, LockError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; describe('toolkit error', () => { let toolkitError = new ToolkitError('TestError', 'Test toolkit error'); @@ -9,6 +9,7 @@ describe('toolkit error', () => { let assemblyCauseError = AssemblyError.withCause('Test authentication error', new Error('other error')); let noResultsError = new NoResultsFoundError('Test no results error'); let lockError = new LockError('ConcurrentWriteLock', 'Test lock error'); + let contextLookupsDisabledError = new ContextLookupsDisabledError('Test context lookups disabled error'); test('types are correctly assigned', async () => { expect(toolkitError.type).toBe('toolkit'); @@ -50,6 +51,14 @@ describe('toolkit error', () => { expect(ToolkitError.isLockError(authError)).toBe(false); }); + test('isContextLookupsDisabledError works', () => { + expect(contextLookupsDisabledError.source).toBe('user'); + + expect(ToolkitError.isContextLookupsDisabledError(contextLookupsDisabledError)).toBe(true); + expect(ToolkitError.isContextLookupsDisabledError(toolkitError)).toBe(false); + expect(ToolkitError.isContextLookupsDisabledError(lockError)).toBe(false); + }); + describe('isAssemblyError works', () => { test('AssemblyError.fromStacks', () => { expect(assemblyError.source).toBe('user'); From cdcae1a7142254141a769ca20499a67f245ca798 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Thu, 18 Jun 2026 15:42:54 -0400 Subject: [PATCH 20/21] feat(cdk-explorer): surface synth failures as LSP diagnostics A failed synth now publishes an error diagnostic per failing source file (line/col parsed from the captured compile stderr, ts-node and tsc formats), falling back to cdk.json when no in-project location parses. Diagnostics clear on the next successful synth. Manual synth and auto-synth-on-save both go through the single guardedSynth path. --- .../cdk-explorer/lib/core/synth-runner.ts | 5 +- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 34 ++++++- .../cdk-explorer/lib/lsp/synth-diagnostics.ts | 87 ++++++++++++++++++ .../test/core/synth-runner.test.ts | 2 +- .../cdk-explorer/test/lsp/server.test.ts | 62 +++++++++++++ .../test/lsp/synth-diagnostics.test.ts | 90 +++++++++++++++++++ 6 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 6aab21c54..6ed54b1fc 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -13,7 +13,7 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; */ export type SynthRunResult = | { status: 'success' } - | { status: 'app-failure'; message: string } + | { status: 'app-failure'; message: string; details?: string } | { status: 'lock-conflict' } | { status: 'error'; message: string }; @@ -69,7 +69,8 @@ function classify(err: unknown): SynthRunResult { }; } if (ToolkitError.isAssemblyError(err)) { - return { status: 'app-failure', message: err.message }; + // details = captured subprocess stderr (file:line:col), used for diagnostics. + return { status: 'app-failure', message: err.message, details: (err.cause as Error | undefined)?.message }; } return { status: 'error', message: (err as Error).message }; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index f2a63bf48..ceec213f3 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -27,6 +27,7 @@ import { codeLensesForFile } from './codelens'; import { executeCommand, SUPPORTED_COMMANDS, type NotifySink } from './commands'; import { mapViolationsToDiagnostics } from './diagnostics'; import { offsetAtPosition } from './positions'; +import { synthFailureDiagnostics } from './synth-diagnostics'; import { sourceTargetAtTemplateOffset } from './template-locator'; import { WATCH_EXCLUDE_DEFAULTS } from '../../../toolkit-lib/lib/actions/watch/private/helpers'; import { createIgnoreMatcher } from '../../../toolkit-lib/lib/util/glob-matcher'; @@ -151,6 +152,8 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers let applicationDir: string | undefined; let shutdownRequested = false; let synthInFlight = false; + // URIs (source files, or cdk.json) currently showing synth-failure diagnostics. + let synthFailureUris = new Set(); let autoSynthEnabled = false; // off by default; user enables via the CodeLens toggle let shouldIgnore: (filePath: string) => boolean = () => false; let assemblyWatcher: AssemblyWatcher | undefined; @@ -217,12 +220,41 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (synthInFlight) return { status: 'lock-conflict' }; synthInFlight = true; try { - return await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + const result = await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + publishSynthDiagnostics(result); + return result; } finally { synthInFlight = false; } } + // Publish diagnostics for a failed synth (one per failing source file, or + // cdk.json as a fallback) and clear them once a synth succeeds. The cdk.out + // watcher owns violation diagnostics; this only manages synth-failure ones. + function publishSynthDiagnostics(result: SynthRunResult): void { + if (result.status === 'success') { + clearSynthFailures(); + return; + } + const failures = synthFailureDiagnostics(result, applicationDir ?? process.cwd()); + if (failures.length === 0) return; // lock-conflict / error: leave any existing diagnostic + const nextUris = new Set(failures.map((f) => f.uri)); + for (const uri of synthFailureUris) { + if (!nextUris.has(uri)) onPublishDiagnostics(uri, []); + } + for (const f of failures) { + onPublishDiagnostics(f.uri, f.diagnostics); + } + synthFailureUris = nextUris; + } + + function clearSynthFailures(): void { + for (const uri of synthFailureUris) { + onPublishDiagnostics(uri, []); + } + synthFailureUris = new Set(); + } + return { onInitialize(params) { applicationDir = params.initializationOptions?.applicationDir; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts new file mode 100644 index 000000000..23c59dc6c --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts @@ -0,0 +1,87 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { type Diagnostic, DiagnosticSeverity, type Range } from 'vscode-languageserver/node'; +import type { SynthRunResult } from '../core/synth-runner'; + +/** A compile error parsed from synth stderr. Line/column are 1-based. */ +export interface SynthErrorLocation { + readonly file: string; + readonly line: number; + readonly column: number; + readonly message: string; +} + +// ts-node "pretty": "bin/app.ts:12:5 - error TS2322: ..." +const TS_NODE_RE = /^(.+?):(\d+):(\d+) - (error TS\d+:.+)$/gm; +// tsc / ts-node default: "bin/app.ts(12,5): error TS2322: ..." +const TSC_RE = /^(.+?)\((\d+),(\d+)\): (error TS\d+:.+)$/gm; + +/** + * Find every TypeScript compile error (file:line:col) in synth stderr. + * Returns an empty array when nothing matches (e.g. a non-TypeScript app or a + * runtime failure). + */ +export function parseSynthErrors(stderr: string | undefined): SynthErrorLocation[] { + if (!stderr) return []; + const out: SynthErrorLocation[] = []; + for (const re of [TS_NODE_RE, TSC_RE]) { + for (const m of stderr.matchAll(re)) { + out.push({ file: m[1], line: Number(m[2]), column: Number(m[3]), message: m[4] }); + } + } + return out; +} + +/** A diagnostic set ready to publish, with the URI it belongs to. */ +export interface SynthFailureDiagnostic { + readonly uri: string; + readonly diagnostics: Diagnostic[]; +} + +/** + * Build diagnostics for an app-failure synth outcome: one entry per failing + * source file, considering only files inside `projectDir`. When nothing anchors + * there, falls back to a single diagnostic on `cdk.json` carrying the summary + * message. Returns an empty array for any non-app-failure outcome. + */ +export function synthFailureDiagnostics( + result: SynthRunResult, + projectDir: string, +): SynthFailureDiagnostic[] { + if (result.status !== 'app-failure') return []; + + const byUri = new Map(); + for (const loc of parseSynthErrors(result.details)) { + const abs = path.isAbsolute(loc.file) ? loc.file : path.resolve(projectDir, loc.file); + if (!isWithin(projectDir, abs)) continue; // never point diagnostics outside the project + const uri = pathToFileURL(abs).toString(); + const list = byUri.get(uri) ?? []; + list.push(diagnostic(rangeAt(loc.line, loc.column), loc.message)); + byUri.set(uri, list); + } + + if (byUri.size > 0) { + return [...byUri].map(([uri, diagnostics]) => ({ uri, diagnostics })); + } + + return [{ + uri: pathToFileURL(path.join(projectDir, 'cdk.json')).toString(), + diagnostics: [diagnostic(rangeAt(1, 1), result.message)], + }]; +} + +function diagnostic(range: Range, message: string): Diagnostic { + return { range, severity: DiagnosticSeverity.Error, source: 'cdk synth', message }; +} + +/** LSP positions are 0-based; the parsed line/column are 1-based. */ +function rangeAt(line: number, column: number): Range { + const l = Math.max(0, line - 1); + const c = Math.max(0, column - 1); + return { start: { line: l, character: c }, end: { line: l, character: Number.MAX_VALUE } }; +} + +function isWithin(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel); +} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts index 0d7023d3f..cda6673c2 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts @@ -54,7 +54,7 @@ describe('runSynth', () => { const result = await run(toolkit); - expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed') }); + expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed'), details: 'TypeError: foo' }); }); test('classifies ConcurrentWriteLock as lock-conflict', async () => { diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index a706f9105..29c2a58d0 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -599,3 +599,65 @@ describe('LSP Server -- auto-synth toggle', () => { expect(log.error).toHaveBeenCalledWith(expect.stringContaining('unexpected failure')); }); }); + +describe('LSP Server -- synth-failure diagnostics', () => { + test('app-failure publishes a diagnostic on the failing source file', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'Subprocess exited with error 1', + details: 'lib/stack.ts:12:5 - error TS2322: nope', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const onFile = client.published.find((p) => p.uri === pathToFileURL('/p/lib/stack.ts').toString()); + expect(onFile?.diagnostics).toHaveLength(1); + expect(onFile?.diagnostics[0].message).toContain('TS2322'); + }); + + test('publishes a diagnostic per failing file', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'm', + details: 'lib/stack.ts(1,1): error TS1000: a\nbin/app.ts(2,2): error TS1001: b', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + expect(client.published.find((p) => p.uri === pathToFileURL('/p/lib/stack.ts').toString())?.diagnostics).toHaveLength(1); + expect(client.published.find((p) => p.uri === pathToFileURL('/p/bin/app.ts').toString())?.diagnostics).toHaveLength(1); + }); + + test('app-failure without a parseable location falls back to cdk.json', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'context needed', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const onCdkJson = client.published.find((p) => p.uri === pathToFileURL('/p/cdk.json').toString()); + expect(onCdkJson?.diagnostics[0].message).toBe('context needed'); + }); + + test('a successful synth clears a prior synth-failure diagnostic', async () => { + const synthRunner = jest.fn, []>() + .mockResolvedValueOnce({ status: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }) + .mockResolvedValueOnce({ status: 'success' }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const uri = pathToFileURL('/p/lib/stack.ts').toString(); + const entriesForFile = client.published.filter((p) => p.uri === uri); + expect(entriesForFile[entriesForFile.length - 1].diagnostics).toEqual([]); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts new file mode 100644 index 000000000..dec4543f7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts @@ -0,0 +1,90 @@ +import { pathToFileURL } from 'url'; +import { DiagnosticSeverity } from 'vscode-languageserver/node'; +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { parseSynthErrors, synthFailureDiagnostics } from '../../lib/lsp/synth-diagnostics'; + +describe('parseSynthErrors', () => { + test('parses multiple ts-node (pretty) errors', () => { + const locs = parseSynthErrors( + 'lib/stack.ts:12:5 - error TS2322: Type A.\nlib/stack.ts:20:9 - error TS2554: Type B.\n', + ); + expect(locs).toEqual([ + { file: 'lib/stack.ts', line: 12, column: 5, message: 'error TS2322: Type A.' }, + { file: 'lib/stack.ts', line: 20, column: 9, message: 'error TS2554: Type B.' }, + ]); + }); + + test('parses multiple tsc-format errors (ts-node default)', () => { + const locs = parseSynthErrors( + 'lib/stack.ts(1,7): error TS2322: x\nbin/app.ts(3,1): error TS1005: y\n', + ); + expect(locs).toEqual([ + { file: 'lib/stack.ts', line: 1, column: 7, message: 'error TS2322: x' }, + { file: 'bin/app.ts', line: 3, column: 1, message: 'error TS1005: y' }, + ]); + }); + + test('returns empty when nothing matches', () => { + expect(parseSynthErrors('unrelated output')).toEqual([]); + expect(parseSynthErrors(undefined)).toEqual([]); + }); +}); + +describe('synthFailureDiagnostics', () => { + test('groups multiple errors in the same file into one entry', () => { + const result: SynthRunResult = { + status: 'app-failure', + message: 'summary', + details: 'lib/stack.ts(1,7): error TS2322: a\nlib/stack.ts(2,3): error TS1005: b', + }; + + const out = synthFailureDiagnostics(result, '/p'); + + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); + expect(out[0].diagnostics).toHaveLength(2); + expect(out[0].diagnostics[0]).toEqual({ + range: { start: { line: 0, character: 6 }, end: { line: 0, character: Number.MAX_VALUE } }, + severity: DiagnosticSeverity.Error, + source: 'cdk synth', + message: 'error TS2322: a', + }); + }); + + test('produces one entry per file', () => { + const result: SynthRunResult = { + status: 'app-failure', + message: 'summary', + details: 'lib/stack.ts(1,1): error TS1: a\nbin/app.ts(2,2): error TS2: b', + }; + + const uris = synthFailureDiagnostics(result, '/p').map((o) => o.uri).sort(); + + expect(uris).toEqual([ + pathToFileURL('/p/bin/app.ts').toString(), + pathToFileURL('/p/lib/stack.ts').toString(), + ].sort()); + }); + + test('falls back to cdk.json when nothing parses', () => { + const out = synthFailureDiagnostics({ status: 'app-failure', message: 'context needed' }, '/p'); + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/cdk.json').toString()); + expect(out[0].diagnostics[0].message).toBe('context needed'); + }); + + test('ignores errors outside the project (falls back to cdk.json)', () => { + const out = synthFailureDiagnostics( + { status: 'app-failure', message: 'summary', details: '../evil.ts(1,1): error TS1: x' }, + '/p', + ); + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/cdk.json').toString()); + }); + + test('returns empty for non-app-failure outcomes', () => { + expect(synthFailureDiagnostics({ status: 'success' }, '/p')).toEqual([]); + expect(synthFailureDiagnostics({ status: 'lock-conflict' }, '/p')).toEqual([]); + expect(synthFailureDiagnostics({ status: 'error', message: 'x' }, '/p')).toEqual([]); + }); +}); From 316aa98f7defb200c2bdb4c5d3191665856be659 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Fri, 19 Jun 2026 15:37:46 -0400 Subject: [PATCH 21/21] fix(cdk-explorer): read cdk.json app per synth, not once at startup The LSP cached the app command from cdk.json at startup, so changing it required an LSP restart. Read it on demand inside the synth runner instead, so editing the app command, or running cdk init in an already-open folder, takes effect on the next synth with no restart. Feature flags and context already reloaded per synth (each synth runs the app in a fresh subprocess that re-reads cdk.json), so the only cached state was the app command and the availability signal, now replaced by an unavailable synth result. Resolve the project root through a single currentProjectDir() owner so synth, the cdk.out watcher, the assembly read, and diagnostics all use the IDE-provided applicationDir. Previously synth used process.cwd() while the watcher used applicationDir, which could silently target different directories. Clear synth-failure diagnostics on unavailable as well as success, since no app means nothing can be failing. --- .../cdk-explorer/lib/core/synth-runner.ts | 15 +++- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 12 +-- .../@aws-cdk/cdk-explorer/lib/lsp/main.ts | 14 ++-- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 38 ++++++---- .../test/core/synth-runner.test.ts | 74 +++++++++++++++---- .../cdk-explorer/test/lsp/commands.test.ts | 9 ++- .../cdk-explorer/test/lsp/server.test.ts | 42 +++++++++-- 7 files changed, 144 insertions(+), 60 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts index 6ed54b1fc..0624fd4c6 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -1,4 +1,5 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; +import { readCdkConfig } from './cdk-config'; /** * The outcome of a single synth attempt. @@ -8,6 +9,9 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; * `lock-conflict` means another process holds `/cdk.out` (a `cdk * synth` running in a terminal, a `cdk watch` loop, or our own previous synth * not yet released). Callers should not surface this as a hard error. + * `unavailable` means `cdk.json` is missing or has no `app` key, so there is + * nothing to synth. Read fresh on every call, so adding an `app` later is + * picked up without restarting the LSP. * `error` is reserved for anything we did not classify, including failures * during dispose. */ @@ -15,6 +19,7 @@ export type SynthRunResult = | { status: 'success' } | { status: 'app-failure'; message: string; details?: string } | { status: 'lock-conflict' } + | { status: 'unavailable' } | { status: 'error'; message: string }; export interface SynthRunnerOptions { @@ -22,8 +27,6 @@ export interface SynthRunnerOptions { readonly toolkit: Toolkit; /** Directory containing the user's `cdk.json`; also the synth working dir. */ readonly projectDir: string; - /** The `app` command from `cdk.json` (e.g. `npx ts-node bin/app.ts`). */ - readonly app: string; } /** @@ -32,11 +35,17 @@ export interface SynthRunnerOptions { * assembly so the read lock is released before the next call. Holding the * cached assembly between calls would cause the next acquireWrite to throw * `ConcurrentReadLock` against ourselves. + * + * The `app` command is read from `cdk.json` on every call, not cached, so an + * edited command or a newly added `app` takes effect on the next synth. */ export async function runSynth(options: SynthRunnerOptions): Promise { + const app = readCdkConfig(options.projectDir).app; + if (app === undefined) return { status: 'unavailable' }; + let cached; try { - const cx = await options.toolkit.fromCdkApp(options.app, { + const cx = await options.toolkit.fromCdkApp(app, { workingDirectory: options.projectDir, lookups: false, }); diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index 82fbbda8a..bb89f0a3a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -32,11 +32,6 @@ export interface NotifySink { export interface CommandHandlerOptions { /** Invokes a single synth. Resolves with the typed outcome; never rejects. */ readonly synth: () => Promise; - /** - * Whether `synth` can be invoked. False when `cdk.json` is missing or has - * no `app` key; the synth command is then unavailable to the user. - */ - readonly synthAvailable: boolean; /** Called with the new desired state when the user toggles auto-synth. */ readonly toggleAutoSynth: (enabled: boolean) => void; /** UI sinks for messages and progress. */ @@ -67,10 +62,6 @@ export async function executeCommand( return; case COMMAND_SYNTH_NOW: - if (!options.synthAvailable) { - options.notify.info(SYNTH_UNAVAILABLE_MESSAGE); - return; - } { const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); handleSynthResult(result, options.notify); @@ -93,6 +84,9 @@ function handleSynthResult(result: SynthRunResult, notify: NotifySink): void { case 'lock-conflict': notify.info(LOCK_CONFLICT_MESSAGE); return; + case 'unavailable': + notify.info(SYNTH_UNAVAILABLE_MESSAGE); + return; case 'error': notify.error(`CDK synth failed unexpectedly: ${result.message}`); return; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index ea97afec9..2e058262f 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -1,23 +1,21 @@ import { Toolkit } from '@aws-cdk/toolkit-lib'; import { LspIoHost } from './io-host'; import { startServer } from './server'; -import { readCdkConfig } from '../core/cdk-config'; import { runSynth } from '../core/synth-runner'; try { - const projectDir = process.cwd(); - const config = readCdkConfig(projectDir); - startServer({ readable: process.stdin, writable: process.stdout, // synthRunnerFactory: startServer invokes it once, after the LSP connection // exists, so the runner it returns can route Toolkit output to the editor's - // Output panel via connection.console. Built only when cdk.json has an `app`. - synthRunnerFactory: config.app !== undefined ? (console) => { + // Output panel via connection.console. The handler passes the resolved + // project root on each call; the runner reads that project's cdk.json `app` + // per synth, so it is always built and "no app" is reported per call. + synthRunnerFactory: (console) => { const toolkit = new Toolkit({ ioHost: new LspIoHost(console) }); - return () => runSynth({ toolkit, projectDir, app: config.app! }); - } : undefined, + return (projectDir) => runSynth({ toolkit, projectDir }); + }, }); } catch (err) { const e = err as Error; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index ceec213f3..b466773e8 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -65,18 +65,19 @@ export interface LspHandlerOptions { */ readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; /** - * Runs a synth and returns its typed outcome. Injected by startServer (built - * from `synthRunnerFactory`); omitted in tests that don't exercise synth. Its - * presence is the single source of truth for whether synth is available: - * `cdk.json` having an `app` is exactly what causes a runner to be built. + * Runs a synth of the project at the given root and returns its typed outcome. + * Injected by startServer (built from `synthRunnerFactory`); omitted in tests + * that don't exercise synth. The runner reads `cdk.json` under the passed root + * on each call and returns `unavailable` when there is no `app`, so + * availability is decided per synth, not cached here. */ - readonly synthRunner?: () => Promise; + readonly synthRunner?: (projectDir: string) => Promise; /** User-facing notification sink. Injected by startServer; omitted in tests. */ readonly notify?: NotifySink; } /** Builds the synth runner once `connection.console` is available (in startServer). */ -export type SynthRunnerFactory = (console: RemoteConsole) => (() => Promise); +export type SynthRunnerFactory = (console: RemoteConsole) => ((projectDir: string) => Promise); export interface LspServerOptions { readonly readable: NodeJS.ReadableStream; @@ -117,7 +118,8 @@ function handleSynthOnSave(result: SynthRunResult, log: LogSink): void { switch (result.status) { case 'success': case 'lock-conflict': - return; // silent — watcher handles the update; lock means another synth is already running + case 'unavailable': + return; // silent — watcher handles updates; lock = another synth running; unavailable = no app case 'app-failure': log.error(`Auto-synth failed: ${result.message}`); return; @@ -140,7 +142,6 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers }); const startWatcher = options.startAssemblyWatcher ?? defaultStartAssemblyWatcher; const synthRunner = options.synthRunner; - const synthAvailable = synthRunner !== undefined; const notify = options.notify ?? { info: () => { }, @@ -169,6 +170,14 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers // the editor's next natural re-query. let codeLensRefreshSupported = false; + // Single source of truth for the project root: the directory the client opened + // (applicationDir from initialize), falling back to cwd for non-IDE callers. + // Every consumer (assembly read, watcher, synth, diagnostics) reads this so + // they never disagree about which project is being operated on. + function currentProjectDir(): string { + return applicationDir ?? process.cwd(); + } + function refreshFromAssembly(projectDir: string): void { const assemblyDir = path.join(projectDir, 'cdk.out'); const result = readAssembly(assemblyDir); @@ -220,7 +229,7 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (synthInFlight) return { status: 'lock-conflict' }; synthInFlight = true; try { - const result = await (synthRunner ? synthRunner() : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + const result = await (synthRunner ? synthRunner(currentProjectDir()) : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); publishSynthDiagnostics(result); return result; } finally { @@ -232,11 +241,13 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers // cdk.json as a fallback) and clear them once a synth succeeds. The cdk.out // watcher owns violation diagnostics; this only manages synth-failure ones. function publishSynthDiagnostics(result: SynthRunResult): void { - if (result.status === 'success') { + // A successful synth resolves all failures; 'unavailable' means there is no + // app to fail, so any prior synth-failure diagnostics no longer apply. + if (result.status === 'success' || result.status === 'unavailable') { clearSynthFailures(); return; } - const failures = synthFailureDiagnostics(result, applicationDir ?? process.cwd()); + const failures = synthFailureDiagnostics(result, currentProjectDir()); if (failures.length === 0) return; // lock-conflict / error: leave any existing diagnostic const nextUris = new Set(failures.map((f) => f.uri)); for (const uri of synthFailureUris) { @@ -277,7 +288,7 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers }; }, onInitialized() { - const projectDir = applicationDir ?? process.cwd(); + const projectDir = currentProjectDir(); // Same exclusion logic as toolkit-lib's watch(): // WATCH_EXCLUDE_DEFAULTS covers common non-source dirs, then we add cdk.out // (our own output) and dotfiles (editor configs, .git, etc.) @@ -306,7 +317,7 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (shutdownRequested) return; const filePath = fileURLToPath(params.textDocument.uri); if (shouldIgnore(filePath)) return; - if (!autoSynthEnabled || !synthAvailable) return; + if (!autoSynthEnabled) return; void guardedSynth() .then((result) => handleSynthOnSave(result, log)) .catch((err: unknown) => log.error(`Auto-synth threw unexpectedly: ${(err as Error).message}`)); @@ -338,7 +349,6 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers async onExecuteCommand(params) { await executeCommand(params.command, params.arguments ?? [], { synth: guardedSynth, - synthAvailable, toggleAutoSynth: (enabled) => { autoSynthEnabled = enabled; onRefreshCodeLenses(); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts index cda6673c2..8981348da 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts @@ -1,6 +1,11 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { AssemblyError, ContextLookupsDisabledError, LockError, ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; import { runSynth } from '../../lib/core/synth-runner'; +const APP = 'npx ts-node bin/app.ts'; + interface FakeCachedAssembly { dispose: jest.Mock; } @@ -28,31 +33,68 @@ function makeToolkit(opts: { return { toolkit, cached }; } -function run(toolkit: FakeToolkit) { - return runSynth({ - toolkit: toolkit as unknown as Toolkit, - projectDir: '/p', - app: 'npx ts-node bin/app.ts', - }); +const tempDirs: string[] = []; + +// Create a throwaway project dir. Pass an object to write its cdk.json +// (`{ app }` for a configured app, `{}` for a cdk.json with no app), or +// `undefined` for no cdk.json at all. runSynth reads the app from this on disk. +function makeProjectDir(cdkJson?: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-synth-')); + tempDirs.push(dir); + if (cdkJson !== undefined) { + fs.writeFileSync(path.join(dir, 'cdk.json'), JSON.stringify(cdkJson)); + } + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function run(toolkit: FakeToolkit, projectDir: string) { + return runSynth({ toolkit: toolkit as unknown as Toolkit, projectDir }); } describe('runSynth', () => { - test('returns success and disposes the cached assembly', async () => { + test('reads the app from cdk.json, returns success, and disposes the cached assembly', async () => { const { toolkit, cached } = makeToolkit({}); + const projectDir = makeProjectDir({ app: APP }); - const result = await run(toolkit); + const result = await run(toolkit, projectDir); expect(result).toEqual({ status: 'success' }); - expect(toolkit.fromCdkApp).toHaveBeenCalledWith('npx ts-node bin/app.ts', { workingDirectory: '/p', lookups: false }); + expect(toolkit.fromCdkApp).toHaveBeenCalledWith(APP, { workingDirectory: projectDir, lookups: false }); expect(cached.dispose).toHaveBeenCalledTimes(1); }); + test('returns unavailable (without invoking the toolkit) when cdk.json has no app', async () => { + const { toolkit } = makeToolkit({}); + const projectDir = makeProjectDir({}); + + const result = await run(toolkit, projectDir); + + expect(result).toEqual({ status: 'unavailable' }); + expect(toolkit.fromCdkApp).not.toHaveBeenCalled(); + }); + + test('returns unavailable when cdk.json is missing entirely', async () => { + const { toolkit } = makeToolkit({}); + const projectDir = makeProjectDir(undefined); + + const result = await run(toolkit, projectDir); + + expect(result).toEqual({ status: 'unavailable' }); + expect(toolkit.fromCdkApp).not.toHaveBeenCalled(); + }); + test('classifies AssemblyError as app-failure with the error message', async () => { const { toolkit } = makeToolkit({ synthThrow: AssemblyError.withCause('Assembly builder failed', new Error('TypeError: foo')), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed'), details: 'TypeError: foo' }); }); @@ -62,7 +104,7 @@ describe('runSynth', () => { synthThrow: new LockError('ConcurrentWriteLock', 'another CLI synthing'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'lock-conflict' }); }); @@ -72,7 +114,7 @@ describe('runSynth', () => { synthThrow: new LockError('ConcurrentReadLock', 'another CLI reading'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'lock-conflict' }); }); @@ -82,7 +124,7 @@ describe('runSynth', () => { synthThrow: new ContextLookupsDisabledError('Context lookups have been disabled. Run cdk synth in a terminal.'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('cdk.context.json') }); }); @@ -92,7 +134,7 @@ describe('runSynth', () => { synthThrow: new ToolkitError('SomeUnexpected', 'unexpected'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'error', message: 'unexpected' }); }); @@ -102,7 +144,7 @@ describe('runSynth', () => { synthThrow: new Error('disk full'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'error', message: 'disk full' }); }); @@ -112,7 +154,7 @@ describe('runSynth', () => { disposeThrow: new Error('lock release failed'), }); - const result = await run(toolkit); + const result = await run(toolkit, makeProjectDir({ app: APP })); expect(result).toEqual({ status: 'error', message: 'lock release failed' }); expect(cached.dispose).toHaveBeenCalledTimes(1); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts index 47d64cd1e..787af03f7 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -39,7 +39,7 @@ function makeOptions(overrides: Partial = {}): { notify, synth, toggleAutoSynth, - options: { synth, synthAvailable: true, toggleAutoSynth, notify, ...overrides }, + options: { synth, toggleAutoSynth, notify, ...overrides }, }; } @@ -55,12 +55,13 @@ describe('executeCommand', () => { await executeCommand(COMMAND_DISABLE_AUTO_SYNTH, [], options); expect(toggleAutoSynth).toHaveBeenCalledWith(false); }); - test('synthNow shows info and skips synth when unavailable', async () => { - const { options, notify, synth } = makeOptions({ synthAvailable: false }); + test('synthNow surfaces an unavailable result as an info notification', async () => { + const synth = jest.fn(async () => ({ status: 'unavailable' } as SynthRunResult)); + const { options, notify } = makeOptions({ synth }); await executeCommand(COMMAND_SYNTH_NOW, [], options); - expect(synth).not.toHaveBeenCalled(); + expect(synth).toHaveBeenCalledTimes(1); expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('cdk.json')); expect(notify.error).not.toHaveBeenCalled(); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 29c2a58d0..313e7aebf 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -392,8 +392,9 @@ describe('LSP Server -- executeCommand', () => { expect(result.capabilities.executeCommandProvider?.commands).toEqual(expect.arrayContaining([COMMAND_SYNTH_NOW])); }); - test('synthNow without synthAvailable notifies info', async () => { - const { handlers, notify } = createCommandClient(); + test('synthNow with no app surfaces an unavailable info message', async () => { + const synthRunner = jest.fn, [string]>().mockResolvedValue({ status: 'unavailable' }); + const { handlers, notify } = createCommandClient({ synthRunner }); await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); expect(notify.infoMessages.some((m) => m.includes('unavailable'))).toBe(true); }); @@ -542,16 +543,30 @@ describe('LSP Server -- auto-synth toggle', () => { expect(refreshCodeLens).toHaveBeenCalledTimes(2); }); - test('didSave does not trigger synth when synth is unavailable (no runner) even if auto-synth enabled', async () => { - const { handlers, log } = createToggleClient(false); + test('save-path unavailable result is silent (no log output)', async () => { + const synthRunner = jest.fn, [string]>().mockResolvedValue({ status: 'unavailable' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); await new Promise((r) => setTimeout(r, 0)); - // No runner means the availability gate returns early; we never reach - // guardedSynth's "No synth runner configured" path, so nothing is logged. + // With no app the runner returns 'unavailable', a silent no-op on the save + // path: there is no app to synth, so nothing is reported. + expect(synthRunner).toHaveBeenCalledTimes(1); expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); }); test('save-path lock-conflict is silent (no log output)', async () => { @@ -660,4 +675,19 @@ describe('LSP Server -- synth-failure diagnostics', () => { const entriesForFile = client.published.filter((p) => p.uri === uri); expect(entriesForFile[entriesForFile.length - 1].diagnostics).toEqual([]); }); + + test('an unavailable result clears a prior synth-failure diagnostic', async () => { + const synthRunner = jest.fn, [string]>() + .mockResolvedValueOnce({ status: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }) + .mockResolvedValueOnce({ status: 'unavailable' }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const uri = pathToFileURL('/p/lib/stack.ts').toString(); + const entriesForFile = client.published.filter((p) => p.uri === uri); + expect(entriesForFile[entriesForFile.length - 1].diagnostics).toEqual([]); + }); });