はじめに
前回記事では、express-validator を利用したバリデーション定義から、
API ドキュメントを自動生成するプロトタイプを題材に、
- 単純な文字列解析では実務コードを扱いきれないこと
- 「構文」と「意味」を分けて扱う必要があること
-
ast-grepとts-morphを役割分担させる構成
について整理しました。
今回はその続きとして、
実際にどのような構成でツールを実装したのかを、コードベースで掘り下げていきます。
シリーズ記事
ただし、本記事で扱いたいのは単なる AST 操作のテクニック集ではありません。
- なぜこの責務分離にしたのか
- どの問題をどのレイヤで解決しているのか
- なぜ一度 IR(中間表現)へ正規化しているのか
といった、「設計上の意図」を中心に見ていきます。
今回のディレクトリ構成
今回のプロトタイプでは、ツール全体を以下のような構成に整理しました。
tools/
entry/
index.ts # オーケストレーションのみ
scan/ # Step 1: 静的スキャン(sg + regex)
routeScanner.ts # sg でルート・エンドポイントを収集
validatorExtractor.ts # バリデータ名・importパスを抽出
analyze/ # Step 2: 意味解釈(ts-morph)
astWalker.ts # AST走査(walk / resolveArgText)
ruleInterpreter.ts # calls → rules(interpretValidator)
validatorResolver.ts # resolveValidator / resolveCall / mergeValidators
build/ # Step 3: IR構築
endpointBuilder.ts # エンドポイントオブジェクトの組み立て
output/ # Step 4: 出力
htmlGenerator.ts # HTML生成
utils/
text.ts # stripQuotes
shared/
types.ts # 型定義
一見すると単純なレイヤ分割ですが、
ポイントとなるのが、「どの責務をどこに閉じ込めるか」をかなり強く意識している点です。
例えば、
-
scanは「どこに対象コードが存在するか」を見つける -
analyzeは AST を辿って「何を意味しているか」を解釈する -
buildは解釈結果を中間表現(IR)へ整理する -
outputは IR を表示へ変換する
というように、各層が扱う関心を意図的に分離しています。
このうち、本記事では主に以下を扱います。
- ツール全体のパイプライン構成
-
scan層の責務分離 -
ast-grepを利用した構造検索 - 型を境界にしたレイヤ設計
一方で、
- AST walk
- 条件分岐の静的解決
- validator の意味解釈
- 関数 validator の展開
といった analyze 層の中核部分については、次回詳しく扱います。
今回の記事ではまず、
- 「どこに解析対象が存在するか」
- 「どこまでを scan 層で扱うのか」
- 「なぜ analyze 層を分離しているのか」
という、ツール全体の土台となる設計を整理していきます。
1. 全体構成とパイプライン
1-1. パイプライン全体像
まずは、ツール全体の流れを整理します。
今回のツールでは、.routes.ts に定義された Express ルートを起点に、
段階的に情報を解釈しながら HTML へ変換しています。
.routes.ts
↓
scan
- routeScanner
AstGrepMatch[]
↓
build
- endpointBuilder
├── validatorExtractor(scan)
└── validatorResolver(analyze)
- astWalker
- ruleInterpreter
Endpoint[]
↓
output
- htmlGenerator
validation.html
重要なのは、この構造が「探索 → 解釈 → 正規化 → 表示」という段階に分かれている点です。
例えば scan 層では、
- どのルートが存在するか
- どの middleware が指定されているか
といった「構文上の情報」だけを扱います。
一方 analyze 層では、
body('email').isEmail()required ? xxx : yyy- 関数化された validator
などを辿りながら、「この validator が実際に何を意味しているのか」を解釈していきます。
つまり、
- scan は「対象の発見」
- analyze は「意味の解釈」
という役割分担になっています。
さらに、その解釈結果を build 層で一度 IR(中間表現)へ整理し、最後に output 層で HTML へ変換しています。
この「一度 IR を挟む」という構造が、後述する疎結合化のポイントになります。
1-2. 型がパイプラインの境界になる
今回の構成では、各層を単にフォルダで分けているわけではありません。
shared/types.ts に定義した型を境界として、レイヤ間を接続しています。
例えば、analyze 層では以下のような型を扱っています。
export type CallInfo = {
name: string;
args: string[];
};
export type ValidatorField = {
field: string | null;
rules: Rule[];
};
export type Endpoint = {
method: string;
path: string;
validators: ValidatorField[] | 'No validation rules';
};
データは内部で、概ね以下のように変換されていきます。
AstGrepMatch
↓
(analyze)
↓
ValidatorField
↓
Endpoint
ここでポイントとなるのが、後段のレイヤが前段の内部実装を知らなくてよい、という点です。
例えば output 層は、
- AST がどう走査されたか
- import をどう解決したか
- 条件分岐をどう評価したか
を一切知りません。
必要なのは、「最終的に整理済みの Endpoint[] が渡されること」だけです。
逆に analyze 層も、「最終的に HTML をどう描画するか」を知る必要はありません。
つまり、各層が「自分の責務だけ」を扱える構造になっています。
これは将来的に、
- HTML ではなく Markdown 出力へ変更する
- OpenAPI 形式へ変換する
- Express 以外へ対応する
といった拡張を行う際にも有効です。
解析ロジックと表示ロジックが分離されているため、
出力形式を変更しても analyze 層にはほとんど影響がありません。
この「IR を境界に責務を切り分ける」という考え方は、今回のツール全体でかなり重要なポイントになっています。
2. Step1: scan
scan 層の責務は、「どこに解析対象が存在するか」を収集することです。
ここではまだ、
- validator が何を意味するのか
- 条件分岐がどう評価されるのか
- メソッドチェーンがどう解釈されるのか
といった“意味”は扱いません。
あくまで、
- どのルートが存在するか
- どの middleware が指定されているか
- validator がどのファイルから import されているか
という、「探索」に責務を限定しています。
2-1. routeScanner.ts
まずは、Express のルート定義そのものを収集する routeScanner.ts です。
このファイルの責務は主に以下の2つです。
- ルート定義を収集する
- router の basePath を解決する
■ routes ファイルの収集
まず、src/routes 配下から .routes.ts を列挙しています。
const routesFiles = fs
.readdirSync(routesDir)
.filter((fileName) => fileName.endsWith('.routes.ts'))
.map((fileName) => path.join(routesDir, fileName));
ここでは AST を使わず、単純にファイルシステムから対象を列挙しています。
重要なのは、この段階ではまだ「中身を解釈しようとしていない」ことです。
scan 層はあくまで、「どこを解析対象にするか」を決定するフェーズとして割り切っています。
■ ast-grep による構造検索
ルート定義の抽出には ast-grep を利用しています。
const result = execSync(
`sg run --pattern '$ROUTER.$METHOD($PATH, $$$AGRS)' ${routesFile} --json`,
{ encoding: 'utf-8' },
);
ここで重要なのは、これは単なる文字列検索ではなく、AST ベースの「構造検索」であるという点です。
例えば以下のようなコードに対して、
router.post('/users', createUserValidator, handler);
$ROUTER.$METHOD($PATH, $$$AGRS) というパターンを使うことで、
- ROUTER →
router - METHOD →
post - PATH →
'/users' - AGRS → middleware 群
を構文単位で取得できます。
ここでのポイントは、「完全な AST 解析」をしようとしていないことです。
scan 層では、
- 「どこにルート定義があるか」
- 「どんな形で書かれているか」
だけ取得できれば十分です。
そのため、型情報や関数展開が必要になる ts-morph はまだ使いません。
■ basePath の解決
次に、app.use('/api', router) のような mount 情報を解析し、router ごとの basePath を取得します。
ここでも同じく ast-grep を利用しています。
const mountResult = execSync(
`sg run --pattern '$API.use($BASE_PATH, $ROUTER)' ${routesIndex} --json`,
{ encoding: 'utf-8' },
);
取得した結果を、最終的に以下のような map へ変換しています。
const routerBasePathMap = Object.fromEntries(
mountMatches.map((match) => [
match.metaVariables.single.ROUTER.text,
match.metaVariables.single.BASE_PATH.text,
]),
);
これによって、
api.use('/users', usersRouter);
のような定義から、
{
usersRouter: '/users'
}
という対応表を作成できます。
この情報は後続の build 層で、最終的な endpoint path を構築する際に利用されます。
■ routeScanner.ts のポイント
このファイルで重要なのは、「探索」と「意味解釈」を分離している点です。
ここでは、
- ルート定義の場所
- HTTP メソッド
- middleware 群
- basePath
は取得していますが、
- validator が何を意味するか
- middleware が何をしているか
は一切解釈していません。
つまり routeScanner.ts は「解析対象を見つけること」だけに責務を限定しています。
2-2. validatorExtractor.ts
次に、ルート定義から validator middleware を抽出する validatorExtractor.ts です。
このファイルの責務は以下です。
- middleware 一覧の取得
- import 元の特定
- validator のみ抽出
export function extractValidators(args: AstGrepMetaVar[], filePath: string) {
const middlewares = parseMiddleware(args); // middleware 一覧の取得
const code = fs.readFileSync(filePath, 'utf-8');
const importMap = parseImports(code); // import 元の特定
const validatorMap = filterValidatorMap(middlewares, importMap); // validator のみ抽出
return validatorMap;
}
■ middleware の抽出
まず、ast-grep で取得した $$$AGRS から middleware 群を取り出します。
function parseMiddleware(args: AstGrepMetaVar[]) {
const middleware = args.map((arg) => arg.text).filter((text) => text !== ',');
return middleware;
}
ここで取得しているのは、あくまで middleware 名の文字列です。
例えば、
router.post('/users', auth, createUserValidator, handler);
なら、
['auth', 'createUserValidator', 'handler']
のような配列になります。
■ import 文の解析
次に、それらがどのファイルから import されているかを解析します。
const importRegex =
/import\s+{([^}]+)}\s+from\s+['"]([^'"]+)['"]/g;
ここは AST ではなく、正規表現で実装しています。
理由は単純で、今回は import 形式をある程度限定したプロトタイプだからです。
例えば、
import { createUserValidator } from '../validators/user';
のような形だけ扱えれば十分だったため、 ここでは「完全な import 解決」は行っていません。
このあたりは、実用化を考えるなら改善余地がある部分です。
■ validator のみ抽出
最後に、middleware の中から validator のみを抽出します。
function filterValidatorMap(
middlewares: string[],
importMap: Record<string, string>,
) {
const validatorMap: Record<string, string> = {};
middlewares.forEach((name) => {
const importPath = importMap[name];
if (!importPath) return;
if (!importPath.includes('validators')) return;
validatorMap[name] = importPath;
});
return validatorMap;
}
ここでは、「validators ディレクトリ配下から import されているものだけを validator とみなす」というルールで、
かなり割り切った実装をしています。
今回はscan 層では、「候補を絞る」だけに留めています。
validator の中身を本当に解釈するのは、次の analyze 層です。
おわりに
今回は、API バリデーションドキュメント生成ツールの全体構成と、scan 層の実装について整理しました。
特に今回重要だったのは、
- 「探索」と「意味解釈」を分離すること
- scan 層では“候補を集める”ことに責務を限定すること
- 型と IR を境界にして、各レイヤを疎結合に保つこと
の3点です。
scan 層では、AST 全体を重く解析するのではなく、
- どこにルートがあるか
- どの middleware が指定されているか
- validator がどこから import されているか
だけを高速に収集しています。
一方で、実際の validator の意味解釈は、次の analyze 層へ委譲しています。
実務コードでは、
- 関数化された validator
- 条件分岐
- 変数経由の message
- 型情報からの値解決
などが登場するため、ここから先は単純な構文解析だけでは対応できません。
次回は、今回収集した validator を ts-morph でどのように解析し、
- メソッドチェーンをどう walk するのか
- 条件分岐をどう静的に解決するのか
- validator の“意味”をどう Rule へ正規化するのか
といった、analyze 層の実装を掘り下げていく予定です。
以上です。最後まで閲覧いただきありがとうございます。