diff --git a/packages/plugins/live-debugger/README.md b/packages/plugins/live-debugger/README.md index c367cdee3..46258ec4d 100644 --- a/packages/plugins/live-debugger/README.md +++ b/packages/plugins/live-debugger/README.md @@ -19,6 +19,7 @@ Automatically instrument JavaScript functions at build time to enable Live Debug - [liveDebugger.honorSkipComments](#livedebuggerhonorskipcomments) - [liveDebugger.functionTypes](#livedebuggerfunctiontypes) - [liveDebugger.namedOnly](#livedebuggernamedonly) + - [liveDebugger.decorators](#livedebuggerdecorators) - [Skipped function types](#skipped-function-types) - [Runtime requirements](#runtime-requirements) - [Safe fallback when the SDK is absent](#safe-fallback-when-the-sdk-is-absent) @@ -56,6 +57,7 @@ liveDebugger?: { honorSkipComments?: boolean; functionTypes?: FunctionKind[]; namedOnly?: boolean; + decorators?: 'legacy' | 'modern'; } ``` @@ -201,6 +203,25 @@ With `namedOnly: true`: - `[1, 2].map((x) => x * 2)` — **skipped** (anonymous callback) - `function add(a, b) { return a + b; }` — **instrumented** (named declaration) +### liveDebugger.decorators + +> default: `'legacy'` + +Selects which decorator syntax the parser accepts when reading your source. The parser cannot enable both grammars at once, so pick the one your codebase uses: + +- `'legacy'` — TypeScript's `experimentalDecorators` semantics. This covers the vast majority of decorator usage, including Angular, NestJS, and TypeORM, and is the only mode that allows **parameter decorators** (e.g. `constructor(@Inject(TOKEN) svc: Svc)`). +- `'modern'` — the TC39 Stage 3 decorators proposal, including `accessor` auto-accessor fields and decorators placed before `export`. Parameter decorators are not part of this proposal. + +If a file uses the decorator style that is **not** selected, it fails to parse and is skipped (left uninstrumented) — it is never miscompiled. Files with no decorators are unaffected by this option. + +**Example — opt into Stage 3 decorators:** + +```ts +liveDebugger: { + decorators: 'modern', +} +``` + ## Skipped function types The following function types are always skipped regardless of configuration: diff --git a/packages/plugins/live-debugger/scripts/benchmark-subset.js b/packages/plugins/live-debugger/scripts/benchmark-subset.js index 09788d461..ad5590652 100644 --- a/packages/plugins/live-debugger/scripts/benchmark-subset.js +++ b/packages/plugins/live-debugger/scripts/benchmark-subset.js @@ -179,6 +179,7 @@ function benchmarkFile(filePath, repeatCount, buildRoot) { honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }), ); diff --git a/packages/plugins/live-debugger/src/index.test.ts b/packages/plugins/live-debugger/src/index.test.ts index 2bbd7f5fa..54dd441f8 100644 --- a/packages/plugins/live-debugger/src/index.test.ts +++ b/packages/plugins/live-debugger/src/index.test.ts @@ -20,6 +20,7 @@ const makeOptions = ( honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', ...overrides, }); diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts index 88f90a3e4..2c3cbc60c 100644 --- a/packages/plugins/live-debugger/src/index.ts +++ b/packages/plugins/live-debugger/src/index.ts @@ -85,6 +85,7 @@ export const getLiveDebuggerPlugin = ( honorSkipComments: pluginOptions.honorSkipComments, functionTypes: pluginOptions.functionTypes, namedOnly: pluginOptions.namedOnly, + decorators: pluginOptions.decorators, }); instrumentedCount += result.instrumentedCount; diff --git a/packages/plugins/live-debugger/src/transform/index.test.ts b/packages/plugins/live-debugger/src/transform/index.test.ts index 9a108296a..fce1a3506 100644 --- a/packages/plugins/live-debugger/src/transform/index.test.ts +++ b/packages/plugins/live-debugger/src/transform/index.test.ts @@ -3,17 +3,20 @@ // Copyright 2019-Present Datadog, Inc. import { parse } from '@babel/parser'; +import type { ParserPlugin } from '@babel/parser'; import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; import { runInNewContext } from 'vm'; import { transformCode } from './index'; +import type { TransformOptions } from './index'; -const BASE_OPTIONS = { +const BASE_OPTIONS: Omit = { filePath: '/src/utils.ts', buildRoot: '/', honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }; describe('transformCode', () => { @@ -594,6 +597,79 @@ describe('transformCode', () => { ); }); + describe('decorators', () => { + const LEGACY_PLUGINS: ParserPlugin[] = ['jsx', 'typescript', 'decorators-legacy']; + const MODERN_PLUGINS: ParserPlugin[] = [ + 'jsx', + 'typescript', + 'decorators', + 'decoratorAutoAccessors', + ]; + + function parseError(code: string, plugins: ParserPlugin[]): string | null { + try { + parse(code, { + sourceType: 'unambiguous', + plugins, + sourceFilename: '/src/utils.ts', + }); + return null; + } catch (e: unknown) { + return e instanceof Error ? e.message : String(e); + } + } + + // TypeScript `experimentalDecorators` style, including parameter decorators. + const LEGACY_DECORATED = [ + '@Component({ selector: "app" })', + 'export class AppComponent {', + ' @Input() title = "";', + ' constructor(@Inject(TOKEN) private svc: Svc) {}', + ' @HostListener("click")', + ' onClick() { return this.title; }', + '}', + ].join('\n'); + + // TC39 Stage 3 style, including an `accessor` auto-accessor field. + const MODERN_DECORATED = [ + 'class Counter {', + ' @logged accessor count = 0;', + ' @logged increment() { return (this.count += 1); }', + '}', + ].join('\n'); + + it('parses and instruments legacy decorators by default', () => { + const result = transformCode({ ...BASE_OPTIONS, code: LEGACY_DECORATED }); + + expect(result.instrumentedCount).toBeGreaterThanOrEqual(1); + expect(parseError(result.code, LEGACY_PLUGINS)).toBeNull(); + expect(result.code).toContain('$dd_probes'); + expect(result.code).toContain('catch(e)'); + }); + + it('parses and instruments modern decorators when decorators is "modern"', () => { + const result = transformCode({ + ...BASE_OPTIONS, + code: MODERN_DECORATED, + decorators: 'modern', + }); + + expect(result.instrumentedCount).toBeGreaterThanOrEqual(1); + expect(parseError(result.code, MODERN_PLUGINS)).toBeNull(); + expect(result.code).toContain('$dd_probes'); + expect(result.code).toContain('catch(e)'); + }); + + it('cannot parse legacy parameter decorators under modern mode', () => { + // Documents why the option exists: the two decorator grammars are + // mutually exclusive. A file the plugin can't parse throws here and + // is caught (and skipped) one level up in the plugin handler. + expect(() => + transformCode({ ...BASE_OPTIONS, code: LEGACY_DECORATED, decorators: 'modern' }), + ).toThrow(); + }); + }); + describe('skipping', () => { it('should skip generators', () => { const result = transformCode({ diff --git a/packages/plugins/live-debugger/src/transform/index.ts b/packages/plugins/live-debugger/src/transform/index.ts index 5abc0a099..971a77ba9 100644 --- a/packages/plugins/live-debugger/src/transform/index.ts +++ b/packages/plugins/live-debugger/src/transform/index.ts @@ -2,13 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { ParserPlugin } from '@babel/parser'; import type _traverse from '@babel/traverse'; import type * as BabelTypes from '@babel/types'; import type MagicStringType from 'magic-string'; import type { SourceMap } from 'magic-string'; import { SKIP_INSTRUMENTATION_COMMENT } from '../constants'; -import type { FunctionKind } from '../types'; +import type { DecoratorSyntax, FunctionKind } from '../types'; import type { BabelPath, BabelTypesModule } from './babel-path.types'; import { resolveCjsDefaultExport } from './cjs-interop'; @@ -22,7 +23,7 @@ type ParseFn = ( code: string, options: { sourceType: 'unambiguous'; - plugins: string[]; + plugins: ParserPlugin[]; sourceFilename: string; }, ) => BabelTypes.File; @@ -202,6 +203,21 @@ export interface TransformOptions { honorSkipComments: boolean; functionTypes: FunctionKind[] | undefined; namedOnly: boolean; + decorators: DecoratorSyntax; +} + +/** + * Build the Babel parser plugin list. Decorators are opt-in per proposal + * because Babel cannot enable the legacy and Stage 3 grammars simultaneously. + */ +function getParserPlugins(decorators: DecoratorSyntax): ParserPlugin[] { + if (decorators === 'modern') { + // TC39 Stage 3 decorators, including `accessor` auto-accessor fields. + return ['jsx', 'typescript', 'decorators', 'decoratorAutoAccessors']; + } + + // TypeScript's `experimentalDecorators` grammar (also allows parameter decorators). + return ['jsx', 'typescript', 'decorators-legacy']; } export interface TransformResult { @@ -220,7 +236,8 @@ export interface TransformResult { * Uses Babel to parse and read the AST, then MagicString for injection. */ export function transformCode(options: TransformOptions): TransformResult { - const { code, filePath, buildRoot, honorSkipComments, functionTypes, namedOnly } = options; + const { code, filePath, buildRoot, honorSkipComments, functionTypes, namedOnly, decorators } = + options; let failedCount = 0; let instrumentedCount = 0; @@ -255,7 +272,7 @@ export function transformCode(options: TransformOptions): TransformResult { getTransformRuntime(); const ast = parse(code, { sourceType: 'unambiguous', - plugins: ['jsx', 'typescript'], + plugins: getParserPlugins(decorators), sourceFilename: filePath, }); diff --git a/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts b/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts index 575802aae..3c269d993 100644 --- a/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts +++ b/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts @@ -2,12 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -const BASE_OPTIONS = { +import type { TransformOptions } from './index'; + +const BASE_OPTIONS: Omit = { filePath: '/src/utils.ts', buildRoot: '/', honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }; const PEER_DEPS = ['@babel/parser', '@babel/traverse', '@babel/types', 'magic-string'] as const; diff --git a/packages/plugins/live-debugger/src/types.ts b/packages/plugins/live-debugger/src/types.ts index f6d69074a..601ecf825 100644 --- a/packages/plugins/live-debugger/src/types.ts +++ b/packages/plugins/live-debugger/src/types.ts @@ -13,6 +13,14 @@ export const VALID_FUNCTION_KINDS = [ export type FunctionKind = (typeof VALID_FUNCTION_KINDS)[number]; +// Which decorator syntax the parser should accept. `legacy` matches +// TypeScript's `experimentalDecorators` (Angular, NestJS, TypeORM, including +// parameter decorators); `modern` matches the TC39 Stage 3 proposal +// (`accessor` fields, decorators before `export`). +export const VALID_DECORATOR_SYNTAXES = ['legacy', 'modern'] as const; + +export type DecoratorSyntax = (typeof VALID_DECORATOR_SYNTAXES)[number]; + export type LiveDebuggerOptions = { enable?: boolean; include?: (string | RegExp)[]; @@ -20,6 +28,7 @@ export type LiveDebuggerOptions = { honorSkipComments?: boolean; functionTypes?: FunctionKind[]; namedOnly?: boolean; + decorators?: DecoratorSyntax; }; export type LiveDebuggerOptionsWithDefaults = { @@ -29,4 +38,5 @@ export type LiveDebuggerOptionsWithDefaults = { honorSkipComments: boolean; functionTypes: FunctionKind[] | undefined; namedOnly: boolean; + decorators: DecoratorSyntax; }; diff --git a/packages/plugins/live-debugger/src/validate.test.ts b/packages/plugins/live-debugger/src/validate.test.ts index 09a282a58..96e5eb951 100644 --- a/packages/plugins/live-debugger/src/validate.test.ts +++ b/packages/plugins/live-debugger/src/validate.test.ts @@ -39,6 +39,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -51,6 +52,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -63,6 +65,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -169,6 +172,16 @@ describe('validateOptions', () => { input: makeConfig({ namedOnly: false }), expected: expect.objectContaining({ namedOnly: false }), }, + { + description: 'accept decorators as legacy', + input: makeConfig({ decorators: 'legacy' }), + expected: expect.objectContaining({ decorators: 'legacy' }), + }, + { + description: 'accept decorators as modern', + input: makeConfig({ decorators: 'modern' }), + expected: expect.objectContaining({ decorators: 'modern' }), + }, { description: 'accept an empty include array', input: makeConfig({ include: [] }), @@ -306,6 +319,28 @@ describe('validateOptions', () => { }); }); + describe('invalid decorators', () => { + const cases = [ + { + description: 'reject decorators with an unknown value', + input: makeInvalidConfig({ decorators: 'stage3' }), + }, + { + description: 'reject decorators when a boolean', + input: makeInvalidConfig({ decorators: true }), + }, + ]; + + test.each(cases)('should $description', ({ input }) => { + expect(() => validateOptions(input, mockLogger)).toThrow( + `Invalid configuration for ${PLUGIN_NAME}.`, + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringMatching(/decorators.*must be one of/), + ); + }); + }); + describe('multiple errors', () => { it('should aggregate all validation errors before throwing', () => { const input = makeInvalidConfig({ diff --git a/packages/plugins/live-debugger/src/validate.ts b/packages/plugins/live-debugger/src/validate.ts index eb8020792..286eeb764 100644 --- a/packages/plugins/live-debugger/src/validate.ts +++ b/packages/plugins/live-debugger/src/validate.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; -import { VALID_FUNCTION_KINDS } from './types'; +import { VALID_DECORATOR_SYNTAXES, VALID_FUNCTION_KINDS } from './types'; const red = chalk.bold.red; @@ -73,6 +73,14 @@ export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptio errors.push(`${red('namedOnly')} must be a boolean`); } + // Validate decorators option + if ( + pluginConfig.decorators !== undefined && + !VALID_DECORATOR_SYNTAXES.includes(pluginConfig.decorators) + ) { + errors.push(`${red('decorators')} must be one of: ${VALID_DECORATOR_SYNTAXES.join(', ')}`); + } + // Throw if there are any errors if (errors.length) { log.error(`\n - ${errors.join('\n - ')}`); @@ -97,5 +105,6 @@ export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptio honorSkipComments: pluginConfig.honorSkipComments ?? true, functionTypes: pluginConfig.functionTypes, namedOnly: pluginConfig.namedOnly ?? false, + decorators: pluginConfig.decorators ?? 'legacy', }; };