PythonとTypeScriptにおける抽象構文木(AST):カスタムLinterとコード変換ツールの構築

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と必要性

午前2時15分、アラートが鳴り響き、本番環境のビルドがクラッシュしました。エスケープ処理されていない古いSQL呼び出しが、CIチェックをすり抜けてしまったのです。その根本原因は?開発者がクエリをインラインコメント付きで4行にわたって記述したため、正規表現検索を完全にすり抜けてしまったことでした。プレーンな文字列マッチングの限界が露呈した瞬間です。

ソースコードは単なるテキストデータではありません。入れ子状の階層構造を持っています。コンパイラやランタイムは、ソースファイルを「抽象構文木(Abstract Syntax Tree: AST)」へと分解します。AST上では、すべての変数宣言、二項演算、関数呼び出し、条件分岐ブロックが、メモリ上の型付きノードとして表現されます。

正規表現ベースのLinterは、ネストされた式、複数行にわたる引数、コードコメントの処理で行き詰まります。ASTレベルの解析に切り替えることで、文字列パターンの推測ではなく、正確なセマンティック(意味論的)ノードを対象にできます。これにより、構文に依存するバグを検出し、厳格なセキュリティルールを適用し、数十万行に及ぶコードベース全体で安全な自動移行(codemod)を実行できるようになります。

環境構築とインストール

PythonとJavaScriptはいずれも、専用のパース用ツールを提供しています。Pythonには標準ライブラリにネイティブのASTモジュールが含まれており、TypeScriptではBabelがトラバースおよびコード生成の標準的なツールセットを提供します。

Python環境のセットアップ

Pythonには組み込みのastモジュールが含まれています。コード生成や構文木の検証を行うために、libcstとastorをインストールします:

# 専用の仮想環境をセットアップ
python3 -m venv .venv
source .venv/bin/activate

# AST操作用のヘルパーライブラリをインストール
pip install astor libcst

TypeScriptおよびNode.js環境のセットアップ

Node.js環境では、BabelのパーサーパッケージをTypeScriptの型定義およびts-nodeとともにインストールします:

mkdir ast-transformer && cd ast-transformer
npm init -y

# Babelパーサー、トラバーサー、ジェネレーター、AST型定義をインストール
npm install @babel/parser @babel/traverse @babel/generator @babel/types
npm install --save-dev typescript @types/node @types/babel__traverse @types/babel__generator ts-node

設定と実装

ここでは2つの実践的なツールを構築します。禁止された関数呼び出しをインターセプトするPythonセキュリティLinterと、非推奨のAPIを自動的に置き換えるTypeScriptのcodemodです。

1. PythonでカスタムAST Linterを構築する

エンジニアリングチームがeval()とレガシーなraw_sql_query()ラッパーの使用を禁止したと想定します。ast.NodeVisitorを継承することで、対象ファイル全体のすべての関数呼び出しノードを検査できます:

import ast
import sys

class SecurityLinter(ast.NodeVisitor):
    def __init__(self):
        self.violations = []

    def visit_Call(self, node):
        func_name = None
        if isinstance(node.func, ast.Name):
            func_name = node.func.id
        elif isinstance(node.func, ast.Attribute):
            func_name = node.func.attr

        # 禁止された関数を検出
        if func_name in ["eval", "raw_sql_query"]:
            self.violations.append({
                "func": func_name,
                "line": node.lineno,
                "col": node.col_offset
            })
        
        # 子ノードの走査を継続
        self.generic_visit(node)

def lint_code(source_code: str):
    tree = ast.parse(source_code)
    linter = SecurityLinter()
    linter.visit(tree)
    return linter.violations

if __name__ == "__main__":
    code_sample = """
# 正規表現チェックをすり抜けやすい複数行クエリ
result = raw_sql_query(
    "SELECT * FROM users WHERE id = %s",
    user_input
)
eval("2 + 2")
    """
    issues = lint_code(code_sample)
    for issue in issues:
        print(f"[セキュリティ違反] 禁止された呼び出し '{issue['func']}' (行 {issue['line']}:{issue['col']})")

2. TypeScriptで自動ASTコード変換ツールを構築する

