From e1c9d24fa5df78ccb5d72732559701740a930933 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 13 May 2026 09:17:57 -0400 Subject: [PATCH] Trace imported connection ID values --- .../ast-parsing/connection-id-values.ts | 61 ++- ...t-connection-ids-from-module-graph.test.ts | 294 ++++++++++++- ...xtract-connection-ids-from-module-graph.ts | 402 +++++++++++++++++- .../src/backend/ast-parsing/module-graph.ts | 219 +++++++++- 4 files changed, 952 insertions(+), 24 deletions(-) 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 index 9ce132b4a..31b05d652 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/connection-id-values.ts @@ -27,6 +27,11 @@ const CONNECTION_ID_PROPERTY = 'connectionId'; type VariableKind = VariableDeclaration['kind']; type ConnectionIdProperty = Property & { value: Expression }; +export interface ResolvedObjectExpression { + expression: ObjectExpression; + context: ConnectionIdResolutionContext; +} + /** * Describes what kind of same-file variable a connectionId expression points to. * @@ -35,7 +40,7 @@ type ConnectionIdProperty = Property & { value: Expression }; * `request({ connectionId: ID })` must point to the same variable created by * `const ID = 'abc'`, not a shadowed function parameter also named `ID`. */ -type StaticBinding = +export type StaticBinding = /** * A top-level `const` declaration whose initializer can be followed during * connection ID resolution. @@ -115,13 +120,25 @@ export interface SameModuleConnectionIdBindings { * `seen` tracks the const declarations currently being followed. It prevents * infinite recursion for cycles such as `const A = B; const B = A;`. */ -interface ConnectionIdResolutionContext { +export interface ConnectionIdResolutionContext { bindings: SameModuleConnectionIdBindings; filePath: string; + importResolver?: ConnectionIdImportResolver; scopeAnalysis: ScopeAnalysis; seen: Set; } +export interface ConnectionIdImportResolver { + resolveImportedIdentifier: ( + identifier: Identifier, + context: ConnectionIdResolutionContext, + ) => string; + resolveImportedObject: ( + identifier: Identifier, + context: ConnectionIdResolutionContext, + ) => ResolvedObjectExpression; +} + /** * Collects top-level variable declarations that same-module connection IDs may * reference. @@ -181,6 +198,7 @@ export function extractConnectionIdFromActionCall( bindings: SameModuleConnectionIdBindings, scopeAnalysis: ScopeAnalysis, filePath: string, + importResolver?: ConnectionIdImportResolver, ): string | undefined { const [firstArg] = node.arguments; if (!firstArg || firstArg.type !== 'ObjectExpression') { @@ -195,6 +213,7 @@ export function extractConnectionIdFromActionCall( return resolveConnectionIdValue(connectionIdProperty.value, { bindings, filePath, + importResolver, scopeAnalysis, seen: new Set(), }); @@ -265,7 +284,7 @@ function collectVariableDeclarationBindings( * * Any other expression, such as `getId()` or `'a' + suffix`, fails closed. */ -function resolveConnectionIdValue( +export function resolveConnectionIdValue( node: Expression, context: ConnectionIdResolutionContext, ): string { @@ -337,9 +356,12 @@ function resolveIdentifierValue( } if (isImportVariable(variable)) { - // Imported values require following another module, which is deferred to - // the module graph PR: + // Imported values require graph context from the module graph + // extraction path: // import { HTTP_CONNECTION_ID } from './connections'; + if (context.importResolver) { + return context.importResolver.resolveImportedIdentifier(identifier, context); + } throw unsupportedConnectionId( context.filePath, `imported connectionId binding ${identifier.name}`, @@ -438,7 +460,7 @@ function resolveObjectMemberValue( // the string literal can become the final connection ID immediately. For // `const CONNECTIONS = { HTTP: HTTP_CONNECTION_ID }`, the identifier can // resolve through its own const binding before producing the final string. - return resolveConnectionIdValue(value, context); + return resolveConnectionIdValue(value.expression, value.context); } /** @@ -451,7 +473,7 @@ function resolveObjectMemberValue( function resolveObjectMemberExpression( node: MemberExpression, context: ConnectionIdResolutionContext, -): Expression { +): { expression: Expression; context: ConnectionIdResolutionContext } { if (node.optional) { throw unsupportedConnectionId(context.filePath, 'optional connectionId member reads'); } @@ -469,8 +491,15 @@ function resolveObjectMemberExpression( ); } - const objectExpression = resolveObjectExpressionValue(node.object, context); - return resolveObjectPropertyExpression(objectExpression, node.property.name, context); + const resolvedObject = resolveObjectExpressionValue(node.object, context); + return { + expression: resolveObjectPropertyExpression( + resolvedObject.expression, + node.property.name, + resolvedObject.context, + ), + context: resolvedObject.context, + }; } /** @@ -484,16 +513,17 @@ function resolveObjectMemberExpression( * request({ connectionId: ACTIVE_CONNECTIONS.HTTP.PROD }); * ``` */ -function resolveObjectExpressionValue( +export function resolveObjectExpressionValue( node: MemberExpression['object'] | Expression, context: ConnectionIdResolutionContext, -): ObjectExpression { +): ResolvedObjectExpression { if (node.type === 'ObjectExpression') { - return node; + return { expression: node, context }; } if (node.type === 'MemberExpression') { - return resolveObjectExpressionValue(resolveObjectMemberExpression(node, context), context); + const resolvedMember = resolveObjectMemberExpression(node, context); + return resolveObjectExpressionValue(resolvedMember.expression, resolvedMember.context); } if (node.type !== 'Identifier') { @@ -508,6 +538,9 @@ function resolveObjectExpressionValue( // import { CONNECTIONS } from './connections'; // request({ connectionId: CONNECTIONS.HTTP }); if (isImportVariable(variable)) { + if (context.importResolver) { + return context.importResolver.resolveImportedObject(node, context); + } throw unsupportedConnectionId( context.filePath, `imported connectionId object binding ${node.name}`, @@ -721,6 +754,6 @@ function unsupportedActionCatalogCall(filePath: string, unsupported: string): Er ); } -function unsupportedConnectionId(filePath: string, unsupported: string): Error { +export function unsupportedConnectionId(filePath: string, unsupported: string): Error { return new Error(`Unsupported action-catalog connectionId in ${filePath}: ${unsupported}.`); } diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts index 9002d90c5..6aa10e79b 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts @@ -259,7 +259,7 @@ describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { expect(() => extract([entry])).toThrow(`uncollected local import ${missingId}`); }); - test('Should keep imported connection ID values unsupported in reachable helpers', () => { + test('Should resolve imported connection ID values in reachable helpers', () => { const helperId = '/project/src/backend/helpers/http.js'; const idsId = '/project/src/backend/helpers/ids.js'; const entry = createRecord( @@ -285,9 +285,297 @@ describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { `, [idsId], ); + const ids = createRecord( + idsId, + ` + export const HTTP_CONNECTION_ID = 'conn-imported'; + `, + ); + + expect(extract([entry, helper, ids])).toEqual(['conn-imported']); + }); + + test('Should resolve imported static templates, const chains, and object roots', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const idsId = '/project/src/backend/helpers/ids.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { TEMPLATE_ID, ACTIVE_ID, CONNECTIONS } from './ids.js'; + + export function getEcho() { + request({ connectionId: TEMPLATE_ID, inputs: {} }); + request({ connectionId: ACTIVE_ID, inputs: {} }); + request({ connectionId: CONNECTIONS.HTTP.PROD, inputs: {} }); + } + `, + [idsId], + ); + const ids = createRecord( + idsId, + ` + const BASE_ID = 'conn-chain'; + + export const TEMPLATE_ID = \`conn-template\`; + export const ACTIVE_ID = BASE_ID; + export const CONNECTIONS = { + HTTP: { + PROD: 'conn-object', + }, + }; + `, + ); + + expect(extract([entry, helper, ids])).toEqual([ + 'conn-chain', + 'conn-object', + 'conn-template', + ]); + }); + + test('Should resolve local export aliases, re-exports, import/export relays, and export star', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const aliasesId = '/project/src/backend/helpers/aliases.js'; + const reExportsId = '/project/src/backend/helpers/re-exports.js'; + const relayId = '/project/src/backend/helpers/relay.js'; + const starId = '/project/src/backend/helpers/star.js'; + const sourceId = '/project/src/backend/helpers/source.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { ALIASED_ID } from './aliases.js'; + import { RE_EXPORTED_ID } from './re-exports.js'; + import { RELAYED_ID } from './relay.js'; + import { STAR_ID } from './star.js'; + + export function getEcho() { + request({ connectionId: ALIASED_ID, inputs: {} }); + request({ connectionId: RE_EXPORTED_ID, inputs: {} }); + request({ connectionId: RELAYED_ID, inputs: {} }); + request({ connectionId: STAR_ID, inputs: {} }); + } + `, + [aliasesId, reExportsId, relayId, starId], + ); + const aliases = createRecord( + aliasesId, + ` + const ID = 'conn-alias'; + export { ID as ALIASED_ID }; + `, + ); + const reExports = createRecord( + reExportsId, + ` + export { SOURCE_ID as RE_EXPORTED_ID } from './source.js'; + `, + [sourceId], + ); + const relay = createRecord( + relayId, + ` + import { SOURCE_ID as RELAYED_ID } from './source.js'; + export { RELAYED_ID }; + `, + [sourceId], + ); + const star = createRecord( + starId, + ` + export * from './source.js'; + `, + [sourceId], + ); + const source = createRecord( + sourceId, + ` + export const SOURCE_ID = 'conn-source'; + export const STAR_ID = 'conn-star'; + `, + ); + + expect(extract([entry, helper, aliases, reExports, relay, star, source])).toEqual([ + 'conn-alias', + 'conn-source', + 'conn-star', + ]); + }); + + test.each([ + { + description: 'missing imported exports', + sourceCode: 'export const OTHER_ID = "conn-other";', + expectedMessage: 'missing export HTTP_CONNECTION_ID', + }, + { + description: 'mutable exported bindings', + sourceCode: 'export let HTTP_CONNECTION_ID = "conn-mutable";', + expectedMessage: 'mutable let exported connectionId binding HTTP_CONNECTION_ID', + }, + ])('Should fail closed for $description', ({ sourceCode, expectedMessage }) => { + const helperId = '/project/src/backend/helpers/http.js'; + const idsId = '/project/src/backend/helpers/ids.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { HTTP_CONNECTION_ID } from './ids.js'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `, + [idsId], + ); + const ids = createRecord(idsId, sourceCode); + + expect(() => extract([entry, helper, ids])).toThrow(expectedMessage); + }); + + test('Should fail closed for ambiguous export star connection IDs', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const barrelId = '/project/src/backend/helpers/barrel.js'; + const aId = '/project/src/backend/helpers/a.js'; + const bId = '/project/src/backend/helpers/b.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { HTTP_CONNECTION_ID } from './barrel.js'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `, + [barrelId], + ); + const barrel = createRecord( + barrelId, + ` + export * from './a.js'; + export * from './b.js'; + `, + [aId, bId], + ); + const a = createRecord(aId, `export const HTTP_CONNECTION_ID = 'conn-a';`); + const b = createRecord(bId, `export const HTTP_CONNECTION_ID = 'conn-b';`); + + expect(() => extract([entry, helper, barrel, a, b])).toThrow( + 'ambiguous export * connectionId HTTP_CONNECTION_ID', + ); + }); + + test('Should fail closed for default imported connection IDs', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const idsId = '/project/src/backend/helpers/ids.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import HTTP_CONNECTION_ID from './ids.js'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `, + [idsId], + ); + const ids = createRecord(idsId, `export const HTTP_CONNECTION_ID = 'conn';`); + + expect(() => extract([entry, helper, ids])).toThrow( + 'default imported connectionId binding HTTP_CONNECTION_ID', + ); + }); + + test('Should fail closed for cyclic import/export connection IDs', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const aId = '/project/src/backend/helpers/a.js'; + const bId = '/project/src/backend/helpers/b.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { HTTP_CONNECTION_ID } from './a.js'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `, + [aId], + ); + const a = createRecord(aId, `export { HTTP_CONNECTION_ID } from './b.js';`, [bId]); + const b = createRecord(bId, `export { HTTP_CONNECTION_ID } from './a.js';`, [aId]); - expect(() => extract([entry, helper])).toThrow( - 'imported connectionId binding HTTP_CONNECTION_ID', + expect(() => extract([entry, helper, a, b])).toThrow( + 'cyclic imported connectionId export HTTP_CONNECTION_ID', ); }); diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts index 6c46c1ee9..5b1662b2d 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts @@ -2,10 +2,34 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { extractConnectionIds } from './extract-connection-ids'; -import type { ParsedModuleRecord } from './module-graph'; +import type * as eslintScope from 'eslint-scope'; + +import { + analyzeActionCatalogScopes, + findActionCatalogCallSites, + type ScopeAnalysis, +} from './action-catalog-call-sites'; +import { collectActionCatalogImports } from './action-catalog-imports'; +import { + collectSameModuleConnectionIdBindings, + extractConnectionIdFromActionCall, + resolveConnectionIdValue, + resolveObjectExpressionValue, + unsupportedConnectionId, + type ConnectionIdImportResolver, + type ConnectionIdResolutionContext, + type ResolvedObjectExpression, + type SameModuleConnectionIdBindings, +} from './connection-id-values'; +import type { ModuleImport, ParsedModuleRecord } from './module-graph'; import { walkModuleGraph } from './walk-module-graph'; +interface ModuleConnectionIdAnalysis { + bindings: SameModuleConnectionIdBindings; + record: ParsedModuleRecord; + scopeAnalysis: ScopeAnalysis; +} + /** * Extracts the conservative backend-file connection ID union from module records * collected while the backend bundler walked the real execution graph. @@ -16,15 +40,381 @@ export function extractConnectionIdsFromModuleGraph( buildRoot: string, ): string[] { const connectionIds = new Set(); + const analyses = new Map(); + + const getAnalysis = (moduleId: string): ModuleConnectionIdAnalysis => { + const existing = analyses.get(moduleId); + if (existing) { + return existing; + } + + const record = modules.get(moduleId); + if (!record) { + throw unsupportedConnectionId( + entryId, + `module ${moduleId} is outside the module graph`, + ); + } + + const imports = collectActionCatalogImports(record.ast); + const scopeAnalysis = analyzeActionCatalogScopes(record.ast, imports); + const bindings = collectSameModuleConnectionIdBindings(record.ast, scopeAnalysis); + const analysis = { bindings, record, scopeAnalysis }; + analyses.set(moduleId, analysis); + return analysis; + }; + + const hasExport = ( + moduleId: string, + exportName: string, + visited = new Set(), + ): boolean => { + const key = `${moduleId}:${exportName}`; + if (visited.has(key)) { + return false; + } + visited.add(key); + + const record = modules.get(moduleId); + if (!record) { + return false; + } + if (record.exports.some((moduleExport) => moduleExport.exportedName === exportName)) { + return true; + } + if (record.reExports.some((reExport) => reExport.exportedName === exportName)) { + return true; + } + return record.starExports.some( + (starExport) => + starExport.resolvedId && hasExport(starExport.resolvedId, exportName, visited), + ); + }; + + const getImportedBinding = (moduleId: string, localName: string): ModuleImport => { + const { record } = getAnalysis(moduleId); + const importedBinding = record.imports.find((binding) => binding.localName === localName); + if (!importedBinding) { + throw unsupportedConnectionId(moduleId, `missing import binding for ${localName}`); + } + if (!importedBinding.resolvedId || !modules.has(importedBinding.resolvedId)) { + throw unsupportedConnectionId( + moduleId, + `imported value ${localName} outside the module graph`, + ); + } + if (importedBinding.kind === 'default') { + throw unsupportedConnectionId( + moduleId, + `default imported connectionId binding ${localName}`, + ); + } + if (importedBinding.kind === 'namespace') { + throw unsupportedConnectionId( + moduleId, + `namespace imported connectionId binding ${localName}`, + ); + } + return importedBinding; + }; + + const findLocalBinding = (analysis: ModuleConnectionIdAnalysis, localName: string) => { + for (const variable of analysis.bindings.byVariable.keys()) { + if (variable.name === localName) { + return variable; + } + } + return undefined; + }; + + const resolveExportedValue = ( + moduleId: string, + exportName: string, + context: ConnectionIdResolutionContext, + visited = new Set(), + ): string => { + const key = `${moduleId}:${exportName}:value`; + if (visited.has(key)) { + throw unsupportedConnectionId( + moduleId, + `cyclic imported connectionId export ${exportName}`, + ); + } + visited.add(key); + + const analysis = getAnalysis(moduleId); + const localExport = analysis.record.exports.find( + (moduleExport) => moduleExport.exportedName === exportName, + ); + if (localExport) { + const importRelay = analysis.record.imports.find( + (binding) => binding.localName === localExport.localName, + ); + if (importRelay) { + return resolveImportedValue(importRelay, context, visited); + } + + const variable = findLocalBinding(analysis, localExport.localName); + if (!variable) { + throw unsupportedConnectionId( + moduleId, + `non-top-level exported connectionId binding ${localExport.localName}`, + ); + } + const binding = analysis.bindings.byVariable.get(variable); + if (!binding) { + throw unsupportedConnectionId( + moduleId, + `non-top-level exported connectionId binding ${localExport.localName}`, + ); + } + if (binding.kind === 'mutable') { + throw unsupportedConnectionId( + moduleId, + `mutable ${binding.declarationKind} exported connectionId binding ${localExport.localName}`, + ); + } + if (binding.kind === 'unsupported-pattern') { + throw unsupportedConnectionId( + moduleId, + `destructured exported connectionId binding ${localExport.localName}`, + ); + } + if (!binding.init) { + throw unsupportedConnectionId( + moduleId, + `uninitialized exported const connectionId binding ${localExport.localName}`, + ); + } + return resolveConnectionIdValue( + binding.init, + createResolutionContext(analysis, context.importResolver, context.seen), + ); + } + + const reExport = analysis.record.reExports.find( + (candidate) => candidate.exportedName === exportName, + ); + if (reExport) { + if (!reExport.resolvedId || !modules.has(reExport.resolvedId)) { + throw unsupportedConnectionId( + moduleId, + `re-export ${exportName} outside the module graph`, + ); + } + return resolveExportedValue( + reExport.resolvedId, + reExport.importedName, + context, + visited, + ); + } + + const matchingStarExports = analysis.record.starExports.filter( + (starExport) => starExport.resolvedId && hasExport(starExport.resolvedId, exportName), + ); + if (matchingStarExports.length > 1) { + throw unsupportedConnectionId( + moduleId, + `ambiguous export * connectionId ${exportName}`, + ); + } + const [matchingStarExport] = matchingStarExports; + if (matchingStarExport?.resolvedId) { + return resolveExportedValue( + matchingStarExport.resolvedId, + exportName, + context, + visited, + ); + } + + throw unsupportedConnectionId(moduleId, `missing export ${exportName}`); + }; + + const resolveExportedObject = ( + moduleId: string, + exportName: string, + context: ConnectionIdResolutionContext, + visited = new Set(), + ): ResolvedObjectExpression => { + const key = `${moduleId}:${exportName}:object`; + if (visited.has(key)) { + throw unsupportedConnectionId( + moduleId, + `cyclic imported connectionId object export ${exportName}`, + ); + } + visited.add(key); + + const analysis = getAnalysis(moduleId); + const localExport = analysis.record.exports.find( + (moduleExport) => moduleExport.exportedName === exportName, + ); + if (localExport) { + const importRelay = analysis.record.imports.find( + (binding) => binding.localName === localExport.localName, + ); + if (importRelay) { + return resolveImportedObject(importRelay, context, visited); + } + + const variable = findLocalBinding(analysis, localExport.localName); + const binding = variable ? analysis.bindings.byVariable.get(variable) : undefined; + if (!binding) { + throw unsupportedConnectionId( + moduleId, + `non-top-level exported connectionId object binding ${localExport.localName}`, + ); + } + if (binding.kind === 'mutable') { + throw unsupportedConnectionId( + moduleId, + `mutable ${binding.declarationKind} exported connectionId object binding ${localExport.localName}`, + ); + } + if (binding.kind === 'unsupported-pattern') { + throw unsupportedConnectionId( + moduleId, + `destructured exported connectionId object binding ${localExport.localName}`, + ); + } + if (!binding.init) { + throw unsupportedConnectionId( + moduleId, + `uninitialized exported const connectionId object binding ${localExport.localName}`, + ); + } + return resolveObjectExpressionValue( + binding.init, + createResolutionContext(analysis, context.importResolver, context.seen), + ); + } + + const reExport = analysis.record.reExports.find( + (candidate) => candidate.exportedName === exportName, + ); + if (reExport) { + if (!reExport.resolvedId || !modules.has(reExport.resolvedId)) { + throw unsupportedConnectionId( + moduleId, + `re-export ${exportName} outside the module graph`, + ); + } + return resolveExportedObject( + reExport.resolvedId, + reExport.importedName, + context, + visited, + ); + } + + const matchingStarExports = analysis.record.starExports.filter( + (starExport) => starExport.resolvedId && hasExport(starExport.resolvedId, exportName), + ); + if (matchingStarExports.length > 1) { + throw unsupportedConnectionId( + moduleId, + `ambiguous export * connectionId object ${exportName}`, + ); + } + const [matchingStarExport] = matchingStarExports; + if (matchingStarExport?.resolvedId) { + return resolveExportedObject( + matchingStarExport.resolvedId, + exportName, + context, + visited, + ); + } + + throw unsupportedConnectionId(moduleId, `missing export ${exportName}`); + }; + + const resolveImportedValue = ( + importedBinding: ModuleImport, + context: ConnectionIdResolutionContext, + visited?: Set, + ): string => { + if (!importedBinding.resolvedId) { + throw unsupportedConnectionId( + context.filePath, + `imported value ${importedBinding.localName} outside the module graph`, + ); + } + return resolveExportedValue( + importedBinding.resolvedId, + importedBinding.importedName, + context, + visited, + ); + }; + + const resolveImportedObject = ( + importedBinding: ModuleImport, + context: ConnectionIdResolutionContext, + visited?: Set, + ): ResolvedObjectExpression => { + if (!importedBinding.resolvedId) { + throw unsupportedConnectionId( + context.filePath, + `imported value ${importedBinding.localName} outside the module graph`, + ); + } + return resolveExportedObject( + importedBinding.resolvedId, + importedBinding.importedName, + context, + visited, + ); + }; + + const importResolver: ConnectionIdImportResolver = { + resolveImportedIdentifier(identifier, context) { + const importedBinding = getImportedBinding(context.filePath, identifier.name); + return resolveImportedValue(importedBinding, context); + }, + resolveImportedObject(identifier, context) { + const importedBinding = getImportedBinding(context.filePath, identifier.name); + return resolveImportedObject(importedBinding, context); + }, + }; walkModuleGraph(entryId, modules, buildRoot, ({ moduleId, record }) => { - // Resolve connection IDs while visiting the reachable graph so this - // step can later receive graph-aware value-resolution context. - const moduleConnectionIds = extractConnectionIds(record.ast, moduleId); - for (const connectionId of moduleConnectionIds) { + const analysis = getAnalysis(moduleId); + + for (const callSite of findActionCatalogCallSites( + record.ast, + analysis.scopeAnalysis, + moduleId, + )) { + const connectionId = extractConnectionIdFromActionCall( + callSite, + analysis.bindings, + analysis.scopeAnalysis, + moduleId, + importResolver, + ); + if (!connectionId) { + continue; + } connectionIds.add(connectionId); } }); return [...connectionIds].sort(); } + +function createResolutionContext( + analysis: ModuleConnectionIdAnalysis, + importResolver: ConnectionIdImportResolver | undefined, + seen: Set, +): ConnectionIdResolutionContext { + return { + bindings: analysis.bindings, + filePath: analysis.record.id, + importResolver, + scopeAnalysis: analysis.scopeAnalysis, + seen, + }; +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index c0e7d46f8..348251db0 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -2,7 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode, ImportExpression, Program, SimpleCallExpression } from 'estree'; +import type { + BaseNode, + ExportAllDeclaration, + ExportNamedDeclaration, + ImportDeclaration, + ImportExpression, + Program, + SimpleCallExpression, +} from 'estree'; import path from 'path'; import { ensureProgram, isStringLiteral } from './type-guards'; @@ -13,6 +21,10 @@ export interface ParsedModuleRecord { ast: Program; staticDependencies: string[]; unsupportedDependencies: ModuleDependency[]; + imports: ModuleImport[]; + exports: ModuleExport[]; + reExports: ModuleReExport[]; + starExports: ModuleStarExport[]; } export interface ModuleDependency { @@ -20,6 +32,31 @@ export interface ModuleDependency { kind: 'dynamic-import' | 'require'; } +export interface ModuleImport { + localName: string; + importedName: string; + source: string; + resolvedId: string | undefined; + kind: 'named' | 'default' | 'namespace'; +} + +export interface ModuleExport { + localName: string; + exportedName: string; +} + +export interface ModuleReExport { + importedName: string; + exportedName: string; + source: string; + resolvedId: string | undefined; +} + +export interface ModuleStarExport { + source: string; + resolvedId: string | undefined; +} + type ImportCallExpression = SimpleCallExpression & { callee: { type: 'Import' } }; const DISALLOWED_GRAPH_DIRS = new Set(['node_modules', 'dist', 'build', '.vite']); @@ -53,6 +90,11 @@ export function createParsedModuleRecord( ast: program, staticDependencies: staticDependencies.map(normalizeModuleId), unsupportedDependencies: collectUnsupportedModuleDependencies(program), + ...collectModuleImportExportMetadata( + program, + staticDependencies.map(normalizeModuleId), + buildRoot, + ), }; } @@ -126,6 +168,165 @@ function collectUnsupportedModuleDependencies(ast: Program): ModuleDependency[] return dependencies; } +function collectModuleImportExportMetadata( + ast: Program, + staticDependencies: string[], + buildRoot: string, +): Pick { + const dependencyBySpecifier = mapDependencySpecifiers(ast, staticDependencies, buildRoot); + const imports: ModuleImport[] = []; + const exports: ModuleExport[] = []; + const reExports: ModuleReExport[] = []; + const starExports: ModuleStarExport[] = []; + + for (const node of ast.body) { + if (node.type === 'ImportDeclaration') { + imports.push(...collectImports(node, dependencyBySpecifier)); + continue; + } + + if (node.type === 'ExportNamedDeclaration') { + collectExports(node, dependencyBySpecifier, exports, reExports); + continue; + } + + if (node.type === 'ExportAllDeclaration') { + starExports.push({ + source: getSourceSpecifier(node), + resolvedId: dependencyBySpecifier.get(getSourceSpecifier(node)), + }); + } + } + + return { imports, exports, reExports, starExports }; +} + +function mapDependencySpecifiers( + ast: Program, + staticDependencies: string[], + buildRoot: string, +): Map { + const specifiers = new Set(); + + for (const node of ast.body) { + if ( + node.type === 'ImportDeclaration' || + node.type === 'ExportAllDeclaration' || + (node.type === 'ExportNamedDeclaration' && node.source) + ) { + const source = getSourceSpecifier(node); + if (isLocalSpecifier(source)) { + specifiers.add(source); + } + } + } + + const localDependencies = staticDependencies.filter((dependency) => + shouldTraverseCollectedModule(dependency, buildRoot), + ); + + return new Map( + [...specifiers].map((specifier, index) => [specifier, localDependencies[index]]), + ); +} + +function collectImports( + declaration: ImportDeclaration, + dependencyBySpecifier: ReadonlyMap, +): ModuleImport[] { + const source = getSourceSpecifier(declaration); + const resolvedId = dependencyBySpecifier.get(source); + + return declaration.specifiers.map((specifier) => { + if (specifier.type === 'ImportDefaultSpecifier') { + return { + localName: specifier.local.name, + importedName: 'default', + source, + resolvedId, + kind: 'default', + }; + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + return { + localName: specifier.local.name, + importedName: '*', + source, + resolvedId, + kind: 'namespace', + }; + } + + return { + localName: specifier.local.name, + importedName: getImportExportName(specifier.imported), + source, + resolvedId, + kind: 'named', + }; + }); +} + +function collectExports( + declaration: ExportNamedDeclaration, + dependencyBySpecifier: ReadonlyMap, + exports: ModuleExport[], + reExports: ModuleReExport[], +): void { + if (declaration.source) { + const source = getSourceSpecifier(declaration); + const resolvedId = dependencyBySpecifier.get(source); + for (const specifier of declaration.specifiers) { + reExports.push({ + importedName: getImportExportName(specifier.local), + exportedName: getImportExportName(specifier.exported), + source, + resolvedId, + }); + } + return; + } + + if (declaration.declaration) { + collectDeclarationExports(declaration.declaration, exports); + } + + for (const specifier of declaration.specifiers) { + exports.push({ + localName: getImportExportName(specifier.local), + exportedName: getImportExportName(specifier.exported), + }); + } +} + +function collectDeclarationExports( + declaration: NonNullable, + exports: ModuleExport[], +): void { + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + exports.push({ + localName: declarator.id.name, + exportedName: declarator.id.name, + }); + } + } + return; + } + + if ( + (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && + declaration.id + ) { + exports.push({ + localName: declaration.id.name, + exportedName: declaration.id.name, + }); + } +} + /** * Dynamic package imports are skipped, but local or non-literal dynamic imports * could hide app-local action-catalog calls and must fail closed. @@ -167,6 +368,22 @@ function getLiteralSpecifier(node: unknown, fallback: string): string { return fallback; } +function getSourceSpecifier( + node: ImportDeclaration | ExportNamedDeclaration | ExportAllDeclaration, +): string { + return getLiteralSpecifier(node.source, 'non-literal static import'); +} + +function getImportExportName(node: { type: string; name?: string; value?: unknown }): string { + if (node.type === 'Identifier' && node.name) { + return node.name; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return 'unsupported export name'; +} + /** * Narrows Rollup's dynamic import call-expression representation. */