From fc9b20ae8b4a096d76ebaafc210694bf49dca364 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 7 May 2026 15:00:30 -0400 Subject: [PATCH 01/15] POC extract connection IDs with eslint-scope --- ...rframe-npm-1.1.4-a2c339a65f-1f85bda673.zip | Bin 0 -> 6974 bytes packages/plugins/apps/package.json | 5 +- .../extract-connection-ids.test.ts | 337 ++++++++++++++ .../ast-parsing/extract-connection-ids.ts | 423 ++++++++++++++++++ packages/plugins/apps/src/index.test.ts | 37 +- packages/plugins/apps/src/index.ts | 9 +- packages/tests/jest.config.ts | 3 + packages/tests/src/_jest/zimmerframe.cjs | 10 + yarn.lock | 14 +- 9 files changed, 817 insertions(+), 21 deletions(-) create mode 100644 .yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts create mode 100644 packages/tests/src/_jest/zimmerframe.cjs diff --git a/.yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip b/.yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip new file mode 100644 index 0000000000000000000000000000000000000000..097b869b5ca85570499bc87fc027521de300f97a GIT binary patch literal 6974 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 { + test('Should extract inline string literal connection IDs from named action-catalog imports', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + return request({ connectionId: 'conn-b', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-b']); + }); + + test('Should dedupe and sort connection IDs', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'conn-b', inputs: {} }); + request({ connectionId: 'conn-a', inputs: {} }); + request({ connectionId: 'conn-b', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-a', 'conn-b']); + }); + + test('Should include same-file helper action calls', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + function helper() { + return request({ connectionId: 'conn-helper', inputs: {} }); + } + + export function run() { + return helper(); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-helper']); + }); + + test('Should detect default and namespace action-catalog imports', () => { + const ast = parse(` + import request from '@datadog/action-catalog/http/http'; + import * as slack from '@datadog/action-catalog/slack/messages'; + + export function run() { + request({ connectionId: 'conn-default', inputs: {} }); + slack.postMessage({ connectionId: 'conn-namespace', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-default', 'conn-namespace']); + }); + + test('Should ignore non-action-catalog calls with connectionId properties', () => { + const ast = parse(` + import { request } from './local'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore action-catalog object arguments without connectionId', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog imports', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + const importDeclaration = (ast as unknown as { body: Array<{ importKind?: string }> }) + .body[0]; + importDeclaration.importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog import specifiers', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + const importSpecifier = ( + ast as unknown as { + body: Array<{ specifiers: Array<{ importKind?: string }> }>; + } + ).body[0].specifiers[0]; + importSpecifier.importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test.each([ + { + description: 'function parameters that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + return request({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'function parameters that shadow namespace imports', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + + export function run(http) { + return http.request({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'catch parameters that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + try { + throw new Error('nope'); + } catch (request) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'local aliases of shadowed parameters', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + const action = request; + action({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'for-of bindings that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(handlers) { + for (const request of handlers) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'for-statement bindings that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(handlers) { + for (const request = handlers.next; request;) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'for-in bindings that shadow namespace imports', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + + export function run(clients) { + for (const http in clients) { + http.request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + ])( + 'Should not treat shadowed action-catalog import names as action calls: $description', + ({ code }) => { + expect(extractConnectionIds(parse(code), filePath)).toEqual([]); + }, + ); + + test.each([ + { + description: 'identifier value', + source: 'const ID = "conn"; request({ connectionId: ID, inputs: {} });', + expectedType: 'Identifier', + }, + { + description: 'template literal value', + source: 'request({ connectionId: `conn`, inputs: {} });', + expectedType: 'TemplateLiteral', + }, + { + description: 'member expression value', + source: 'request({ connectionId: CONNECTIONS.HTTP, inputs: {} });', + expectedType: 'MemberExpression', + }, + { + description: 'call expression value', + source: 'request({ connectionId: getConnectionId(), inputs: {} });', + expectedType: 'CallExpression', + }, + { + description: 'binary expression value', + source: "request({ connectionId: 'conn-' + suffix, inputs: {} });", + expectedType: 'BinaryExpression', + }, + ])('Should fail closed for unsupported $description', ({ source, expectedType }) => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + ${source} + } + `); + + expect(() => extractConnectionIds(ast, filePath)).toThrow( + `expected an inline string literal, got ${expectedType}`, + ); + }); + + test.each([ + { + description: 'non-object first arguments', + source: 'request(opts);', + expectedMessage: 'non-object action-catalog call arguments', + }, + { + description: 'spread-composed object arguments', + source: 'request({ ...opts });', + expectedMessage: 'spread object arguments', + }, + { + description: 'computed connectionId keys', + source: "request({ ['connectionId']: 'conn' });", + expectedMessage: 'computed object property keys', + }, + { + description: 'optional action calls', + source: "request?.({ connectionId: 'conn' });", + expectedMessage: 'optional action-catalog calls', + }, + { + description: 'action-catalog import aliases', + source: "const action = request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + }, + { + description: 'action-catalog namespace member aliases', + source: "const action = http.request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + { + description: 'action-catalog namespace destructuring aliases', + source: "const { request: action } = http; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + { + description: 'multiple connectionId properties', + source: "request({ connectionId: 'conn-a', connectionId: 'conn-b' });", + expectedMessage: 'multiple connectionId properties', + }, + { + description: 'accessor connectionId properties', + source: 'request({ get connectionId() { return CONNECTIONS.HTTP; } });', + expectedMessage: 'accessor connectionId properties', + }, + { + description: 'computed namespace calls', + source: "http['request']({ connectionId: 'conn' });", + expectedMessage: 'optional or computed action-catalog namespace calls', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + ])( + 'Should fail closed for unsupported $description', + ({ source, expectedMessage, importStatement }) => { + const ast = parse(` + ${importStatement ?? "import { request } from '@datadog/action-catalog/http/http';"} + + export function run() { + ${source} + } + `); + + expect(() => extractConnectionIds(ast, filePath)).toThrow(expectedMessage); + }, + ); + + test('Should return an empty allowlist when no connection IDs are present', () => { + const ast = parse(` + export function run() { + return 'ok'; + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts new file mode 100644 index 000000000..a3f7b9ae9 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -0,0 +1,423 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import * as eslintScope from 'eslint-scope'; +import type { + BaseNode, + Identifier, + MemberExpression, + Node, + ObjectExpression, + Pattern, + Program, + Property, + SimpleCallExpression, + VariableDeclarator, +} from 'estree'; +import type { AstNode } from 'rollup'; +import { walk } from 'zimmerframe'; + +import { isProgramNode } from './type-guards'; + +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +const CONNECTION_ID_PROPERTY = 'connectionId'; + +interface ActionCatalogImports { + functions: Set; + namespaces: Set; + unsupportedAliases: Set; +} + +// Do not trust names alone when deciding whether `request(...)` is an +// action-catalog call. A local parameter or variable can reuse the same name: +// +// import { request } from '@datadog/action-catalog/http/http'; +// request({ connectionId: 'real' }); +// +// function run(request) { +// request({ connectionId: 'local' }); +// } +// +// Both calls use the text `request`, but only the first one refers to the +// imported action-catalog function. eslint-scope tells us which declaration each +// identifier refers to, and ScopeAnalysis keeps the lookup tables we need while +// walking the file. +interface ScopeAnalysis { + // The full scope model from eslint-scope, used when we need declared + // variables for aliases like `const action = request`. + scopeManager: eslintScope.ScopeManager; + + // Maps each identifier node to the declaration eslint-scope resolved it to. + references: Map; + + // The actual import variables for action-catalog functions and namespaces. + // Call sites must resolve to one of these variables to count. + actionFunctions: Set; + actionNamespaces: Set; +} + +type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; +type NodeWithRange = Node & { start?: number; end?: number; range?: [number, number] }; + +export function extractConnectionIds(ast: AstNode, filePath: string): string[] { + if (!isProgramNode(ast)) { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, + ); + } + + const imports = collectActionCatalogImports(ast); + const importedNames = getImportedNames(imports); + if (importedNames.size === 0) { + return []; + } + + const scopeAnalysis = analyzeScopes(ast, imports); + collectUnsupportedActionCatalogAliases(ast, imports, scopeAnalysis); + + const connectionIds = new Set(); + walk(ast as Node, scopeAnalysis, { + CallExpression(node, { state }) { + const actionCall = classifyActionCatalogCall(node, imports, state, filePath); + if (!actionCall) { + return; + } + + extractConnectionIdFromActionCall(node, filePath, connectionIds); + }, + }); + + return [...connectionIds].sort(); +} + +function collectActionCatalogImports(ast: Program): ActionCatalogImports { + const functions = new Set(); + const namespaces = new Set(); + const unsupportedAliases = new Set(); + + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration' || !isActionCatalogSource(node.source.value)) { + continue; + } + if (isTypeOnly(node)) { + continue; + } + + for (const specifier of node.specifiers) { + if (isTypeOnly(specifier)) { + continue; + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaces.add(specifier.local.name); + } else { + functions.add(specifier.local.name); + } + } + } + + return { functions, namespaces, unsupportedAliases }; +} + +function isActionCatalogSource(source: unknown): boolean { + return ( + typeof source === 'string' && + (source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`)) + ); +} + +function isTypeOnly(node: NodeWithOptionalImportKind): boolean { + return node.importKind === 'type'; +} + +function classifyActionCatalogCall( + node: SimpleCallExpression, + imports: ActionCatalogImports, + scopeAnalysis: ScopeAnalysis, + filePath: string, +): boolean { + const callee = node.callee; + + if (callee.type === 'Identifier') { + if (resolvesTo(callee, imports.unsupportedAliases, scopeAnalysis)) { + throw unsupportedActionCatalogCall(filePath, 'action-catalog call aliases'); + } + if (resolvesTo(callee, scopeAnalysis.actionFunctions, scopeAnalysis)) { + if (node.optional) { + throw unsupportedActionCatalogCall(filePath, 'optional action-catalog calls'); + } + return true; + } + return false; + } + + if (callee.type !== 'MemberExpression') { + return false; + } + + if (!isNamespaceMember(callee, scopeAnalysis)) { + return false; + } + + if (node.optional || hasUnsupportedMemberAccess(callee)) { + throw unsupportedActionCatalogCall( + filePath, + 'optional or computed action-catalog namespace calls', + ); + } + return true; +} + +function collectUnsupportedActionCatalogAliases( + ast: Program, + imports: ActionCatalogImports, + scopeAnalysis: ScopeAnalysis, +): void { + walk(ast as Node, scopeAnalysis, { + VariableDeclarator(node, { state }) { + for (const aliasVariable of getActionCatalogAliasVariables(node, state)) { + imports.unsupportedAliases.add(aliasVariable); + } + }, + }); +} + +function getActionCatalogAliasVariables( + node: VariableDeclarator, + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + if ( + node.id.type === 'Identifier' && + node.init?.type === 'Identifier' && + resolvesTo(node.init, scopeAnalysis.actionFunctions, scopeAnalysis) + ) { + return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); + } + + if ( + node.id.type === 'Identifier' && + node.init?.type === 'MemberExpression' && + isNamespaceMember(node.init, scopeAnalysis) + ) { + return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); + } + + if ( + node.id.type !== 'ObjectPattern' || + node.init?.type !== 'Identifier' || + !resolvesTo(node.init, scopeAnalysis.actionNamespaces, scopeAnalysis) + ) { + return []; + } + + const aliasNames = node.id.properties.flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternNames(property.value); + }); + return getDeclaredVariables(node, scopeAnalysis, aliasNames); +} + +function getImportedNames(imports: ActionCatalogImports): Set { + return new Set([...imports.functions, ...imports.namespaces]); +} + +function isNamespaceMember(node: MemberExpression, scopeAnalysis: ScopeAnalysis): boolean { + const root = getMemberExpressionRoot(node); + return !!root && resolvesTo(root, scopeAnalysis.actionNamespaces, scopeAnalysis); +} + +function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined { + if (node.object.type === 'Identifier') { + return node.object; + } + if (node.object.type === 'MemberExpression') { + return getMemberExpressionRoot(node.object); + } + return undefined; +} + +function hasUnsupportedMemberAccess(node: MemberExpression): boolean { + if (node.optional || node.computed) { + return true; + } + return node.object.type === 'MemberExpression' && hasUnsupportedMemberAccess(node.object); +} + +function extractConnectionIdFromActionCall( + node: SimpleCallExpression, + filePath: string, + connectionIds: Set, +): void { + const [firstArg] = node.arguments; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + throw unsupportedActionCatalogCall(filePath, 'non-object action-catalog call arguments'); + } + + const connectionIdProperty = findConnectionIdProperty(firstArg, filePath); + if (!connectionIdProperty) { + return; + } + + const { value } = connectionIdProperty; + if (value.type === 'Literal' && typeof value.value === 'string') { + connectionIds.add(value.value); + return; + } + + throw unsupportedConnectionId(filePath, value.type); +} + +function findConnectionIdProperty( + objectExpression: ObjectExpression, + filePath: string, +): Property | undefined { + let connectionIdProperty: Property | undefined; + for (const property of objectExpression.properties) { + if (property.type === 'SpreadElement') { + throw unsupportedActionCatalogCall(filePath, 'spread object arguments'); + } + if (property.computed) { + throw unsupportedActionCatalogCall(filePath, 'computed object property keys'); + } + if (isConnectionIdKey(property)) { + if (connectionIdProperty) { + throw unsupportedActionCatalogCall(filePath, 'multiple connectionId properties'); + } + if (property.kind !== 'init') { + throw unsupportedActionCatalogCall(filePath, 'accessor connectionId properties'); + } + connectionIdProperty = property; + } + } + return connectionIdProperty; +} + +function isConnectionIdKey(property: Property): boolean { + if (property.key.type === 'Identifier') { + return property.key.name === CONNECTION_ID_PROPERTY; + } + return property.key.type === 'Literal' && property.key.value === CONNECTION_ID_PROPERTY; +} + +function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { + return new Error( + `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, + ); +} + +function unsupportedConnectionId(filePath: string, type: string): Error { + return new Error( + `Unsupported action-catalog connectionId in ${filePath}: expected an inline string literal, got ${type}.`, + ); +} + +function analyzeScopes(ast: Program, imports: ActionCatalogImports): ScopeAnalysis { + ensureRanges(ast); + const scopeManager = eslintScope.analyze(ast, { + ecmaVersion: 2022, + ignoreEval: true, + sourceType: 'module', + }); + + const references = new Map(); + const actionFunctions = new Set(); + const actionNamespaces = new Set(); + + // Cache every identifier reference so call classification can ask "what + // variable does this exact node resolve to?" without re-walking scopes. + for (const scope of scopeManager.scopes) { + for (const reference of scope.references) { + references.set(reference.identifier, reference); + } + + // Save the actual import declarations that came from action-catalog. + // Later, when we see `request(...)`, we check whether that `request` + // points back to one of these declarations instead of to a local + // parameter or variable with the same name. + for (const variable of scope.variables) { + if (!isImportVariable(variable)) { + continue; + } + if (imports.functions.has(variable.name)) { + actionFunctions.add(variable); + } + if (imports.namespaces.has(variable.name)) { + actionNamespaces.add(variable); + } + } + } + + return { scopeManager, references, actionFunctions, actionNamespaces }; +} + +function isImportVariable(variable: eslintScope.Variable): boolean { + return variable.defs.some((definition) => definition.type === 'ImportBinding'); +} + +function resolvesTo( + identifier: Identifier, + variables: ReadonlySet, + scopeAnalysis: ScopeAnalysis, +): boolean { + // eslint-scope has already resolved this Identifier to the declaration it + // refers to. Comparing Variable identity is what makes shadowing safe. + const reference = scopeAnalysis.references.get(identifier); + return !!reference?.resolved && variables.has(reference.resolved); +} + +function getDeclaredVariables( + node: Node, + scopeAnalysis: ScopeAnalysis, + names: string[], +): eslintScope.Variable[] { + // Alias declarations are tracked as Variables too, so later `action(...)` + // calls can fail closed only when they resolve to the alias we identified. + const wantedNames = new Set(names); + return scopeAnalysis.scopeManager + .getDeclaredVariables(node) + .filter((variable) => wantedNames.has(variable.name)); +} + +function collectPatternNames(pattern: Pattern): string[] { + switch (pattern.type) { + case 'Identifier': + return [pattern.name]; + case 'ObjectPattern': + return pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + return collectPatternNames(property.argument); + } + return collectPatternNames(property.value); + }); + case 'ArrayPattern': + return pattern.elements.flatMap((element) => + element ? collectPatternNames(element) : [], + ); + case 'RestElement': + return collectPatternNames(pattern.argument); + case 'AssignmentPattern': + return collectPatternNames(pattern.left); + case 'MemberExpression': + return []; + } +} + +function ensureRanges(node: Node): void { + walk(node, null, { + _(child, { next }) { + const nodeWithRange = child as NodeWithRange; + if ( + !nodeWithRange.range && + typeof nodeWithRange.start === 'number' && + typeof nodeWithRange.end === 'number' + ) { + nodeWithRange.range = [nodeWithRange.start, nodeWithRange.end]; + } + + next(); + }, + }); +} diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 5daa06bdc..4d126a46d 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -20,6 +20,7 @@ import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; +import { parseAst } from 'rollup/parseAst'; import { APPS_API_PATH } from './constants'; @@ -252,21 +253,19 @@ describe('Apps Plugin - getPlugins', () => { }; transform.handler.call( { - parse: () => ({ - type: 'Program', - body: [ - { - type: 'ExportNamedDeclaration', - declaration: { - type: 'FunctionDeclaration', - id: { type: 'Identifier', name: 'greet' }, - }, - specifiers: [], - }, - ], - }), + parse: parseAst, }, - 'export function greet() {}', + ` + import { request } from '@datadog/action-catalog/http/http'; + + export function greet() { + request({ connectionId: 'conn-b', inputs: {} }); + } + + export function salute() { + request({ connectionId: 'conn-a', inputs: {} }); + } + `, '/project/src/backend/greet.backend.js', ); @@ -282,7 +281,10 @@ describe('Apps Plugin - getPlugins', () => { ); expect( Object.keys((manifest as { backend: { functions: object } }).backend.functions), - ).toEqual([expect.stringMatching(/^[a-f0-9]{64}\.greet$/)]); + ).toEqual([ + expect.stringMatching(/^[a-f0-9]{64}\.greet$/), + expect.stringMatching(/^[a-f0-9]{64}\.salute$/), + ]); expect(manifest).toMatchObject({ backend: { functions: expect.any(Object) }, }); @@ -290,7 +292,10 @@ describe('Apps Plugin - getPlugins', () => { Object.values( (manifest as { backend: { functions: Record } }).backend.functions, ), - ).toEqual([{ allowedConnectionIds: [] }]); + ).toEqual([ + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + ]); }); test('Should surface upload errors', async () => { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 1299947de..f23758b2c 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -14,6 +14,7 @@ import { createArchive } from './archive'; import type { Asset } from './assets'; import { collectAssets } from './assets'; import { extractExportedFunctions } from './backend/ast-parsing/extract-backend-functions'; +import { extractConnectionIds } from './backend/ast-parsing/extract-connection-ids'; import { encodeQueryName } from './backend/encodeQueryName'; import { generateProxyModule } from './backend/proxy-codegen'; import type { BackendFunction } from './backend/types'; @@ -34,6 +35,7 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, + allowedConnectionIds: string[], ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -46,7 +48,7 @@ function buildProxyModule( relativePath: refPath, name: exportName, absolutePath: id, - allowedConnectionIds: [], + allowedConnectionIds, }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); @@ -275,7 +277,8 @@ Either: // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. handler(code, id) { - const exportNames = extractExportedFunctions(this.parse(code), id); + const ast = this.parse(code); + const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( `Backend file ${id} has no exported functions. ` + @@ -287,10 +290,12 @@ Either: return { code: '', map: null }; } + const allowedConnectionIds = extractConnectionIds(ast, id); const { functions, proxyCode } = buildProxyModule( exportNames, id, context.buildRoot, + allowedConnectionIds, ); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`); diff --git a/packages/tests/jest.config.ts b/packages/tests/jest.config.ts index f11bc3f9a..ddc399cd0 100644 --- a/packages/tests/jest.config.ts +++ b/packages/tests/jest.config.ts @@ -14,6 +14,9 @@ const config: JestConfigWithTsJest = { roots: ['/../'], setupFilesAfterEnv: ['/src/_jest/setupAfterEnv.ts'], testEnvironment: 'node', + moduleNameMapper: { + '^zimmerframe$': '/src/_jest/zimmerframe.cjs', + }, testMatch: ['**/*.test.*'], testTimeout: 10000, }; diff --git a/packages/tests/src/_jest/zimmerframe.cjs b/packages/tests/src/_jest/zimmerframe.cjs new file mode 100644 index 000000000..50038b936 --- /dev/null +++ b/packages/tests/src/_jest/zimmerframe.cjs @@ -0,0 +1,10 @@ +const Module = require('node:module'); +const path = require('node:path'); + +// zimmerframe is ESM-only. The production build can import it directly, but this +// Jest package still executes transformed TypeScript through CommonJS. +module.exports = Module._load( + path.join(__dirname, '../../../../node_modules/zimmerframe/src/walk.js'), + module, + false, +); diff --git a/yarn.lock b/yarn.lock index 329b10d2e..4c3d06dff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1945,13 +1945,16 @@ __metadata: resolution: "@dd/apps-plugin@workspace:packages/plugins/apps" dependencies: "@dd/core": "workspace:*" + "@types/eslint-scope": "npm:3.7.7" "@types/estree": "npm:1.0.8" chalk: "npm:2.3.1" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" jszip: "npm:3.10.1" pretty-bytes: "npm:5.6.0" typescript: "npm:5.4.3" vite: "npm:6.3.5" + zimmerframe: "npm:1.1.4" languageName: unknown linkType: soft @@ -4008,7 +4011,7 @@ __metadata: languageName: node linkType: hard -"@types/eslint-scope@npm:^3.7.7": +"@types/eslint-scope@npm:3.7.7, @types/eslint-scope@npm:^3.7.7": version: 3.7.7 resolution: "@types/eslint-scope@npm:3.7.7" dependencies: @@ -6516,7 +6519,7 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^7.2.2": +"eslint-scope@npm:7.2.2, eslint-scope@npm:^7.2.2": version: 7.2.2 resolution: "eslint-scope@npm:7.2.2" dependencies: @@ -11470,3 +11473,10 @@ __metadata: checksum: 10/f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"zimmerframe@npm:1.1.4": + version: 1.1.4 + resolution: "zimmerframe@npm:1.1.4" + checksum: 10/1f85bda673e6c08dfbfbce14a684a9b127781e8b723994b2359f2659be755712d6a6c787bb54ef9738ad1dcaad0b8299425ca2a2a9ba3b928e0737e01afae878 + languageName: node + linkType: hard From c1d22c630bacd53dc69b140debf8237b60a7ba64 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 7 May 2026 17:20:09 -0400 Subject: [PATCH 02/15] Fail closed for assigned action aliases --- .../extract-connection-ids.test.ts | 17 ++++ .../ast-parsing/extract-connection-ids.ts | 77 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts index 323abecf9..506443dde 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -294,6 +294,23 @@ describe('Backend Functions - extractConnectionIds', () => { expectedMessage: 'action-catalog call aliases', importStatement: "import * as http from '@datadog/action-catalog/http/http';", }, + { + description: 'assigned action-catalog import aliases', + source: "let action; action = request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + }, + { + description: 'assigned action-catalog namespace member aliases', + source: "let action; action = http.request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + { + description: 'assigned action-catalog namespace destructuring aliases', + source: "let action; ({ request: action } = http); action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, { description: 'multiple connectionId properties', source: "request({ connectionId: 'conn-a', connectionId: 'conn-b' });", diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index a3f7b9ae9..636b2fa2a 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -4,6 +4,7 @@ import * as eslintScope from 'eslint-scope'; import type { + AssignmentExpression, BaseNode, Identifier, MemberExpression, @@ -180,6 +181,11 @@ function collectUnsupportedActionCatalogAliases( imports.unsupportedAliases.add(aliasVariable); } }, + AssignmentExpression(node, { state }) { + for (const aliasVariable of getAssignedActionCatalogAliasVariables(node, state)) { + imports.unsupportedAliases.add(aliasVariable); + } + }, }); } @@ -220,6 +226,43 @@ function getActionCatalogAliasVariables( return getDeclaredVariables(node, scopeAnalysis, aliasNames); } +function getAssignedActionCatalogAliasVariables( + node: AssignmentExpression, + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + if ( + node.left.type === 'Identifier' && + node.right.type === 'Identifier' && + resolvesTo(node.right, scopeAnalysis.actionFunctions, scopeAnalysis) + ) { + return getResolvedVariables([node.left], scopeAnalysis); + } + + if ( + node.left.type === 'Identifier' && + node.right.type === 'MemberExpression' && + isNamespaceMember(node.right, scopeAnalysis) + ) { + return getResolvedVariables([node.left], scopeAnalysis); + } + + if ( + node.left.type !== 'ObjectPattern' || + node.right.type !== 'Identifier' || + !resolvesTo(node.right, scopeAnalysis.actionNamespaces, scopeAnalysis) + ) { + return []; + } + + const aliasIdentifiers = node.left.properties.flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternIdentifiers(property.value); + }); + return getResolvedVariables(aliasIdentifiers, scopeAnalysis); +} + function getImportedNames(imports: ActionCatalogImports): Set { return new Set([...imports.functions, ...imports.namespaces]); } @@ -368,6 +411,16 @@ function resolvesTo( return !!reference?.resolved && variables.has(reference.resolved); } +function getResolvedVariables( + identifiers: Identifier[], + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + return identifiers.flatMap((identifier) => { + const variable = scopeAnalysis.references.get(identifier)?.resolved; + return variable ? [variable] : []; + }); +} + function getDeclaredVariables( node: Node, scopeAnalysis: ScopeAnalysis, @@ -405,6 +458,30 @@ function collectPatternNames(pattern: Pattern): string[] { } } +function collectPatternIdentifiers(pattern: Pattern): Identifier[] { + switch (pattern.type) { + case 'Identifier': + return [pattern]; + case 'ObjectPattern': + return pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + return collectPatternIdentifiers(property.argument); + } + return collectPatternIdentifiers(property.value); + }); + case 'ArrayPattern': + return pattern.elements.flatMap((element) => + element ? collectPatternIdentifiers(element) : [], + ); + case 'RestElement': + return collectPatternIdentifiers(pattern.argument); + case 'AssignmentPattern': + return collectPatternIdentifiers(pattern.left); + case 'MemberExpression': + return []; + } +} + function ensureRanges(node: Node): void { walk(node, null, { _(child, { next }) { From bd4e1a4cd9074f043c3eb49b30e0f578b066e334 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 7 May 2026 18:00:09 -0400 Subject: [PATCH 03/15] Import test parseAst from Vite --- .../ast-parsing/extract-connection-ids.test.ts | 2 +- packages/plugins/apps/src/index.test.ts | 2 +- packages/tests/jest.config.ts | 1 + packages/tests/src/_jest/vite.cjs | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 packages/tests/src/_jest/vite.cjs diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts index 506443dde..5ca361aa9 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -2,8 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { parseAst } from 'rollup/parseAst'; import type { AstNode } from 'rollup'; +import { parseAst } from 'vite'; import { extractConnectionIds } from './extract-connection-ids'; diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 4d126a46d..ace890594 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -20,7 +20,7 @@ import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; -import { parseAst } from 'rollup/parseAst'; +import { parseAst } from 'vite'; import { APPS_API_PATH } from './constants'; diff --git a/packages/tests/jest.config.ts b/packages/tests/jest.config.ts index ddc399cd0..04e01a131 100644 --- a/packages/tests/jest.config.ts +++ b/packages/tests/jest.config.ts @@ -15,6 +15,7 @@ const config: JestConfigWithTsJest = { setupFilesAfterEnv: ['/src/_jest/setupAfterEnv.ts'], testEnvironment: 'node', moduleNameMapper: { + '^vite$': '/src/_jest/vite.cjs', '^zimmerframe$': '/src/_jest/zimmerframe.cjs', }, testMatch: ['**/*.test.*'], diff --git a/packages/tests/src/_jest/vite.cjs b/packages/tests/src/_jest/vite.cjs new file mode 100644 index 000000000..ceeea232a --- /dev/null +++ b/packages/tests/src/_jest/vite.cjs @@ -0,0 +1,14 @@ +const vite = require('../../../../node_modules/vite/index.cjs'); +const { parseAst, parseAstAsync } = require('rollup/parseAst'); + +module.exports = new Proxy(vite, { + get(target, prop, receiver) { + if (prop === 'parseAst') { + return parseAst; + } + if (prop === 'parseAstAsync') { + return parseAstAsync; + } + return Reflect.get(target, prop, receiver); + }, +}); From bbaab591e12856d70f2166d34c1a707ec7d0cc35 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:11:49 -0400 Subject: [PATCH 04/15] Add Rollup dev dependency for apps tests --- packages/plugins/apps/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 85e2e8100..4ae037221 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@types/eslint-scope": "3.7.7", "@types/estree": "1.0.8", + "rollup": "4.45.1", "typescript": "5.4.3", "vite": "6.3.5" } diff --git a/yarn.lock b/yarn.lock index 4c3d06dff..cd4a38b62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1952,6 +1952,7 @@ __metadata: glob: "npm:11.1.0" jszip: "npm:3.10.1" pretty-bytes: "npm:5.6.0" + rollup: "npm:4.45.1" typescript: "npm:5.4.3" vite: "npm:6.3.5" zimmerframe: "npm:1.1.4" From 2375412589ea1996c1bf82ac7c51aef6ed6dd169 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:21:44 -0400 Subject: [PATCH 05/15] Document type-only import AST patches --- .../src/backend/ast-parsing/extract-connection-ids.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts index 5ca361aa9..763810636 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -104,6 +104,8 @@ describe('Backend Functions - extractConnectionIds', () => { `); const importDeclaration = (ast as unknown as { body: Array<{ importKind?: string }> }) .body[0]; + // Rollup's parser rejects TypeScript `import type` syntax, so patch the + // ESTree field that a TypeScript-aware parser would add. importDeclaration.importKind = 'type'; expect(extractConnectionIds(ast, filePath)).toEqual([]); @@ -122,6 +124,8 @@ describe('Backend Functions - extractConnectionIds', () => { body: Array<{ specifiers: Array<{ importKind?: string }> }>; } ).body[0].specifiers[0]; + // Rollup's parser rejects TypeScript `import { type ... }` syntax, so + // patch the ESTree field that a TypeScript-aware parser would add. importSpecifier.importKind = 'type'; expect(extractConnectionIds(ast, filePath)).toEqual([]); From 7048074fad04cc732a3747c4b60f0c1552072bd1 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:26:38 -0400 Subject: [PATCH 06/15] Use ESTree types for apps AST parsing --- .../src/backend/ast-parsing/extract-backend-functions.ts | 9 ++++----- .../backend/ast-parsing/extract-connection-ids.test.ts | 6 +++--- .../src/backend/ast-parsing/extract-connection-ids.ts | 3 +-- .../plugins/apps/src/backend/ast-parsing/type-guards.ts | 5 ++--- packages/plugins/apps/src/backend/discovery.test.ts | 7 +++---- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts index ac60c5664..f77263f93 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts @@ -2,8 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Declaration, Expression, Program } from 'estree'; -import type { AstNode } from 'rollup'; +import type { BaseNode, Declaration, Expression, Program } from 'estree'; import { isProgramNode } from './type-guards'; import type { BackendExport } from './types'; @@ -16,14 +15,14 @@ import type { BackendExport } from './types'; * Throws on invalid exports (e.g. default exports) and unexpected AST shapes. * Returns an empty array when the file has no named exports. * - * @param ast - AstNode from `this.parse()` in unplugin's transform hook + * @param ast - ESTree node from `this.parse()` in unplugin's transform hook * @param filePath - Path to the source file (used in error messages) */ -export function extractExportedFunctions(ast: AstNode, filePath: string): string[] { +export function extractExportedFunctions(ast: BaseNode, filePath: string): string[] { return enumerateBackendExports(ast, filePath).map((backendExport) => backendExport.name); } -export function enumerateBackendExports(ast: AstNode, filePath: string): BackendExport[] { +export function enumerateBackendExports(ast: BaseNode, filePath: string): BackendExport[] { if (!isProgramNode(ast)) { throw new Error( `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts index 763810636..b824fe547 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -2,15 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { AstNode } from 'rollup'; +import type { Program } from 'estree'; import { parseAst } from 'vite'; import { extractConnectionIds } from './extract-connection-ids'; const filePath = '/project/src/backend/actions.backend.js'; -function parse(code: string): AstNode { - return parseAst(code) as AstNode; +function parse(code: string): Program { + return parseAst(code) as Program; } describe('Backend Functions - extractConnectionIds', () => { diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 636b2fa2a..70293ba93 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -16,7 +16,6 @@ import type { SimpleCallExpression, VariableDeclarator, } from 'estree'; -import type { AstNode } from 'rollup'; import { walk } from 'zimmerframe'; import { isProgramNode } from './type-guards'; @@ -61,7 +60,7 @@ interface ScopeAnalysis { type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; type NodeWithRange = Node & { start?: number; end?: number; range?: [number, number] }; -export function extractConnectionIds(ast: AstNode, filePath: string): string[] { +export function extractConnectionIds(ast: BaseNode, filePath: string): string[] { if (!isProgramNode(ast)) { throw new Error( `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, diff --git a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts index 7a5d3205c..5f1776c75 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts @@ -2,9 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Program } from 'estree'; -import type { AstNode } from 'rollup'; +import type { BaseNode, Program } from 'estree'; -export function isProgramNode(node: AstNode): node is AstNode & Program { +export function isProgramNode(node: BaseNode): node is Program { return node.type === 'Program'; } diff --git a/packages/plugins/apps/src/backend/discovery.test.ts b/packages/plugins/apps/src/backend/discovery.test.ts index 4698c560c..0ee67a0b6 100644 --- a/packages/plugins/apps/src/backend/discovery.test.ts +++ b/packages/plugins/apps/src/backend/discovery.test.ts @@ -4,13 +4,12 @@ import { extractExportedFunctions } from '@dd/apps-plugin/backend/ast-parsing/extract-backend-functions'; import type { Program } from 'estree'; -import type { AstNode } from 'rollup'; /** - * Helper to build a minimal ESTree Program AstNode for testing. + * Helper to build a minimal ESTree Program for testing. */ -function program(body: Program['body']): AstNode & Program { - return { type: 'Program', sourceType: 'module', body, start: 0, end: 0 }; +function program(body: Program['body']): Program { + return { type: 'Program', sourceType: 'module', body }; } describe('Backend Functions - extractExportedFunctions', () => { From c21e019a0b5179768b0e94d76236ba2c2e58ba14 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:34:55 -0400 Subject: [PATCH 07/15] Remove Vite Jest parser shim --- .../ast-parsing/extract-connection-ids.test.ts | 2 +- packages/plugins/apps/src/index.test.ts | 2 +- packages/tests/jest.config.ts | 1 - packages/tests/src/_jest/vite.cjs | 14 -------------- 4 files changed, 2 insertions(+), 17 deletions(-) delete mode 100644 packages/tests/src/_jest/vite.cjs diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts index b824fe547..d14d92688 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -3,7 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import type { Program } from 'estree'; -import { parseAst } from 'vite'; +import { parseAst } from 'rollup/parseAst'; import { extractConnectionIds } from './extract-connection-ids'; diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index ace890594..4d126a46d 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -20,7 +20,7 @@ import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; -import { parseAst } from 'vite'; +import { parseAst } from 'rollup/parseAst'; import { APPS_API_PATH } from './constants'; diff --git a/packages/tests/jest.config.ts b/packages/tests/jest.config.ts index 04e01a131..ddc399cd0 100644 --- a/packages/tests/jest.config.ts +++ b/packages/tests/jest.config.ts @@ -15,7 +15,6 @@ const config: JestConfigWithTsJest = { setupFilesAfterEnv: ['/src/_jest/setupAfterEnv.ts'], testEnvironment: 'node', moduleNameMapper: { - '^vite$': '/src/_jest/vite.cjs', '^zimmerframe$': '/src/_jest/zimmerframe.cjs', }, testMatch: ['**/*.test.*'], diff --git a/packages/tests/src/_jest/vite.cjs b/packages/tests/src/_jest/vite.cjs deleted file mode 100644 index ceeea232a..000000000 --- a/packages/tests/src/_jest/vite.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const vite = require('../../../../node_modules/vite/index.cjs'); -const { parseAst, parseAstAsync } = require('rollup/parseAst'); - -module.exports = new Proxy(vite, { - get(target, prop, receiver) { - if (prop === 'parseAst') { - return parseAst; - } - if (prop === 'parseAstAsync') { - return parseAstAsync; - } - return Reflect.get(target, prop, receiver); - }, -}); From eec6dc8e42cd789b0f10c52344c28062a341b859 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:43:00 -0400 Subject: [PATCH 08/15] Document connection ID AST helpers --- .../ast-parsing/extract-connection-ids.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 70293ba93..7eba6cdf0 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -188,6 +188,18 @@ function collectUnsupportedActionCatalogAliases( }); } +/** + * Finds variables declared as aliases of an action-catalog function. + * + * Examples this catches: + * - `const action = request` + * - `const action = http.request` + * - `const { request: action } = http` + * + * We do not try to follow these aliases. Instead, we mark them as unsupported + * so a later `action(...)` call fails closed instead of silently missing a + * `connectionId`. + */ function getActionCatalogAliasVariables( node: VariableDeclarator, scopeAnalysis: ScopeAnalysis, @@ -225,6 +237,18 @@ function getActionCatalogAliasVariables( return getDeclaredVariables(node, scopeAnalysis, aliasNames); } +/** + * Finds existing variables that are assigned an action-catalog function after + * they have already been declared. + * + * Examples this catches: + * - `let action; action = request` + * - `let action; action = http.request` + * - `let action; ({ request: action } = http)` + * + * These are the assignment-expression versions of the declarations handled by + * `getActionCatalogAliasVariables`. + */ function getAssignedActionCatalogAliasVariables( node: AssignmentExpression, scopeAnalysis: ScopeAnalysis, @@ -266,11 +290,29 @@ function getImportedNames(imports: ActionCatalogImports): Set { return new Set([...imports.functions, ...imports.namespaces]); } +/** + * Returns true when a property access starts from an imported action-catalog + * namespace. + * + * For `http.request(...)`, this checks that `http` is the namespace imported by + * `import * as http from '@datadog/action-catalog/...'`, not a local variable + * that happens to be named `http`. + */ function isNamespaceMember(node: MemberExpression, scopeAnalysis: ScopeAnalysis): boolean { const root = getMemberExpressionRoot(node); return !!root && resolvesTo(root, scopeAnalysis.actionNamespaces, scopeAnalysis); } +/** + * Returns the left-most identifier in a property access chain. + * + * Examples: + * - `http.request` -> `http` + * - `catalog.http.request` -> `catalog` + * + * The root name is what scope analysis can resolve back to an import or local + * declaration. + */ function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined { if (node.object.type === 'Identifier') { return node.object; @@ -281,6 +323,13 @@ function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined return undefined; } +/** + * Detects namespace call shapes we intentionally do not support. + * + * We only support direct, non-optional property access like `http.request(...)`. + * Computed or optional forms such as `http['request'](...)` and + * `http?.request(...)` could hide what action is called, so they fail closed. + */ function hasUnsupportedMemberAccess(node: MemberExpression): boolean { if (node.optional || node.computed) { return true; @@ -399,6 +448,14 @@ function isImportVariable(variable: eslintScope.Variable): boolean { return variable.defs.some((definition) => definition.type === 'ImportBinding'); } +/** + * Checks whether an identifier points to one of the exact variables we care + * about. + * + * This is the shadowing-safe comparison. For example, a local function + * parameter named `request` has the same text as an imported `request`, but + * eslint-scope resolves it to a different variable. + */ function resolvesTo( identifier: Identifier, variables: ReadonlySet, @@ -410,6 +467,13 @@ function resolvesTo( return !!reference?.resolved && variables.has(reference.resolved); } +/** + * Converts identifier nodes into the variables they refer to. + * + * This is used for assignment aliases because the variable already exists: + * `action = request` does not declare `action`, it only assigns to it. We ask + * eslint-scope which existing variable that `action` identifier points to. + */ function getResolvedVariables( identifiers: Identifier[], scopeAnalysis: ScopeAnalysis, @@ -420,6 +484,13 @@ function getResolvedVariables( }); } +/** + * Returns variables created by a declaration node, limited to the names we + * extracted from the declaration pattern. + * + * This is used for alias declarations like `const action = request`, where the + * declaration itself creates the `action` variable we need to remember. + */ function getDeclaredVariables( node: Node, scopeAnalysis: ScopeAnalysis, @@ -433,6 +504,17 @@ function getDeclaredVariables( .filter((variable) => wantedNames.has(variable.name)); } +/** + * Pulls variable names out of a declaration pattern. + * + * Patterns are the left side of declarations such as: + * - `const action = ...` + * - `const { request: action } = ...` + * - `const [action] = ...` + * + * For declarations, names are enough because eslint-scope can tell us which + * variables were created by the declaration node. + */ function collectPatternNames(pattern: Pattern): string[] { switch (pattern.type) { case 'Identifier': @@ -457,6 +539,14 @@ function collectPatternNames(pattern: Pattern): string[] { } } +/** + * Pulls identifier nodes out of an assignment pattern. + * + * This is similar to `collectPatternNames`, but assignments need the actual + * identifier nodes, not just their text. In `({ request: action } = http)`, + * eslint-scope resolves the `action` node to the existing variable being + * assigned. + */ function collectPatternIdentifiers(pattern: Pattern): Identifier[] { switch (pattern.type) { case 'Identifier': From ae64a570cf42853e05bf787c6fb73acab680d341 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:50:05 -0400 Subject: [PATCH 09/15] Clarify resolvesTo parameters --- .../apps/src/backend/ast-parsing/extract-connection-ids.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 7eba6cdf0..e20b556c8 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -455,6 +455,13 @@ function isImportVariable(variable: eslintScope.Variable): boolean { * This is the shadowing-safe comparison. For example, a local function * parameter named `request` has the same text as an imported `request`, but * eslint-scope resolves it to a different variable. + * + * @param identifier - The exact identifier node from the AST, such as the + * `request` in `request(...)`. + * @param variables - The set of allowed target variables, such as the imported + * action-catalog function declarations. + * @param scopeAnalysis - The precomputed eslint-scope lookup tables that map + * identifier nodes back to the variables they reference. */ function resolvesTo( identifier: Identifier, From c96301c33a9ac630a986e8dc6ec58a917b28ca2b Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:51:59 -0400 Subject: [PATCH 10/15] Comment action catalog alias cases --- .../apps/src/backend/ast-parsing/extract-connection-ids.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index e20b556c8..5b169fa58 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -204,6 +204,7 @@ function getActionCatalogAliasVariables( node: VariableDeclarator, scopeAnalysis: ScopeAnalysis, ): eslintScope.Variable[] { + // `const action = request` if ( node.id.type === 'Identifier' && node.init?.type === 'Identifier' && @@ -212,6 +213,7 @@ function getActionCatalogAliasVariables( return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); } + // `const action = http.request` if ( node.id.type === 'Identifier' && node.init?.type === 'MemberExpression' && @@ -220,6 +222,8 @@ function getActionCatalogAliasVariables( return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); } + // Ignore declarations that are not destructuring an action-catalog namespace, + // then handle `const { request: action } = http` below. if ( node.id.type !== 'ObjectPattern' || node.init?.type !== 'Identifier' || From 06272ca0fcf98291522225f6208fcc60ce7cdf1c Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:53:08 -0400 Subject: [PATCH 11/15] Comment assigned alias cases --- .../apps/src/backend/ast-parsing/extract-connection-ids.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 5b169fa58..0d50dee49 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -257,6 +257,7 @@ function getAssignedActionCatalogAliasVariables( node: AssignmentExpression, scopeAnalysis: ScopeAnalysis, ): eslintScope.Variable[] { + // `let action; action = request` if ( node.left.type === 'Identifier' && node.right.type === 'Identifier' && @@ -265,6 +266,7 @@ function getAssignedActionCatalogAliasVariables( return getResolvedVariables([node.left], scopeAnalysis); } + // `let action; action = http.request` if ( node.left.type === 'Identifier' && node.right.type === 'MemberExpression' && @@ -273,6 +275,8 @@ function getAssignedActionCatalogAliasVariables( return getResolvedVariables([node.left], scopeAnalysis); } + // Ignore assignments that are not destructuring an action-catalog namespace, + // then handle `let action; ({ request: action } = http)` below. if ( node.left.type !== 'ObjectPattern' || node.right.type !== 'Identifier' || From b7f8425ded94ef1edbb88f2ec7e04943bdb42b8e Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:54:09 -0400 Subject: [PATCH 12/15] Comment alias collection variables --- .../apps/src/backend/ast-parsing/extract-connection-ids.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 0d50dee49..0558f08bb 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -232,6 +232,9 @@ function getActionCatalogAliasVariables( return []; } + // In a declaration, eslint-scope can give us declared variables from the + // whole `const { request: action } = http` node, so collecting names is + // enough to pick out the alias variables. const aliasNames = node.id.properties.flatMap((property) => { if (property.type === 'RestElement' || property.computed) { return []; @@ -285,6 +288,8 @@ function getAssignedActionCatalogAliasVariables( return []; } + // In an assignment, the variables already exist. Keep the actual identifier + // nodes so eslint-scope can resolve each one back to the existing variable. const aliasIdentifiers = node.left.properties.flatMap((property) => { if (property.type === 'RestElement' || property.computed) { return []; From a9f9ffdce27d9d801340e2e07cc8fdcf36f3abb9 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 07:58:48 -0400 Subject: [PATCH 13/15] Consolidate pattern identifier collection --- .../ast-parsing/extract-connection-ids.ts | 61 +++++-------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 0558f08bb..5f54b9364 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -233,14 +233,16 @@ function getActionCatalogAliasVariables( } // In a declaration, eslint-scope can give us declared variables from the - // whole `const { request: action } = http` node, so collecting names is - // enough to pick out the alias variables. - const aliasNames = node.id.properties.flatMap((property) => { - if (property.type === 'RestElement' || property.computed) { - return []; - } - return collectPatternNames(property.value); - }); + // whole `const { request: action } = http` node. We only need identifier + // names to pick the alias variables out of that declaration result. + const aliasNames = node.id.properties + .flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternIdentifiers(property.value); + }) + .map((identifier) => identifier.name); return getDeclaredVariables(node, scopeAnalysis, aliasNames); } @@ -525,47 +527,16 @@ function getDeclaredVariables( } /** - * Pulls variable names out of a declaration pattern. + * Pulls identifier nodes out of a declaration or assignment pattern. * - * Patterns are the left side of declarations such as: + * Patterns are the left side of declarations or assignments, such as: * - `const action = ...` * - `const { request: action } = ...` - * - `const [action] = ...` - * - * For declarations, names are enough because eslint-scope can tell us which - * variables were created by the declaration node. - */ -function collectPatternNames(pattern: Pattern): string[] { - switch (pattern.type) { - case 'Identifier': - return [pattern.name]; - case 'ObjectPattern': - return pattern.properties.flatMap((property) => { - if (property.type === 'RestElement') { - return collectPatternNames(property.argument); - } - return collectPatternNames(property.value); - }); - case 'ArrayPattern': - return pattern.elements.flatMap((element) => - element ? collectPatternNames(element) : [], - ); - case 'RestElement': - return collectPatternNames(pattern.argument); - case 'AssignmentPattern': - return collectPatternNames(pattern.left); - case 'MemberExpression': - return []; - } -} - -/** - * Pulls identifier nodes out of an assignment pattern. + * - `({ request: action } = ...)` * - * This is similar to `collectPatternNames`, but assignments need the actual - * identifier nodes, not just their text. In `({ request: action } = http)`, - * eslint-scope resolves the `action` node to the existing variable being - * assigned. + * Declarations use the identifier names to filter variables created by the + * declaration. Assignments use the identifier nodes directly, because + * eslint-scope resolves those nodes to existing variables. */ function collectPatternIdentifiers(pattern: Pattern): Identifier[] { switch (pattern.type) { From ed2ddaea19832c49832a129679aa63ca9ddb2036 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 08:53:11 -0400 Subject: [PATCH 14/15] Use internal AST walker for connection IDs --- ...mmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip | Bin 6974 -> 0 bytes packages/plugins/apps/package.json | 3 +-- .../ast-parsing/extract-connection-ids.ts | 12 +++++------- packages/published/esbuild-plugin/package.json | 1 + packages/published/rollup-plugin/package.json | 1 + packages/published/rspack-plugin/package.json | 1 + packages/published/vite-plugin/package.json | 1 + packages/published/webpack-plugin/package.json | 1 + packages/tests/jest.config.ts | 3 --- packages/tests/src/_jest/zimmerframe.cjs | 10 ---------- yarn.lock | 13 +++++-------- 11 files changed, 16 insertions(+), 30 deletions(-) delete mode 100644 .yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip delete mode 100644 packages/tests/src/_jest/zimmerframe.cjs diff --git a/.yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip b/.yarn/cache/zimmerframe-npm-1.1.4-a2c339a65f-1f85bda673.zip deleted file mode 100644 index 097b869b5ca85570499bc87fc027521de300f97a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6974 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 - walk(ast as Node, scopeAnalysis, { + walkAst(ast, scopeAnalysis, { CallExpression(node, { state }) { const actionCall = classifyActionCatalogCall(node, imports, state, filePath); if (!actionCall) { @@ -174,7 +174,7 @@ function collectUnsupportedActionCatalogAliases( imports: ActionCatalogImports, scopeAnalysis: ScopeAnalysis, ): void { - walk(ast as Node, scopeAnalysis, { + walkAst(ast, scopeAnalysis, { VariableDeclarator(node, { state }) { for (const aliasVariable of getActionCatalogAliasVariables(node, state)) { imports.unsupportedAliases.add(aliasVariable); @@ -563,8 +563,8 @@ function collectPatternIdentifiers(pattern: Pattern): Identifier[] { } function ensureRanges(node: Node): void { - walk(node, null, { - _(child, { next }) { + walkAst(node, null, { + _(child) { const nodeWithRange = child as NodeWithRange; if ( !nodeWithRange.range && @@ -573,8 +573,6 @@ function ensureRanges(node: Node): void { ) { nodeWithRange.range = [nodeWithRange.start, nodeWithRange.end]; } - - next(); }, }); } diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index f2fd247db..fa3b43184 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -53,6 +53,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 384b08767..609b96254 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -56,6 +56,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 861025ca5..c141a25ce 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -53,6 +53,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 5c67e58ee..e4d979c8a 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -53,6 +53,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 6fed55ed7..6d6142390 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -53,6 +53,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", diff --git a/packages/tests/jest.config.ts b/packages/tests/jest.config.ts index ddc399cd0..f11bc3f9a 100644 --- a/packages/tests/jest.config.ts +++ b/packages/tests/jest.config.ts @@ -14,9 +14,6 @@ const config: JestConfigWithTsJest = { roots: ['/../'], setupFilesAfterEnv: ['/src/_jest/setupAfterEnv.ts'], testEnvironment: 'node', - moduleNameMapper: { - '^zimmerframe$': '/src/_jest/zimmerframe.cjs', - }, testMatch: ['**/*.test.*'], testTimeout: 10000, }; diff --git a/packages/tests/src/_jest/zimmerframe.cjs b/packages/tests/src/_jest/zimmerframe.cjs deleted file mode 100644 index 50038b936..000000000 --- a/packages/tests/src/_jest/zimmerframe.cjs +++ /dev/null @@ -1,10 +0,0 @@ -const Module = require('node:module'); -const path = require('node:path'); - -// zimmerframe is ESM-only. The production build can import it directly, but this -// Jest package still executes transformed TypeScript through CommonJS. -module.exports = Module._load( - path.join(__dirname, '../../../../node_modules/zimmerframe/src/walk.js'), - module, - false, -); diff --git a/yarn.lock b/yarn.lock index cd4a38b62..0df3f371d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1699,6 +1699,7 @@ __metadata: async-retry: "npm:1.3.3" chalk: "npm:2.3.1" esbuild: "npm:0.25.8" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" @@ -1757,6 +1758,7 @@ __metadata: async-retry: "npm:1.3.3" chalk: "npm:2.3.1" esbuild: "npm:0.25.8" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" @@ -1808,6 +1810,7 @@ __metadata: async-retry: "npm:1.3.3" chalk: "npm:2.3.1" esbuild: "npm:0.25.8" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" @@ -1859,6 +1862,7 @@ __metadata: async-retry: "npm:1.3.3" chalk: "npm:2.3.1" esbuild: "npm:0.25.8" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" @@ -1910,6 +1914,7 @@ __metadata: async-retry: "npm:1.3.3" chalk: "npm:2.3.1" esbuild: "npm:0.25.8" + eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" @@ -1955,7 +1960,6 @@ __metadata: rollup: "npm:4.45.1" typescript: "npm:5.4.3" vite: "npm:6.3.5" - zimmerframe: "npm:1.1.4" languageName: unknown linkType: soft @@ -11474,10 +11478,3 @@ __metadata: checksum: 10/f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard - -"zimmerframe@npm:1.1.4": - version: 1.1.4 - resolution: "zimmerframe@npm:1.1.4" - checksum: 10/1f85bda673e6c08dfbfbce14a684a9b127781e8b723994b2359f2659be755712d6a6c787bb54ef9738ad1dcaad0b8299425ca2a2a9ba3b928e0737e01afae878 - languageName: node - linkType: hard From 27282b8b919153a10aadda10b8e93faa5b3e5459 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 13:42:56 -0400 Subject: [PATCH 15/15] Refactor connection ID extraction modules --- .../ast-parsing/action-catalog-call-sites.ts | 467 ++++++++++++++ .../ast-parsing/action-catalog-imports.ts | 57 ++ .../ast-parsing/connection-id-values.ts | 73 +++ .../ast-parsing/extract-connection-ids.ts | 570 +----------------- 4 files changed, 612 insertions(+), 555 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/action-catalog-call-sites.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-call-sites.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-call-sites.ts new file mode 100644 index 000000000..46e9ed946 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-call-sites.ts @@ -0,0 +1,467 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import * as eslintScope from 'eslint-scope'; +import type { + AssignmentExpression, + Identifier, + MemberExpression, + Node, + Pattern, + Program, + SimpleCallExpression, + VariableDeclarator, +} from 'estree'; + +import type { ActionCatalogImports } from './action-catalog-imports'; +import { walkAst } from './walk-ast'; + +// Do not trust names alone when deciding whether `request(...)` is an +// action-catalog call. A local parameter or variable can reuse the same name: +// +// import { request } from '@datadog/action-catalog/http/http'; +// request({ connectionId: 'real' }); +// +// function run(request) { +// request({ connectionId: 'local' }); +// } +// +// Both calls use the text `request`, but only the first one refers to the +// imported action-catalog function. eslint-scope tells us which declaration each +// identifier refers to, and ScopeAnalysis keeps the lookup tables we need while +// walking the file. +export interface ScopeAnalysis { + // The full scope model from eslint-scope, used when we need declared + // variables for aliases like `const action = request`. + scopeManager: eslintScope.ScopeManager; + + // Maps each identifier node to the declaration eslint-scope resolved it to. + references: Map; + + // The actual import variables for action-catalog functions and namespaces. + // Call sites must resolve to one of these variables to count. + actionFunctions: Set; + actionNamespaces: Set; +} + +interface ActionCatalogCallState { + scopeAnalysis: ScopeAnalysis; + unsupportedAliases: Set; +} + +type NodeWithRange = Node & { start?: number; end?: number; range?: [number, number] }; + +export function analyzeActionCatalogScopes( + ast: Program, + imports: ActionCatalogImports, +): ScopeAnalysis { + ensureRanges(ast); + const scopeManager = eslintScope.analyze(ast, { + ecmaVersion: 2022, + ignoreEval: true, + sourceType: 'module', + }); + + const references = new Map(); + const actionFunctions = new Set(); + const actionNamespaces = new Set(); + + // Cache every identifier reference so call classification can ask "what + // variable does this exact node resolve to?" without re-walking scopes. + for (const scope of scopeManager.scopes) { + for (const reference of scope.references) { + references.set(reference.identifier, reference); + } + + // Save the actual import declarations that came from action-catalog. + // Later, when we see `request(...)`, we check whether that `request` + // points back to one of these declarations instead of to a local + // parameter or variable with the same name. + for (const variable of scope.variables) { + if (!isImportVariable(variable)) { + continue; + } + if (imports.functions.has(variable.name)) { + actionFunctions.add(variable); + } + if (imports.namespaces.has(variable.name)) { + actionNamespaces.add(variable); + } + } + } + + return { scopeManager, references, actionFunctions, actionNamespaces }; +} + +export function findActionCatalogCallSites( + ast: Program, + scopeAnalysis: ScopeAnalysis, + filePath: string, +): SimpleCallExpression[] { + const callState: ActionCatalogCallState = { + scopeAnalysis, + unsupportedAliases: collectUnsupportedActionCatalogAliases(ast, scopeAnalysis), + }; + const callSites: SimpleCallExpression[] = []; + + walkAst(ast, callState, { + CallExpression(node, { state }) { + if (classifyActionCatalogCall(node, state, filePath)) { + callSites.push(node); + } + }, + }); + + return callSites; +} + +export function resolveIdentifier( + identifier: Identifier, + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable | undefined { + return scopeAnalysis.references.get(identifier)?.resolved ?? undefined; +} + +export function isImportVariable(variable: eslintScope.Variable): boolean { + return variable.defs.some((definition) => definition.type === 'ImportBinding'); +} + +function classifyActionCatalogCall( + node: SimpleCallExpression, + state: ActionCatalogCallState, + filePath: string, +): boolean { + const callee = node.callee; + + if (callee.type === 'Identifier') { + if (resolvesTo(callee, state.unsupportedAliases, state.scopeAnalysis)) { + throw unsupportedActionCatalogCall(filePath, 'action-catalog call aliases'); + } + if (resolvesTo(callee, state.scopeAnalysis.actionFunctions, state.scopeAnalysis)) { + if (node.optional) { + throw unsupportedActionCatalogCall(filePath, 'optional action-catalog calls'); + } + return true; + } + return false; + } + + if (callee.type !== 'MemberExpression') { + return false; + } + + if (!isNamespaceMember(callee, state.scopeAnalysis)) { + return false; + } + + if (node.optional || hasUnsupportedMemberAccess(callee)) { + throw unsupportedActionCatalogCall( + filePath, + 'optional or computed action-catalog namespace calls', + ); + } + return true; +} + +function collectUnsupportedActionCatalogAliases( + ast: Program, + scopeAnalysis: ScopeAnalysis, +): Set { + const unsupportedAliases = new Set(); + + walkAst(ast, scopeAnalysis, { + VariableDeclarator(node, { state }) { + for (const aliasVariable of getActionCatalogAliasVariables(node, state)) { + unsupportedAliases.add(aliasVariable); + } + }, + AssignmentExpression(node, { state }) { + for (const aliasVariable of getAssignedActionCatalogAliasVariables(node, state)) { + unsupportedAliases.add(aliasVariable); + } + }, + }); + + return unsupportedAliases; +} + +/** + * Finds variables declared as aliases of an action-catalog function. + * + * Examples this catches: + * - `const action = request` + * - `const action = http.request` + * - `const { request: action } = http` + * + * We do not try to follow these aliases. Instead, we mark them as unsupported + * so a later `action(...)` call fails closed instead of silently missing a + * `connectionId`. + */ +function getActionCatalogAliasVariables( + node: VariableDeclarator, + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + // `const action = request` + if ( + node.id.type === 'Identifier' && + node.init?.type === 'Identifier' && + resolvesTo(node.init, scopeAnalysis.actionFunctions, scopeAnalysis) + ) { + return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); + } + + // `const action = http.request` + if ( + node.id.type === 'Identifier' && + node.init?.type === 'MemberExpression' && + isNamespaceMember(node.init, scopeAnalysis) + ) { + return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); + } + + // Ignore declarations that are not destructuring an action-catalog namespace, + // then handle `const { request: action } = http` below. + if ( + node.id.type !== 'ObjectPattern' || + node.init?.type !== 'Identifier' || + !resolvesTo(node.init, scopeAnalysis.actionNamespaces, scopeAnalysis) + ) { + return []; + } + + // In a declaration, eslint-scope can give us declared variables from the + // whole `const { request: action } = http` node. We only need identifier + // names to pick the alias variables out of that declaration result. + const aliasNames = node.id.properties + .flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternIdentifiers(property.value); + }) + .map((identifier) => identifier.name); + return getDeclaredVariables(node, scopeAnalysis, aliasNames); +} + +/** + * Finds existing variables that are assigned an action-catalog function after + * they have already been declared. + * + * Examples this catches: + * - `let action; action = request` + * - `let action; action = http.request` + * - `let action; ({ request: action } = http)` + * + * These are the assignment-expression versions of the declarations handled by + * `getActionCatalogAliasVariables`. + */ +function getAssignedActionCatalogAliasVariables( + node: AssignmentExpression, + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + // `let action; action = request` + if ( + node.left.type === 'Identifier' && + node.right.type === 'Identifier' && + resolvesTo(node.right, scopeAnalysis.actionFunctions, scopeAnalysis) + ) { + return getResolvedVariables([node.left], scopeAnalysis); + } + + // `let action; action = http.request` + if ( + node.left.type === 'Identifier' && + node.right.type === 'MemberExpression' && + isNamespaceMember(node.right, scopeAnalysis) + ) { + return getResolvedVariables([node.left], scopeAnalysis); + } + + // Ignore assignments that are not destructuring an action-catalog namespace, + // then handle `let action; ({ request: action } = http)` below. + if ( + node.left.type !== 'ObjectPattern' || + node.right.type !== 'Identifier' || + !resolvesTo(node.right, scopeAnalysis.actionNamespaces, scopeAnalysis) + ) { + return []; + } + + // In an assignment, the variables already exist. Keep the actual identifier + // nodes so eslint-scope can resolve each one back to the existing variable. + const aliasIdentifiers = node.left.properties.flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternIdentifiers(property.value); + }); + return getResolvedVariables(aliasIdentifiers, scopeAnalysis); +} + +/** + * Returns true when a property access starts from an imported action-catalog + * namespace. + * + * For `http.request(...)`, this checks that `http` is the namespace imported by + * `import * as http from '@datadog/action-catalog/...'`, not a local variable + * that happens to be named `http`. + */ +function isNamespaceMember(node: MemberExpression, scopeAnalysis: ScopeAnalysis): boolean { + const root = getMemberExpressionRoot(node); + return !!root && resolvesTo(root, scopeAnalysis.actionNamespaces, scopeAnalysis); +} + +/** + * Returns the left-most identifier in a property access chain. + * + * Examples: + * - `http.request` -> `http` + * - `catalog.http.request` -> `catalog` + * + * The root name is what scope analysis can resolve back to an import or local + * declaration. + */ +function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined { + if (node.object.type === 'Identifier') { + return node.object; + } + if (node.object.type === 'MemberExpression') { + return getMemberExpressionRoot(node.object); + } + return undefined; +} + +/** + * Detects namespace call shapes we intentionally do not support. + * + * We only support direct, non-optional property access like `http.request(...)`. + * Computed or optional forms such as `http['request'](...)` and + * `http?.request(...)` could hide what action is called, so they fail closed. + */ +function hasUnsupportedMemberAccess(node: MemberExpression): boolean { + if (node.optional || node.computed) { + return true; + } + return node.object.type === 'MemberExpression' && hasUnsupportedMemberAccess(node.object); +} + +/** + * Checks whether an identifier points to one of the exact variables we care + * about. + * + * This is the shadowing-safe comparison. For example, a local function + * parameter named `request` has the same text as an imported `request`, but + * eslint-scope resolves it to a different variable. + * + * @param identifier - The exact identifier node from the AST, such as the + * `request` in `request(...)`. + * @param variables - The set of allowed target variables, such as the imported + * action-catalog function declarations. + * @param scopeAnalysis - The precomputed eslint-scope lookup tables that map + * identifier nodes back to the variables they reference. + */ +function resolvesTo( + identifier: Identifier, + variables: ReadonlySet, + scopeAnalysis: ScopeAnalysis, +): boolean { + // eslint-scope has already resolved this Identifier to the declaration it + // refers to. Comparing Variable identity is what makes shadowing safe. + const reference = scopeAnalysis.references.get(identifier); + return !!reference?.resolved && variables.has(reference.resolved); +} + +/** + * Converts identifier nodes into the variables they refer to. + * + * This is used for assignment aliases because the variable already exists: + * `action = request` does not declare `action`, it only assigns to it. We ask + * eslint-scope which existing variable that `action` identifier points to. + */ +function getResolvedVariables( + identifiers: Identifier[], + scopeAnalysis: ScopeAnalysis, +): eslintScope.Variable[] { + return identifiers.flatMap((identifier) => { + const variable = resolveIdentifier(identifier, scopeAnalysis); + return variable ? [variable] : []; + }); +} + +/** + * Returns variables created by a declaration node, limited to the names we + * extracted from the declaration pattern. + * + * This is used for alias declarations like `const action = request`, where the + * declaration itself creates the `action` variable we need to remember. + */ +function getDeclaredVariables( + node: Node, + scopeAnalysis: ScopeAnalysis, + names: string[], +): eslintScope.Variable[] { + // Alias declarations are tracked as Variables too, so later `action(...)` + // calls can fail closed only when they resolve to the alias we identified. + const wantedNames = new Set(names); + return scopeAnalysis.scopeManager + .getDeclaredVariables(node) + .filter((variable) => wantedNames.has(variable.name)); +} + +/** + * Pulls identifier nodes out of a declaration or assignment pattern. + * + * Patterns are the left side of declarations or assignments, such as: + * - `const action = ...` + * - `const { request: action } = ...` + * - `({ request: action } = ...)` + * + * Declarations use the identifier names to filter variables created by the + * declaration. Assignments use the identifier nodes directly, because + * eslint-scope resolves those nodes to existing variables. + */ +function collectPatternIdentifiers(pattern: Pattern): Identifier[] { + switch (pattern.type) { + case 'Identifier': + return [pattern]; + case 'ObjectPattern': + return pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + return collectPatternIdentifiers(property.argument); + } + return collectPatternIdentifiers(property.value); + }); + case 'ArrayPattern': + return pattern.elements.flatMap((element) => + element ? collectPatternIdentifiers(element) : [], + ); + case 'RestElement': + return collectPatternIdentifiers(pattern.argument); + case 'AssignmentPattern': + return collectPatternIdentifiers(pattern.left); + case 'MemberExpression': + return []; + } +} + +function ensureRanges(node: Node): void { + walkAst(node, null, { + _(child) { + const nodeWithRange = child as NodeWithRange; + if ( + !nodeWithRange.range && + typeof nodeWithRange.start === 'number' && + typeof nodeWithRange.end === 'number' + ) { + nodeWithRange.range = [nodeWithRange.start, nodeWithRange.end]; + } + }, + }); +} + +function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { + return new Error( + `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, + ); +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts new file mode 100644 index 000000000..83f994b39 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts @@ -0,0 +1,57 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { BaseNode, Program } from 'estree'; + +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; + +export interface ActionCatalogImports { + functions: Set; + namespaces: Set; +} + +type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; + +export function collectActionCatalogImports(ast: Program): ActionCatalogImports { + const functions = new Set(); + const namespaces = new Set(); + + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration' || !isActionCatalogSource(node.source.value)) { + continue; + } + if (isTypeOnly(node)) { + continue; + } + + for (const specifier of node.specifiers) { + if (isTypeOnly(specifier)) { + continue; + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaces.add(specifier.local.name); + } else { + functions.add(specifier.local.name); + } + } + } + + return { functions, namespaces }; +} + +export function hasActionCatalogImports(imports: ActionCatalogImports): boolean { + return imports.functions.size > 0 || imports.namespaces.size > 0; +} + +function isActionCatalogSource(source: unknown): boolean { + return ( + typeof source === 'string' && + (source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`)) + ); +} + +function isTypeOnly(node: NodeWithOptionalImportKind): boolean { + return node.importKind === 'type'; +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts b/packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts new file mode 100644 index 000000000..139a997e4 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts @@ -0,0 +1,73 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { ObjectExpression, Property, SimpleCallExpression } from 'estree'; + +const CONNECTION_ID_PROPERTY = 'connectionId'; + +export function extractConnectionIdFromActionCall( + node: SimpleCallExpression, + filePath: string, +): string | undefined { + const [firstArg] = node.arguments; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + throw unsupportedActionCatalogCall(filePath, 'non-object action-catalog call arguments'); + } + + const connectionIdProperty = findConnectionIdProperty(firstArg, filePath); + if (!connectionIdProperty) { + return undefined; + } + + const { value } = connectionIdProperty; + if (value.type === 'Literal' && typeof value.value === 'string') { + return value.value; + } + + throw unsupportedConnectionId(filePath, value.type); +} + +function findConnectionIdProperty( + objectExpression: ObjectExpression, + filePath: string, +): Property | undefined { + let connectionIdProperty: Property | undefined; + for (const property of objectExpression.properties) { + if (property.type === 'SpreadElement') { + throw unsupportedActionCatalogCall(filePath, 'spread object arguments'); + } + if (property.computed) { + throw unsupportedActionCatalogCall(filePath, 'computed object property keys'); + } + if (isConnectionIdKey(property)) { + if (connectionIdProperty) { + throw unsupportedActionCatalogCall(filePath, 'multiple connectionId properties'); + } + if (property.kind !== 'init') { + throw unsupportedActionCatalogCall(filePath, 'accessor connectionId properties'); + } + connectionIdProperty = property; + } + } + return connectionIdProperty; +} + +function isConnectionIdKey(property: Property): boolean { + if (property.key.type === 'Identifier') { + return property.key.name === CONNECTION_ID_PROPERTY; + } + return property.key.type === 'Literal' && property.key.value === CONNECTION_ID_PROPERTY; +} + +function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { + return new Error( + `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, + ); +} + +function unsupportedConnectionId(filePath: string, type: string): Error { + return new Error( + `Unsupported action-catalog connectionId in ${filePath}: expected an inline string literal, got ${type}.`, + ); +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index dfce8f3d8..4496aa83e 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -2,63 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import * as eslintScope from 'eslint-scope'; -import type { - AssignmentExpression, - BaseNode, - Identifier, - MemberExpression, - Node, - ObjectExpression, - Pattern, - Program, - Property, - SimpleCallExpression, - VariableDeclarator, -} from 'estree'; - +import type { BaseNode } from 'estree'; + +import { + analyzeActionCatalogScopes, + findActionCatalogCallSites, +} from './action-catalog-call-sites'; +import { collectActionCatalogImports, hasActionCatalogImports } from './action-catalog-imports'; +import { extractConnectionIdFromActionCall } from './connection-id-values'; import { isProgramNode } from './type-guards'; -import { walkAst } from './walk-ast'; - -const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; -const CONNECTION_ID_PROPERTY = 'connectionId'; - -interface ActionCatalogImports { - functions: Set; - namespaces: Set; - unsupportedAliases: Set; -} - -// Do not trust names alone when deciding whether `request(...)` is an -// action-catalog call. A local parameter or variable can reuse the same name: -// -// import { request } from '@datadog/action-catalog/http/http'; -// request({ connectionId: 'real' }); -// -// function run(request) { -// request({ connectionId: 'local' }); -// } -// -// Both calls use the text `request`, but only the first one refers to the -// imported action-catalog function. eslint-scope tells us which declaration each -// identifier refers to, and ScopeAnalysis keeps the lookup tables we need while -// walking the file. -interface ScopeAnalysis { - // The full scope model from eslint-scope, used when we need declared - // variables for aliases like `const action = request`. - scopeManager: eslintScope.ScopeManager; - - // Maps each identifier node to the declaration eslint-scope resolved it to. - references: Map; - - // The actual import variables for action-catalog functions and namespaces. - // Call sites must resolve to one of these variables to count. - actionFunctions: Set; - actionNamespaces: Set; -} - -type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; -type NodeWithRange = Node & { start?: number; end?: number; range?: [number, number] }; export function extractConnectionIds(ast: BaseNode, filePath: string): string[] { if (!isProgramNode(ast)) { @@ -68,511 +20,19 @@ export function extractConnectionIds(ast: BaseNode, filePath: string): string[] } const imports = collectActionCatalogImports(ast); - const importedNames = getImportedNames(imports); - if (importedNames.size === 0) { + if (!hasActionCatalogImports(imports)) { return []; } - const scopeAnalysis = analyzeScopes(ast, imports); - collectUnsupportedActionCatalogAliases(ast, imports, scopeAnalysis); - + const scopeAnalysis = analyzeActionCatalogScopes(ast, imports); const connectionIds = new Set(); - walkAst(ast, scopeAnalysis, { - CallExpression(node, { state }) { - const actionCall = classifyActionCatalogCall(node, imports, state, filePath); - if (!actionCall) { - return; - } - - extractConnectionIdFromActionCall(node, filePath, connectionIds); - }, - }); - - return [...connectionIds].sort(); -} - -function collectActionCatalogImports(ast: Program): ActionCatalogImports { - const functions = new Set(); - const namespaces = new Set(); - const unsupportedAliases = new Set(); - - for (const node of ast.body) { - if (node.type !== 'ImportDeclaration' || !isActionCatalogSource(node.source.value)) { - continue; - } - if (isTypeOnly(node)) { - continue; - } - - for (const specifier of node.specifiers) { - if (isTypeOnly(specifier)) { - continue; - } - - if (specifier.type === 'ImportNamespaceSpecifier') { - namespaces.add(specifier.local.name); - } else { - functions.add(specifier.local.name); - } - } - } - - return { functions, namespaces, unsupportedAliases }; -} - -function isActionCatalogSource(source: unknown): boolean { - return ( - typeof source === 'string' && - (source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`)) - ); -} - -function isTypeOnly(node: NodeWithOptionalImportKind): boolean { - return node.importKind === 'type'; -} - -function classifyActionCatalogCall( - node: SimpleCallExpression, - imports: ActionCatalogImports, - scopeAnalysis: ScopeAnalysis, - filePath: string, -): boolean { - const callee = node.callee; - - if (callee.type === 'Identifier') { - if (resolvesTo(callee, imports.unsupportedAliases, scopeAnalysis)) { - throw unsupportedActionCatalogCall(filePath, 'action-catalog call aliases'); - } - if (resolvesTo(callee, scopeAnalysis.actionFunctions, scopeAnalysis)) { - if (node.optional) { - throw unsupportedActionCatalogCall(filePath, 'optional action-catalog calls'); - } - return true; - } - return false; - } - - if (callee.type !== 'MemberExpression') { - return false; - } - - if (!isNamespaceMember(callee, scopeAnalysis)) { - return false; - } - - if (node.optional || hasUnsupportedMemberAccess(callee)) { - throw unsupportedActionCatalogCall( - filePath, - 'optional or computed action-catalog namespace calls', - ); - } - return true; -} - -function collectUnsupportedActionCatalogAliases( - ast: Program, - imports: ActionCatalogImports, - scopeAnalysis: ScopeAnalysis, -): void { - walkAst(ast, scopeAnalysis, { - VariableDeclarator(node, { state }) { - for (const aliasVariable of getActionCatalogAliasVariables(node, state)) { - imports.unsupportedAliases.add(aliasVariable); - } - }, - AssignmentExpression(node, { state }) { - for (const aliasVariable of getAssignedActionCatalogAliasVariables(node, state)) { - imports.unsupportedAliases.add(aliasVariable); - } - }, - }); -} - -/** - * Finds variables declared as aliases of an action-catalog function. - * - * Examples this catches: - * - `const action = request` - * - `const action = http.request` - * - `const { request: action } = http` - * - * We do not try to follow these aliases. Instead, we mark them as unsupported - * so a later `action(...)` call fails closed instead of silently missing a - * `connectionId`. - */ -function getActionCatalogAliasVariables( - node: VariableDeclarator, - scopeAnalysis: ScopeAnalysis, -): eslintScope.Variable[] { - // `const action = request` - if ( - node.id.type === 'Identifier' && - node.init?.type === 'Identifier' && - resolvesTo(node.init, scopeAnalysis.actionFunctions, scopeAnalysis) - ) { - return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); - } - - // `const action = http.request` - if ( - node.id.type === 'Identifier' && - node.init?.type === 'MemberExpression' && - isNamespaceMember(node.init, scopeAnalysis) - ) { - return getDeclaredVariables(node, scopeAnalysis, [node.id.name]); - } - - // Ignore declarations that are not destructuring an action-catalog namespace, - // then handle `const { request: action } = http` below. - if ( - node.id.type !== 'ObjectPattern' || - node.init?.type !== 'Identifier' || - !resolvesTo(node.init, scopeAnalysis.actionNamespaces, scopeAnalysis) - ) { - return []; - } - - // In a declaration, eslint-scope can give us declared variables from the - // whole `const { request: action } = http` node. We only need identifier - // names to pick the alias variables out of that declaration result. - const aliasNames = node.id.properties - .flatMap((property) => { - if (property.type === 'RestElement' || property.computed) { - return []; - } - return collectPatternIdentifiers(property.value); - }) - .map((identifier) => identifier.name); - return getDeclaredVariables(node, scopeAnalysis, aliasNames); -} - -/** - * Finds existing variables that are assigned an action-catalog function after - * they have already been declared. - * - * Examples this catches: - * - `let action; action = request` - * - `let action; action = http.request` - * - `let action; ({ request: action } = http)` - * - * These are the assignment-expression versions of the declarations handled by - * `getActionCatalogAliasVariables`. - */ -function getAssignedActionCatalogAliasVariables( - node: AssignmentExpression, - scopeAnalysis: ScopeAnalysis, -): eslintScope.Variable[] { - // `let action; action = request` - if ( - node.left.type === 'Identifier' && - node.right.type === 'Identifier' && - resolvesTo(node.right, scopeAnalysis.actionFunctions, scopeAnalysis) - ) { - return getResolvedVariables([node.left], scopeAnalysis); - } - - // `let action; action = http.request` - if ( - node.left.type === 'Identifier' && - node.right.type === 'MemberExpression' && - isNamespaceMember(node.right, scopeAnalysis) - ) { - return getResolvedVariables([node.left], scopeAnalysis); - } - // Ignore assignments that are not destructuring an action-catalog namespace, - // then handle `let action; ({ request: action } = http)` below. - if ( - node.left.type !== 'ObjectPattern' || - node.right.type !== 'Identifier' || - !resolvesTo(node.right, scopeAnalysis.actionNamespaces, scopeAnalysis) - ) { - return []; - } - - // In an assignment, the variables already exist. Keep the actual identifier - // nodes so eslint-scope can resolve each one back to the existing variable. - const aliasIdentifiers = node.left.properties.flatMap((property) => { - if (property.type === 'RestElement' || property.computed) { - return []; - } - return collectPatternIdentifiers(property.value); - }); - return getResolvedVariables(aliasIdentifiers, scopeAnalysis); -} - -function getImportedNames(imports: ActionCatalogImports): Set { - return new Set([...imports.functions, ...imports.namespaces]); -} - -/** - * Returns true when a property access starts from an imported action-catalog - * namespace. - * - * For `http.request(...)`, this checks that `http` is the namespace imported by - * `import * as http from '@datadog/action-catalog/...'`, not a local variable - * that happens to be named `http`. - */ -function isNamespaceMember(node: MemberExpression, scopeAnalysis: ScopeAnalysis): boolean { - const root = getMemberExpressionRoot(node); - return !!root && resolvesTo(root, scopeAnalysis.actionNamespaces, scopeAnalysis); -} - -/** - * Returns the left-most identifier in a property access chain. - * - * Examples: - * - `http.request` -> `http` - * - `catalog.http.request` -> `catalog` - * - * The root name is what scope analysis can resolve back to an import or local - * declaration. - */ -function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined { - if (node.object.type === 'Identifier') { - return node.object; - } - if (node.object.type === 'MemberExpression') { - return getMemberExpressionRoot(node.object); - } - return undefined; -} - -/** - * Detects namespace call shapes we intentionally do not support. - * - * We only support direct, non-optional property access like `http.request(...)`. - * Computed or optional forms such as `http['request'](...)` and - * `http?.request(...)` could hide what action is called, so they fail closed. - */ -function hasUnsupportedMemberAccess(node: MemberExpression): boolean { - if (node.optional || node.computed) { - return true; - } - return node.object.type === 'MemberExpression' && hasUnsupportedMemberAccess(node.object); -} - -function extractConnectionIdFromActionCall( - node: SimpleCallExpression, - filePath: string, - connectionIds: Set, -): void { - const [firstArg] = node.arguments; - if (!firstArg || firstArg.type !== 'ObjectExpression') { - throw unsupportedActionCatalogCall(filePath, 'non-object action-catalog call arguments'); - } - - const connectionIdProperty = findConnectionIdProperty(firstArg, filePath); - if (!connectionIdProperty) { - return; - } - - const { value } = connectionIdProperty; - if (value.type === 'Literal' && typeof value.value === 'string') { - connectionIds.add(value.value); - return; - } - - throw unsupportedConnectionId(filePath, value.type); -} - -function findConnectionIdProperty( - objectExpression: ObjectExpression, - filePath: string, -): Property | undefined { - let connectionIdProperty: Property | undefined; - for (const property of objectExpression.properties) { - if (property.type === 'SpreadElement') { - throw unsupportedActionCatalogCall(filePath, 'spread object arguments'); - } - if (property.computed) { - throw unsupportedActionCatalogCall(filePath, 'computed object property keys'); - } - if (isConnectionIdKey(property)) { - if (connectionIdProperty) { - throw unsupportedActionCatalogCall(filePath, 'multiple connectionId properties'); - } - if (property.kind !== 'init') { - throw unsupportedActionCatalogCall(filePath, 'accessor connectionId properties'); - } - connectionIdProperty = property; - } - } - return connectionIdProperty; -} - -function isConnectionIdKey(property: Property): boolean { - if (property.key.type === 'Identifier') { - return property.key.name === CONNECTION_ID_PROPERTY; - } - return property.key.type === 'Literal' && property.key.value === CONNECTION_ID_PROPERTY; -} - -function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { - return new Error( - `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, - ); -} - -function unsupportedConnectionId(filePath: string, type: string): Error { - return new Error( - `Unsupported action-catalog connectionId in ${filePath}: expected an inline string literal, got ${type}.`, - ); -} - -function analyzeScopes(ast: Program, imports: ActionCatalogImports): ScopeAnalysis { - ensureRanges(ast); - const scopeManager = eslintScope.analyze(ast, { - ecmaVersion: 2022, - ignoreEval: true, - sourceType: 'module', - }); - - const references = new Map(); - const actionFunctions = new Set(); - const actionNamespaces = new Set(); - - // Cache every identifier reference so call classification can ask "what - // variable does this exact node resolve to?" without re-walking scopes. - for (const scope of scopeManager.scopes) { - for (const reference of scope.references) { - references.set(reference.identifier, reference); - } - - // Save the actual import declarations that came from action-catalog. - // Later, when we see `request(...)`, we check whether that `request` - // points back to one of these declarations instead of to a local - // parameter or variable with the same name. - for (const variable of scope.variables) { - if (!isImportVariable(variable)) { - continue; - } - if (imports.functions.has(variable.name)) { - actionFunctions.add(variable); - } - if (imports.namespaces.has(variable.name)) { - actionNamespaces.add(variable); - } + for (const callSite of findActionCatalogCallSites(ast, scopeAnalysis, filePath)) { + const connectionId = extractConnectionIdFromActionCall(callSite, filePath); + if (connectionId) { + connectionIds.add(connectionId); } } - return { scopeManager, references, actionFunctions, actionNamespaces }; -} - -function isImportVariable(variable: eslintScope.Variable): boolean { - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - -/** - * Checks whether an identifier points to one of the exact variables we care - * about. - * - * This is the shadowing-safe comparison. For example, a local function - * parameter named `request` has the same text as an imported `request`, but - * eslint-scope resolves it to a different variable. - * - * @param identifier - The exact identifier node from the AST, such as the - * `request` in `request(...)`. - * @param variables - The set of allowed target variables, such as the imported - * action-catalog function declarations. - * @param scopeAnalysis - The precomputed eslint-scope lookup tables that map - * identifier nodes back to the variables they reference. - */ -function resolvesTo( - identifier: Identifier, - variables: ReadonlySet, - scopeAnalysis: ScopeAnalysis, -): boolean { - // eslint-scope has already resolved this Identifier to the declaration it - // refers to. Comparing Variable identity is what makes shadowing safe. - const reference = scopeAnalysis.references.get(identifier); - return !!reference?.resolved && variables.has(reference.resolved); -} - -/** - * Converts identifier nodes into the variables they refer to. - * - * This is used for assignment aliases because the variable already exists: - * `action = request` does not declare `action`, it only assigns to it. We ask - * eslint-scope which existing variable that `action` identifier points to. - */ -function getResolvedVariables( - identifiers: Identifier[], - scopeAnalysis: ScopeAnalysis, -): eslintScope.Variable[] { - return identifiers.flatMap((identifier) => { - const variable = scopeAnalysis.references.get(identifier)?.resolved; - return variable ? [variable] : []; - }); -} - -/** - * Returns variables created by a declaration node, limited to the names we - * extracted from the declaration pattern. - * - * This is used for alias declarations like `const action = request`, where the - * declaration itself creates the `action` variable we need to remember. - */ -function getDeclaredVariables( - node: Node, - scopeAnalysis: ScopeAnalysis, - names: string[], -): eslintScope.Variable[] { - // Alias declarations are tracked as Variables too, so later `action(...)` - // calls can fail closed only when they resolve to the alias we identified. - const wantedNames = new Set(names); - return scopeAnalysis.scopeManager - .getDeclaredVariables(node) - .filter((variable) => wantedNames.has(variable.name)); -} - -/** - * Pulls identifier nodes out of a declaration or assignment pattern. - * - * Patterns are the left side of declarations or assignments, such as: - * - `const action = ...` - * - `const { request: action } = ...` - * - `({ request: action } = ...)` - * - * Declarations use the identifier names to filter variables created by the - * declaration. Assignments use the identifier nodes directly, because - * eslint-scope resolves those nodes to existing variables. - */ -function collectPatternIdentifiers(pattern: Pattern): Identifier[] { - switch (pattern.type) { - case 'Identifier': - return [pattern]; - case 'ObjectPattern': - return pattern.properties.flatMap((property) => { - if (property.type === 'RestElement') { - return collectPatternIdentifiers(property.argument); - } - return collectPatternIdentifiers(property.value); - }); - case 'ArrayPattern': - return pattern.elements.flatMap((element) => - element ? collectPatternIdentifiers(element) : [], - ); - case 'RestElement': - return collectPatternIdentifiers(pattern.argument); - case 'AssignmentPattern': - return collectPatternIdentifiers(pattern.left); - case 'MemberExpression': - return []; - } -} - -function ensureRanges(node: Node): void { - walkAst(node, null, { - _(child) { - const nodeWithRange = child as NodeWithRange; - if ( - !nodeWithRange.range && - typeof nodeWithRange.start === 'number' && - typeof nodeWithRange.end === 'number' - ) { - nodeWithRange.range = [nodeWithRange.start, nodeWithRange.end]; - } - }, - }); + return [...connectionIds].sort(); }