大規模なリポジトリで手動リファクタリングを行うと、デグレード(リグレッション)が発生しやすくなります。Babelのパーサーとトラバーサーを使用することで、TypeScriptファイルを解析し、メモリ上で非推奨のASTノードを置換して、綺麗にフォーマットされたコードを再生成できます。

import * as parser from "@babel/parser";
import _traverse from "@babel/traverse";
import _generate from "@babel/generator";
import * as t from "@babel/types";

// CommonJS/ESMの相互運用性を処理
const traverse = (_traverse as any).default || _traverse;
const generate = (_generate as any).default || _generate;

const sourceCode = `
// 対象: oldLogger.warn を appLogger.warning に移行
function processPayment(account: string) {
  oldLogger.warn("支払い処理開始: " + account);
  console.log("デバッグ: 処理完了");
}
`;

// ステップ 1: ソースコードをASTにパース
const ast = parser.parse(sourceCode, {
  sourceType: "module",
  plugins: ["typescript"],
});

// ステップ 2: ビジターパターンでノードを変換
traverse(ast, {
  CallExpression(path: any) {
    const callee = path.node.callee;

    // oldLogger.warn(...) を appLogger.warning(...) に置換
    if (
      t.isMemberExpression(callee) &&
      t.isIdentifier(callee.object, { name: "oldLogger" }) &&
      t.isIdentifier(callee.property, { name: "warn" })
    ) {
      path.get("callee").replaceWith(
        t.memberExpression(
          t.identifier("appLogger"),
          t.identifier("warning")
        )
      );
    }

    // console.log 文を完全に削除
    if (
      t.isMemberExpression(callee) &&
      t.isIdentifier(callee.object, { name: "console" }) &&
      t.isIdentifier(callee.property, { name: "log" })
    ) {
      path.remove();
    }
  },
});

// ステップ 3: 更新されたASTをソースコードにシリアライズ
const output = generate(ast, {}, sourceCode);
console.log(output.code);

あるフレームワーク移行プロジェクトでは、このように構成されたcodemodによって、420個のTypeScriptファイル(約85,000行のコード)を3.8秒で更新しました。構文トークンの破損や複数行呼び出しの見落としを一切発生させず、人為的ミスを完全に排除することに成功しました。

検証とモニタリング

自動化されたAST変換はソースコードを直接変更します。意図しないロジックの削除や制御フローの破損を防ぐために、厳格なガードレールが必要です。

1. パイプライン統合と自動チェック

作成したカスタムAST Linterをpre-commitスクリプトやCIパイプラインに組み込みます。テストスイートが実行される前に、ルール違反を早期に検知して失敗させる(Fail-fast)ようにします:

# AST違反が検出された場合はビルドをブロック
python -m linter_runner src/ || exit 1

2. 冪等性(べきとうせい)の検証

codemodは冪等でなければなりません。変換スクリプトを2回連続で実行した際、2回目の実行ではファイルの差分がゼロになる必要があります:

# 1回目の実行: 変換を適用
npx ts-node transform.ts src/

# 2回目の実行: git statusに変更がないことを確認
npx ts-node transform.ts src/
git diff --exit-code

3. 大規模リポジトリにおけるパフォーマンス最適化

数百万行のコードを構文木にパースすると、CPUとメモリの消費量が増加します。以下のベンチマークを念頭に置いてください:

  • メモリオーバーヘッド: パースされたASTは、通常、元のソースファイルの8〜12倍のメモリを必要とします。50MB規模のコードベースの場合、すべての構文木を単一の配列に読み込むのは避けてください。ワーカープール(Node.jsのworker_threadsやPythonのmultiprocessing.Poolなど)を使用して、ファイルをバッチ単位で処理します。
  • 早期ノードフィルタリング: トップレベルのモジュール文が条件に一致しない場合は、ネストされた関数本体の走査をスキップします。Babelでpath.skip()を呼び出したり、Pythonで早期リターンを行うことで、トラバースのオーバーヘッドを最大60%削減できます。

静的解析やリファクタリングを脆弱な正規表現パターンからASTトラバースへと移行することで、チームは一貫したコード標準を維持し、極めて高い精度で大規模な移行を実行できるようになります。

Share: