traverse() {
		// Because the AST doesn't mutate, we can cache the steps
		if (this.#steps) {
			return this.#steps;
		}

		const steps = (this.#steps = []);

		/*
		 * This logic works for any AST, not just ESTree. Because ESLint has allowed
		 * custom parsers to return any AST, we need to ensure that the traversal
		 * logic works for any AST.
		 */
		let analyzer = {
			enterNode(node) {
				steps.push(
					new VisitNodeStep({
						target: node,
						phase: 1,
						args: [node, node.parent],
					}),
				);
			},
			leaveNode(node) {
				steps.push(
					new VisitNodeStep({
						target: node,
						phase: 2,
						args: [node, node.parent],
					}),
				);
			},
			emit(eventName, args) {
				steps.push(
					new CallMethodStep({
						target: eventName,
						args,
					}),
				);
			},
		};

		/*
		 * We do code path analysis for ESTree only. Code path analysis is not
		 * necessary for other ASTs, and it's also not possible to do for other
		 * ASTs because the necessary information is not available.
		 *
		 * Generally speaking, we can tell that the AST is an ESTree if it has a
		 * Program node at the top level. This is not a perfect heuristic, but it
		 * is good enough for now.
		 */
		if (this.isESTree) {
			analyzer = new CodePathAnalyzer(analyzer);
		}

		/*
		 * The actual AST traversal is done by the `Traverser` class. This class
		 * is responsible for walking the AST and calling the appropriate methods
		 * on the `analyzer` object, which is appropriate for the given AST.
		 */
		Traverser.traverse(this.ast, {
			enter(node, parent) {
				// save the parent node on a property for backwards compatibility
				node.parent = parent;

				analyzer.enterNode(node);
			},
			leave(node) {
				analyzer.leaveNode(node);
			},
			visitorKeys: this.visitorKeys,
		});

		return steps;
	}
Traverse the source code and return the steps that were taken.
@returns {Array<TraversalStep>} The steps that were taken while traversing the source code.