From d681de368e51a5dc5e47313ea6cbfe8b29a9127b Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 8 May 2026 11:43:20 -0400 Subject: [PATCH] Add internal AST traversal helper --- .../src/backend/ast-parsing/walk-ast.test.ts | 236 ++++++++++++++++++ .../apps/src/backend/ast-parsing/walk-ast.ts | 112 +++++++++ 2 files changed, 348 insertions(+) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts new file mode 100644 index 000000000..a4d0e8ba9 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts @@ -0,0 +1,236 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { BaseNode, Node, Program } from 'estree'; + +import { walkAst } from './walk-ast'; + +describe('Backend AST Parsing - walkAst', () => { + test('Should visit universal and specialized visitors for ESTree-shaped nodes', () => { + const ast = buildEstreeFixture(); + const state = { + visitedTypes: [] as string[], + callArgumentCounts: [] as number[], + functionNames: [] as string[], + }; + + walkAst(ast as Node, state, { + _(node, { state: visitorState }) { + visitorState.visitedTypes.push(node.type); + }, + CallExpression(node, { state: visitorState }) { + visitorState.callArgumentCounts.push(node.arguments.length); + }, + FunctionDeclaration(node, { state: visitorState }) { + if (node.id) { + visitorState.functionNames.push(node.id.name); + } + }, + }); + + expect(state.functionNames).toEqual(['run']); + expect(state.callArgumentCounts).toEqual([1]); + expect(state.visitedTypes).toEqual( + expect.arrayContaining([ + 'Program', + 'ImportDeclaration', + 'ImportSpecifier', + 'FunctionDeclaration', + 'ObjectPattern', + 'Property', + 'Identifier', + 'BlockStatement', + 'ReturnStatement', + 'CallExpression', + 'MemberExpression', + 'ObjectExpression', + 'Literal', + ]), + ); + }); + + test('Should traverse arrays and nested node objects in deterministic pre-order', () => { + const ast = { + type: 'Root', + first: { type: 'First' }, + children: [ + { type: 'ChildA' }, + null, + { not: 'a node' }, + { type: 'ChildB', nested: { type: 'Grandchild' } }, + ], + } as unknown as TestNode; + const visited: string[] = []; + + walkAst( + ast, + { visited }, + { + _(node, { state }) { + state.visited.push(node.type); + }, + }, + ); + + expect(visited).toEqual(['Root', 'First', 'ChildA', 'ChildB', 'Grandchild']); + }); + + test('Should ignore the type property and non-node objects', () => { + const ast = { + type: 'Root', + metadata: { + type: 123, + nestedNodeThatShouldNotBeVisited: { type: 'IgnoredNestedNode' }, + }, + source: { + value: '@datadog/action-catalog/http/http', + }, + child: { type: 'VisitedChild' }, + } as unknown as TestNode; + const visited: string[] = []; + + walkAst( + ast, + { visited }, + { + _(node, { state }) { + state.visited.push(node.type); + }, + }, + ); + + expect(visited).toEqual(['Root', 'VisitedChild']); + }); + + test('Should share one state object across all visitors', () => { + const ast = { + type: 'Root', + children: [{ type: 'ChildA' }, { type: 'ChildB' }], + } as unknown as TestNode; + const state = { count: 0 }; + + walkAst(ast, state, { + _(_, { state: visitorState }) { + visitorState.count += 1; + }, + }); + + expect(state.count).toBe(3); + }); +}); + +type TestNode = BaseNode & { + first?: TestNode; + nested?: TestNode; + child?: TestNode; + children?: Array; + metadata?: object; + source?: object; +}; + +function buildEstreeFixture(): Program { + return { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + source: { + type: 'Literal', + value: '@datadog/action-catalog/http/http', + }, + attributes: [], + specifiers: [ + { + type: 'ImportSpecifier', + imported: { + type: 'Identifier', + name: 'request', + }, + local: { + type: 'Identifier', + name: 'request', + }, + }, + ], + }, + { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'run', + }, + params: [ + { + type: 'ObjectPattern', + properties: [ + { + type: 'Property', + kind: 'init', + method: false, + shorthand: false, + computed: false, + key: { + type: 'Identifier', + name: 'client', + }, + value: { + type: 'Identifier', + name: 'client', + }, + }, + ], + }, + ], + body: { + type: 'BlockStatement', + body: [ + { + type: 'ReturnStatement', + argument: { + type: 'CallExpression', + optional: false, + callee: { + type: 'MemberExpression', + optional: false, + computed: false, + object: { + type: 'Identifier', + name: 'http', + }, + property: { + type: 'Identifier', + name: 'request', + }, + }, + arguments: [ + { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + kind: 'init', + method: false, + shorthand: false, + computed: false, + key: { + type: 'Identifier', + name: 'connectionId', + }, + value: { + type: 'Literal', + value: 'conn', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + ], + }; +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts new file mode 100644 index 000000000..1d8c439af --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts @@ -0,0 +1,112 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { BaseNode, Node as EstreeNode } from 'estree'; + +/** + * Object passed to every visitor. + * + * `state` is shared for the whole walk. This helper intentionally does not + * thread child-specific state because current AST analysis only needs one + * shared collection/lookup object. + */ +export interface WalkAstContext { + state: State; +} + +/** + * Function called when the walker reaches a matching node. + * + * `Node` is the broad tree type passed to `walkAst`, while `CurrentNode` is the + * narrowed node type for a specialized visitor such as `CallExpression`. + */ +export type WalkAstVisitor = ( + node: CurrentNode, + context: WalkAstContext, +) => void; + +/** + * Visitor map for concrete ESTree node types. + * + * The runtime walker is generic and does not special-case ESTree types. This + * mapped type only exists to make visitor callbacks typed when users write + * keys like `CallExpression` or `VariableDeclarator`. + */ +type SpecializedWalkAstVisitors = { + [Type in EstreeNode['type']]?: WalkAstVisitor>; +}; + +/** + * Visitors accepted by `walkAst`. + * + * `_` is a universal visitor that runs for every node. Keys matching concrete + * ESTree node types run only for nodes with that `type`. + */ +export type WalkAstVisitors = SpecializedWalkAstVisitors< + Node, + State +> & { + _?: WalkAstVisitor; +}; + +/** + * Walks an ESTree-shaped AST without maintaining a hardcoded visitor-key table. + * + * Any object with a string `type` property is treated as a child node. Primitive + * values, arrays entries without `type`, and metadata objects such as `loc` are + * ignored. + */ +export function walkAst( + node: Node, + state: State, + visitors: WalkAstVisitors, +): void { + const context: WalkAstContext = { state }; + + const visit = (currentNode: Node): void => { + visitors._?.(currentNode, context); + getSpecializedVisitor(currentNode, visitors)?.(currentNode, context); + + for (const key of Object.keys(currentNode)) { + if (key === 'type') { + continue; + } + + visitChildren((currentNode as Record)[key]); + } + }; + + const visitChildren = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item)) { + visit(item as Node); + } + } + return; + } + + if (isAstNode(value)) { + visit(value as Node); + } + }; + + visit(node); +} + +function isAstNode(value: unknown): value is BaseNode { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof value.type === 'string' + ); +} + +function getSpecializedVisitor( + node: Node, + visitors: WalkAstVisitors, +): WalkAstVisitor | undefined { + return (visitors as Record | undefined>)[node.type]; +}