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..e3822105f 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 @@ -9,10 +9,12 @@ import type { Literal, MemberExpression, ObjectExpression, + Pattern, Program, Property, SimpleCallExpression, TemplateLiteral, + UpdateExpression, VariableDeclaration, } from 'estree'; @@ -21,10 +23,24 @@ import { resolveIdentifier, type ScopeAnalysis, } from './action-catalog-call-sites'; +import { walkAst } from './walk-ast'; const CONNECTION_ID_PROPERTY = 'connectionId'; +/** + * Variable declaration kind recorded for fail-closed binding decisions. + * + * Example: `let HTTP_ID = 'conn'` records `let` so reads can report a mutable + * binding instead of trusting the initializer. + */ type VariableKind = VariableDeclaration['kind']; + +/** + * Object property whose value is guaranteed to be an expression. + * + * Example: `{ connectionId: HTTP_ID }` is accepted, while destructuring pattern + * values are rejected before the property reaches value resolution. + */ type ConnectionIdProperty = Property & { value: Expression }; /** @@ -35,7 +51,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. @@ -48,7 +64,17 @@ type StaticBinding = * ``` */ | { + /** + * Marks a binding as a supported immutable declaration. + * + * Example: `const HTTP_ID = 'conn-http'`. + */ kind: 'const'; + /** + * Initializer expression to resolve later. + * + * Example: the `'conn-http'` literal in `const HTTP_ID = 'conn-http'`. + */ init: Expression | null; } /** @@ -64,7 +90,17 @@ type StaticBinding = * ``` */ | { + /** + * Marks a binding as mutable and unsupported for static values. + * + * Example: `let HTTP_ID = 'conn-http'`. + */ kind: 'mutable'; + /** + * Original mutable declaration kind. + * + * Example: `let` or `var`. + */ declarationKind: Exclude; } /** @@ -79,7 +115,24 @@ type StaticBinding = * ``` */ | { + /** + * Marks a binding created from destructuring or another pattern. + * + * Example: `const { HTTP_ID } = CONNECTIONS`. + */ kind: 'unsupported-pattern'; + } + /** + * A top-level `const` binding that is assigned after declaration. This is + * invalid at runtime, but if a parser accepts it we still fail closed. + */ + | { + /** + * Marks a binding that is written after declaration. + * + * Example: `HTTP_ID = nextId()`. + */ + kind: 'reassigned'; }; /** @@ -106,6 +159,12 @@ type StaticBinding = * intentionally does not support. */ export interface SameModuleConnectionIdBindings { + /** + * Static binding facts keyed by eslint-scope variable identity. + * + * Example: the `HTTP_ID` variable from `const HTTP_ID = 'conn'` maps to a + * `const` binding with the string literal initializer. + */ byVariable: Map; } @@ -116,12 +175,121 @@ export interface SameModuleConnectionIdBindings { * infinite recursion for cycles such as `const A = B; const B = A;`. */ interface ConnectionIdResolutionContext { + /** + * Same-module binding facts available to this resolution. + * + * Example: `HTTP_ID` can resolve through `const HTTP_ID = 'conn'`. + */ bindings: SameModuleConnectionIdBindings; + /** + * Current file path used in fail-closed diagnostics. + * + * Example: `/project/src/backend/actions.backend.ts`. + */ filePath: string; + /** + * Optional graph-aware resolver for imported identifiers and object roots. + * + * Example: resolves `import { HTTP_ID } from './ids.js'`. + */ + importResolver?: ImportedConnectionIdResolver; + /** + * eslint-scope analysis for resolving identifiers to declaration identity. + * + * Example: distinguishes top-level `HTTP_ID` from a shadowed parameter with + * the same name. + */ scopeAnalysis: ScopeAnalysis; + /** + * Variables currently being resolved through const chains. + * + * Example: catches `const A = B; const B = A`. + */ seen: Set; } +/** + * Imported expression plus the resolution context from its source module. + * + * Example: resolving `import { CONNECTIONS } from './ids.js'` returns the + * `CONNECTIONS` initializer expression and the `ids.js` binding context. + */ +export interface ImportedConnectionIdValue { + /** + * Resolution context for the module that owns `expression`. + * + * Example: the context for `ids.js`, not the importing helper module. + */ + context: ConnectionIdResolutionContextInput; + /** + * Source expression that should be resolved by the shared value resolver. + * + * Example: the object literal from `export const CONNECTIONS = {...}`. + */ + expression: Expression; + /** + * Optional cleanup callback for import/export cycle tracking. + * + * Example: releases the active `ids.js\0CONNECTIONS` export key after the + * caller has resolved the returned expression. + */ + release?: () => void; +} + +/** + * Graph-aware imported value resolver used by same-module value resolution. + * + * Example: when `connectionId: HTTP_ID` references an imported variable, this + * resolver follows the import to the exported value expression. + */ +export interface ImportedConnectionIdResolver { + /** + * Resolves one imported local variable to its exported value expression. + * + * Example: local `ACTIVE_ID` from + * `import { HTTP_ID as ACTIVE_ID } from './ids.js'`. + */ + resolveImportedConnectionIdValue: ( + variable: eslintScope.Variable, + localName: string, + filePath: string, + ) => ImportedConnectionIdValue; +} + +/** + * Serializable subset of connection ID resolution context that can be passed + * between modules. + * + * Example: import tracing can hand the `ids.js` bindings and scope analysis + * back to the same value resolver used by the importing helper. + */ +export interface ConnectionIdResolutionContextInput { + /** + * Same-module binding facts for the module being resolved. + * + * Example: bindings collected from `ids.js`. + */ + bindings: SameModuleConnectionIdBindings; + /** + * File path for the module being resolved. + * + * Example: `/project/src/backend/ids.js`. + */ + filePath: string; + /** + * Optional resolver to continue through additional imported values. + * + * Example: `ids.js` can itself re-export from `shared-ids.js`. + */ + importResolver?: ImportedConnectionIdResolver; + /** + * Scope analysis for identifier lookup in the module being resolved. + * + * Example: resolves `BASE_ID` in `export const HTTP_ID = BASE_ID`. + */ + scopeAnalysis: ScopeAnalysis; +} + /** * Collects top-level variable declarations that same-module connection IDs may * reference. @@ -158,6 +326,8 @@ export function collectSameModuleConnectionIdBindings( } } + markReassignedBindings(ast, scopeAnalysis, byVariable); + return { byVariable }; } @@ -181,20 +351,26 @@ export function extractConnectionIdFromActionCall( bindings: SameModuleConnectionIdBindings, scopeAnalysis: ScopeAnalysis, filePath: string, + importResolver?: ImportedConnectionIdResolver, ): string | undefined { const [firstArg] = node.arguments; if (!firstArg || firstArg.type !== 'ObjectExpression') { + // Example: `request(options)` could hide a `connectionId`, so only + // inline object arguments are accepted. throw unsupportedActionCatalogCall(filePath, 'non-object action-catalog call arguments'); } const connectionIdProperty = findConnectionIdProperty(firstArg, filePath); if (!connectionIdProperty) { + // Example: `request({ inputs: {} })` does not request a connection and + // therefore contributes no allowlist entry. return undefined; } return resolveConnectionIdValue(connectionIdProperty.value, { bindings, filePath, + importResolver, scopeAnalysis, seen: new Set(), }); @@ -252,6 +428,105 @@ function collectVariableDeclarationBindings( } } +/** + * Marks top-level bindings as reassigned when the file writes to them after + * declaration. + * + * Example: + * + * ```ts + * const HTTP_ID = 'conn'; + * HTTP_ID = nextId(); + * ``` + * + * records `HTTP_ID` as `reassigned` so connection ID extraction fails closed. + */ +function markReassignedBindings( + ast: Program, + scopeAnalysis: ScopeAnalysis, + byVariable: Map, +): void { + walkAst(ast, undefined, { + AssignmentExpression(node) { + // Example: `HTTP_ID = nextId()` or `{ HTTP_ID } = nextIds()`. + for (const identifier of getPatternIdentifiers(node.left)) { + markReassignedBinding(identifier, scopeAnalysis, byVariable); + } + }, + UpdateExpression(node: UpdateExpression) { + // Example: `HTTP_ID++`. This is invalid for string constants, but + // if parsed it still means the binding is not trustworthy. + for (const identifier of getPatternIdentifiers(node.argument)) { + markReassignedBinding(identifier, scopeAnalysis, byVariable); + } + }, + }); +} + +/** + * Marks one identifier's tracked binding as reassigned. + * + * Example: the `HTTP_ID` identifier in `HTTP_ID = nextId()` updates the + * top-level `HTTP_ID` binding if that binding is part of connection ID analysis. + */ +function markReassignedBinding( + identifier: Identifier, + scopeAnalysis: ScopeAnalysis, + byVariable: Map, +): void { + const variable = resolveIdentifier(identifier, scopeAnalysis); + if (!variable || !byVariable.has(variable)) { + // Example: assignment to a local helper variable or unresolved global + // does not affect top-level connection ID binding facts. + return; + } + byVariable.set(variable, { kind: 'reassigned' }); +} + +/** + * Extracts all identifiers written by an assignment or update target pattern. + * + * Example: + * + * ```ts + * HTTP_ID = nextId(); + * ({ HTTP_ID } = nextIds()); + * [HTTP_ID] = nextIds(); + * ``` + * + * returns the identifiers that should be marked as reassigned. + */ +function getPatternIdentifiers(pattern: Pattern | Expression): Identifier[] { + switch (pattern.type) { + case 'Identifier': + // Example: `HTTP_ID = nextId()`. + return [pattern]; + case 'ArrayPattern': + // Example: `[HTTP_ID] = nextIds()`. + return pattern.elements.flatMap((element) => + element ? getPatternIdentifiers(element) : [], + ); + case 'ObjectPattern': + // Example: `({ HTTP_ID } = nextIds())`. + return pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + // Example: `({ ...rest } = nextIds())`. + return getPatternIdentifiers(property.argument); + } + return getPatternIdentifiers(property.value); + }); + case 'RestElement': + // Example: `[...ids] = nextIds()`. + return getPatternIdentifiers(pattern.argument); + case 'AssignmentPattern': + // Example: `({ HTTP_ID = fallback } = nextIds())`. + return getPatternIdentifiers(pattern.left); + default: + // Example: `member.value = nextId()` is not a binding identifier. + return []; + } +} + /** * Resolves one ESTree expression into the final connection ID string. * @@ -271,14 +546,20 @@ function resolveConnectionIdValue( ): string { switch (node.type) { case 'Literal': + // Example: `connectionId: 'conn-http'`. return resolveLiteral(node, context.filePath); case 'TemplateLiteral': + // Example: `` connectionId: `conn-http` ``. return resolveTemplateLiteral(node, context.filePath); case 'Identifier': + // Example: `connectionId: HTTP_ID`. return resolveIdentifierValue(node, context); case 'MemberExpression': + // Example: `connectionId: CONNECTIONS.HTTP.PROD`. return resolveObjectMemberValue(node, context); default: + // Example: `connectionId: getConnectionId()` is not statically + // safe to put in the manifest allowlist. throw unsupportedConnectionId(context.filePath, `unsupported ${node.type} values`); } } @@ -290,8 +571,10 @@ function resolveConnectionIdValue( */ function resolveLiteral(node: Literal, filePath: string): string { if (typeof node.value === 'string') { + // Example: `connectionId: 'conn-http'`. return node.value; } + // Example: `connectionId: 123` is a literal, but not a string ID. throw unsupportedConnectionId(filePath, `non-string Literal values`); } @@ -303,9 +586,12 @@ function resolveLiteral(node: Literal, filePath: string): string { */ function resolveTemplateLiteral(node: TemplateLiteral, filePath: string): string { if (node.expressions.length > 0) { + // Example: `` connectionId: `${prefix}-http` `` depends on runtime + // interpolation and cannot be included safely. throw unsupportedConnectionId(filePath, 'dynamic template literals'); } + // Example: `` connectionId: `conn-http` `` is fully static. return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''); } @@ -337,9 +623,22 @@ function resolveIdentifierValue( } if (isImportVariable(variable)) { - // Imported values require following another module, which is deferred to - // the module graph PR: - // import { HTTP_CONNECTION_ID } from './connections'; + if (context.importResolver) { + const imported = context.importResolver.resolveImportedConnectionIdValue( + variable, + identifier.name, + context.filePath, + ); + try { + return resolveConnectionIdValue(imported.expression, { + ...imported.context, + seen: context.seen, + }); + } finally { + imported.release?.(); + } + } + throw unsupportedConnectionId( context.filePath, `imported connectionId binding ${identifier.name}`, @@ -375,6 +674,11 @@ function resolveIdentifierValue( context.filePath, `destructured connectionId binding ${identifier.name}`, ); + case 'reassigned': + throw unsupportedConnectionId( + context.filePath, + `reassigned connectionId binding ${identifier.name}`, + ); case 'const': if (!binding.init) { // This is invalid JavaScript for plain `const`, but keep the @@ -438,7 +742,50 @@ 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); +} + +/** + * Resolved expression plus the module context required to keep resolving it. + * + * Example: resolving `CONNECTIONS.HTTP` returns the expression stored at + * property `HTTP` and the context where `CONNECTIONS` was declared. + */ +interface ResolvedConnectionIdExpression { + /** + * Context for resolving identifiers inside `expression`. + * + * Example: imported object members keep the source module context. + */ + context: ConnectionIdResolutionContext; + /** + * Expression selected by a connection ID lookup. + * + * Example: the `'conn-http'` literal inside `{ HTTP: 'conn-http' }`. + */ + expression: Expression; +} + +/** + * Resolved static object expression plus the module context that owns it. + * + * Example: resolving imported `CONNECTIONS` returns the object literal from + * `ids.ts` and the `ids.ts` resolution context. + */ +interface ResolvedObjectExpression { + /** + * Context for resolving nested object values. + * + * Example: `CONNECTIONS.HTTP.PROD` keeps the context where `CONNECTIONS` + * was defined. + */ + context: ConnectionIdResolutionContext; + /** + * Static object expression that can be inspected for property values. + * + * Example: `{ HTTP: { PROD: 'conn' } }`. + */ + expression: ObjectExpression; } /** @@ -451,8 +798,10 @@ function resolveObjectMemberValue( function resolveObjectMemberExpression( node: MemberExpression, context: ConnectionIdResolutionContext, -): Expression { +): ResolvedConnectionIdExpression { if (node.optional) { + // Example: `CONNECTIONS?.HTTP` can produce `undefined` at runtime and + // is not a statically guaranteed connection ID. throw unsupportedConnectionId(context.filePath, 'optional connectionId member reads'); } // We only support dot property names because the key is visible in source: @@ -470,7 +819,11 @@ function resolveObjectMemberExpression( } const objectExpression = resolveObjectExpressionValue(node.object, context); - return resolveObjectPropertyExpression(objectExpression, node.property.name, context); + return resolveObjectPropertyExpression( + objectExpression.expression, + node.property.name, + objectExpression.context, + ); } /** @@ -487,27 +840,49 @@ function resolveObjectMemberExpression( function resolveObjectExpressionValue( node: MemberExpression['object'] | Expression, context: ConnectionIdResolutionContext, -): ObjectExpression { +): ResolvedObjectExpression { if (node.type === 'ObjectExpression') { - return node; + // Example: `{ HTTP: 'conn-http' }` can be inspected directly. + return { context, expression: node }; } if (node.type === 'MemberExpression') { - return resolveObjectExpressionValue(resolveObjectMemberExpression(node, context), context); + // Example: resolving `CONNECTIONS.HTTP.PROD` first resolves + // `CONNECTIONS.HTTP` to an object, then reads `PROD`. + const resolved = resolveObjectMemberExpression(node, context); + return resolveObjectExpressionValue(resolved.expression, resolved.context); } if (node.type !== 'Identifier') { + // Example: `getConnections().HTTP` has a dynamic object root. throw unsupportedConnectionId(context.filePath, 'non-object connectionId member values'); } const variable = resolveIdentifier(node, context.scopeAnalysis); if (!variable) { + // Example: `CONNECTIONS.HTTP` with no `CONNECTIONS` declaration. throw unsupportedConnectionId(context.filePath, `unresolved object binding ${node.name}`); } // Imported maps require module graph analysis: // import { CONNECTIONS } from './connections'; // request({ connectionId: CONNECTIONS.HTTP }); if (isImportVariable(variable)) { + if (context.importResolver) { + const imported = context.importResolver.resolveImportedConnectionIdValue( + variable, + node.name, + context.filePath, + ); + try { + return resolveObjectExpressionValue(imported.expression, { + ...imported.context, + seen: context.seen, + }); + } finally { + imported.release?.(); + } + } + throw unsupportedConnectionId( context.filePath, `imported connectionId object binding ${node.name}`, @@ -516,6 +891,8 @@ function resolveObjectExpressionValue( const binding = context.bindings.byVariable.get(variable); if (!binding) { + // Example: a function-local `CONNECTIONS` object is not a top-level + // binding tracked by this static resolver. throw unsupportedConnectionId( context.filePath, `non-top-level connectionId object binding ${node.name}`, @@ -538,7 +915,15 @@ function resolveObjectExpressionValue( `destructured connectionId object binding ${node.name}`, ); } + if (binding.kind === 'reassigned') { + // Example: `const CONNECTIONS = {...}; CONNECTIONS = nextConnections`. + throw unsupportedConnectionId( + context.filePath, + `reassigned connectionId object binding ${node.name}`, + ); + } if (!binding.init) { + // Example: parser edge cases around an uninitialized `const`. throw unsupportedConnectionId( context.filePath, `uninitialized const connectionId object binding ${node.name}`, @@ -581,7 +966,7 @@ function resolveObjectPropertyExpression( objectExpression: ObjectExpression, propertyName: string, context: ConnectionIdResolutionContext, -): Expression { +): ResolvedConnectionIdExpression { let match: Property | undefined; for (const property of objectExpression.properties) { @@ -639,7 +1024,7 @@ function resolveObjectPropertyExpression( ); } - return match.value as Expression; + return { context, expression: match.value as Expression }; } /** @@ -661,30 +1046,46 @@ function findConnectionIdProperty( let connectionIdProperty: ConnectionIdProperty | undefined; for (const property of objectExpression.properties) { if (property.type === 'SpreadElement') { + // Example: `request({ ...options })` could hide `connectionId`. throw unsupportedActionCatalogCall(filePath, 'spread object arguments'); } if (property.computed) { + // Example: `request({ [key]: HTTP_ID })` could produce + // `connectionId` at runtime. throw unsupportedActionCatalogCall(filePath, 'computed object property keys'); } if (isConnectionIdKey(property)) { if (connectionIdProperty) { + // Example: duplicate `connectionId` keys rely on object + // overwrite semantics, so reject instead of guessing. throw unsupportedActionCatalogCall(filePath, 'multiple connectionId properties'); } if (property.kind !== 'init') { + // Example: `get connectionId() { return getId(); }` can run + // arbitrary code. throw unsupportedActionCatalogCall(filePath, 'accessor connectionId properties'); } if (!hasExpressionValue(property)) { + // Example: parser pattern values are not connection ID + // expressions this resolver can evaluate. throw unsupportedActionCatalogCall( filePath, 'destructuring pattern in connectionId value', ); } + // Example: `request({ connectionId: HTTP_ID, inputs: {} })`. connectionIdProperty = property; } } return connectionIdProperty; } +/** + * Narrows an object property to one whose value is an expression. + * + * Example: `{ connectionId: HTTP_ID }` returns true, while parser pattern + * values are rejected so value resolution never sees destructuring nodes. + */ function hasExpressionValue(property: Property): property is ConnectionIdProperty { const { value } = property; return ( @@ -695,6 +1096,11 @@ function hasExpressionValue(property: Property): property is ConnectionIdPropert ); } +/** + * Checks whether an object property is the action-catalog `connectionId` key. + * + * Example: matches `{ connectionId: HTTP_ID }` and `{ 'connectionId': HTTP_ID }`. + */ function isConnectionIdKey(property: Property): boolean { return getStaticPropertyKey(property) === CONNECTION_ID_PROPERTY; } @@ -707,20 +1113,36 @@ function isConnectionIdKey(property: Property): boolean { */ function getStaticPropertyKey(property: Property): string | undefined { if (property.key.type === 'Identifier') { + // Example: `{ HTTP: 'conn-http' }`. return property.key.name; } if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + // Example: `{ 'HTTP': 'conn-http' }`. return property.key.value; } + // Example: `{ [key]: 'conn-http' }` has no statically visible key. return undefined; } +/** + * Builds the common fail-closed error for unsupported action-catalog call + * shapes. + * + * Example: `request(options)` could hide `connectionId`, so it reports an + * unsupported non-object action-catalog call. + */ function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { return new Error( `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, ); } +/** + * Builds the common fail-closed error for unsupported connection ID values. + * + * Example: `connectionId: getId()` reports an unsupported call-expression + * connection ID value instead of silently omitting it from the manifest. + */ 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 040f1ee48..473d2aa9b 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 @@ -10,6 +10,7 @@ import { createParsedModuleRecord, type ParsedModuleRecord } from './module-grap const buildRoot = '/project'; const entryId = '/project/src/backend/actions.backend.js'; +const actionCatalogId = '/project/node_modules/@datadog/action-catalog/http/http.js'; function parse(code: string): Program { return parseAst(code) as Program; @@ -289,7 +290,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 constants in reachable helpers', () => { const helperId = '/project/src/backend/helpers/http.js'; const idsId = '/project/src/backend/helpers/ids.js'; const entry = createRecord( @@ -313,14 +314,356 @@ describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); } `, + [actionCatalogId, 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 and const chains', () => { + 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 { ACTIVE_ID, TEMPLATE_ID } from './ids.js'; + + export function getEcho() { + request({ connectionId: ACTIVE_ID, inputs: {} }); + request({ connectionId: TEMPLATE_ID, inputs: {} }); + } + `, + [actionCatalogId, idsId], + ); + const ids = createRecord( + idsId, + ` + const BASE_ID = 'conn-chain'; + export const ACTIVE_ID = BASE_ID; + export const TEMPLATE_ID = \`conn-template\`; + `, + ); + + expect(extract([entry, helper, ids])).toEqual(['conn-chain', 'conn-template']); + }); + + test('Should resolve imported object roots and nested static member paths', () => { + 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 { CONNECTIONS } from './ids.js'; + + export function getEcho() { + request({ connectionId: CONNECTIONS.HTTP.PROD, inputs: {} }); + } + `, + [actionCatalogId, idsId], + ); + const ids = createRecord( + idsId, + ` + const PROD_ID = 'conn-object'; + export const CONNECTIONS = { + HTTP: { + PROD: PROD_ID, + }, + }; + `, + ); + + expect(extract([entry, helper, ids])).toEqual(['conn-object']); + }); + + test('Should resolve local export aliases and re-export aliases', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const barrelId = '/project/src/backend/helpers/index.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_ID, SLACK_ID } from './helpers/index.js'; + + export function getEcho() { + request({ connectionId: HTTP_ID, inputs: {} }); + request({ connectionId: SLACK_ID, inputs: {} }); + } + `, + [actionCatalogId, barrelId], + ); + const barrel = createRecord( + barrelId, + ` + export { LOCAL_ID as HTTP_ID }; + export { REMOTE_ID as SLACK_ID } from './ids.js'; + + const LOCAL_ID = 'conn-local-alias'; + `, [idsId], ); + const ids = createRecord( + idsId, + ` + export const REMOTE_ID = 'conn-reexport'; + `, + ); + + expect(extract([entry, helper, barrel, ids])).toEqual([ + 'conn-local-alias', + 'conn-reexport', + ]); + }); - expect(() => extract([entry, helper])).toThrow( - 'imported connectionId binding HTTP_CONNECTION_ID', + test('Should resolve local import/export relays and unambiguous export stars', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const barrelId = '/project/src/backend/helpers/index.js'; + const relayId = '/project/src/backend/helpers/relay.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 { RELAY_ID, STAR_ID } from './helpers/index.js'; + + export function getEcho() { + request({ connectionId: RELAY_ID, inputs: {} }); + request({ connectionId: STAR_ID, inputs: {} }); + } + `, + [actionCatalogId, barrelId], + ); + const barrel = createRecord( + barrelId, + ` + export { RELAY_ID } from './relay.js'; + export * from './ids.js'; + `, + [relayId, idsId], + ); + const relay = createRecord( + relayId, + ` + import { SOURCE_ID as RELAY_ID } from './ids.js'; + export { RELAY_ID }; + `, + [idsId], + ); + const ids = createRecord( + idsId, + ` + export const SOURCE_ID = 'conn-relay'; + export const STAR_ID = 'conn-star-id'; + `, + ); + + expect(extract([entry, helper, barrel, relay, ids])).toEqual([ + 'conn-relay', + 'conn-star-id', + ]); + }); + + test('Should fail closed for ambiguous export stars', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const barrelId = '/project/src/backend/helpers/index.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_ID } from './helpers/index.js'; + + export function getEcho() { + request({ connectionId: HTTP_ID, inputs: {} }); + } + `, + [actionCatalogId, barrelId], + ); + const barrel = createRecord( + barrelId, + ` + export * from './a.js'; + export * from './b.js'; + `, + [aId, bId], + ); + const a = createRecord(aId, "export const HTTP_ID = 'conn-a';"); + const b = createRecord(bId, "export const HTTP_ID = 'conn-b';"); + + expect(() => extract([entry, helper, barrel, a, b])).toThrow( + 'ambiguous star export HTTP_ID', ); }); + test.each([ + { + description: 'missing exports', + idsCode: "export const OTHER_ID = 'conn';", + expectedMessage: 'missing export HTTP_ID', + }, + { + description: 'mutable exported bindings', + idsCode: "export let HTTP_ID = 'conn';", + expectedMessage: 'mutable let exported connectionId binding HTTP_ID', + }, + { + description: 'reassigned exported bindings', + idsCode: "export const HTTP_ID = 'conn'; HTTP_ID = 'changed';", + expectedMessage: 'reassigned exported connectionId binding HTTP_ID', + }, + { + description: 'default imports', + importClause: 'HTTP_ID', + idsCode: "export default 'conn';", + expectedMessage: 'default import HTTP_ID', + }, + ])( + 'Should fail closed for $description', + ({ + importClause = '{ HTTP_ID }', + idsCode, + expectedMessage, + }: { + importClause?: string; + idsCode: string; + expectedMessage: string; + }) => { + 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 ${importClause} from './ids.js'; + + export function getEcho() { + request({ connectionId: HTTP_ID, inputs: {} }); + } + `, + [actionCatalogId, idsId], + ); + const ids = createRecord(idsId, idsCode); + + expect(() => extract([entry, helper, ids])).toThrow(expectedMessage); + }, + ); + + test('Should fail closed for cyclic import/export chains', () => { + 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 { A } from './helpers/a.js'; + + export function getEcho() { + request({ connectionId: A, inputs: {} }); + } + `, + [actionCatalogId, aId], + ); + const a = createRecord( + aId, + ` + import { B } from './b.js'; + export const A = B; + `, + [bId], + ); + const b = createRecord( + bId, + ` + import { A } from './a.js'; + export const B = A; + `, + [aId], + ); + + expect(() => extract([entry, helper, a, b])).toThrow('cyclic import/export chain'); + }); + test('Should read transformed local TypeScript helpers as collected records', () => { const helperId = '/project/src/backend/helpers/http.ts'; const entry = createRecord( 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 00adee490..9360f5406 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,7 +2,10 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { extractConnectionIds } from './extract-connection-ids'; +import { + createModuleGraphConnectionIdImportResolver, + extractConnectionIds, +} from './extract-connection-ids'; import type { ParsedModuleRecord } from './module-graph'; import { walkModuleGraph } from './walk-module-graph'; @@ -16,12 +19,14 @@ export function extractConnectionIdsFromModuleGraph( buildRoot: string, ): string[] { const connectionIds = new Set(); + const importResolver = createModuleGraphConnectionIdImportResolver(modules); - // Walk the already-parsed records from this backend entry's build. The - // extraction cost is linear in reachable app-local modules, without - // reparsing source files here. walkModuleGraph(entryId, modules, buildRoot, ({ moduleId, record }) => { - const moduleConnectionIds = extractConnectionIds(record.ast, moduleId); + // 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, { + getImportResolver: importResolver.getImportResolver(record), + }); for (const connectionId of moduleConnectionIds) { connectionIds.add(connectionId); } 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 3fe90ef22..3cb287bb8 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,38 +2,1148 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode } from 'estree'; +import type * as eslintScope from 'eslint-scope'; +import type { + BaseNode, + ExportNamedDeclaration, + ExportSpecifier, + Identifier, + ImportDeclaration, + ImportSpecifier, + Literal, +} from 'estree'; import { analyzeActionCatalogScopes, findActionCatalogCallSites, + isImportVariable, + type ScopeAnalysis, } from './action-catalog-call-sites'; import { collectActionCatalogImports } from './action-catalog-imports'; import { collectSameModuleConnectionIdBindings, + type ConnectionIdResolutionContextInput, extractConnectionIdFromActionCall, + type ImportedConnectionIdResolver, + type ImportedConnectionIdValue, } from './connection-id-values'; +import type { ParsedModuleRecord } from './module-graph'; import { ensureProgram } from './type-guards'; -export function extractConnectionIds(ast: BaseNode, filePath: string): string[] { +/** + * Optional extension points for extracting connection IDs from one module. + * + * Example: + * + * ```ts + * extractConnectionIds(record.ast, record.id, { + * getImportResolver: graphImportResolver.getImportResolver(record), + * }); + * ``` + * + * lets module-graph extraction add imported value tracing while entry-file + * extraction keeps using same-module-only resolution by default. + */ +export interface ExtractConnectionIdsOptions { + /** + * Creates an import resolver after this module's same-module context has + * been collected. + * + * Example: the module graph path registers the current module record and + * returns a resolver that can follow `import { HTTP_ID } from './ids.js'`. + */ + getImportResolver?: ( + context: ConnectionIdResolutionContextInput, + ) => ImportedConnectionIdResolver | undefined; +} + +/** + * Extracts sorted, deduped action-catalog connection IDs from one parsed + * module. + * + * Example: + * + * ```ts + * request({ connectionId: HTTP_ID, inputs: {} }); + * ``` + * + * finds action-catalog call sites, collects same-module binding facts, and + * resolves each `connectionId` value. Graph extraction can pass an import + * resolver through `options` to support imported constants and object roots. + */ +export function extractConnectionIds( + ast: BaseNode, + filePath: string, + options: ExtractConnectionIdsOptions = {}, +): string[] { const program = ensureProgram(ast, filePath); const imports = collectActionCatalogImports(program); const scopeAnalysis = analyzeActionCatalogScopes(program, imports); const bindings = collectSameModuleConnectionIdBindings(program, scopeAnalysis); + // Example: graph extraction registers the current module before resolving + // imported values; same-file extraction leaves this undefined. + const importResolver = options.getImportResolver?.({ + bindings, + filePath, + scopeAnalysis, + }); const connectionIds = new Set(); for (const callSite of findActionCatalogCallSites(program, scopeAnalysis, filePath)) { + // Example: each known `request({ connectionId: ... })` call contributes + // zero or one resolved static connection ID. const connectionId = extractConnectionIdFromActionCall( callSite, bindings, scopeAnalysis, filePath, + importResolver, ); if (connectionId) { + // Example: repeated calls with the same `HTTP_ID` collapse to one + // allowlist entry for the backend file. connectionIds.add(connectionId); } } return [...connectionIds].sort(); } + +/** + * Per-module connection ID import/export analysis used while resolving imported + * `connectionId` values. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * export { SLACK_ID as ACTIVE_SLACK_ID } from './slack.js'; + * export * from './shared.js'; + * ``` + * + * records the local `HTTP_ID` import binding, an `ACTIVE_SLACK_ID` re-export, + * and one star-export edge to `./shared.js`. + */ +interface ModuleConnectionIdAnalysis extends ConnectionIdResolutionContextInput { + /** + * Imported variables keyed by eslint-scope binding identity. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * ``` + * + * maps the local `ACTIVE_ID` variable to imported name `HTTP_ID` and the + * resolved `./ids.js` module ID. + */ + importsByVariable: Map; + /** + * Named exports backed by variables declared in the current module. + * + * Example: + * + * ```ts + * const HTTP_ID = 'conn-http'; + * export { HTTP_ID as ACTIVE_ID }; + * ``` + * + * maps exported name `ACTIVE_ID` to the local `HTTP_ID` variable. + */ + localExports: Map; + /** + * Named exports forwarded from another module. + * + * Example: + * + * ```ts + * export { HTTP_ID as ACTIVE_ID } from './ids.js'; + * ``` + * + * maps exported name `ACTIVE_ID` to imported name `HTTP_ID` in the resolved + * `./ids.js` module. + */ + reExports: Map; + /** + * Star export edges used only when they resolve one unambiguous export. + * + * Example: + * + * ```ts + * export * from './ids.js'; + * ``` + * + * records a star-export edge to the resolved `./ids.js` module. + */ + starExports: ModuleStarExport[]; +} + +/** + * Import binding visible in a module. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * import DEFAULT_ID from './ids.js'; + * import * as ids from './ids.js'; + * ``` + * + * records named imports with their imported export name, while default and + * namespace imports are recorded so they can fail closed with clear errors. + */ +type ModuleImportBinding = + /** + * Named import that can be followed to a concrete exported binding. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * ``` + * + * records `importedName: 'HTTP_ID'` for the local `ACTIVE_ID` variable. + */ + | { + /** + * Export name requested from the source module. + * + * Example: `HTTP_ID` in `import { HTTP_ID as ACTIVE_ID }`. + */ + importedName: string; + /** + * Marks this as a supported named import. + * + * Example: `import { HTTP_ID } from './ids.js'`. + */ + kind: 'named'; + /** + * Canonical module ID for the import source. + * + * Example: `/project/src/backend/ids.js` for source `./ids.js`. + */ + resolvedId: string; + } + /** + * Import forms that are recorded only so value resolution can reject them + * with clear fail-closed errors. + * + * Example: + * + * ```ts + * import HTTP_ID from './ids.js'; + * import * as ids from './ids.js'; + * ``` + */ + | { + /** + * Unsupported import shape. + * + * Example: `default` for `import HTTP_ID ...`, `namespace` for + * `import * as ids ...`. + */ + kind: 'default' | 'namespace'; + /** + * Canonical module ID for the import source. + * + * Example: `/project/src/backend/ids.js` for source `./ids.js`. + */ + resolvedId: string; + }; + +/** + * A named re-export edge from the current module to another resolved module. + * + * Example: + * + * ```ts + * export { HTTP_ID as ACTIVE_ID } from './ids.js'; + * ``` + * + * records `{ importedName: 'HTTP_ID', resolvedId: '/project/src/backend/ids.js' }` + * under exported name `ACTIVE_ID`. + */ +interface ModuleReExport { + /** + * Name to read from the re-exported module. + * + * Example: `HTTP_ID` in `export { HTTP_ID as ACTIVE_ID } from './ids.js'`. + */ + importedName: string; + /** + * Canonical module ID for the re-export source. + * + * Example: `/project/src/backend/ids.js` for source `./ids.js`. + */ + resolvedId: string; +} + +/** + * A star re-export edge from the current module to another resolved module. + * + * Example: + * + * ```ts + * export * from './ids.js'; + * ``` + * + * records `{ resolvedId: '/project/src/backend/ids.js' }`. + */ +interface ModuleStarExport { + /** + * Canonical module ID for the star-export source. + * + * Example: `/project/src/backend/ids.js` for + * `export * from './ids.js'`. + */ + resolvedId: string; +} + +/** + * Shared mutable state for one graph-aware import resolver instance. + * + * Example: + * + * ```ts + * const importResolver = createModuleGraphConnectionIdImportResolver(modules); + * ``` + * + * creates one state object whose analysis cache and cycle tracking are reused + * while extracting all reachable modules for one backend entry. + */ +interface ModuleGraphImportResolverState { + /** + * Cached per-module import/export analyses keyed by canonical module ID. + * + * Example: once `/project/src/backend/ids.js` is analyzed, later imports + * from the same module reuse the cached metadata. + */ + analyses: Map; + /** + * Parsed reachable module graph records supplied by backend graph + * extraction. + * + * Example: the map contains records for `actions.backend.ts`, reachable + * helper modules, and imported local `ids.ts` modules. + */ + modules: ReadonlyMap; + /** + * Export keys currently being resolved, used to detect cycles. + * + * Example: resolving `/project/a.js\0A` twice before release means an + * import/export cycle was found. + */ + resolvingExports: Set; +} + +/** + * Factory returned by `createModuleGraphConnectionIdImportResolver`. + * + * Example: + * + * ```ts + * const graphImportResolver = createModuleGraphConnectionIdImportResolver(modules); + * const getImportResolver = graphImportResolver.getImportResolver(record); + * ``` + * + * captures graph-wide state while exposing a per-module registration hook to + * `extractConnectionIds`. + */ +interface ModuleGraphConnectionIdImportResolverFactory { + /** + * Builds the per-module callback expected by `extractConnectionIds`. + * + * Example: + * + * ```ts + * extractConnectionIds(record.ast, record.id, { + * getImportResolver: graphImportResolver.getImportResolver(record), + * }); + * ``` + * + * registers `record` with the current module's resolution context and + * returns the shared graph-aware resolver. + */ + getImportResolver: ( + record: ParsedModuleRecord, + ) => (context: ConnectionIdResolutionContextInput) => ImportedConnectionIdResolver; +} + +/** + * Internal sentinel used while probing export paths. Callers convert it into a + * user-facing fail-closed connection ID error at the import site. + */ +class MissingExportError extends Error {} + +/** + * Creates the graph-aware import resolver used by reachable-module connection + * ID extraction. + * + * Example: + * + * ```ts + * const importResolver = createModuleGraphConnectionIdImportResolver(modules); + * + * extractConnectionIds(record.ast, record.id, { + * getImportResolver: importResolver.getImportResolver(record), + * }); + * ``` + * + * The returned resolver follows named imports, re-exports, and unambiguous + * star exports through the already-collected module graph. It fails closed for + * missing graph records, default or namespace imports, ambiguous star exports, + * and import/export cycles. + */ +export function createModuleGraphConnectionIdImportResolver( + modules: ReadonlyMap, +): ModuleGraphConnectionIdImportResolverFactory { + const state: ModuleGraphImportResolverState = { + analyses: new Map(), + modules, + resolvingExports: new Set(), + }; + const resolver: ImportedConnectionIdResolver = { + resolveImportedConnectionIdValue(variable, localName, filePath) { + return resolveImportedConnectionIdValue(variable, localName, filePath, state, resolver); + }, + }; + + return { + getImportResolver(record) { + return (context) => registerModuleContext(record, context, state, resolver); + }, + }; +} + +/** + * Registers the same-module resolution context for one module before scanning + * its action-catalog calls. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * request({ connectionId: HTTP_ID, inputs: {} }); + * ``` + * + * stores the current module's imports, exports, scope analysis, and local + * connection ID bindings so later imported-value reads can resolve `HTTP_ID`. + */ +function registerModuleContext( + record: ParsedModuleRecord, + context: ConnectionIdResolutionContextInput, + state: ModuleGraphImportResolverState, + resolver: ImportedConnectionIdResolver, +): ImportedConnectionIdResolver { + state.analyses.set(record.id, collectModuleAnalysis(record, context, resolver)); + return resolver; +} + +/** + * Resolves the exported value behind one imported local binding. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * request({ connectionId: ACTIVE_ID, inputs: {} }); + * ``` + * + * follows the local `ACTIVE_ID` variable to exported name `HTTP_ID` in the + * resolved `./ids.js` module. + */ +function resolveImportedConnectionIdValue( + variable: eslintScope.Variable, + localName: string, + filePath: string, + state: ModuleGraphImportResolverState, + resolver: ImportedConnectionIdResolver, +): ImportedConnectionIdValue { + const analysis = getAnalysis(filePath, state, resolver); + const binding = analysis.importsByVariable.get(variable); + if (!binding) { + // Example: eslint-scope says `HTTP_ID` is imported, but this module's + // import declaration was not recorded. That means the analysis is + // internally inconsistent, so fail closed. + throw unsupportedConnectionId(filePath, `unresolved imported binding ${localName}`); + } + + if (binding.kind !== 'named') { + // Example: `import HTTP_ID from './ids.js'` or + // `import * as ids from './ids.js'`. The first version only supports + // named imports because they map directly to one exported binding. + throw unsupportedConnectionId(filePath, `${binding.kind} import ${localName}`); + } + + try { + return resolveExport(binding.resolvedId, binding.importedName, filePath, state, resolver); + } catch (error) { + if (error instanceof MissingExportError) { + // Example: `import { HTTP_ID } from './ids.js'` but `ids.js` only + // exports `SLACK_ID`. Surface the missing export at the import site. + throw unsupportedConnectionId( + filePath, + `missing export ${binding.importedName} from ${binding.resolvedId}`, + ); + } + throw error; + } +} + +/** + * Resolves a named export from a module to the expression that defines its + * value. + * + * Example: + * + * ```ts + * export const HTTP_ID = 'conn-http'; + * export { SLACK_ID } from './slack.js'; + * export * from './shared.js'; + * ``` + * + * tries direct local exports first, then named re-exports, then unambiguous + * star exports. + */ +function resolveExport( + moduleId: string, + exportName: string, + requestingFilePath: string, + state: ModuleGraphImportResolverState, + resolver: ImportedConnectionIdResolver, +): ImportedConnectionIdValue { + if (exportName === 'default') { + // Example: `import HTTP_ID from './ids.js'`. Default export semantics + // are intentionally out of scope for this resolver. + throw unsupportedConnectionId(requestingFilePath, 'default exports'); + } + + const analysis = getAnalysis(moduleId, state, resolver); + const exportKey = `${moduleId}\0${exportName}`; + if (state.resolvingExports.has(exportKey)) { + // Example: `a.js` exports `A = B` from `b.js`, and `b.js` exports + // `B = A` from `a.js`. Stop before recursive export resolution loops. + throw unsupportedConnectionId( + requestingFilePath, + `cyclic import/export chain for ${exportName}`, + ); + } + + state.resolvingExports.add(exportKey); + try { + const local = analysis.localExports.get(exportName); + if (local) { + // Example: `export const HTTP_ID = 'conn-http'` or + // `const HTTP_ID = 'conn-http'; export { HTTP_ID };`. + return withExportRelease( + resolveLocalExport(local, exportName, analysis, state, resolver), + exportKey, + state, + ); + } + + const reExport = analysis.reExports.get(exportName); + if (reExport) { + // Example: `export { HTTP_ID as ACTIVE_ID } from './ids.js'`. + // Follow the named edge to the source module and preserve the + // current export key until the returned expression is resolved. + try { + return withExportRelease( + resolveExport( + reExport.resolvedId, + reExport.importedName, + analysis.filePath, + state, + resolver, + ), + exportKey, + state, + ); + } catch (error) { + if (error instanceof MissingExportError) { + // Example: the barrel says + // `export { HTTP_ID } from './ids.js'`, but `ids.js` does + // not provide `HTTP_ID`. + throw unsupportedConnectionId( + analysis.filePath, + `missing export ${reExport.importedName} from ${reExport.resolvedId}`, + ); + } + throw error; + } + } + + const starMatches: ImportedConnectionIdValue[] = []; + for (const starExport of analysis.starExports) { + try { + // Example: `export * from './ids.js'`. Missing exports are + // expected while probing star-export candidates, so only + // successful matches are collected. + starMatches.push( + resolveExport( + starExport.resolvedId, + exportName, + analysis.filePath, + state, + resolver, + ), + ); + } catch (error) { + if (error instanceof MissingExportError) { + // Example: one star-export source does not export + // `HTTP_ID`; keep looking because another star source may + // provide it unambiguously. + continue; + } + throw error; + } + } + + if (starMatches.length === 1) { + // Example: exactly one `export *` source provides `HTTP_ID`, so the + // barrel has an unambiguous static value. + return withExportRelease(starMatches[0], exportKey, state); + } + if (starMatches.length > 1) { + // Example: both `export * from './a.js'` and + // `export * from './b.js'` provide `HTTP_ID`. Reject instead of + // guessing which export wins. + for (const match of starMatches) { + match.release?.(); + } + throw unsupportedConnectionId( + requestingFilePath, + `ambiguous star export ${exportName}`, + ); + } + + // Example: neither local exports, named re-exports, nor star exports + // can provide the requested name. + throw new MissingExportError(`Missing export ${exportName} from ${moduleId}`); + } catch (error) { + // If resolution fails before a value is handed back to the caller, the + // in-progress export key can be released immediately. + state.resolvingExports.delete(exportKey); + throw error; + } +} + +/** + * Resolves an export that is backed by a local eslint-scope variable. + * + * Example: + * + * ```ts + * const HTTP_ID = 'conn-http'; + * export { HTTP_ID }; + * ``` + * + * returns the initializer expression for supported top-level `const` bindings, + * or follows an imported export relay such as `import { ID } ...; export { ID }`. + */ +function resolveLocalExport( + variable: eslintScope.Variable, + exportName: string, + analysis: ModuleConnectionIdAnalysis, + state: ModuleGraphImportResolverState, + resolver: ImportedConnectionIdResolver, +): ImportedConnectionIdValue { + if (isImportVariable(variable)) { + // Example: `import { HTTP_ID } from './ids.js'; export { HTTP_ID };`. + // This local export is only a relay, so continue through the import. + const binding = analysis.importsByVariable.get(variable); + if (!binding) { + // Example: eslint-scope marks `HTTP_ID` as imported, but the import + // declaration was not captured in module analysis. + throw unsupportedConnectionId( + analysis.filePath, + `unresolved imported export ${exportName}`, + ); + } + if (binding.kind !== 'named') { + // Example: `import HTTP_ID from './ids.js'; export { HTTP_ID };`. + // Default and namespace import relays stay unsupported. + throw unsupportedConnectionId( + analysis.filePath, + `${binding.kind} import ${exportName}`, + ); + } + return resolveExport( + binding.resolvedId, + binding.importedName, + analysis.filePath, + state, + resolver, + ); + } + + const binding = analysis.bindings.byVariable.get(variable); + if (!binding) { + // Example: + // `function makeId() { const HTTP_ID = 'conn'; } export { HTTP_ID }`. + // Only top-level static bindings are safe to use as exported constants. + throw unsupportedConnectionId( + analysis.filePath, + `non-top-level exported connectionId binding ${exportName}`, + ); + } + + switch (binding.kind) { + case 'mutable': + // Example: `export let HTTP_ID = 'conn-http'`. The value can change + // before the action runs, so the manifest cannot trust it. + throw unsupportedConnectionId( + analysis.filePath, + `mutable ${binding.declarationKind} exported connectionId binding ${exportName}`, + ); + case 'unsupported-pattern': + // Example: `export const { HTTP_ID } = CONNECTIONS`. Destructuring + // adds aliasing this static resolver does not follow. + throw unsupportedConnectionId( + analysis.filePath, + `destructured exported connectionId binding ${exportName}`, + ); + case 'reassigned': + // Example: `export const HTTP_ID = 'conn'; HTTP_ID = nextId`. + // Even if invalid at runtime, a parsed reassignment fails closed. + throw unsupportedConnectionId( + analysis.filePath, + `reassigned exported connectionId binding ${exportName}`, + ); + case 'const': + if (!binding.init) { + // Example: parser edge cases around `const HTTP_ID;`. Keep the + // guard explicit even though normal JavaScript rejects it. + throw unsupportedConnectionId( + analysis.filePath, + `uninitialized exported connectionId binding ${exportName}`, + ); + } + // Example: `export const HTTP_ID = 'conn-http'`. Return the raw + // expression so the shared value resolver can handle strings, + // template literals, const chains, and static objects. + return { + context: analysis, + expression: binding.init, + }; + } +} + +/** + * Wraps a resolved export value with cleanup for the active export-resolution + * stack. + * + * Example: + * + * ```ts + * export { HTTP_ID } from './ids.js'; + * ``` + * + * keeps the current `HTTP_ID` export marked as resolving until the caller has + * finished resolving the returned expression, so nested import cycles remain + * detectable. + */ +function withExportRelease( + value: ImportedConnectionIdValue, + exportKey: string, + state: ModuleGraphImportResolverState, +): ImportedConnectionIdValue { + return { + ...value, + release: () => { + try { + // Example: nested re-export resolution may have its own release + // callback. Run it before clearing this export key. + value.release?.(); + } finally { + state.resolvingExports.delete(exportKey); + } + }, + }; +} + +/** + * Returns cached import/export analysis for a module, computing it on demand. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * ``` + * + * causes `ids.js` to be analyzed only if `HTTP_ID` is actually needed while + * resolving a reachable action-catalog call. + */ +function getAnalysis( + moduleId: string, + state: ModuleGraphImportResolverState, + resolver: ImportedConnectionIdResolver, +): ModuleConnectionIdAnalysis { + const cached = state.analyses.get(moduleId); + if (cached) { + // Example: two calls import from the same `ids.js`; reuse its collected + // import/export metadata instead of walking its AST twice. + return cached; + } + + const record = state.modules.get(moduleId); + if (!record) { + // Example: `import { HTTP_ID } from './ids.js'`, but `ids.js` is not in + // the reachable parsed module graph collected by the backend build. + throw unsupportedConnectionId(moduleId, `imported value outside reachable graph`); + } + + const imports = collectActionCatalogImports(record.ast); + const scopeAnalysis = analyzeActionCatalogScopes(record.ast, imports); + const bindings = collectSameModuleConnectionIdBindings(record.ast, scopeAnalysis); + const context = { + bindings, + filePath: record.id, + importResolver: resolver, + scopeAnalysis, + }; + const analysis = collectModuleAnalysis(record, context, resolver); + state.analyses.set(record.id, analysis); + return analysis; +} + +/** + * Collects import and export metadata from one parsed module. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * export { HTTP_ID as ACTIVE_ID }; + * export * from './shared.js'; + * ``` + * + * records import bindings, local export aliases, named re-exports, and star + * export edges without resolving any values yet. + */ +function collectModuleAnalysis( + record: ParsedModuleRecord, + context: ConnectionIdResolutionContextInput, + importResolver: ImportedConnectionIdResolver, +): ModuleConnectionIdAnalysis { + const analysis: ModuleConnectionIdAnalysis = { + ...context, + importResolver, + importsByVariable: new Map(), + localExports: new Map(), + reExports: new Map(), + starExports: [], + }; + + for (const node of record.ast.body) { + if (node.type === 'ImportDeclaration') { + // Example: `import { HTTP_ID } from './ids.js'`. + collectImportDeclaration(node, record, analysis); + continue; + } + + if (node.type === 'ExportNamedDeclaration') { + // Examples: `export const HTTP_ID = 'conn'`, + // `export { HTTP_ID }`, and + // `export { HTTP_ID } from './ids.js'`. + collectExportNamedDeclaration(node, record, analysis); + continue; + } + + if (node.type === 'ExportAllDeclaration' && node.source && isStringLiteral(node.source)) { + const resolvedId = getResolvedSource(record, node.source.value); + if ('exported' in node && node.exported) { + // Example: `export * as ids from './ids.js'`. ESTree models + // this as an export-all with an exported name, so record it as + // a named re-export of `*`. + analysis.reExports.set(getModuleName(node.exported), { + importedName: '*', + resolvedId, + }); + } else { + // Example: `export * from './ids.js'`. Keep the edge for later + // unambiguous star-export probing. + analysis.starExports.push({ resolvedId }); + } + } + } + + return analysis; +} + +/** + * Records import specifiers from one static import declaration. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * import DEFAULT_ID from './defaults.js'; + * import * as ids from './ids.js'; + * ``` + * + * maps the declared local variables to named/default/namespace import metadata. + */ +function collectImportDeclaration( + node: ImportDeclaration, + record: ParsedModuleRecord, + analysis: ModuleConnectionIdAnalysis, +): void { + if (!isStringLiteral(node.source)) { + // Example: parser edge cases with a non-literal import source cannot be + // matched to Rollup's resolved static dependency, so ignore the record. + return; + } + + const resolvedId = getResolvedSource(record, node.source.value); + for (const specifier of node.specifiers) { + const [variable] = analysis.scopeAnalysis.scopeManager.getDeclaredVariables(specifier); + if (!variable) { + // Example: if eslint-scope does not produce a variable for an + // import specifier, there is no stable binding identity to store. + continue; + } + + if (specifier.type === 'ImportSpecifier') { + // Example: `import { HTTP_ID as ACTIVE_ID } from './ids.js'`. + // Store the exported name `HTTP_ID` under local variable `ACTIVE_ID`. + analysis.importsByVariable.set(variable, { + importedName: getImportSpecifierName(specifier), + kind: 'named', + resolvedId, + }); + } else if (specifier.type === 'ImportDefaultSpecifier') { + // Example: `import HTTP_ID from './ids.js'`. Record it so a later + // value read can fail closed with a default-import error. + analysis.importsByVariable.set(variable, { kind: 'default', resolvedId }); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + // Example: `import * as ids from './ids.js'`. Record it so + // namespace value reads fail closed explicitly. + analysis.importsByVariable.set(variable, { kind: 'namespace', resolvedId }); + } + } +} + +/** + * Records local named exports and named re-export edges from one export + * declaration. + * + * Example: + * + * ```ts + * export const HTTP_ID = 'conn-http'; + * export { LOCAL_ID as HTTP_ID }; + * export { REMOTE_ID as SLACK_ID } from './ids.js'; + * ``` + * + * maps local exports to eslint-scope variables and re-exports to resolved + * module edges. + */ +function collectExportNamedDeclaration( + node: ExportNamedDeclaration, + record: ParsedModuleRecord, + analysis: ModuleConnectionIdAnalysis, +): void { + if (node.declaration) { + // Example: `export const HTTP_ID = 'conn-http'`. The declaration itself + // creates the exported binding. + collectDeclarationExports(node.declaration, analysis); + return; + } + + if (node.source && isStringLiteral(node.source)) { + // Example: `export { HTTP_ID as ACTIVE_ID } from './ids.js'`. This is a + // named edge to another module, not a local variable export. + const resolvedId = getResolvedSource(record, node.source.value); + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier') { + // Example: parser-specific export specifier shapes that are not + // standard named exports are ignored for this resolver. + continue; + } + analysis.reExports.set(getExportedName(specifier), { + importedName: getModuleName(specifier.local), + resolvedId, + }); + } + return; + } + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier') { + // Example: ignore non-standard export specifier forms rather than + // inventing semantics for connection ID resolution. + continue; + } + const variable = findVariable(analysis.scopeAnalysis, getModuleName(specifier.local)); + if (variable) { + // Example: `const HTTP_ID = 'conn'; export { HTTP_ID as ACTIVE_ID }`. + analysis.localExports.set(getExportedName(specifier), variable); + } else { + // Example: `export { HTTP_ID }` without a local `HTTP_ID` binding. + // The value resolver will fail later if an action-catalog call + // needs it. + } + } +} + +/** + * Records variables created by an exported declaration. + * + * Example: + * + * ```ts + * export const HTTP_ID = 'conn-http'; + * ``` + * + * maps exported name `HTTP_ID` to the eslint-scope variable declared by the + * export statement. + */ +function collectDeclarationExports( + declaration: ExportNamedDeclaration['declaration'], + analysis: ModuleConnectionIdAnalysis, +): void { + if (!declaration) { + // Example: defensive guard for parser shapes where an export wrapper + // has no declaration payload. + return; + } + + for (const variable of analysis.scopeAnalysis.scopeManager.getDeclaredVariables(declaration)) { + analysis.localExports.set(variable.name, variable); + } +} + +/** + * Finds a variable by name in the module's eslint-scope analysis. + * + * Example: + * + * ```ts + * const HTTP_ID = 'conn-http'; + * export { HTTP_ID as ACTIVE_ID }; + * ``` + * + * finds the local `HTTP_ID` variable referenced by the export specifier. + */ +function findVariable( + scopeAnalysis: ScopeAnalysis, + name: string, +): eslintScope.Variable | undefined { + for (const scope of scopeAnalysis.scopeManager.scopes) { + const variable = scope.variables.find((candidate) => candidate.name === name); + if (variable) { + // Example: prefer the first eslint-scope variable named `HTTP_ID`; + // exported top-level names should be present in the module scope. + return variable; + } + } + return undefined; +} + +/** + * Maps an AST source literal to Rollup's resolved module ID. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * ``` + * + * resolves `./ids.js` to the canonical module ID stored on the parsed module + * record, falling back to the source string if no resolution is available. + */ +function getResolvedSource(record: ParsedModuleRecord, source: string): string { + return ( + record.staticDependencies.find((dependency) => dependency.source === source)?.resolvedId ?? + source + ); +} + +/** + * Reads the exported name requested by an import specifier. + * + * Example: + * + * ```ts + * import { HTTP_ID as ACTIVE_ID } from './ids.js'; + * ``` + * + * returns `HTTP_ID`, not the local alias `ACTIVE_ID`. + */ +function getImportSpecifierName(specifier: ImportSpecifier): string { + return getModuleName(specifier.imported); +} + +/** + * Reads the public exported name from an export specifier. + * + * Example: + * + * ```ts + * export { LOCAL_ID as HTTP_ID }; + * ``` + * + * returns `HTTP_ID`, not the local name `LOCAL_ID`. + */ +function getExportedName(specifier: ExportSpecifier): string { + return getModuleName(specifier.exported); +} + +/** + * Normalizes ESTree identifier and string-literal module names. + * + * Example: + * + * ```ts + * export { HTTP_ID }; + * export { "legacy-id" as HTTP_ID }; + * ``` + * + * returns the identifier name for `HTTP_ID` and the string value for literal + * export names. + */ +function getModuleName(node: Identifier | Literal): string { + if (node.type === 'Identifier') { + // Example: `{ HTTP_ID }` stores the module name directly on the + // identifier node. + return node.name; + } + // Example: `{ "legacy-id" as HTTP_ID }` uses a literal for the imported or + // exported module name. + return String(node.value); +} + +/** + * Narrows unknown ESTree nodes to string literals. + * + * Example: + * + * ```ts + * import { HTTP_ID } from './ids.js'; + * ``` + * + * returns true for the `./ids.js` source literal and false for non-literal + * parser edge cases. + */ +function isStringLiteral(node: unknown): node is Literal & { value: string } { + const maybeNode = node as { type?: unknown; value?: unknown } | undefined; + return maybeNode?.type === 'Literal' && typeof maybeNode.value === 'string'; +} + +/** + * Builds the common fail-closed connection ID error for imported value tracing. + * + * Example: + * + * ```ts + * import HTTP_ID from './ids.js'; + * request({ connectionId: HTTP_ID, inputs: {} }); + * ``` + * + * becomes `Unsupported action-catalog connectionId ... default import HTTP_ID`. + */ +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/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index 9269bcb05..0baa0d9dc 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -13,7 +13,30 @@ import { walkAst } from './walk-ast'; export interface ParsedModuleRecord { id: string; ast: Program; + /** + * Static import/export graph edges reported by Rollup, with both the source + * literal from the module AST and the canonical resolved module ID. + * + * Example: + * + * ```ts + * export { HTTP_ID } from './ids.js'; + * ``` + * + * records `{ source: './ids.js', resolvedId: '/project/src/backend/ids.js' }`. + */ staticDependencies: StaticModuleDependency[]; + /** + * Local dependency forms that cannot be represented as static graph edges. + * + * Example: + * + * ```ts + * const helper = await import('./helpers/' + name); + * ``` + * + * records `{ specifier: 'non-literal dynamic import', kind: 'dynamic-import' }`. + */ unsupportedDependencies: ModuleDependency[]; } @@ -64,7 +87,7 @@ function collectStaticModuleDependencies( ast: Program, staticDependencyIds: string[], ): StaticModuleDependency[] { - const staticModuleSources = getStaticModuleSources(ast); + const staticModuleSources = ast.body.flatMap(getStaticModuleSources); return staticDependencyIds.map((resolvedId, index) => ({ source: staticModuleSources[index] ?? resolvedId, @@ -72,20 +95,18 @@ function collectStaticModuleDependencies( })); } -function getStaticModuleSources(ast: Program): string[] { - return ast.body.flatMap((node) => { - if ( - (node.type === 'ImportDeclaration' || - node.type === 'ExportNamedDeclaration' || - node.type === 'ExportAllDeclaration') && - node.source && - isStringLiteral(node.source) - ) { - return [node.source.value]; - } - - return []; - }); +function getStaticModuleSources(node: Program['body'][number]): string[] { + if (node.type === 'ImportDeclaration' && isStringLiteral(node.source)) { + return [node.source.value]; + } + if ( + (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && + node.source && + isStringLiteral(node.source) + ) { + return [node.source.value]; + } + return []; } /**