diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts index aaee20224..52bab7b22 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts @@ -4,10 +4,36 @@ import { parseAst } from 'rollup/parseAst'; -import { createParsedModuleRecord } from './module-graph'; +import { + createParsedModuleRecord, + type ExportBinding, + type ImportBinding, + type ParsedModuleRecord, + type StaticBinding, +} from './module-graph'; const buildRoot = '/project'; +function createRecord(code: string, staticDependencies: string[] = []): ParsedModuleRecord { + const record = createParsedModuleRecord( + '/project/src/backend/actions.backend.js', + buildRoot, + parseAst(code), + staticDependencies, + ); + + if (!record) { + throw new Error('Expected module record to be created'); + } + return record; +} + +function bindingsByVariableName(bindings: Map<{ name: string }, T>): Record { + return Object.fromEntries( + [...bindings.entries()].map(([variable, binding]) => [variable.name, binding]), + ); +} + describe('Backend Functions - module graph records', () => { test('Should create graph records for app-local backend modules', () => { const record = createParsedModuleRecord( @@ -128,4 +154,120 @@ describe('Backend Functions - module graph records', () => { expect(record?.unsupportedDependencies).toEqual([]); }); + + test('Should record import bindings by declared variable identity', () => { + const record = createRecord( + ` + import { HTTP_ID as ACTIVE_ID } from './ids.js'; + import DEFAULT_ID from './defaults.js'; + import * as namespaceIds from './namespace.js'; + `, + [ + '/project/src/backend/ids.js', + '/project/src/backend/defaults.js', + '/project/src/backend/namespace.js', + ], + ); + + expect(bindingsByVariableName(record.importsByVariable)).toMatchObject({ + ACTIVE_ID: { + kind: 'named', + importedName: 'HTTP_ID', + resolvedId: '/project/src/backend/ids.js', + }, + DEFAULT_ID: { + kind: 'default', + resolvedId: '/project/src/backend/defaults.js', + }, + namespaceIds: { + kind: 'namespace', + resolvedId: '/project/src/backend/namespace.js', + }, + }); + }); + + test('Should record local exports, re-exports, unsupported named exports, and star exports', () => { + const record = createRecord( + ` + const LOCAL_ID = 'conn-local'; + const { PATTERN_ID } = ids; + + export const DIRECT_ID = 'conn-direct'; + export { LOCAL_ID as ACTIVE_ID, PATTERN_ID }; + export { REMOTE_ID as FORWARDED_ID, default as DEFAULT_ID } from './ids.js'; + export * as namespaceIds from './namespace.js'; + export * from './star.js'; + export { LOCAL_ID as default }; + `, + [ + '/project/src/backend/ids.js', + '/project/src/backend/namespace.js', + '/project/src/backend/star.js', + ], + ); + const exportsByName = Object.fromEntries(record.exportsByName) as Record< + string, + ExportBinding + >; + + expect(exportsByName.ACTIVE_ID).toMatchObject({ kind: 'local' }); + expect(exportsByName.DIRECT_ID).toMatchObject({ kind: 'local' }); + expect(exportsByName.PATTERN_ID).toMatchObject({ kind: 'local' }); + expect(exportsByName.FORWARDED_ID).toEqual({ + kind: 're-export', + importedName: 'REMOTE_ID', + resolvedId: '/project/src/backend/ids.js', + }); + expect(exportsByName.DEFAULT_ID).toEqual({ + kind: 're-export', + importedName: 'default', + resolvedId: '/project/src/backend/ids.js', + }); + expect(exportsByName.namespaceIds).toEqual({ + kind: 'unsupported', + reason: 'namespace re-export', + resolvedId: '/project/src/backend/namespace.js', + }); + expect(exportsByName.default).toEqual({ + kind: 'unsupported', + reason: 'default export', + }); + expect(record.starExports).toEqual([{ resolvedId: '/project/src/backend/star.js' }]); + }); + + test('Should record top-level static bindings by declared variable identity', () => { + const record = createRecord(` + const CONST_ID = 'conn-const'; + let MUTABLE_ID = 'conn-mutable'; + const { PATTERN_ID } = ids; + function getId() { + return 'conn-function'; + } + export default function defaultGetId() { + return 'conn-default-function'; + } + const CONNECTIONS = { HTTP: 'conn-http' }; + CONNECTIONS.HTTP = 'conn-mutated'; + const DELETED_CONNECTIONS = { HTTP: 'conn-http' }; + delete DELETED_CONNECTIONS.HTTP; + const FOR_IN_CONNECTIONS = { HTTP: 'conn-http' }; + for (FOR_IN_CONNECTIONS.HTTP in source) {} + const FOR_OF_CONNECTIONS = { HTTP: 'conn-http' }; + for (FOR_OF_CONNECTIONS.HTTP of source) {} + `); + + expect( + bindingsByVariableName(record.topLevelBindingsByVariable), + ).toMatchObject({ + CONST_ID: { kind: 'const' }, + MUTABLE_ID: { kind: 'mutable', declarationKind: 'let' }, + PATTERN_ID: { kind: 'unsupported', reason: 'binding pattern' }, + getId: { kind: 'unsupported', reason: 'FunctionDeclaration binding' }, + defaultGetId: { kind: 'unsupported', reason: 'FunctionDeclaration binding' }, + CONNECTIONS: { kind: 'unsupported', reason: 'mutated object binding' }, + DELETED_CONNECTIONS: { kind: 'unsupported', reason: 'mutated object binding' }, + FOR_IN_CONNECTIONS: { kind: 'unsupported', reason: 'mutated object binding' }, + FOR_OF_CONNECTIONS: { kind: 'unsupported', reason: 'mutated object binding' }, + }); + }); }); 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..171f90066 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -2,19 +2,57 @@ // 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 * as eslintScope from 'eslint-scope'; +import type { + BaseNode, + ExportAllDeclaration, + ExportNamedDeclaration, + Expression, + Identifier, + ImportExpression, + Literal, + ModuleDeclaration, + Pattern, + Program, + SimpleCallExpression, + Super, + VariableDeclaration, +} from 'estree'; import path from 'path'; import { BACKEND_CODE_EXTENSIONS } from '../../constants'; +import { + analyzeModuleScope, + getModuleVariable, + isImportVariable, + type ModuleScopeAnalysis, + resolveIdentifier, +} from './module-scope'; import { ensureProgram, isStringLiteral } from './type-guards'; import { walkAst } from './walk-ast'; +/** + * Parsed app-local backend module plus reusable static facts about its module + * boundary and top-level declarations. These facts are intentionally + * domain-neutral: action-catalog and connection ID logic consume them later, + * but they are not encoded into the record itself. + */ export interface ParsedModuleRecord { id: string; ast: Program; + scopeAnalysis: ModuleScopeAnalysis; staticDependencies: StaticModuleDependency[]; unsupportedDependencies: ModuleDependency[]; + importsByVariable: Map; + /** + * Export names that can be answered directly by this module. Bare + * `export *` edges stay in `starExports` because they must be probed by + * requested export name during resolution. + */ + exportsByName: Map; + starExports: StarExport[]; + topLevelBindingsByVariable: Map; } export interface StaticModuleDependency { @@ -27,7 +65,80 @@ export interface ModuleDependency { kind: 'dynamic-import' | 'require'; } +/** + * Import binding keyed by the local eslint-scope variable, not the local text + * name, so later lookups remain safe when names are shadowed. + */ +export type ImportBinding = NamedImportBinding | DefaultImportBinding | NamespaceImportBinding; + +export interface NamedImportBinding { + kind: 'named'; + importedName: string; + resolvedId: string; +} + +export interface DefaultImportBinding { + kind: 'default'; + resolvedId: string; +} + +export interface NamespaceImportBinding { + kind: 'namespace'; + resolvedId: string; +} + +/** + * Export binding for names this module can answer without probing star + * exports. Unsupported records preserve the fact that an export name exists + * even when later static definition resolution will reject its form. + */ +export type ExportBinding = LocalExportBinding | ReExportBinding | UnsupportedExportBinding; + +export interface LocalExportBinding { + kind: 'local'; + variable: eslintScope.Variable; +} + +export interface ReExportBinding { + kind: 're-export'; + importedName: string; + resolvedId: string; +} + +export interface UnsupportedExportBinding { + kind: 'unsupported'; + reason: string; + resolvedId?: string; +} + +export interface StarExport { + resolvedId: string; +} + +/** + * Top-level declaration summary. This records whether a local variable has a + * static expression later resolvers can inspect, without deciding whether that + * expression is meaningful for any particular domain. + */ +export type StaticBinding = ConstStaticBinding | MutableStaticBinding | UnsupportedStaticBinding; + +export interface ConstStaticBinding { + kind: 'const'; + expression: Expression | null; +} + +export interface MutableStaticBinding { + kind: 'mutable'; + declarationKind: Exclude; +} + +export interface UnsupportedStaticBinding { + kind: 'unsupported'; + reason: string; +} + type ImportCallExpression = SimpleCallExpression & { callee: { type: 'Import' } }; +type ModuleExportName = Identifier | Literal; const PACKAGE_MANAGER_DIRS = new Set(['node_modules', '.yarn']); @@ -51,12 +162,19 @@ export function createParsedModuleRecord( } const program = ensureProgram(ast, moduleId); + const scopeAnalysis = analyzeModuleScope(program); + const staticModuleDependencies = collectStaticModuleDependencies(program, staticDependencies); return { id: moduleId, ast: program, - staticDependencies: collectStaticModuleDependencies(program, staticDependencies), + scopeAnalysis, + staticDependencies: staticModuleDependencies, unsupportedDependencies: collectUnsupportedModuleDependencies(program), + importsByVariable: collectImportBindings(program, scopeAnalysis, staticModuleDependencies), + exportsByName: collectExportBindings(program, scopeAnalysis, staticModuleDependencies), + starExports: collectStarExports(program, staticModuleDependencies), + topLevelBindingsByVariable: collectTopLevelBindings(program, scopeAnalysis), }; } @@ -88,6 +206,417 @@ function getStaticModuleSources(ast: Program): string[] { }); } +function collectImportBindings( + ast: Program, + scopeAnalysis: ModuleScopeAnalysis, + staticDependencies: StaticModuleDependency[], +): Map { + const importsByVariable = new Map(); + + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration' || !isStringLiteral(node.source)) { + continue; + } + + const resolvedId = getResolvedSource(staticDependencies, node.source.value); + for (const specifier of node.specifiers) { + const [variable] = scopeAnalysis.scopeManager.getDeclaredVariables(specifier); + if (!variable) { + continue; + } + + if (specifier.type === 'ImportSpecifier') { + importsByVariable.set(variable, { + kind: 'named', + importedName: getModuleExportName(specifier.imported), + resolvedId, + }); + continue; + } + + importsByVariable.set(variable, { + kind: specifier.type === 'ImportDefaultSpecifier' ? 'default' : 'namespace', + resolvedId, + }); + } + } + + return importsByVariable; +} + +function collectExportBindings( + ast: Program, + scopeAnalysis: ModuleScopeAnalysis, + staticDependencies: StaticModuleDependency[], +): Map { + const exportsByName = new Map(); + + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + collectNamedExportBindings(node, scopeAnalysis, staticDependencies, exportsByName); + continue; + } + + if (node.type === 'ExportDefaultDeclaration') { + exportsByName.set('default', { kind: 'unsupported', reason: 'default export' }); + continue; + } + + if (node.type === 'ExportAllDeclaration') { + collectNamespaceExportBinding(node, staticDependencies, exportsByName); + } + } + + return exportsByName; +} + +function collectNamedExportBindings( + node: ExportNamedDeclaration, + scopeAnalysis: ModuleScopeAnalysis, + staticDependencies: StaticModuleDependency[], + exportsByName: Map, +): void { + if (node.declaration) { + collectDeclarationExportBindings(node.declaration, scopeAnalysis, exportsByName); + return; + } + + if (node.source && isStringLiteral(node.source)) { + const resolvedId = getResolvedSource(staticDependencies, node.source.value); + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier') { + continue; + } + const exportedName = getModuleExportName(specifier.exported); + if (exportedName === 'default') { + exportsByName.set(exportedName, { + kind: 'unsupported', + reason: 'default re-export', + resolvedId, + }); + continue; + } + exportsByName.set(exportedName, { + kind: 're-export', + importedName: getModuleExportName(specifier.local), + resolvedId, + }); + } + return; + } + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier') { + continue; + } + const exportedName = getModuleExportName(specifier.exported); + if (exportedName === 'default') { + exportsByName.set(exportedName, { + kind: 'unsupported', + reason: 'default export', + }); + continue; + } + const variable = getModuleVariable(getModuleExportName(specifier.local), scopeAnalysis); + exportsByName.set( + exportedName, + variable + ? { kind: 'local', variable } + : { kind: 'unsupported', reason: 'unresolved local export' }, + ); + } +} + +function collectDeclarationExportBindings( + declaration: ExportNamedDeclaration['declaration'], + scopeAnalysis: ModuleScopeAnalysis, + exportsByName: Map, +): void { + if (!declaration) { + return; + } + + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + const variables = scopeAnalysis.scopeManager.getDeclaredVariables(declarator); + if (declarator.id.type !== 'Identifier') { + for (const variable of variables) { + exportsByName.set(variable.name, { + kind: 'unsupported', + reason: 'binding pattern export', + }); + } + continue; + } + + const [variable] = variables; + if (variable) { + exportsByName.set(declarator.id.name, { kind: 'local', variable }); + } + } + return; + } + + if ( + (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && + declaration.id + ) { + const [variable] = scopeAnalysis.scopeManager.getDeclaredVariables(declaration); + if (variable) { + exportsByName.set(declaration.id.name, { kind: 'local', variable }); + } + } +} + +function collectNamespaceExportBinding( + node: ExportAllDeclaration, + staticDependencies: StaticModuleDependency[], + exportsByName: Map, +): void { + const exported = getExportAllExportedName(node); + if (!exported || !isStringLiteral(node.source)) { + return; + } + + exportsByName.set(exported, { + kind: 'unsupported', + reason: 'namespace re-export', + resolvedId: getResolvedSource(staticDependencies, node.source.value), + }); +} + +function collectStarExports( + ast: Program, + staticDependencies: StaticModuleDependency[], +): StarExport[] { + return ast.body.flatMap((node) => { + if ( + node.type !== 'ExportAllDeclaration' || + getExportAllExportedName(node) || + !isStringLiteral(node.source) + ) { + return []; + } + + return [{ resolvedId: getResolvedSource(staticDependencies, node.source.value) }]; + }); +} + +function collectTopLevelBindings( + ast: Program, + scopeAnalysis: ModuleScopeAnalysis, +): Map { + const bindings = new Map(); + + for (const node of ast.body) { + collectTopLevelNodeBindings(node, scopeAnalysis, bindings); + } + + markReassignedTopLevelBindings(ast, scopeAnalysis, bindings); + return bindings; +} + +function collectTopLevelNodeBindings( + node: ModuleDeclaration | Program['body'][number], + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, +): void { + if (node.type === 'VariableDeclaration') { + collectVariableDeclarationBindings(node, scopeAnalysis, bindings); + return; + } + + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + collectVariableDeclarationBindings(node.declaration, scopeAnalysis, bindings); + return; + } + + const declaration = + node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration' + ? node.declaration + : node; + if ( + declaration && + (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && + declaration.id + ) { + const [variable] = scopeAnalysis.scopeManager.getDeclaredVariables(declaration); + if (variable) { + bindings.set(variable, { + kind: 'unsupported', + reason: `${declaration.type} binding`, + }); + } + } +} + +function collectVariableDeclarationBindings( + declaration: VariableDeclaration, + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, +): void { + for (const declarator of declaration.declarations) { + const variables = scopeAnalysis.scopeManager.getDeclaredVariables(declarator); + if (declarator.id.type !== 'Identifier') { + for (const variable of variables) { + bindings.set(variable, { kind: 'unsupported', reason: 'binding pattern' }); + } + continue; + } + + const [variable] = variables; + if (!variable) { + continue; + } + if (declaration.kind === 'const') { + bindings.set(variable, { kind: 'const', expression: declarator.init ?? null }); + } else { + bindings.set(variable, { + kind: 'mutable', + declarationKind: declaration.kind, + }); + } + } +} + +function markReassignedTopLevelBindings( + ast: Program, + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, +): void { + walkAst( + ast, + { scopeAnalysis, bindings }, + { + AssignmentExpression(node, { state }) { + markAssignedPattern(node.left, state.scopeAnalysis, state.bindings); + }, + UpdateExpression(node, { state }) { + markAssignedPattern(node.argument, state.scopeAnalysis, state.bindings); + }, + UnaryExpression(node, { state }) { + if (node.operator === 'delete') { + markAssignedPattern(node.argument, state.scopeAnalysis, state.bindings); + } + }, + ForInStatement(node, { state }) { + markForIterationTarget(node.left, state.scopeAnalysis, state.bindings); + }, + ForOfStatement(node, { state }) { + markForIterationTarget(node.left, state.scopeAnalysis, state.bindings); + }, + }, + ); +} + +function markForIterationTarget( + left: Pattern | VariableDeclaration, + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, +): void { + if (left.type !== 'VariableDeclaration') { + markAssignedPattern(left, scopeAnalysis, bindings); + } +} + +function markAssignedPattern( + pattern: Pattern | Expression, + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, +): void { + if (pattern.type === 'Identifier') { + markAssignedVariable(pattern, scopeAnalysis, bindings, 'reassigned binding'); + return; + } + + if (pattern.type === 'MemberExpression') { + const root = getMemberExpressionRoot(pattern); + if (root) { + markAssignedVariable(root, scopeAnalysis, bindings, 'mutated object binding'); + } + return; + } + + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + markAssignedPattern( + property.type === 'RestElement' ? property.argument : property.value, + scopeAnalysis, + bindings, + ); + } + return; + } + + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + if (element) { + markAssignedPattern(element, scopeAnalysis, bindings); + } + } + return; + } + + if (pattern.type === 'RestElement') { + markAssignedPattern(pattern.argument, scopeAnalysis, bindings); + return; + } + + if (pattern.type === 'AssignmentPattern') { + markAssignedPattern(pattern.left, scopeAnalysis, bindings); + } +} + +function markAssignedVariable( + identifier: Identifier, + scopeAnalysis: ModuleScopeAnalysis, + bindings: Map, + reason: string, +): void { + const variable = resolveIdentifier(identifier, scopeAnalysis); + if (!variable || isImportVariable(variable) || !bindings.has(variable)) { + return; + } + + const binding = bindings.get(variable); + if (binding?.kind === 'mutable') { + return; + } + bindings.set(variable, { kind: 'unsupported', reason }); +} + +function getMemberExpressionRoot(node: Expression | Super): Identifier | undefined { + if (node.type === 'Identifier') { + return node; + } + if (node.type === 'MemberExpression') { + return getMemberExpressionRoot(node.object); + } + return undefined; +} + +function getModuleExportName(node: ModuleExportName): string { + if (node.type === 'Identifier') { + return node.name; + } + return String(node.value); +} + +function getExportAllExportedName(node: ExportAllDeclaration): string | undefined { + const exported = (node as ExportAllDeclaration & { exported?: ModuleExportName | null }) + .exported; + return exported ? getModuleExportName(exported) : undefined; +} + +function getResolvedSource(staticDependencies: StaticModuleDependency[], source: string): string { + return ( + staticDependencies.find((dependency) => dependency.source === source)?.resolvedId ?? source + ); +} + /** * Finds dependency forms that cannot be represented by the static dependency * IDs supplied by the backend build collector. diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts index 1e941baea..bd04b3580 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts @@ -5,6 +5,7 @@ import { parseAst } from 'rollup/parseAst'; import type { ModuleDependency, ParsedModuleRecord, StaticModuleDependency } from './module-graph'; +import { analyzeModuleScope } from './module-scope'; import { ensureProgram } from './type-guards'; import { walkModuleGraph } from './walk-module-graph'; @@ -15,11 +16,18 @@ function createRecord( staticDependencies: string[] = [], unsupportedDependencies: ModuleDependency[] = [], ): ParsedModuleRecord { + const ast = ensureProgram(parseAst('export const value = true;'), id); + return { id, - ast: ensureProgram(parseAst('export const value = true;'), id), + ast, + scopeAnalysis: analyzeModuleScope(ast), staticDependencies: staticDependencies.map(toStaticDependency), unsupportedDependencies, + importsByVariable: new Map(), + exportsByName: new Map(), + starExports: [], + topLevelBindingsByVariable: new Map(), }; }