From 16a289423812f3bbc07e9f9ada802c61fdcbf35c Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 19 Oct 2026 15:45:58 +0200 Subject: [PATCH 01/16] refactor(cdk-explorer): rebuild the core on the assembly index Move the explorer's core layer onto `AssemblyIndex` and reshape it so the LSP (and a future web UI) share one read model of a synthesized app. - `AssemblyIndexWithSourceLocations` wraps the index with source-location resolution, source-map reverse mapping and a line-to-constructs lookup per source file - paths are branded (`RelativePath` + `PathTranslator`), so app-relative, assembly-relative and absolute paths can no longer be mixed up, and `safe-url.ts` is the only way to turn one into a URL (enforced by a `no-restricted-imports` rule) - `source-watcher.ts` watches authored sources; `from-cdk-app-directory.ts` centralizes opening an app directory - `template-ranges.ts` is copied in from `cloud-assembly-api`, which has no other caller for it - `assembly-lock.ts` is gone: toolkit-lib now exports `SYNTH_LOCK_FILE`, which is all the watcher needed - LSP features (hover, codelens, diagnostics, template locator, server) are rewritten against the new core, with construct summaries split out into `summarize.ts` and logging into `log-sink.ts` - test fixtures replaced by `_helpers/assembly.ts` + `_helpers/fake-toolkit.ts` This leaves `construct-tree.ts` and `template-ranges.ts` in `cloud-assembly-api` without callers; a follow-up removes them. --- .projenrc.ts | 24 + packages/@aws-cdk/cdk-explorer/.eslintrc.json | 28 + .../@aws-cdk/cdk-explorer/.projen/deps.json | 15 + packages/@aws-cdk/cdk-explorer/bin/lsp | 3 + .../@aws-cdk/cdk-explorer/lib/api-private.ts | 1 + .../assembly-index-with-source-locations.ts | 330 ++++ .../cdk-explorer/lib/core/assembly-lock.ts | 25 - .../cdk-explorer/lib/core/assembly-reader.ts | 146 +- .../cdk-explorer/lib/core/assembly-watcher.ts | 21 +- .../cdk-explorer/lib/core/cdk-config.ts | 21 +- .../lib/core/from-cdk-app-directory.ts | 66 + .../cdk-explorer/lib/core/safe-url.ts | 13 + .../cdk-explorer/lib/core/source-resolver.ts | 164 +- .../cdk-explorer/lib/core/source-watcher.ts | 130 ++ .../cdk-explorer/lib/core/synth-runner.ts | 39 +- .../cdk-explorer/lib/core/template-ranges.ts | 197 +++ .../cdk-explorer/lib/core/type-brands.ts | 32 + .../@aws-cdk/cdk-explorer/lib/core/undef.ts | 9 + packages/@aws-cdk/cdk-explorer/lib/index.ts | 4 +- .../@aws-cdk/cdk-explorer/lib/lsp/codelens.ts | 98 +- .../@aws-cdk/cdk-explorer/lib/lsp/commands.ts | 31 +- .../lib/lsp/construct-tree-request.ts | 11 +- .../cdk-explorer/lib/lsp/diagnostics.ts | 86 +- .../@aws-cdk/cdk-explorer/lib/lsp/hover.ts | 128 +- .../@aws-cdk/cdk-explorer/lib/lsp/log-sink.ts | 12 + .../@aws-cdk/cdk-explorer/lib/lsp/main.ts | 13 +- .../cdk-explorer/lib/lsp/positions.ts | 2 +- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 366 ++--- .../cdk-explorer/lib/lsp/summarize.ts | 16 + .../cdk-explorer/lib/lsp/synth-diagnostics.ts | 2 +- .../cdk-explorer/lib/lsp/template-locator.ts | 30 +- packages/@aws-cdk/cdk-explorer/package.json | 6 + .../cdk-explorer/test/_fixtures/README.md | 33 +- .../test/_fixtures/builders.test.ts | 229 --- .../cdk-explorer/test/_fixtures/builders.ts | 506 ------ .../cdk-explorer/test/_helpers/assembly.ts | 202 +++ .../test/_helpers/fake-toolkit.ts | 120 ++ .../test/core/assembly-reader.test.ts | 390 ----- .../test/core/assembly-watcher.test.ts | 36 +- .../cdk-explorer/test/core/cdk-config.test.ts | 80 +- .../test/core/path-translator.test.ts | 108 ++ .../test/core/source-resolver.test.ts | 452 ++--- .../test/core/source-watcher.test.ts | 116 ++ .../test/core/synth-runner.test.ts | 41 +- .../test/core/template-ranges.test.ts | 207 +++ .../cdk-explorer/test/lsp/codelens.test.ts | 468 +++--- .../cdk-explorer/test/lsp/commands.test.ts | 116 +- .../cdk-explorer/test/lsp/diagnostics.test.ts | 310 ++-- .../cdk-explorer/test/lsp/hover.test.ts | 392 +++-- .../cdk-explorer/test/lsp/server.test.ts | 1455 +++++++---------- .../test/lsp/synth-diagnostics.test.ts | 3 +- .../test/lsp/template-locator.test.ts | 288 ++-- packages/@aws-cdk/cdk-explorer/tsconfig.json | 1 + .../@aws-cdk/toolkit-lib/lib/api/rwlock.ts | 8 +- .../lib/api/source-tracing/index.ts | 1 + yarn.lock | 5 + 56 files changed, 4058 insertions(+), 3578 deletions(-) create mode 100755 packages/@aws-cdk/cdk-explorer/bin/lsp create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/assembly-index-with-source-locations.ts delete mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/assembly-lock.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/safe-url.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/type-brands.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/undef.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/log-sink.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/summarize.ts delete mode 100644 packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.test.ts delete mode 100644 packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/_helpers/assembly.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/_helpers/fake-toolkit.ts delete mode 100644 packages/@aws-cdk/cdk-explorer/test/core/assembly-reader.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/path-translator.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/source-watcher.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts diff --git a/.projenrc.ts b/.projenrc.ts index a1782bc0b..85d17af7d 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1222,8 +1222,11 @@ const cdkExplorer = configureProject( 'chokidar@^4', '@jridgewell/trace-mapping@^0.3', 'convert-source-map@^2', + 'json-source-map@^0.6.1', + 'p-limit@^3', ], devDeps: [ + '@types/json-source-map@^0.6.0', 'vscode-languageserver-protocol@^3', '@types/convert-source-map@^2', ], @@ -1245,6 +1248,27 @@ const cdkExplorer = configureProject( }), ); fixupTestTask(cdkExplorer); +cdkExplorer.eslint?.addOverride({ + files: [ + 'lib/**', + ], + rules: { + 'no-restricted-imports': ['error', { + paths: [ + { + name: 'url', + importNames: ['pathToFileURL'], + message: "Use pathToFileURL from './safe-url' instead.", + }, + { + name: 'node:url', + importNames: ['pathToFileURL'], + message: "Use pathToFileURL from './safe-url' instead.", + }, + ], + }], + }, +}); // #endregion ////////////////////////////////////////////////////////////////////// diff --git a/packages/@aws-cdk/cdk-explorer/.eslintrc.json b/packages/@aws-cdk/cdk-explorer/.eslintrc.json index 41efb7f7f..b0d26ee73 100644 --- a/packages/@aws-cdk/cdk-explorer/.eslintrc.json +++ b/packages/@aws-cdk/cdk-explorer/.eslintrc.json @@ -363,6 +363,34 @@ } ] } + }, + { + "files": [ + "lib/**" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "url", + "importNames": [ + "pathToFileURL" + ], + "message": "Use pathToFileURL from './safe-url' instead." + }, + { + "name": "node:url", + "importNames": [ + "pathToFileURL" + ], + "message": "Use pathToFileURL from './safe-url' instead." + } + ] + } + ] + } } ] } diff --git a/packages/@aws-cdk/cdk-explorer/.projen/deps.json b/packages/@aws-cdk/cdk-explorer/.projen/deps.json index c2152ce92..0bb6b50f1 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/deps.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/deps.json @@ -18,6 +18,11 @@ "name": "@types/jest", "type": "build" }, + { + "name": "@types/json-source-map", + "version": "^0.6.0", + "type": "build" + }, { "name": "@types/node", "version": "^20", @@ -130,6 +135,16 @@ "version": "^2", "type": "runtime" }, + { + "name": "json-source-map", + "version": "^0.6.1", + "type": "runtime" + }, + { + "name": "p-limit", + "version": "^3", + "type": "runtime" + }, { "name": "vscode-jsonrpc", "version": "^8", diff --git a/packages/@aws-cdk/cdk-explorer/bin/lsp b/packages/@aws-cdk/cdk-explorer/bin/lsp new file mode 100755 index 000000000..253d50b56 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/bin/lsp @@ -0,0 +1,3 @@ +#!/usr/bin/env node +const { startLspServer } = require('../lib/lsp/main'); +startLspServer(); \ No newline at end of file diff --git a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts index 006d91e2d..811a6e738 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts @@ -2,3 +2,4 @@ export { findCreationStackTrace } from '../../toolkit-lib/lib/api/source-tracing/private/stack-source-tracing'; export { WATCH_EXCLUDE_DEFAULTS } from '../../toolkit-lib/lib/actions/watch/private/helpers'; export { createIgnoreMatcher } from '../../toolkit-lib/lib/util/glob-matcher'; +export { SYNTH_LOCK_FILE } from '../../toolkit-lib/lib/api/rwlock'; diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-index-with-source-locations.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-index-with-source-locations.ts new file mode 100644 index 000000000..1a1376e6c --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-index-with-source-locations.ts @@ -0,0 +1,330 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { AssemblyIndex, DEFAULT_USER_CODE_FINDER, StackTrace, type AssemblyIndexNode, type ConstructInfo } from '@aws-cdk/cloud-assembly-api'; +import type { ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; +import { type Branded } from './type-brands'; + +export type AssemblyIndexNodeLike = AssemblyIndexNode | AssemblyResourceNode; + +export interface AssemblyIndexWithSourceLocationsOptions { + readonly appDir: string; + readonly assemblyDir: string; + readonly assemblyIndex: AssemblyIndex; +} + +export interface ISourceFileResolver { + resolve(file: SourceLocation): SourceLocation; +} + +/** + * Holds an AssemblyIndex, and translates stack traces into source locations + */ +export class AssemblyIndexWithSourceLocations { + public static empty() { + return new AssemblyIndexWithSourceLocations({ + appDir: '/tmp', + assemblyDir: '/tmp', + assemblyIndex: new AssemblyIndex(), + }); + } + + public static async createAndIndex(options: AssemblyIndexWithSourceLocationsOptions) { + const ret = new AssemblyIndexWithSourceLocations({ + appDir: await realOrSelf(options.appDir), + assemblyDir: await realOrSelf(options.assemblyDir), + assemblyIndex: options.assemblyIndex, + }); + ret.indexAssembly(); + return ret; + } + + public static async createUnindexed(options: AssemblyIndexWithSourceLocationsOptions) { + return new AssemblyIndexWithSourceLocations({ + appDir: await realOrSelf(options.appDir), + assemblyDir: await realOrSelf(options.assemblyDir), + assemblyIndex: options.assemblyIndex, + }); + } + + public readonly assemblyIndex: AssemblyIndex; + public readonly appDir: PathTranslator; + public readonly assemblyDir: PathTranslator; + + private sourceResolver?: ISourceFileResolver; + private readonly sourceLocationCache = new Map(); + private readonly stackLocationFinder = DEFAULT_USER_CODE_FINDER; + private readonly nodesBySourceFile: Record> = {}; + + private constructor(options: AssemblyIndexWithSourceLocationsOptions) { + this.assemblyIndex = options.assemblyIndex; + this.appDir = new PathTranslator(options.appDir); + this.assemblyDir = new PathTranslator(options.assemblyDir); + } + + public constructsInSourceFile(file: RelativePath): Array<[number, AssemblyIndexNode]> { + return Array.from(this.nodesBySourceFile[file]?.entries() ?? []) + .flatMap(([line, nodes]) => nodes.map(node => [line, node] as [number, AssemblyIndexNode])); + } + + public constructsOnLine(file: RelativePath, line: number): AssemblyIndexNode[] { + return this.nodesBySourceFile[file]?.get(line) ?? []; + } + + public byTemplateLocation(templateFile: RelativePath, logicalId: string) { + return this.assemblyIndex.byTemplateLocation(templateFile, logicalId); + } + + public setSourceResolver(sourceResolver: ISourceFileResolver | undefined) { + this.sourceResolver = sourceResolver; + } + + /** + * Parse the creation stack to a source location, cached on the object. + */ + public sourceLocationFor(node: AssemblyIndexNode): SourceLocation | undefined { + if (!node.creationStack) return undefined; + if (this.sourceLocationCache.has(node)) { + return this.sourceLocationCache.get(node); + } + + let result = this.unmappedSourceLocationFor(node); + + if (result && this.sourceResolver) { + result = this.sourceResolver.resolve(result); + } + + this.sourceLocationCache.set(node, result); + return result; + } + + public traceViolation(violation: ViolatingConstructJson): AssemblyIndexViolationWithSource { + const traced = this.assemblyIndex.traceViolation(violation); + const sourceLocations: SourceLocation[] = (traced.stackTraces ?? []).flatMap((stackTrace) => { + const frame = new StackTrace(stackTrace).findFirstUserFrame(); + if (!frame) return []; + + return { + file: this.appDir.relative(frame.fileName), + line: frame.lineLocation?.line ?? 0, + } satisfies SourceLocation; + }).map(loc => this.sourceResolver?.resolve(loc) ?? loc); + + const creationLocation = traced.node && this.sourceLocationFor(traced.node); + return { + violation: traced.violation, + cfnResource: traced.cfnResource ? { + ...traced.cfnResource, + templatePath: this.assemblyDir.relative(traced.cfnResource.templatePath), + } : undefined, + node: traced.node, + sourceLocations: sourceLocations.length > 0 ? sourceLocations : (creationLocation ? [creationLocation] : []), + }; + } + + private unmappedSourceLocationFor(node: AssemblyIndexNode): SourceLocation | undefined { + if (!node.creationStack) return undefined; + const stack = new StackTrace(node.creationStack); + const frame = stack.findAndParse(this.stackLocationFinder); + + let result: SourceLocation | undefined; + if (frame?.fileName) { + const lineLoc = frame?.lineLocation; + result = { + file: this.appDir.relative(frame.fileName), + line: lineLoc?.line ?? 0, + }; + } + + return result; + } + + /** + * Find all referenced files in the assembly. + */ + public allSourceFiles() { + const sourceFiles = new Set(); + + this.forEveryNode((node) => { + const sourceLocation = this.unmappedSourceLocationFor(node); + if (sourceLocation?.file) { + sourceFiles.add(sourceLocation.file); + } + }); + + return sourceFiles; + } + + public indexAssembly() { + // In case the source locations have changed, clear the cache before re-indexing. + this.sourceLocationCache.clear(); + + this.forEveryNode((node) => { + const sourceLocation = this.sourceLocationFor(node); + if (sourceLocation?.file) { + if (!this.nodesBySourceFile[sourceLocation.file]) { + this.nodesBySourceFile[sourceLocation.file] = new Map(); + } + const line = sourceLocation.line; + if (!this.nodesBySourceFile[sourceLocation.file].has(line)) { + this.nodesBySourceFile[sourceLocation.file].set(line, []); + } + this.nodesBySourceFile[sourceLocation.file].get(line)!.push(node); + } + }); + } + + private forEveryNode(callback: (node: AssemblyIndexNode) => void) { + recurse.call(this, this.assemblyIndex.root); + + function recurse(this: AssemblyIndexWithSourceLocations, node: AssemblyIndexNode) { + callback(node); + for (const child of Object.values(node.children ?? {})) { + recurse.call(this, child); + } + } + } +} + +export class PathTranslator { + constructor(public readonly directory: string) { + } + + /** + * Make sure the given path is relative to the root directories (either app or assembly). + * + * If it's relative, we assume it's already relative to that directory; if it's + * absolute we make it relative. + * + * In either case, if a path tries to escape the root directory we are going to mangle + * it to in invalid path that is guaranteed to not escape the given root. + * + * Also turn it into a POSIX path always. + */ + public relative(file: string): RelativePath { + const absPath = path.isAbsolute(file) ? file : path.join(this.directory, file); + const relPath = path.relative(this.directory, absPath); + const parts = relPath.split(path.sep); + if (parts[0] === '..') { + return 'ESCAPING' as RelativePath; + } + return parts.join(path.posix.sep) as RelativePath; + } + + public absolute(relPath: RelativePath): AbsolutePath { + return path.join(this.directory, relPath) as AbsolutePath; + } + + public async contains(file: string): Promise { + const absPath = await realOrSelf(path.resolve(this.directory, file)); + return absPath === this.directory || absPath.startsWith(this.directory + path.sep); + } +} + +export function isResourceNode(node: AssemblyIndexNodeLike): node is AssemblyResourceNode { + return !!node.cfnResource && 'logicalId' in node.cfnResource && 'assemblyRelativeTemplateFile' in node.cfnResource && 'type' in node.cfnResource; +} + +/** + * A construct from tree.json joined with the CloudFormation metadata of the + * stack it belongs to: the construct path/id plus, for CFN resources, the + * logical ID and resource type. Readers that need more (for example a source + * location) extend this and build the richer tree via {@link buildConstructTree}. + */ +export interface AssemblyResourceNode { + readonly constructPath: string; + readonly id: string; + readonly cfnResource: DefinitelyCloudFormationResource; + readonly constructInfo?: ConstructInfo; +} + +export interface DefinitelyCloudFormationResource { + /** + * CloudFormation resource type. + */ + readonly type: string; + + /** + * CFN logical ID + * + * Comes from either `tree.json` or metadata. + * + * This is usually the logical ID in the `Resources` section of the CloudFormation template, + * but it may also be in the `Parameters` section or `Conditions` section; we don't have + * enough information to tell. + */ + readonly logicalId: string; + + /** + * Template file. + * + * Comes from metadata. + */ + readonly assemblyRelativeTemplateFile: RelativePath; + + readonly cfnProperties?: Record; +} + +export interface AssemblyIndexViolationWithSource { + readonly violation: ViolatingConstructJson; + readonly node?: AssemblyIndexNode; + readonly cfnResource?: AssemblyIndexCloudFormationResource; + readonly sourceLocations: SourceLocation[]; +} + +export interface AssemblyIndexCloudFormationResource { + /** + * The path to the CloudFormation template containing this resource. + * + * This path is relative to the Cloud Assembly root directory. + */ + readonly templatePath: RelativePath; + + /** + * The logical ID of the resource in the CloudFormation template. + */ + readonly logicalId: string; + + /** + * Properties within the construct where the violation was detected. + * + * Either a single component, in which case it regards a top-level property + * name, or a JSON path (starting with `$.`) to indicate a deeper property. + * + * @default - no locations + */ + readonly propertyPaths?: string[]; +} + +export type RelativePath = Branded; +export type AbsolutePath = Branded; + +export function assertRelativePath(x: string): RelativePath; +export function assertRelativePath(x: string | undefined): RelativePath | undefined; +export function assertRelativePath(x: string | undefined): RelativePath | undefined { + if (x === undefined) { + return undefined; + } + if (path.isAbsolute(x)) { + throw new Error(`Expecting a relative path, got ${x}`); + } + return x as RelativePath; +} + +/** + * Resolved source location for a construct, in user-space coordinates. + * Always 1-based line and column. Resolves to .ts when a sibling source + * map exists; otherwise to the .js (or whatever the trace produced). + */ +export interface SourceLocation { + readonly file: RelativePath; + readonly line: number; +} + +/** Real path with symlinks resolved, or the resolved input if it does not exist. */ +async function realOrSelf(p: string): Promise { + try { + return await fs.realpath(p); + } catch { + return p; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-lock.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-lock.ts deleted file mode 100644 index c04467136..000000000 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-lock.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Toolkit } from '@aws-cdk/toolkit-lib'; - -/** - * A held read lock on the cloud assembly directory; `release()` unlocks it. - * Wraps the Toolkit's `fromAssemblyDirectory().produce()` readable. - */ -export interface AssemblyLock { - release(): Promise; -} - -/** Acquire a read lock on the assembly dir; throws a `LockError` on writer contention. */ -export type AcquireAssemblyLock = (assemblyDir: string) => Promise; - -/** - * Builds an assembly read-lock acquirer from a Toolkit. The read lock is the - * Toolkit's own `fromAssemblyDirectory().produce()` lease, so callers never - * touch `RWLock` directly; `release()` disposes the lease. - */ -export function toolkitAssemblyLock(toolkit: Toolkit): AcquireAssemblyLock { - return async (assemblyDir) => { - const cx = await toolkit.fromAssemblyDirectory(assemblyDir, { failOnMissingContext: false }); - const readable = await cx.produce(); - return { release: () => readable.dispose() }; - }; -} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts index cf9f83440..ce8657aca 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts @@ -1,95 +1,87 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { buildConstructTreeAsync, CloudAssembly, MANIFEST_FILE, type ConstructTreeNode } from '@aws-cdk/cloud-assembly-api'; -import { VALIDATION_REPORT_FILE, type PolicyValidationReportJson } from '@aws-cdk/cloud-assembly-schema'; -import { findCreationStackTrace } from '../api-private'; -import { SourceMapResolver, isWithinRoot, type SourceLocation } from './source-resolver'; - -/** - * A construct from the cloud assembly, decorated with the user source location - * where it was created. Extends the generic tree node from cloud-assembly-api - * with the source-map-resolved location the LSP/explorer surfaces. - */ -export interface ConstructNode extends ConstructTreeNode { - /** User source location where the construct was created; undefined for non-TS apps. */ - readonly sourceLocation?: SourceLocation; - readonly children: readonly ConstructNode[]; -} +import { AssemblyIndex, loadUnifiedValidationReport } from '@aws-cdk/cloud-assembly-api'; +import type { IReadableCloudAssembly, PluginReportJson } from '@aws-cdk/toolkit-lib'; +import type { RelativePath } from './assembly-index-with-source-locations'; +import { AssemblyIndexWithSourceLocations } from './assembly-index-with-source-locations'; +import { SourceMapResolver } from './source-resolver'; export interface AssemblyData { - readonly tree: readonly ConstructNode[]; - readonly violations?: PolicyValidationReportJson; + readonly index: AssemblyIndexWithSourceLocations; + readonly violations?: PluginReportJson[]; /** Non-fatal warnings collected while reading the assembly (e.g. unparseable source maps). */ readonly warnings: readonly string[]; + readonly timestamp: number; } export type AssemblyReadResult = - | { readonly status: 'success'; readonly data: AssemblyData } + | { readonly status: 'success'; readonly data: AssemblyData; readonly mtimeMs: number } | { readonly status: 'not-found' } | { readonly status: 'error'; readonly message: string }; -/** - * Decorates the cloud assembly's construct tree with the source location of - * each node and attaches any policy-validation violations. Tree construction - * (tree.json + stack-metadata join) is delegated to buildConstructTreeAsync. - */ -export async function readAssembly(assemblyDir: string): Promise { - const manifestPath = path.join(assemblyDir, MANIFEST_FILE); - if (!fs.existsSync(manifestPath)) { - return { status: 'not-found' }; - } +export async function webAssemblyDataFromAssembly(projectRoot: string, readableAssembly: IReadableCloudAssembly): Promise { + const assembly = readableAssembly.cloudAssembly; + const warnings: string[] = []; + + const index = await AssemblyIndexWithSourceLocations.createUnindexed({ + appDir: projectRoot, + assemblyDir: assembly.directory, + assemblyIndex: await AssemblyIndex.fromAssembly(assembly), + }); + let violations: PluginReportJson[] = []; try { - const assembly = new CloudAssembly(assemblyDir); - // The project root is the parent of the assembly dir: server.ts always - // builds it as /cdk.out, so dirname() recovers the project dir. - // (If --output support is added to relocate cdk.out, thread the IDE-provided - // application dir here instead.) Used to keep every file the resolver reads - // within the project - const projectRoot = path.dirname(assemblyDir); - // One resolver per readAssembly call: caches parsed source maps across - // constructs, scoped so a fresh synth observes any moved/edited maps. - const sourceResolver = new SourceMapResolver(projectRoot); - const tree = await buildConstructTreeAsync(assembly, async (fields, stack, constructPath) => ({ - ...fields, - // templateFile comes from the manifest / a nested stack's aws:asset:path, - // both attacker-influenceable if cdk.out is tampered with. Drop any that - // escape the assembly dir so the template read in resourceTarget stays - // contained - templateFile: fields.templateFile && (await isWithinRoot(assemblyDir, fields.templateFile)) - ? fields.templateFile - : undefined, - sourceLocation: stack - ? await sourceResolver.resolveFrames(findCreationStackTrace(stack, constructPath)) - : undefined, - })); + violations = await loadUnifiedValidationReport(assembly, assembly.stacksRecursively); + } catch (err: any) { + warnings.push(`Failed to load diagnostic report: ${err.message}`); + } - let violations: PolicyValidationReportJson | undefined; - const warnings = [...sourceResolver.warnings]; - try { - violations = loadViolations(assemblyDir); - } catch (err) { - // The producer writes the validation report synchronously, so a corrupt - // file is not reachable through normal synth flow. We treat it the same - // as "no report present" and surface a warning for the rare case. - warnings.push(`Failed to load ${VALIDATION_REPORT_FILE}: ${(err as Error).message}`); - } + // Collect all source files + const sourceFiles = index.allSourceFiles(); + for (const f of sourceFilesInViolations(violations, index)) { + sourceFiles.add(f); + } - return { - status: 'success', - data: { tree, violations, warnings }, - }; - } catch (err) { - return { status: 'error', message: (err as Error).message }; + // Read source maps for .js files and index based on the mapped locations + const map = new SourceMapResolver(index); + await map.loadSourceMapFor(...Array.from(sourceFiles).filter(file => file.endsWith('.js'))); + index.setSourceResolver({ resolve: (loc) => map.reverseMapJs(loc) }); + index.indexAssembly(); + // updateSourceLocationsInViolations(violations, index, map); + + return { + index, + violations, + warnings, + timestamp: assembly.mtimeMs, + }; +} + +function* sourceFilesInViolations( + violations: PluginReportJson[], + index: AssemblyIndexWithSourceLocations, +): Iterable { + for (const plugin of violations ?? []) { + for (const violation of plugin.violations ?? []) { + for (const vc of violation.violatingConstructs ?? []) { + const traced = index.traceViolation(vc); + for (const loc of traced.sourceLocations) { + yield loc.file; + } + } + } } } -/** Loads the policy-validation report from the assembly dir, if present. */ -function loadViolations(assemblyDir: string): PolicyValidationReportJson | undefined { - const reportPath = path.join(assemblyDir, VALIDATION_REPORT_FILE); - if (!fs.existsSync(reportPath)) return undefined; - // Read as data, not via Manifest.loadValidationReport: that loader's version-compat - // check throws on older aws-cdk-lib reports that omit `version`. We only consume - // pluginReports, which are version-independent across producer versions. - return JSON.parse(fs.readFileSync(reportPath, 'utf-8')) as PolicyValidationReportJson; +/* +function updateSourceLocationsInViolations(violations: PluginReportJson[], index: AssemblyIndexWithSourceLocations, map: SourceMapResolver) { + for (const plugin of violations ?? []) { + for (const violation of plugin.violations ?? []) { + for (const vc of violation.violatingConstructs ?? []) { + const traced = index.traceViolation(vc); + for (const loc of traced.sourceLocations) { + yield loc.file; + } + } + } + } } +*/ diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts index ce065f21e..03c839408 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; import { VALIDATION_REPORT_FILE } from '@aws-cdk/cloud-assembly-schema'; import * as chokidar from 'chokidar'; +import { SYNTH_LOCK_FILE } from '../api-private'; /** * The name of the construct tree metadata file emitted alongside the manifest. @@ -51,6 +52,12 @@ export interface AssemblyWatcherOptions { readonly assemblyDir: string; /** Invoked (debounced) when the assembly's signal files change. */ readonly onChange: () => void; + /** + * Invoked when synth write-lock activity (`synth.lock`) first appears, i.e. a + * synth started. `atMs` is the observation time. Lets a consumer date-stamp + * source-file staleness against synth start rather than synth finish. + */ + readonly onSynthActivity?: (atMs: number) => void; /** Receives non-fatal watcher errors. */ readonly onError?: (error: unknown) => void; /** @@ -90,9 +97,19 @@ export function startAssemblyWatcher(options: AssemblyWatcherOptions): AssemblyW const watcher = createWatcher(options.assemblyDir); - watcher.on('all', (_eventName, filePath) => { + watcher.on('all', (eventName, filePath) => { if (closed) return; - if (!ASSEMBLY_SIGNAL_FILES.has(path.basename(filePath))) return; + const base = path.basename(filePath); + if (base === SYNTH_LOCK_FILE) { + // toolkit-lib's `RWLock` creates this marker for the duration of a synth, + // so its appearance is our only signal that a synth has started (the + // explorer never starts synths itself). Record it, but never treat it as + // an assembly refresh: the lock's create/delete would otherwise cause + // spurious reloads. + if (eventName === 'add') options.onSynthActivity?.(Date.now()); + return; + } + if (!ASSEMBLY_SIGNAL_FILES.has(base)) return; if (timer) { clearTimeout(timer); } diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts index 54653fa2c..946e22b09 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; /** @@ -9,8 +9,8 @@ import * as path from 'path'; * via `fromCdkApp`. */ export interface CdkConfig { - /** The `app` command, or `undefined` if missing/malformed. */ - readonly app: string | undefined; + readonly app?: string; + readonly output?: string; } /** @@ -18,18 +18,21 @@ export interface CdkConfig { * 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 { +export async function readCdkConfig(projectDir: string): Promise { 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')); + parsed = JSON.parse(await fs.readFile(configPath, 'utf-8')); } catch { - return { app: undefined }; + return undefined; } - if (parsed === null || typeof parsed !== 'object') return { app: undefined }; + if (parsed === null || typeof parsed !== 'object') return undefined; const app = (parsed as { app?: unknown }).app; - return { app: typeof app === 'string' ? app : undefined }; + const output = (parsed as { output?: unknown }).output; + return { + app: typeof app === 'string' ? app : undefined, + output: typeof output === 'string' ? output : undefined, + }; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts b/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts new file mode 100644 index 000000000..c0716a638 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts @@ -0,0 +1,66 @@ +import { ToolkitError, type FromCdkAppOptions, type ICloudAssemblySource, type IReadableCloudAssembly, type Toolkit } from '@aws-cdk/toolkit-lib'; +import { readCdkConfig } from './cdk-config'; + +/** + * Returns an assembly source from a CDK app directory + * + * This could/should have been built into the toolkit library itself, but isn't + * currently. + * + * Returns `undefined` if we can't determine a valid CDK app in the directory. + */ +export async function fromCdkAppDirectory( + toolkit: Toolkit, + appDirectory: string, + props: FromCdkAppOptions = {}, +): Promise { + const config = await readCdkConfig(appDirectory); + + if (!config?.app) { + return undefined; + } + + return toolkit.fromCdkApp(config.app, { + ...props, + outdir: config.output, + workingDirectory: appDirectory, + lookups: false, + // Make jsii forward host-language (Python/Java) source frames; TS resolves via source maps. + env: { + ...props.env, + JSII_HOST_STACK_TRACES: '1', + }, + }); +} + +/** + * Retry the given producer as long as the error is a lock + */ +export async function retryOnLock(abort: AbortSignal, fn: () => Promise) { + let delayMs = 500; + const deadline = AbortSignal.any([abort, AbortSignal.timeout(60 * 60 * 1000)]); + while (true) { + try { + return await fn(); + } catch (error: any) { + if (!ToolkitError.isLockError(error)) { + throw error; + } + if (deadline.aborted) { + throw new Error('Gave up waiting for lock'); + } + await sleep(Math.floor(Math.random() * delayMs)); + delayMs = Math.min(delayMs * 2, 10_000); + } + } +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function readAssemblyDirectory(toolkit: Toolkit, abort: AbortSignal, assemblyDir: string) { + const asmSource = await toolkit.fromAssemblyDirectory(assemblyDir); + // NOTE: Purposely using asmSource.produce() here: toolkit.synth() would throw on validation failures and we don't necessarily want that. + return retryOnLock(abort, () => asmSource.produce({ abort: abort })); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/safe-url.ts b/packages/@aws-cdk/cdk-explorer/lib/core/safe-url.ts new file mode 100644 index 000000000..55221cf27 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/safe-url.ts @@ -0,0 +1,13 @@ +/* eslint-disable no-restricted-imports */ +import { pathToFileURL as _pathToFileURL } from 'node:url'; +import type { RelativePath } from './assembly-index-with-source-locations'; +import type { NotBranded } from './type-brands'; + +/** + * Do not allow calling this function on relative URLs + * + * Force making the path absolute first. + */ +export function pathToFileURL(path: NotBranded): URL { + return _pathToFileURL(path); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts index 12b1f378e..5721f14e1 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts @@ -1,19 +1,11 @@ -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; -import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; +import { fileURLToPath } from 'url'; +import { LEAST_UPPER_BOUND, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; import * as convertSourceMap from 'convert-source-map'; - -/** - * Resolved source location for a construct, in user-space coordinates. - * Always 1-based line and column. Resolves to .ts when a sibling source - * map exists; otherwise to the .js (or whatever the trace produced). - */ -export interface SourceLocation { - readonly file: string; - readonly line: number; - readonly column: number; -} +import pLimit from 'p-limit'; +import { type RelativePath, type AssemblyIndexWithSourceLocations, type SourceLocation } from './assembly-index-with-source-locations'; +import { pathToFileURL } from './safe-url'; /** * Resolves construct creation stack traces to user source locations, caching @@ -23,7 +15,7 @@ export interface SourceLocation { */ export class SourceMapResolver { // Parsed source maps keyed by absolute .js path; null = known to have no map. - private readonly cache = new Map(); + private readonly cache = new Map(); private readonly collectedWarnings: string[] = []; /** @@ -33,7 +25,7 @@ export class SourceMapResolver { * the source maps they point at), which are attacker-influenceable if * cdk.out is tampered with, so anything escaping the root is dropped */ - constructor(private readonly projectRoot: string) { + constructor(private readonly index: AssemblyIndexWithSourceLocations) { } /** Non-fatal warnings collected during resolution (e.g. an unparseable .js.map). */ @@ -42,56 +34,43 @@ export class SourceMapResolver { } /** - * Resolve the user source location from a creation stack trace (the frames - * produced by toolkit-lib's findCreationStackTrace). Returns undefined when - * there's no trace or no frame resolves to an in-root user source file. + * Load and cache source map for a given .js file. */ - public async resolveFrames(frames: readonly string[] | undefined): Promise { - if (!frames) return undefined; - - // First in-root, supported-source frame wins. Host-language traces carry - // framework .js frames (outside the root) ahead of the user's .py - // frame, so skip past them rather than stop at the first parsed frame. - for (const frame of frames) { - const parsed = parseFrame(frame); - if (!parsed) continue; - const kind = sourceKind(parsed.file); - if (!kind) continue; - // The frame's file path is assembly-derived and attacker-influenceable. - // Never read or surface a location outside the project. - if (!(await isWithinRoot(this.projectRoot, parsed.file))) continue; - return kind === 'host' ? normalizeHostFrame(parsed) : this.mapJsToOriginalSource(parsed); - } - return undefined; + public async loadSourceMapFor(...jsFiles: RelativePath[]): Promise { + const limit = pLimit(20); // Limit concurrent source map loads to 10 + + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + await Promise.all( + jsFiles.map((jsFile) => limit(() => this.loadTraceMap(jsFile))), + ); } /** * Map a .js location to its original .ts via a sibling .js.map. Returns the * input location unchanged when it isn't a .js file or has no usable map. */ - private async mapJsToOriginalSource(loc: SourceLocation): Promise { - // Input is allow-listed to .ts/.tsx/.js by resolveFrames; .ts/.tsx are - // already source-space, only .js needs mapping back to its original. - if (!loc.file.endsWith('.js')) return loc; - - const tracer = await this.loadTraceMap(loc.file); + public reverseMapJs(loc: SourceLocation): SourceLocation { + const tracer = this.cache.get(loc.file); if (!tracer) return loc; - // trace-mapping uses 0-based columns, stack frames are 1-based. - const orig = originalPositionFor(tracer, { line: loc.line, column: loc.column - 1 }); + // We only have a line, so ask for the first mapping at or after column 0 on + // that line; the default lower-bound search misses lines whose first mapping + // starts past column 0 (indented code). + const orig = originalPositionFor(tracer, { line: loc.line, column: 0, bias: LEAST_UPPER_BOUND }); if (!orig.source || orig.line == null || orig.column == null) return loc; // orig.source is already resolved against the map's location (we pass the // map URL when building the TraceMap), with any sourceRoot applied — so it's // a file:// URL for local maps. Convert it back to a path. - const file = orig.source.startsWith('file://') ? fileURLToPath(orig.source) : orig.source; - // `sources` comes from the .js.map, which is also assembly-derived. Keep the - // mapped original within the project, else fall back to the (in-root) .js. - if (!(await isWithinRoot(this.projectRoot, file))) return loc; - return { file, line: orig.line, column: orig.column + 1 }; + const origFile = orig.source.startsWith('file://') ? fileURLToPath(orig.source) : orig.source; + + return { + file: this.index.appDir.relative(origFile), + line: orig.line, + }; } - private async loadTraceMap(jsFile: string): Promise { + private async loadTraceMap(jsFile: RelativePath): Promise { const cached = this.cache.get(jsFile); if (cached !== undefined) return cached; @@ -106,87 +85,36 @@ export class SourceMapResolver { * any filename (resolved relative to the .js). Returns null when the file has * no map; warns when a referenced map exists but can't be read or parsed. */ - private async readSourceMap(jsFile: string): Promise { - let code: string; + private async readSourceMap(jsFile: RelativePath): Promise { try { - code = await fs.promises.readFile(jsFile, 'utf-8'); - } catch { - return null; // .js not present/readable -> treat as "no map" - } + const fullSourceFilePath = this.index.appDir.absolute(jsFile); + + let code = await fs.readFile(fullSourceFilePath, 'utf-8'); - try { // Inline maps live at the .js; external maps live at the referenced file. // The map URL tells trace-mapping where `sources` (and sourceRoot) resolve. - let mapUrl = pathToFileURL(jsFile).href; - const converter = - convertSourceMap.fromSource(code) - ?? (await convertSourceMap.fromMapFileSource(code, async (mapFile) => { - const mapPath = path.resolve(path.dirname(jsFile), mapFile); + let mapUrl = pathToFileURL(fullSourceFilePath).href; + let converter = convertSourceMap.fromSource(code); + + if (!converter) { + converter = await convertSourceMap.fromMapFileSource(code, async (mapFile) => { + const mapPath = path.resolve(path.dirname(fullSourceFilePath), mapFile); // sourceMappingURL is assembly-derived; don't read a map outside the // project. The throw is caught below and surfaced as a load warning. - if (!(await isWithinRoot(this.projectRoot, mapPath))) { + if (!(await this.index.appDir.contains(mapPath))) { throw new Error(`source map path escapes the project root: ${mapPath}`); } mapUrl = pathToFileURL(mapPath).href; - return fs.promises.readFile(mapPath, 'utf-8'); - })); - return converter ? new TraceMap(converter.toObject(), mapUrl) : null; + return fs.readFile(mapPath, 'utf-8'); + }); + } + + return converter ? new TraceMap(converter.toObject(), mapUrl) : undefined; } catch (err) { // A map was referenced but couldn't be read/parsed. Surface it once // rather than letting a broken map masquerade as "no source map". this.collectedWarnings.push(`Source map for ${jsFile} failed to load: ${(err as Error).message}`); - return null; + return undefined; } } } -// TypeScript/JavaScript frames go through source-map resolution (.js -> .ts). -const TS_JS_EXTENSIONS = ['.ts', '.tsx', '.js'] as const; -// jsii host-language frames already point at user source (no source map needed). -const HOST_LANGUAGE_EXTENSIONS = ['.py'] as const; - -function sourceKind(file: string): 'tsjs' | 'host' | undefined { - if (TS_JS_EXTENSIONS.some((ext) => file.endsWith(ext))) return 'tsjs'; - if (HOST_LANGUAGE_EXTENSIONS.some((ext) => file.endsWith(ext))) return 'host'; - return undefined; -} - -// Treat the host column as 1-based; jsii sends 0 when unavailable, so clamp 0 to line start. -function normalizeHostFrame(loc: SourceLocation): SourceLocation { - return { file: loc.file, line: loc.line, column: Math.max(1, loc.column) }; -} - -// " (:[:])"; host frames omit the column when -// unavailable, so it's optional. Anchoring on "(" avoids a leading "at ". -const FRAME_RE = /\(([^()\s][^()]*?):(\d+)(?::(\d+))?\)\s*$/; - -function parseFrame(frame: string): SourceLocation | undefined { - const m = FRAME_RE.exec(frame); - if (!m) return undefined; - const line = Number(m[2]); - // Host frames may omit the column; treat absent as 0 (unavailable). - const column = m[3] !== undefined ? Number(m[3]) : 0; - if (!Number.isFinite(line) || !Number.isFinite(column)) return undefined; - return { file: m[1], line, column }; -} - -/** - * True when `candidate` resolves to a path inside `root` (or is `root` itself). - * Both are resolved to absolute, symlink-real paths first, so a file reached - * through a symlinked directory pointing outside `root` is rejected too. Used - * to keep file reads driven by (attacker-influenceable) cloud-assembly paths - * within the project before any read happens. - */ -export async function isWithinRoot(root: string, candidate: string): Promise { - const realRoot = await realOrSelf(path.resolve(root)); - const realCandidate = await realOrSelf(path.resolve(candidate)); - return realCandidate === realRoot || realCandidate.startsWith(realRoot + path.sep); -} - -/** Real path with symlinks resolved, or the resolved input if it does not exist. */ -async function realOrSelf(p: string): Promise { - try { - return await fs.promises.realpath(p); - } catch { - return p; - } -} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts b/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts new file mode 100644 index 000000000..8fbefe812 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts @@ -0,0 +1,130 @@ +import * as chokidar from 'chokidar'; +import { WATCH_EXCLUDE_DEFAULTS, createIgnoreMatcher } from '../api-private'; +import type { FileWatcher } from './assembly-watcher'; + +// Coalesce an editor's burst of writes (e.g. save-all) into a single signal. +const DEBOUNCE_MS = 200; + +/** + * Paths under the app dir that never count as a source change: toolkit-lib's + * own watch exclusions, dependencies, our synth output (`cdk.out`), and + * dotfiles (editor configs, `.git`). Applied both at the chokidar level (so the + * tree is never traversed) and re-checked in the handler. + */ +export const SOURCE_WATCH_EXCLUDES = [ + ...WATCH_EXCLUDE_DEFAULTS, + '**/node_modules/**', + '**/cdk.out/**', + '.*', + '**/.*', + '**/.*/**', +]; + +/** A running source-tree watcher. */ +export interface SourceWatcher { + /** Stop watching and release the underlying file handles. */ + close(): Promise; +} + +export interface SourceWatcherOptions { + /** The application directory whose source tree is watched. */ + readonly appDir: string; + /** Invoked (debounced) when a non-ignored source file changes. */ + readonly onChange: () => void; + /** Receives non-fatal watcher errors. */ + readonly onError: (error: unknown) => void; + /** + * Factory for the underlying file watcher. Defaults to chokidar; overridden + * in tests with a fake so behavior is verified without real file IO. + */ + readonly createWatcher?: (appDir: string) => FileWatcher; +} + +// Thin wrapper over real chokidar; exercised via integration, not unit tests. +/* c8 ignore start */ +function defaultCreateWatcher(appDir: string): FileWatcher { + // chokidar applies the same ignore policy while traversing, so excluded paths + // (node_modules, cdk.out, dotfiles) are skipped at the source instead of + // streamed to the handler. It emits absolute paths, which the handler + // re-checks against the same policy. + return chokidar.watch(appDir, { + ignored: createIgnoreMatcher({ exclude: SOURCE_WATCH_EXCLUDES, rootDir: appDir }), + ignoreInitial: true, + }) as unknown as FileWatcher; +} +/* c8 ignore stop */ + +/** A debounced action: coalesces a burst of triggers into one delayed run. */ +interface Debounced { + /** Schedule `action`, resetting the delay if a run is already pending. */ + trigger(): void; + /** Drop a pending run, if any. */ + cancel(): void; +} + +/** + * Run `action` once, `delayMs` after the most recent `trigger()`. Each new + * trigger restarts the delay, so a burst collapses into a single trailing run. + * `cancel()` discards a pending run (used on close). This is a debounce, not a + * sleep: the pending run is cancelable and reset on every trigger. + */ +function debounce(action: () => void, delayMs: number): Debounced { + let timer: NodeJS.Timeout | undefined; + return { + trigger() { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = undefined; + action(); + }, delayMs); + }, + cancel() { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} + +/** + * Watch a CDK app's source tree and fire `onChange` (debounced) when a + * non-ignored file changes. + */ +export function startSourceWatcher(options: SourceWatcherOptions): SourceWatcher { + const createWatcher = options.createWatcher ?? defaultCreateWatcher; + // Drop changes to ignored paths so they never raise a spurious signal. chokidar + // emits absolute paths, which the matcher's rootDir resolves before matching. + const shouldIgnore = createIgnoreMatcher({ exclude: SOURCE_WATCH_EXCLUDES, rootDir: options.appDir }); + + let closed = false; + const debounced = debounce(() => { + try { + options.onChange(); + } catch (error) { + options.onError(error); + } + }, DEBOUNCE_MS); + + const watcher = createWatcher(options.appDir); + + watcher.on('all', (_eventName, filePath) => { + if (closed) return; + if (shouldIgnore(filePath)) return; + debounced.trigger(); + }); + + watcher.on('error', (error) => { + options.onError(error); + }); + + return { + async close() { + closed = true; + debounced.cancel(); + await watcher.close(); + }, + }; +} 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 838e1b912..7121069df 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -1,5 +1,5 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; -import { readCdkConfig } from './cdk-config'; +import { fromCdkAppDirectory, retryOnLock } from './from-cdk-app-directory'; /** * Hold synth()'s read lock this long before releasing it. While we hold a @@ -14,21 +14,17 @@ const READER_HANDOFF_DELAY_MS = 100; /** * 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, 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. - * `unavailable` means `cdk.json` is missing or has no `app` key, so there is + * - `success` means the assembly was written to disk (the watcher will see it). + * - `app-failure` means the user's CDK app threw, did not compile, or needs uncached context lookups. + * - `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 + * - `error` is reserved for anything we did not classify, including failures * during dispose. */ export type SynthRunResult = | { status: 'success' } | { status: 'app-failure'; message: string; details?: string } - | { status: 'lock-conflict' } | { status: 'unavailable' } | { status: 'error'; message: string }; @@ -37,6 +33,8 @@ export interface SynthRunnerOptions { readonly toolkit: Toolkit; /** Directory containing the user's `cdk.json`; also the synth working dir. */ readonly projectDir: string; + + readonly abort: AbortSignal; } /** @@ -50,18 +48,14 @@ export interface SynthRunnerOptions { * 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; + let asm; try { - const cx = await options.toolkit.fromCdkApp(app, { - workingDirectory: options.projectDir, - lookups: false, - // Make jsii forward host-language (Python/Java) source frames; TS resolves via source maps. - env: { JSII_HOST_STACK_TRACES: '1' }, - }); - cached = await options.toolkit.synth(cx); + const cx = await fromCdkAppDirectory(options.toolkit, options.projectDir); + if (!cx) { + return { status: 'unavailable' }; + } + + asm = await retryOnLock(options.abort, () => options.toolkit.synth(cx, { abort: options.abort })); } catch (err) { return classify(err); } @@ -71,7 +65,7 @@ export async function runSynth(options: SynthRunnerOptions): Promise((resolve) => setTimeout(resolve, READER_HANDOFF_DELAY_MS)); try { - await cached.dispose(); + await asm.dispose(); } catch (err) { // Releases the read lock synth() left on the assembly. Failure is rare (an // fs error deleting the lock file); report it as `error` so the next synth @@ -83,9 +77,6 @@ export async function runSynth(options: SynthRunnerOptions): Promise`, so `JSON.parse(text.slice(start, end))` returns the + * resource object. + * + * Returns `undefined` when the text is not valid JSON, or when there is no such + * resource. + */ +export function resolveResourceRange(templateText: string, logicalId: string): OffsetRange | undefined { + return indexTemplateRanges(templateText)?.block(logicalId); +} + +/** A resource's block range plus the range of each of its top-level properties. */ +export interface ResourceRanges { + /** The resource's value block `{ ... }` (value-only, like {@link resolveResourceRange}). */ + readonly block: OffsetRange; + /** Each top-level property keyed by name, covering the whole `"Key": value` entry. */ + readonly properties: Record; +} + +/** + * Resolves, in a single parse, a resource's block range and the range of each + * of its top-level properties. Property ranges span `"Key": value` so a + * navigation lands on the named property. + * + * Returns `undefined` when the text is not valid JSON or there is no such resource. + */ +export function resolveResourceRanges(templateText: string, logicalId: string): ResourceRanges | undefined { + return indexTemplateRanges(templateText)?.resource(logicalId); +} + +/** + * A template parsed once into a queryable range index, so a caller resolving + * several resources (for example a hover over a multi-resource construct) pays + * the parse cost once instead of per resource. + */ +export interface TemplateRanges { + /** The resource's value-block range, or `undefined` if there is no such resource. */ + block(logicalId: string): OffsetRange | undefined; + /** The resource's block plus its top-level property ranges, or `undefined` if absent. */ + resource(logicalId: string): ResourceRanges | undefined; +} + +/** + * Parse a template once into a {@link TemplateRanges} index. Returns `undefined` + * when the text is not valid JSON. The single-resource {@link resolveResourceRange} + * and {@link resolveResourceRanges} are thin wrappers over this. + */ +export function indexTemplateRanges(templateText: string): TemplateRanges { + const pointers = parsePointers(templateText); + if (pointers === undefined) { + return { + block: () => undefined, + resource: () => undefined, + }; + } + return { + block: (logicalId) => blockRange(pointers, escapePointerSegment(logicalId)), + resource: (logicalId) => { + const escaped = escapePointerSegment(logicalId); + const block = blockRange(pointers, escaped); + return block === undefined ? undefined : { block, properties: propertyRanges(pointers, escaped) }; + }, + }; +} + +/** The value-block range of `/Resources/`, or `undefined` if absent. */ +function blockRange(pointers: Pointers, escapedId: string): OffsetRange | undefined { + const mapping = pointers[`/Resources/${escapedId}`]; + return mapping === undefined ? undefined : { start: mapping.value.pos, end: mapping.valueEnd.pos }; +} + +/** Key+value ranges of each top-level property of `/Resources/`. */ +function propertyRanges(pointers: Pointers, escapedId: string): Record { + const properties: Record = {}; + for (const [pointer, mapping] of Object.entries(pointers)) { + // This resource's top-level properties only. json-source-map types the key + // position as optional (absent for array items); a /Properties/ pointer + // is always an object member, so this narrows the type, not an impossible case. + const match = /^\/Resources\/([^/]+)\/Properties\/([^/]+)$/.exec(pointer); + if (match === null || match[1] !== escapedId || mapping.key === undefined) { + continue; + } + properties[unescapePointerSegment(match[2])] = { start: mapping.key.pos, end: mapping.valueEnd.pos }; + } + return properties; +} + +/** + * Resolves block and property ranges for ALL resources in a single parse pass. + * Equivalent to calling `resolveResourceRanges` per logical ID, but O(1) parses + * instead of O(N). + */ +export function resolveAllResourceRanges(templateText: string): Record | undefined { + const pointers = parsePointers(templateText); + if (pointers === undefined) { + return undefined; + } + + const result: Record = {}; + const resourceProperties = new Map>(); + + for (const [pointer, mapping] of Object.entries(pointers)) { + // 'pointer' looks like '/
//Properties/...' etc. + const justAResource = /^\/([^/]+)\/([^/]+)$/.exec(pointer); + + // We ignore the 'Section' part; 'logicalId' might be an index into any of the + // Sections but we don't have enough information to pinpoint the right section. + // That means if both a Parameter and Resource have the same identifier, we might + // get this wrong. Unlikely enough to happen that this will suffice until we run + // into that practice. + if (justAResource) { + const id = unescapePointerSegment(justAResource[2]); + result[id] = { block: { start: mapping.value.pos, end: mapping.valueEnd.pos }, properties: {} }; + continue; + } + + const propMatch = /^\/Resources\/([^/]+)\/Properties\/([^/]+)$/.exec(pointer); + if (propMatch && mapping.key !== undefined) { + const id = unescapePointerSegment(propMatch[1]); + let props = resourceProperties.get(id); + if (!props) { + props = {}; + resourceProperties.set(id, props); + } + props[unescapePointerSegment(propMatch[2])] = { start: mapping.key.pos, end: mapping.valueEnd.pos }; + } + } + + for (const [id, props] of resourceProperties) { + if (result[id]) { + result[id] = { block: result[id].block, properties: props }; + } + } + + return result; +} + +/** + * The inverse of `resolveResourceRange`: given a character offset into a + * template's text, returns the logical id of the resource whose block contains + * that offset, for linking a position in the template back to its construct. + * + * Returns `undefined` when the text is not valid JSON, or when the offset is not + * inside any `Resources/` block (for example whitespace, the + * `Resources` key, or another top-level section). + */ +export function resolveLogicalIdAtOffset(templateText: string, offset: number): string | undefined { + const pointers = parsePointers(templateText); + if (pointers === undefined) { + return undefined; + } + for (const [pointer, mapping] of Object.entries(pointers)) { + // Match only top-level resources (`/Resources/`), not nested property + // pointers like `/Resources//Properties/...`. + const match = /^\/Resources\/([^/]+)$/.exec(pointer); + if (match && offset >= mapping.value.pos && offset < mapping.valueEnd.pos) { + return unescapePointerSegment(match[1]); + } + } + return undefined; +} + +/** Parse the template into its JSON-pointer map, or `undefined` if it is not valid JSON. */ +function parsePointers(templateText: string): Pointers | undefined { + try { + return parse(templateText).pointers; + } catch { + return undefined; + } +} + +/** Escape a single path segment for use in a JSON pointer. */ +function escapePointerSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** Reverse of `escapePointerSegment`. */ +function unescapePointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/type-brands.ts b/packages/@aws-cdk/cdk-explorer/lib/core/type-brands.ts new file mode 100644 index 000000000..7785cbb21 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/type-brands.ts @@ -0,0 +1,32 @@ +/** + * Type branding + * + * This allows marking certain types as having gone through particular operations. + * + * Branded types can be used anywhere the base type is expected, but the base type + * cannot be used where a branded type is expected; the values have to go through + * a type assertion operation to confirm their brand. + * + * Usage: + * + * ``` + * type ValidatedString = Branded; + * + * function validate(x: string): asserts x is ValidatedString { + * // ... throw an error if not + * } + * + * function isValid(x: string): x is ValidatedString { + * // ... throw an error if not + * } + * ``` + */ + +// This construct purely happens at type checking time. There is no run-time impact. +// Hence, we never even have to construct values of this type. +declare const __brand: unique symbol; + +export type Brand = { [__brand]: B }; +export type Branded = T & Brand; + +export type NotBranded> = T extends B ? never : T; diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/undef.ts b/packages/@aws-cdk/cdk-explorer/lib/core/undef.ts new file mode 100644 index 000000000..ba88e9a3a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/undef.ts @@ -0,0 +1,9 @@ +/** + * Helpers for mapping over undefined values + * + * We have so many of them in this code base that this gets rid of repetitive undefined checks. + */ + +export function umap(value: T | undefined, fn: (value: T) => U): U | undefined { + return value === undefined ? undefined : fn(value); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/index.ts b/packages/@aws-cdk/cdk-explorer/lib/index.ts index 45fde8d68..f2c7cd1ea 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/index.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/index.ts @@ -1,8 +1,6 @@ export const VERSION = '0.0.0'; -export { readAssembly } from './core/assembly-reader'; -export type { AssemblyData, AssemblyReadResult, ConstructNode } from './core/assembly-reader'; -export type { SourceLocation } from './core/source-resolver'; +export type { AssemblyData, AssemblyReadResult } from './core/assembly-reader'; export { startLspServer } from './lsp/main'; export { cdkLspManifest } from './lsp/features'; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index f12e0b5d1..4302d9298 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -1,10 +1,9 @@ -import { pathToFileURL } from 'url'; -import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; +import { ENTIRE_TEMPLATE_LOGICALID_MARKER } from '@aws-cdk/cloud-assembly-api'; import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node'; import { COMMAND_DISABLE_AUTO_SYNTH, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_SYNTH_NOW } from './commands'; +import { histogram } from './summarize'; import { resourceTarget, type ResourceTarget } from './template-locator'; -import type { ConstructNode } from '../core/assembly-reader'; -import type { SourceLocation } from '../core/source-resolver'; +import { type AssemblyResourceNode, isResourceNode, type AssemblyIndexWithSourceLocations } from '../core/assembly-index-with-source-locations'; /** Command the client registers to open a resource in its template. */ export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource'; @@ -23,22 +22,25 @@ export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource'; * @param autoSynthEnabled - current toggle state; controls which header lenses appear */ export async function codeLensesForFile( - index: ConstructIndex, - fileUri: string, + index: AssemblyIndexWithSourceLocations, + assemblyDir: string, + filePath: string, autoSynthEnabled: boolean, ): Promise { - const matches = [...index] - .filter((node) => isResourceOnFile(node, fileUri)) - .map((node) => ({ line: node.sourceLocation.line, node })); + const matches = index.constructsInSourceFile(index.appDir.relative(filePath)) + .filter(([_, x]) => isResourceNode(x) && x.cfnResource.type && x.cfnResource.logicalId !== ENTIRE_TEMPLATE_LOGICALID_MARKER) + .map(([line, node]) => ({ line, node: node as unknown as AssemblyResourceNode })); // Multiple resources can map to one line when an L2 construct fans out // (e.g. an L2 producing a primary resource + auxiliary resources). Resolve // sequentially so the number of concurrent reads never grows with app size. const l1Lenses: CodeLens[] = []; for (const [line, group] of groupBy(matches, (m) => m.line)) { + const typeGrouped = groupByType(group.map((m) => m.node)); + l1Lenses.push({ range: lineRange(line), - command: await commandFor(group.map((m) => m.node)), + command: await commandFor(assemblyDir, typeGrouped), }); } @@ -56,6 +58,33 @@ export async function codeLensesForFile( return [...headerLenses, ...l1Lenses]; } +function groupByType(nodes: readonly AssemblyResourceNode[]): Map { + const grouped = groupBy(nodes, resourceTypeDescription); + for (const group of grouped.values()) { + group.sort(sortByDescription); + } + return grouped; +} + +export function resourceTypeDescription(n: AssemblyResourceNode): string { + return n.cfnResource.type ?? n.constructInfo?.fqn ?? 'Construct'; +} + +function sortByDescription(a: AssemblyResourceNode, b: AssemblyResourceNode): number { + return descriptionOrder(resourceTypeDescription(a)) - descriptionOrder(resourceTypeDescription(b)) || + resourceTypeDescription(a).localeCompare(resourceTypeDescription(b)); +} + +function descriptionOrder(x: string) { + if (x.includes('::')) { + return 0; + } + if (x.includes('.')) { + return 1; + } + return 2; +} + /** * One selectable resource on a lens, shaped as a VS Code QuickPick item so the * client renders it directly: `label` is the CFN type, `description` the @@ -72,13 +101,15 @@ interface ResourceChoice { * client opens directly (one) or via a picker (several). Unresolvable resources * are dropped; a line where none resolve stays title-only. */ -async function commandFor(nodes: readonly ResourceConstruct[]): Promise { +async function commandFor(assemblyDir: string, nodes: Map): Promise { const title = titleFor(nodes); const choices: ResourceChoice[] = []; - for (const node of nodes) { - const target = await resourceTarget(node); - if (target !== undefined) { - choices.push({ label: node.type, description: friendlyName(node.path), target }); + for (const [type, ns] of nodes.entries()) { + for (const node of ns) { + const target = await resourceTarget(assemblyDir, node); + if (target !== undefined) { + choices.push({ label: type, description: friendlyName(node.constructPath), target }); + } } } if (choices.length === 0) { @@ -87,27 +118,6 @@ async function commandFor(nodes: readonly ResourceConstruct[]): Promise return { title, command: OPEN_RESOURCE_COMMAND, arguments: [choices] }; } -/** A construct that produces a CFN resource and carries a source location. */ -export interface ResourceConstruct extends ConstructNode { - readonly sourceLocation: SourceLocation; - readonly logicalId: string; - readonly type: string; -} - -/** - * A node gets a lens only if it maps to a CFN resource (has logicalId + type) - * and has a source location in the requested file. Wrapper nodes and non-TS - * constructs are excluded. - */ -export function isResourceOnFile(node: ConstructNode, fileUri: string): node is ResourceConstruct { - return ( - node.sourceLocation !== undefined && - node.logicalId !== undefined && - node.type !== undefined && - pathToFileURL(node.sourceLocation.file).toString() === fileUri - ); -} - /** Group items by a derived key, preserving first-seen order. */ export function groupBy(items: readonly T[], key: (item: T) => K): Map { const out = new Map(); @@ -126,12 +136,14 @@ function lineRange(line1Based: number): Range { return { start: { line, character: 0 }, end: { line, character: 0 } }; } -function titleFor(resources: readonly ResourceConstruct[]): string { - if (resources.length === 1) { - return `Creates ${resources[0].type}`; +function titleFor(resources: Map): string { + if (resources.size === 1 && Array.from(resources.values())[0].length === 1) { + return `Creates ${Array.from(resources.keys())[0]}`; } - const types = resources.map((r) => r.type).join(', '); - return `Creates ${resources.length} resources: ${types}`; + + const allResources = Array.from(resources.values()).flat(); + const counts = Array.from(resources.values()).map((ns) => ns.length); + return `Creates ${sum(counts)} resources: ${histogram(allResources)}`; } /** Developer-facing construct name: the construct path without the synthetic CfnResource leaf. */ @@ -143,3 +155,7 @@ function friendlyName(constructPath: string): string { } return segments.join('/'); } + +function sum(numbers: readonly number[]): number { + return numbers.reduce((acc, n) => acc + n, 0); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts index bb89f0a3a..64da29450 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -1,5 +1,3 @@ -import type { SynthRunResult } from '../core/synth-runner'; - /** * 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). @@ -30,16 +28,14 @@ export interface NotifySink { } export interface CommandHandlerOptions { - /** Invokes a single synth. Resolves with the typed outcome; never rejects. */ - readonly synth: () => Promise; + /** Invokes a single synth */ + readonly synth: () => Promise; /** 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; } -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...'; /** @@ -63,8 +59,7 @@ export async function executeCommand( case COMMAND_SYNTH_NOW: { - const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); - handleSynthResult(result, options.notify); + await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); } return; @@ -72,23 +67,3 @@ export async function executeCommand( 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 '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/construct-tree-request.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/construct-tree-request.ts index ebf724718..684d3012b 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/construct-tree-request.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/construct-tree-request.ts @@ -1,5 +1,5 @@ import { RequestType0 } from 'vscode-languageserver'; -import type { SourceLocation } from '../core/source-resolver'; +import type { AbsolutePath } from '../core/assembly-index-with-source-locations'; /** * Custom LSP request that returns the source-resolved construct tree, flattened @@ -22,9 +22,9 @@ export interface ConstructSourceEntry { /** CFN logical id, if this construct maps to a resource. */ readonly logicalId?: string; /** User source location where the construct was created; undefined when unresolved. */ - readonly sourceLocation?: SourceLocation; + readonly sourceLocation?: AbsoluteSourceLocation; /** Absolute path to the construct's template, containment-checked; undefined when none. */ - readonly templateFile?: string; + readonly templateFile?: AbsolutePath; /** * Character offset of the resource's block within `templateFile`, so a client * can open the template positioned on the resource. Undefined when there is no @@ -46,3 +46,8 @@ export interface GetConstructTreeResult { /** No request params: the server serves its single initialized project. */ export const GetConstructTreeRequest = new RequestType0(GET_CONSTRUCT_TREE_METHOD); + +export interface AbsoluteSourceLocation { + readonly file: AbsolutePath; + readonly line: number; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/diagnostics.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/diagnostics.ts index 7a7f8eba6..543df850d 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/diagnostics.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/diagnostics.ts @@ -1,9 +1,8 @@ -import { pathToFileURL } from 'url'; -import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import type { - PolicyValidationReportJson, + PluginReportJson, PolicyViolationJson, PolicyViolationSeverity, + ViolatingConstructJson, } from '@aws-cdk/cloud-assembly-schema'; import { type Diagnostic, @@ -11,8 +10,8 @@ import { type Range, } from 'vscode-languageserver/node'; import { groupBy } from './codelens'; -import type { ConstructNode } from '../core/assembly-reader'; -import type { SourceLocation } from '../core/source-resolver'; +import type { AssemblyIndexWithSourceLocations } from '../core/assembly-index-with-source-locations'; +import { pathToFileURL } from '../core/safe-url'; // Range end character: max valid LSP uinteger (2^31-1); clients clamp it to the // line length. Not Number.MAX_VALUE, which VS Code tolerates but IntelliJ's @@ -36,66 +35,68 @@ export interface MapViolationsResult { * (with a reason) rather than thrown. */ export function mapViolationsToDiagnostics( - violations: PolicyValidationReportJson | undefined, - index: ConstructIndex, + violations: PluginReportJson[] | undefined, + index: AssemblyIndexWithSourceLocations, ): MapViolationsResult { const dropped: Array = []; const located: Array<{ uri: string; diagnostic: Diagnostic }> = []; - for (const { pluginName, violation, constructPath } of flattenTargets(violations)) { - const anchored = anchorViolation(constructPath, index); - if ('reason' in anchored) { - dropped.push({ ruleName: violation.ruleName, constructPath: constructPath ?? '', reason: anchored.reason }); + for (const { pluginName, violatedRule, constructPath, violation } of flattenTargets(violations)) { + const anchored = anchorViolation(violation, index); + if (isFailure(anchored)) { + dropped.push({ ruleName: violatedRule.ruleName, constructPath: constructPath ?? '', reason: anchored.reason }); } else { - located.push({ uri: anchored.uri, diagnostic: buildDiagnostic(violation, anchored.range, pluginName) }); + located.push(...anchored.map((a) => ({ uri: a.uri, diagnostic: buildDiagnostic(violatedRule, a.range, pluginName) }))); } } const byUri = new Map( - [...groupBy(located, (l) => l.uri)].map(([uri, items]) => [uri, items.map((i) => i.diagnostic)]), + [...groupBy(located, (l) => l.uri)].map(([uri, items]) => [uri, dedupeDiagnostics(items.map((i) => i.diagnostic))]), ); return { byUri, dropped }; } interface ViolationTarget { readonly pluginName: string; - readonly violation: PolicyViolationJson; + readonly violatedRule: PolicyViolationJson; readonly constructPath: string | undefined; + readonly violation: ViolatingConstructJson; } /** Flatten the report's plugin -> violation -> target nesting into a flat list. */ -function flattenTargets(report: PolicyValidationReportJson | undefined): ViolationTarget[] { - return (report?.pluginReports ?? []).flatMap((plugin) => +function flattenTargets(reports: PluginReportJson[] | undefined): ViolationTarget[] { + return (reports ?? []).flatMap((plugin) => (plugin.violations ?? []).flatMap((violation) => (violation.violatingConstructs ?? []).map((target) => ({ pluginName: plugin.pluginName, - violation, + violatedRule: violation, constructPath: target.constructPath, + violation: target, })))); } -/** A construct's source location, or the reason it has none. */ -function resolveLocation( - constructPath: string | undefined, - index: ConstructIndex, -): SourceLocation | { readonly reason: string } { - if (!constructPath) return { reason: 'violation has no construct path' }; - const node = index.byPath(constructPath); - if (!node) return { reason: 'not found in the construct tree' }; - if (!node.sourceLocation) return { reason: 'no source location (framework-only trace or unresolved source)' }; - return node.sourceLocation; -} - /** Resolve a violation to a presentable anchor, or a reason it can't be shown. */ function anchorViolation( - constructPath: string | undefined, - index: ConstructIndex, -): { readonly uri: string; readonly range: Range } | { readonly reason: string } { - const loc = resolveLocation(constructPath, index); - if ('reason' in loc) return loc; + violation: ViolatingConstructJson, + index: AssemblyIndexWithSourceLocations, +): Result<{ readonly uri: string; readonly range: Range }[]> { + const traced = index.traceViolation(violation); + return traced.sourceLocations.map((loc) => { + return { uri: pathToFileURL(index.appDir.absolute(loc.file)).toString(), range: toRange(loc) }; + }); +} - // Resolver already vetted the source file; just turn the location into a range. - return { uri: pathToFileURL(loc.file).toString(), range: toRange(loc) }; +function dedupeDiagnostics(xs: Diagnostic[]): Diagnostic[] { + const seen = new Set(); + const result: Diagnostic[] = []; + for (const diag of xs) { + const key = `${diag.range.start.line}:${diag.range.start.character}:${diag.code}:${diag.source}:${diag.message}`; + if (!seen.has(key)) { + seen.add(key); + result.push(diag); + } + } + return result; } /** @@ -103,11 +104,10 @@ function anchorViolation( * top of the file when line/column aren't known. LSP positions are 0-based; * sourceLocation is 1-based. The end spans the rest of the line. */ -function toRange(loc: SourceLocation): Range { - const hasLineCol = loc.line >= 1 && loc.column >= 1; +function toRange(loc: { line: number }): Range { + const hasLineCol = loc.line >= 1; const line = hasLineCol ? loc.line - 1 : 0; - const character = hasLineCol ? loc.column - 1 : 0; - return { start: { line, character }, end: { line, character: END_OF_LINE_CHARACTER } }; + return { start: { line, character: 0 }, end: { line, character: END_OF_LINE_CHARACTER } }; } function buildDiagnostic(violation: PolicyViolationJson, range: Range, pluginName: string): Diagnostic { @@ -138,3 +138,9 @@ function formatMessage(v: PolicyViolationJson): string { const head = v.description ?? v.ruleName; return v.suggestedFix ? `${head}\n\nSuggested fix: ${v.suggestedFix}` : head; } + +type Result = A | { readonly reason: string }; + +function isFailure(result: Result): result is Extract, { readonly reason: string }> { + return result && typeof result === 'object' && 'reason' in (result as any); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/hover.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/hover.ts index d53174b3e..c987447a3 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/hover.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/hover.ts @@ -1,9 +1,12 @@ -import { pathToFileURL } from 'url'; -import { type ConstructIndex, indexTemplateRanges, type OffsetRange, type TemplateRanges } from '@aws-cdk/cloud-assembly-api'; +import { ENTIRE_TEMPLATE_LOGICALID_MARKER } from '@aws-cdk/cloud-assembly-api'; import { type Hover, MarkupKind, type Position, type Range } from 'vscode-languageserver/node'; -import { isResourceOnFile, type ResourceConstruct } from './codelens'; +import { type LogSink } from './log-sink'; import { offsetsToRange } from './positions'; -import type { ConstructNode } from '../core/assembly-reader'; +import { histogram } from './summarize'; +import { type AssemblyIndexWithSourceLocations, isResourceNode, type AssemblyResourceNode, type RelativePath } from '../core/assembly-index-with-source-locations'; +import { pathToFileURL } from '../core/safe-url'; +import type { OffsetRange, TemplateRanges } from '../core/template-ranges'; +import { indexTemplateRanges } from '../core/template-ranges'; /** A clickable template target: a file URI and a 1-based line. */ export interface LinkTarget { @@ -27,8 +30,8 @@ export interface HoverLinks { /** A construct line's primary resource (the one the hover leads with) and its other resources. */ export interface PrimarySelection { - readonly primary: ResourceConstruct; - readonly others: readonly ResourceConstruct[]; + readonly primary: AssemblyResourceNode; + readonly others: readonly AssemblyResourceNode[]; } /** Top-level properties shown before truncating with a "+N more" line. */ @@ -39,23 +42,21 @@ const MAX_LINKED_AUX = 5; const MAX_STRING_LENGTH = 60; /** Object keys previewed inline before collapsing with an ellipsis. */ const MAX_OBJECT_KEYS = 4; -/** Distinct CFN types shown in an auxiliary histogram before "+N more". */ -const MAX_HISTOGRAM_TYPES = 6; /** * Resource nodes whose creation line is the hovered position in `uri`. Reuses * the CodeLens resource predicate, narrowed to the single hovered line. */ export function resourceNodesOnLine( - index: ConstructIndex, - uri: string, + index: AssemblyIndexWithSourceLocations, + filePath: string, position: Position, -): ResourceConstruct[] { +): AssemblyResourceNode[] { // sourceLocation is 1-based; LSP positions are 0-based. const line = position.line + 1; - return [...index].filter( - (node): node is ResourceConstruct => isResourceOnFile(node, uri) && node.sourceLocation.line === line, - ); + return index.constructsOnLine(index.appDir.relative(filePath), line) + .filter(x => isResourceNode(x) && x.cfnResource.type && x.cfnResource.logicalId !== ENTIRE_TEMPLATE_LOGICALID_MARKER) + .map(x => x as unknown as AssemblyResourceNode); } /** @@ -64,19 +65,20 @@ export function resourceNodesOnLine( * property link targets. Returns undefined when no resource maps to the line. */ export async function hoverForPosition( - index: ConstructIndex, - uri: string, + console: LogSink, + index: AssemblyIndexWithSourceLocations, + filePath: string, position: Position, - readTemplate: (file: string) => Promise, + readTemplate: (file: RelativePath) => Promise, ): Promise { - const nodes = resourceNodesOnLine(index, uri, position); + const nodes = resourceNodesOnLine(index, filePath, position); if (nodes.length === 0) { return undefined; } // Select the primary once and thread it through both link resolution and // rendering, so a single place owns "which resource is canonical". const primaryResource = selectPrimary(nodes); - const links = await resolveHoverLinks(nodes, primaryResource?.primary, readTemplate); + const links = await resolveHoverLinks(console, index, nodes, primaryResource?.primary, readTemplate); const range: Range = { start: { line: position.line, character: 0 }, end: { line: position.line, character: Number.MAX_VALUE }, @@ -99,37 +101,45 @@ interface ResolvedTemplate { * stack-relative logical ids can collide across templates. */ async function resolveHoverLinks( - nodes: readonly ResourceConstruct[], - primary: ResourceConstruct | undefined, - readTemplate: (file: string) => Promise, + console: LogSink, + index: AssemblyIndexWithSourceLocations, + nodes: readonly AssemblyResourceNode[], + primary: AssemblyResourceNode | undefined, + readTemplate: (file: RelativePath) => Promise, ): Promise { - const templates = new Map(); - const resolveOnce = async (file: string): Promise => { - if (!templates.has(file)) { - const text = await readTemplate(file); - const ranges = text === undefined ? undefined : indexTemplateRanges(text); - templates.set(file, text !== undefined && ranges !== undefined ? { text, ranges } : undefined); + const templates = new Map(); + const resolveOnce = async (file: RelativePath): Promise => { + const existing = templates.get(file); + if (existing) { + return existing; } - return templates.get(file); + const text = await readTemplate(file); + const ranges = indexTemplateRanges(text); + templates.set(file, { text, ranges }); + return { text, ranges }; }; const resourceLocations: Record = {}; const properties: Record = {}; for (const node of nodes) { - if (node.templateFile === undefined) { + if (node.cfnResource.assemblyRelativeTemplateFile === undefined) { continue; } - const template = await resolveOnce(node.templateFile); - if (template === undefined) { + + let template; + try { + template = await resolveOnce(node.cfnResource.assemblyRelativeTemplateFile); + } catch (e) { + console.error(`Failed to resolve template for ${node.cfnResource.assemblyRelativeTemplateFile}: ${(e as Error).message}`); continue; } - const uri = pathToFileURL(node.templateFile).toString(); + const uri = pathToFileURL(index.assemblyDir.absolute(node.cfnResource.assemblyRelativeTemplateFile)).toString(); if (node === primary) { - const resource = template.ranges.resource(node.logicalId); + const resource = template.ranges.resource(node.cfnResource.logicalId); if (resource === undefined) { continue; } - resourceLocations[node.path] = { uri, line: lineOf(template.text, resource.block) }; + resourceLocations[node.constructPath] = { uri, line: lineOf(template.text, resource.block) }; for (const [name, range] of Object.entries(resource.properties)) { // Lower-case the key so the tree's L1 camelCase prop name matches the // template's PascalCase name even on an acronym (sseSpecification vs @@ -137,9 +147,9 @@ async function resolveHoverLinks( properties[name.toLowerCase()] = { uri, line: lineOf(template.text, range) }; } } else { - const block = template.ranges.block(node.logicalId); + const block = template.ranges.block(node.cfnResource.logicalId); if (block !== undefined) { - resourceLocations[node.path] = { uri, line: lineOf(template.text, block) }; + resourceLocations[node.constructPath] = { uri, line: lineOf(template.text, block) }; } } } @@ -160,7 +170,7 @@ function lineOf(text: string, range: OffsetRange): number { * Returns undefined when no resource maps to the line. */ export function buildHover( - nodes: readonly ResourceConstruct[], + nodes: readonly AssemblyResourceNode[], primaryResource: PrimarySelection | undefined, links: HoverLinks | undefined, range: Range, @@ -181,7 +191,7 @@ export function buildHover( * (`Resource`/`Default`) breaks the tie; if none or more than one qualifies, * there is no clear primary and this returns undefined. `others` holds the rest. */ -export function selectPrimary(nodes: readonly ResourceConstruct[]): PrimarySelection | undefined { +export function selectPrimary(nodes: readonly AssemblyResourceNode[]): PrimarySelection | undefined { const byDepth = [...nodes].sort((a, b) => segments(a) - segments(b)); if (byDepth.length === 1) { return { primary: byDepth[0], others: [] }; @@ -200,18 +210,18 @@ export function selectPrimary(nodes: readonly ResourceConstruct[]): PrimarySelec return undefined; } -function segments(node: ConstructNode): number { - return node.path.split('/').length; +function segments(node: AssemblyResourceNode): number { + return node.constructPath.split('/').length; } function renderResource( - primary: ResourceConstruct, - others: readonly ResourceConstruct[], + primary: AssemblyResourceNode, + others: readonly AssemblyResourceNode[], links: HoverLinks | undefined, ): string { const lines = [ - `${linked(`**${primary.logicalId}**`, links?.resourceLocations[primary.path])} · \`${primary.type}\``, - `\`${primary.path}\``, + `${linked(`**${primary.cfnResource.logicalId}**`, links?.resourceLocations[primary.constructPath])} · \`${primary.cfnResource.type}\``, + `\`${primary.constructPath}\``, '', ...propertyLines(primary, links), ]; @@ -221,8 +231,8 @@ function renderResource( return lines.join('\n'); } -function propertyLines(primary: ResourceConstruct, links: HoverLinks | undefined): string[] { - const properties = primary.cfnProperties ?? {}; +function propertyLines(primary: AssemblyResourceNode, links: HoverLinks | undefined): string[] { + const properties = primary.cfnResource.cfnProperties ?? {}; const keys = Object.keys(properties); const lines = keys.slice(0, MAX_PROPERTIES).map((key) => { const value = `\`${renderValue(properties[key])}\``; @@ -234,17 +244,19 @@ function propertyLines(primary: ResourceConstruct, links: HoverLinks | undefined return lines; } -function alsoCreates(others: readonly ResourceConstruct[], links: HoverLinks | undefined): string { +function alsoCreates(others: readonly AssemblyResourceNode[], links: HoverLinks | undefined): string { if (others.length > MAX_LINKED_AUX) { return `Also creates ${others.length} resources: ${histogram(others)}`; } - const items = others.map((node) => linked(`\`${node.type}\``, links?.resourceLocations[node.path])); + const items = others.map((node) => linked(`\`${node.cfnResource.type}\``, links?.resourceLocations[node.constructPath])); return `Also creates: ${items.join(' · ')}`; } -function renderSummary(nodes: readonly ResourceConstruct[], links: HoverLinks | undefined): string { - const items = nodes.map((node) => `- ${linked(`\`${node.type}\``, links?.resourceLocations[node.path])} \`${node.path}\``); - return [`**${nodes.length} resources on this line**`, '', ...items].join('\n'); +function renderSummary(nodes: readonly AssemblyResourceNode[], links: HoverLinks | undefined): string { + const items = [...nodes] + .sort((a, b) => a.constructPath.localeCompare(b.constructPath)) + .map((node) => `- ${linked(`\`${node.cfnResource.type}\``, links?.resourceLocations[node.constructPath])} \`${node.constructPath}\``); + return [`**${nodes.length} resources defined here**`, '', ...items].join('\n'); } /** Wrap `text` in a markdown link to `target`, or return it unchanged when absent. */ @@ -298,18 +310,6 @@ function renderIntrinsic(record: Record): string | undefined { return undefined; } -/** Group auxiliary resources by short CFN type, most common first, e.g. "8× Subnet". */ -function histogram(nodes: readonly ResourceConstruct[]): string { - const counts = new Map(); - for (const node of nodes) { - const short = node.type.split('::').pop() ?? node.type; - counts.set(short, (counts.get(short) ?? 0) + 1); - } - const parts = [...counts].sort((a, b) => b[1] - a[1]).map(([type, count]) => `${count}× ${type}`); - const shown = parts.slice(0, MAX_HISTOGRAM_TYPES); - return shown.join(', ') + (parts.length > shown.length ? `, … +${parts.length - shown.length} more` : ''); -} - function truncate(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max - 1)}…`; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/log-sink.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/log-sink.ts new file mode 100644 index 000000000..df718d0bb --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/log-sink.ts @@ -0,0 +1,12 @@ +export interface LogSink { + warn(message: string): void; + error(message: string): void; +} + +export const NOOP_LOGGER: LogSink = { + warn: () => { + }, + error: () => { + }, +}; + diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index f1b9201b7..262d6b8e1 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -1,8 +1,6 @@ import { Toolkit } from '@aws-cdk/toolkit-lib'; import { LspIoHost } from './io-host'; import { startServer } from './server'; -import { toolkitAssemblyLock } from '../core/assembly-lock'; -import { runSynth } from '../core/synth-runner'; /** * Starts the CDK Language Server over the process's stdin/stdout. Wires the @@ -14,15 +12,6 @@ export function startLspServer(version?: string): void { readable: process.stdin, writable: process.stdout, version, - // Build the Toolkit once connection.console exists (so output reaches the - // editor Output panel), then bind the two ops the handlers need. The read - // lock comes from fromAssemblyDirectory().produce(), so we never touch RWLock. - toolkitBindingsFactory: (console) => { - const toolkit = new Toolkit({ ioHost: new LspIoHost(console) }); - return { - synthRunner: (projectDir) => runSynth({ toolkit, projectDir }), - acquireAssemblyLock: toolkitAssemblyLock(toolkit), - }; - }, + toolkitFactory: (console) => new Toolkit({ ioHost: new LspIoHost(console) }), }); } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts index a36151aec..9257ae93a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts @@ -1,6 +1,6 @@ -import type { OffsetRange } from '@aws-cdk/cloud-assembly-api'; import { type Position, type Range } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { type OffsetRange } from '../core/template-ranges'; // Character offsets (from the core range resolver) and LSP positions are two // coordinate systems over the same text. These convert between them at the LSP diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 1dc56709f..ae2974614 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -1,8 +1,8 @@ -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; -import { ConstructIndex, indexTemplateRanges, type TemplateRanges } from '@aws-cdk/cloud-assembly-api'; -import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { type AssemblyIndexNode } from '@aws-cdk/cloud-assembly-api'; +import type { Toolkit } from '@aws-cdk/toolkit-lib'; import { StreamMessageReader, StreamMessageWriter, @@ -25,39 +25,34 @@ import { type Location, type RemoteConsole, } from 'vscode-languageserver/node'; -import { WATCH_EXCLUDE_DEFAULTS, createIgnoreMatcher } from '../api-private'; +import { createIgnoreMatcher } from '../api-private'; import { codeLensesForFile } from './codelens'; import { executeCommand, SUPPORTED_COMMANDS, type NotifySink } from './commands'; import { GetConstructTreeRequest, type ConstructSourceEntry, type GetConstructTreeResult } from './construct-tree-request'; import { mapViolationsToDiagnostics } from './diagnostics'; import { cdkLspManifest } from './features'; import { hoverForPosition } from './hover'; +import { type LogSink, NOOP_LOGGER } from './log-sink'; import { offsetAtPosition } from './positions'; import { synthFailureDiagnostics } from './synth-diagnostics'; import { sourceTargetAtTemplateOffset } from './template-locator'; -import type { AssemblyLock } from '../core/assembly-lock'; +import { AssemblyIndexWithSourceLocations, assertRelativePath, type RelativePath } from '../core/assembly-index-with-source-locations'; import { - readAssembly as defaultReadAssembly, + webAssemblyDataFromAssembly, type AssemblyReadResult, - type ConstructNode, } from '../core/assembly-reader'; import { startAssemblyWatcher as defaultStartAssemblyWatcher, type AssemblyWatcher, type AssemblyWatcherOptions, } from '../core/assembly-watcher'; -import { isWithinRoot } from '../core/source-resolver'; -import type { SynthRunResult } from '../core/synth-runner'; - -/** - * The cdk.out watcher fires after a synth's file writes but possibly before the - * synth releases its write lock. The toolkit's read lock is fail-fast (it does - * not queue), so refreshFromAssembly polls up to REFRESH_LOCK_RETRIES times, - * REFRESH_LOCK_RETRY_MS apart, for the write lock to clear before giving up on - * a refresh pass. - */ -const REFRESH_LOCK_RETRIES = 10; -const REFRESH_LOCK_RETRY_MS = 50; +import { readCdkConfig } from '../core/cdk-config'; +import { readAssemblyDirectory } from '../core/from-cdk-app-directory'; +import { SOURCE_WATCH_EXCLUDES } from '../core/source-watcher'; +import { runSynth, type SynthRunResult } from '../core/synth-runner'; +import type { TemplateRanges } from '../core/template-ranges'; +import { indexTemplateRanges } from '../core/template-ranges'; +import { umap } from '../core/undef'; /** * Synth concurrency latch, mirroring `cdk watch` (see toolkit.ts). 'open' = idle, @@ -67,20 +62,8 @@ const REFRESH_LOCK_RETRY_MS = 50; */ type SynthLatch = 'open' | 'synthing' | 'queued'; -/** - * When an external writer (a terminal `cdk synth`/`cdk watch`) holds the cdk.out - * write lock, the synth retries with exponential backoff + full jitter rather - * than giving up, so the LSP always eventually synths the latest save once the - * writer releases. Delay is capped; attempts are not. - */ -const SYNTH_LOCK_RETRY_BASE_MS = 100; -const SYNTH_LOCK_RETRY_MAX_MS = 2_000; - export interface LspHandlerOptions { - /** Override readAssembly for tests. Defaults to reading /cdk.out. */ - readonly readAssembly?: (assemblyDir: string) => Promise; - /** Acquire a read lock on the assembly dir; throws a `LockError` on writer contention. */ - readonly acquireAssemblyLock: (assemblyDir: string) => Promise; + readonly toolkit: Toolkit; /** Shipped CDK CLI version to advertise (serverInfo + manifest). Injected by the entrypoint; omitted in tests. */ readonly version?: string; /** @@ -101,35 +84,14 @@ export interface LspHandlerOptions { * overridden in tests to drive refreshes deterministically. */ readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; - /** - * Runs a synth of the project at the given root and returns its typed outcome. - * Injected by startServer (built from the toolkit bindings); 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?: (projectDir: string) => Promise; /** User-facing notification sink. Injected by startServer; omitted in tests. */ readonly notify?: NotifySink; } -/** - * The Toolkit-backed operations the handlers need, built from one Toolkit wired - * to the editor Output panel. The read lock comes from the Toolkit's own - * `fromAssemblyDirectory().produce()`, so the LSP never imports `RWLock`. - */ -export interface ToolkitBindings { - readonly synthRunner: (projectDir: string) => Promise; - readonly acquireAssemblyLock: (assemblyDir: string) => Promise; -} - -export type ToolkitBindingsFactory = (console: RemoteConsole) => ToolkitBindings; - export interface LspServerOptions { readonly readable: NodeJS.ReadableStream; readonly writable: NodeJS.WritableStream; - /** Builds the Toolkit-backed bindings once `connection.console` exists. Required; main.ts always wires it. */ - readonly toolkitBindingsFactory: ToolkitBindingsFactory; + readonly toolkitFactory: (console: RemoteConsole) => Toolkit; /** Shipped CDK CLI version to advertise. Injected by main.ts from the CLI. */ readonly version?: string; } @@ -144,56 +106,21 @@ export interface LspHandlers { onExecuteCommand(params: ExecuteCommandParams): Promise; onHover(params: HoverParams): Promise; /** Serve the source-resolved construct tree (flattened) for a client overlay. */ - onGetConstructTree(): GetConstructTreeResult; + onGetConstructTree(): Promise; onShutdown(): void; } -interface LogSink { - warn(message: string): void; - error(message: string): void; -} - -const NOOP_LOGGER: LogSink = { - warn: () => { - }, - error: () => { - }, -}; - -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** 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': - 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; - 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 readAssembly = options.readAssembly ?? defaultReadAssembly; - const acquireAssemblyLock = options.acquireAssemblyLock; const log = options.logger ?? NOOP_LOGGER; const onPublishDiagnostics = options.onPublishDiagnostics ?? (() => { }); const onRefreshCodeLenses = options.onRefreshCodeLenses ?? (() => { }); const startWatcher = options.startAssemblyWatcher ?? defaultStartAssemblyWatcher; - const synthRunner = options.synthRunner; const notify = options.notify ?? { info: () => { }, @@ -203,11 +130,6 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { }; let applicationDir: string | undefined; - let shutdownRequested = false; - // Concurrency latch, mirroring `cdk watch` (toolkit.ts): a synth requested - // while one runs is batched into a single follow-up, so synths run one at a - // time and the assembly catches up to the latest save. - let synthLatch: SynthLatch = 'open'; // 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 @@ -215,11 +137,7 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { let assemblyWatcher: AssemblyWatcher | undefined; // Latest index from readAssembly, served to CodeLens. Refreshed at startup // and whenever the cdk.out watcher detects a re-synth. - let cachedIndex: ConstructIndex = ConstructIndex.fromTree([]); - // The decorated tree, its warnings, and the source dir behind cachedIndex, - // retained so the getConstructTree request serves source locations without a - // re-read. undefined assemblyDir means no successful read has happened yet. - let cachedTree: readonly ConstructNode[] = []; + let cachedIndex = AssemblyIndexWithSourceLocations.empty(); let cachedWarnings: readonly string[] = []; let cachedAssemblyDir: string | undefined; // URIs that currently have published diagnostics. On each refresh we publish @@ -231,6 +149,8 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { // the editor's next natural re-query. let codeLensRefreshSupported = false; + const shutdown = new AbortController(); + // 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 @@ -239,39 +159,26 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { return applicationDir ?? process.cwd(); } - async function refreshFromAssembly(projectDir: string): Promise { - const assemblyDir = path.join(projectDir, 'cdk.out'); - - // Hold a read lock so a concurrent synth can't overwrite cdk.out mid-read. - // The lock is fail-fast, so poll until the writer releases; the file events - // that triggered us are already over. - let lock: AssemblyLock | undefined; - for (let attempt = 0; attempt <= REFRESH_LOCK_RETRIES; attempt++) { - try { - lock = await acquireAssemblyLock(assemblyDir); - break; - } catch (err) { - if (ToolkitError.isLockError(err)) { - if (attempt < REFRESH_LOCK_RETRIES) { - await sleep(REFRESH_LOCK_RETRY_MS); - } - continue; - } - // ENOENT (no cdk.out yet) is expected and silent; anything else is real. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - log.error(`Failed to open cloud assembly for reading: ${(err as Error).message}`); - } - return; - } - } - // Write lock still held after every retry: skip this pass. - if (lock === undefined) return; - + async function refreshFromAssembly(projectDir: string, assemblyDir: string): Promise { let result: AssemblyReadResult; try { - result = await readAssembly(assemblyDir); - } finally { - await lock.release(); + await using asm = await readAssemblyDirectory(options.toolkit, shutdown.signal, assemblyDir); + result = { + status: 'success', + data: await webAssemblyDataFromAssembly(projectDir, asm), + mtimeMs: asm.cloudAssembly.mtimeMs, + }; + } catch (e: any) { + if (e.code === 'ENOENT') { + result = { + status: 'not-found', + }; + } else { + result = { + status: 'error', + message: e.message ?? String(e), + }; + } } if (result.status === 'error') { @@ -280,13 +187,12 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { } if (result.status === 'not-found') return; - const { tree, violations, warnings } = result.data; + const { index, violations, warnings } = result.data; for (const warning of warnings) { log.warn(warning); } - cachedIndex = ConstructIndex.fromTree(tree); - cachedTree = tree; + cachedIndex = index; cachedWarnings = warnings; cachedAssemblyDir = assemblyDir; @@ -315,50 +221,56 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { } } - // One synth, retrying the write lock with exponential backoff + full jitter. - // The latch guarantees we never contend with our own synth, so a lock-conflict - // here is an external writer (a terminal `cdk synth`/`cdk watch`) holding - // cdk.out; retry until it releases rather than dropping the synth. - async function runSynthWithRetry(): Promise { - let delayMs = SYNTH_LOCK_RETRY_BASE_MS; - const deadline = Date.now() + 3_600_000; // 1 hour - while (Date.now() < deadline) { - const result = await (synthRunner ? synthRunner(currentProjectDir()) : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); - if (result.status !== 'lock-conflict') { - publishSynthDiagnostics(result); - return result; - } - await sleep(Math.random() * delayMs); - delayMs = Math.min(delayMs * 2, SYNTH_LOCK_RETRY_MAX_MS); - } - const timeoutResult: SynthRunResult = { status: 'error', message: 'Auto-synth timed out waiting for external writer to release cdk.out' }; - return timeoutResult; - } + // Concurrency latch, mirroring `cdk watch` (toolkit.ts): a synth requested + // while one runs is batched into a single follow-up, so synths run one at a + // time and the assembly catches up to the latest save. + let synthLatch: SynthLatch = 'open'; + const synthListeners: Array<() => void> = []; - // Shared by the manual CodeLens command and auto-synth-on-save. A call while a - // synth runs is batched (latch 'queued') and returns lock-conflict now, so the - // caller still sees "in progress" while the assembly catches up to the latest - // save. Any number of overlapping calls collapse into that one follow-up. - async function guardedSynth(): Promise { - if (synthLatch !== 'open') { + /** + * Queue a new synth + * + * Start it immediately if we are not currently synthing; otherwise, another instance + * of this async function is already running. Mark the latch as + * queued and the existing instance will check it in a loop. + * + * Returns a Promise that resolves once the queued synth has completed. + */ + function queueSynth() { + const ret = new Promise((resolve) => { + synthListeners.push(resolve); + }); + + if (synthLatch === 'open') { + void runSynthLoop(); + } else { synthLatch = 'queued'; - return { status: 'lock-conflict' }; } - synthLatch = 'synthing'; - try { - // Drain the queued slot with a loop (not recursion) so the stack stays - // flat. There is no await between reading the latch as not-'queued' and the - // finally resetting it to 'open', so under the single-threaded event loop - // any request that set 'queued' did so during an await and is observed - // here -- no wakeup is lost. - let result = await runSynthWithRetry(); - while ((synthLatch as SynthLatch) === 'queued') { - synthLatch = 'synthing'; - result = await runSynthWithRetry(); + + return ret; + + async function runSynthLoop() { + try { + do { + synthLatch = 'synthing' as SynthLatch; + const currentListeners = synthListeners.splice(0, synthListeners.length); + const result = await runSynth({ + toolkit: options.toolkit, + projectDir: currentProjectDir(), + abort: shutdown.signal, + }); + publishSynthDiagnostics(result); + for (const listener of currentListeners) { + listener(); + } + + // If another synth was queued while we were running, the loop will go around again. + } while (synthLatch === 'queued' && !shutdown.signal.aborted); + } catch (e: any) { + log.error(`Synth failed during queued run: ${e.message ?? String(e)}`); + } finally { + synthLatch = 'open'; } - return result; - } finally { - synthLatch = 'open'; } } @@ -366,6 +278,19 @@ 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 { + switch (result.status) { + case 'success': + // Silent. The watcher refreshes the editor when `cdk.out` changes. + break; + case 'unavailable': + notify.info('No CDK app found in the current project directory'); + break; + case 'app-failure': + case 'error': + notify.error(`CDK synth failed: ${result.message}`); + break; + } + // 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') { @@ -373,7 +298,8 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { return; } const failures = synthFailureDiagnostics(result, currentProjectDir()); - if (failures.length === 0) return; // lock-conflict / error: leave any existing diagnostic + if (failures.length === 0) return; + const nextUris = new Set(failures.map((f) => f.uri)); for (const uri of synthFailureUris) { if (!nextUris.has(uri)) onPublishDiagnostics(uri, []); @@ -423,46 +349,39 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { }, async onInitialized() { 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.) + const assemblyDir = path.resolve(projectDir, (await readCdkConfig(projectDir))?.output ?? 'cdk.out'); + + // Ignore non-source paths (deps, cdk.out, dotfiles) using the same policy + // the web explorer's source watcher applies. shouldIgnore = createIgnoreMatcher({ - exclude: [ - ...WATCH_EXCLUDE_DEFAULTS, - '**/node_modules/**', - '**/cdk.out/**', - '.*', - '**/.*', - '**/.*/**', - ], + exclude: SOURCE_WATCH_EXCLUDES, rootDir: projectDir, }); - await refreshFromAssembly(projectDir); + await refreshFromAssembly(projectDir, assemblyDir); // Watch cdk.out so any synth (an external `cdk synth`/`cdk watch`, or a // future in-process synth) refreshes the editor's diagnostics and lenses. assemblyWatcher = startWatcher({ - assemblyDir: path.join(projectDir, 'cdk.out'), + assemblyDir, onChange: () => { // refreshFromAssembly is async; surface a rejection rather than // floating the promise from this watcher callback. - refreshFromAssembly(projectDir).catch((err) => + refreshFromAssembly(projectDir, assemblyDir).catch((err) => log.error(`Assembly refresh failed: ${(err as Error).message}`)); }, onError: (err) => log.error(`Assembly watcher error: ${(err as Error).message}`), }); }, onDidSaveTextDocument(params) { - if (shutdownRequested) return; + if (shutdown.signal.aborted) return; const filePath = fileURLToPath(params.textDocument.uri); if (shouldIgnore(filePath)) return; if (!autoSynthEnabled) return; - void guardedSynth() - .then((result) => handleSynthOnSave(result, log)) - .catch((err: unknown) => log.error(`Auto-synth threw unexpectedly: ${(err as Error).message}`)); + void queueSynth(); }, onCodeLens(params) { - return codeLensesForFile(cachedIndex, params.textDocument.uri, autoSynthEnabled); + const assemblyDir = cachedAssemblyDir ?? path.join(currentProjectDir(), 'cdk.out'); + return codeLensesForFile(cachedIndex, assemblyDir, fileURLToPath(params.textDocument.uri), autoSynthEnabled); }, async onDefinition(params) { // Only synthesized templates link back to source, and only file: URIs are @@ -473,13 +392,14 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { return undefined; } const filePath = fileURLToPath(uri); + // Bound the client-supplied path to this project's cdk.out before reading. - if (!(await isWithinRoot(path.join(currentProjectDir(), 'cdk.out'), filePath))) { + if (!await cachedIndex.assemblyDir.contains(filePath)) { return undefined; } let templateText: string; try { - templateText = await fs.promises.readFile(filePath, 'utf-8'); + templateText = await fs.readFile(filePath, 'utf-8'); } catch { return undefined; } @@ -491,7 +411,7 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { }, async onExecuteCommand(params) { await executeCommand(params.command, params.arguments ?? [], { - synth: guardedSynth, + synth: queueSynth, toggleAutoSynth: (enabled) => { autoSynthEnabled = enabled; onRefreshCodeLenses(); @@ -499,11 +419,16 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { notify, }); }, - onHover(params) { - return hoverForPosition(cachedIndex, params.textDocument.uri, params.position, - (file) => fs.promises.readFile(file, 'utf-8').catch(() => undefined)); + async onHover(params) { + try { + return await hoverForPosition(log, cachedIndex, fileURLToPath(params.textDocument.uri), params.position, + (file) => fs.readFile(cachedIndex.assemblyDir.absolute(file), 'utf-8')); + } catch (e) { + log.error(`Failed to get hover information: ${e}`); + throw e; + } }, - onGetConstructTree() { + async onGetConstructTree() { const assemblyDir = cachedAssemblyDir ?? path.join(currentProjectDir(), 'cdk.out'); if (cachedAssemblyDir === undefined) { return { status: 'no-assembly', assemblyDir, entries: [], warnings: [] }; @@ -511,10 +436,11 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { // Flatten the already-decorated tree in pre-order; the client joins on `path`. // Parse each template's resource ranges at most once per request. const rangesByTemplate = new Map(); - const templateRangesFor = (templateFile: string): TemplateRanges | undefined => { + const templateRangesFor = async (templateFile: RelativePath): Promise => { if (!rangesByTemplate.has(templateFile)) { try { - rangesByTemplate.set(templateFile, indexTemplateRanges(fs.readFileSync(templateFile, 'utf-8'))); + const absPath = cachedIndex.assemblyDir.absolute(templateFile); + rangesByTemplate.set(templateFile, indexTemplateRanges(await fs.readFile(absPath, 'utf-8'))); } catch { rangesByTemplate.set(templateFile, undefined); } @@ -522,29 +448,34 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { return rangesByTemplate.get(templateFile); }; const entries: ConstructSourceEntry[] = []; - const collect = (nodes: readonly ConstructNode[]): void => { + const collect = async (nodes: readonly AssemblyIndexNode[]): Promise => { for (const node of nodes) { const templateOffset = - node.templateFile && node.logicalId - ? templateRangesFor(node.templateFile)?.block(node.logicalId)?.start + node.cfnResource.assemblyRelativeTemplateFile && node.cfnResource.logicalId + ? (await templateRangesFor(assertRelativePath(node.cfnResource.assemblyRelativeTemplateFile)))?.block(node.cfnResource.logicalId)?.start : undefined; + entries.push({ - path: node.path, + path: node.constructPath, id: node.id, - type: node.type, - logicalId: node.logicalId, - sourceLocation: node.sourceLocation, - templateFile: node.templateFile, + type: node.cfnResource.type, + logicalId: node.cfnResource.logicalId, + sourceLocation: umap(cachedIndex.sourceLocationFor(node), (sl) => ({ + ...sl, + file: cachedIndex.appDir.absolute(sl.file), + })), + templateFile: umap(node.cfnResource.assemblyRelativeTemplateFile, (x) => + cachedIndex.assemblyDir.absolute(assertRelativePath(x))), templateOffset, }); - collect(node.children); + await collect(Object.values(node.children)); } }; - collect(cachedTree); + await collect(cachedIndex.assemblyIndex.firstLevelChildren); return { status: 'ok', assemblyDir, entries, warnings: [...cachedWarnings] }; }, onShutdown() { - shutdownRequested = true; + shutdown.abort(); void assemblyWatcher?.close(); assemblyWatcher = undefined; }, @@ -561,10 +492,6 @@ export function startServer(options: LspServerOptions): void { // Captured from onInitialize; used to gate CodeLens refresh requests. let codeLensRefreshSupported = false; - // Build the toolkit bindings once connection.console exists; main.ts owns how - // they are constructed (synth runner + assembly read lock). - const { synthRunner, acquireAssemblyLock } = options.toolkitBindingsFactory(connection.console); - const handlers = createLspHandlers({ logger: connection.console, onPublishDiagnostics: (uri, diagnostics) => { @@ -578,8 +505,7 @@ export function startServer(options: LspServerOptions): void { } }, version: options.version, - synthRunner, - acquireAssemblyLock, + toolkit: options.toolkitFactory(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/lib/lsp/summarize.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/summarize.ts new file mode 100644 index 000000000..46371a031 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/summarize.ts @@ -0,0 +1,16 @@ +import type { AssemblyResourceNode } from '../core/assembly-index-with-source-locations'; + +/** Distinct CFN types shown in an auxiliary histogram before "+N more". */ +const MAX_HISTOGRAM_TYPES = 6; + +/** Group auxiliary resources by short CFN type, most common first, e.g. "8× Subnet". */ +export function histogram(nodes: readonly AssemblyResourceNode[]): string { + const counts = new Map(); + for (const node of nodes) { + const short = node.cfnResource.type.split('::').pop() ?? node.cfnResource.type; + counts.set(short, (counts.get(short) ?? 0) + 1); + } + const parts = [...counts].sort((a, b) => b[1] - a[1]).map(([type, count]) => `${count}× ${type}`); + const shown = parts.slice(0, MAX_HISTOGRAM_TYPES); + return shown.join(', ') + (parts.length > shown.length ? `, … +${parts.length - shown.length} more` : ''); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts index 23c59dc6c..ac75a81a0 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts @@ -1,6 +1,6 @@ import * as path from 'path'; -import { pathToFileURL } from 'url'; import { type Diagnostic, DiagnosticSeverity, type Range } from 'vscode-languageserver/node'; +import { pathToFileURL } from '../core/safe-url'; import type { SynthRunResult } from '../core/synth-runner'; /** A compile error parsed from synth stderr. Line/column are 1-based. */ diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts index 9b9d7863a..b4c066f5e 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts @@ -1,9 +1,10 @@ import * as fs from 'fs'; -import { pathToFileURL } from 'url'; -import { resolveLogicalIdAtOffset, resolveResourceRange, type ConstructIndex } from '@aws-cdk/cloud-assembly-api'; +import * as path from 'path'; import { type Location, type Range } from 'vscode-languageserver/node'; import { offsetsToRange } from './positions'; -import type { ConstructNode } from '../core/assembly-reader'; +import type { AssemblyIndexWithSourceLocations, AssemblyResourceNode } from '../core/assembly-index-with-source-locations'; +import { pathToFileURL } from '../core/safe-url'; +import { resolveLogicalIdAtOffset, resolveResourceRange } from '../core/template-ranges'; /** * An editor navigation target: the template file and the range to reveal. @@ -23,22 +24,20 @@ export interface ResourceTarget { * Returns `undefined` when the node is not navigable: no resolved template, the * template cannot be read, it does not parse, or the logical id is absent. */ -export async function resourceTarget(node: { templateFile?: string; logicalId: string }): Promise { - if (node.templateFile === undefined) { - return undefined; - } +export async function resourceTarget(assemblyDir: string, node: AssemblyResourceNode): Promise { + const fullAssemblyPath = path.join(assemblyDir, node.cfnResource.assemblyRelativeTemplateFile); let templateText: string; try { - templateText = await fs.promises.readFile(node.templateFile, 'utf-8'); + templateText = await fs.promises.readFile(fullAssemblyPath, 'utf-8'); } catch { return undefined; } - const block = resolveResourceRange(templateText, node.logicalId); + const block = resolveResourceRange(templateText, node.cfnResource.logicalId); if (block === undefined) { return undefined; } return { - uri: pathToFileURL(node.templateFile).toString(), + uri: pathToFileURL(fullAssemblyPath).toString(), range: offsetsToRange(templateText, block), }; } @@ -54,7 +53,7 @@ export async function resourceTarget(node: { templateFile?: string; logicalId: s * or the construct has no source location (for example a framework-only trace). */ export function sourceTargetAtTemplateOffset( - index: ConstructIndex, + index: AssemblyIndexWithSourceLocations, templateFile: string, templateText: string, offset: number, @@ -63,14 +62,15 @@ export function sourceTargetAtTemplateOffset( if (logicalId === undefined) { return undefined; } - const owner = [...index].find((node) => node.logicalId === logicalId && node.templateFile === templateFile); - if (owner?.sourceLocation === undefined) { + const owner = index.byTemplateLocation(index.assemblyDir.relative(templateFile), logicalId); + const sourceLocation = owner ? index.sourceLocationFor(owner) : undefined; + if (sourceLocation === undefined) { return undefined; } // SourceLocation is 1-based; LSP positions are 0-based. - const position = { line: owner.sourceLocation.line - 1, character: owner.sourceLocation.column - 1 }; + const position = { line: sourceLocation.line - 1, character: 0 }; return { - uri: pathToFileURL(owner.sourceLocation.file).toString(), + uri: pathToFileURL(index.appDir.absolute(sourceLocation.file)).toString(), range: { start: position, end: position }, }; } diff --git a/packages/@aws-cdk/cdk-explorer/package.json b/packages/@aws-cdk/cdk-explorer/package.json index 468c56489..aae52f37b 100644 --- a/packages/@aws-cdk/cdk-explorer/package.json +++ b/packages/@aws-cdk/cdk-explorer/package.json @@ -6,6 +6,9 @@ "url": "https://github.com/aws/aws-cdk-cli", "directory": "packages/@aws-cdk/cdk-explorer" }, + "bin": { + "lsp": "bin/lsp" + }, "scripts": { "build": "projen build", "bump": "projen bump", @@ -34,6 +37,7 @@ "@stylistic/eslint-plugin": "^3", "@types/convert-source-map": "^2", "@types/jest": "^29.5.14", + "@types/json-source-map": "^0.6.0", "@types/node": "^20", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", @@ -61,6 +65,8 @@ "@jridgewell/trace-mapping": "^0.3", "chokidar": "^4", "convert-source-map": "^2", + "json-source-map": "^0.6.1", + "p-limit": "^3", "vscode-jsonrpc": "^8", "vscode-languageserver": "^9", "vscode-languageserver-textdocument": "^1" diff --git a/packages/@aws-cdk/cdk-explorer/test/_fixtures/README.md b/packages/@aws-cdk/cdk-explorer/test/_fixtures/README.md index 9146924b0..58e5d9df4 100644 --- a/packages/@aws-cdk/cdk-explorer/test/_fixtures/README.md +++ b/packages/@aws-cdk/cdk-explorer/test/_fixtures/README.md @@ -2,32 +2,23 @@ These fixtures back the cdk-explorer unit tests. They come in two kinds. -## Programmatic assemblies (`builders.ts`) +## Programmatic assemblies (`../_helpers/assembly.ts`) Rather than checking in static `cdk.out/` directories, most tests construct -their cloud assembly in memory with `builders.ts` — helpers that write a -`manifest.json` + `tree.json` describing stacks, constructs, CFN resources, -and validation reports. This keeps each test's intent readable in TypeScript -and reduces the chance of the fixtures going stale as the cloud-assembly format evolves. The -builders only populate the fields `readAssembly` actually consumes, using the -metadata-key constants from `@aws-cdk/cloud-assembly-schema` so the keys stay -correct. `builders.test.ts` sanity-checks each builder against `readAssembly`. +their cloud assembly on disk with `AssemblyFixture` — a helper that writes a +`manifest.json` + `tree.json` + templates describing stacks, constructs, CFN +resources, and validation reports, then indexes it through the production +`AssemblyIndex` path. This keeps each test's intent readable in TypeScript and +reduces the chance of the fixtures going stale as the cloud-assembly format +evolves. + +The helper deliberately models only flat stacks. Nested stacks, Stages, and +assembly-relative template resolution belong to `AssemblyIndex` and are covered +in the `@aws-cdk/cloud-assembly-api` suite; duplicating them here would test +somebody else's code. ## source-maps/ `sample.ts` + `sample.js` + `sample.js.map` are real `tsc` output, used by `source-resolver.test.ts` to verify `.js` → `.ts` resolution via `@jridgewell/trace-mapping`. - -To regenerate (if the test starts pointing at the wrong line/col): - -```bash -cd packages/@aws-cdk/cdk-explorer -rm test/_fixtures/source-maps/sample.{js,js.map} -npx tsc --target ES2020 --module commonjs --sourceMap \ - --outDir test/_fixtures/source-maps \ - test/_fixtures/source-maps/sample.ts -``` - -If you edit `sample.ts`, also update the line/column expectations in -`source-resolver.test.ts`. diff --git a/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.test.ts b/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.test.ts deleted file mode 100644 index 781102611..000000000 --- a/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Sanity tests for the fixture builders. If these pass, every other test -// that uses the builders inherits a known-good baseline. -import * as path from 'path'; -import { - buildFlatAssembly, - buildNestedAssembly, - buildNestedStackAssembly, - buildNonTypeScriptAssembly, - cleanupFixture, - withMalformedValidationReport, - withValidationReport, -} from './builders'; -import { readAssembly } from '../../lib'; - -describe('fixture builders', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('buildFlatAssembly produces a readAssembly-compatible directory', async () => { - dir = buildFlatAssembly({ - stacks: [{ - id: 'Stack1', - resources: [{ - id: 'MyBucket', - logicalId: 'MyBucketF68F3FF0', - cfnType: 'AWS::S3::Bucket', - }], - }], - }); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error(`expected success, got ${result.status}: ${(result as any).message ?? ''}`); - - expect(result.data.tree).toHaveLength(1); - expect(result.data.tree[0].id).toBe('Stack1'); - }); - - test('buildFlatAssembly attaches logicalId and cfnType correctly', async () => { - dir = buildFlatAssembly({ - stacks: [{ - id: 'Stack1', - resources: [{ - id: 'MyBucket', - logicalId: 'MyBucketF68F3FF0', - cfnType: 'AWS::S3::Bucket', - }], - }], - }); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - const resource = result.data.tree[0].children[0].children[0]; - expect(resource.path).toBe('Stack1/MyBucket/Resource'); - expect(resource.logicalId).toBe('MyBucketF68F3FF0'); - expect(resource.type).toBe('AWS::S3::Bucket'); - // No creationTrace was set, so the resolver finds no frames -> undefined. - expect(resource.sourceLocation).toBeUndefined(); - }); - - test('buildFlatAssembly with creationTrace exposes sourceLocation', async () => { - dir = buildFlatAssembly({ - stacks: [{ - id: 'Stack1', - resources: [{ - id: 'MyBucket', - logicalId: 'MyBucketF68F3FF0', - cfnType: 'AWS::S3::Bucket', - // Mirrors aws-cdk-lib's renderCallStackJustMyCode output: node_modules - // and node: frames are pre-filtered into skip-placeholder lines, - // so the first frame that parses is the user's call site. - creationTrace: [ - ' ...node_modules-aws-cdk-lib...', - ' at new MyStack (/lib/my-stack.ts:12:5)', - ], - }], - }], - }); - const projectRoot = path.dirname(dir); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - const resource = result.data.tree[0].children[0].children[0]; - expect(resource.sourceLocation).toEqual({ - file: path.join(projectRoot, 'lib/my-stack.ts'), - line: 12, - column: 5, - }); - }); - - test('buildNestedAssembly produces a Stage-based assembly with correct tree', async () => { - dir = buildNestedAssembly({ - stages: [{ - id: 'Prod', - stacks: [{ - id: 'Service', - resources: [{ - id: 'MyBucket', - logicalId: 'MyBucketABC', - cfnType: 'AWS::S3::Bucket', - }], - }], - }], - }); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - expect(result.data.tree).toHaveLength(1); - - const stage = result.data.tree[0]; - expect(stage.id).toBe('Prod'); - - const stack = stage.children[0]; - expect(stack.path).toBe('Prod/Service'); - - const resource = stack.children[0].children[0]; - expect(resource.path).toBe('Prod/Service/MyBucket/Resource'); - expect(resource.logicalId).toBe('MyBucketABC'); - expect(resource.type).toBe('AWS::S3::Bucket'); - }); - - test('buildNestedStackAssembly enriches resources INSIDE a NestedStack via parent metadata', async () => { - // aws-cdk-lib emits nested-stack-internal metadata into the parent's - // artifact metadata. Verifies buildNode's metadata inheritance handles - // this with no separate walker. - dir = buildNestedStackAssembly({ - parent: { - id: 'Parent', - resources: [{ - id: 'TopBucket', - logicalId: 'TopBucketAAA', - cfnType: 'AWS::S3::Bucket', - }], - nestedStacks: [{ - id: 'MyNestedStack', - resources: [{ - id: 'NestedBucket', - logicalId: 'NestedBucketBBB', - cfnType: 'AWS::S3::Bucket', - creationTrace: [ - ' ...node_modules-aws-cdk-lib...', - ' at new MyNestedStack (/lib/nested.ts:7:5)', - ], - }], - }], - }, - }); - const projectRoot = path.dirname(dir); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - const parent = result.data.tree[0]; - expect(parent.id).toBe('Parent'); - - const topBucket = parent.children.find((c) => c.id === 'TopBucket')!.children[0]; - expect(topBucket.logicalId).toBe('TopBucketAAA'); - expect(topBucket.type).toBe('AWS::S3::Bucket'); - - const nestedStack = parent.children.find((c) => c.id === 'MyNestedStack')!; - expect(nestedStack.path).toBe('Parent/MyNestedStack'); - - const nestedBucket = nestedStack.children[0].children[0]; - expect(nestedBucket.path).toBe('Parent/MyNestedStack/NestedBucket/Resource'); - expect(nestedBucket.logicalId).toBe('NestedBucketBBB'); - expect(nestedBucket.type).toBe('AWS::S3::Bucket'); - expect(nestedBucket.sourceLocation).toEqual({ - file: path.join(projectRoot, 'lib/nested.ts'), - line: 7, - column: 5, - }); - }); - - test('buildNonTypeScriptAssembly returns success without crashing', async () => { - dir = buildNonTypeScriptAssembly(); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - expect(result.data.tree).toHaveLength(1); - const stack = result.data.tree[0]; - expect(stack.id).toBe('Stack1'); - // Non-TS apps emit no aws:cdk:logicalId metadata. - expect(stack.sourceLocation).toBeUndefined(); - }); - - test('withMalformedValidationReport surfaces the error without breaking the tree', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [] }], - }); - withMalformedValidationReport(dir); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - expect(result.data.tree).toHaveLength(1); - expect(result.data.violations).toBeUndefined(); - expect(result.data.warnings.some((w) => w.includes('validation-report.json'))).toBe(true); - }); - - test('withValidationReport produces a parseable report', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [] }], - }); - withValidationReport(dir, { - pluginReports: [{ - pluginName: 'test', - conclusion: 'failure', - violations: [{ - ruleName: 'no-bad-things', - description: 'no', - severity: 'error', - violatingConstructs: [{ constructPath: 'Stack1/x' }], - }], - }], - }); - - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - - expect(result.data.violations?.pluginReports[0].pluginName).toBe('test'); - }); -}); diff --git a/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.ts b/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.ts deleted file mode 100644 index 1c12486ec..000000000 --- a/packages/@aws-cdk/cdk-explorer/test/_fixtures/builders.ts +++ /dev/null @@ -1,506 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; -import { ArtifactMetadataEntryType, VALIDATION_REPORT_FILE } from '@aws-cdk/cloud-assembly-schema'; -import { TREE_FILE } from '../../lib/core/assembly-watcher'; - -/** - * Programmatic fixture builders. Each builder writes a minimal cdk.out/ to a - * temp dir and returns its path; cleanupFixture removes it. CloudAssembly - * construction enforces the schema, which is what catches drift in builders. - * - * const dir = buildFlatAssembly({ stacks: [...] }); - * try { ... } finally { cleanupFixture(dir); } - */ - -/** - * Pinned schema revision written into fixture manifests. Intentionally a static - * literal (not Manifest.version()): a fixture frozen at a known revision doubles - * as a forward-compat test that the reader can still load older assemblies. - * Deriving the version would make the manifest claim "latest" while its contents - * stay frozen — i.e. lie about its shape if a future revision changes it. - */ -const ASSEMBLY_SCHEMA_VERSION = '53.0.0'; - -/** aws-cdk-lib version stamped into fixture tree.json constructInfo. */ -const CONSTRUCT_INFO_VERSION = '2.245.0'; - -/** tree.json schema version written into fixtures. */ -const TREE_SCHEMA_VERSION = 'tree-0.1'; - -export interface ResourceSpec { - /** Construct id under the stack (e.g. "MyBucket"). */ - readonly id: string; - /** CFN logical ID (e.g. "MyBucketF68F3FF0"). */ - readonly logicalId: string; - /** CFN resource type (e.g. "AWS::S3::Bucket"). */ - readonly cfnType: string; - /** Stack frames for the creation trace (omit for non-TS apps). */ - readonly creationTrace?: readonly string[]; -} - -export interface StackSpec { - /** Stack id. Becomes both artifact id and tree path. */ - readonly id: string; - readonly resources: readonly ResourceSpec[]; -} - -export interface FlatAssemblySpec { - readonly stacks: readonly StackSpec[]; - /** Emit `aws:cdk:path` on template resources (default true); false simulates `--no-path-metadata`. */ - readonly pathMetadata?: boolean; -} - -export interface StageStackSpec { - /** Construct id under the Stage. Tree path = `/`. */ - readonly id: string; - readonly resources: readonly ResourceSpec[]; -} - -export interface StageSpec { - readonly id: string; - readonly stacks: readonly StageStackSpec[]; -} - -export interface NestedAssemblySpec { - readonly stages: readonly StageSpec[]; -} - -export interface NestedStackSpec { - /** Construct id under the parent stack (e.g. "MyNestedStack"). */ - readonly id: string; - /** Resources inside the nested stack. */ - readonly resources: readonly ResourceSpec[]; - /** Nested stacks inside this nested stack (for nested-in-nested fixtures). */ - readonly nestedStacks?: readonly NestedStackSpec[]; -} - -export interface NestedStackParentSpec { - /** Parent stack id. */ - readonly id: string; - /** Top-level resources of the parent stack. */ - readonly resources: readonly ResourceSpec[]; - /** NestedStack children of the parent stack. */ - readonly nestedStacks: readonly NestedStackSpec[]; -} - -/** Build a flat assembly (stacks directly under App, no Stages). */ -export function buildFlatAssembly(spec: FlatAssemblySpec): string { - const dir = mkAssemblyDir('flat'); - const projectRoot = path.dirname(dir); - const artifacts: Record = { - Tree: { type: 'cdk:tree', properties: { file: TREE_FILE } }, - }; - - for (const stack of spec.stacks) { - artifacts[stack.id] = stackArtifact(stack, projectRoot); - writeTemplate(dir, stack.id, stack.resources, stack.id, spec.pathMetadata ?? true); - } - - writeJson(path.join(dir, MANIFEST_FILE), { - version: ASSEMBLY_SCHEMA_VERSION, - artifacts, - }); - writeJson(path.join(dir, TREE_FILE), { - version: TREE_SCHEMA_VERSION, - tree: appNode(spec.stacks.map(stackTreeNode)), - }); - fs.writeFileSync(path.join(dir, 'cdk.out'), JSON.stringify({ version: ASSEMBLY_SCHEMA_VERSION })); - return dir; -} - -/** Build a Stage-based assembly (each stage produces a nested cloud-assembly). */ -export function buildNestedAssembly(spec: NestedAssemblySpec): string { - const rootDir = mkAssemblyDir('nested'); - const projectRoot = path.dirname(rootDir); - const rootArtifacts: Record = { - Tree: { type: 'cdk:tree', properties: { file: TREE_FILE } }, - }; - - const treeChildren: Record = {}; - - for (const stage of spec.stages) { - const dirName = `assembly-${stage.id}`; - rootArtifacts[dirName] = { - type: 'cdk:cloud-assembly', - properties: { directoryName: dirName, displayName: stage.id }, - }; - - const stageDir = path.join(rootDir, dirName); - fs.mkdirSync(stageDir, { recursive: true }); - - const stageArtifacts: Record = {}; - for (const stack of stage.stacks) { - const artifactId = `${stage.id}${stack.id}Artifact`; - const constructPath = `${stage.id}/${stack.id}`; - stageArtifacts[artifactId] = { - type: 'aws:cloudformation:stack', - environment: 'aws://unknown-account/unknown-region', - properties: { templateFile: `${artifactId}.template.json` }, - // displayName is the construct path; the reader keys metadata by - // hierarchicalId, which falls back to displayName. - displayName: constructPath, - metadata: stackMetadata(stack.resources, `/${constructPath}`, projectRoot), - }; - writeTemplate(stageDir, artifactId, stack.resources, constructPath); - } - - writeJson(path.join(stageDir, MANIFEST_FILE), { - version: ASSEMBLY_SCHEMA_VERSION, - artifacts: stageArtifacts, - }); - fs.writeFileSync(path.join(stageDir, 'cdk.out'), JSON.stringify({ version: ASSEMBLY_SCHEMA_VERSION })); - - treeChildren[stage.id] = { - id: stage.id, - path: stage.id, - constructInfo: { fqn: 'aws-cdk-lib.Stage', version: CONSTRUCT_INFO_VERSION }, - children: Object.fromEntries(stage.stacks.map((s) => [s.id, { - id: s.id, - path: `${stage.id}/${s.id}`, - constructInfo: { fqn: 'aws-cdk-lib.Stack', version: CONSTRUCT_INFO_VERSION }, - children: resourcesToTreeChildren(`${stage.id}/${s.id}`, s.resources), - }])), - }; - } - - writeJson(path.join(rootDir, MANIFEST_FILE), { - version: ASSEMBLY_SCHEMA_VERSION, - artifacts: rootArtifacts, - }); - writeJson(path.join(rootDir, TREE_FILE), { - version: TREE_SCHEMA_VERSION, - tree: appNode(Object.values(treeChildren)), - }); - fs.writeFileSync(path.join(rootDir, 'cdk.out'), JSON.stringify({ version: ASSEMBLY_SCHEMA_VERSION })); - return rootDir; -} - -/** - * Build an assembly that mimics a parent stack with NestedStack children. - * aws-cdk-lib emits NO separate manifest artifact for nested stacks; their - * resources' metadata lives under the PARENT artifact, keyed by full - * construct path (e.g. /Parent/MyNestedStack/MyBucket/Resource). - */ -export function buildNestedStackAssembly(spec: { parent: NestedStackParentSpec }): string { - const dir = mkAssemblyDir('nestedstack'); - const projectRoot = path.dirname(dir); - const { parent } = spec; - - // All nested resources' metadata lives under the PARENT artifact, keyed by - // full construct path (any depth), mirroring aws-cdk-lib. - const metadata: Record = {}; - const parentChildren: Record = {}; - const parentResources: Record = {}; - - for (const r of parent.resources) { - metadata[`/${parent.id}/${r.id}/Resource`] = [logicalIdEntry(r, projectRoot)]; - parentChildren[r.id] = resourceTreeNode(parent.id, r); - parentResources[r.logicalId] = { - Type: r.cfnType, - Metadata: { 'aws:cdk:path': `${parent.id}/${r.id}/Resource` }, - Properties: {}, - }; - } - for (const ns of parent.nestedStacks) { - emitNestedStack(dir, metadata, parentChildren, parentResources, parent.id, ns, projectRoot); - } - - writeJson(path.join(dir, MANIFEST_FILE), { - version: ASSEMBLY_SCHEMA_VERSION, - artifacts: { - Tree: { type: 'cdk:tree', properties: { file: TREE_FILE } }, - [parent.id]: { - type: 'aws:cloudformation:stack', - environment: 'aws://unknown-account/unknown-region', - properties: { templateFile: `${parent.id}.template.json` }, - displayName: parent.id, - metadata, - }, - }, - }); - writeJson(path.join(dir, TREE_FILE), { - version: TREE_SCHEMA_VERSION, - tree: appNode([{ - id: parent.id, - path: parent.id, - constructInfo: { fqn: 'aws-cdk-lib.Stack', version: CONSTRUCT_INFO_VERSION }, - children: parentChildren, - }]), - }); - writeJson(path.join(dir, `${parent.id}.template.json`), { Resources: parentResources }); - fs.writeFileSync(path.join(dir, 'cdk.out'), JSON.stringify({ version: ASSEMBLY_SCHEMA_VERSION })); - return dir; -} - -/** - * Writes one nested stack's template (its resources plus an - * AWS::CloudFormation::Stack + aws:asset:path per child nested stack) and - * recurses for those children. Accumulates every resource's logical-ID - * metadata into the parent artifact's metadata map, keyed by full construct - * path. Returns the tree node and the nested template filename. - */ -function emitNestedStack( - dir: string, - metadata: Record, - parentChildren: Record, - parentResources: Record, - parentPath: string, - ns: NestedStackSpec, - projectRoot: string, -): void { - const nsPath = `${parentPath}/${ns.id}`; - const templateFile = `${nsPath.replace(/\//g, '')}.nested.template.json`; - const cfnStackLogicalId = `${ns.id}NestedStackResource`; - - // Parent template: the AWS::CloudFormation::Stack resource pointing at the - // nested template via aws:asset:path. - parentResources[cfnStackLogicalId] = { - Type: 'AWS::CloudFormation::Stack', - Metadata: { 'aws:asset:path': templateFile }, - Properties: {}, - }; - // Tree: mirror aws-cdk-lib -- the CfnStack resource lives in a SIBLING - // `.NestedStack` wrapper (not under the NestedStack construct), and its - // logical ID is emitted into the owning stack's metadata. - const resourcePath = `${nsPath}.NestedStack/${ns.id}.NestedStackResource`; - parentChildren[`${ns.id}.NestedStack`] = { - id: `${ns.id}.NestedStack`, - path: `${nsPath}.NestedStack`, - constructInfo: { fqn: 'constructs.Construct', version: CONSTRUCT_INFO_VERSION }, - children: { - [`${ns.id}.NestedStackResource`]: { - id: `${ns.id}.NestedStackResource`, - path: resourcePath, - attributes: { 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack' }, - }, - }, - }; - metadata[`/${resourcePath}`] = [{ type: ArtifactMetadataEntryType.LOGICAL_ID, data: cfnStackLogicalId }]; - - // The nested stack's own construct subtree + template. - const nsChildren: Record = {}; - const nsResources: Record = {}; - for (const r of ns.resources) { - metadata[`/${nsPath}/${r.id}/Resource`] = [logicalIdEntry(r, projectRoot)]; - nsChildren[r.id] = resourceTreeNode(nsPath, r); - nsResources[r.logicalId] = { - Type: r.cfnType, - Metadata: { 'aws:cdk:path': `${nsPath}/${r.id}/Resource` }, - Properties: {}, - }; - } - for (const child of ns.nestedStacks ?? []) { - emitNestedStack(dir, metadata, nsChildren, nsResources, nsPath, child, projectRoot); - } - writeJson(path.join(dir, templateFile), { Resources: nsResources }); - - parentChildren[ns.id] = { - id: ns.id, - path: nsPath, - constructInfo: { fqn: 'aws-cdk-lib.NestedStack', version: CONSTRUCT_INFO_VERSION }, - children: nsChildren, - }; -} - -/** Manifest + tree with no metadata, no traces — for non-TS app graceful-degradation tests. */ -export function buildNonTypeScriptAssembly(): string { - const dir = mkAssemblyDir('nonts'); - writeJson(path.join(dir, MANIFEST_FILE), { - version: ASSEMBLY_SCHEMA_VERSION, - artifacts: { - Stack1: { - type: 'aws:cloudformation:stack', - environment: 'aws://unknown-account/unknown-region', - properties: { templateFile: 'Stack1.template.json' }, - displayName: 'Stack1', - // No metadata: non-TS apps emit no aws:cdk:logicalId entries. - }, - Tree: { type: 'cdk:tree', properties: { file: TREE_FILE } }, - }, - }); - writeJson(path.join(dir, TREE_FILE), { - version: TREE_SCHEMA_VERSION, - tree: { - id: 'App', - path: '', - constructInfo: { fqn: 'aws-cdk-lib.App', version: CONSTRUCT_INFO_VERSION }, - children: { - Stack1: { - id: 'Stack1', - path: 'Stack1', - constructInfo: { fqn: 'aws-cdk-lib.Stack', version: CONSTRUCT_INFO_VERSION }, - }, - }, - }, - }); - writeJson(path.join(dir, 'Stack1.template.json'), { Resources: {} }); - fs.writeFileSync(path.join(dir, 'cdk.out'), JSON.stringify({ version: ASSEMBLY_SCHEMA_VERSION })); - return dir; -} - -/** Drop an unparseable validation-report.json into an existing fixture dir. */ -export function withMalformedValidationReport(dir: string): void { - fs.writeFileSync(path.join(dir, VALIDATION_REPORT_FILE), '{ "pluginReports": ['); -} - -/** - * Drop a well-formed but version-LESS validation-report.json: the legacy shape - * older aws-cdk-lib emits (no `version` field). The reader must still load it. - */ -export function withVersionlessValidationReport(dir: string): void { - writeJson(path.join(dir, VALIDATION_REPORT_FILE), { - title: 'Validation Report', - pluginReports: [], - }); -} - -/** Drop a well-formed validation-report.json into an existing fixture dir. */ -export function withValidationReport(dir: string, report: { - version?: string; - pluginReports: Array<{ - pluginName: string; - conclusion: 'success' | 'failure'; - violations: Array<{ - ruleName: string; - description: string; - severity: 'fatal' | 'error' | 'warning' | 'info' | 'custom'; - violatingConstructs: Array<{ - constructPath: string; - cloudFormationResource?: { templatePath: string; logicalId: string }; - }>; - }>; - }>; -}): void { - writeJson(path.join(dir, VALIDATION_REPORT_FILE), { - version: report.version ?? '1.0.0', - ...report, - }); -} - -/** Delete a fixture dir; safe on undefined / missing dirs. */ -export function cleanupFixture(dir: string | undefined): void { - if (!dir) return; - fs.rmSync(dir, { recursive: true, force: true }); -} - -// ---------- internals ---------- - -function mkAssemblyDir(prefix: string): string { - // realpath the temp dir so the project root (its parent) is canonical and - // matches source paths resolved from traces under the reader's containment - // check (e.g. macOS /var -> /private/var). - const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `cdk-explorer-${prefix}-`))); - return path.join(base, 'cdk.out'); -} - -function writeJson(filePath: string, data: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); -} - -function appNode(children: readonly unknown[]): unknown { - const childrenMap: Record = {}; - for (const child of children) { - const c = child as { id: string }; - childrenMap[c.id] = child; - } - return { - id: 'App', - path: '', - constructInfo: { fqn: 'aws-cdk-lib.App', version: CONSTRUCT_INFO_VERSION }, - children: childrenMap, - }; -} - -function stackTreeNode(stack: StackSpec): unknown { - return { - id: stack.id, - path: stack.id, - constructInfo: { fqn: 'aws-cdk-lib.Stack', version: CONSTRUCT_INFO_VERSION }, - children: Object.fromEntries( - stack.resources.map((r) => [r.id, resourceTreeNode(stack.id, r)]), - ), - }; -} - -function resourcesToTreeChildren(stackPath: string, resources: readonly ResourceSpec[]): Record { - return Object.fromEntries(resources.map((r) => [r.id, resourceTreeNode(stackPath, r)])); -} - -// Mirrors aws-cdk-lib's L2 construct -> CfnResource child shape. -function resourceTreeNode(parentPath: string, r: ResourceSpec): unknown { - return { - id: r.id, - path: `${parentPath}/${r.id}`, - children: { - Resource: { - id: 'Resource', - path: `${parentPath}/${r.id}/Resource`, - attributes: { 'aws:cdk:cloudformation:type': r.cfnType }, - }, - }, - }; -} - -function stackArtifact(stack: StackSpec, projectRoot: string): unknown { - return { - type: 'aws:cloudformation:stack', - environment: 'aws://unknown-account/unknown-region', - properties: { templateFile: `${stack.id}.template.json` }, - displayName: stack.id, - metadata: stackMetadata(stack.resources, `/${stack.id}`, projectRoot), - }; -} - -function stackMetadata( - resources: readonly ResourceSpec[], - pathPrefix: string, - projectRoot: string, -): Record { - const metadata: Record = {}; - for (const r of resources) { - metadata[`${pathPrefix}/${r.id}/Resource`] = [logicalIdEntry(r, projectRoot)]; - } - return metadata; -} - -function logicalIdEntry(r: ResourceSpec, projectRoot: string): Record { - const entry: Record = { type: ArtifactMetadataEntryType.LOGICAL_ID, data: r.logicalId }; - const trace = rebaseTrace(r.creationTrace, projectRoot); - if (trace && trace.length > 0) { - entry.trace = trace; - } - return entry; -} - -/** - * Anchor a creation trace's source paths to the fixture's project root (the - * parent of cdk.out), mirroring real apps whose sources live beside cdk.out. - * Tests write `` in a frame; the reader's containment check then - * accepts the resolved path instead of dropping it as out-of-project. - */ -function rebaseTrace(trace: readonly string[] | undefined, projectRoot: string): string[] | undefined { - return trace?.map((frame) => frame.replace(//g, projectRoot)); -} - -function writeTemplate( - dir: string, - fileBaseId: string, - resources: readonly ResourceSpec[], - constructPathPrefix: string, - pathMetadata = true, -): void { - const out: Record = {}; - for (const r of resources) { - out[r.logicalId] = { - Type: r.cfnType, - // aws:cdk:path mirrors real synth output (on by default), giving each - // resource its globally-unique construct path for collision-free lookup. - ...(pathMetadata ? { Metadata: { 'aws:cdk:path': `${constructPathPrefix}/${r.id}/Resource` } } : {}), - Properties: {}, - }; - } - writeJson(path.join(dir, `${fileBaseId}.template.json`), { Resources: out }); -} diff --git a/packages/@aws-cdk/cdk-explorer/test/_helpers/assembly.ts b/packages/@aws-cdk/cdk-explorer/test/_helpers/assembly.ts new file mode 100644 index 000000000..0356c4136 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/_helpers/assembly.ts @@ -0,0 +1,202 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { AssemblyIndex, CloudAssembly } from '@aws-cdk/cloud-assembly-api'; +import type { PluginReportJson } from '@aws-cdk/cloud-assembly-schema'; +import { AssemblyIndexWithSourceLocations } from '../../lib/core/assembly-index-with-source-locations'; + +/** + * Builds a real cloud assembly on disk and indexes it, so tests exercise the + * production read path (manifest + tree.json + templates) instead of + * hand-shaped node objects. + * + * Deliberately only models flat stacks: nested stacks, Stages and template + * resolution are `AssemblyIndex`'s job and are covered in the + * `@aws-cdk/cloud-assembly-api` suite. What matters here is how the explorer + * layers on top of an index, so this keeps the fixture to the smallest shape + * that produces one. + */ +export interface ResourceSpec { + /** Construct path below its stack, e.g. `Bucket/Resource`. */ + readonly path: string; + readonly logicalId: string; + /** CFN type. Omit to model a construct that is not a CFN resource. */ + readonly type?: string; + /** + * Creation site as `:[:]`, the file app-relative. Omit to + * model a non-TypeScript app, where no creation stack is recorded. + */ + readonly at?: string; + /** Resolved CFN properties, as tree.json records them. */ + readonly props?: Record; +} + +export interface StackSpec { + readonly id: string; + readonly resources?: readonly ResourceSpec[]; +} + +export interface AssemblySpec { + readonly stacks?: readonly StackSpec[]; + /** Written to `validation-report.json`. */ + readonly reports?: readonly PluginReportJson[]; + /** Extra files to create in the app dir, keyed by app-relative path. */ + readonly sourceFiles?: Record; + /** Omit `tree.json`, leaving only manifest metadata to index from. */ + readonly withoutTree?: boolean; +} + +/** A tree.json node under construction (children keyed by construct id). */ +interface MutableTreeNode { + id: string; + path: string; + children: Record; + attributes?: Record; +} + +export class AssemblyFixture { + public static create(spec: AssemblySpec = {}): AssemblyFixture { + // realpath: on macOS os.tmpdir() is a symlink, and the index relativizes + // creation-stack paths against appDir, so the two must agree. + const appDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-'))); + const assemblyDir = path.join(appDir, 'cdk.out'); + fs.mkdirSync(assemblyDir); + + for (const [relPath, content] of Object.entries(spec.sourceFiles ?? {})) { + const full = path.join(appDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const artifacts: Record = {}; + const root: MutableTreeNode = { id: 'App', path: '', children: {} }; + + for (const stack of spec.stacks ?? []) { + const metadata: Record = {}; + const resources: Record = {}; + + for (const resource of stack.resources ?? []) { + const constructPath = `${stack.id}/${resource.path}`; + const entries: unknown[] = []; + if (resource.at) { + entries.push({ type: 'aws:cdk:creationStack', data: creationStack(appDir, resource.at) }); + } + entries.push({ type: 'aws:cdk:logicalId', data: resource.logicalId }); + metadata[`/${constructPath}`] = entries; + + if (resource.type) { + resources[resource.logicalId] = { + Type: resource.type, + ...(resource.props ? { Properties: resource.props } : {}), + }; + } + insert(root, constructPath, resource); + } + + artifacts[stack.id] = { + type: 'aws:cloudformation:stack', + environment: 'aws://111111111111/us-east-1', + properties: { templateFile: templateFileFor(stack.id) }, + displayName: stack.id, + metadata, + }; + // Indented so resource blocks span several lines, which is what makes the + // template line ranges in these tests meaningful. + write(assemblyDir, templateFileFor(stack.id), { Resources: resources }); + } + + if (!spec.withoutTree) { + artifacts.Tree = { type: 'cdk:tree', properties: { file: 'tree.json' } }; + write(assemblyDir, 'tree.json', { version: 'tree-0.1', tree: root }); + } + + write(assemblyDir, 'manifest.json', { version: '0.0.0', artifacts }); + + if (spec.reports) { + write(assemblyDir, 'validation-report.json', { version: '1.0.0', pluginReports: spec.reports }); + } + + return new AssemblyFixture(appDir, assemblyDir); + } + + private constructor( + public readonly appDir: string, + public readonly assemblyDir: string, + ) { + } + + /** Index the assembly exactly as production does. */ + public async index(): Promise { + return AssemblyIndexWithSourceLocations.createAndIndex({ + appDir: this.appDir, + assemblyDir: this.assemblyDir, + assemblyIndex: await AssemblyIndex.fromAssembly(new CloudAssembly(this.assemblyDir)), + }); + } + + /** Absolute path of a source file in the app dir. */ + public sourcePath(relPath: string): string { + return path.join(this.appDir, relPath); + } + + /** Assembly-relative template name for a stack. */ + public templateFile(stackId: string): string { + return templateFileFor(stackId); + } + + public templatePath(stackId: string): string { + return path.join(this.assemblyDir, templateFileFor(stackId)); + } + + public templateText(stackId: string): string { + return fs.readFileSync(this.templatePath(stackId), 'utf-8'); + } + + public cleanup(): void { + fs.rmSync(this.appDir, { recursive: true, force: true }); + } +} + +function templateFileFor(stackId: string): string { + return `${stackId}.template.json`; +} + +function write(dir: string, relPath: string, content: unknown): void { + fs.writeFileSync(path.join(dir, relPath), JSON.stringify(content, undefined, 1)); +} + +/** + * A "Just My Code" creation stack whose only user frame is `at`. The leading + * frame mimics the redacted aws-cdk-lib frames, so the default frame finder has + * something to skip past. File names are absolute, as a real capture records them. + */ +function creationStack(appDir: string, at: string): string[] { + const match = /^(.*?):(\d+)(?::(\d+))?$/.exec(at); + if (!match) { + throw new Error(`creation site must be ':[:]', got '${at}'`); + } + const [, file, line, column] = match; + const absolute = path.isAbsolute(file) ? file : path.join(appDir, file); + return [ + '...aws-cdk-lib...', + `myConstruct (${absolute}:${line}${column ? `:${column}` : ''})`, + ]; +} + +/** Insert a construct path into the tree, creating intermediate wrapper nodes. */ +function insert(root: MutableTreeNode, constructPath: string, resource: ResourceSpec): void { + const segments = constructPath.split('/'); + let node = root; + let walked = ''; + for (const segment of segments) { + walked = walked ? `${walked}/${segment}` : segment; + node.children[segment] ??= { id: segment, path: walked, children: {} }; + node = node.children[segment]; + } + if (resource.type) { + node.attributes = { + 'aws:cdk:cloudformation:type': resource.type, + ...(resource.props ? { 'aws:cdk:cloudformation:props': resource.props } : {}), + }; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/test/_helpers/fake-toolkit.ts b/packages/@aws-cdk/cdk-explorer/test/_helpers/fake-toolkit.ts new file mode 100644 index 000000000..a4ba559cb --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/_helpers/fake-toolkit.ts @@ -0,0 +1,120 @@ +import { AssemblyError, LockError, NonInteractiveIoHost, Toolkit } from '@aws-cdk/toolkit-lib'; +import type { ICloudAssemblySource, IReadableCloudAssembly } from '@aws-cdk/toolkit-lib'; + +/** + * A `Toolkit` stand-in that scripts the app-synth path while keeping the + * assembly-read path real. + * + * `createLspHandlers` takes a `Toolkit` and calls `runSynth` directly, so there + * is no synth seam to inject. To drive synth outcomes without spawning a CDK app + * subprocess, this delegates `fromAssemblyDirectory` (and any synth of the + * source it returns) to a genuine Toolkit, so the construct index is still built + * from the fixture on disk, and intercepts only `fromCdkApp` + the synth of + * *that* source. + */ + +/** Marks the assembly source `fromCdkApp` hands back, so `synth` can recognise it. */ +const APP_SOURCE = Symbol('fake-app-source'); + +/** One scripted outcome of synthesizing the app. */ +export type SynthOutcome = + | { readonly kind: 'success' } + /** A failing CDK app; `details` becomes the error's cause, which carries the compiler output. */ + | { readonly kind: 'app-failure'; readonly message: string; readonly details?: string } + /** + * An external writer (a terminal `cdk synth`) holding the cdk.out write lock. + * Raises a `LockError`, which `retryOnLock` retries rather than surfacing, so + * this is not a `SynthRunResult` status. + */ + | { readonly kind: 'locked' } + | { readonly kind: 'error'; readonly message: string }; + +export class FakeToolkit { + /** Project dirs passed to `fromCdkApp`, in call order. */ + public readonly appSynthCalls: string[] = []; + + private readonly real = new Toolkit({ ioHost: new NonInteractiveIoHost() }); + private readonly outcomes: SynthOutcome[] = []; + /** Repeated once `outcomes` runs out, so a test need only script what it cares about. */ + private trailing: SynthOutcome = { kind: 'success' }; + private gate: Promise | undefined; + private openGate: (() => void) | undefined; + + /** Script the next outcomes; the last one repeats for any further synth. */ + public willSynth(...outcomes: SynthOutcome[]): this { + this.outcomes.push(...outcomes); + if (outcomes.length > 0) { + this.trailing = outcomes[outcomes.length - 1]; + } + return this; + } + + /** + * Hold the next app synth open until {@link releaseSynth}, so a test can + * observe what happens while one is in flight (the concurrency latch). + */ + public blockSynth(): this { + this.gate = new Promise((resolve) => { + this.openGate = resolve; + }); + return this; + } + + public releaseSynth(): void { + this.openGate?.(); + this.gate = undefined; + this.openGate = undefined; + } + + /** The number of app synths attempted (each lock retry counts as one). */ + public get synthCount(): number { + return this.appSynthCalls.length; + } + + /** Cast to the type `createLspHandlers` expects. */ + public asToolkit(): Toolkit { + return this as unknown as Toolkit; + } + + public async fromAssemblyDirectory(directory: string, props?: unknown): Promise { + return (this.real as any).fromAssemblyDirectory(directory, props); + } + + public async fromCdkApp(app: string, props?: { readonly workingDirectory?: string }): Promise { + return { [APP_SOURCE]: true, workingDirectory: props?.workingDirectory ?? app } as unknown as ICloudAssemblySource; + } + + public async synth(source: any, props?: unknown): Promise { + if (!source?.[APP_SOURCE]) { + // A real assembly source: let the genuine Toolkit read it. + return (this.real as any).synth(source, props); + } + + this.appSynthCalls.push(source.workingDirectory); + if (this.gate) { + await this.gate; + } + + const outcome = this.outcomes.shift() ?? this.trailing; + switch (outcome.kind) { + case 'success': + return fakeReadableAssembly(); + case 'app-failure': + throw AssemblyError.withCause(outcome.message, new Error(outcome.details ?? '')); + case 'locked': + throw new LockError('ConcurrentWriteLock', 'a synth is writing'); + case 'error': + throw new Error(outcome.message); + } + } +} + +/** The handle `runSynth` disposes after a successful synth. */ +function fakeReadableAssembly(): IReadableCloudAssembly { + return { + cloudAssembly: undefined, + dispose: async () => undefined, + _unlock: async () => undefined, + [Symbol.asyncDispose]: async () => undefined, + } as unknown as IReadableCloudAssembly; +} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/assembly-reader.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/assembly-reader.test.ts deleted file mode 100644 index 9a4d3c001..000000000 --- a/packages/@aws-cdk/cdk-explorer/test/core/assembly-reader.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; -import { readAssembly, type AssemblyData, type AssemblyReadResult, type ConstructNode } from '../../lib'; -import { - buildFlatAssembly, - buildNestedAssembly, - buildNestedStackAssembly, - buildNonTypeScriptAssembly, - cleanupFixture, - withMalformedValidationReport, - withValidationReport, - withVersionlessValidationReport, -} from '../_fixtures/builders'; - -/** Assert that readAssembly succeeded and return the typed data. */ -function expectSuccess(result: AssemblyReadResult): AssemblyData { - expect(result.status).toBe('success'); - // Cast is safe because expect() above would have failed the test on mismatch. - return (result as Extract).data; -} - -describe('readAssembly', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('returns not-found for nonexistent directory', async () => { - expect((await readAssembly('/nonexistent/path')).status).toBe('not-found'); - }); - - test('returns not-found for directory without manifest.json', async () => { - const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-empty-')); - try { - expect((await readAssembly(empty)).status).toBe('not-found'); - } finally { - fs.rmSync(empty, { recursive: true, force: true }); - } - }); - - test('parses tree from a flat assembly', async () => { - dir = buildFlatAssembly({ - stacks: [ - { id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }, - { id: 'Stack2', resources: [{ id: 'MyQueue', logicalId: 'MyQueueE6CA6235', cfnType: 'AWS::SQS::Queue' }] }, - ], - }); - - const data = expectSuccess(await readAssembly(dir)); - - expect(data.tree).toHaveLength(2); - expect(data.tree.map((n) => n.id).sort()).toEqual(['Stack1', 'Stack2']); - }); - - test('enriches resource nodes with logicalId and cfnType', async () => { - dir = buildFlatAssembly({ - stacks: [{ - id: 'Stack1', - resources: [{ - id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket', - }], - }], - }); - const data = expectSuccess(await readAssembly(dir)); - - const resource = findNode(data.tree, 'Stack1/MyBucket/Resource')!; - expect(resource.logicalId).toBe('MyBucketF68F3FF0'); - expect(resource.type).toBe('AWS::S3::Bucket'); - }); - - test('non-resource constructs (L2 wrappers) have no logicalId or type', async () => { - dir = buildFlatAssembly({ - stacks: [{ - id: 'Stack1', - resources: [{ - id: 'MyBucket', - logicalId: 'MyBucketF68F3FF0', - cfnType: 'AWS::S3::Bucket', - }], - }], - }); - const data = expectSuccess(await readAssembly(dir)); - - const wrapper = findNode(data.tree, 'Stack1/MyBucket')!; - expect(wrapper.logicalId).toBeUndefined(); - expect(wrapper.type).toBeUndefined(); - }); - - test('children are arrays', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [{ id: 'X', logicalId: 'X', cfnType: 'AWS::S3::Bucket' }] }], - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(Array.isArray(data.tree)).toBe(true); - expect(Array.isArray(data.tree[0].children)).toBe(true); - }); - - test('returns error for malformed manifest', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-malformed-')); - fs.writeFileSync(path.join(tmpDir, MANIFEST_FILE), 'not json{{{'); - try { - const result = await readAssembly(tmpDir); - expect(result.status).toBe('error'); - if (result.status === 'error') expect(result.message).toBeTruthy(); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - -describe('readAssembly with violations', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('parses validation report when present', async () => { - dir = buildFlatAssembly({ stacks: [{ id: 'Stack1', resources: [] }] }); - withValidationReport(dir, { - pluginReports: [{ - pluginName: 'no-public-buckets-plugin', - conclusion: 'failure', - violations: [{ - ruleName: 'no-public-buckets', - description: 'S3 must not be public', - severity: 'error', - violatingConstructs: [{ - constructPath: 'Stack1/MyBucket', - cloudFormationResource: { templatePath: 'Stack1.template.json', logicalId: 'B1' }, - }], - }], - }], - }); - - const data = expectSuccess(await readAssembly(dir)); - - expect(data.violations).toBeDefined(); - expect(data.violations!.pluginReports[0].conclusion).toBe('failure'); - expect(data.violations!.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); - }); - - test('returns undefined violations when report file is absent', async () => { - dir = buildFlatAssembly({ stacks: [{ id: 'Stack1', resources: [] }] }); - const data = expectSuccess(await readAssembly(dir)); - - expect(data.violations).toBeUndefined(); - }); - - test('malformed validation report does not crash the tree read', async () => { - dir = buildFlatAssembly({ stacks: [{ id: 'Stack1', resources: [] }] }); - withMalformedValidationReport(dir); - - const data = expectSuccess(await readAssembly(dir)); - - expect(data.tree).toHaveLength(1); - expect(data.violations).toBeUndefined(); - expect(data.warnings.some((w) => w.includes('validation-report.json'))).toBe(true); - }); - - test('loads a version-less validation report (legacy aws-cdk-lib shape)', async () => { - dir = buildFlatAssembly({ stacks: [{ id: 'Stack1', resources: [] }] }); - withVersionlessValidationReport(dir); - - const data = expectSuccess(await readAssembly(dir)); - - expect(data.tree).toHaveLength(1); - expect(data.violations).toBeDefined(); - }); -}); - -describe('readAssembly with Stage-based (nested-assembly) apps', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('preserves Stage grouping node in the tree', async () => { - dir = buildNestedAssembly({ - stages: [{ id: 'Prod', stacks: [{ id: 'Service', resources: [] }] }], - }); - const data = expectSuccess(await readAssembly(dir)); - - const stage = data.tree.find((n) => n.id === 'Prod')!; - expect(stage.path).toBe('Prod'); - }); - - test('resources inside a Stage stack get logicalId and cfnType', async () => { - dir = buildNestedAssembly({ - stages: [{ - id: 'Prod', - stacks: [{ - id: 'Service', - resources: [{ id: 'MyBucket', logicalId: 'MyBucketABC', cfnType: 'AWS::S3::Bucket' }], - }], - }], - }); - const data = expectSuccess(await readAssembly(dir)); - - const resource = findNode(data.tree, 'Prod/Service/MyBucket/Resource')!; - expect(resource.logicalId).toBe('MyBucketABC'); - expect(resource.type).toBe('AWS::S3::Bucket'); - }); - - test('multi-stage apps route metadata to the correct nested assembly', async () => { - // Two stages, same construct ids — proves we don't cross-contaminate - // metadata from one stage's nested-assembly manifest to another's. - dir = buildNestedAssembly({ - stages: [ - { id: 'Prod', stacks: [{ id: 'Service', resources: [{ id: 'X', logicalId: 'ProdX', cfnType: 'AWS::S3::Bucket' }] }] }, - { id: 'Staging', stacks: [{ id: 'Service', resources: [{ id: 'X', logicalId: 'StagingX', cfnType: 'AWS::S3::Bucket' }] }] }, - ], - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Prod/Service/X/Resource')!.logicalId).toBe('ProdX'); - expect(findNode(data.tree, 'Staging/Service/X/Resource')!.logicalId).toBe('StagingX'); - }); -}); - -describe('readAssembly graceful degradation', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('non-TypeScript app returns success with no source enrichment, no crash', async () => { - dir = buildNonTypeScriptAssembly(); - - const data = expectSuccess(await readAssembly(dir)); - - expect(data.tree).toHaveLength(1); - const stack = data.tree[0]; - expect(stack.id).toBe('Stack1'); - expect(stack.sourceLocation).toBeUndefined(); - }); -}); - -describe('readAssembly resource templateFile', () => { - let dir: string | undefined; - - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); - - test('sets templateFile to the resource\'s own stack template, none on wrappers', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }], - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Stack1/MyBucket/Resource')!.templateFile) - .toBe(path.join(dir!, 'Stack1.template.json')); - // L2 wrapper is not a CFN resource -> no template. - expect(findNode(data.tree, 'Stack1/MyBucket')!.templateFile).toBeUndefined(); - }); - - test('resolves nested-stack resources to the nested template, not the parent', async () => { - dir = buildNestedStackAssembly({ - parent: { - id: 'Parent', - resources: [{ id: 'TopBucket', logicalId: 'TopBucketABC', cfnType: 'AWS::S3::Bucket' }], - nestedStacks: [{ - id: 'MyNested', - resources: [{ id: 'NestedQueue', logicalId: 'NestedQueueXYZ', cfnType: 'AWS::SQS::Queue' }], - }], - }, - }); - const data = expectSuccess(await readAssembly(dir)); - - // Top-level resource lives in the parent template. - expect(findNode(data.tree, 'Parent/TopBucket/Resource')!.templateFile) - .toBe(path.join(dir!, 'Parent.template.json')); - // The resource inside the NestedStack lives in the nested template. - expect(findNode(data.tree, 'Parent/MyNested/NestedQueue/Resource')!.templateFile) - .toBe(path.join(dir!, 'ParentMyNested.nested.template.json')); - }); - - test('keeps templates distinct when two stacks share a logical id (stack-relative ids)', async () => { - // Same-shape stacks produce the SAME stack-relative logicalId in different - // templates; each resource must resolve to its OWN stack's template. - dir = buildFlatAssembly({ - stacks: [ - { id: 'Prod', resources: [{ id: 'Data', logicalId: 'DataX', cfnType: 'AWS::S3::Bucket' }] }, - { id: 'Dev', resources: [{ id: 'Data', logicalId: 'DataX', cfnType: 'AWS::S3::Bucket' }] }, - ], - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Prod/Data/Resource')!.templateFile).toBe(path.join(dir!, 'Prod.template.json')); - expect(findNode(data.tree, 'Dev/Data/Resource')!.templateFile).toBe(path.join(dir!, 'Dev.template.json')); - }); - - test('resolves a parent resource and its nested-stack twin that share a logical id', async () => { - // A NestedStack resets the logical-ID namespace, so a parent resource and a - // resource in its own nested stack can share an id. The globally-unique - // construct path (aws:cdk:path) disambiguates them. - dir = buildNestedStackAssembly({ - parent: { - id: 'Parent', - resources: [{ id: 'Data', logicalId: 'DataX', cfnType: 'AWS::S3::Bucket' }], - nestedStacks: [{ - id: 'Nested', - resources: [{ id: 'Data', logicalId: 'DataX', cfnType: 'AWS::S3::Bucket' }], - }], - }, - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Parent/Data/Resource')!.templateFile) - .toBe(path.join(dir!, 'Parent.template.json')); - expect(findNode(data.tree, 'Parent/Nested/Data/Resource')!.templateFile) - .toBe(path.join(dir!, 'ParentNested.nested.template.json')); - }); - - test('resolves a resource in a doubly-nested stack to the innermost template', async () => { - dir = buildNestedStackAssembly({ - parent: { - id: 'Parent', - resources: [], - nestedStacks: [{ - id: 'Outer', - resources: [{ id: 'OuterFn', logicalId: 'OuterFnABC', cfnType: 'AWS::Lambda::Function' }], - nestedStacks: [{ - id: 'Inner', - resources: [{ id: 'InnerQueue', logicalId: 'InnerQueueXYZ', cfnType: 'AWS::SQS::Queue' }], - }], - }], - }, - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Parent/Outer/OuterFn/Resource')!.templateFile) - .toBe(path.join(dir!, 'ParentOuter.nested.template.json')); - expect(findNode(data.tree, 'Parent/Outer/Inner/InnerQueue/Resource')!.templateFile) - .toBe(path.join(dir!, 'ParentOuterInner.nested.template.json')); - }); - - test('skips an unreadable nested template instead of failing the whole read', async () => { - dir = buildNestedStackAssembly({ - parent: { - id: 'Parent', - resources: [{ id: 'TopBucket', logicalId: 'TopBucketABC', cfnType: 'AWS::S3::Bucket' }], - nestedStacks: [{ - id: 'Nested', - resources: [{ id: 'NestedQueue', logicalId: 'NestedQueueXYZ', cfnType: 'AWS::SQS::Queue' }], - }], - }, - }); - fs.rmSync(path.join(dir, 'ParentNested.nested.template.json')); - // Still a success: the missing nested template degrades only its subtree. - const data = expectSuccess(await readAssembly(dir)); - expect(findNode(data.tree, 'Parent/TopBucket/Resource')!.templateFile) - .toBe(path.join(dir!, 'Parent.template.json')); - expect(findNode(data.tree, 'Parent/Nested/NestedQueue/Resource')!.templateFile).toBeUndefined(); - }); - - test('resolves templateFile without path metadata (positional, not id-based)', async () => { - // Resolution threads the template down the construct tree, so it works - // regardless of --no-path-metadata (no reliance on aws:cdk:path). - dir = buildFlatAssembly({ - pathMetadata: false, - stacks: [{ id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }], - }); - const data = expectSuccess(await readAssembly(dir)); - - expect(findNode(data.tree, 'Stack1/MyBucket/Resource')!.templateFile) - .toBe(path.join(dir!, 'Stack1.template.json')); - }); -}); - -function findNode(nodes: readonly ConstructNode[], targetPath: string): ConstructNode | undefined { - for (const node of nodes) { - if (node.path === targetPath) return node; - const found = findNode(node.children, targetPath); - if (found) return found; - } - return undefined; -} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts index 89b414f54..2795b654a 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts @@ -2,13 +2,20 @@ import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; import { VALIDATION_REPORT_FILE } from '@aws-cdk/cloud-assembly-schema'; import { startAssemblyWatcher, TREE_FILE, type FileWatcher } from '../../lib/core/assembly-watcher'; -type AnyListener = (...args: unknown[]) => void; +// `any[]` rather than `unknown[]`: the stored listeners are invoked with +// whatever the emitting helper passes, and `unknown[]` params are not +// contravariantly compatible with FileWatcher's typed overloads. +type AnyListener = (...args: any[]) => void; /** A controllable in-memory stand-in for chokidar's FSWatcher. */ class FakeWatcher implements FileWatcher { public closed = false; private readonly listeners: Record = {}; + // Mirror FileWatcher's overloads so the class satisfies the interface; the + // implementation signature stays permissive because the registry is untyped. + public on(event: 'all', listener: (eventName: string, filePath: string) => void): FileWatcher; + public on(event: 'error', listener: (error: unknown) => void): FileWatcher; public on(event: string, listener: AnyListener): FileWatcher { (this.listeners[event] ??= []).push(listener); return this; @@ -36,12 +43,16 @@ class FakeWatcher implements FileWatcher { function setup() { const fake = new FakeWatcher(); const onChange = jest.fn(); + const onError = jest.fn(); + const onSynthActivity = jest.fn(); const watcher = startAssemblyWatcher({ assemblyDir: '/p/cdk.out', onChange, + onError, + onSynthActivity, createWatcher: () => fake, }); - return { fake, onChange, watcher }; + return { fake, onChange, onError, onSynthActivity, watcher }; } describe('Assembly Watcher', () => { @@ -71,6 +82,27 @@ describe('Assembly Watcher', () => { expect(onChange).not.toHaveBeenCalled(); }); + test('reports the write lock (synth.lock) appearing as synth activity', () => { + const { fake, onChange, onSynthActivity } = setup(); + + fake.emitFile('add', '/p/cdk.out/synth.lock'); + + expect(onSynthActivity).toHaveBeenCalledTimes(1); + expect(onSynthActivity).toHaveBeenCalledWith(expect.any(Number)); + // The write lock is never an assembly refresh. + jest.advanceTimersByTime(500); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('does not report synth activity for a read lock or a lock removal', () => { + const { fake, onSynthActivity } = setup(); + + fake.emitFile('add', '/p/cdk.out/read.12345.1.lock'); + fake.emitFile('unlink', '/p/cdk.out/synth.lock'); + + expect(onSynthActivity).not.toHaveBeenCalled(); + }); + test('ignores non-signal files such as templates', () => { const { fake, onChange } = setup(); 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 index ddfea427d..d52543138 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts @@ -3,10 +3,10 @@ import * as os from 'os'; import * as path from 'path'; import { readCdkConfig } from '../../lib/core/cdk-config'; -function withTempDir(fn: (dir: string) => T): T { +async function withTempDir(fn: (dir: string) => Promise): Promise { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-cdkconfig-')); try { - return fn(dir); + await fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -16,45 +16,77 @@ function writeCdkJson(dir: string, contents: string): void { fs.writeFileSync(path.join(dir, 'cdk.json'), contents); } +/** + * `readCdkConfig` reports only what `cdk.json` actually says: `undefined` for + * "there is no usable config here", and `undefined` fields for keys it does not + * set. Defaulting is the caller's job (both servers resolve `output` against the + * project dir with their own `?? 'cdk.out'`), so this never invents a value. + */ describe('readCdkConfig', () => { - test('returns the app command when present', () => { - withTempDir((dir) => { + test('returns the app command when present', async () => { + await withTempDir(async (dir) => { writeCdkJson(dir, JSON.stringify({ app: 'npx tsx bin/app.ts' })); - expect(readCdkConfig(dir)).toEqual({ app: 'npx tsx bin/app.ts' }); + expect(await readCdkConfig(dir)).toEqual({ app: 'npx tsx bin/app.ts', output: undefined }); }); }); - test('returns undefined when cdk.json is absent', () => { - withTempDir((dir) => { - expect(readCdkConfig(dir)).toEqual({ app: undefined }); + test('returns the configured output directory', async () => { + await withTempDir(async (dir) => { + writeCdkJson(dir, JSON.stringify({ app: 'node bin/app.js', output: 'dist/assembly' })); + expect(await readCdkConfig(dir)).toEqual({ app: 'node bin/app.js', output: 'dist/assembly' }); }); }); - test('returns undefined when cdk.json is malformed', () => { - withTempDir((dir) => { - writeCdkJson(dir, '{not valid json'); - expect(readCdkConfig(dir)).toEqual({ app: undefined }); + test('reads output independently of a missing app command', async () => { + await withTempDir(async (dir) => { + writeCdkJson(dir, JSON.stringify({ output: 'dist/assembly' })); + expect(await readCdkConfig(dir)).toEqual({ app: undefined, output: 'dist/assembly' }); }); }); - test('returns undefined when the app key is missing', () => { - withTempDir((dir) => { - writeCdkJson(dir, JSON.stringify({ context: {} })); - expect(readCdkConfig(dir)).toEqual({ app: undefined }); + test('returns output verbatim, leaving a relative path for the caller to resolve', async () => { + await withTempDir(async (dir) => { + writeCdkJson(dir, JSON.stringify({ output: '../shared/cdk.out' })); + expect((await readCdkConfig(dir))?.output).toBe('../shared/cdk.out'); }); }); - 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('keeps an absolute configured output as-is', async () => { + await withTempDir(async (dir) => { + const absolute = path.join(path.sep, 'tmp', 'explorer-assembly'); + writeCdkJson(dir, JSON.stringify({ output: absolute })); + expect((await readCdkConfig(dir))?.output).toBe(absolute); }); }); - test('returns undefined when cdk.json contains a JSON null', () => { - withTempDir((dir) => { - writeCdkJson(dir, 'null'); - expect(readCdkConfig(dir)).toEqual({ app: undefined }); + test.each([ + ['cdk.json is absent', undefined], + ['cdk.json is malformed', '{not valid json'], + ['cdk.json holds a JSON null', 'null'], + ['cdk.json holds a non-object', '42'], + ])('returns undefined when %s', async (_what, contents) => { + await withTempDir(async (dir) => { + if (contents !== undefined) { + writeCdkJson(dir, contents); + } + expect(await readCdkConfig(dir)).toBeUndefined(); + }); + }); + + test.each([ + ['app', { app: 42, output: 'out' }, 'app'], + ['output', { app: 'node bin/app.js', output: 42 }, 'output'], + ])('ignores a non-string %s value rather than passing it on', async (_what, config, key) => { + await withTempDir(async (dir) => { + writeCdkJson(dir, JSON.stringify(config)); + expect((await readCdkConfig(dir)) as Record).toMatchObject({ [key]: undefined }); + }); + }); + + test('keeps the other key when one is absent', async () => { + await withTempDir(async (dir) => { + writeCdkJson(dir, JSON.stringify({ context: { foo: 'bar' } })); + expect(await readCdkConfig(dir)).toEqual({ app: undefined, output: undefined }); }); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/path-translator.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/path-translator.test.ts new file mode 100644 index 000000000..f931cc1ea --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/path-translator.test.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PathTranslator } from '../../lib/core/assembly-index-with-source-locations'; + +/** + * `PathTranslator` converts between the app/assembly-relative paths the wire + * formats use and the absolute paths the filesystem needs, and bounds both to + * its root directory. + * + * `contains()` is a security boundary: it gates `onDefinition`'s read of a + * client-supplied template path, and the source-map loader's read of an + * assembly-supplied `sourceMappingURL`. Both inputs are attacker-influenceable + * if `cdk.out` is tampered with. + */ +let root: string; +let outside: string; + +beforeEach(() => { + // realpath: on macOS os.tmpdir() is a symlink, and contains() compares + // against the realpath of its input, so the root must already be resolved. + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-root-'))); + outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-outside-'))); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +const translator = () => new PathTranslator(root); + +describe('relative', () => { + test('makes an absolute path inside the root relative to it', () => { + expect(translator().relative(path.join(root, 'lib', 'stack.ts'))).toBe('lib/stack.ts'); + }); + + test('leaves an already-relative path alone', () => { + expect(translator().relative('lib/stack.ts')).toBe('lib/stack.ts'); + }); + + test('normalizes to POSIX separators so the wire format is platform-stable', () => { + expect(translator().relative(path.join(root, 'lib', 'nested', 'deep.ts'))).toBe('lib/nested/deep.ts'); + }); + + test('maps the root itself to the empty path', () => { + expect(translator().relative(root)).toBe(''); + }); + + test('replaces a path escaping the root with a contained sentinel', () => { + // Deliberate: rather than emit a '../' path that a consumer would resolve + // outside the project, the escape is made visible and harmless. + expect(translator().relative(path.join(outside, 'other.ts'))).toBe('ESCAPING'); + }); +}); + +describe('absolute', () => { + test('round-trips a path inside the root', () => { + const original = path.join(root, 'lib', 'stack.ts'); + const translated = translator(); + + expect(translated.absolute(translated.relative(original))).toBe(original); + }); +}); + +describe('contains', () => { + test('accepts an absolute path inside the root', async () => { + expect(await translator().contains(path.join(root, 'sub', 'file.map'))).toBe(true); + }); + + test('accepts the root itself', async () => { + expect(await translator().contains(root)).toBe(true); + }); + + test('rejects an absolute path outside the root', async () => { + expect(await translator().contains(path.join(outside, 'secret.map'))).toBe(false); + }); + + test('rejects a sibling directory that merely shares the root prefix', async () => { + // `/root-evil` must not pass as `/root`; the check is path-segment aware, + // not a bare string prefix. + expect(await translator().contains(`${root}-evil/file.map`)).toBe(false); + }); + + test('rejects a symlink inside the root that points outside it', async () => { + fs.writeFileSync(path.join(outside, 'secret.map'), '{}'); + const link = path.join(root, 'link.map'); + try { + fs.symlinkSync(path.join(outside, 'secret.map'), link); + } catch { + return; // symlinks not permitted in this environment + } + + // Resolved before comparing, so a planted link cannot smuggle a read out. + expect(await translator().contains(link)).toBe(false); + }); + + test('rejects a relative path that traverses out of the root', async () => { + // A relative path is interpreted against the root, so whether it stays + // inside depends on its content -- '../' walks out and must be refused just + // as the equivalent absolute path is. + expect(await translator().contains(path.join('..', path.basename(outside), 'secret.map'))).toBe(false); + }); + + test('accepts a relative path that stays within the root', async () => { + expect(await translator().contains(path.join('lib', 'stack.ts'))).toBe(true); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/source-resolver.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/source-resolver.test.ts index 3c60cf049..79201f343 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/source-resolver.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/source-resolver.test.ts @@ -1,257 +1,303 @@ -import fs from 'fs'; +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { SourceMapResolver, isWithinRoot } from '../../lib/core/source-resolver'; - -const SOURCE_MAPS_DIR = path.join(__dirname, '..', '_fixtures', 'source-maps'); -const SAMPLE_JS = path.join(SOURCE_MAPS_DIR, 'sample.js'); -const SAMPLE_MAP = path.join(SOURCE_MAPS_DIR, 'sample.js.map'); +import { AssemblyIndex } from '@aws-cdk/cloud-assembly-api'; +import { + AssemblyIndexWithSourceLocations, + type RelativePath, + type SourceLocation, +} from '../../lib/core/assembly-index-with-source-locations'; +import { SourceMapResolver } from '../../lib/core/source-resolver'; + +/** + * `reverseMapJs` maps a `.js` location back to the `.ts` it was compiled from, + * which is what lets the explorer point at authored source rather than at + * compiler output. + * + * Reading is split from mapping: `loadSourceMapFor` does all the I/O up front + * (and collects any warnings), after which `reverseMapJs` is a synchronous + * lookup that returns the location unchanged for anything it has no map for. + * + * Everything crossing this boundary is app-relative (`RelativePath`), both in + * and out, and every file read is bounded to the app dir — the paths driving + * those reads come from the cloud assembly, so a tampered `cdk.out` must not be + * able to make the resolver read outside the project. + * + * `_fixtures/source-maps/sample.{ts,js,js.map}` is real `tsc` output: `greet()` + * sits on line 5 of the `.js` and line 2 of the `.ts`, so a correct mapping is + * recognisable rather than merely self-consistent. + */ +const FIXTURES = path.join(__dirname, '..', '_fixtures', 'source-maps'); +const SAMPLE_MAP = fs.readFileSync(path.join(FIXTURES, 'sample.js.map'), 'utf-8'); + +/** The compiled body with its `sourceMappingURL` stripped, so each test attaches its own. */ +const SAMPLE_JS_BODY = fs + .readFileSync(path.join(FIXTURES, 'sample.js'), 'utf-8') + .replace(/\/\/# sourceMappingURL=.*$/m, '') + .trimEnd(); + +/** `greet()` in the compiled output. */ +const GREET_IN_JS = 5; +/** The same statement in the original TypeScript. */ +const GREET_IN_TS = 2; + +let appDir: string; + +beforeEach(() => { + // realpath: on macOS os.tmpdir() is a symlink, and the resolver relativizes + // against appDir, so the two have to agree. + appDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-srcmap-'))); +}); -const tmpDirs: string[] = []; afterEach(() => { - tmpDirs.forEach((d) => fs.rmSync(d, { recursive: true, force: true })); - tmpDirs.length = 0; + fs.rmSync(appDir, { recursive: true, force: true }); jest.restoreAllMocks(); }); -const tmpDir = (): string => { - // realpath so the macOS /var -> /private/var symlink doesn't make an existing - // root and a not-yet-created child disagree under isWithinRoot. - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'srcmap-'))); - tmpDirs.push(dir); - return dir; -}; - -// sample.js body without its trailing sourceMappingURL comment, so each test -// can attach its own (inline or external) form. greet() stays on line 5. -const sampleBody = (): string => - fs.readFileSync(SAMPLE_JS, 'utf-8').replace(/\/\/# sourceMappingURL=.*$/m, '').trimEnd(); - -// NOTE: choosing WHICH metadata entry's frames to use (LOGICAL_ID.trace vs -// aws:cdk:creationStack) is toolkit-lib's findCreationStackTrace; this resolver -// only turns the chosen frames into a user source location. -describe('SourceMapResolver.resolveFrames', () => { - // All frames in this block live under /project; the resolver is rooted there. - let resolver: SourceMapResolver; - beforeEach(() => { - resolver = new SourceMapResolver('/project'); +/** + * A resolver bound to the fixture app dir, with the maps for `jsFiles` loaded. + * + * Production loads the maps of every `.js` an assembly's stack traces mention + * before mapping anything, so a test that omits a file here is testing the + * "no map loaded" path. + */ +async function resolver(...jsFiles: string[]): Promise { + const index = await AssemblyIndexWithSourceLocations.createAndIndex({ + appDir, + assemblyDir: path.join(appDir, 'cdk.out'), + assemblyIndex: new AssemblyIndex(), }); - - test('returns undefined for no frames', async () => { - expect(await resolver.resolveFrames(undefined)).toBeUndefined(); + const subject = new SourceMapResolver(index); + await subject.loadSourceMapFor(...(jsFiles as RelativePath[])); + return subject; +} + +function write(relPath: string, contents: string): void { + const full = path.join(appDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); +} + +/** Write a compiled `.js` carrying the given `sourceMappingURL`. */ +function writeJs(relPath: string, sourceMappingUrl?: string): void { + write(relPath, sourceMappingUrl === undefined + ? SAMPLE_JS_BODY + : `${SAMPLE_JS_BODY}\n//# sourceMappingURL=${sourceMappingUrl}\n`); +} + +/** Copy the original TypeScript to where a map's `sources` will point. */ +function writeOriginalTs(relPath: string): void { + write(relPath, fs.readFileSync(path.join(FIXTURES, 'sample.ts'), 'utf-8')); +} + +const at = (file: string, line: number): SourceLocation => ({ file: file as RelativePath, line }); + +describe('reverseMapJs', () => { + test('maps a .js location back to the .ts it was compiled from', async () => { + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + + const resolved = (await resolver('sample.js')).reverseMapJs(at('sample.js', GREET_IN_JS)); + + // Both the file and the line move, so this proves a real mapping rather + // than a filename swap. Lines are 1-based on the way in and out. + expect(resolved).toEqual(at('sample.ts', GREET_IN_TS)); }); - test('returns undefined when all frames are skip-placeholders', async () => { - // aws-cdk-lib's renderCallStackJustMyCode emits these for filtered frames - // (e.g. node_modules and node: internals). They have no parens and no - // :line:col, so they don't match FRAME_RE. - const frames = [ - ' ...node_modules-aws-cdk-lib...', - ' ...node internals...', - ' (no user code in 10 frames, use --stack-trace-limit to capture more)', - ]; - expect(await resolver.resolveFrames(frames)).toBeUndefined(); - }); + test('maps a line whose first mapping starts past column 0', async () => { + // The resolver only has a line to go on, and the first mapping on an + // indented line sits at its indent, not at column 0 — searching strictly + // leftwards from column 0 would find nothing and silently pass the .js + // location through. + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); - test('skips skip-placeholder frames and picks the first parseable user frame', async () => { - const frames = [ - ' ...node_modules-aws-cdk-lib...', - ' at new MyStack (/project/lib/my-stack.ts:42:7)', - ' at Object. (/project/bin/app.ts:8:1)', - ]; - expect(await resolver.resolveFrames(frames)).toEqual({ - file: '/project/lib/my-stack.ts', - line: 42, - column: 7, - }); - }); + // Line 6 of the .js is the indented `return` inside greet(). + const resolved = (await resolver('sample.js')).reverseMapJs(at('sample.js', 6)); - test('resolves a Python host frame in the real released format (placeholders + no-column .py)', async () => { - // Verbatim shape from aws-cdk-lib 2.260.0 / jsii 1.137.0: kernel frames are - // collapsed to non-parsing placeholders, and host frames carry no column - // (jsii's Python runtime sends column 0, which formatExternalFrame omits). - const frames = [ - '...jsii runtime, node internals...', - '(no user code in 10 frames, use --stack-trace-limit to capture more)', - '__init__ (/project/stacks/network_stack.py:42)', - ' (/project/app.py:10)', - ]; - // No column -> 1-based start of line. - expect(await resolver.resolveFrames(frames)).toEqual({ - file: '/project/stacks/network_stack.py', - line: 42, - column: 1, - }); + expect(resolved).toEqual(at('sample.ts', 3)); }); - test('skips out-of-root .js frames to reach the first in-root .py frame', async () => { - // Robustness: if raw jsii/aws-cdk-lib .js frames appear (outside the root) - // ahead of the user frame, skip them rather than give up. - const frames = [ - 'Kernel._Kernel_create (/private/var/folders/qw/T/tmpiqvf63c1/lib/program.js:1:61273)', - '__init__ (/project/stacks/network_stack.py:42)', - ]; - expect(await resolver.resolveFrames(frames)).toEqual({ - file: '/project/stacks/network_stack.py', line: 42, column: 1, - }); - }); + test('resolves a map inlined as a data: URI', async () => { + const inline = Buffer.from(SAMPLE_MAP).toString('base64'); + writeJs('bundled.js', `data:application/json;base64,${inline}`); + writeOriginalTs('sample.ts'); - test('preserves an explicit (non-zero) host column', async () => { - // A real host column is already 1-based, so it is kept as-is. Only the 0 - // "unavailable" sentinel is clamped to the start of the line. - expect(await resolver.resolveFrames(['f (/project/stacks/data_stack.py:31:8)'])).toEqual({ - file: '/project/stacks/data_stack.py', line: 31, column: 8, - }); - }); + const resolved = (await resolver('bundled.js')).reverseMapJs(at('bundled.js', GREET_IN_JS)); - test('drops a host frame whose file escapes the project root', async () => { - expect(await resolver.resolveFrames([' (/etc/evil.py:1)'])).toBeUndefined(); + expect(resolved).toEqual(at('sample.ts', GREET_IN_TS)); }); -}); -describe('source-map resolution', () => { - test('resolves .js to .ts using sibling .js.map', async () => { - // sample.js line 5: `function greet(name) {` - const resolver = new SourceMapResolver(SOURCE_MAPS_DIR); - const result = await resolver.resolveFrames([` at greet (${SAMPLE_JS}:5:10)`]); - expect(result).toBeDefined(); - expect(result!.file).toContain('sample.ts'); - expect(result!.line).toBe(2); // ts line 2 = `export function greet` - }); + test('resolves an external map under a non-default filename', async () => { + // The directive is authoritative; the `.map` convention is not assumed. + writeJs('sample.js', 'weird.name.map'); + write('weird.name.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + + const resolved = (await resolver('sample.js')).reverseMapJs(at('sample.js', GREET_IN_JS)); - test('falls back to .js location when no .js.map exists', async () => { - const dir = tmpDir(); - const jsPath = path.join(dir, 'no-map-here.js'); - const resolver = new SourceMapResolver(dir); - expect(await resolver.resolveFrames([` at someFn (${jsPath}:1:1)`])).toEqual({ - file: jsPath, line: 1, column: 1, - }); + expect(resolved).toEqual(at('sample.ts', GREET_IN_TS)); }); - test('returns .ts location unchanged (no source-map needed)', async () => { - const resolver = new SourceMapResolver('/project'); - expect(await resolver.resolveFrames([' at someFn (/project/lib/foo.ts:3:2)'])).toEqual({ - file: '/project/lib/foo.ts', line: 3, column: 2, - }); + test('resolves the map relative to the .js, not the process working directory', async () => { + // The language server's cwd is wherever the editor spawned it, which is + // essentially never the project dir, so a cwd-relative resolution would + // find nothing here. + writeJs('dist/sample.js', 'sample.js.map'); + write('dist/sample.js.map', SAMPLE_MAP); + writeOriginalTs('dist/sample.ts'); + + const resolved = (await resolver('dist/sample.js')).reverseMapJs(at('dist/sample.js', GREET_IN_JS)); + + expect(resolved).toEqual(at('dist/sample.ts', GREET_IN_TS)); }); - test('resolves an inline (data: URI) source map', async () => { - const b64 = Buffer.from(fs.readFileSync(SAMPLE_MAP, 'utf-8'), 'utf-8').toString('base64'); - const dir = tmpDir(); - const jsPath = path.join(dir, 'sample.js'); - fs.writeFileSync(jsPath, `${sampleBody()}\n//# sourceMappingURL=data:application/json;base64,${b64}\n`); + test('applies sourceRoot when resolving the original', async () => { + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', JSON.stringify({ ...JSON.parse(SAMPLE_MAP), sourceRoot: 'src/' })); + writeOriginalTs('src/sample.ts'); - const resolver = new SourceMapResolver(dir); - const result = await resolver.resolveFrames([` at greet (${jsPath}:5:10)`]); - expect(result!.file).toContain('sample.ts'); - expect(result!.line).toBe(2); + const resolved = (await resolver('sample.js')).reverseMapJs(at('sample.js', GREET_IN_JS)); + + expect(resolved).toEqual(at('src/sample.ts', GREET_IN_TS)); }); - test('resolves an external map referenced under a non-default filename', async () => { - const dir = tmpDir(); - fs.copyFileSync(SAMPLE_MAP, path.join(dir, 'renamed.map')); - const jsPath = path.join(dir, 'sample.js'); - fs.writeFileSync(jsPath, `${sampleBody()}\n//# sourceMappingURL=renamed.map\n`); + test('parses each map once across repeated loads', async () => { + // One assembly asks for the same map many times, which is the whole reason + // the resolver is long-lived. + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + const subject = await resolver('sample.js'); + + const readFile = jest.spyOn(fs.promises, 'readFile'); + await subject.loadSourceMapFor('sample.js' as RelativePath); - const resolver = new SourceMapResolver(dir); - const result = await resolver.resolveFrames([` at greet (${jsPath}:5:10)`]); - expect(result!.file).toContain('sample.ts'); - expect(result!.line).toBe(2); + expect(readFile).not.toHaveBeenCalled(); + expect(subject.reverseMapJs(at('sample.js', GREET_IN_JS))).toEqual(at('sample.ts', GREET_IN_TS)); }); - test('applies sourceRoot, resolving sources relative to the map', async () => { - const dir = tmpDir(); - const map = JSON.parse(fs.readFileSync(SAMPLE_MAP, 'utf-8')); - map.sourceRoot = 'nested/'; - fs.writeFileSync(path.join(dir, 'renamed.map'), JSON.stringify(map)); - const jsPath = path.join(dir, 'sample.js'); - fs.writeFileSync(jsPath, `${sampleBody()}\n//# sourceMappingURL=renamed.map\n`); - - const resolver = new SourceMapResolver(dir); - const result = await resolver.resolveFrames([` at greet (${jsPath}:5:10)`]); - expect(result!.file).toBe(path.join(dir, 'nested', 'sample.ts')); - expect(result!.line).toBe(2); + test('mapping does no I/O of its own', async () => { + // Every read happens during loading, so mapping stays synchronous and + // cheap even though an assembly maps thousands of locations. + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + const subject = await resolver('sample.js'); + + const readFile = jest.spyOn(fs.promises, 'readFile'); + subject.reverseMapJs(at('sample.js', GREET_IN_JS)); + + expect(readFile).not.toHaveBeenCalled(); }); }); -// Paths from the (attacker-influenceable) cloud assembly must never drive a -// read or a navigation target outside the project. -describe('path containment', () => { - test('drops a frame whose file escapes the project root, without reading it', async () => { - const root = tmpDir(); - const readSpy = jest.spyOn(fs, 'readFileSync'); - const resolver = new SourceMapResolver(root); - - expect(await resolver.resolveFrames([' at evil (/etc/passwd.js:1:1)'])).toBeUndefined(); - // The escaping path must be rejected before any read of it. - expect(readSpy.mock.calls.some(([p]) => String(p).includes('/etc/passwd'))).toBe(false); - }); +describe('reverseMapJs passthrough', () => { + test.each(['app.ts', 'component.tsx'])('returns a %s location unchanged', async (file) => { + // Already source-space; there is nothing to map, and production never even + // offers a non-.js file to the loader. + writeOriginalTs(file); + const subject = await resolver(); - test('drops a .ts frame that escapes the project root', async () => { - const root = tmpDir(); - const resolver = new SourceMapResolver(root); - expect(await resolver.resolveFrames([` at f (${path.join(root, '..', 'outside.ts')}:1:1)`])).toBeUndefined(); + const location = at(file, GREET_IN_TS); + expect(subject.reverseMapJs(location)).toEqual(location); + expect(subject.warnings).toEqual([]); }); - test('ignores an external source map that escapes the root, falling back to the .js', async () => { - const dir = tmpDir(); - const jsPath = path.join(dir, 'sample.js'); - // In-root .js, but its sourceMappingURL points outside the project. - fs.writeFileSync(jsPath, `${sampleBody()}\n//# sourceMappingURL=../../../../etc/evil.map\n`); - const readSpy = jest.spyOn(fs, 'readFileSync'); + test('returns the .js location unchanged when it has no map', async () => { + writeJs('plain.js'); - const resolver = new SourceMapResolver(dir); - const result = await resolver.resolveFrames([` at greet (${jsPath}:5:10)`]); + const subject = await resolver('plain.js'); + const location = at('plain.js', GREET_IN_JS); - // Falls back to the (in-root) .js location; the escaping map is never read. - expect(result).toEqual({ file: jsPath, line: 5, column: 10 }); - expect(readSpy.mock.calls.some(([p]) => String(p).includes('etc/evil.map'))).toBe(false); - expect(resolver.warnings.join('\n')).toMatch(/escapes the project root/); + expect(subject.reverseMapJs(location)).toEqual(location); + // No map is the normal case for plain JS, not something to warn about. + expect(subject.warnings).toEqual([]); }); - test('falls back to the .js when the mapped original resolves outside the root', async () => { - const dir = tmpDir(); - const map = JSON.parse(fs.readFileSync(SAMPLE_MAP, 'utf-8')); - map.sourceRoot = '../../../../../../etc/'; // pushes sample.ts outside the root - fs.writeFileSync(path.join(dir, 'renamed.map'), JSON.stringify(map)); - const jsPath = path.join(dir, 'sample.js'); - fs.writeFileSync(jsPath, `${sampleBody()}\n//# sourceMappingURL=renamed.map\n`); - - const resolver = new SourceMapResolver(dir); - const result = await resolver.resolveFrames([` at greet (${jsPath}:5:10)`]); - expect(result).toEqual({ file: jsPath, line: 5, column: 10 }); + test('returns the location unchanged when the .js itself is unreadable', async () => { + const subject = await resolver('absent.js'); + const location = at('absent.js', GREET_IN_JS); + + expect(subject.reverseMapJs(location)).toEqual(location); + expect(subject.warnings).toHaveLength(1); }); -}); -describe('isWithinRoot', () => { - const root = path.resolve('/srv/app'); + test('returns the location unchanged when the line is not in the map', async () => { + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); - test('accepts a path inside the root', async () => { - expect(await isWithinRoot(root, path.join(root, 'lib/stack.ts'))).toBe(true); + const location = at('sample.js', 999); + + expect((await resolver('sample.js')).reverseMapJs(location)).toEqual(location); }); - test('accepts the root itself', async () => { - expect(await isWithinRoot(root, root)).toBe(true); + test('returns the location unchanged when the map was never loaded', async () => { + // Nothing is loaded lazily: a .js the assembly never mentioned stays as-is. + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + + const location = at('sample.js', GREET_IN_JS); + + expect((await resolver()).reverseMapJs(location)).toEqual(location); }); +}); + +describe('source map loading failures', () => { + test('warns once when a referenced map cannot be parsed', async () => { + // A broken map must not masquerade as "no source map": silently falling + // back to .js locations is the confusing outcome. + writeJs('sample.js', 'sample.js.map'); + write('sample.js.map', '{ not json'); - test('rejects traversal above the root', async () => { - expect(await isWithinRoot(root, path.join(root, '../../etc/passwd'))).toBe(false); + const subject = await resolver('sample.js'); + + const location = at('sample.js', GREET_IN_JS); + expect(subject.reverseMapJs(location)).toEqual(location); + expect(subject.warnings).toHaveLength(1); + expect(subject.warnings[0]).toContain('sample.js'); }); - test('accepts traversal that stays within the root', async () => { - expect(await isWithinRoot(root, path.join(root, 'lib/../bin/cdk.ts'))).toBe(true); + test('warns when a referenced map is missing', async () => { + writeJs('sample.js', 'sample.js.map'); + + const subject = await resolver('sample.js'); + + expect(subject.warnings).toHaveLength(1); }); - test('does not treat a sibling dir with a shared prefix as inside', async () => { - expect(await isWithinRoot(root, path.resolve('/srv/app-secrets/file'))).toBe(false); + test('refuses a map outside the app dir rather than reading it', async () => { + // sourceMappingURL comes from the assembly, so it is attacker-influenceable + // if cdk.out is tampered with. + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-outside-'))); + try { + fs.writeFileSync(path.join(outside, 'stolen.js.map'), SAMPLE_MAP); + writeJs('sample.js', path.join('..', path.basename(outside), 'stolen.js.map')); + + const subject = await resolver('sample.js'); + + const location = at('sample.js', GREET_IN_JS); + expect(subject.reverseMapJs(location)).toEqual(location); + expect(subject.warnings).toHaveLength(1); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } }); - test('rejects a target reached through a symlink that points outside the root', async () => { - const root2 = tmpDir(); - const outside = tmpDir(); - fs.writeFileSync(path.join(outside, 'secret.ts'), 'secret'); - fs.symlinkSync(outside, path.join(root2, 'link')); - // Lexically inside root2, but realpath lands in `outside`. - expect(await isWithinRoot(root2, path.join(root2, 'link', 'secret.ts'))).toBe(false); + test('keeps resolving other files after one map fails', async () => { + writeJs('broken.js', 'broken.js.map'); + write('broken.js.map', '{ not json'); + writeJs('good.js', 'good.js.map'); + write('good.js.map', SAMPLE_MAP); + writeOriginalTs('sample.ts'); + + const subject = await resolver('broken.js', 'good.js'); + + expect(subject.reverseMapJs(at('broken.js', GREET_IN_JS))).toEqual(at('broken.js', GREET_IN_JS)); + expect(subject.reverseMapJs(at('good.js', GREET_IN_JS))).toEqual(at('sample.ts', GREET_IN_TS)); + expect(subject.warnings).toHaveLength(1); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/source-watcher.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/source-watcher.test.ts new file mode 100644 index 000000000..d5b9da32d --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/source-watcher.test.ts @@ -0,0 +1,116 @@ +import type { FileWatcher } from '../../lib/core/assembly-watcher'; +import { startSourceWatcher } from '../../lib/core/source-watcher'; + +// `any[]` rather than `unknown[]`: the stored listeners are invoked with +// whatever the emitting helper passes, and `unknown[]` params are not +// contravariantly compatible with FileWatcher's typed overloads. +type AnyListener = (...args: any[]) => void; + +/** A controllable in-memory stand-in for chokidar's FSWatcher. */ +class FakeWatcher implements FileWatcher { + public closed = false; + private readonly listeners: Record = {}; + + // Mirror FileWatcher's overloads so the class satisfies the interface; the + // implementation signature stays permissive because the registry is untyped. + public on(event: 'all', listener: (eventName: string, filePath: string) => void): FileWatcher; + public on(event: 'error', listener: (error: unknown) => void): FileWatcher; + public on(event: string, listener: AnyListener): FileWatcher { + (this.listeners[event] ??= []).push(listener); + return this; + } + + public emitFile(eventName: string, filePath: string): void { + for (const listener of this.listeners.all ?? []) { + listener(eventName, filePath); + } + } + + public emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners[event] ?? []) { + listener(...args); + } + } + + public async close(): Promise { + this.closed = true; + } +} + +function setup() { + const fake = new FakeWatcher(); + const onChange = jest.fn(); + const onError = jest.fn(); + const watcher = startSourceWatcher({ + appDir: '/proj', + onChange, + onError, + createWatcher: () => fake, + }); + return { fake, onChange, onError, watcher }; +} + +describe('Source Watcher', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('fires a single debounced onChange for a burst of source edits', () => { + const { fake, onChange } = setup(); + + fake.emitFile('change', '/proj/lib/stack.ts'); + fake.emitFile('change', '/proj/app.ts'); + expect(onChange).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + test('ignores dependencies, synth output, and dotfiles', () => { + const { fake, onChange } = setup(); + + fake.emitFile('change', '/proj/node_modules/pkg/index.js'); + fake.emitFile('change', '/proj/cdk.out/tree.json'); + fake.emitFile('change', '/proj/.git/HEAD'); + + jest.advanceTimersByTime(500); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('close stops a pending onChange and closes the underlying watcher', async () => { + const { fake, onChange, watcher } = setup(); + + fake.emitFile('change', '/proj/lib/stack.ts'); + await watcher.close(); + + jest.advanceTimersByTime(500); + expect(onChange).not.toHaveBeenCalled(); + expect(fake.closed).toBe(true); + }); + + test('forwards watcher errors to onError', () => { + const { fake, onError } = setup(); + + fake.emit('error', new Error('boom')); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + test('forwards an onChange throw to onError instead of leaking from the timer', () => { + const fake = new FakeWatcher(); + const failure = new Error('handler blew up'); + const onError = jest.fn(); + startSourceWatcher({ + appDir: '/proj', + onChange: () => { + throw failure; + }, + onError, + createWatcher: () => fake, + }); + + fake.emitFile('change', '/proj/lib/stack.ts'); + expect(() => jest.advanceTimersByTime(200)).not.toThrow(); + + expect(onError).toHaveBeenCalledWith(failure); + }); +}); 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 b85cacecb..f3e38dc34 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 @@ -53,8 +53,12 @@ afterAll(() => { } }); -function run(toolkit: FakeToolkit, projectDir: string) { - return runSynth({ toolkit: toolkit as unknown as Toolkit, projectDir }); +/** + * `abort` cancels the lock-retry wait on shutdown. These tests never abort, but + * a signal is required, and `retryOnLock` reads it after a failed attempt. + */ +function run(toolkit: FakeToolkit, projectDir: string, abort = new AbortController().signal) { + return runSynth({ toolkit: toolkit as unknown as Toolkit, projectDir, abort }); } describe('runSynth', () => { @@ -65,7 +69,11 @@ describe('runSynth', () => { const result = await run(toolkit, projectDir); expect(result).toEqual({ status: 'success' }); - expect(toolkit.fromCdkApp).toHaveBeenCalledWith(APP, { workingDirectory: projectDir, lookups: false, env: { JSII_HOST_STACK_TRACES: '1' } }); + expect(toolkit.fromCdkApp).toHaveBeenCalledWith(APP, expect.objectContaining({ + workingDirectory: projectDir, + lookups: false, + env: expect.objectContaining({ JSII_HOST_STACK_TRACES: '1' }), + })); expect(cached.dispose).toHaveBeenCalledTimes(1); }); @@ -99,24 +107,29 @@ describe('runSynth', () => { expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed'), details: 'TypeError: foo' }); }); - test('classifies ConcurrentWriteLock as lock-conflict', async () => { - const { toolkit } = makeToolkit({ - synthThrow: new LockError('ConcurrentWriteLock', 'another CLI synthing'), - }); + test.each(['ConcurrentWriteLock', 'ConcurrentReadLock'] as const)('gives up on %s once the caller aborts', async (lockType) => { + // A lock is transient, so runSynth keeps retrying rather than giving up. + // An already-aborted signal makes it stop after the first attempt. + const { toolkit } = makeToolkit({ synthThrow: new LockError(lockType, 'another CLI holding the lock') }); + const aborted = AbortSignal.abort(); - const result = await run(toolkit, makeProjectDir({ app: APP })); + const result = await run(toolkit, makeProjectDir({ app: APP }), aborted); - expect(result).toEqual({ status: 'lock-conflict' }); + expect(result).toEqual({ status: 'error', message: 'Gave up waiting for lock' }); + expect(toolkit.synth).toHaveBeenCalledTimes(1); }); - test('classifies ConcurrentReadLock as lock-conflict', async () => { - const { toolkit } = makeToolkit({ - synthThrow: new LockError('ConcurrentReadLock', 'another CLI reading'), - }); + test('retries a lock until it clears, then succeeds', async () => { + const { toolkit, cached } = makeToolkit({}); + toolkit.synth + .mockRejectedValueOnce(new LockError('ConcurrentWriteLock', 'another CLI synthing')) + .mockRejectedValueOnce(new LockError('ConcurrentWriteLock', 'still synthing')) + .mockResolvedValue(cached); const result = await run(toolkit, makeProjectDir({ app: APP })); - expect(result).toEqual({ status: 'lock-conflict' }); + expect(result).toEqual({ status: 'success' }); + expect(toolkit.synth).toHaveBeenCalledTimes(3); }); test('classifies ContextLookupsDisabledError as app-failure with the error message', async () => { diff --git a/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts new file mode 100644 index 000000000..01f3f4c58 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts @@ -0,0 +1,207 @@ +import { resolveAllResourceRanges, resolveLogicalIdAtOffset, resolveResourceRange, resolveResourceRanges } from '../../lib/core/template-ranges'; + +// A template object is the single source of truth: the text under test is its +// 1-space serialization (exactly how synth writes `*.template.json`), and the +// expected values are read straight off the object, so input and expectations +// can never drift. The shapes pack the cases that defeat naive approaches: +// +// - Policy carries an `Fn::Sub` (`${...}` braces), an `InlineJson` value with +// escaped quotes and a `}`, and a `Description` with a lone unmatched `{` — +// all of which break brace-counting but not a real parse. +// - MyBucket / MyBucketF68F3FF0 are a prefix-collision pair. +// - MyBucket also appears as a value (Fn::Sub, DependsOn), never as another key. +const TEMPLATE = { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'plain-bucket' }, + }, + MyBucketF68F3FF0: { + Type: 'AWS::S3::Bucket', + Properties: { VersioningConfiguration: { Status: 'Enabled' } }, + }, + Policy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Action: 's3:*', Resource: { 'Fn::Sub': 'arn:${AWS::Partition}:s3:::${MyBucket}/*' } }], + }, + InlineJson: '{"key":"va}lue"}', + Description: 'trailing brace } then a lone open { inside a string', + }, + DependsOn: ['MyBucket'], + }, + Topic: { Type: 'AWS::SNS::Topic' }, + }, +} as const; + +// 1-space indent mirrors `JSON.stringify(template, undefined, 1)` in synth. +const TEMPLATE_TEXT = JSON.stringify(TEMPLATE, undefined, 1); +const RESOURCES: Record = TEMPLATE.Resources; +const LOGICAL_IDS = Object.keys(RESOURCES); + +/** Slice the serialized text by a computed range and re-parse it. */ +const sliceParse = (range: { start: number; end: number }) => + JSON.parse(TEMPLATE_TEXT.slice(range.start, range.end)); + +describe('resolveResourceRange', () => { + // The authoritative check: a range is correct iff the substring it selects + // re-parses to the same value the template's own JSON.parse produced. No + // hand-computed offsets, and it runs over every resource. + test.each(LOGICAL_IDS)('the resolved range for %s re-parses to that exact resource', (logicalId) => { + const block = resolveResourceRange(TEMPLATE_TEXT, logicalId)!; + expect(sliceParse(block)).toEqual(RESOURCES[logicalId]); + }); + + test('resolves a block whose property values contain brace/quote hazards', () => { + // Policy holds an Fn::Sub (${...}), an InlineJson value with escaped quotes + // and a }, and a Description with a lone {, all of which defeat brace counting. + const block = resolveResourceRange(TEMPLATE_TEXT, 'Policy')!; + expect(sliceParse(block)).toEqual(RESOURCES.Policy); + }); + + test('a logical id resolves to its own block despite a prefix-named sibling', () => { + const short = resolveResourceRange(TEMPLATE_TEXT, 'MyBucket')!; + const long = resolveResourceRange(TEMPLATE_TEXT, 'MyBucketF68F3FF0')!; + expect(sliceParse(short)).toEqual(RESOURCES.MyBucket); + expect(sliceParse(long)).toEqual(RESOURCES.MyBucketF68F3FF0); + expect(short).not.toEqual(long); + }); + + test('resolves the definition block even when the id also appears as a value', () => { + // MyBucket appears under Policy's Fn::Sub and DependsOn, never as another key. + const block = resolveResourceRange(TEMPLATE_TEXT, 'MyBucket')!; + expect((sliceParse(block) as { Type: string }).Type).toBe('AWS::S3::Bucket'); + }); + + test('returns undefined for invalid JSON (for example a trailing comma)', () => { + // json-source-map is a strict parser, so a malformed template yields no + // range rather than a wrong one. Synthesized templates are always strict JSON. + const invalid = '{\n "Resources": {\n "B": {\n "Type": "AWS::S3::Bucket",\n }\n }\n}'; + expect(resolveResourceRange(invalid, 'B')).toBeUndefined(); + }); + + test('returns undefined for an unknown logical id', () => { + expect(resolveResourceRange(TEMPLATE_TEXT, 'DoesNotExist')).toBeUndefined(); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveResourceRange('not json at all', 'MyBucket')).toBeUndefined(); + }); +}); + +describe('resolveLogicalIdAtOffset', () => { + // Inverse round-trip: an offset inside each resource's block resolves back to it. + test.each(LOGICAL_IDS)('an offset inside %s resolves to that logical id', (logicalId) => { + const block = resolveResourceRange(TEMPLATE_TEXT, logicalId)!; + const mid = Math.floor((block.start + block.end) / 2); + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, mid)).toBe(logicalId); + }); + + test('an offset in a nested property value resolves to its owning resource', () => { + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, TEMPLATE_TEXT.indexOf('lone open'))).toBe('Policy'); + }); + + test('returns undefined for an offset outside any resource block', () => { + // Offset 0 is the opening brace of the whole document, before Resources. + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, 0)).toBeUndefined(); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveLogicalIdAtOffset('not json at all', 3)).toBeUndefined(); + }); +}); + +describe('resolveResourceRanges', () => { + // Wrapping a key+value slice in braces makes it a valid object, so the same + // re-parse oracle proves each property range covers exactly `"Key": value`. + const sliceParseEntry = (range: { start: number; end: number }) => + JSON.parse('{' + TEMPLATE_TEXT.slice(range.start, range.end) + '}'); + + test.each(LOGICAL_IDS)('the block range for %s re-parses to that exact resource', (logicalId) => { + const { block } = resolveResourceRanges(TEMPLATE_TEXT, logicalId)!; + expect(sliceParse(block)).toEqual(RESOURCES[logicalId]); + }); + + test('each Policy property range re-parses to its exact `"Key": value` entry', () => { + // Policy's values include an Fn::Sub, an InlineJson string with braces and + // escaped quotes, and a Description with a lone brace -- hazards for anything + // but a real parse. + const { properties } = resolveResourceRanges(TEMPLATE_TEXT, 'Policy')!; + const expected = (RESOURCES.Policy as { Properties: Record }).Properties; + expect(Object.keys(properties).sort()).toEqual(Object.keys(expected).sort()); + for (const [name, range] of Object.entries(properties)) { + expect(sliceParseEntry(range)).toEqual({ [name]: expected[name] }); + } + }); + + test('property ranges include the key, not just the value', () => { + const { properties } = resolveResourceRanges(TEMPLATE_TEXT, 'MyBucket')!; + const slice = TEMPLATE_TEXT.slice(properties.BucketName.start, properties.BucketName.end); + expect(slice.startsWith('"BucketName"')).toBe(true); + expect(sliceParseEntry(properties.BucketName)).toEqual({ BucketName: 'plain-bucket' }); + }); + + test('enumerates only top-level properties, not nested keys', () => { + // PolicyDocument is one entry; its nested Statement/Action are not separate. + const { properties } = resolveResourceRanges(TEMPLATE_TEXT, 'Policy')!; + expect(Object.keys(properties).sort()).toEqual(['Description', 'InlineJson', 'PolicyDocument']); + }); + + test('a resource with no Properties yields an empty property map', () => { + const ranges = resolveResourceRanges(TEMPLATE_TEXT, 'Topic')!; + expect(ranges.properties).toEqual({}); + expect(sliceParse(ranges.block)).toEqual(RESOURCES.Topic); + }); + + test('returns undefined for an unknown logical id', () => { + expect(resolveResourceRanges(TEMPLATE_TEXT, 'DoesNotExist')).toBeUndefined(); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveResourceRanges('not json at all', 'MyBucket')).toBeUndefined(); + }); +}); + +/** + * `resolveAllResourceRanges` is documented as the bulk equivalent of + * `resolveResourceRanges`: "Equivalent to calling `resolveResourceRanges` per + * logical ID, but O(1) parses instead of O(N)." These tests hold it to exactly + * that, using the single-resource resolver (covered above) as the oracle. + */ +describe('resolveAllResourceRanges', () => { + test('covers every resource in the template', () => { + expect(Object.keys(resolveAllResourceRanges(TEMPLATE_TEXT)!).sort()).toEqual([...LOGICAL_IDS].sort()); + }); + + test.each(LOGICAL_IDS)('the block range for %s re-parses to that exact resource', (logicalId) => { + expect(sliceParse(resolveAllResourceRanges(TEMPLATE_TEXT)![logicalId].block)).toEqual(RESOURCES[logicalId]); + }); + + test.each(LOGICAL_IDS)('the property ranges for %s match the single-resource resolver', (logicalId) => { + const all = resolveAllResourceRanges(TEMPLATE_TEXT)!; + const one = resolveResourceRanges(TEMPLATE_TEXT, logicalId)!; + + expect(all[logicalId].properties).toEqual(one.properties); + }); + + test('keys properties under their owning resource, not under the property name', () => { + // The failure mode this guards: capturing the property name where the logical + // id belongs, which both loses every property range and risks attaching one + // resource's properties to another whose id matches a property name. + const all = resolveAllResourceRanges(TEMPLATE_TEXT)!; + + expect(Object.keys(all.MyBucket.properties)).toEqual(['BucketName']); + expect(Object.keys(all.Policy.properties).sort()).toEqual(['Description', 'InlineJson', 'PolicyDocument']); + // A property name must never become a top-level entry of its own. + expect(all).not.toHaveProperty('BucketName'); + }); + + test('a resource with no Properties yields an empty property map', () => { + expect(resolveAllResourceRanges(TEMPLATE_TEXT)!.Topic.properties).toEqual({}); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveAllResourceRanges('not json at all')).toBeUndefined(); + }); +}); 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 81d46cbb1..7f2c80865 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -1,21 +1,8 @@ import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; 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_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH } from '../../lib/lsp/commands'; - -const FILE = '/p/lib/stack.ts'; -const URI = pathToFileURL(FILE).toString(); -const OTHER_URI = pathToFileURL('/p/lib/other.ts').toString(); - -const node = (overrides: Partial & { path: string }): ConstructNode => ({ - id: overrides.path.split('/').pop() ?? overrides.path, - children: [], - ...overrides, -}); +import { AssemblyFixture, type AssemblySpec } from '../_helpers/assembly'; /** Shape of one resource choice carried in the openResource command arguments. */ interface CommandChoice { @@ -24,266 +11,265 @@ interface CommandChoice { target: { uri: string; range: { start: unknown; end: unknown } }; } +let fixture: AssemblyFixture | undefined; + +afterEach(() => { + fixture?.cleanup(); + fixture = undefined; +}); + +const SOURCE = { 'lib/stack.ts': '\n'.repeat(40), 'lib/other.ts': '\n'.repeat(40) }; + +async function lensesFor(spec: AssemblySpec, relPath = 'lib/stack.ts', autoSynth = false) { + fixture = AssemblyFixture.create({ sourceFiles: SOURCE, ...spec }); + const index = await fixture.index(); + return codeLensesForFile(index, fixture.assemblyDir, fixture.sourcePath(relPath), autoSynth); +} + +/** The two or three commands that only ever appear on the line-0 header lenses. */ +const HEADER_COMMANDS: string[] = [COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH]; + +/** The resource lenses, i.e. everything that is not a header lens. */ +function resourceLenses(lenses: Awaited>) { + return lenses.filter((l) => !HEADER_COMMANDS.includes(l.command?.command ?? '')); +} + +function choicesOf(lens: { command?: { arguments?: unknown[] } }): CommandChoice[] { + return (lens.command!.arguments as CommandChoice[][])[0]; +} + describe('codeLensesForFile', () => { - test('returns no lenses when tree is empty', async () => { - expect(await codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); + test('returns no lenses when the assembly is empty', async () => { + expect(await lensesFor({})).toEqual([]); + }); + + test('returns no lenses for a file with no constructs', async () => { + const lenses = await lensesFor({ + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }, 'lib/other.ts'); + + // No header lenses either: a file with nothing CDK in it stays clean. + expect(lenses).toEqual([]); }); - test('returns no lenses for non-resource wrapper nodes (no logicalId)', async () => { - // L2 wrapper: has sourceLocation but no logicalId/cfnType — they live on - // its `Resource` child. Wrappers shouldn't get their own lens. - const tree = [node({ - path: 'Stack1/MyBucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - // logicalId/type intentionally omitted - })]; - expect(await codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); + test('returns no lenses for a construct that is not a CFN resource', async () => { + // An L2 wrapper carries a source location but no logical id or CFN type; + // those live on its Resource child. Wrappers get no lens of their own. + const lenses = await lensesFor({ + withoutTree: true, + stacks: [{ id: 'S', resources: [{ path: 'MyBucket', logicalId: 'Wrapper', at: 'lib/stack.ts:12:5' }] }], + }); + + expect(lenses).toEqual([]); }); - test('emits one lens per resource on its source line', async () => { - const tree = [node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketF68F3FF0', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - })]; - - const lenses = await 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 }, + test('omits constructs with no creation stack (non-TypeScript app)', async () => { + const lenses = await lensesFor({ + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }], }); - expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); + + expect(lenses).toEqual([]); }); - test('groups multiple resources on the same source line into one lens', async () => { - // L2 like Bucket can produce Bucket + BucketPolicy + Key, all anchored - // to the same `new s3.Bucket(...)` line. One lens, listing all. - const tree = [ - node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'BucketABC', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - }), - node({ - path: 'Stack1/MyBucket/Policy', - logicalId: 'BucketPolicyDEF', - type: 'AWS::S3::BucketPolicy', - sourceLocation: { file: FILE, line: 12, column: 5 }, - }), - node({ - path: 'Stack1/MyBucket/Key', - logicalId: 'KeyGHI', - type: 'AWS::KMS::Key', - sourceLocation: { file: FILE, line: 12, column: 5 }, - }), - ]; - - const lenses = await 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 one lens on the creation line of a resource', async () => { + const lenses = await lensesFor({ + stacks: [{ id: 'S', resources: [{ path: 'MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); + + const [lens] = resourceLenses(lenses); + // 1-based source line to 0-based LSP line; the lens renders above it. + expect(lens.range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 } }); + expect(lens.command?.title).toBe('Creates AWS::S3::Bucket'); }); - test('emits separate lenses for resources on different lines', async () => { - const tree = [ - node({ - path: 'Stack1/A', - logicalId: 'A1', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 10, column: 1 }, - }), - node({ - path: 'Stack1/B', - logicalId: 'B1', - type: 'AWS::SQS::Queue', - sourceLocation: { file: FILE, line: 20, column: 1 }, - }), - ]; - - const lenses = await 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('groups several resources on one line into a single lens', async () => { + // An L2 Bucket fans out into a bucket, its policy and a key, all anchored to + // the same `new s3.Bucket(...)` line. + const lenses = await lensesFor({ + stacks: [{ + id: 'S', + resources: [ + { path: 'MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }, + { path: 'MyBucket/Policy/Resource', logicalId: 'P1', type: 'AWS::S3::BucketPolicy', at: 'lib/stack.ts:12:5' }, + { path: 'MyBucket/Key/Resource', logicalId: 'K1', type: 'AWS::KMS::Key', at: 'lib/stack.ts:12:5' }, + ], + }], + }); + + const lenses0 = resourceLenses(lenses); + expect(lenses0).toHaveLength(1); + // The title histograms the short CFN type names, most common type first. + expect(lenses0[0].command?.title).toBe('Creates 3 resources: 1× Bucket, 1× BucketPolicy, 1× Key'); }); - test('filters out resources from other files', async () => { - const tree = [ - node({ - path: 'Stack1/A', - logicalId: 'A1', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 10, column: 1 }, - }), - node({ - path: 'Stack1/B', - logicalId: 'B1', - type: 'AWS::SQS::Queue', - sourceLocation: { file: '/p/lib/other.ts', line: 5, column: 1 }, - }), - ]; - - const index = ConstructIndex.fromTree(tree); - // 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 = await codeLensesForFile(index, URI, false); - expect(onThisFile).toHaveLength(3); // 2 header + 1 L1 - expect(onThisFile[2].command?.title).toBe('Creates AWS::S3::Bucket'); - const onOtherFile = await codeLensesForFile(index, OTHER_URI, false); - expect(onOtherFile).toHaveLength(3); // 2 header + 1 L1 - expect(onOtherFile[2].command?.title).toBe('Creates AWS::SQS::Queue'); + test('emits separate lenses for resources on different lines', async () => { + const lenses = await lensesFor({ + stacks: [{ + id: 'S', + resources: [ + { path: 'A/Resource', logicalId: 'A1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:10:1' }, + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::SQS::Queue', at: 'lib/stack.ts:20:1' }, + ], + }], + }); + + expect(resourceLenses(lenses).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); }); - test('walks descendants — finds resources nested under wrappers', async () => { - const tree = [ - node({ - path: 'Stack1', - sourceLocation: { file: FILE, line: 1, column: 1 }, - children: [ - node({ - path: 'Stack1/MyBucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - children: [ - node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketABC', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - }), - ], - }), + test('selects by file, so each file shows only its own resources', async () => { + const spec: AssemblySpec = { + stacks: [{ + id: 'S', + resources: [ + { path: 'A/Resource', logicalId: 'A1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:10:1' }, + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::SQS::Queue', at: 'lib/other.ts:5:1' }, ], - }), - ]; + }], + }; - const lenses = await codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); - expect(lenses).toHaveLength(3); // 2 header + 1 - expect(lenses[2].command?.title).toContain('AWS::S3::Bucket'); + const onStack = resourceLenses(await lensesFor(spec, 'lib/stack.ts')); + expect(onStack).toHaveLength(1); + expect(onStack[0].command?.title).toBe('Creates AWS::S3::Bucket'); + + const onOther = resourceLenses(await lensesFor(spec, 'lib/other.ts')); + expect(onOther).toHaveLength(1); + expect(onOther[0].command?.title).toBe('Creates AWS::SQS::Queue'); }); - test('omits resources without sourceLocation (non-TS apps)', async () => { - const tree = [node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketF68F3FF0', - type: 'AWS::S3::Bucket', - // sourceLocation omitted — non-TS app - })]; + test('finds resources nested deep under wrapper constructs', async () => { + const lenses = await lensesFor({ + stacks: [{ id: 'S', resources: [{ path: 'Outer/Middle/MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); - expect(await codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); + expect(resourceLenses(lenses)[0].command?.title).toContain('AWS::S3::Bucket'); }); - test('a single resource with a resolvable template gets a clickable openResource command', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codelens-')); - const templateFile = path.join(dir, 'Stack1.template.json'); - fs.writeFileSync(templateFile, JSON.stringify({ Resources: { MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket' } } }, null, 2)); - try { - const tree = [node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketF68F3FF0', - type: 'AWS::S3::Bucket', - templateFile, - sourceLocation: { file: FILE, line: 12, column: 5 }, - })]; - - const lens = (await 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); - expect(choices[0]).toMatchObject({ - label: 'AWS::S3::Bucket', - description: 'Stack1/MyBucket', - target: { uri: pathToFileURL(templateFile).toString() }, - }); - // The target carries a real (non-zero-width) block range; exact offsets are covered in template-locator.test. - expect(choices[0].target.range.start).not.toEqual(choices[0].target.range.end); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + test('accepts the absolute path the language server hands it', async () => { + // The server only knows the document's absolute path; the lens builder must + // translate it to the app-relative key the index is built on. + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); + const index = await fixture.index(); + + const absolute = await codeLensesForFile(index, fixture.assemblyDir, fixture.sourcePath('lib/stack.ts'), false); + + expect(resourceLenses(absolute)).toHaveLength(1); }); +}); + +describe('codeLensesForFile openResource command', () => { + test('a resolvable resource becomes a clickable single choice', async () => { + const lenses = await lensesFor({ + stacks: [{ id: 'S', resources: [{ path: 'MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); - test('a multi-resource line carries all resolvable resources as choices', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codelens-')); - const templateFile = path.join(dir, 'Stack1.template.json'); - fs.writeFileSync(templateFile, JSON.stringify({ - Resources: { B1: { Type: 'AWS::S3::Bucket' }, B2: { Type: 'AWS::S3::BucketPolicy' } }, - }, null, 2)); - try { - const tree = [ - node({ path: 'Stack1/B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), - node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), - ]; - - const lens = (await 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]; - expect(choices).toMatchObject([ - { label: 'AWS::S3::Bucket', description: 'Stack1/B', target: { uri } }, - { label: 'AWS::S3::BucketPolicy', description: 'Stack1/B/Policy', target: { uri } }, - ]); - // Each carries a real span, and the two resources resolve to distinct blocks. - expect(choices[0].target.range.start).not.toEqual(choices[0].target.range.end); - expect(choices[1].target.range.start).not.toEqual(choices[1].target.range.end); - expect(choices[0].target.range).not.toEqual(choices[1].target.range); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + const [lens] = resourceLenses(lenses); + expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); + const choices = choicesOf(lens); + expect(choices).toHaveLength(1); + expect(choices[0]).toMatchObject({ + label: 'AWS::S3::Bucket', + // The synthetic Resource leaf is stripped for display. + description: 'S/MyBucket', + target: { uri: pathToFileURL(fixture!.templatePath('S')).toString() }, + }); + // A real span, not zero-width; exact offsets are template-locator's business. + expect(choices[0].target.range.start).not.toEqual(choices[0].target.range.end); }); - test('a multi-resource line with no resolvable templates stays title-only', async () => { - const tree = [ - node({ path: 'Stack1/B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), - node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), - ]; + test('a multi-resource line carries every resolvable resource as a choice', async () => { + const lenses = await lensesFor({ + stacks: [{ + id: 'S', + resources: [ + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }, + { path: 'B/Policy/Resource', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', at: 'lib/stack.ts:12:5' }, + ], + }], + }); + + const choices = choicesOf(resourceLenses(lenses)[0]); + const uri = pathToFileURL(fixture!.templatePath('S')).toString(); + expect(choices).toMatchObject([ + { label: 'AWS::S3::Bucket', description: 'S/B', target: { uri } }, + { label: 'AWS::S3::BucketPolicy', description: 'S/B/Policy', target: { uri } }, + ]); + // Each resolves to its own block. + expect(choices[0].target.range).not.toEqual(choices[1].target.range); + }); + + test('stays title-only when the template cannot be read', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); + const index = await fixture.index(); + fs.rmSync(fixture.templatePath('S')); + + const [lens] = resourceLenses(await codeLensesForFile(index, fixture.assemblyDir, fixture.sourcePath('lib/stack.ts'), false)); - const lens = (await codeLensesForFile(ConstructIndex.fromTree(tree), URI, false))[2]; + // The title still tells you what is created, it just is not navigable. + expect(lens.command?.title).toBe('Creates AWS::S3::Bucket'); expect(lens.command?.command).toBe(''); expect(lens.command?.arguments).toBeUndefined(); }); - test('a single resource whose id is missing from the template degrades to title-only', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codelens-')); - const templateFile = path.join(dir, 'Stack1.template.json'); - fs.writeFileSync(templateFile, JSON.stringify({ Resources: { SomethingElse: { Type: 'AWS::S3::Bucket' } } }, null, 2)); - try { - const tree = [node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketF68F3FF0', - type: 'AWS::S3::Bucket', - templateFile, - sourceLocation: { file: FILE, line: 12, column: 5 }, - })]; - - const lens = (await codeLensesForFile(ConstructIndex.fromTree(tree), URI, false))[2]; - expect(lens.command?.command).toBe(''); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + test('stays title-only when the logical id is missing from the template', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }); + const index = await fixture.index(); + fs.writeFileSync(fixture.templatePath('S'), JSON.stringify({ Resources: { SomethingElse: { Type: 'AWS::S3::Bucket' } } }, undefined, 1)); + + const [lens] = resourceLenses(await codeLensesForFile(index, fixture.assemblyDir, fixture.sourcePath('lib/stack.ts'), false)); + + expect(lens.command?.command).toBe(''); }); - test('header lens appears at line 0 with synthNow command when L1 lenses are present', async () => { - const tree = [node({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucketF68F3FF0', - type: 'AWS::S3::Bucket', - sourceLocation: { file: FILE, line: 12, column: 5 }, - })]; - - // auto-synth off: Synth now + Enable auto-synth + L1 - const lensesOff = await 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 = await 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('drops only the unresolvable resources from a mixed line', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ + id: 'S', + resources: [ + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }, + { path: 'B/Policy/Resource', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', at: 'lib/stack.ts:12:5' }, + ], + }], + }); + const index = await fixture.index(); + // Remove just one of the two resources from the template. + fs.writeFileSync(fixture.templatePath('S'), JSON.stringify({ Resources: { B1: { Type: 'AWS::S3::Bucket' } } }, undefined, 1)); + + const [lens] = resourceLenses(await codeLensesForFile(index, fixture.assemblyDir, fixture.sourcePath('lib/stack.ts'), false)); + + // Title still reports both; only the resolvable one is clickable. + expect(lens.command?.title).toBe('Creates 2 resources: 1× Bucket, 1× BucketPolicy'); + expect(choicesOf(lens).map((c) => c.label)).toEqual(['AWS::S3::Bucket']); }); +}); + +describe('codeLensesForFile header lenses', () => { + const spec: AssemblySpec = { + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }] }], + }; + + test('offers synth-now and enable when auto-synth is off', async () => { + const lenses = await lensesFor(spec, 'lib/stack.ts', false); + + expect(lenses[0].range.start.line).toBe(0); + expect(lenses[0].command?.command).toBe(COMMAND_SYNTH_NOW); + expect(lenses[1].command?.command).toBe(COMMAND_ENABLE_AUTO_SYNTH); + }); + + test('offers only disable when auto-synth is on', async () => { + // Saves already trigger a synth, so "Synth now" would be noise. + const lenses = await lensesFor(spec, 'lib/stack.ts', true); - test('no header lenses on files with no L1 lenses', async () => { - // File has no CDK resources → no lenses at all, including no header lenses - expect(await codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); + expect(lenses).toHaveLength(2); // 1 header + 1 resource + expect(lenses[0].command?.command).toBe(COMMAND_DISABLE_AUTO_SYNTH); }); }); 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 787af03f7..dbbea9a55 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -1,8 +1,8 @@ -import type { SynthRunResult } from '../../lib/core/synth-runner'; import { COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH, + SUPPORTED_COMMANDS, executeCommand, type CommandHandlerOptions, type NotifySink, @@ -17,13 +17,15 @@ interface CapturedNotify extends NotifySink { 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 }; + return { + info: jest.fn(), + error: jest.fn(), + progressMessages, + withProgress: jest.fn(async (message: string, fn: () => Promise) => { + progressMessages.push(message); + return fn(); + }), + }; } function makeOptions(overrides: Partial = {}): { @@ -33,7 +35,7 @@ function makeOptions(overrides: Partial = {}): { toggleAutoSynth: jest.Mock; } { const notify = createNotify(); - const synth = jest.fn(async () => ({ status: 'success' } as SynthRunResult)); + const synth = jest.fn(async () => undefined); const toggleAutoSynth = jest.fn(); return { notify, @@ -44,79 +46,91 @@ function makeOptions(overrides: Partial = {}): { } describe('executeCommand', () => { - test('enableAutoSynth calls toggleAutoSynth(true)', async () => { - const { options, toggleAutoSynth } = makeOptions(); - await executeCommand(COMMAND_ENABLE_AUTO_SYNTH, [], options); - expect(toggleAutoSynth).toHaveBeenCalledWith(true); - }); + test.each([ + [COMMAND_ENABLE_AUTO_SYNTH, true], + [COMMAND_DISABLE_AUTO_SYNTH, false], + ])('%s toggles auto-synth to %s', async (command, expected) => { + const { options, toggleAutoSynth, synth } = makeOptions(); - test('disableAutoSynth calls toggleAutoSynth(false)', async () => { - const { options, toggleAutoSynth } = makeOptions(); - await executeCommand(COMMAND_DISABLE_AUTO_SYNTH, [], options); - expect(toggleAutoSynth).toHaveBeenCalledWith(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); + await executeCommand(command, [], options); - expect(synth).toHaveBeenCalledTimes(1); - expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('cdk.json')); - expect(notify.error).not.toHaveBeenCalled(); + expect(toggleAutoSynth).toHaveBeenCalledWith(expected); + // Toggling is a state change only; it must not synth as a side effect. + expect(synth).not.toHaveBeenCalled(); }); - test('synthNow runs synth under withProgress on success and notifies nothing', async () => { - const { options, notify, synth } = makeOptions(); + test('synthNow runs exactly one synth', async () => { + const { options, 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' })), + test('synthNow runs the synth inside the progress indicator', async () => { + // The indicator has to be open while the synth runs, not merely around it, + // or the user gets no feedback for the duration. + const { options, notify, synth } = makeOptions(); + let progressOpenDuringSynth = false; + notify.withProgress.mockImplementation(async (message: string, fn: () => Promise) => { + notify.progressMessages.push(message); + synth.mockImplementation(async () => { + progressOpenDuringSynth = true; + }); + return fn(); }); await executeCommand(COMMAND_SYNTH_NOW, [], options); - expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('TypeError: x')); - expect(notify.info).not.toHaveBeenCalled(); + expect(notify.progressMessages).toEqual([expect.stringContaining('Synthesizing')]); + expect(progressOpenDuringSynth).toBe(true); }); - test('synthNow surfaces lock-conflict as an info notification', async () => { - const { options, notify } = makeOptions({ - synth: jest.fn(async () => ({ status: 'lock-conflict' })), - }); + test('synthNow reports outcomes through diagnostics, not notifications', async () => { + // The dispatcher no longer inspects the synth result: failures surface as + // editor diagnostics (see synth-failure diagnostics in server.test.ts), so + // a successful run is silent here and so is a failing one. + const { options, notify } = makeOptions(); await executeCommand(COMMAND_SYNTH_NOW, [], options); - expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('in progress')); + expect(notify.info).not.toHaveBeenCalled(); 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' })), + test('synthNow lets a rejecting synth propagate to the caller', async () => { + // `synth` is documented as never rejecting; if it ever does, the dispatcher + // must not swallow it into a silent no-op. + const { options } = makeOptions({ + synth: jest.fn(async () => { + throw new Error('boom'); + }), }); - await executeCommand(COMMAND_SYNTH_NOW, [], options); - - expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('disk full')); - expect(notify.info).not.toHaveBeenCalled(); + await expect(executeCommand(COMMAND_SYNTH_NOW, [], options)).rejects.toThrow('boom'); }); - test('unknown commands are silently ignored', async () => { - const { options, notify, synth } = makeOptions(); + test('an unknown command is ignored without touching anything', async () => { + const { options, notify, synth, toggleAutoSynth } = makeOptions(); await executeCommand('cdk.explorer.bogus', [], options); expect(synth).not.toHaveBeenCalled(); + expect(toggleAutoSynth).not.toHaveBeenCalled(); expect(notify.info).not.toHaveBeenCalled(); expect(notify.error).not.toHaveBeenCalled(); + expect(notify.withProgress).not.toHaveBeenCalled(); + }); + + test('every advertised command is handled', async () => { + // Guards against advertising a command via executeCommandProvider that the + // dispatcher then silently drops. + for (const command of SUPPORTED_COMMANDS) { + const { options, synth, toggleAutoSynth } = makeOptions(); + + await executeCommand(command, [], options); + + expect(synth.mock.calls.length + toggleAutoSynth.mock.calls.length).toBeGreaterThan(0); + } }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/diagnostics.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/diagnostics.test.ts index 293fa3eca..13ffc6cb0 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/diagnostics.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/diagnostics.test.ts @@ -1,34 +1,26 @@ import { pathToFileURL } from 'url'; -import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; -import type { PolicyValidationReportJson } from '@aws-cdk/cloud-assembly-schema'; +import type { PluginReportJson, PolicyViolationSeverity } from '@aws-cdk/cloud-assembly-schema'; import { DiagnosticSeverity } from 'vscode-languageserver/node'; -import type { ConstructNode } from '../../lib'; import { mapViolationsToDiagnostics } from '../../lib/lsp/diagnostics'; +import { AssemblyFixture } from '../_helpers/assembly'; -const nodeWithLocation = (path: string, file: string, line: number, column: number): ConstructNode => ({ - path, - id: path.split('/').pop() ?? path, - children: [], - sourceLocation: { file, line, column }, -}); +let fixture: AssemblyFixture | undefined; -const nodeNoLocation = (path: string): ConstructNode => ({ - path, - id: path.split('/').pop() ?? path, - children: [], +afterEach(() => { + fixture?.cleanup(); + fixture = undefined; }); -const indexOf = (...nodes: ConstructNode[]): ConstructIndex => ConstructIndex.fromTree(nodes); - -const reportWith = (...violations: Array<{ - ruleName: string; - description?: string; - severity: 'fatal' | 'error' | 'warning' | 'info' | 'custom'; - suggestedFix?: string; - paths: string[]; -}>): PolicyValidationReportJson => ({ - version: '1.0.0', - pluginReports: [{ +interface ViolationSpec { + readonly ruleName: string; + readonly description?: string; + readonly severity: PolicyViolationSeverity; + readonly suggestedFix?: string; + readonly paths: string[]; +} + +function reportWith(...violations: ViolationSpec[]): PluginReportJson[] { + return [{ pluginName: 'test-plugin', conclusion: 'failure', violations: violations.map((v) => ({ @@ -38,143 +30,217 @@ const reportWith = (...violations: Array<{ suggestedFix: v.suggestedFix, violatingConstructs: v.paths.map((p) => ({ constructPath: p })), })), - }], -}); + }]; +} + +/** URI the diagnostics should be published under for an app-relative source file. */ +function uriFor(relPath: string): string { + return pathToFileURL(fixture!.sourcePath(relPath)).toString(); +} describe('mapViolationsToDiagnostics', () => { - test('returns empty maps when violations is undefined', () => { - const { byUri, dropped } = mapViolationsToDiagnostics(undefined, indexOf()); + test('returns empty maps when there is no report', async () => { + fixture = AssemblyFixture.create(); + + const { byUri, dropped } = mapViolationsToDiagnostics(undefined, await fixture.index()); + expect(byUri.size).toBe(0); expect(dropped).toHaveLength(0); }); - test('emits one diagnostic per violating construct, grouped by URI', () => { - const tree = indexOf(nodeWithLocation('S/MyBucket', '/p/lib/s.ts', 12, 5)); - const report = reportWith({ - ruleName: 'no-public-buckets', - description: 'S3 must not be public', - severity: 'error', - paths: ['S/MyBucket'], + test('anchors a diagnostic at the construct creation site, spanning to end of line', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(20) }, + stacks: [{ id: 'S', resources: [{ path: 'MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/s.ts:12:5' }] }], }); - const { byUri, dropped } = mapViolationsToDiagnostics(report, tree); + const { byUri, dropped } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'no-public-buckets', description: 'S3 must not be public', severity: 'error', paths: ['S/MyBucket/Resource'] }), + await fixture.index(), + ); - const expectedUri = pathToFileURL('/p/lib/s.ts').toString(); - expect(byUri.get(expectedUri)).toHaveLength(1); expect(dropped).toHaveLength(0); - - const diag = byUri.get(expectedUri)![0]; - expect(diag.severity).toBe(DiagnosticSeverity.Error); - expect(diag.code).toBe('no-public-buckets'); - expect(diag.source).toBe('test-plugin'); - // Range starts at construct creation (1-based -> 0-based) and extends - // to LSP's end-of-line sentinel so the squiggle is visible. - expect(diag.range).toEqual({ - start: { line: 11, character: 4 }, + const diagnostics = byUri.get(uriFor('lib/s.ts'))!; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + severity: DiagnosticSeverity.Error, + code: 'no-public-buckets', + source: 'test-plugin', + }); + // 1-based source location to 0-based LSP: the whole line, from its start + // out to the end-of-line sentinel so the squiggle is visible. + expect(diagnostics[0].range).toEqual({ + start: { line: 11, character: 0 }, end: { line: 11, character: 2147483647 }, }); }); - test('groups multiple violations on the same file under one URI', () => { - const tree = indexOf( - nodeWithLocation('S/A', '/p/lib/s.ts', 10, 1), - nodeWithLocation('S/B', '/p/lib/s.ts', 20, 1), - ); - const report = reportWith( - { ruleName: 'r1', severity: 'error', paths: ['S/A'] }, - { ruleName: 'r2', severity: 'warning', paths: ['S/B'] }, + test('publishes under a URI inside the app dir, not relative to the process cwd', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(20) }, + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket', at: 'lib/s.ts:3:1' }] }], + }); + + const { byUri } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity: 'error', paths: ['S/B/Resource'] }), + await fixture.index(), ); - const { byUri } = mapViolationsToDiagnostics(report, tree); - const uri = pathToFileURL('/p/lib/s.ts').toString(); - expect(byUri.get(uri)).toHaveLength(2); + const [uri] = [...byUri.keys()]; + expect(uri).toBe(uriFor('lib/s.ts')); + expect(uri).toContain(fixture.appDir); }); - test('maps severity correctly', () => { - const tree = indexOf( - nodeWithLocation('S/F', '/x.ts', 1, 1), - nodeWithLocation('S/E', '/x.ts', 2, 1), - nodeWithLocation('S/W', '/x.ts', 3, 1), - nodeWithLocation('S/I', '/x.ts', 4, 1), - nodeWithLocation('S/C', '/x.ts', 5, 1), - ); - const report = reportWith( - { ruleName: 'fatal', severity: 'fatal', paths: ['S/F'] }, - { ruleName: 'error', severity: 'error', paths: ['S/E'] }, - { ruleName: 'warning', severity: 'warning', paths: ['S/W'] }, - { ruleName: 'info', severity: 'info', paths: ['S/I'] }, - { ruleName: 'custom', severity: 'custom', paths: ['S/C'] }, + test('groups diagnostics from the same file under one URI', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(30) }, + stacks: [{ + id: 'S', + resources: [ + { path: 'A/Resource', logicalId: 'A1', type: 'AWS::S3::Bucket', at: 'lib/s.ts:10:1' }, + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::SQS::Queue', at: 'lib/s.ts:20:1' }, + ], + }], + }); + + const { byUri } = mapViolationsToDiagnostics( + reportWith( + { ruleName: 'r1', severity: 'error', paths: ['S/A/Resource'] }, + { ruleName: 'r2', severity: 'warning', paths: ['S/B/Resource'] }, + ), + await fixture.index(), ); - const { byUri } = mapViolationsToDiagnostics(report, tree); - const diags = byUri.get(pathToFileURL('/x.ts').toString())!; - const byCode = new Map(diags.map((d) => [d.code, d.severity])); - expect(byCode.get('fatal')).toBe(DiagnosticSeverity.Error); - expect(byCode.get('error')).toBe(DiagnosticSeverity.Error); - expect(byCode.get('warning')).toBe(DiagnosticSeverity.Warning); - expect(byCode.get('info')).toBe(DiagnosticSeverity.Information); - expect(byCode.get('custom')).toBe(DiagnosticSeverity.Information); + expect(byUri.size).toBe(1); + expect(byUri.get(uriFor('lib/s.ts'))).toHaveLength(2); }); - test('appends suggestedFix to the message when present', () => { - const tree = indexOf(nodeWithLocation('S/X', '/x.ts', 1, 1)); - const report = reportWith({ - ruleName: 'r', - description: 'do not do that', - severity: 'error', - suggestedFix: 'do this instead', - paths: ['S/X'], + test('splits diagnostics across the files their constructs come from', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/a.ts': '\n'.repeat(10), 'lib/b.ts': '\n'.repeat(10) }, + stacks: [{ + id: 'S', + resources: [ + { path: 'A/Resource', logicalId: 'A1', type: 'AWS::S3::Bucket', at: 'lib/a.ts:2:1' }, + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::SQS::Queue', at: 'lib/b.ts:4:1' }, + ], + }], }); - const { byUri } = mapViolationsToDiagnostics(report, tree); - const diag = byUri.get(pathToFileURL('/x.ts').toString())![0]; - expect(diag.message).toContain('do not do that'); - expect(diag.message).toContain('Suggested fix: do this instead'); + const { byUri } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity: 'error', paths: ['S/A/Resource', 'S/B/Resource'] }), + await fixture.index(), + ); + + expect([...byUri.keys()].sort()).toEqual([uriFor('lib/a.ts'), uriFor('lib/b.ts')].sort()); }); - test('drops violations whose constructPath is unknown', () => { - const tree = indexOf(); - const report = reportWith({ ruleName: 'r', severity: 'error', paths: ['S/Missing'] }); - const { byUri, dropped } = mapViolationsToDiagnostics(report, tree); + test.each<[PolicyViolationSeverity, DiagnosticSeverity]>([ + ['fatal', DiagnosticSeverity.Error], + ['error', DiagnosticSeverity.Error], + ['warning', DiagnosticSeverity.Warning], + ['info', DiagnosticSeverity.Information], + ['custom', DiagnosticSeverity.Information], + ])('maps %s severity to the LSP level', async (severity, expected) => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(5) }, + stacks: [{ id: 'S', resources: [{ path: 'X/Resource', logicalId: 'X1', type: 'AWS::S3::Bucket', at: 'lib/s.ts:1:1' }] }], + }); - expect(byUri.size).toBe(0); - expect(dropped).toHaveLength(1); - expect(dropped[0]).toMatchObject({ - ruleName: 'r', - constructPath: 'S/Missing', - reason: expect.stringContaining('construct tree'), + const { byUri } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity, paths: ['S/X/Resource'] }), + await fixture.index(), + ); + + expect([...byUri.values()][0][0].severity).toBe(expected); + }); + + test('appends the suggested fix to the message', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(5) }, + stacks: [{ id: 'S', resources: [{ path: 'X/Resource', logicalId: 'X1', type: 'AWS::S3::Bucket', at: 'lib/s.ts:1:1' }] }], }); + + const { byUri } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', description: 'do not do that', severity: 'error', suggestedFix: 'do this instead', paths: ['S/X/Resource'] }), + await fixture.index(), + ); + + const message = [...byUri.values()][0][0].message; + expect(message).toContain('do not do that'); + expect(message).toContain('Suggested fix: do this instead'); }); - test('drops violations whose construct has no sourceLocation (non-TS app)', () => { - const tree = indexOf(nodeNoLocation('S/MyBucket')); - const report = reportWith({ ruleName: 'r', severity: 'error', paths: ['S/MyBucket'] }); + test('anchors at the top of the file when no line or column is known', async () => { + // A frame with a file but no line (some jsii host languages). + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/s.ts': '\n'.repeat(5) }, + stacks: [{ id: 'S', resources: [{ path: 'X/Resource', logicalId: 'X1', type: 'AWS::S3::Bucket' }] }], + }); + const index = await fixture.index(); + + const { byUri } = mapViolationsToDiagnostics( + [{ + pluginName: 'p', + conclusion: 'failure', + violations: [{ + ruleName: 'r', + description: 'd', + severity: 'error', + violatingConstructs: [{ + constructPath: 'S/X/Resource', + stackTraces: [`...aws-cdk-lib...\n (${fixture.sourcePath('lib/s.ts')})`], + }], + }], + }], + index, + ); - const { byUri, dropped } = mapViolationsToDiagnostics(report, tree); - expect(byUri.size).toBe(0); - expect(dropped).toHaveLength(1); - expect(dropped[0].reason).toContain('no source location'); + expect([...byUri.values()][0][0].range.start).toEqual({ line: 0, character: 0 }); }); - test('anchors a violation to a Python (host-language) source file', () => { - const tree = indexOf(nodeWithLocation('S/X', '/p/stacks/x.py', 31, 1)); - const report = reportWith({ ruleName: 'r', severity: 'error', paths: ['S/X'] }); + test('anchors a violation in a non-TypeScript source file', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: { 'stacks/x.py': '\n'.repeat(40) }, + stacks: [{ id: 'S', resources: [{ path: 'X/Resource', logicalId: 'X1', type: 'AWS::S3::Bucket', at: 'stacks/x.py:31:1' }] }], + }); + + const { byUri, dropped } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity: 'error', paths: ['S/X/Resource'] }), + await fixture.index(), + ); - const { byUri, dropped } = mapViolationsToDiagnostics(report, tree); expect(dropped).toHaveLength(0); - expect(byUri.size).toBe(1); - const [uri, diags] = [...byUri.entries()][0]; + const [uri, diagnostics] = [...byUri.entries()][0]; expect(uri).toContain('x.py'); - expect(diags[0].range.start).toEqual({ line: 30, character: 0 }); + expect(diagnostics[0].range.start).toEqual({ line: 30, character: 0 }); }); - test('anchors at the top of the file when line/column are unknown', () => { - const tree = indexOf(nodeWithLocation('S/X', '/p/lib/stack.ts', 0, 0)); - const report = reportWith({ ruleName: 'r', severity: 'error', paths: ['S/X'] }); + test('yields no diagnostic for a construct absent from the index', async () => { + fixture = AssemblyFixture.create(); + + const { byUri, dropped } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity: 'error', paths: ['S/Missing'] }), + await fixture.index(), + ); - const { byUri, dropped } = mapViolationsToDiagnostics(report, tree); + expect(byUri.size).toBe(0); + // Unanchorable violations are skipped silently; `dropped` is only populated + // by an explicit anchoring failure, which the current anchor path never returns. expect(dropped).toHaveLength(0); - const diags = [...byUri.values()][0]; - expect(diags[0].range.start).toEqual({ line: 0, character: 0 }); + }); + + test('yields no diagnostic for a construct with no creation stack', async () => { + // Non-TypeScript app with no creation stacks recorded: nothing to anchor to. + fixture = AssemblyFixture.create({ + stacks: [{ id: 'S', resources: [{ path: 'MyBucket/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }], + }); + + const { byUri } = mapViolationsToDiagnostics( + reportWith({ ruleName: 'r', severity: 'error', paths: ['S/MyBucket/Resource'] }), + await fixture.index(), + ); + + expect(byUri.size).toBe(0); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/hover.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/hover.test.ts index 127c4ff74..b4fa5ea25 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/hover.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/hover.test.ts @@ -1,31 +1,51 @@ -import { pathToFileURL } from 'url'; -import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type Range } from 'vscode-languageserver/node'; -import type { ConstructNode } from '../../lib/core/assembly-reader'; -import type { ResourceConstruct } from '../../lib/lsp/codelens'; +import { + assertRelativePath, + type AssemblyResourceNode, +} from '../../lib/core/assembly-index-with-source-locations'; import { buildHover, hoverForPosition, type HoverLinks, renderValue, resourceNodesOnLine, selectPrimary } from '../../lib/lsp/hover'; +import { AssemblyFixture, type AssemblySpec } from '../_helpers/assembly'; const RANGE: Range = { start: { line: 9, character: 0 }, end: { line: 9, character: 0 } }; -const FILE = '/app/lib/app.ts'; -const FILE_URI = pathToFileURL(FILE).toString(); +const LINK_URI = 'file:///app/cdk.out/S.template.json'; +const SOURCE = { 'lib/app.ts': '\n'.repeat(40), 'lib/other.ts': '\n'.repeat(40) }; +/** The LogSink hoverForPosition writes template-read failures to. */ +function makeLog() { + return { warn: jest.fn(), error: jest.fn() }; +} + +let fixture: AssemblyFixture | undefined; + +afterEach(() => { + fixture?.cleanup(); + fixture = undefined; +}); + +/** A resource node as the index produces them, for the pure rendering functions. */ function res( - path: string, + constructPath: string, logicalId: string, type: string, - opts: { cfnProperties?: Record; file?: string; line?: number } = {}, -): ResourceConstruct { + cfnProperties?: Record, +): AssemblyResourceNode { return { - path, - id: path.split('/').slice(-1)[0], - logicalId, - type, - sourceLocation: { file: opts.file ?? FILE, line: opts.line ?? 10, column: 3 }, - cfnProperties: opts.cfnProperties, - children: [], + constructPath, + id: constructPath.split('/').slice(-1)[0], + cfnResource: { + logicalId, + type, + assemblyRelativeTemplateFile: assertRelativePath('S.template.json'), + cfnProperties, + }, }; } +/** Markdown body of a hover built from `nodes`. */ +function hoverText(nodes: readonly AssemblyResourceNode[], links?: HoverLinks): string { + return (buildHover(nodes, selectPrimary(nodes), links, RANGE)!.contents as { value: string }).value; +} + describe('renderValue', () => { test('quotes strings and truncates long ones', () => { expect(renderValue('nodejs16.x')).toBe('"nodejs16.x"'); @@ -50,159 +70,305 @@ describe('renderValue', () => { expect(renderValue({ a: 1, b: 2, c: 3, d: 4, e: 5 })).toBe('{ a, b, c, d, … }'); }); - test('renders intrinsics compactly', () => { + test('renders CFN intrinsics compactly', () => { expect(renderValue({ Ref: 'AppVpc80F1F7F9' })).toBe('{Ref AppVpc80F1F7F9}'); expect(renderValue({ 'Fn::GetAtt': ['Role592E70E9', 'Arn'] })).toBe('{Fn::GetAtt Role592E70E9.Arn}'); expect(renderValue({ 'Fn::Sub': 'arn:${X}' })).toBe('{Fn::Sub …}'); }); + + test('treats a two-key object as data, not an intrinsic', () => { + expect(renderValue({ Ref: 'X', Other: 1 })).toBe('{ Ref, Other }'); + }); }); describe('selectPrimary', () => { test('a single resource is its own primary', () => { - const only = res('Stack/Bucket/Resource', 'B1', 'AWS::S3::Bucket'); + const only = res('S/Bucket/Resource', 'B1', 'AWS::S3::Bucket'); expect(selectPrimary([only])).toEqual({ primary: only, others: [] }); }); - test('the uniquely shallowest resource is primary, the rest are others', () => { - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function'); - const role = res('Stack/Fn/ServiceRole/Resource', 'Role1', 'AWS::IAM::Role'); - const policy = res('Stack/Fn/ServiceRole/DefaultPolicy/Resource', 'Pol1', 'AWS::IAM::Policy'); + test('the uniquely shallowest resource is primary', () => { + const fn = res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function'); + const role = res('S/Fn/ServiceRole/Resource', 'Role1', 'AWS::IAM::Role'); + const policy = res('S/Fn/ServiceRole/DefaultPolicy/Resource', 'Pol1', 'AWS::IAM::Policy'); + expect(selectPrimary([role, fn, policy])).toEqual({ primary: fn, others: [role, policy] }); }); - test('a tie at the shallowest depth has no single primary', () => { - const lb = res('Stack/Svc/LB/Resource', 'Lb1', 'AWS::ElasticLoadBalancingV2::LoadBalancer'); - const task = res('Stack/Svc/Task/Resource', 'Task1', 'AWS::ECS::TaskDefinition'); + test('a default child breaks a tie at the shallowest depth', () => { + // A Vpc creates its CfnVPC (the `Resource` default child) and an + // InternetGateway as siblings at the same depth. + const vpc = res('S/Vpc/Resource', 'Vpc1', 'AWS::EC2::VPC'); + const igw = res('S/Vpc/IGW', 'Igw1', 'AWS::EC2::InternetGateway'); + + expect(selectPrimary([igw, vpc])).toEqual({ primary: vpc, others: [igw] }); + }); + + test('a tie at the shallowest depth with no default child has no primary', () => { + const lb = res('S/Svc/LB/Resource', 'Lb1', 'AWS::ElasticLoadBalancingV2::LoadBalancer'); + const task = res('S/Svc/Task/Resource', 'Task1', 'AWS::ECS::TaskDefinition'); + expect(selectPrimary([lb, task])).toBeUndefined(); }); - test('the default child is primary even when tied at the shallowest depth', () => { - // A Vpc creates its CfnVPC (default child `Resource`) and an InternetGateway - // as siblings at the same depth; the default child is still the primary. - const vpc = res('Stack/Vpc/Resource', 'Vpc1', 'AWS::EC2::VPC'); - const igw = res('Stack/Vpc/IGW', 'Igw1', 'AWS::EC2::InternetGateway'); - expect(selectPrimary([igw, vpc])).toEqual({ primary: vpc, others: [igw] }); + test('two default children tied at the shallowest depth have no primary', () => { + const a = res('S/A/Resource', 'A1', 'AWS::S3::Bucket'); + const b = res('S/B/Resource', 'B1', 'AWS::SQS::Queue'); + + expect(selectPrimary([a, b])).toBeUndefined(); }); }); describe('buildHover', () => { const links: HoverLinks = { resourceLocations: { - 'Stack/Fn/Resource': { uri: FILE_URI, line: 5 }, - 'Stack/Fn/ServiceRole/Resource': { uri: FILE_URI, line: 40 }, + 'S/Fn/Resource': { uri: LINK_URI, line: 5 }, + 'S/Fn/ServiceRole/Resource': { uri: LINK_URI, line: 40 }, }, - // resolveHoverLinks keys properties by lower-cased name (see HoverLinks). - properties: { runtime: { uri: FILE_URI, line: 8 } }, + // resolveHoverLinks keys properties by lower-cased name. + properties: { runtime: { uri: LINK_URI, line: 8 } }, }; test('returns undefined when no resource maps to the line', () => { expect(buildHover([], undefined, links, RANGE)).toBeUndefined(); }); - test('links the logical id to its block and the value to its property line', () => { - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties: { runtime: 'nodejs16.x' } }); - const value = buildHover([fn], selectPrimary([fn]), links, RANGE)!.contents as { value: string }; - expect(value.value).toContain(`[**Fn1**](${FILE_URI}#L5) · \`AWS::Lambda::Function\``); - expect(value.value).toContain('`Stack/Fn/Resource`'); - expect(value.value).toContain(`- \`runtime\`: [\`"nodejs16.x"\`](${FILE_URI}#L8)`); + test('links the logical id to its block and each value to its property line', () => { + const fn = res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { runtime: 'nodejs16.x' }); + + const text = hoverText([fn], links); + + expect(text).toContain(`[**Fn1**](${LINK_URI}#L5) · \`AWS::Lambda::Function\``); + expect(text).toContain('`S/Fn/Resource`'); + expect(text).toContain(`- \`runtime\`: [\`"nodejs16.x"\`](${LINK_URI}#L8)`); }); - test('renders a value without a link when no range resolves for it', () => { - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties: { memorySize: 512 } }); - const value = (buildHover([fn], selectPrimary([fn]), links, RANGE)!.contents as { value: string }).value; - expect(value).toContain('- `memorySize`: `512`'); - expect(value).not.toContain('memorySize`]'); + test('renders a value without a link when none resolved for it', () => { + const fn = res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { memorySize: 512 }); + + const text = hoverText([fn], links); + + expect(text).toContain('- `memorySize`: `512`'); + expect(text).not.toContain('memorySize`]'); }); test('renders everything plainly when links are absent', () => { - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties: { runtime: 'nodejs16.x' } }); - const value = (buildHover([fn], selectPrimary([fn]), undefined, RANGE)!.contents as { value: string }).value; - expect(value).toContain('**Fn1** · `AWS::Lambda::Function`'); - expect(value).not.toContain(']('); + const fn = res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { runtime: 'nodejs16.x' }); + + const text = hoverText([fn]); + + expect(text).toContain('**Fn1** · `AWS::Lambda::Function`'); + expect(text).not.toContain(']('); }); - test('caps properties and appends a "+N more" line', () => { + test('caps the property list and reports the remainder', () => { const cfnProperties = Object.fromEntries(Array.from({ length: 15 }, (_, i) => [`p${i}`, i])); - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties }); - const value = (buildHover([fn], selectPrimary([fn]), undefined, RANGE)!.contents as { value: string }).value; - expect(value).toContain('- `p11`: `11`'); - expect(value).not.toContain('- `p12`:'); - expect(value).toContain('- +3 more'); + + const text = hoverText([res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', cfnProperties)]); + + expect(text).toContain('- `p11`: `11`'); + expect(text).not.toContain('- `p12`:'); + expect(text).toContain('- +3 more'); }); - test('shows only the primary values and lists the others under "Also creates"', () => { - const fn = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties: { runtime: 'nodejs16.x' } }); - const role = res('Stack/Fn/ServiceRole/Resource', 'Role1', 'AWS::IAM::Role', { cfnProperties: { roleName: 'x' } }); - const nodes = [fn, role]; - const value = (buildHover(nodes, selectPrimary(nodes), links, RANGE)!.contents as { value: string }).value; - expect(value).toContain('- `runtime`:'); - expect(value).not.toContain('roleName'); - expect(value).toContain(`Also creates: [\`AWS::IAM::Role\`](${FILE_URI}#L40)`); + test('shows only the primary properties and lists the rest under Also creates', () => { + const fn = res('S/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { runtime: 'nodejs16.x' }); + const role = res('S/Fn/ServiceRole/Resource', 'Role1', 'AWS::IAM::Role', { roleName: 'x' }); + + const text = hoverText([fn, role], links); + + expect(text).toContain('- `runtime`:'); + expect(text).not.toContain('roleName'); + expect(text).toContain(`Also creates: [\`AWS::IAM::Role\`](${LINK_URI}#L40)`); }); - test('collapses a large auxiliary set to a type histogram', () => { - const vpc = res('Stack/Vpc/Resource', 'Vpc1', 'AWS::EC2::VPC'); + test('collapses a large auxiliary set into a type histogram', () => { + const vpc = res('S/Vpc/Resource', 'Vpc1', 'AWS::EC2::VPC'); const aux = [ - ...Array.from({ length: 3 }, (_, i) => res(`Stack/Vpc/S${i}/Subnet`, `S${i}`, 'AWS::EC2::Subnet')), - ...Array.from({ length: 2 }, (_, i) => res(`Stack/Vpc/R${i}/RouteTable`, `R${i}`, 'AWS::EC2::RouteTable')), - res('Stack/Vpc/Igw/Resource', 'Igw1', 'AWS::EC2::InternetGateway'), + ...Array.from({ length: 3 }, (_, i) => res(`S/Vpc/S${i}/Subnet`, `S${i}`, 'AWS::EC2::Subnet')), + ...Array.from({ length: 2 }, (_, i) => res(`S/Vpc/R${i}/RouteTable`, `R${i}`, 'AWS::EC2::RouteTable')), + res('S/Vpc/Igw/Resource', 'Igw1', 'AWS::EC2::InternetGateway'), ]; - const nodes = [vpc, ...aux]; - const value = (buildHover(nodes, selectPrimary(nodes), undefined, RANGE)!.contents as { value: string }).value; - expect(value).toContain('Also creates 6 resources:'); - expect(value).toContain('3× Subnet'); - expect(value).toContain('2× RouteTable'); + + const text = hoverText([vpc, ...aux]); + + expect(text).toContain('Also creates 6 resources:'); + expect(text).toContain('3× Subnet'); + expect(text).toContain('2× RouteTable'); }); - test('shows a resource summary when several resources tie at the shallowest depth', () => { - const lb = res('Stack/Svc/LB/Resource', 'Lb1', 'AWS::ElasticLoadBalancingV2::LoadBalancer'); - const task = res('Stack/Svc/Task/Resource', 'Task1', 'AWS::ECS::TaskDefinition'); - const nodes = [lb, task]; - const value = (buildHover(nodes, selectPrimary(nodes), undefined, RANGE)!.contents as { value: string }).value; - expect(value).toContain('**2 resources on this line**'); - expect(value).not.toContain('Also creates'); + test('shows a plain summary when several resources tie with no primary', () => { + const lb = res('S/Svc/LB/Resource', 'Lb1', 'AWS::ElasticLoadBalancingV2::LoadBalancer'); + const task = res('S/Svc/Task/Resource', 'Task1', 'AWS::ECS::TaskDefinition'); + + const text = hoverText([lb, task]); + + expect(text).toContain('**2 resources defined here**'); + expect(text).not.toContain('Also creates'); }); }); describe('resourceNodesOnLine', () => { - test('returns only resource nodes on the hovered file and line', () => { - const onLine = res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { line: 10 }); - const otherLine = res('Stack/Other/Resource', 'Ot1', 'AWS::S3::Bucket', { line: 20 }); - const otherFile = res('Stack/Elsewhere/Resource', 'El1', 'AWS::S3::Bucket', { file: '/app/lib/other.ts', line: 10 }); - const wrapper: ConstructNode = { - path: 'Stack/Wrapper', - id: 'Wrapper', - sourceLocation: { file: FILE, line: 10, column: 1 }, - children: [], - }; - const index = ConstructIndex.fromTree([onLine, otherLine, otherFile, wrapper]); - - const found = resourceNodesOnLine(index, FILE_URI, { line: 9, character: 0 }); - expect(found.map((n) => n.logicalId)).toEqual(['Fn1']); + async function indexWith(spec: AssemblySpec) { + fixture = AssemblyFixture.create({ sourceFiles: SOURCE, ...spec }); + return fixture.index(); + } + + test('returns only the resources on the hovered file and line', async () => { + const index = await indexWith({ + stacks: [{ + id: 'S', + resources: [ + { path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }, + { path: 'Other/Resource', logicalId: 'Ot1', type: 'AWS::S3::Bucket', at: 'lib/app.ts:20:3' }, + { path: 'Elsewhere/Resource', logicalId: 'El1', type: 'AWS::S3::Bucket', at: 'lib/other.ts:10:3' }, + ], + }], + }); + + // LSP line 9 is 1-based line 10. + const found = resourceNodesOnLine(index, fixture!.sourcePath('lib/app.ts'), { line: 9, character: 0 }); + + expect(found.map((n) => n.cfnResource.logicalId)).toEqual(['Fn1']); + }); + + test('excludes a wrapper construct that is not a CFN resource', async () => { + const index = await indexWith({ + withoutTree: true, + stacks: [{ id: 'S', resources: [{ path: 'Wrapper', logicalId: 'W1', at: 'lib/app.ts:10:1' }] }], + }); + + expect(resourceNodesOnLine(index, fixture!.sourcePath('lib/app.ts'), { line: 9, character: 0 })).toEqual([]); + }); + + test('returns an empty list for a line with nothing on it', async () => { + const index = await indexWith({ + stacks: [{ id: 'S', resources: [{ path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }] }], + }); + + expect(resourceNodesOnLine(index, fixture!.sourcePath('lib/app.ts'), { line: 98, character: 0 })).toEqual([]); }); }); describe('hoverForPosition', () => { - const TEMPLATE = JSON.stringify( - { Resources: { Fn1: { Type: 'AWS::Lambda::Function', Properties: { Runtime: 'nodejs16.x' } } } }, - undefined, - 1, - ); - - test('resolves block and per-property links from the template', async () => { - const fn: ResourceConstruct = { - ...res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function', { cfnProperties: { runtime: 'nodejs16.x' } }), - templateFile: '/app/cdk.out/Stack.template.json', - }; - const index = ConstructIndex.fromTree([fn]); - const hover = await hoverForPosition(index, FILE_URI, { line: 9, character: 0 }, async () => TEMPLATE); - const value = (hover!.contents as { value: string }).value; - expect(value).toMatch(/\[\*\*Fn1\*\*\]\(file:.*#L\d+\) · `AWS::Lambda::Function`/); - expect(value).toMatch(/- `runtime`: \[`"nodejs16\.x"`\]\(file:.*#L\d+\)/); + test('resolves block and per-property links from the real template', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ + id: 'S', + resources: [{ + path: 'Fn/Resource', + logicalId: 'Fn1', + type: 'AWS::Lambda::Function', + at: 'lib/app.ts:10:3', + props: { Runtime: 'nodejs16.x' }, + }], + }], + }); + const index = await fixture.index(); + + const hover = await hoverForPosition(makeLog(), index, fixture.sourcePath('lib/app.ts'), { line: 9, character: 0 }, + async (file) => fixture!.templateText(file.replace(/\.template\.json$/, ''))); + const text = (hover!.contents as { value: string }).value; + + // Both links point into the assembly's template, at resolved lines. + expect(text).toMatch(/\[\*\*Fn1\*\*\]\(file:.*S\.template\.json#L\d+\) · `AWS::Lambda::Function`/); + expect(text).toMatch(/- `Runtime`: \[`"nodejs16\.x"`\]\(file:.*S\.template\.json#L\d+\)/); + }); + + test('links to a URI inside the assembly dir, not relative to the process cwd', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }] }], + }); + const index = await fixture.index(); + + const hover = await hoverForPosition(makeLog(), index, fixture.sourcePath('lib/app.ts'), { line: 9, character: 0 }, + async () => fixture!.templateText('S')); + const text = (hover!.contents as { value: string }).value; + + expect(text).toContain(fixture.assemblyDir); + }); + + test('renders without links and reports when the template read fails', async () => { + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }] }], + }); + const index = await fixture.index(); + const log = makeLog(); + + // The seam throws rather than returning undefined, so the failure is a real + // error the user can be told about instead of silently losing the links. + const hover = await hoverForPosition(log, index, fixture.sourcePath('lib/app.ts'), { line: 9, character: 0 }, + async () => { + throw new Error('ENOENT'); + }); + const text = (hover!.contents as { value: string }).value; + + expect(text).toContain('**Fn1** · `AWS::Lambda::Function`'); + expect(text).not.toContain(']('); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('S.template.json')); + }); + + test('keeps linking the resources whose templates do read', async () => { + // One bad template must not cost the other resources their links. + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ + id: 'S', + resources: [ + { path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }, + { path: 'Fn/Role/Resource', logicalId: 'Role1', type: 'AWS::IAM::Role', at: 'lib/app.ts:10:3' }, + ], + }], + }); + const index = await fixture.index(); + let call = 0; + const log = makeLog(); + + const hover = await hoverForPosition(log, index, fixture.sourcePath('lib/app.ts'), { line: 9, character: 0 }, + async () => { + call += 1; + if (call === 1) throw new Error('ENOENT'); + return fixture!.templateText('S'); + }); + const text = (hover!.contents as { value: string }).value; + + // The primary's own read failed, but the auxiliary still resolved a link. + expect(text).toContain('Also creates: [`AWS::IAM::Role`]'); + expect(log.error).toHaveBeenCalledTimes(1); }); test('returns undefined when no resource is on the hovered line', async () => { - const index = ConstructIndex.fromTree([res('Stack/Fn/Resource', 'Fn1', 'AWS::Lambda::Function')]); - expect(await hoverForPosition(index, FILE_URI, { line: 99, character: 0 }, async () => '')).toBeUndefined(); + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ id: 'S', resources: [{ path: 'Fn/Resource', logicalId: 'Fn1', type: 'AWS::Lambda::Function', at: 'lib/app.ts:10:3' }] }], + }); + const index = await fixture.index(); + + expect(await hoverForPosition(makeLog(), index, fixture.sourcePath('lib/app.ts'), { line: 98, character: 0 }, async () => '')).toBeUndefined(); + }); + + test('reads each referenced template only once for a multi-resource line', async () => { + // A VPC-like fan-out: every resource lives in the same template, so the + // hover must not re-read it per resource. + fixture = AssemblyFixture.create({ + sourceFiles: SOURCE, + stacks: [{ + id: 'S', + resources: [ + { path: 'Vpc/Resource', logicalId: 'Vpc1', type: 'AWS::EC2::VPC', at: 'lib/app.ts:10:3' }, + { path: 'Vpc/Subnet1/Resource', logicalId: 'Sub1', type: 'AWS::EC2::Subnet', at: 'lib/app.ts:10:3' }, + { path: 'Vpc/Subnet2/Resource', logicalId: 'Sub2', type: 'AWS::EC2::Subnet', at: 'lib/app.ts:10:3' }, + ], + }], + }); + const index = await fixture.index(); + const readTemplate = jest.fn(async () => fixture!.templateText('S')); + + await hoverForPosition(makeLog(), index, fixture.sourcePath('lib/app.ts'), { line: 9, character: 0 }, readTemplate); + + expect(readTemplate).toHaveBeenCalledTimes(1); }); }); 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 2331b4075..9b3fa6f65 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -1,17 +1,48 @@ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; -import { LockError } from '@aws-cdk/toolkit-lib'; import type { Diagnostic, InitializeParams } from 'vscode-languageserver/node'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import type { AssemblyReadResult } from '../../lib'; -import type { AssemblyLock } from '../../lib/core/assembly-lock'; -import type { SynthRunResult } from '../../lib/core/synth-runner'; -import { COMMAND_SYNTH_NOW, type NotifySink } from '../../lib/lsp/commands'; +import type { AssemblyWatcherOptions } from '../../lib/core/assembly-watcher'; +import { + COMMAND_DISABLE_AUTO_SYNTH, + COMMAND_ENABLE_AUTO_SYNTH, + COMMAND_SYNTH_NOW, + type NotifySink, +} from '../../lib/lsp/commands'; +import { CDK_LSP_FEATURES, CDK_LSP_PROTOCOL } from '../../lib/lsp/features'; import { createLspHandlers, type LspHandlerOptions, type LspHandlers } from '../../lib/lsp/server'; +import { AssemblyFixture, type AssemblySpec } from '../_helpers/assembly'; +import { FakeToolkit } from '../_helpers/fake-toolkit'; -function makeNotifySink(): NotifySink & { infoMessages: string[]; errorMessages: string[] } { +/** + * The handlers are driven against a real `Toolkit` over a fixture cloud assembly + * on disk, so the assembly read path is exercised end to end. Only the things + * that would reach outside the test are faked: the synth runner (no child + * process) and the cdk.out watcher (no chokidar, and it fires on demand). + * + * The assembly read lock is not exercised here. It lives inside + * `toolkit.fromAssemblyDirectory().produce()`, so contention and its retries are + * `@aws-cdk/toolkit-lib`'s contract, tested in that package. + */ +interface TestClient { + readonly handlers: LspHandlers; + readonly fixture: AssemblyFixture; + /** Scripts synth outcomes; the assembly-read path stays real. */ + readonly toolkit: FakeToolkit; + readonly published: Array<{ uri: string; diagnostics: Diagnostic[] }>; + readonly log: { warn: jest.Mock; error: jest.Mock }; + readonly notify: NotifySink & { infoMessages: string[]; errorMessages: string[] }; + readonly refreshCodeLens: jest.Mock; + readonly watcherClosed: jest.Mock; + readonly watchedDirs: string[]; + /** Fire the cdk.out watcher's onChange. The refresh it starts is fire-and- + * forget, so callers wait on the effect they expect via {@link waitFor}. */ + triggerWatcher(): void; + /** URI of an app-relative source file, as the editor would send it. */ + uri(relPath: string): string; +} + +function makeNotifySink(): TestClient['notify'] { const infoMessages: string[] = []; const errorMessages: string[] = []; return { @@ -23,1025 +54,785 @@ function makeNotifySink(): NotifySink & { infoMessages: string[]; errorMessages: }; } -/** Simple no-op assembly lock for tests that do not exercise lock contention. */ -const mockAssemblyLock = async (): Promise => ({ - release: async () => { - }, -}); - -interface CapturedClient { - handlers: LspHandlers; - published: Array<{ uri: string; diagnostics: Diagnostic[] }>; - log: { warn: jest.Mock; error: jest.Mock }; - refreshCodeLens: jest.Mock; - watcherClosed: jest.Mock; - /** Fire the cdk.out watcher's onChange and await the refresh it triggers. */ - triggerWatcher: () => Promise; +interface ClientOptions extends Partial { + /** Cloud assembly to build on disk; omit for a project with no assembly. */ + readonly assembly?: AssemblySpec; + /** + * Written as the project's `cdk.json`. A synthesizable project needs an `app`, + * which is what `runSynth` reads to decide there is anything to synth. + */ + readonly cdkJson?: Record; } -function createTestClient(opts?: Partial): CapturedClient { +function createTestClient(opts: ClientOptions = {}): TestClient { + const { assembly, cdkJson, ...handlerOptions } = opts; + const fixture = AssemblyFixture.create({ + sourceFiles: { + 'lib/stack.ts': '\n'.repeat(30), + 'bin/app.ts': '\n'.repeat(10), + // Default to a synthesizable project; a test can override or omit the app. + 'cdk.json': JSON.stringify(cdkJson ?? { app: 'node bin/app.js' }), + }, + ...assembly, + }); + const toolkit = new FakeToolkit(); + const published: Array<{ uri: string; diagnostics: Diagnostic[] }> = []; const log = { warn: jest.fn(), error: jest.fn() }; + const notify = makeNotifySink(); const refreshCodeLens = jest.fn(); const watcherClosed = jest.fn(); + const watchedDirs: string[] = []; let watcherOnChange: (() => void) | undefined; + const handlers = createLspHandlers({ - // 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 ?? (async () => ({ status: 'not-found' })), - // Fake assembly lock so refreshFromAssembly does not touch the real filesystem. - acquireAssemblyLock: opts?.acquireAssemblyLock ?? mockAssemblyLock, - synthRunner: opts?.synthRunner, - notify: opts?.notify, - version: opts?.version, + toolkit: toolkit.asToolkit(), logger: log, + notify, onPublishDiagnostics: (uri, diagnostics) => published.push({ uri, diagnostics }), onRefreshCodeLenses: refreshCodeLens, - startAssemblyWatcher: (watchOpts) => { + startAssemblyWatcher: (watchOpts: AssemblyWatcherOptions) => { + watchedDirs.push(watchOpts.assemblyDir); watcherOnChange = watchOpts.onChange; - return { - close: async () => { - watcherClosed(); - }, - }; + return { close: async () => void watcherClosed() }; }, + ...handlerOptions, }); + return { handlers, + fixture, + toolkit, published, log, + notify, refreshCodeLens, watcherClosed, - triggerWatcher: async () => { - watcherOnChange?.(); - // onChange kicks off refreshFromAssembly fire-and-forget; let it settle - // before the test asserts on the published diagnostics. - await new Promise((resolve) => setImmediate(resolve)); - }, + watchedDirs, + triggerWatcher: () => watcherOnChange?.(), + uri: (relPath) => pathToFileURL(fixture.sourcePath(relPath)).toString(), }; } -async function initializeClient( - client: CapturedClient, - options?: Record, - capabilities?: InitializeParams['capabilities'], +async function initialize( + target: TestClient, + capabilities: InitializeParams['capabilities'] = {}, ): Promise { - client.handlers.onInitialize({ + target.handlers.onInitialize({ processId: null, - capabilities: capabilities ?? {}, + capabilities, rootUri: null, - initializationOptions: options ?? {}, + initializationOptions: { applicationDir: target.fixture.appDir }, }); - await client.handlers.onInitialized(); + await target.handlers.onInitialized(); } -function bucketViolationFixtures() { - const tree = [{ - path: 'Stack1', - id: 'Stack1', - children: [{ - path: 'Stack1/MyBucket', - id: 'MyBucket', - children: [], - sourceLocation: { file: '/p/lib/stack.ts', line: 12, column: 5 }, - }], - }]; - const violations = { - version: '1.0.0', - pluginReports: [{ - pluginName: 'test-plugin', - conclusion: 'failure', - violations: [{ - ruleName: 'no-public-buckets', - description: 'no public buckets', - severity: 'error', - violatingConstructs: [{ constructPath: 'Stack1/MyBucket' }], - }], - }], - }; - return { tree, violations }; -} +/** Let a fire-and-forget handler settle. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); /** - * 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. + * Poll until `predicate` holds. The watcher's refresh is fire-and-forget and + * does real I/O, so there is no promise for a test to await. */ -function readAssemblyResolvingAfterFirst(): () => Promise { - const { tree, violations } = bucketViolationFixtures(); - let call = 0; - return async (): Promise => { - call += 1; - return { status: 'success', data: call === 1 ? { warnings: [], tree, violations } : { warnings: [], tree } }; - }; +async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for the assembly refresh to settle'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** An assembly with one bucket created at lib/stack.ts:12:5. */ +const ONE_BUCKET: AssemblySpec = { + stacks: [{ + id: 'Stack1', + resources: [{ path: 'MyBucket/Resource', logicalId: 'MyBucket123', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }], + }], +}; + +/** The same bucket, plus a violation on it. */ +const ONE_BUCKET_WITH_VIOLATION: AssemblySpec = { + ...ONE_BUCKET, + reports: [{ + pluginName: 'test-plugin', + conclusion: 'failure', + violations: [{ + ruleName: 'no-public-buckets', + description: 'no public buckets', + severity: 'error', + violatingConstructs: [{ constructPath: 'Stack1/MyBucket/Resource' }], + }], + }], +}; + +let clients: TestClient[] = []; + +/** Track every client so its fixture is cleaned up. */ +function client(opts: ClientOptions = {}): TestClient { + const tracked = createTestClient(opts); + clients.push(tracked); + return tracked; } -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' }, +afterEach(() => { + for (const tracked of clients) { + tracked.fixture.cleanup(); + } + clients = []; +}); + +describe('initialize', () => { + test('advertises codeLens, definition, hover and save-sync capabilities', async () => { + const result = client().handlers.onInitialize({ + processId: null, capabilities: {}, rootUri: null, initializationOptions: {}, }); - expect(result).toMatchObject({ - capabilities: { - textDocumentSync: { openClose: true, change: 0, save: { includeText: false } }, - codeLensProvider: { resolveProvider: false }, - definitionProvider: true, - }, + + expect(result.capabilities.codeLensProvider).toBeDefined(); + expect(result.capabilities.definitionProvider).toBe(true); + expect(result.capabilities.hoverProvider).toBe(true); + expect(result.capabilities.textDocumentSync).toMatchObject({ save: { includeText: false } }); + }); + + test('advertises the executeCommand provider with every supported command', async () => { + const result = client().handlers.onInitialize({ + processId: null, capabilities: {}, rootUri: null, initializationOptions: {}, }); + + expect(result.capabilities.executeCommandProvider?.commands).toEqual( + expect.arrayContaining([COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH]), + ); }); - test('initialize advertises serverInfo and the experimental.cdk feature manifest', () => { - const client = createTestClient({ version: '9.9.9' }); - const result = client.handlers.onInitialize({ - processId: null, - capabilities: {}, - rootUri: null, - initializationOptions: {}, + test('advertises serverInfo and the experimental cdk manifest', async () => { + const result = client({ version: '2.1.0' }).handlers.onInitialize({ + processId: null, capabilities: {}, rootUri: null, initializationOptions: {}, }); - expect(result.serverInfo).toEqual({ name: 'cdk-lsp', version: '9.9.9' }); - expect(result.capabilities.experimental).toMatchObject({ - cdk: { - protocol: 1, - version: '9.9.9', - features: expect.arrayContaining(['hover', 'codeLens', 'definition', 'synth', 'autoSynth']), - commands: expect.arrayContaining([COMMAND_SYNTH_NOW]), - }, + + expect(result.serverInfo).toMatchObject({ version: '2.1.0' }); + expect((result.capabilities.experimental as any).cdk).toMatchObject({ + protocol: CDK_LSP_PROTOCOL, + version: '2.1.0', + features: [...CDK_LSP_FEATURES], }); }); - test('every advertised cdk feature is backed by a real capability (guards manifest drift)', () => { - const client = createTestClient(); - const result = client.handlers.onInitialize({ - processId: null, - capabilities: {}, - rootUri: null, - initializationOptions: {}, + test('every advertised cdk feature is backed by a real capability', async () => { + // Guards manifest drift: a feature named here but not implemented would let + // a client gate on something that silently does nothing. + const result = client().handlers.onInitialize({ + processId: null, capabilities: {}, rootUri: null, initializationOptions: {}, }); - const caps = result.capabilities; - const commands = caps.executeCommandProvider?.commands ?? []; - const backing: Record = { - hover: !!caps.hoverProvider, - codeLens: !!caps.codeLensProvider, - definition: !!caps.definitionProvider, - synth: commands.includes('cdk.explorer.synthNow'), - autoSynth: commands.includes('cdk.explorer.enableAutoSynth') && commands.includes('cdk.explorer.disableAutoSynth'), + const { capabilities } = result; + const manifest = (capabilities.experimental as any).cdk; + + const backing: Record = { + hover: capabilities.hoverProvider, + codeLens: capabilities.codeLensProvider, + definition: capabilities.definitionProvider, + synth: capabilities.executeCommandProvider?.commands.includes(COMMAND_SYNTH_NOW), + autoSynth: capabilities.executeCommandProvider?.commands.includes(COMMAND_ENABLE_AUTO_SYNTH), }; - const manifest = (caps.experimental as { cdk: { features: string[]; commands: string[] } }).cdk; - // An advertised feature with no backing capability means the list is lying. for (const feature of manifest.features) { - expect(backing[feature]).toBe(true); + expect(backing[feature]).toBeTruthy(); } - // The manifest's command list must mirror the wire executeCommandProvider list. - expect(manifest.commands).toEqual(commands); }); +}); - 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 }); - await initializeClient(client, { applicationDir: '/tmp/test-project' }); - - // Enable auto-synth via the toggle command first - await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); +describe('assembly refresh', () => { + test('publishes diagnostics for the assembly violations on initialize', async () => { + const c = client({ assembly: ONE_BUCKET_WITH_VIOLATION }); - client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - }); - await new Promise((r) => setTimeout(r, 0)); + await initialize(c); - expect(synthRunner).toHaveBeenCalledTimes(1); + expect(c.published).toHaveLength(1); + expect(c.published[0].uri).toBe(c.uri('lib/stack.ts')); + expect(c.published[0].diagnostics[0]).toMatchObject({ code: 'no-public-buckets', source: 'test-plugin' }); }); - test('didSave does not trigger synth when auto-synth is disabled (default)', async () => { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner }); - await initializeClient(client, { applicationDir: '/tmp/test-project' }); + test('publishes nothing when the app has not been synthesized', async () => { + const c = client(); + fs.rmSync(c.fixture.assemblyDir, { recursive: true, force: true }); - // auto-synth is off by default - client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - }); - await new Promise((r) => setTimeout(r, 0)); + await initialize(c); - expect(synthRunner).not.toHaveBeenCalled(); + expect(c.published).toHaveLength(0); + expect(c.log.error).not.toHaveBeenCalled(); }); - test('didSave does not trigger synth for ignored files', async () => { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner }); - await initializeClient(client, { applicationDir: '/tmp/test-project' }); - await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + test('logs and carries on when the assembly cannot be read', async () => { + const c = client({ assembly: ONE_BUCKET }); + fs.writeFileSync(path.join(c.fixture.assemblyDir, 'manifest.json'), 'not json{{{'); - 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' }, - }); - await new Promise((r) => setTimeout(r, 0)); + await initialize(c); - expect(synthRunner).not.toHaveBeenCalled(); + expect(c.log.error).toHaveBeenCalledWith(expect.stringContaining('cloud assembly')); }); - test('didSave is ignored after shutdown', async () => { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const client = createTestClient({ synthRunner }); - await initializeClient(client, { applicationDir: '/tmp/test-project' }); - await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + test('watches the assembly dir resolved from cdk.json, not a hardcoded cdk.out', async () => { + const c = client({ assembly: ONE_BUCKET }); + fs.renameSync(c.fixture.assemblyDir, path.join(c.fixture.appDir, 'out2')); + fs.writeFileSync(path.join(c.fixture.appDir, 'cdk.json'), JSON.stringify({ app: 'x', output: 'out2' })); - client.handlers.onShutdown(); - client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - }); - await new Promise((r) => setTimeout(r, 0)); + await initialize(c); - expect(synthRunner).not.toHaveBeenCalled(); + expect(c.watchedDirs).toEqual([path.join(c.fixture.appDir, 'out2')]); }); - 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 }); - await initializeClient(client, { applicationDir: '/tmp/test-project' }); - await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + test('reads the assembly from the dir cdk.json points at', async () => { + const c = client({ assembly: ONE_BUCKET_WITH_VIOLATION }); + fs.renameSync(c.fixture.assemblyDir, path.join(c.fixture.appDir, 'out2')); + fs.writeFileSync(path.join(c.fixture.appDir, 'cdk.json'), JSON.stringify({ app: 'x', output: 'out2' })); - expect(() => client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - })).not.toThrow(); - await new Promise((r) => setTimeout(r, 0)); + await initialize(c); - expect(client.log.error).toHaveBeenCalledWith(expect.stringContaining('compile err')); + // Diagnostics only appear if the assembly was actually found and indexed. + expect(c.published).toHaveLength(1); }); - test('shutdown completes without error', async () => { - const client = createTestClient(); - await initializeClient(client); - expect(() => client.handlers.onShutdown()).not.toThrow(); - }); + test('a watcher-detected re-synth refreshes diagnostics', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + expect(c.published).toHaveLength(0); - test('publishes diagnostics on initialized when assembly has violations', async () => { - const { tree, violations } = bucketViolationFixtures(); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(1); - expect(client.published[0].uri).toContain('stack.ts'); - expect(client.published[0].diagnostics).toHaveLength(1); + // The user fixed nothing; a re-synth introduced a violation. + fs.writeFileSync( + path.join(c.fixture.assemblyDir, 'validation-report.json'), + JSON.stringify({ version: '1.0.0', pluginReports: ONE_BUCKET_WITH_VIOLATION.reports }), + ); + c.triggerWatcher(); + await waitFor(() => c.published.length > 0); + + expect(c.published[0].diagnostics[0].code).toBe('no-public-buckets'); }); - test('acquires and releases the assembly lock around the assembly read', async () => { - const { tree, violations } = bucketViolationFixtures(); - const release = jest.fn(async () => { - }); - const acquireAssemblyLock = jest.fn(async () => ({ release })); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - acquireAssemblyLock, - }); - await initializeClient(client, { applicationDir: '/p' }); - expect(acquireAssemblyLock).toHaveBeenCalledWith(path.join('/p', 'cdk.out')); - expect(release).toHaveBeenCalledTimes(1); - expect(client.published).toHaveLength(1); // the read happened under the lock + test('clears a diagnostic for a violation resolved by a later synth', async () => { + const c = client({ assembly: ONE_BUCKET_WITH_VIOLATION }); + await initialize(c); + + fs.rmSync(path.join(c.fixture.assemblyDir, 'validation-report.json')); + c.triggerWatcher(); + await waitFor(() => c.published.length > 1); + + // An empty array for the URI, otherwise the squiggle would linger. + const forFile = c.published.filter((p) => p.uri === c.uri('lib/stack.ts')); + expect(forFile[forFile.length - 1].diagnostics).toEqual([]); }); - test('retries while a synth holds the write lock, then skips without error once retries are exhausted', async () => { - jest.useFakeTimers(); - try { - const { tree, violations } = bucketViolationFixtures(); - const acquireAssemblyLock = jest.fn(async () => { - throw new LockError('ConcurrentWriteLock', 'a synth is writing'); - }); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - acquireAssemblyLock, - }); - // onInitialized awaits refreshFromAssembly, which now polls for the lock. - // Drive the retry delays via fake timers rather than waiting in real time. - client.handlers.onInitialize({ - processId: null, - capabilities: {}, - rootUri: null, - initializationOptions: { applicationDir: '/p' }, - }); - const initialized = client.handlers.onInitialized(); - await jest.runAllTimersAsync(); - await initialized; - - expect(acquireAssemblyLock.mock.calls.length).toBeGreaterThan(1); // it retried - expect(client.published).toHaveLength(0); // never got the lock, so no read - expect(client.log.error).not.toHaveBeenCalled(); // contention is not an error - } finally { - jest.useRealTimers(); - } + test('requests a CodeLens refresh when the client supports it', async () => { + const c = client({ assembly: ONE_BUCKET }); + + await initialize(c, { workspace: { codeLens: { refreshSupport: true } } }); + + expect(c.refreshCodeLens).toHaveBeenCalled(); }); - test('retries the assembly lock on write-lock contention and refreshes once it clears', async () => { - jest.useFakeTimers(); - try { - const { tree, violations } = bucketViolationFixtures(); - const release = jest.fn(async () => { - }); - let calls = 0; - const acquireAssemblyLock = jest.fn(async () => { - calls += 1; - if (calls <= 3) throw new LockError('ConcurrentWriteLock', 'a synth is writing'); - return { release }; - }); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - acquireAssemblyLock, - }); - client.handlers.onInitialize({ - processId: null, - capabilities: {}, - rootUri: null, - initializationOptions: { applicationDir: '/p' }, - }); - const initialized = client.handlers.onInitialized(); - await jest.runAllTimersAsync(); - await initialized; - - expect(acquireAssemblyLock).toHaveBeenCalledTimes(4); // 3 contended + 1 success - expect(release).toHaveBeenCalledTimes(1); // released after the successful read - expect(client.published).toHaveLength(1); // the violation was published - expect(client.published[0].diagnostics).toHaveLength(1); - } finally { - jest.useRealTimers(); - } + test('does not request a CodeLens refresh when the client lacks refreshSupport', async () => { + const c = client({ assembly: ONE_BUCKET }); + + await initialize(c, {}); + + expect(c.refreshCodeLens).not.toHaveBeenCalled(); }); - test('skips silently when there is no assembly yet (ENOENT)', async () => { - const { tree, violations } = bucketViolationFixtures(); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - acquireAssemblyLock: async () => { - throw Object.assign(new Error('no cdk.out'), { code: 'ENOENT' }); - }, - }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(0); - expect(client.log.error).not.toHaveBeenCalled(); + test('closes the watcher on shutdown', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + + c.handlers.onShutdown(); + await waitFor(() => c.watcherClosed.mock.calls.length > 0); + + expect(c.watcherClosed).toHaveBeenCalled(); }); - test('logs and skips on an unexpected lock error', async () => { - const { tree, violations } = bucketViolationFixtures(); - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: [], tree, violations } }), - acquireAssemblyLock: async () => { - throw new Error('disk on fire'); - }, - }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(0); - expect(client.log.error).toHaveBeenCalled(); + test('shutdown completes without error', async () => { + const c = client(); + await initialize(c); + + expect(() => c.handlers.onShutdown()).not.toThrow(); }); +}); - test('responds to codeLens with header + resource lenses for the requested file', async () => { - const stackTs = '/p/lib/stack.ts'; - const stackUri = pathToFileURL(stackTs).toString(); - const client = createTestClient({ - readAssembly: async (): Promise => ({ - status: 'success', - data: { - warnings: [], - tree: [{ - path: 'Stack1', - id: 'Stack1', - children: [{ - path: 'Stack1/MyBucket/Resource', - id: 'Resource', - logicalId: 'MyBucketABC', - type: 'AWS::S3::Bucket', - sourceLocation: { file: stackTs, line: 12, column: 5 }, - children: [], - }], - }], - }, - }), - }); - await initializeClient(client, { applicationDir: '/p' }); - const lenses = await 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 +describe('codeLens', () => { + test('serves header and resource lenses for the requested file', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + + const lenses = await c.handlers.onCodeLens({ textDocument: { uri: c.uri('lib/stack.ts') } }); + + expect(lenses).toHaveLength(3); // 2 header + 1 resource + expect(lenses[0].command?.command).toBe(COMMAND_SYNTH_NOW); + expect(lenses[1].command?.command).toBe(COMMAND_ENABLE_AUTO_SYNTH); expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); - test('publishes nothing when assembly is not-found (pre-synth)', async () => { - const client = createTestClient({ readAssembly: async () => ({ status: 'not-found' }) }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(0); - }); + test('serves no lenses for a file with no constructs', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); - test('clears diagnostics for a violation resolved on a later refresh', async () => { - const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(1); - const violationUri = client.published[0].uri; - expect(client.published[0].diagnostics).toHaveLength(1); + expect(await c.handlers.onCodeLens({ textDocument: { uri: c.uri('bin/app.ts') } })).toEqual([]); + }); - // Simulate a re-synth picked up by the cdk.out watcher. - await client.triggerWatcher(); + test('serves no lenses before the app is synthesized', async () => { + const c = client(); + fs.rmSync(c.fixture.assemblyDir, { recursive: true, force: true }); + await initialize(c); - expect(client.published).toHaveLength(2); - expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); + expect(await c.handlers.onCodeLens({ textDocument: { uri: c.uri('lib/stack.ts') } })).toEqual([]); }); +}); - test('requests a CodeLens refresh after a refresh when the client supports it', async () => { - const client = createTestClient({ - readAssembly: async (): Promise => ({ - status: 'success', - data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, - }), +describe('onDefinition', () => { + test('resolves a template position back to the construct source', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + const text = c.fixture.templateText('Stack1'); + const typeLine = text.split('\n').findIndex((l) => l.includes('AWS::S3::Bucket')); + + const location = await c.handlers.onDefinition({ + textDocument: { uri: pathToFileURL(c.fixture.templatePath('Stack1')).toString() }, + position: { line: typeLine, character: 2 }, }); - await initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); - expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); - }); - test('does not request a CodeLens refresh when the client lacks refreshSupport', async () => { - const client = createTestClient({ - readAssembly: async (): Promise => ({ - status: 'success', - data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, - }), + expect(location).toEqual({ + uri: c.uri('lib/stack.ts'), + range: { start: { line: 11, character: 0 }, end: { line: 11, character: 0 } }, }); - await initializeClient(client, { applicationDir: '/p' }); - expect(client.refreshCodeLens).not.toHaveBeenCalled(); }); - test('a watcher-detected re-synth refreshes diagnostics and lenses', async () => { - const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - await initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); - expect(client.published).toHaveLength(1); - const violationUri = client.published[0].uri; - expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); + test('resolves a template in the dir cdk.json points at', async () => { + const c = client({ assembly: ONE_BUCKET }); + fs.renameSync(c.fixture.assemblyDir, path.join(c.fixture.appDir, 'out2')); + fs.writeFileSync(path.join(c.fixture.appDir, 'cdk.json'), JSON.stringify({ app: 'x', output: 'out2' })); + await initialize(c); + const templatePath = path.join(c.fixture.appDir, 'out2', 'Stack1.template.json'); + const text = fs.readFileSync(templatePath, 'utf-8'); + const typeLine = text.split('\n').findIndex((l) => l.includes('AWS::S3::Bucket')); - // Simulate a re-synth picked up by the cdk.out watcher. - await client.triggerWatcher(); + const location = await c.handlers.onDefinition({ + textDocument: { uri: pathToFileURL(templatePath).toString() }, + position: { line: typeLine, character: 2 }, + }); - expect(client.published).toHaveLength(2); - expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); - expect(client.refreshCodeLens).toHaveBeenCalledTimes(2); + expect(location?.uri).toBe(c.uri('lib/stack.ts')); }); - test('closes the cdk.out watcher on shutdown', async () => { - const client = createTestClient(); - await initializeClient(client, { applicationDir: '/p' }); - client.handlers.onShutdown(); - expect(client.watcherClosed).toHaveBeenCalledTimes(1); - }); + test('returns undefined for a document that is not a template', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); - test('onDefinition resolves a template position back to construct source', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'server-def-')); - const outDir = path.join(dir, 'cdk.out'); - fs.mkdirSync(outDir, { recursive: true }); - const templateFile = path.join(outDir, 'Stack1.template.json'); - const text = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); - fs.writeFileSync(templateFile, text); - try { - const client = createTestClient({ - readAssembly: async () => ({ - status: 'success', - data: { - tree: [{ - path: 'Stack1/MyBucket/Resource', - id: 'Resource', - logicalId: 'MyBucket', - type: 'AWS::S3::Bucket', - templateFile, - sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 }, - children: [], - }], - violations: [], - warnings: [], - }, - }), - }); - await 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 = await 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 }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('onDefinition returns undefined for a non-template document', async () => { - const client = createTestClient(); - await initializeClient(client, { applicationDir: '/p' }); - expect(await client.handlers.onDefinition({ - textDocument: { uri: pathToFileURL('/p/lib/stack.ts').toString() }, + expect(await c.handlers.onDefinition({ + textDocument: { uri: c.uri('lib/stack.ts') }, position: { line: 0, character: 0 }, })).toBeUndefined(); }); - test('onDefinition returns undefined (does not throw) for a non-file URI', async () => { - const client = createTestClient(); - await initializeClient(client, { applicationDir: '/p' }); - expect(await client.handlers.onDefinition({ - textDocument: { uri: 'untitled:Untitled-1' }, + test('returns undefined for a non-file URI rather than throwing', async () => { + // An untitled buffer or a diff view; fileURLToPath would throw on these. + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + + expect(await c.handlers.onDefinition({ + textDocument: { uri: 'untitled:Untitled-1.template.json' }, position: { line: 0, character: 0 }, })).toBeUndefined(); }); - test('onDefinition returns undefined for a template outside the project cdk.out', async () => { - const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'server-def-outside-')); - const templateFile = path.join(outside, 'Evil.template.json'); - fs.writeFileSync(templateFile, JSON.stringify({ Resources: {} })); - try { - const client = createTestClient(); - await initializeClient(client, { applicationDir: '/p' }); - const target = await client.handlers.onDefinition({ - textDocument: { uri: pathToFileURL(templateFile).toString() }, - position: { line: 0, character: 0 }, - }); - expect(target).toBeUndefined(); - } finally { - fs.rmSync(outside, { recursive: true, force: true }); - } + test('returns undefined for a template outside the project assembly dir', async () => { + const c = client({ assembly: ONE_BUCKET }); + const other = client({ assembly: ONE_BUCKET }); + await initialize(c); + + expect(await c.handlers.onDefinition({ + textDocument: { uri: pathToFileURL(other.fixture.templatePath('Stack1')).toString() }, + position: { line: 2, character: 2 }, + })).toBeUndefined(); }); }); -describe('LSP Server -- executeCommand', () => { - function createCommandClient(opts: Partial = {}): { - handlers: LspHandlers; - notify: NotifySink & { infoMessages: string[]; errorMessages: string[] }; - } { - const notify = makeNotifySink(); - const handlers = createLspHandlers({ - readAssembly: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - notify, - startAssemblyWatcher: (o) => { - return { - close: async () => { - }, - }; - }, - ...opts, +describe('onHover', () => { + test('renders a hover for a construct on the hovered line', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + + const hover = await c.handlers.onHover({ + textDocument: { uri: c.uri('lib/stack.ts') }, + position: { line: 11, character: 0 }, }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); - void handlers.onInitialized(); - return { handlers, notify }; - } - 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])); + expect((hover!.contents as { value: string }).value).toContain('AWS::S3::Bucket'); }); - 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); - }); + test('renders without links and logs when the template has gone missing', async () => { + // Caught between indexing and the hover, e.g. cdk.out wiped mid-session. + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + fs.rmSync(c.fixture.templatePath('Stack1')); - test('synthNow with success is silent', async () => { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); - const { handlers, notify } = createCommandClient({ synthRunner }); - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - expect(notify.infoMessages).toHaveLength(0); - expect(notify.errorMessages).toHaveLength(0); + const hover = await c.handlers.onHover({ + textDocument: { uri: c.uri('lib/stack.ts') }, + position: { line: 11, character: 0 }, + }); + + const value = (hover!.contents as { value: string }).value; + expect(value).toContain('AWS::S3::Bucket'); + expect(value).not.toContain(']('); + expect(c.log.error).toHaveBeenCalledWith(expect.stringContaining('Stack1.template.json')); }); - test('synthNow with app-failure notifies error', async () => { - const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile error' }); - const { handlers, notify } = createCommandClient({ synthRunner }); - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - expect(notify.errorMessages.some((m) => m.includes('compile error'))).toBe(true); + test('returns undefined for a line with no construct', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + + expect(await c.handlers.onHover({ + textDocument: { uri: c.uri('lib/stack.ts') }, + position: { line: 28, character: 0 }, + })).toBeUndefined(); }); +}); - test('synthNow in-flight latch: second concurrent call is suppressed 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({ synthRunner }); - - const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - resolveFirst(); - await first; - - expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); - // Queue of 1: the coalesced second call is not dropped -- it runs as a - // trailing synth once the first completes, so synthRunner runs twice. - expect(synthRunner).toHaveBeenCalledTimes(2); +describe('executeCommand synthNow', () => { + test('is silent on a successful synth', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'success' }); + await initialize(c); + + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + expect(c.toolkit.synthCount).toBe(1); + expect(c.notify.infoMessages).toEqual([]); + expect(c.notify.errorMessages).toEqual([]); }); - 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: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner, - notify, - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - void handlers.onInitialized(); + test('surfaces a failing app as both a notification and diagnostics', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'app-failure', message: 'compile err' }); + await initialize(c); - // Enable auto-synth so didSave triggers a synth - await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - // Start a save-triggered synth (first, holds the latch) - handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + // The notification says a synth failed at all; the diagnostics say where + // (anchored in the synth-failure diagnostics tests below). + expect(c.notify.errorMessages.some((m) => m.includes('compile err'))).toBe(true); + expect(c.published.some((pub) => pub.diagnostics.length > 0)).toBe(true); + }); + + test('reports an unexpected failure as an error, with no diagnostics to anchor it', async () => { + // A full disk, a lock we gave up waiting on, or a dispose failure is not an + // app problem, so there is no source location to point at. It still has to + // reach the user, or the synth silently never happened. + const c = client(); + c.toolkit.willSynth({ kind: 'error', message: 'disk full' }); + await initialize(c); - // Manual command fires while save-synth is in progress - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - resolveFirst(); - await new Promise((r) => setTimeout(r, 0)); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - // The manual command hit the latch → lock-conflict info message - expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); - // Queue of 1: the coalesced manual command runs as a trailing synth after - // the save-triggered synth completes, so synthRunner runs twice. - expect(synthRunner).toHaveBeenCalledTimes(2); + expect(c.notify.errorMessages.some((m) => m.includes('disk full'))).toBe(true); + expect(c.published.every((pub) => pub.diagnostics.length === 0)).toBe(true); }); - test('many overlapping calls coalesce into exactly one trailing synth', 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 } = createCommandClient({ synthRunner }); - - const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - // Several requests arrive while the first synth is in flight. - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - expect(synthRunner).toHaveBeenCalledTimes(1); - - resolveFirst(); - await first; - - // The three overlapping calls collapse into a single trailing synth. - expect(synthRunner).toHaveBeenCalledTimes(2); + test('explains a project with no app instead of reporting a failure', async () => { + // Nothing is configured to synth. That is worth saying out loud, since the + // command would otherwise look like it did nothing, but it is not an error. + const c = client({ cdkJson: { context: {} } }); + await initialize(c); + + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + expect(c.toolkit.synthCount).toBe(0); + expect(c.notify.infoMessages.some((m) => m.includes('No CDK app'))).toBe(true); + expect(c.notify.errorMessages).toEqual([]); + expect(c.log.error).not.toHaveBeenCalled(); + expect(c.published.every((pub) => pub.diagnostics.length === 0)).toBe(true); }); - test('retries the synth with backoff until an external write lock frees', async () => { - jest.useFakeTimers(); - try { - let calls = 0; - const synthRunner = jest.fn, []>(async () => { - calls += 1; - // Simulate a terminal `cdk synth` holding the lock for the first 3 tries. - return calls <= 3 ? { status: 'lock-conflict' } : { status: 'success' }; - }); - const { handlers } = createCommandClient({ synthRunner }); - - const done = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await jest.runAllTimersAsync(); // advance through the backoff delays - await done; - - // Retried through the 3 lock-conflicts rather than dropping the synth. - expect(synthRunner).toHaveBeenCalledTimes(4); - } finally { - jest.useRealTimers(); + test('coalesces calls arriving during a synth into one trailing synth', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'success' }).blockSynth(); + await initialize(c); + + const first = c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + // The synth starts asynchronously (it reads cdk.json first), so wait until it + // is actually in flight and held open by the gate. + await waitFor(() => c.toolkit.synthCount === 1); + + // Three more arrive while the first is still in flight. + const rest = [ + c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }), + c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }), + c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }), + ]; + await settle(); + expect(c.toolkit.synthCount).toBe(1); + + c.toolkit.releaseSynth(); + for (const pending of [first, ...rest]) { + await pending; } + + // The three collapse into a single follow-up, so the assembly catches up to + // the latest request without running three synths. + expect(c.toolkit.synthCount).toBe(2); + }); + + test('shares one latch with the save path', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'success' }).blockSynth(); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + + // A save starts a synth, then a manual command arrives mid-flight. + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await waitFor(() => c.toolkit.synthCount === 1); + const manual = c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + c.toolkit.releaseSynth(); + await manual; + + // The manual command did not start its own synth alongside the save's. + expect(c.toolkit.synthCount).toBe(2); + }); + + test('retries while an external writer holds the cdk.out lock', async () => { + // A terminal `cdk synth` holds the lock for the first three attempts. + const c = client(); + c.toolkit.willSynth( + { kind: 'locked' }, + { kind: 'locked' }, + { kind: 'locked' }, + { kind: 'success' }, + ); + await initialize(c); + + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + // Retried rather than dropping the user's synth. + expect(c.toolkit.synthCount).toBe(4); + expect(c.notify.errorMessages).toEqual([]); }); }); -describe('LSP Server -- auto-synth toggle', () => { - function createToggleClient(synthAvailable = true) { - 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: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner, - logger: log, - onRefreshCodeLenses: refreshCodeLens, - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - void handlers.onInitialized(); - return { handlers, synthRunner, refreshCodeLens, log }; - } +describe('auto-synth on save', () => { + test('synths a saved source file once auto-synth is enabled', async () => { + const c = client(); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); - 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: async () => ({ status: 'success', data: { warnings: [], tree: treeWithResource } }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner: jest.fn, []>().mockResolvedValue({ status: 'success' }), - onRefreshCodeLenses: jest.fn(), - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - await handlers.onInitialized(); + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await waitFor(() => c.toolkit.synthCount === 1); - // Before toggle: 2 header lenses (Synth now + Enable auto-synth) - const before = await handlers.onCodeLens({ textDocument: { uri: stackUri } }); - expect(before[0].command?.title).toBe('↻ Synth now'); - expect(before[1].command?.title).toBe('▶ Enable auto-synth'); + expect(c.toolkit.appSynthCalls[0]).toBe(c.fixture.appDir); + }); - // Toggle on - await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + test('does not synth while auto-synth is disabled, which is the default', async () => { + const c = client(); + await initialize(c); - // After toggle: 1 header lens (Disable auto-synth) - const after = await handlers.onCodeLens({ textDocument: { uri: stackUri } }); - expect(after[0].command?.title).toBe('⏹ Disable auto-synth'); - expect(after).toHaveLength(2); // 1 header + 1 L1 + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await settle(); + + expect(c.toolkit.synthCount).toBe(0); }); - 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.each([ + ['a dependency', 'node_modules/foo/index.ts'], + ['the assembly itself', 'cdk.out/tree.json'], + ])('does not synth when the saved file is %s', async (_what, relPath) => { + const c = client(); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri(relPath) } }); + await settle(); + + expect(c.toolkit.synthCount).toBe(0); }); - 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: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner, - logger: log, - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - await 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)); - - // 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('ignores a save after shutdown', async () => { + const c = client(); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + + c.handlers.onShutdown(); + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await settle(); + + expect(c.toolkit.synthCount).toBe(0); }); - 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: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner, - logger: log, - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - await handlers.onInitialized(); - await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + test('stays silent on the save path when there is no app to synth', async () => { + // Not the user's problem: nothing is configured to synth. + const c = client({ cdkJson: { context: {} } }); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); - handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); - await new Promise((r) => setTimeout(r, 0)); + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await settle(); - expect(log.error).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalled(); + expect(c.log.error).not.toHaveBeenCalled(); + expect(c.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: async () => ({ status: 'not-found' }), - acquireAssemblyLock: mockAssemblyLock, - synthRunner, - logger: log, - startAssemblyWatcher: () => ({ - close: async () => { - }, - }), - }); - handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); - await handlers.onInitialized(); - await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); +describe('auto-synth toggle', () => { + test('swaps the header lenses when toggled', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); - handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); - await new Promise((r) => setTimeout(r, 0)); + const before = await c.handlers.onCodeLens({ textDocument: { uri: c.uri('lib/stack.ts') } }); + expect(before[0].command?.title).toBe('↻ Synth now'); + expect(before[1].command?.title).toBe('▶ Enable auto-synth'); + + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + + const after = await c.handlers.onCodeLens({ textDocument: { uri: c.uri('lib/stack.ts') } }); + expect(after).toHaveLength(2); // 1 header + 1 resource + expect(after[0].command?.title).toBe('⏹ Disable auto-synth'); + }); + + test('asks the editor to re-query lenses on each toggle', async () => { + const c = client(); + await initialize(c); + + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + expect(c.refreshCodeLens).toHaveBeenCalledTimes(1); + + await c.handlers.onExecuteCommand({ command: COMMAND_DISABLE_AUTO_SYNTH }); + expect(c.refreshCodeLens).toHaveBeenCalledTimes(2); + }); + + test('disabling stops saves from synthing', async () => { + const c = client(); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_ENABLE_AUTO_SYNTH }); + await c.handlers.onExecuteCommand({ command: COMMAND_DISABLE_AUTO_SYNTH }); + + c.handlers.onDidSaveTextDocument({ textDocument: { uri: c.uri('lib/stack.ts') } }); + await settle(); - expect(log.error).toHaveBeenCalledWith(expect.stringContaining('unexpected failure')); + expect(c.toolkit.synthCount).toBe(0); }); }); -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', +describe('synth-failure diagnostics', () => { + test('anchors a failure on the source file it names', async () => { + const c = client(); + c.toolkit.willSynth({ + kind: 'app-failure', message: 'Subprocess exited with error 1', details: 'lib/stack.ts:12:5 - error TS2322: nope', }); - const client = createTestClient({ synthRunner }); - await initializeClient(client, { applicationDir: '/p' }); + await initialize(c); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - const onFile = client.published.find((p) => p.uri === pathToFileURL('/p/lib/stack.ts').toString()); + const onFile = c.published.find((pub) => pub.uri === c.uri('lib/stack.ts')); 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', + test('publishes one diagnostic per failing file', async () => { + const c = client(); + c.toolkit.willSynth({ + kind: '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 }); - await initializeClient(client, { applicationDir: '/p' }); + await initialize(c); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await c.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); + expect(c.published.find((pub) => pub.uri === c.uri('lib/stack.ts'))?.diagnostics).toHaveLength(1); + expect(c.published.find((pub) => pub.uri === c.uri('bin/app.ts'))?.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 }); - await initializeClient(client, { applicationDir: '/p' }); + test('falls back to cdk.json when the failure names no location', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'app-failure', message: 'context needed' }); + await initialize(c); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - const onCdkJson = client.published.find((p) => p.uri === pathToFileURL('/p/cdk.json').toString()); + const onCdkJson = c.published.find((pub) => pub.uri === c.uri('cdk.json')); 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 }); - await initializeClient(client, { applicationDir: '/p' }); + test('clears a prior failure once a synth succeeds', async () => { + const c = client(); + c.toolkit.willSynth( + { kind: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }, + { kind: 'success' }, + ); + await initialize(c); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await c.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([]); + const forFile = c.published.filter((pub) => pub.uri === c.uri('lib/stack.ts')); + expect(forFile[forFile.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 }); - await initializeClient(client, { applicationDir: '/p' }); + test('clears a prior failure once there is no app left to synth', async () => { + const c = client(); + c.toolkit.willSynth({ kind: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }); + await initialize(c); + await c.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); - await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + // The app command is removed, so the previous failure no longer applies. + fs.writeFileSync(path.join(c.fixture.appDir, 'cdk.json'), JSON.stringify({ context: {} })); + await c.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([]); + const forFile = c.published.filter((pub) => pub.uri === c.uri('lib/stack.ts')); + expect(forFile[forFile.length - 1].diagnostics).toEqual([]); }); }); -describe('getConstructTree request', () => { - const emptyViolations = { version: '1.0.0', pluginReports: [] }; - - it('flattens the source-resolved tree in pre-order', async () => { - const tree = [ - { - path: 'Stack1', - id: 'Stack1', - children: [ - { - path: 'Stack1/MyBucket', - id: 'MyBucket', - type: 'AWS::S3::Bucket', - logicalId: 'MyBucket123', - templateFile: path.join('/p', 'cdk.out', 'Stack1.template.json'), - sourceLocation: { file: '/p/lib/stack.ts', line: 12, column: 5 }, - children: [], - }, - ], - }, - ]; - const client = createTestClient({ - readAssembly: async () => ({ status: 'success', data: { warnings: ['bad source map'], tree, violations: emptyViolations } }), - }); - await initializeClient(client, { applicationDir: '/p' }); +describe('getConstructTree', () => { + test('flattens the tree in pre-order with resolved source locations', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); - const result = client.handlers.onGetConstructTree(); + const result = await c.handlers.onGetConstructTree(); expect(result.status).toBe('ok'); - expect(result.assemblyDir).toBe(path.join('/p', 'cdk.out')); - expect(result.warnings).toEqual(['bad source map']); - // Pre-order: parent before child. - expect(result.entries.map((e) => e.path)).toEqual(['Stack1', 'Stack1/MyBucket']); - const bucket = result.entries.find((e) => e.path === 'Stack1/MyBucket'); - expect(bucket).toMatchObject({ - id: 'MyBucket', + expect(result.assemblyDir).toBe(c.fixture.assemblyDir); + // Parent before child. + expect(result.entries.map((e) => e.path)).toEqual(['Stack1', 'Stack1/MyBucket', 'Stack1/MyBucket/Resource']); + expect(result.entries.find((e) => e.path === 'Stack1/MyBucket/Resource')).toMatchObject({ + id: 'Resource', type: 'AWS::S3::Bucket', logicalId: 'MyBucket123', - sourceLocation: { file: '/p/lib/stack.ts', line: 12, column: 5 }, - templateFile: path.join('/p', 'cdk.out', 'Stack1.template.json'), + // This is the LSP's wire format for an external client (the AWS Toolkit + // tree), so paths are absolute and directly openable — unlike the web + // protocol, which relativizes to the app dir. + sourceLocation: { file: c.fixture.sourcePath('lib/stack.ts'), line: 12 }, + templateFile: c.fixture.templatePath('Stack1'), }); }); - it('includes the template offset of the resource block', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'server-tree-')); - const outDir = path.join(dir, 'cdk.out'); - fs.mkdirSync(outDir, { recursive: true }); - const templateFile = path.join(outDir, 'Stack1.template.json'); - const text = JSON.stringify({ Resources: { MyBucket123: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); - fs.writeFileSync(templateFile, text); - try { - const client = createTestClient({ - readAssembly: async () => ({ - status: 'success', - data: { - tree: [{ - path: 'Stack1/MyBucket', - id: 'MyBucket', - type: 'AWS::S3::Bucket', - logicalId: 'MyBucket123', - templateFile, - children: [], - }], - violations: emptyViolations, - warnings: [], - }, - }), - }); - await initializeClient(client, { applicationDir: dir }); - - const result = client.handlers.onGetConstructTree(); - - // The offset is the start of the resource's value block: the first `{` - // after the logical id key. - const expectedStart = text.indexOf('{', text.indexOf('"MyBucket123"')); - const bucket = result.entries.find((e) => e.path === 'Stack1/MyBucket'); - expect(bucket?.templateOffset).toBe(expectedStart); - expect(text[bucket!.templateOffset!]).toBe('{'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + test('carries the template offset of each resource block', async () => { + const c = client({ assembly: ONE_BUCKET }); + await initialize(c); + const text = c.fixture.templateText('Stack1'); + + const result = await c.handlers.onGetConstructTree(); + const resource = result.entries.find((e) => e.path === 'Stack1/MyBucket/Resource')!; + + // The offset is the start of the resource's value block, so a client can + // open the template positioned on it. + expect(resource.templateOffset).toBe(text.indexOf('{', text.indexOf('"MyBucket123"'))); + expect(text[resource.templateOffset!]).toBe('{'); }); - it('reports no-assembly before the app is synthesized', async () => { - const client = createTestClient({ readAssembly: async () => ({ status: 'not-found' }) }); - await initializeClient(client, { applicationDir: '/p' }); + test('reports no-assembly before the app is synthesized', async () => { + const c = client(); + fs.rmSync(c.fixture.assemblyDir, { recursive: true, force: true }); + await initialize(c); - const result = client.handlers.onGetConstructTree(); + const result = await c.handlers.onGetConstructTree(); expect(result.status).toBe('no-assembly'); expect(result.entries).toEqual([]); - expect(result.assemblyDir).toBe(path.join('/p', 'cdk.out')); }); }); 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 index dec4543f7..5011d50fb 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts @@ -83,8 +83,9 @@ describe('synthFailureDiagnostics', () => { }); test('returns empty for non-app-failure outcomes', () => { + // Only an app failure names source locations; the rest have nothing to anchor. expect(synthFailureDiagnostics({ status: 'success' }, '/p')).toEqual([]); - expect(synthFailureDiagnostics({ status: 'lock-conflict' }, '/p')).toEqual([]); + expect(synthFailureDiagnostics({ status: 'unavailable' }, '/p')).toEqual([]); expect(synthFailureDiagnostics({ status: 'error', message: 'x' }, '/p')).toEqual([]); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts index ef6b6cf4e..9ddfdf4a3 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts @@ -1,187 +1,185 @@ import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; import { pathToFileURL } from 'url'; -import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type Range } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { readAssembly, type ConstructNode } from '../../lib'; +import { assertRelativePath, type AssemblyResourceNode } from '../../lib/core/assembly-index-with-source-locations'; import { resourceTarget, sourceTargetAtTemplateOffset } from '../../lib/lsp/template-locator'; -import { buildFlatAssembly, cleanupFixture } from '../_fixtures/builders'; - -// A synthesized-style template where MyBucketF68F3FF0 is defined once and also -// referenced (Ref + DependsOn) -- the references must not be matched. -const TEMPLATE = JSON.stringify( - { - Resources: { - MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket' }, - MyPolicy3A1B2C3D: { - Type: 'AWS::IAM::Policy', - Properties: { Bucket: { Ref: 'MyBucketF68F3FF0' } }, - DependsOn: ['MyBucketF68F3FF0'], - }, - }, - }, - undefined, - 1, -); - -/** Re-parse the substring that a returned range selects, so assertions need no hand-computed offsets. */ +import { AssemblyFixture } from '../_helpers/assembly'; + +let fixture: AssemblyFixture | undefined; + +afterEach(() => { + fixture?.cleanup(); + fixture = undefined; +}); + +/** Re-parse the substring a range selects, so assertions need no hand-computed offsets. */ function sliceParse(text: string, range: Range): unknown { const doc = TextDocument.create('', 'json', 0, text); return JSON.parse(text.slice(doc.offsetAt(range.start), doc.offsetAt(range.end))); } -/** Write a template to a throwaway file and return its path. */ -function writeTemplate(contents: string): { dir: string; file: string } { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'locator-')); - const file = path.join(dir, 'T.template.json'); - fs.writeFileSync(file, contents); - return { dir, file }; +function resourceNode(logicalId: string, type: string, templateFile: string): AssemblyResourceNode { + return { + constructPath: `S/${logicalId}/Resource`, + id: 'Resource', + cfnResource: { logicalId, type, assemblyRelativeTemplateFile: assertRelativePath(templateFile) }, + }; } describe('resourceTarget', () => { - let dir: string | undefined; + test('selects exactly the resource value block', async () => { + fixture = AssemblyFixture.create({ + stacks: [{ + id: 'S', + resources: [ + { path: 'Bucket/Resource', logicalId: 'MyBucketF68F3FF0', type: 'AWS::S3::Bucket' }, + { path: 'Policy/Resource', logicalId: 'MyPolicy3A1B2C3D', type: 'AWS::IAM::Policy', props: { Bucket: { Ref: 'MyBucketF68F3FF0' } } }, + ], + }], + }); - afterEach(() => { - cleanupFixture(dir); - dir = undefined; - }); + const target = (await resourceTarget(fixture.assemblyDir, resourceNode('MyBucketF68F3FF0', 'AWS::S3::Bucket', 'S.template.json')))!; - const find = (nodes: readonly ConstructNode[], targetPath: string): ConstructNode | undefined => { - for (const node of nodes) { - if (node.path === targetPath) return node; - const hit = find(node.children, targetPath); - if (hit) return hit; - } - return undefined; - }; + expect(target.uri).toBe(pathToFileURL(fixture.templatePath('S')).toString()); + // Slicing the range back out must yield the resource object itself. + expect(sliceParse(fixture.templateText('S'), target.range)).toEqual({ Type: 'AWS::S3::Bucket' }); + }); - test('resolves a resource node to its template uri and block range', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }], + test('matches the definition, not a Ref or DependsOn mentioning the same id', async () => { + fixture = AssemblyFixture.create({ + stacks: [{ + id: 'S', + resources: [ + { path: 'Bucket/Resource', logicalId: 'MyBucket', type: 'AWS::S3::Bucket' }, + // A resource that references the bucket by logical id. + { path: 'Policy/Resource', logicalId: 'MyPolicy', type: 'AWS::IAM::Policy', props: { Bucket: { Ref: 'MyBucket' } } }, + ], + }], }); - const result = await readAssembly(dir); - if (result.status !== 'success') throw new Error('expected success'); - const node = find(result.data.tree, 'Stack1/MyBucket/Resource')!; - const templateFile = path.join(dir!, 'Stack1.template.json'); - - const target = (await resourceTarget(node))!; - expect(target.uri).toBe(pathToFileURL(templateFile).toString()); - // The range spans the resource block (not a zero-width cursor) and re-parses to it. - const text = fs.readFileSync(templateFile, 'utf-8'); - expect(target.range.start).not.toEqual(target.range.end); - expect(sliceParse(text, target.range)).toEqual(JSON.parse(text).Resources.MyBucketF68F3FF0); - }); - test('returns undefined for a node without a resolved templateFile', async () => { - expect(await resourceTarget({ templateFile: undefined, logicalId: 'MyBucketF68F3FF0' })).toBeUndefined(); + const target = (await resourceTarget(fixture.assemblyDir, resourceNode('MyBucket', 'AWS::S3::Bucket', 'S.template.json')))!; + + expect(sliceParse(fixture.templateText('S'), target.range)).toEqual({ Type: 'AWS::S3::Bucket' }); }); - test('returns undefined when the logical id is not in the resolved template', async () => { - dir = buildFlatAssembly({ - stacks: [{ id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }], + test('resolves distinct ranges for two resources in one template', async () => { + fixture = AssemblyFixture.create({ + stacks: [{ + id: 'S', + resources: [ + { path: 'A/Resource', logicalId: 'A1', type: 'AWS::S3::Bucket' }, + { path: 'B/Resource', logicalId: 'B1', type: 'AWS::SQS::Queue' }, + ], + }], }); - expect(await resourceTarget({ templateFile: path.join(dir, 'Stack1.template.json'), logicalId: 'GhostId' })).toBeUndefined(); + + const a = (await resourceTarget(fixture.assemblyDir, resourceNode('A1', 'AWS::S3::Bucket', 'S.template.json')))!; + const b = (await resourceTarget(fixture.assemblyDir, resourceNode('B1', 'AWS::SQS::Queue', 'S.template.json')))!; + + expect(a.range).not.toEqual(b.range); + expect(sliceParse(fixture.templateText('S'), a.range)).toEqual({ Type: 'AWS::S3::Bucket' }); + expect(sliceParse(fixture.templateText('S'), b.range)).toEqual({ Type: 'AWS::SQS::Queue' }); }); - test('returns undefined (does not throw) when the template can no longer be read', async () => { - expect(await resourceTarget({ templateFile: '/no/such/template.json', logicalId: 'MyBucketF68F3FF0' })).toBeUndefined(); + test('returns undefined when the template file is missing', async () => { + fixture = AssemblyFixture.create({ stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }] }); + + expect(await resourceTarget(fixture.assemblyDir, resourceNode('B1', 'AWS::S3::Bucket', 'Absent.template.json'))).toBeUndefined(); }); - test('resolves the definition block, not Ref/DependsOn occurrences of the same id', async () => { - const written = writeTemplate(TEMPLATE); - dir = written.dir; - // MyBucketF68F3FF0 is defined once and also referenced; the block must be the definition. - const target = (await resourceTarget({ templateFile: written.file, logicalId: 'MyBucketF68F3FF0' }))!; - expect(sliceParse(TEMPLATE, target.range)).toEqual({ Type: 'AWS::S3::Bucket' }); + test('returns undefined when the template does not parse', async () => { + fixture = AssemblyFixture.create({ stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }] }); + fs.writeFileSync(fixture.templatePath('S'), '{ not json'); + + expect(await resourceTarget(fixture.assemblyDir, resourceNode('B1', 'AWS::S3::Bucket', 'S.template.json'))).toBeUndefined(); }); - test('does not match a logical id that is a prefix of a longer key', async () => { - const contents = JSON.stringify( - { - Resources: { - MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket' }, - MyBucket: { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'short' } }, - }, - }, - undefined, - 1, - ); - const written = writeTemplate(contents); - dir = written.dir; - // The shorter id must resolve to its own (distinct) block, not the longer-named sibling. - const target = (await resourceTarget({ templateFile: written.file, logicalId: 'MyBucket' }))!; - expect(sliceParse(contents, target.range)).toEqual({ Type: 'AWS::S3::Bucket', Properties: { BucketName: 'short' } }); + test('returns undefined when the logical id is absent from the template', async () => { + fixture = AssemblyFixture.create({ stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }] }); + + expect(await resourceTarget(fixture.assemblyDir, resourceNode('NotInTemplate', 'AWS::S3::Bucket', 'S.template.json'))).toBeUndefined(); }); }); describe('sourceTargetAtTemplateOffset', () => { - let dir: string | undefined; - afterEach(() => { - cleanupFixture(dir); - dir = undefined; + /** An app whose bucket is created at lib/stack.ts:12:5. */ + async function appWithBucket() { + fixture = AssemblyFixture.create({ + sourceFiles: { 'lib/stack.ts': '\n'.repeat(20) }, + stacks: [{ + id: 'S', + resources: [ + { path: 'Bucket/Resource', logicalId: 'MyBucket', type: 'AWS::S3::Bucket', at: 'lib/stack.ts:12:5' }, + { path: 'Queue/Resource', logicalId: 'MyQueue', type: 'AWS::SQS::Queue', at: 'lib/stack.ts:18:3' }, + ], + }], + }); + return fixture.index(); + } + + test('resolves an offset inside a resource block to its construct source', async () => { + const index = await appWithBucket(); + const text = fixture!.templateText('S'); + + const target = sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('AWS::S3::Bucket'))!; + + // 1-based source location to 0-based LSP. Only the line is known, so the + // range collapses to the start of that line. + expect(target.uri).toBe(pathToFileURL(fixture!.sourcePath('lib/stack.ts')).toString()); + expect(target.range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 } }); }); - const indexWith = (overrides: Partial & { path: string }) => - ConstructIndex.fromTree([{ id: overrides.path.split('/').pop()!, children: [], ...overrides }]); - - test('resolves a template offset back to the construct source location', () => { - const contents = JSON.stringify( - { Resources: { MyBucket: { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'b' } } } }, - undefined, - 1, - ); - const written = writeTemplate(contents); - dir = written.dir; - const index = indexWith({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucket', - type: 'AWS::S3::Bucket', - templateFile: written.file, - sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 }, - }); + test('accepts the absolute template path the LSP has on hand', async () => { + // The language server only knows the document's absolute path; the locator + // must translate it to the assembly-relative key the index uses. + const index = await appWithBucket(); + const text = fixture!.templateText('S'); - const offset = contents.indexOf('AWS::S3::Bucket'); // inside the MyBucket block - const target = sourceTargetAtTemplateOffset(index, written.file, contents, offset)!; - expect(target.uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); - // 1-based source location (5, 3) maps to a 0-based LSP position (4, 2). - expect(target.range).toEqual({ start: { line: 4, character: 2 }, end: { line: 4, character: 2 } }); + expect(sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('AWS::SQS::Queue'))!.range.start) + .toEqual({ line: 17, character: 0 }); }); - test('returns undefined for an offset outside any resource block', () => { - const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); - const index = indexWith({ - path: 'Stack1/MyBucket/Resource', - logicalId: 'MyBucket', - templateFile: '/x/T.template.json', - sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 }, - }); - expect(sourceTargetAtTemplateOffset(index, '/x/T.template.json', contents, 0)).toBeUndefined(); + test('distinguishes the resource the offset falls in', async () => { + const index = await appWithBucket(); + const text = fixture!.templateText('S'); + + const atBucket = sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('AWS::S3::Bucket'))!; + const atQueue = sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('AWS::SQS::Queue'))!; + + expect(atBucket.range.start.line).toBe(11); + expect(atQueue.range.start.line).toBe(17); + }); + + test('returns undefined for an offset outside any resource block', async () => { + const index = await appWithBucket(); + const text = fixture!.templateText('S'); + + // The "Resources" key itself, not inside a resource value. + expect(sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('"Resources"'))).toBeUndefined(); + }); + + test('returns undefined when the text does not parse', async () => { + const index = await appWithBucket(); + + expect(sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), '{ not json', 3)).toBeUndefined(); }); - test('returns undefined when the owning construct has no source location', () => { - const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); - const offset = contents.indexOf('AWS::S3::Bucket'); - const index = indexWith({ path: 'Stack1/MyBucket/Resource', logicalId: 'MyBucket', templateFile: '/x/T.template.json' }); - expect(sourceTargetAtTemplateOffset(index, '/x/T.template.json', contents, offset)).toBeUndefined(); + test('returns undefined when no construct owns the resource', async () => { + const index = await appWithBucket(); + // A template that parses but whose logical id is not in the index. + const text = JSON.stringify({ Resources: { Unknown: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); + + expect(sourceTargetAtTemplateOffset(index, fixture!.templatePath('S'), text, text.indexOf('AWS::S3::Bucket'))).toBeUndefined(); }); - test('matches on templateFile, not logical id alone (cross-template collision)', () => { - const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); - const written = writeTemplate(contents); - dir = written.dir; - // Two constructs share the logical id 'MyBucket' across different templates; - // logical ids are only unique within a template, so the lookup must also key - // on templateFile. - const index = ConstructIndex.fromTree([ - { path: 'StackA/MyBucket/Resource', id: 'Resource', logicalId: 'MyBucket', templateFile: written.file, sourceLocation: { file: '/p/a.ts', line: 2, column: 1 }, children: [] }, - { path: 'StackB/MyBucket/Resource', id: 'Resource', logicalId: 'MyBucket', templateFile: '/other/Stack2.template.json', sourceLocation: { file: '/p/b.ts', line: 9, column: 1 }, children: [] }, - ]); - - const target = sourceTargetAtTemplateOffset(index, written.file, contents, contents.indexOf('AWS::S3::Bucket'))!; - // Resolves to template A's construct (a.ts), not B's, despite the shared id. - expect(target.uri).toBe(pathToFileURL('/p/a.ts').toString()); - expect(target.range.start).toEqual({ line: 1, character: 0 }); + test('returns undefined when the owning construct has no source location', async () => { + fixture = AssemblyFixture.create({ + stacks: [{ id: 'S', resources: [{ path: 'B/Resource', logicalId: 'B1', type: 'AWS::S3::Bucket' }] }], + }); + const index = await fixture.index(); + const text = fixture.templateText('S'); + + expect(sourceTargetAtTemplateOffset(index, fixture.templatePath('S'), text, text.indexOf('AWS::S3::Bucket'))).toBeUndefined(); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/tsconfig.json b/packages/@aws-cdk/cdk-explorer/tsconfig.json index 8874ce8bf..54d15fa67 100644 --- a/packages/@aws-cdk/cdk-explorer/tsconfig.json +++ b/packages/@aws-cdk/cdk-explorer/tsconfig.json @@ -29,6 +29,7 @@ "types": [ "convert-source-map", "jest", + "json-source-map", "node" ], "incremental": true, diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts index 7cca9d0ec..f3306e022 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts @@ -17,6 +17,12 @@ const ownedLockFiles = new Set(); */ let readCounter = 0; +/** + * The marker file a writer (i.e. a synth) creates in the locked directory for + * the duration of the write. + */ +export const SYNTH_LOCK_FILE = 'synth.lock'; + /** * A single-writer/multi-reader lock on a directory * @@ -35,7 +41,7 @@ export class RWLock { constructor(public readonly directory: string) { this.pidString = `${process.pid}`; - this.writerFile = path.resolve(this.directory, 'synth.lock'); + this.writerFile = path.resolve(this.directory, SYNTH_LOCK_FILE); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts index fcb073fef..46e5f1757 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts @@ -1 +1,2 @@ export * from './types'; +export { findCreationStackTrace, findMutationStackTraces } from './private/stack-source-tracing'; diff --git a/yarn.lock b/yarn.lock index a8b2744ba..5c10ab33b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -181,6 +181,7 @@ __metadata: "@stylistic/eslint-plugin": "npm:^3" "@types/convert-source-map": "npm:^2" "@types/jest": "npm:^29.5.14" + "@types/json-source-map": "npm:^0.6.0" "@types/node": "npm:^20" "@typescript-eslint/eslint-plugin": "npm:^8" "@typescript-eslint/parser": "npm:^8" @@ -196,7 +197,9 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.5" jest: "npm:^29.7.0" jest-junit: "npm:^16" + json-source-map: "npm:^0.6.1" nx: "npm:^22.7.12" + p-limit: "npm:^3" prettier: "npm:^2.8" projen: "npm:^0.104.8" ts-jest: "npm:^29.4.15" @@ -205,6 +208,8 @@ __metadata: vscode-languageserver: "npm:^9" vscode-languageserver-protocol: "npm:^3" vscode-languageserver-textdocument: "npm:^1" + bin: + lsp: bin/lsp languageName: unknown linkType: soft From 6726801815407bdebf4ae93ab8343019a0b4c562 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 09:34:00 +0200 Subject: [PATCH 02/16] Remove unused exports --- packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts index 46e5f1757..fcb073fef 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/source-tracing/index.ts @@ -1,2 +1 @@ export * from './types'; -export { findCreationStackTrace, findMutationStackTraces } from './private/stack-source-tracing'; From 0620bc64650aa958f3e58cc8a1ffb5948aa6b725 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 10:42:47 +0200 Subject: [PATCH 03/16] Remove onSynthActivity --- .../@aws-cdk/cdk-explorer/lib/api-private.ts | 1 - .../cdk-explorer/lib/core/assembly-watcher.ts | 18 +------------ .../test/core/assembly-watcher.test.ts | 25 +------------------ .../@aws-cdk/toolkit-lib/lib/api/rwlock.ts | 2 +- 4 files changed, 3 insertions(+), 43 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts index 811a6e738..006d91e2d 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts @@ -2,4 +2,3 @@ export { findCreationStackTrace } from '../../toolkit-lib/lib/api/source-tracing/private/stack-source-tracing'; export { WATCH_EXCLUDE_DEFAULTS } from '../../toolkit-lib/lib/actions/watch/private/helpers'; export { createIgnoreMatcher } from '../../toolkit-lib/lib/util/glob-matcher'; -export { SYNTH_LOCK_FILE } from '../../toolkit-lib/lib/api/rwlock'; diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts index 03c839408..3b8d8e22c 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts @@ -2,7 +2,6 @@ import * as path from 'path'; import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; import { VALIDATION_REPORT_FILE } from '@aws-cdk/cloud-assembly-schema'; import * as chokidar from 'chokidar'; -import { SYNTH_LOCK_FILE } from '../api-private'; /** * The name of the construct tree metadata file emitted alongside the manifest. @@ -52,12 +51,6 @@ export interface AssemblyWatcherOptions { readonly assemblyDir: string; /** Invoked (debounced) when the assembly's signal files change. */ readonly onChange: () => void; - /** - * Invoked when synth write-lock activity (`synth.lock`) first appears, i.e. a - * synth started. `atMs` is the observation time. Lets a consumer date-stamp - * source-file staleness against synth start rather than synth finish. - */ - readonly onSynthActivity?: (atMs: number) => void; /** Receives non-fatal watcher errors. */ readonly onError?: (error: unknown) => void; /** @@ -97,18 +90,9 @@ export function startAssemblyWatcher(options: AssemblyWatcherOptions): AssemblyW const watcher = createWatcher(options.assemblyDir); - watcher.on('all', (eventName, filePath) => { + watcher.on('all', (_eventName, filePath) => { if (closed) return; const base = path.basename(filePath); - if (base === SYNTH_LOCK_FILE) { - // toolkit-lib's `RWLock` creates this marker for the duration of a synth, - // so its appearance is our only signal that a synth has started (the - // explorer never starts synths itself). Record it, but never treat it as - // an assembly refresh: the lock's create/delete would otherwise cause - // spurious reloads. - if (eventName === 'add') options.onSynthActivity?.(Date.now()); - return; - } if (!ASSEMBLY_SIGNAL_FILES.has(base)) return; if (timer) { clearTimeout(timer); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts index 2795b654a..fabfaa472 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/assembly-watcher.test.ts @@ -44,15 +44,13 @@ function setup() { const fake = new FakeWatcher(); const onChange = jest.fn(); const onError = jest.fn(); - const onSynthActivity = jest.fn(); const watcher = startAssemblyWatcher({ assemblyDir: '/p/cdk.out', onChange, onError, - onSynthActivity, createWatcher: () => fake, }); - return { fake, onChange, onError, onSynthActivity, watcher }; + return { fake, onChange, onError, watcher }; } describe('Assembly Watcher', () => { @@ -82,27 +80,6 @@ describe('Assembly Watcher', () => { expect(onChange).not.toHaveBeenCalled(); }); - test('reports the write lock (synth.lock) appearing as synth activity', () => { - const { fake, onChange, onSynthActivity } = setup(); - - fake.emitFile('add', '/p/cdk.out/synth.lock'); - - expect(onSynthActivity).toHaveBeenCalledTimes(1); - expect(onSynthActivity).toHaveBeenCalledWith(expect.any(Number)); - // The write lock is never an assembly refresh. - jest.advanceTimersByTime(500); - expect(onChange).not.toHaveBeenCalled(); - }); - - test('does not report synth activity for a read lock or a lock removal', () => { - const { fake, onSynthActivity } = setup(); - - fake.emitFile('add', '/p/cdk.out/read.12345.1.lock'); - fake.emitFile('unlink', '/p/cdk.out/synth.lock'); - - expect(onSynthActivity).not.toHaveBeenCalled(); - }); - test('ignores non-signal files such as templates', () => { const { fake, onChange } = setup(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts index f3306e022..2699f07f6 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts @@ -21,7 +21,7 @@ let readCounter = 0; * The marker file a writer (i.e. a synth) creates in the locked directory for * the duration of the write. */ -export const SYNTH_LOCK_FILE = 'synth.lock'; +const SYNTH_LOCK_FILE = 'synth.lock'; /** * A single-writer/multi-reader lock on a directory From f378687c834f7ab4586da132675e94e5841c2760 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 10:49:13 +0200 Subject: [PATCH 04/16] Update abort handling code --- .../@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts | 4 +--- packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts b/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts index c0716a638..1765ddfde 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts @@ -40,15 +40,13 @@ export async function retryOnLock(abort: AbortSignal, fn: () => Promise { const result = await run(toolkit, makeProjectDir({ app: APP }), aborted); - expect(result).toEqual({ status: 'error', message: 'Gave up waiting for lock' }); - expect(toolkit.synth).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 'error', message: 'This operation was aborted' }); + expect(toolkit.synth).toHaveBeenCalledTimes(0); }); test('retries a lock until it clears, then succeeds', async () => { From faa5aafa2278f416c3308a958505fd2e9260490f Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 11:33:16 +0200 Subject: [PATCH 05/16] Remove dead code --- .../cdk-explorer/lib/core/assembly-reader.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts index ce8657aca..3fa7b19cb 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts @@ -45,7 +45,6 @@ export async function webAssemblyDataFromAssembly(projectRoot: string, readableA await map.loadSourceMapFor(...Array.from(sourceFiles).filter(file => file.endsWith('.js'))); index.setSourceResolver({ resolve: (loc) => map.reverseMapJs(loc) }); index.indexAssembly(); - // updateSourceLocationsInViolations(violations, index, map); return { index, @@ -70,18 +69,3 @@ function* sourceFilesInViolations( } } } - -/* -function updateSourceLocationsInViolations(violations: PluginReportJson[], index: AssemblyIndexWithSourceLocations, map: SourceMapResolver) { - for (const plugin of violations ?? []) { - for (const violation of plugin.violations ?? []) { - for (const vc of violation.violatingConstructs ?? []) { - const traced = index.traceViolation(vc); - for (const loc of traced.sourceLocations) { - yield loc.file; - } - } - } - } -} -*/ From a062f3d89bc91b3494c57e3f867f16090a554c15 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 14:26:19 +0200 Subject: [PATCH 06/16] Migrate over to newly integrated fromCdkAppDirectory --- ...-app-directory.ts => read-cdk-assembly.ts} | 35 +------------------ .../cdk-explorer/lib/core/synth-runner.ts | 4 +-- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 2 +- 3 files changed, 4 insertions(+), 37 deletions(-) rename packages/@aws-cdk/cdk-explorer/lib/core/{from-cdk-app-directory.ts => read-cdk-assembly.ts} (50%) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts similarity index 50% rename from packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts rename to packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts index 1765ddfde..9088f7115 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/from-cdk-app-directory.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts @@ -1,37 +1,4 @@ -import { ToolkitError, type FromCdkAppOptions, type ICloudAssemblySource, type IReadableCloudAssembly, type Toolkit } from '@aws-cdk/toolkit-lib'; -import { readCdkConfig } from './cdk-config'; - -/** - * Returns an assembly source from a CDK app directory - * - * This could/should have been built into the toolkit library itself, but isn't - * currently. - * - * Returns `undefined` if we can't determine a valid CDK app in the directory. - */ -export async function fromCdkAppDirectory( - toolkit: Toolkit, - appDirectory: string, - props: FromCdkAppOptions = {}, -): Promise { - const config = await readCdkConfig(appDirectory); - - if (!config?.app) { - return undefined; - } - - return toolkit.fromCdkApp(config.app, { - ...props, - outdir: config.output, - workingDirectory: appDirectory, - lookups: false, - // Make jsii forward host-language (Python/Java) source frames; TS resolves via source maps. - env: { - ...props.env, - JSII_HOST_STACK_TRACES: '1', - }, - }); -} +import { ToolkitError, type IReadableCloudAssembly, type Toolkit } from '@aws-cdk/toolkit-lib'; /** * Retry the given producer as long as the error is a lock 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 7121069df..885c4f730 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -1,5 +1,5 @@ import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; -import { fromCdkAppDirectory, retryOnLock } from './from-cdk-app-directory'; +import { retryOnLock } from './read-cdk-assembly'; /** * Hold synth()'s read lock this long before releasing it. While we hold a @@ -50,7 +50,7 @@ export interface SynthRunnerOptions { export async function runSynth(options: SynthRunnerOptions): Promise { let asm; try { - const cx = await fromCdkAppDirectory(options.toolkit, options.projectDir); + const cx = await options.toolkit.fromCdkAppDirectory(options.projectDir, { lookups: false }); if (!cx) { return { status: 'unavailable' }; } diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index ae2974614..a9e0ef44a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -47,7 +47,7 @@ import { type AssemblyWatcherOptions, } from '../core/assembly-watcher'; import { readCdkConfig } from '../core/cdk-config'; -import { readAssemblyDirectory } from '../core/from-cdk-app-directory'; +import { readAssemblyDirectory } from '../core/read-cdk-assembly'; import { SOURCE_WATCH_EXCLUDES } from '../core/source-watcher'; import { runSynth, type SynthRunResult } from '../core/synth-runner'; import type { TemplateRanges } from '../core/template-ranges'; From c31349e8ea98c3a36f9e289817c53665a0f8c77a Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 20 Oct 2026 17:06:14 +0200 Subject: [PATCH 07/16] Rename `lsp` binary --- packages/@aws-cdk/cdk-explorer/bin/{lsp => cdk-lsp} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/@aws-cdk/cdk-explorer/bin/{lsp => cdk-lsp} (100%) diff --git a/packages/@aws-cdk/cdk-explorer/bin/lsp b/packages/@aws-cdk/cdk-explorer/bin/cdk-lsp similarity index 100% rename from packages/@aws-cdk/cdk-explorer/bin/lsp rename to packages/@aws-cdk/cdk-explorer/bin/cdk-lsp From a1dcf53bb108598f59a32930756ea6164356f6c7 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 14:39:40 +0200 Subject: [PATCH 08/16] Update tests --- .../cdk-explorer/lib/core/synth-runner.ts | 12 ++++-- packages/@aws-cdk/cdk-explorer/package.json | 2 +- .../test/_helpers/fake-toolkit.ts | 39 +++++++++++++++---- .../test/core/synth-runner.test.ts | 14 +++---- yarn.lock | 2 +- 5 files changed, 48 insertions(+), 21 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 885c4f730..c98b7988f 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -50,10 +50,9 @@ export interface SynthRunnerOptions { export async function runSynth(options: SynthRunnerOptions): Promise { let asm; try { - const cx = await options.toolkit.fromCdkAppDirectory(options.projectDir, { lookups: false }); - if (!cx) { - return { status: 'unavailable' }; - } + const cx = await options.toolkit.fromCdkAppDirectory(options.projectDir, { + lookups: false, + }); asm = await retryOnLock(options.abort, () => options.toolkit.synth(cx, { abort: options.abort })); } catch (err) { @@ -77,6 +76,11 @@ export async function runSynth(options: SynthRunnerOptions): Promise { + const cdkJsonPath = path.join(appDirectory, 'cdk.json'); + let cdkJson: Record; + try { + cdkJson = JSON.parse(await fs.promises.readFile(cdkJsonPath, 'utf-8')); + } catch (e: any) { + if (e.code === 'ENOENT') { + throw new ToolkitError('CdkJsonMissing', `fromCdkAppDirectory: cdk.json not found at ${cdkJsonPath}`); + } + throw e; + } + if (!cdkJson.app) { + throw new ToolkitError('CdkJsonIncomplete', `fromCdkAppDirectory: missing "app" field in ${cdkJsonPath}`); + } + return { [APP_SOURCE]: true, appDirectory } as unknown as ICloudAssemblySource; +} + /** One scripted outcome of synthesizing the app. */ export type SynthOutcome = | { readonly kind: 'success' } @@ -30,7 +53,7 @@ export type SynthOutcome = | { readonly kind: 'error'; readonly message: string }; export class FakeToolkit { - /** Project dirs passed to `fromCdkApp`, in call order. */ + /** Project dirs passed to `fromCdkAppDirectory`, in call order. */ public readonly appSynthCalls: string[] = []; private readonly real = new Toolkit({ ioHost: new NonInteractiveIoHost() }); @@ -80,8 +103,8 @@ export class FakeToolkit { return (this.real as any).fromAssemblyDirectory(directory, props); } - public async fromCdkApp(app: string, props?: { readonly workingDirectory?: string }): Promise { - return { [APP_SOURCE]: true, workingDirectory: props?.workingDirectory ?? app } as unknown as ICloudAssemblySource; + public async fromCdkAppDirectory(appDirectory: string): Promise { + return fakeFromCdkAppDirectory(appDirectory); } public async synth(source: any, props?: unknown): Promise { @@ -90,7 +113,7 @@ export class FakeToolkit { return (this.real as any).synth(source, props); } - this.appSynthCalls.push(source.workingDirectory); + this.appSynthCalls.push(source.appDirectory); if (this.gate) { await this.gate; } 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 1f3c004ee..266296090 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 @@ -3,6 +3,7 @@ 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'; +import { fakeFromCdkAppDirectory } from '../_helpers/fake-toolkit'; const APP = 'npx tsx bin/app.ts'; @@ -11,7 +12,7 @@ interface FakeCachedAssembly { } interface FakeToolkit { - fromCdkApp: jest.Mock; + fromCdkAppDirectory: jest.Mock; synth: jest.Mock; } @@ -25,7 +26,7 @@ function makeToolkit(opts: { ), }; const toolkit: FakeToolkit = { - fromCdkApp: jest.fn().mockResolvedValue({}), + fromCdkAppDirectory: jest.fn((appDirectory: string) => fakeFromCdkAppDirectory(appDirectory)), synth: jest.fn().mockImplementation(() => opts.synthThrow ? Promise.reject(opts.synthThrow) : Promise.resolve(cached), ), @@ -69,22 +70,21 @@ describe('runSynth', () => { const result = await run(toolkit, projectDir); expect(result).toEqual({ status: 'success' }); - expect(toolkit.fromCdkApp).toHaveBeenCalledWith(APP, expect.objectContaining({ - workingDirectory: projectDir, + expect(toolkit.fromCdkAppDirectory).toHaveBeenCalledWith(projectDir, expect.objectContaining({ lookups: false, env: expect.objectContaining({ JSII_HOST_STACK_TRACES: '1' }), })); expect(cached.dispose).toHaveBeenCalledTimes(1); }); - test('returns unavailable (without invoking the toolkit) when cdk.json has no app', async () => { + test('returns unavailable 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(); + expect(toolkit.synth).not.toHaveBeenCalled(); }); test('returns unavailable when cdk.json is missing entirely', async () => { @@ -94,7 +94,7 @@ describe('runSynth', () => { const result = await run(toolkit, projectDir); expect(result).toEqual({ status: 'unavailable' }); - expect(toolkit.fromCdkApp).not.toHaveBeenCalled(); + expect(toolkit.synth).not.toHaveBeenCalled(); }); test('classifies AssemblyError as app-failure with the error message', async () => { diff --git a/yarn.lock b/yarn.lock index 5c10ab33b..19366a969 100644 --- a/yarn.lock +++ b/yarn.lock @@ -209,7 +209,7 @@ __metadata: vscode-languageserver-protocol: "npm:^3" vscode-languageserver-textdocument: "npm:^1" bin: - lsp: bin/lsp + cdk-lsp: bin/cdk-lsp languageName: unknown linkType: soft From a63d0fefb9a12352344a338296dd143ce98f0c03 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 14:57:23 +0200 Subject: [PATCH 09/16] Test --- .../@aws-cdk/cdk-explorer/lib/core/synth-runner.ts | 3 +++ .../cdk-explorer/test/core/synth-runner.test.ts | 11 ++++------- 2 files changed, 7 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 c98b7988f..039f2cab9 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -52,6 +52,9 @@ export async function runSynth(options: SynthRunnerOptions): Promise options.toolkit.synth(cx, { abort: options.abort })); 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 266296090..ee470480d 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 @@ -63,17 +63,14 @@ function run(toolkit: FakeToolkit, projectDir: string, abort = new AbortControll } describe('runSynth', () => { - test('reads the app from cdk.json, returns success, and disposes the cached assembly', async () => { + test('reads the app from cdk.json, sets the right environment variables, returns success, and disposes the cached assembly', async () => { const { toolkit, cached } = makeToolkit({}); const projectDir = makeProjectDir({ app: APP }); - const result = await run(toolkit, projectDir); + // WHEN + await run(toolkit, projectDir); - expect(result).toEqual({ status: 'success' }); - expect(toolkit.fromCdkAppDirectory).toHaveBeenCalledWith(projectDir, expect.objectContaining({ - lookups: false, - env: expect.objectContaining({ JSII_HOST_STACK_TRACES: '1' }), - })); + // THEN expect(cached.dispose).toHaveBeenCalledTimes(1); }); From 4516ebd20e8655a5ec00bf046d0fbee391046eed Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 15:25:12 +0200 Subject: [PATCH 10/16] use jsonc-parser instead of a random NPM dependency --- .projenrc.ts | 3 +- .../@aws-cdk/cdk-explorer/.projen/deps.json | 8 +- .../@aws-cdk/cdk-explorer/.projen/tasks.json | 2 +- .../cdk-explorer/lib/core/template-ranges.ts | 257 ++++++++---------- packages/@aws-cdk/cdk-explorer/package.json | 3 +- .../test/core/template-ranges.test.ts | 68 +++-- packages/@aws-cdk/cdk-explorer/tsconfig.json | 1 - yarn.lock | 10 +- 8 files changed, 159 insertions(+), 193 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index 85d17af7d..b47fff32c 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1222,11 +1222,10 @@ const cdkExplorer = configureProject( 'chokidar@^4', '@jridgewell/trace-mapping@^0.3', 'convert-source-map@^2', - 'json-source-map@^0.6.1', + 'jsonc-parser', 'p-limit@^3', ], devDeps: [ - '@types/json-source-map@^0.6.0', 'vscode-languageserver-protocol@^3', '@types/convert-source-map@^2', ], diff --git a/packages/@aws-cdk/cdk-explorer/.projen/deps.json b/packages/@aws-cdk/cdk-explorer/.projen/deps.json index 0bb6b50f1..4efaa090f 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/deps.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/deps.json @@ -18,11 +18,6 @@ "name": "@types/jest", "type": "build" }, - { - "name": "@types/json-source-map", - "version": "^0.6.0", - "type": "build" - }, { "name": "@types/node", "version": "^20", @@ -136,8 +131,7 @@ "type": "runtime" }, { - "name": "json-source-map", - "version": "^0.6.1", + "name": "jsonc-parser", "type": "runtime" }, { diff --git a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json index 616938740..33ec35def 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json @@ -53,7 +53,7 @@ "--peer", "--no-deprecated", "--dep=dev,prod,peer,optional", - "--filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest" + "--filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest,jsonc-parser" ] } ] diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts index b8974f28b..961c720e2 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts @@ -1,4 +1,4 @@ -import { parse, type Pointers } from 'json-source-map'; +import { findNodeAtLocation, parseTree, type Node, type ParseError } from 'jsonc-parser'; /** * A character-offset range into a template's text, as a half-open interval @@ -13,185 +13,158 @@ export interface OffsetRange { readonly end: number; } -/** - * Resolves the character range of a resource's value block inside a synthesized - * CloudFormation template. The range covers the value `{ ... }` under - * `Resources/`, so `JSON.parse(text.slice(start, end))` returns the - * resource object. - * - * Returns `undefined` when the text is not valid JSON, or when there is no such - * resource. - */ -export function resolveResourceRange(templateText: string, logicalId: string): OffsetRange | undefined { - return indexTemplateRanges(templateText)?.block(logicalId); -} - -/** A resource's block range plus the range of each of its top-level properties. */ +/** A template member's block range plus the range of each of its top-level properties. */ export interface ResourceRanges { - /** The resource's value block `{ ... }` (value-only, like {@link resolveResourceRange}). */ + /** The member's value block `{ ... }`, so `JSON.parse(text.slice(start, end))` returns it. */ readonly block: OffsetRange; - /** Each top-level property keyed by name, covering the whole `"Key": value` entry. */ + /** + * Each top-level property keyed by name, covering the whole `"Key": value` + * entry. Only resources have properties; empty for other sections. + */ readonly properties: Record; } /** - * Resolves, in a single parse, a resource's block range and the range of each - * of its top-level properties. Property ranges span `"Key": value` so a - * navigation lands on the named property. - * - * Returns `undefined` when the text is not valid JSON or there is no such resource. - */ -export function resolveResourceRanges(templateText: string, logicalId: string): ResourceRanges | undefined { - return indexTemplateRanges(templateText)?.resource(logicalId); -} - -/** - * A template parsed once into a queryable range index, so a caller resolving - * several resources (for example a hover over a multi-resource construct) pays - * the parse cost once instead of per resource. + * A template parsed once into a queryable range index. Every lookup below is a + * view on that one index, so a caller resolving several logical ids (for example + * a hover over a multi-resource construct) pays the parse cost once. */ export interface TemplateRanges { - /** The resource's value-block range, or `undefined` if there is no such resource. */ + /** The logical id's value-block range, or `undefined` if there is no such member. */ block(logicalId: string): OffsetRange | undefined; - /** The resource's block plus its top-level property ranges, or `undefined` if absent. */ + /** The logical id's block plus its top-level property ranges, or `undefined` if absent. */ resource(logicalId: string): ResourceRanges | undefined; + /** The logical id whose block contains `offset`, or `undefined` if none does. */ + logicalIdAt(offset: number): string | undefined; } /** - * Parse a template once into a {@link TemplateRanges} index. Returns `undefined` - * when the text is not valid JSON. The single-resource {@link resolveResourceRange} - * and {@link resolveResourceRanges} are thin wrappers over this. + * Parse a template once into a {@link TemplateRanges} index. + * + * A logical id is looked up across all top-level sections, so a `Parameter` or + * an `Output` resolves as well as a `Resource`. Every lookup, forward and + * reverse, reads the same index, so they cannot disagree about what a logical + * id covers. + * + * Lookups on a template that is not valid JSON all come back empty + * (`undefined`), rather than a wrong range. */ export function indexTemplateRanges(templateText: string): TemplateRanges { - const pointers = parsePointers(templateText); - if (pointers === undefined) { - return { - block: () => undefined, - resource: () => undefined, - }; - } + const root = parseTemplate(templateText); + const index = root === undefined ? undefined : buildIndex(root); + return { - block: (logicalId) => blockRange(pointers, escapePointerSegment(logicalId)), - resource: (logicalId) => { - const escaped = escapePointerSegment(logicalId); - const block = blockRange(pointers, escaped); - return block === undefined ? undefined : { block, properties: propertyRanges(pointers, escaped) }; + block: (logicalId) => index?.[logicalId]?.block, + resource: (logicalId) => index?.[logicalId], + logicalIdAt: (offset) => { + for (const [logicalId, ranges] of Object.entries(index ?? {})) { + if (offset >= ranges.block.start && offset < ranges.block.end) { + return logicalId; + } + } + return undefined; }, }; } -/** The value-block range of `/Resources/`, or `undefined` if absent. */ -function blockRange(pointers: Pointers, escapedId: string): OffsetRange | undefined { - const mapping = pointers[`/Resources/${escapedId}`]; - return mapping === undefined ? undefined : { start: mapping.value.pos, end: mapping.valueEnd.pos }; +/** + * Resolves the character range of a logical id's value block inside a + * synthesized CloudFormation template, so that + * `JSON.parse(text.slice(start, end))` returns that resource. + * + * Returns `undefined` when the text is not valid JSON, or when there is no such + * logical id. + */ +export function resolveResourceRange(templateText: string, logicalId: string): OffsetRange | undefined { + return indexTemplateRanges(templateText).block(logicalId); } -/** Key+value ranges of each top-level property of `/Resources/`. */ -function propertyRanges(pointers: Pointers, escapedId: string): Record { - const properties: Record = {}; - for (const [pointer, mapping] of Object.entries(pointers)) { - // This resource's top-level properties only. json-source-map types the key - // position as optional (absent for array items); a /Properties/ pointer - // is always an object member, so this narrows the type, not an impossible case. - const match = /^\/Resources\/([^/]+)\/Properties\/([^/]+)$/.exec(pointer); - if (match === null || match[1] !== escapedId || mapping.key === undefined) { - continue; - } - properties[unescapePointerSegment(match[2])] = { start: mapping.key.pos, end: mapping.valueEnd.pos }; - } - return properties; +/** + * The inverse of `resolveResourceRange`: given a character offset into a + * template's text, returns the logical id whose block contains that offset, for + * linking a position in the template back to its construct. + * + * Returns `undefined` when the text is not valid JSON, or when the offset is not + * inside any logical id's block (for example whitespace, or the `Resources` key + * itself). + */ +export function resolveLogicalIdAtOffset(templateText: string, offset: number): string | undefined { + return indexTemplateRanges(templateText).logicalIdAt(offset); } /** - * Resolves block and property ranges for ALL resources in a single parse pass. - * Equivalent to calling `resolveResourceRanges` per logical ID, but O(1) parses - * instead of O(N). + * Index every top-level section's members by logical id. This is the only place + * that locates anything; all lookups are views on its result. */ -export function resolveAllResourceRanges(templateText: string): Record | undefined { - const pointers = parsePointers(templateText); - if (pointers === undefined) { - return undefined; - } - - const result: Record = {}; - const resourceProperties = new Map>(); - - for (const [pointer, mapping] of Object.entries(pointers)) { - // 'pointer' looks like '/
//Properties/...' etc. - const justAResource = /^\/([^/]+)\/([^/]+)$/.exec(pointer); - - // We ignore the 'Section' part; 'logicalId' might be an index into any of the - // Sections but we don't have enough information to pinpoint the right section. - // That means if both a Parameter and Resource have the same identifier, we might - // get this wrong. Unlikely enough to happen that this will suffice until we run - // into that practice. - if (justAResource) { - const id = unescapePointerSegment(justAResource[2]); - result[id] = { block: { start: mapping.value.pos, end: mapping.valueEnd.pos }, properties: {} }; - continue; - } - - const propMatch = /^\/Resources\/([^/]+)\/Properties\/([^/]+)$/.exec(pointer); - if (propMatch && mapping.key !== undefined) { - const id = unescapePointerSegment(propMatch[1]); - let props = resourceProperties.get(id); - if (!props) { - props = {}; - resourceProperties.set(id, props); +function buildIndex(root: Node): Record { + const index: Record = {}; + const resourceIds = new Set(); + + for (const [section, sectionValue] of members(root)) { + const isResources = section === 'Resources'; + for (const [logicalId, value] of members(sectionValue)) { + // On a collision (a Parameter named after a Resource, say) the resource + // wins: that is what a caller holding a logical id means by it. + if (!isResources && resourceIds.has(logicalId)) { + continue; + } + index[logicalId] = { + block: nodeRange(value), + properties: isResources ? memberRanges(findNodeAtLocation(value, ['Properties'])) : {}, + }; + if (isResources) { + resourceIds.add(logicalId); } - props[unescapePointerSegment(propMatch[2])] = { start: mapping.key.pos, end: mapping.valueEnd.pos }; - } - } - - for (const [id, props] of resourceProperties) { - if (result[id]) { - result[id] = { block: result[id].block, properties: props }; } } - return result; + return index; } /** - * The inverse of `resolveResourceRange`: given a character offset into a - * template's text, returns the logical id of the resource whose block contains - * that offset, for linking a position in the template back to its construct. - * - * Returns `undefined` when the text is not valid JSON, or when the offset is not - * inside any `Resources/` block (for example whitespace, the - * `Resources` key, or another top-level section). + * Parse the template into its syntax tree, or `undefined` if it is not valid + * JSON. jsonc-parser is a recovering parser, so we reject anything that reported + * an error: a malformed template must yield no range rather than a wrong one. + * Synthesized templates are always strict JSON, hence no comments or trailing + * commas either. */ -export function resolveLogicalIdAtOffset(templateText: string, offset: number): string | undefined { - const pointers = parsePointers(templateText); - if (pointers === undefined) { - return undefined; - } - for (const [pointer, mapping] of Object.entries(pointers)) { - // Match only top-level resources (`/Resources/`), not nested property - // pointers like `/Resources//Properties/...`. - const match = /^\/Resources\/([^/]+)$/.exec(pointer); - if (match && offset >= mapping.value.pos && offset < mapping.valueEnd.pos) { - return unescapePointerSegment(match[1]); - } - } - return undefined; +function parseTemplate(templateText: string): Node | undefined { + const errors: ParseError[] = []; + const root = parseTree(templateText, errors, { disallowComments: true, allowTrailingComma: false }); + return errors.length > 0 ? undefined : root; } -/** Parse the template into its JSON-pointer map, or `undefined` if it is not valid JSON. */ -function parsePointers(templateText: string): Pointers | undefined { - try { - return parse(templateText).pointers; - } catch { - return undefined; - } +function nodeRange(node: Node): OffsetRange { + return { start: node.offset, end: node.offset + node.length }; } -/** Escape a single path segment for use in a JSON pointer. */ -function escapePointerSegment(segment: string): string { - return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +/** + * The members of an object node as `[name, valueNode]` pairs, in document + * order. Anything that is not an object (or is absent) has no members. + */ +function members(node: Node | undefined): Array<[string, Node]> { + if (node?.type !== 'object') { + return []; + } + return (node.children ?? []).flatMap((property): Array<[string, Node]> => { + const [key, value] = property.children ?? []; + // A `property` node always has both, except mid-edit in a recovering parse + // (which we already rejected), so this narrows the type. + return key === undefined || value === undefined ? [] : [[key.value as string, value]]; + }); } -/** Reverse of `escapePointerSegment`. */ -function unescapePointerSegment(segment: string): string { - return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +/** Each member of an object node keyed by name, ranged over the whole `"Key": value` entry. */ +function memberRanges(node: Node | undefined): Record { + if (node?.type !== 'object') { + return {}; + } + const ranges: Record = {}; + for (const property of node.children ?? []) { + const key = property.children?.[0]; + if (key !== undefined) { + ranges[key.value as string] = nodeRange(property); + } + } + return ranges; } diff --git a/packages/@aws-cdk/cdk-explorer/package.json b/packages/@aws-cdk/cdk-explorer/package.json index 62ebe93ad..a17ad489f 100644 --- a/packages/@aws-cdk/cdk-explorer/package.json +++ b/packages/@aws-cdk/cdk-explorer/package.json @@ -37,7 +37,6 @@ "@stylistic/eslint-plugin": "^3", "@types/convert-source-map": "^2", "@types/jest": "^29.5.14", - "@types/json-source-map": "^0.6.0", "@types/node": "^20", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", @@ -65,7 +64,7 @@ "@jridgewell/trace-mapping": "^0.3", "chokidar": "^4", "convert-source-map": "^2", - "json-source-map": "^0.6.1", + "jsonc-parser": "^3.3.1", "p-limit": "^3", "vscode-jsonrpc": "^8", "vscode-languageserver": "^9", diff --git a/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts index 01f3f4c58..b4874001f 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts @@ -1,4 +1,4 @@ -import { resolveAllResourceRanges, resolveLogicalIdAtOffset, resolveResourceRange, resolveResourceRanges } from '../../lib/core/template-ranges'; +import { indexTemplateRanges, resolveLogicalIdAtOffset, resolveResourceRange } from '../../lib/core/template-ranges'; // A template object is the single source of truth: the text under test is its // 1-space serialization (exactly how synth writes `*.template.json`), and the @@ -75,8 +75,9 @@ describe('resolveResourceRange', () => { }); test('returns undefined for invalid JSON (for example a trailing comma)', () => { - // json-source-map is a strict parser, so a malformed template yields no - // range rather than a wrong one. Synthesized templates are always strict JSON. + // jsonc-parser recovers from syntax errors, but we reject any template it + // reported an error for, so a malformed template yields no range rather than + // a wrong one. Synthesized templates are always strict JSON. const invalid = '{\n "Resources": {\n "B": {\n "Type": "AWS::S3::Bucket",\n }\n }\n}'; expect(resolveResourceRange(invalid, 'B')).toBeUndefined(); }); @@ -112,7 +113,10 @@ describe('resolveLogicalIdAtOffset', () => { }); }); -describe('resolveResourceRanges', () => { +// `.resource()` is what hover calls for a construct's property ranges. +describe('indexTemplateRanges().resource', () => { + const resolveResourceRanges = (text: string, logicalId: string) => indexTemplateRanges(text).resource(logicalId); + // Wrapping a key+value slice in braces makes it a valid object, so the same // re-parse oracle proves each property range covers exactly `"Key": value`. const sliceParseEntry = (range: { start: number; end: number }) => @@ -163,45 +167,37 @@ describe('resolveResourceRanges', () => { }); }); -/** - * `resolveAllResourceRanges` is documented as the bulk equivalent of - * `resolveResourceRanges`: "Equivalent to calling `resolveResourceRanges` per - * logical ID, but O(1) parses instead of O(N)." These tests hold it to exactly - * that, using the single-resource resolver (covered above) as the oracle. - */ -describe('resolveAllResourceRanges', () => { - test('covers every resource in the template', () => { - expect(Object.keys(resolveAllResourceRanges(TEMPLATE_TEXT)!).sort()).toEqual([...LOGICAL_IDS].sort()); - }); - - test.each(LOGICAL_IDS)('the block range for %s re-parses to that exact resource', (logicalId) => { - expect(sliceParse(resolveAllResourceRanges(TEMPLATE_TEXT)![logicalId].block)).toEqual(RESOURCES[logicalId]); - }); +describe('indexTemplateRanges', () => { + const TEMPLATE_WITH_PARAMETER = { + Parameters: { BootstrapVersion: { Type: 'AWS::SSM::Parameter::Value' } }, + Resources: { Topic: { Type: 'AWS::SNS::Topic' } }, + }; + const WITH_PARAMETER_TEXT = JSON.stringify(TEMPLATE_WITH_PARAMETER, undefined, 1); - test.each(LOGICAL_IDS)('the property ranges for %s match the single-resource resolver', (logicalId) => { - const all = resolveAllResourceRanges(TEMPLATE_TEXT)!; - const one = resolveResourceRanges(TEMPLATE_TEXT, logicalId)!; + test.each(['BootstrapVersion', 'Topic'])('forward and reverse lookup agree on %s', (logicalId) => { + const ranges = indexTemplateRanges(WITH_PARAMETER_TEXT); + const block = ranges.block(logicalId)!; - expect(all[logicalId].properties).toEqual(one.properties); + expect(ranges.resource(logicalId)!.block).toEqual(block); + expect(ranges.logicalIdAt(Math.floor((block.start + block.end) / 2))).toBe(logicalId); }); - test('keys properties under their owning resource, not under the property name', () => { - // The failure mode this guards: capturing the property name where the logical - // id belongs, which both loses every property range and risks attaching one - // resource's properties to another whose id matches a property name. - const all = resolveAllResourceRanges(TEMPLATE_TEXT)!; + test('a logical id used by both a Resource and a Parameter resolves to the Resource', () => { + const text = JSON.stringify({ + Resources: { Thing: { Type: 'AWS::SNS::Topic', Properties: { TopicName: 't' } } }, + Parameters: { Thing: { Type: 'String' } }, + }, undefined, 1); - expect(Object.keys(all.MyBucket.properties)).toEqual(['BucketName']); - expect(Object.keys(all.Policy.properties).sort()).toEqual(['Description', 'InlineJson', 'PolicyDocument']); - // A property name must never become a top-level entry of its own. - expect(all).not.toHaveProperty('BucketName'); - }); + const block = indexTemplateRanges(text).block('Thing')!; - test('a resource with no Properties yields an empty property map', () => { - expect(resolveAllResourceRanges(TEMPLATE_TEXT)!.Topic.properties).toEqual({}); + expect(JSON.parse(text.slice(block.start, block.end)).Type).toBe('AWS::SNS::Topic'); }); - test('returns undefined when the text cannot be parsed into a tree', () => { - expect(resolveAllResourceRanges('not json at all')).toBeUndefined(); + test('every lookup comes back empty for a template that is not valid JSON', () => { + const ranges = indexTemplateRanges('not json at all'); + + expect(ranges.block('Topic')).toBeUndefined(); + expect(ranges.resource('Topic')).toBeUndefined(); + expect(ranges.logicalIdAt(0)).toBeUndefined(); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/tsconfig.json b/packages/@aws-cdk/cdk-explorer/tsconfig.json index 54d15fa67..8874ce8bf 100644 --- a/packages/@aws-cdk/cdk-explorer/tsconfig.json +++ b/packages/@aws-cdk/cdk-explorer/tsconfig.json @@ -29,7 +29,6 @@ "types": [ "convert-source-map", "jest", - "json-source-map", "node" ], "incremental": true, diff --git a/yarn.lock b/yarn.lock index 19366a969..46f8ec5bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -181,7 +181,6 @@ __metadata: "@stylistic/eslint-plugin": "npm:^3" "@types/convert-source-map": "npm:^2" "@types/jest": "npm:^29.5.14" - "@types/json-source-map": "npm:^0.6.0" "@types/node": "npm:^20" "@typescript-eslint/eslint-plugin": "npm:^8" "@typescript-eslint/parser": "npm:^8" @@ -197,7 +196,7 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.5" jest: "npm:^29.7.0" jest-junit: "npm:^16" - json-source-map: "npm:^0.6.1" + jsonc-parser: "npm:^3.3.1" nx: "npm:^22.7.12" p-limit: "npm:^3" prettier: "npm:^2.8" @@ -10831,6 +10830,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" From 4fc15505308c1ceaecadbbf62d5b1380fbcb3da8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 15:47:42 +0200 Subject: [PATCH 11/16] Fixy fix --- .../cdk-explorer/lib/core/assembly-reader.ts | 2 ++ .../cdk-explorer/lib/core/source-resolver.ts | 2 +- .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 22 ++++++++++++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts index 3fa7b19cb..d0cc49e79 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-reader.ts @@ -46,6 +46,8 @@ export async function webAssemblyDataFromAssembly(projectRoot: string, readableA index.setSourceResolver({ resolve: (loc) => map.reverseMapJs(loc) }); index.indexAssembly(); + warnings.push(...map.warnings); + return { index, violations, diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts index 5721f14e1..a1c32d0d7 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts @@ -37,7 +37,7 @@ export class SourceMapResolver { * Load and cache source map for a given .js file. */ public async loadSourceMapFor(...jsFiles: RelativePath[]): Promise { - const limit = pLimit(20); // Limit concurrent source map loads to 10 + const limit = pLimit(20); // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism await Promise.all( diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index a9e0ef44a..f1d92a329 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -253,15 +253,21 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { try { do { synthLatch = 'synthing' as SynthLatch; + + // Get the current listeners and notify them once we are done, successfully + // or otherwise. const currentListeners = synthListeners.splice(0, synthListeners.length); - const result = await runSynth({ - toolkit: options.toolkit, - projectDir: currentProjectDir(), - abort: shutdown.signal, - }); - publishSynthDiagnostics(result); - for (const listener of currentListeners) { - listener(); + try { + const result = await runSynth({ + toolkit: options.toolkit, + projectDir: currentProjectDir(), + abort: shutdown.signal, + }); + publishSynthDiagnostics(result); + } finally { + for (const listener of currentListeners) { + listener(); + } } // If another synth was queued while we were running, the loop will go around again. From 4ec02cce4bfffc61970ca79b605fc7cf0e82edc2 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 16:17:48 +0200 Subject: [PATCH 12/16] Polyfill for AbortSignal.any() --- .../lib/core/read-cdk-assembly.ts | 23 ++++++++++++++++++- .../cdk-explorer/lib/core/source-resolver.ts | 7 ------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts index 9088f7115..3b720ea91 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts @@ -5,7 +5,7 @@ import { ToolkitError, type IReadableCloudAssembly, type Toolkit } from '@aws-cd */ export async function retryOnLock(abort: AbortSignal, fn: () => Promise) { let delayMs = 500; - const deadline = AbortSignal.any([abort, AbortSignal.timeout(60 * 60 * 1000)]); + const deadline = anyAbortSignal([abort, AbortSignal.timeout(60 * 60 * 1000)]); while (true) { deadline.throwIfAborted(); try { @@ -29,3 +29,24 @@ export async function readAssemblyDirectory(toolkit: Toolkit, abort: AbortSignal // NOTE: Purposely using asmSource.produce() here: toolkit.synth() would throw on validation failures and we don't necessarily want that. return retryOnLock(abort, () => asmSource.produce({ abort: abort })); } + +/** + * Polyfill for AbortSignal.any, which may not be available in all environments. + */ +function anyAbortSignal(signals: AbortSignal[]): AbortSignal { + // Return real implementation if available; we want to test our polyfill, so don't do the + // fallback when testing. + if (typeof AbortSignal.any === 'function' && process.env.NODE_ENV !== 'test') { + return AbortSignal.any(signals); + } + const controller = new AbortController(); + for (const signal of signals) { + // If we start aborted, no need to add the event listener. + if (signal.aborted) { + controller.abort(); + break; + } + signal.addEventListener('abort', () => controller.abort()); + } + return controller.signal; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts index a1c32d0d7..b53e1da55 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/source-resolver.ts @@ -18,13 +18,6 @@ export class SourceMapResolver { private readonly cache = new Map(); private readonly collectedWarnings: string[] = []; - /** - * @param projectRoot - Absolute path to the project the assembly belongs to. - * Every file this resolver reads or returns must stay within it. The paths - * driving those reads come from the cloud assembly (stack-trace frames and - * the source maps they point at), which are attacker-influenceable if - * cdk.out is tampered with, so anything escaping the root is dropped - */ constructor(private readonly index: AssemblyIndexWithSourceLocations) { } From 8af03e980e125a98955de137ef21f76dd319de0e Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 16:21:21 +0200 Subject: [PATCH 13/16] 3p --- packages/aws-cdk/THIRD_PARTY_LICENSES | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 30569f719..9a687c909 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -11764,6 +11764,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** jsonc-parser@3.3.1 - https://www.npmjs.com/package/jsonc-parser/v/3.3.1 | MIT + ---------------- ** jsonfile@6.2.0 - https://www.npmjs.com/package/jsonfile/v/6.2.0 | MIT From 1cb157d7fe3d938589299465a076f1f3b76f7bb8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Oct 2026 16:41:38 +0200 Subject: [PATCH 14/16] Reimplement our own JSON parser --- .projenrc.ts | 1 - .../@aws-cdk/cdk-explorer/.projen/deps.json | 4 - .../@aws-cdk/cdk-explorer/.projen/tasks.json | 2 +- .../lib/core/json-source-parser.ts | 309 ++++++++++++++++++ .../cdk-explorer/lib/core/template-ranges.ts | 22 +- packages/@aws-cdk/cdk-explorer/package.json | 1 - .../test/core/json-source-parser.test.ts | 229 +++++++++++++ .../test/core/template-ranges.test.ts | 5 +- yarn.lock | 8 - 9 files changed, 546 insertions(+), 35 deletions(-) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/core/json-source-parser.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/core/json-source-parser.test.ts diff --git a/.projenrc.ts b/.projenrc.ts index b47fff32c..dc4dc5cd7 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1222,7 +1222,6 @@ const cdkExplorer = configureProject( 'chokidar@^4', '@jridgewell/trace-mapping@^0.3', 'convert-source-map@^2', - 'jsonc-parser', 'p-limit@^3', ], devDeps: [ diff --git a/packages/@aws-cdk/cdk-explorer/.projen/deps.json b/packages/@aws-cdk/cdk-explorer/.projen/deps.json index 4efaa090f..0ce8d2993 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/deps.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/deps.json @@ -130,10 +130,6 @@ "version": "^2", "type": "runtime" }, - { - "name": "jsonc-parser", - "type": "runtime" - }, { "name": "p-limit", "version": "^3", diff --git a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json index 33ec35def..616938740 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json @@ -53,7 +53,7 @@ "--peer", "--no-deprecated", "--dep=dev,prod,peer,optional", - "--filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest,jsonc-parser" + "--filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest" ] } ] diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/json-source-parser.ts b/packages/@aws-cdk/cdk-explorer/lib/core/json-source-parser.ts new file mode 100644 index 000000000..7b418fa8e --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/json-source-parser.ts @@ -0,0 +1,309 @@ +/** + * A strict JSON parser that records where every value came from in the source + * text, so a consumer can map a value back to a character range (and from there + * to a line/column, which is the caller's business — see `OffsetRange` in + * `template-ranges.ts`). + * + * Accepts exactly what `JSON.parse` accepts (RFC 8259): no comments, no trailing + * commas, no single quotes, no unquoted keys. Anything else is a parse error. + * The node shape deliberately mirrors the subset of `jsonc-parser`'s API we use, + * so consumers read the same way: `type`/`offset`/`length`/`value`/`children`, + * plus {@link findNodeAtLocation}. + */ + +/** The kind of a {@link Node}. `property` is one `"key": value` member of an object. */ +export type NodeType = 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; + +/** A parsed JSON value, tagged with the source range it was parsed from. */ +export interface Node { + readonly type: NodeType; + /** Offset of the node's first character in the source text, 0-based. */ + readonly offset: number; + /** + * The node's length in characters, so it spans `[offset, offset + length)`. + * A `property` spans its key through the end of its value, excluding the + * separating comma. + */ + readonly length: number; + /** + * The parsed value of a scalar node: the unescaped text of a `string`, the + * numeric value of a `number`, `true`/`false`, or `null`. Absent for + * `object`, `array` and `property` nodes. + */ + readonly value?: string | number | boolean | null; + /** + * An `object`'s `property` nodes, an `array`'s element nodes, or a + * `property`'s `[key, value]` pair, in document order. Absent for scalars. + */ + readonly children?: Node[]; +} + +/** + * Nesting depth we refuse to go past. Recursive descent means depth costs stack, + * and a template is a handful of levels deep; this turns a pathological document + * into a parse error instead of a crashed process. + */ +const MAX_DEPTH = 200; + +/** + * Parse `text` into a {@link Node} tree, or return `undefined` if it is not + * valid JSON. All-or-nothing on purpose: a half-parsed document would hand out + * ranges that do not mean what the caller thinks they mean. + */ +export function parseTree(text: string): Node | undefined { + try { + return new Parser(text).parseDocument(); + } catch (error) { + if (error instanceof JsonSyntaxError) { + return undefined; + } + throw error; + } +} + +/** + * Walk `path` down from `node`, taking object keys for strings and array indices + * for numbers, and return the node found there. `undefined` if any step is + * missing or is the wrong kind of container. + */ +export function findNodeAtLocation(node: Node | undefined, path: Array): Node | undefined { + let current = node; + for (const segment of path) { + if (current === undefined) { + return undefined; + } + current = typeof segment === 'string' ? objectMember(current, segment) : arrayElement(current, segment); + } + return current; +} + +/** The value node of an object's `key` member, or `undefined`. Last duplicate key wins, like `JSON.parse`. */ +function objectMember(node: Node, key: string): Node | undefined { + if (node.type !== 'object') { + return undefined; + } + let found: Node | undefined; + for (const property of node.children ?? []) { + if (property.children?.[0]?.value === key) { + found = property.children[1]; + } + } + return found; +} + +function arrayElement(node: Node, index: number): Node | undefined { + return node.type === 'array' ? node.children?.[index] : undefined; +} + +/** Thrown internally for any malformed input; {@link parseTree} turns it into `undefined`. */ +class JsonSyntaxError extends Error { + constructor(message: string, public readonly offset: number) { + super(`${message} at offset ${offset}`); + } +} + +/** + * A hand-written recursive-descent parser. Hand-written rather than built on + * `JSON.parse` because offsets are the whole point, and `JSON.parse` does not + * report them (its reviver sees values, not positions). + */ +class Parser { + private pos = 0; + + constructor(private readonly text: string) { + } + + /** A whole document: one value, surrounded by optional whitespace, nothing after it. */ + public parseDocument(): Node { + this.skipWhitespace(); + const node = this.parseValue(0); + this.skipWhitespace(); + if (this.pos !== this.text.length) { + throw this.error('trailing content after the top-level value'); + } + return node; + } + + private parseValue(depth: number): Node { + if (depth > MAX_DEPTH) { + throw this.error('JSON nested too deeply'); + } + switch (this.peek()) { + case '{': return this.parseObject(depth); + case '[': return this.parseArray(depth); + case '"': return this.parseString(); + case 't': return this.parseLiteral('true', true); + case 'f': return this.parseLiteral('false', false); + case 'n': return this.parseLiteral('null', null); + default: return this.parseNumber(); + } + } + + private parseObject(depth: number): Node { + const start = this.pos; + this.expect('{'); + const children: Node[] = []; + + this.skipWhitespace(); + if (this.peek() !== '}') { + for (;;) { + this.skipWhitespace(); + children.push(this.parseProperty(depth)); + this.skipWhitespace(); + if (this.peek() !== ',') { + break; + } + this.pos++; + } + this.skipWhitespace(); + } + + this.expect('}'); + return { type: 'object', offset: start, length: this.pos - start, children }; + } + + /** One `"key": value` member. Its range runs from the key's quote to the end of the value. */ + private parseProperty(depth: number): Node { + const start = this.pos; + if (this.peek() !== '"') { + throw this.error('expected a quoted property name'); + } + const key = this.parseString(); + + this.skipWhitespace(); + this.expect(':'); + this.skipWhitespace(); + + const value = this.parseValue(depth + 1); + return { type: 'property', offset: start, length: this.pos - start, children: [key, value] }; + } + + private parseArray(depth: number): Node { + const start = this.pos; + this.expect('['); + const children: Node[] = []; + + this.skipWhitespace(); + if (this.peek() !== ']') { + for (;;) { + this.skipWhitespace(); + children.push(this.parseValue(depth + 1)); + this.skipWhitespace(); + if (this.peek() !== ',') { + break; + } + this.pos++; + } + this.skipWhitespace(); + } + + this.expect(']'); + return { type: 'array', offset: start, length: this.pos - start, children }; + } + + private parseString(): Node { + const start = this.pos; + this.expect('"'); + let value = ''; + + for (;;) { + const char = this.text[this.pos]; + if (char === undefined) { + throw this.error('unterminated string'); + } + if (char === '"') { + this.pos++; + return { type: 'string', offset: start, length: this.pos - start, value }; + } + if (char === '\\') { + value += this.readEscape(); + continue; + } + // Unescaped control characters are not legal in a JSON string. + if (char < '\u0020') { + throw this.error('control character in string'); + } + value += char; + this.pos++; + } + } + + /** Read one `\x` escape sequence, returning the character(s) it denotes. */ + private readEscape(): string { + this.pos++; // the backslash + const escape = this.text[this.pos++]; + switch (escape) { + case '"': return '"'; + case '\\': return '\\'; + case '/': return '/'; + case 'b': return '\b'; + case 'f': return '\f'; + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'u': { + const hex = this.text.slice(this.pos, this.pos + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + throw this.error('invalid unicode escape'); + } + this.pos += 4; + // One code unit at a time: a surrogate pair arrives as two escapes and + // reassembles itself on concatenation, exactly like `JSON.parse`. + return String.fromCharCode(parseInt(hex, 16)); + } + default: + throw this.error('invalid escape sequence'); + } + } + + /** + * A JSON number: `-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?`. No leading `+`, no + * leading zeroes, no bare `.5` or `5.`, no `Infinity`/`NaN` — matching + * `JSON.parse`, which is stricter than `Number()`. + */ + private parseNumber(): Node { + const start = this.pos; + const match = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/.exec(this.text.slice(this.pos)); + if (match === null) { + throw this.error('expected a value'); + } + this.pos += match[0].length; + return { type: 'number', offset: start, length: this.pos - start, value: Number(match[0]) }; + } + + private parseLiteral(word: string, value: boolean | null): Node { + const start = this.pos; + if (this.text.startsWith(word, this.pos)) { + this.pos += word.length; + return { type: value === null ? 'null' : 'boolean', offset: start, length: word.length, value }; + } + throw this.error('expected a value'); + } + + /** The current character, or `undefined` at end of input. */ + private peek(): string | undefined { + return this.text[this.pos]; + } + + private expect(char: string): void { + if (this.text[this.pos] !== char) { + throw this.error(`expected '${char}'`); + } + this.pos++; + } + + /** JSON's whitespace is exactly these four characters. */ + private skipWhitespace(): void { + while (this.pos < this.text.length) { + const char = this.text[this.pos]; + if (char !== ' ' && char !== '\t' && char !== '\n' && char !== '\r') { + return; + } + this.pos++; + } + } + + private error(message: string): JsonSyntaxError { + return new JsonSyntaxError(message, this.pos); + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts index 961c720e2..66b776d0a 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts @@ -1,4 +1,4 @@ -import { findNodeAtLocation, parseTree, type Node, type ParseError } from 'jsonc-parser'; +import { findNodeAtLocation, parseTree, type Node } from './json-source-parser'; /** * A character-offset range into a template's text, as a half-open interval @@ -50,7 +50,9 @@ export interface TemplateRanges { * (`undefined`), rather than a wrong range. */ export function indexTemplateRanges(templateText: string): TemplateRanges { - const root = parseTemplate(templateText); + // All-or-nothing: an unparseable template yields no ranges rather than wrong + // ones. Synthesized templates are always strict JSON. + const root = parseTree(templateText); const index = root === undefined ? undefined : buildIndex(root); return { @@ -121,19 +123,6 @@ function buildIndex(root: Node): Record { return index; } -/** - * Parse the template into its syntax tree, or `undefined` if it is not valid - * JSON. jsonc-parser is a recovering parser, so we reject anything that reported - * an error: a malformed template must yield no range rather than a wrong one. - * Synthesized templates are always strict JSON, hence no comments or trailing - * commas either. - */ -function parseTemplate(templateText: string): Node | undefined { - const errors: ParseError[] = []; - const root = parseTree(templateText, errors, { disallowComments: true, allowTrailingComma: false }); - return errors.length > 0 ? undefined : root; -} - function nodeRange(node: Node): OffsetRange { return { start: node.offset, end: node.offset + node.length }; } @@ -148,8 +137,7 @@ function members(node: Node | undefined): Array<[string, Node]> { } return (node.children ?? []).flatMap((property): Array<[string, Node]> => { const [key, value] = property.children ?? []; - // A `property` node always has both, except mid-edit in a recovering parse - // (which we already rejected), so this narrows the type. + // A `property` node always has both; this narrows the type. return key === undefined || value === undefined ? [] : [[key.value as string, value]]; }); } diff --git a/packages/@aws-cdk/cdk-explorer/package.json b/packages/@aws-cdk/cdk-explorer/package.json index a17ad489f..b0abf3859 100644 --- a/packages/@aws-cdk/cdk-explorer/package.json +++ b/packages/@aws-cdk/cdk-explorer/package.json @@ -64,7 +64,6 @@ "@jridgewell/trace-mapping": "^0.3", "chokidar": "^4", "convert-source-map": "^2", - "jsonc-parser": "^3.3.1", "p-limit": "^3", "vscode-jsonrpc": "^8", "vscode-languageserver": "^9", diff --git a/packages/@aws-cdk/cdk-explorer/test/core/json-source-parser.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/json-source-parser.test.ts new file mode 100644 index 000000000..cb5526070 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/json-source-parser.test.ts @@ -0,0 +1,229 @@ +import { findNodeAtLocation, parseTree, type Node } from '../../lib/core/json-source-parser'; + +/** + * The oracle for every range in this suite: a node's range is correct iff the + * substring it selects re-parses (with the real `JSON.parse`) to the value the + * node stands for. No hand-counted offsets anywhere. + */ +function sliceOf(text: string, node: Node): string { + return text.slice(node.offset, node.offset + node.length); +} + +function parse(text: string): Node { + const root = parseTree(text); + expect(root).toBeDefined(); + return root!; +} + +describe('accepts whatever JSON.parse accepts', () => { + // Round-trip: for each document, every node's slice must re-parse to its value. + const documents: Record = { + 'an object': { a: 1, b: 'two', c: true, d: null }, + 'an array': [1, 2, [3, [4]]], + 'nested objects': { a: { b: { c: { d: [] } } } }, + 'an empty object': {}, + 'an empty array': [], + 'a top-level string': 'hello', + 'a top-level number': 42, + 'a top-level boolean': false, + 'a top-level null': null, + 'strings with escapes': { s: 'quote " backslash \\ slash / tab \t newline \n brace }' }, + 'strings with unicode': { s: 'caf\u00e9 \u2603 \ud83d\ude00' }, + 'numbers of every shape': [0, -0, 1, -1, 1.5, -1.5, 1e10, 1e-10, 1.5e+10, -0.0001, 1e999], + 'deeply quoted keys': { 'a.b/c~d': 1, '': 2, '\u00e9': 3 }, + }; + + test.each(Object.entries(documents))('%s round-trips every node', (_name, value) => { + // Two serializations, because indentation changes every offset. + for (const text of [JSON.stringify(value), JSON.stringify(value, undefined, 1)]) { + expectNodeSlicesReparse(text, parse(text)); + } + }); + + /** Every node in the tree must slice to text that re-parses to that node's value. */ + function expectNodeSlicesReparse(text: string, node: Node): void { + if (node.type === 'property') { + const [key, value] = node.children!; + // A property spans `"key": value`, which is an object body: brace it to re-parse. + expect(JSON.parse(`{${sliceOf(text, node)}}`)).toEqual({ [key.value as string]: JSON.parse(sliceOf(text, value)) }); + } else { + expect(JSON.parse(sliceOf(text, node))).toEqual(nodeValue(node)); + } + for (const child of node.children ?? []) { + expectNodeSlicesReparse(text, child); + } + } + + /** The plain JS value a node stands for, rebuilt from the tree alone. */ + function nodeValue(node: Node): unknown { + switch (node.type) { + case 'object': + return Object.fromEntries((node.children ?? []).map((p) => [p.children![0].value, nodeValue(p.children![1])])); + case 'array': + return (node.children ?? []).map(nodeValue); + default: + return node.value; + } + } +}); + +describe('node shape', () => { + test('an object has one property child per member, in document order', () => { + const root = parse('{"a": 1, "b": 2}'); + + expect(root.type).toBe('object'); + expect(root.children!.map((p) => p.children![0].value)).toEqual(['a', 'b']); + expect(root.children!.every((p) => p.type === 'property')).toBe(true); + }); + + test('a property range covers the key through the value, excluding the comma', () => { + const text = '{"a": 1, "b": 2}'; + const [a, b] = parse(text).children!; + + expect(sliceOf(text, a)).toBe('"a": 1'); + expect(sliceOf(text, b)).toBe('"b": 2'); + }); + + test('a string node range includes both quotes, and its value is unescaped', () => { + const text = '{"k": "a\\"b\\n"}'; + const value = parse(text).children![0].children![1]; + + expect(sliceOf(text, value)).toBe('"a\\"b\\n"'); + expect(value.value).toBe('a"b\n'); + }); + + test('scalar node types are tagged', () => { + const root = parse('[1, "s", true, false, null, {}, []]'); + + expect(root.children!.map((c) => c.type)) + .toEqual(['number', 'string', 'boolean', 'boolean', 'null', 'object', 'array']); + }); + + test('scalars carry their parsed value, containers do not', () => { + const root = parse('[1.5, "s", true, null, {}]'); + + expect(root.children!.map((c) => c.value)).toEqual([1.5, 's', true, null, undefined]); + }); + + test('a surrogate pair escape becomes one astral character', () => { + const value = parse('{"k": "\\ud83d\\ude00"}').children![0].children![1]; + + expect(value.value).toBe('\u{1f600}'); + }); + + test('offsets are relative to the whole document, including leading whitespace', () => { + const text = '\n\n {"a": 1}'; + const root = parse(text); + + expect(root.offset).toBe(5); + expect(sliceOf(text, root)).toBe('{"a": 1}'); + }); + + test('a duplicate key keeps both properties in the tree', () => { + // JSON.parse resolves last-wins; the tree is positional, so both survive and + // lookups pick the last (see findNodeAtLocation). + const root = parse('{"a": 1, "a": 2}'); + + expect(root.children).toHaveLength(2); + }); +}); + +describe('rejects what JSON.parse rejects', () => { + const invalid: Record = { + 'a trailing comma in an object': '{"a": 1,}', + 'a trailing comma in an array': '[1,]', + 'a line comment': '{"a": 1} // hi', + 'a block comment': '{/* hi */ "a": 1}', + 'a single-quoted string': "{'a': 1}", + 'an unquoted key': '{a: 1}', + 'an unterminated string': '{"a": "x}', + 'an unterminated object': '{"a": 1', + 'an unterminated array': '[1, 2', + 'a missing colon': '{"a" 1}', + 'a missing comma': '{"a": 1 "b": 2}', + 'a bare word': 'nope', + 'empty text': '', + 'only whitespace': ' \n ', + 'trailing content': '{"a": 1} {"b": 2}', + 'a leading plus': '+1', + 'a leading zero': '01', + 'a bare fraction': '.5', + 'a trailing decimal point': '5.', + 'a hex number': '0x10', + 'Infinity': 'Infinity', + 'NaN': 'NaN', + 'an undefined literal': 'undefined', + 'a capitalized literal': 'True', + 'a raw newline in a string': '{"a": "x\ny"}', + 'a raw tab in a string': '{"a": "x\ty"}', + 'an invalid escape': '{"a": "\\q"}', + 'a short unicode escape': '{"a": "\\u12"}', + 'a non-hex unicode escape': '{"a": "\\uZZZZ"}', + 'a missing value': '{"a": }', + 'a stray closing brace': '}', + }; + + test.each(Object.entries(invalid))('%s', (_name, text) => { + expect(parseTree(text)).toBeUndefined(); + // The oracle: whatever JSON.parse refuses, we refuse. + expect(() => JSON.parse(text)).toThrow(); + }); + + test('rejects nesting deeper than the depth limit instead of overflowing the stack', () => { + const deep = '['.repeat(5000) + ']'.repeat(5000); + + expect(parseTree(deep)).toBeUndefined(); + }); + + test('accepts nesting up to a depth a real template might reach', () => { + const text = '['.repeat(100) + ']'.repeat(100); + + expect(parseTree(text)).toBeDefined(); + }); +}); + +describe('findNodeAtLocation', () => { + const TEXT = JSON.stringify({ + Resources: { Topic: { Type: 'AWS::SNS::Topic', Properties: { Tags: [{ Key: 'k' }] } } }, + }, undefined, 1); + const ROOT = parse(TEXT); + + test('walks object keys', () => { + const node = findNodeAtLocation(ROOT, ['Resources', 'Topic', 'Type'])!; + + expect(node.value).toBe('AWS::SNS::Topic'); + }); + + test('walks array indices', () => { + const node = findNodeAtLocation(ROOT, ['Resources', 'Topic', 'Properties', 'Tags', 0, 'Key'])!; + + expect(node.value).toBe('k'); + }); + + test('the node it returns slices to that subtree', () => { + const node = findNodeAtLocation(ROOT, ['Resources', 'Topic'])!; + + expect(JSON.parse(sliceOf(TEXT, node))).toEqual({ Type: 'AWS::SNS::Topic', Properties: { Tags: [{ Key: 'k' }] } }); + }); + + test('returns undefined for a missing key, a missing index, or the wrong container kind', () => { + expect(findNodeAtLocation(ROOT, ['Nope'])).toBeUndefined(); + expect(findNodeAtLocation(ROOT, ['Resources', 'Topic', 'Properties', 'Tags', 7])).toBeUndefined(); + // An object indexed as an array, and an array indexed as an object. + expect(findNodeAtLocation(ROOT, ['Resources', 0])).toBeUndefined(); + expect(findNodeAtLocation(ROOT, ['Resources', 'Topic', 'Properties', 'Tags', 'Key'])).toBeUndefined(); + // Walking past a scalar. + expect(findNodeAtLocation(ROOT, ['Resources', 'Topic', 'Type', 'more'])).toBeUndefined(); + }); + + test('returns the root for an empty path, and undefined for an undefined root', () => { + expect(findNodeAtLocation(ROOT, [])).toBe(ROOT); + expect(findNodeAtLocation(undefined, ['Resources'])).toBeUndefined(); + }); + + test('a duplicate key resolves to the last one, like JSON.parse', () => { + const text = '{"a": 1, "a": 2}'; + + expect(findNodeAtLocation(parse(text), ['a'])!.value).toBe(JSON.parse(text).a); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts index b4874001f..cb7663f06 100644 --- a/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/core/template-ranges.test.ts @@ -75,9 +75,8 @@ describe('resolveResourceRange', () => { }); test('returns undefined for invalid JSON (for example a trailing comma)', () => { - // jsonc-parser recovers from syntax errors, but we reject any template it - // reported an error for, so a malformed template yields no range rather than - // a wrong one. Synthesized templates are always strict JSON. + // A malformed template yields no range rather than a wrong one. Synthesized + // templates are always strict JSON, so nothing is lost by being strict here. const invalid = '{\n "Resources": {\n "B": {\n "Type": "AWS::S3::Bucket",\n }\n }\n}'; expect(resolveResourceRange(invalid, 'B')).toBeUndefined(); }); diff --git a/yarn.lock b/yarn.lock index 46f8ec5bc..565c8a79d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -196,7 +196,6 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.5" jest: "npm:^29.7.0" jest-junit: "npm:^16" - jsonc-parser: "npm:^3.3.1" nx: "npm:^22.7.12" p-limit: "npm:^3" prettier: "npm:^2.8" @@ -10830,13 +10829,6 @@ __metadata: languageName: node linkType: hard -"jsonc-parser@npm:^3.3.1": - version: 3.3.1 - resolution: "jsonc-parser@npm:3.3.1" - checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 - languageName: node - linkType: hard - "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" From 47e5e4ad3df406415f2f7e3f9f7d1bc43e7fb2f1 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Oct 2026 10:13:18 +0200 Subject: [PATCH 15/16] Get rid of AbortSignal.any --- .../lib/core/read-cdk-assembly.ts | 29 ++++--------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts index 3b720ea91..7f13f9e0c 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/read-cdk-assembly.ts @@ -5,13 +5,15 @@ import { ToolkitError, type IReadableCloudAssembly, type Toolkit } from '@aws-cd */ export async function retryOnLock(abort: AbortSignal, fn: () => Promise) { let delayMs = 500; - const deadline = anyAbortSignal([abort, AbortSignal.timeout(60 * 60 * 1000)]); + + const deadline = Date.now() + 60 * 60 * 1000; while (true) { - deadline.throwIfAborted(); + abort.throwIfAborted(); + try { return await fn(); } catch (error: any) { - if (!ToolkitError.isLockError(error)) { + if (!ToolkitError.isLockError(error) || Date.now() > deadline) { throw error; } await sleep(Math.floor(Math.random() * delayMs)); @@ -29,24 +31,3 @@ export async function readAssemblyDirectory(toolkit: Toolkit, abort: AbortSignal // NOTE: Purposely using asmSource.produce() here: toolkit.synth() would throw on validation failures and we don't necessarily want that. return retryOnLock(abort, () => asmSource.produce({ abort: abort })); } - -/** - * Polyfill for AbortSignal.any, which may not be available in all environments. - */ -function anyAbortSignal(signals: AbortSignal[]): AbortSignal { - // Return real implementation if available; we want to test our polyfill, so don't do the - // fallback when testing. - if (typeof AbortSignal.any === 'function' && process.env.NODE_ENV !== 'test') { - return AbortSignal.any(signals); - } - const controller = new AbortController(); - for (const signal of signals) { - // If we start aborted, no need to add the event listener. - if (signal.aborted) { - controller.abort(); - break; - } - signal.addEventListener('abort', () => controller.abort()); - } - return controller.signal; -} From 120c1be6a22c3c01f43efaad339a3fa24f3d25cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:26:00 +0000 Subject: [PATCH 16/16] chore: self mutation Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- packages/aws-cdk/THIRD_PARTY_LICENSES | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 9a687c909..30569f719 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -11764,10 +11764,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------- - -** jsonc-parser@3.3.1 - https://www.npmjs.com/package/jsonc-parser/v/3.3.1 | MIT - ---------------- ** jsonfile@6.2.0 - https://www.npmjs.com/package/jsonfile/v/6.2.0 | MIT