diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts index 5bd7797c5..9e292e50f 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -115,6 +115,93 @@ describe('Backend Functions - extractConnectionIds', () => { expect(extractConnectionIds(ast, filePath)).toEqual([]); }); + // This extractor receives the ESTree Program from Rollup's parser; TS-only + // syntax such as `as const` is outside this helper's parser boundary. + test.each([ + { + description: 'same-file const string identifiers', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTION_ID = 'same-file-const'; + request({ connectionId: CONNECTION_ID }); + `, + expected: ['same-file-const'], + }, + { + description: 'exported same-file const string identifiers', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + export const CONNECTION_ID = 'exported-same-file-const'; + request({ connectionId: CONNECTION_ID }); + `, + expected: ['exported-same-file-const'], + }, + { + description: 'same-file const-to-const chains', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const A = 'const-chain'; + const B = A; + const C = B; + request({ connectionId: C }); + `, + expected: ['const-chain'], + }, + { + description: 'inline static template literals', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: \`inline-static-template\` }); + `, + expected: ['inline-static-template'], + }, + { + description: 'same-file const static template literals', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTION_ID = \`const-static-template\`; + request({ connectionId: CONNECTION_ID }); + `, + expected: ['const-static-template'], + }, + { + description: 'same-file const object members with identifier keys', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { + HTTP: 'object-identifier-key', + }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: ['object-identifier-key'], + }, + { + description: 'same-file const object members with string-literal keys', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { + 'HTTP': 'object-string-key', + }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: ['object-string-key'], + }, + { + description: 'same-file const object members whose values are const identifiers', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const HTTP_CONNECTION_ID = 'object-const-value'; + const CONNECTIONS = { + HTTP: HTTP_CONNECTION_ID, + }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: ['object-const-value'], + }, + ])('Should resolve $description', ({ code, expected }) => { + expect(extractConnectionIds(parseModule(code), filePath)).toEqual(expected); + }); + test.each([ { description: 'non-object first arguments', @@ -231,41 +318,153 @@ describe('Backend Functions - extractConnectionIds', () => { test.each([ { - description: 'identifier', - expression: 'CONNECTION_ID', - expectedType: 'Identifier', + description: 'mutable let bindings', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + let CONNECTION_ID = 'mutable-let'; + request({ connectionId: CONNECTION_ID }); + `, + expected: "declared with 'let'", + }, + { + description: 'mutable var bindings', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + var CONNECTION_ID = 'mutable-var'; + request({ connectionId: CONNECTION_ID }); + `, + expected: "declared with 'var'", + }, + { + description: 'unresolved identifiers', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: CONNECTION_ID }); + `, + expected: "identifier 'CONNECTION_ID' is not a top-level same-file const binding", + }, + { + description: 'destructured connection bindings', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { HTTP: 'destructured-connection-binding' }; + const { HTTP } = CONNECTIONS; + request({ connectionId: HTTP }); + `, + expected: "identifier 'HTTP' is not a top-level same-file const binding", + }, + { + description: 'imported identifiers', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + import { CONNECTION_ID } from './connections'; + request({ connectionId: CONNECTION_ID }); + `, + expected: "imported identifier 'CONNECTION_ID' cannot be statically analyzed", + }, + { + description: 'imported object members', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + import { CONNECTIONS } from './connections'; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: "imported object 'CONNECTIONS' cannot be statically analyzed", + }, + { + description: 'dynamic template literals', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const prefix = 'conn'; + request({ connectionId: \`\${prefix}-dynamic\` }); + `, + expected: 'template literals with interpolations cannot be statically analyzed', }, { - description: 'template literal', - expression: '`conn-template`', - expectedType: 'TemplateLiteral', + description: 'binary expressions', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'conn-' + suffix }); + `, + expected: 'got BinaryExpression', }, { - description: 'member expression', - expression: 'CONNECTIONS.HTTP', - expectedType: 'MemberExpression', + description: 'function calls', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: getConnectionId() }); + `, + expected: 'got CallExpression', }, { - description: 'call expression', - expression: 'getConnectionId()', - expectedType: 'CallExpression', + description: 'env reads', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: process.env.CONNECTION_ID }); + `, + expected: 'nested or non-static member expressions cannot be statically analyzed', }, { - description: 'binary expression', - expression: "'conn-' + suffix", - expectedType: 'BinaryExpression', + description: 'computed object properties', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const key = 'HTTP'; + const CONNECTIONS = { [key]: 'computed-object-property' }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: 'computed object properties can hide connectionId object members', + }, + { + description: 'object spreads', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const BASE = { HTTP: 'spread-object' }; + const CONNECTIONS = { ...BASE }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: 'object spreads can hide connectionId object members', + }, + { + description: 'nested member chains', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { HTTP: { PROD: 'nested-member-chain' } }; + request({ connectionId: CONNECTIONS.HTTP.PROD }); + `, + expected: 'nested or non-static member expressions cannot be statically analyzed', + }, + { + description: 'computed member reads', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { HTTP: 'computed-member-read' }; + request({ connectionId: CONNECTIONS['HTTP'] }); + `, + expected: 'computed member expressions cannot be statically analyzed', + }, + { + description: 'object members missing a static property', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const CONNECTIONS = { SLACK: 'slack-connection' }; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: "object has no static 'HTTP' property", + }, + { + description: 'const object aliases', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const BASE = { HTTP: 'aliased-object' }; + const CONNECTIONS = BASE; + request({ connectionId: CONNECTIONS.HTTP }); + `, + expected: "object 'CONNECTIONS' must be initialized to an object literal", }, ])( 'Should fail closed for unsupported connectionId value expressions: $description', - ({ expression, expectedType }) => { - const ast = parseModule(` - import { request } from '@datadog/action-catalog/http/http'; - request({ connectionId: ${expression} }); - `); - - expect(() => extractConnectionIds(ast, filePath)).toThrow( - `expected an inline string literal, got ${expectedType}`, - ); + ({ code, expected }) => { + expect(() => extractConnectionIds(parseModule(code), filePath)).toThrow(expected); }, ); }); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts index a43787052..edc9d0b8e 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -14,11 +14,16 @@ import type { Property, Statement, Super, + TemplateLiteral, + VariableDeclaration, VariableDeclarator, } from 'estree'; import type { AstNode } from 'rollup'; const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +const MAX_CONST_RESOLUTION_DEPTH = 32; + +type MutableKind = 'let' | 'var'; interface ActionCatalogImports { functions: Set; @@ -26,6 +31,13 @@ interface ActionCatalogImports { unsupportedAliases: Set; } +interface SameModuleBindings { + consts: Map; + mutables: Map; + importedIdentifiers: Set; + importedNamespaces: Set; +} + class ConnectionIdExtractionError extends Error { constructor(message: string) { super(message); @@ -46,6 +58,7 @@ export function extractConnectionIds(ast: AstNode, filePath: string): string[] { } const actionImports = collectActionCatalogImports(ast); + const bindings = collectSameModuleBindings(ast); const ids = new Set(); walkWithScope(ast, actionImports, (node, shadowedBindings) => { @@ -64,7 +77,7 @@ export function extractConnectionIds(ast: AstNode, filePath: string): string[] { return; } - for (const id of extractIdsFromActionCatalogCall(node, filePath)) { + for (const id of extractIdsFromActionCatalogCall(node, bindings, filePath)) { ids.add(id); } }); @@ -100,6 +113,72 @@ function isActionCatalogSource(source: string): boolean { return source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`); } +/** + * Reports whether a variable declaration kind can be reassigned. + */ +function isMutableKind(kind: string): kind is MutableKind { + return kind === 'let' || kind === 'var'; +} + +/** + * Collects top-level same-module bindings used to resolve connectionId expressions. + */ +function collectSameModuleBindings(ast: Program): SameModuleBindings { + const consts = new Map(); + const mutables = new Map(); + const importedIdentifiers = new Set(); + const importedNamespaces = new Set(); + + for (const node of ast.body) { + if (node.type === 'VariableDeclaration') { + recordVariableDeclaration(node, consts, mutables); + } else if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + recordVariableDeclaration(node.declaration, consts, mutables); + } else if ( + node.type === 'ImportDeclaration' && + !isTypeOnlyImport(node) && + typeof node.source.value === 'string' + ) { + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier') { + if (!isTypeOnlyImportSpecifier(spec)) { + importedIdentifiers.add(spec.local.name); + } + } else if (spec.type === 'ImportDefaultSpecifier') { + importedIdentifiers.add(spec.local.name); + } else if (spec.type === 'ImportNamespaceSpecifier') { + importedNamespaces.add(spec.local.name); + } + } + } + } + + return { consts, mutables, importedIdentifiers, importedNamespaces }; +} + +/** + * Records top-level const values and mutable names from one variable declaration. + */ +function recordVariableDeclaration( + declaration: VariableDeclaration, + consts: Map, + mutables: Map, +): void { + for (const declarator of declaration.declarations) { + if (declarator.id.type !== 'Identifier') { + continue; + } + if (declaration.kind === 'const' && declarator.init) { + consts.set(declarator.id.name, declarator.init); + } else if (isMutableKind(declaration.kind)) { + mutables.set(declarator.id.name, declaration.kind); + } + } +} + /** * Collects action-catalog function imports, namespace imports, and unsupported local aliases. */ @@ -186,7 +265,11 @@ function collectActionCatalogImports(ast: Program): ActionCatalogImports { /** * Extracts connection IDs from a statically analyzable action-catalog call. */ -function extractIdsFromActionCatalogCall(call: CallExpression, filePath: string): string[] { +function extractIdsFromActionCatalogCall( + call: CallExpression, + bindings: SameModuleBindings, + filePath: string, +): string[] { failIfOptionalActionCatalogCall(call, filePath); const firstArg = call.arguments[0]; @@ -201,14 +284,9 @@ function extractIdsFromActionCatalogCall(call: CallExpression, filePath: string) if (!connectionIdValue) { return []; } - // In PR #339, only inline strings such as `{ connectionId: 'abc' }` - // are supported; later PRs widen this to const references. - if (connectionIdValue.type !== 'Literal' || typeof connectionIdValue.value !== 'string') { - fail( - `Unsupported connectionId expression in ${filePath}: expected an inline string literal, got ${connectionIdValue.type}.`, - ); - } - return [connectionIdValue.value]; + // In PR #340, this same ESTree node can be an inline string + // `{ connectionId: 'abc' }` or a same-file const like `CONNECTIONS.HTTP`. + return [resolveConnectionIdValue(connectionIdValue, bindings, filePath)]; } /** @@ -273,6 +351,209 @@ function isConnectionIdProperty(prop: Property): boolean { return prop.key.type === 'Literal' && prop.key.value === 'connectionId'; } +/** + * Resolves a supported static connectionId expression to a string. + */ +function resolveConnectionIdValue( + node: Expression, + bindings: SameModuleBindings, + filePath: string, + resolutionStack: string[] = [], +): string { + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral') { + return resolveStaticTemplateLiteral(node, filePath); + } + if (node.type === 'Identifier') { + return resolveConnectionIdIdentifier(node.name, bindings, filePath, resolutionStack); + } + if (node.type === 'MemberExpression') { + return resolveConnectionIdMember(node, bindings, filePath, resolutionStack); + } + fail( + `Unsupported connectionId expression in ${filePath}: expected a static string literal, static template literal, same-file const identifier, or same-file const object member; got ${node.type}.`, + ); +} + +/** + * Resolves a template literal that has no dynamic expressions. + */ +function resolveStaticTemplateLiteral(node: TemplateLiteral, filePath: string): string { + if (node.expressions.length > 0) { + fail( + `Unsupported connectionId expression in ${filePath}: template literals with interpolations cannot be statically analyzed.`, + ); + } + + const quasi = node.quasis[0]; + return quasi.value.cooked ?? quasi.value.raw; +} + +/** + * Resolves a connectionId identifier through same-file top-level const bindings. + */ +function resolveConnectionIdIdentifier( + name: string, + bindings: SameModuleBindings, + filePath: string, + resolutionStack: string[], +): string { + const mutableKind = bindings.mutables.get(name); + if (mutableKind) { + fail( + `Unsupported connectionId expression in ${filePath}: '${name}' is declared with '${mutableKind}' and may be reassigned; only top-level const bindings are supported.`, + ); + } + if (bindings.importedIdentifiers.has(name) || bindings.importedNamespaces.has(name)) { + fail( + `Unsupported connectionId expression in ${filePath}: imported identifier '${name}' cannot be statically analyzed in this PR.`, + ); + } + + const init = bindings.consts.get(name); + if (!init) { + fail( + `Unsupported connectionId expression in ${filePath}: identifier '${name}' is not a top-level same-file const binding.`, + ); + } + if (resolutionStack.includes(name)) { + fail( + `Unsupported connectionId expression in ${filePath}: cyclic const connectionId reference '${[ + ...resolutionStack, + name, + ].join(' -> ')}'.`, + ); + } + if (resolutionStack.length >= MAX_CONST_RESOLUTION_DEPTH) { + fail( + `Unsupported connectionId expression in ${filePath}: const connectionId reference chain is too deep.`, + ); + } + + return resolveConnectionIdValue(init, bindings, filePath, [...resolutionStack, name]); +} + +/** + * Resolves a connectionId member expression through same-file const object members. + */ +function resolveConnectionIdMember( + node: MemberExpression, + bindings: SameModuleBindings, + filePath: string, + resolutionStack: string[], +): string { + if (node.computed) { + fail( + `Unsupported connectionId expression in ${filePath}: computed member expressions cannot be statically analyzed.`, + ); + } + if (node.object.type !== 'Identifier') { + fail( + `Unsupported connectionId expression in ${filePath}: nested or non-static member expressions cannot be statically analyzed.`, + ); + } + + const objectName = node.object.name; + const propertyName = readStaticPropertyName(node.property); + if (!propertyName) { + fail( + `Unsupported connectionId expression in ${filePath}: member property must be a static identifier.`, + ); + } + + const mutableKind = bindings.mutables.get(objectName); + if (mutableKind) { + fail( + `Unsupported connectionId expression in ${filePath}: object '${objectName}' is declared with '${mutableKind}' and may be reassigned; only top-level const object literals are supported.`, + ); + } + if ( + bindings.importedIdentifiers.has(objectName) || + bindings.importedNamespaces.has(objectName) + ) { + fail( + `Unsupported connectionId expression in ${filePath}: imported object '${objectName}' cannot be statically analyzed in this PR.`, + ); + } + + const objectInit = bindings.consts.get(objectName); + if (!objectInit) { + fail( + `Unsupported connectionId expression in ${filePath}: object '${objectName}' is not a top-level same-file const binding.`, + ); + } + if (objectInit.type !== 'ObjectExpression') { + fail( + `Unsupported connectionId expression in ${filePath}: object '${objectName}' must be initialized to an object literal.`, + ); + } + + return resolveObjectMemberValue(objectInit, propertyName, bindings, filePath, resolutionStack); +} + +/** + * Resolves one static property from a const object expression. + */ +function resolveObjectMemberValue( + objectExpression: ObjectExpression, + propertyName: string, + bindings: SameModuleBindings, + filePath: string, + resolutionStack: string[], +): string { + let value: Expression | undefined; + + for (const prop of objectExpression.properties) { + if (prop.type === 'SpreadElement') { + fail( + `Unsupported connectionId expression in ${filePath}: object spreads can hide connectionId object members.`, + ); + } + if (prop.type !== 'Property') { + continue; + } + if (prop.computed) { + fail( + `Unsupported connectionId expression in ${filePath}: computed object properties can hide connectionId object members.`, + ); + } + + const key = readStaticPropertyName(prop.key); + if (key !== propertyName) { + continue; + } + if (value) { + fail( + `Unsupported connectionId expression in ${filePath}: object member '${propertyName}' is defined multiple times.`, + ); + } + value = prop.value as Expression; + } + + if (!value) { + fail( + `Unsupported connectionId expression in ${filePath}: object has no static '${propertyName}' property.`, + ); + } + + return resolveConnectionIdValue(value, bindings, filePath, resolutionStack); +} + +/** + * Reads a property name when the ESTree property key is statically known. + */ +function readStaticPropertyName(node: Node): string | undefined { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return undefined; +} + /** * Reports whether a call expression callee resolves directly to an action-catalog import. */