CLIプロジェクトジェネレーターにおけるオプショナル機能の扱い方
概要
自分が開発しているドキュメンテーションフレームワークzudo-docには、create-zudo-docというCLIジェネレーターがある。ユーザーがプロジェクトをセットアップするとき、AI Assistant、検索、i18n、バージョニングなど14個のオプショナル機能を選択できる。
以前の実装は「全部コピーしてから不要なものを削除する(copy-then-strip)」アプローチだったのだが、機能を無効にしたときにデッドコードが残るという問題があった。OSSのジェネレーターツールがどう対処しているか調査して、最終的に「ベーステンプレート + 機能別の合成」方式に移行した。そのまとめ。
問題: copy-then-stripでデッドコードが残る
create-zudo-docのscaffold処理は以下の流れで動いていた。
- zudo-docのソースを丸ごとターゲットディレクトリにコピー
- ユーザーが無効にした機能に関連するファイルを
strip.tsで削除 settings.tsのフラグを書き換え
この「全部コピーしてから削る」方式は直感的でわかりやすい。が、根本的な問題がある。
AI Assistantを無効にしたとき、メインのファイル(APIルート、コンポーネント)は削除される。しかし以下が残っていた。
src/types/ai-chat.ts— 型定義(削除されたコンポーネントからのみ参照)src/utils/render-markdown.ts— ユーティリティ(削除されたコンポーネントからのみ使用)src/mocks/— モック基盤全体(AIチャットのモックのみ)src/components/header.astroのAIチャットトリガーボタンsrc/styles/global.cssの.ai-chat-mdCSSルール + チャット用カラートークンsrc/config/color-scheme-utils.tsのチャット用セマンティックカラー定義src/config/color-schemes.tsのチャット用インターフェースフィールド
修正前のstrip.tsはこうなっていた。
// Remove AI chat API route, components, and MSW mock
await removeIfExists(targetDir, "src/pages/api/ai-chat.ts");
await removeIfExists(targetDir, "src/components/ai-chat-modal.tsx");
await removeIfExists(targetDir, "src/components/mock-init.tsx");
await patchFile(path.join(targetDir, "src/layouts/doc-layout.astro"), [
[/import AiChatModal from.*\n/g, ""],
[/import MockInit from.*\n/g, ""],
[/\s*\{settings\.aiAssistant && <AiChatModal.*\/>\}\s*\n?/g, "\n"],
[
/\s*\{import\.meta\.env\.DEV && import\.meta\.env\.PUBLIC_ENABLE_MOCKS.*<MockInit.*\/>\}\s*\n?/g,
"\n",
],
]);
修正後は6ファイルの削除と3ファイルへのパッチが追加された。
// Remove AI chat API route, components, types, utils, and MSW mocks
await removeIfExists(targetDir, "src/pages/api/ai-chat.ts");
await removeIfExists(targetDir, "src/components/ai-chat-modal.tsx");
await removeIfExists(targetDir, "src/components/mock-init.tsx");
await removeIfExists(targetDir, "src/types/ai-chat.ts");
await removeIfExists(targetDir, "src/utils/render-markdown.ts");
await removeIfExists(targetDir, "src/mocks");
// + patches for header.astro, global.css,
// color-scheme-utils.ts, color-schemes.ts
これは1つの機能だけの問題ではない。機能が増えるたびにstrip.tsの正規表現パターンが増え、見落としが起きやすくなる。strip処理を書く人間(またはClaude Code)が、その機能がどのファイルに影響しているかを完全に把握していなければ、デッドコードが残る。
cross-cutting concern問題
ジェネレーターにおける最大の難所は、機能が自己完結しないことにある。
たとえばAI Assistant機能は、専用のコンポーネントやAPIルートだけでなく、6つ以上の共有ファイルに影響する。
header.astroにトリガーボタンを追加doc-layout.astroにインポートとコンポーネントを追加global.cssにCSSルールを追加color-scheme-utils.tsにセマンティックカラー定義を追加color-schemes.tsにインターフェースフィールドを追加astro.config.tsにNode.jsアダプターを追加
これらの共有ファイルには、他の機能のコードも混在している。header.astroにはAI Assistantだけでなく検索ボタン、テーマトグル、i18n切り替えなど4機能分のコードが入っている。global.cssには5機能分のCSSセクションがある。
この「複数の機能が1つの共有ファイルに注入を必要とする」構造が、strip処理の正規表現を複雑にする根本原因になっている。機能Aの正規表現が機能Bのコードを巻き込まないように注意しなければならないし、機能Cを追加したときに既存の正規表現が壊れないかも確認が必要になる。
「永遠にstrippingし続ける」のは根本的に良くないのでは、と考えてOSSのジェネレーターツールがどうしているか調査した。
OSSジェネレーターの6つのアーキテクチャパターン
調査した結果、OSSのCLIジェネレーターは大きく6つのアーキテクチャパターンに分類できた。
パターン1: Full Pre-built Templates(create-next-app方式)
create-next-appは、すべての組み合わせに対して完全なテンプレートディレクトリを用意するアプローチを取る。
templates/
default/ # JavaScript + App Router
default-tw/ # JavaScript + App Router + Tailwind
default-empty/ # JavaScript + App Router + Empty
default-tw-empty/ # JavaScript + App Router + Tailwind + Empty
app/ # TypeScript + App Router
app-tw/ # TypeScript + App Router + Tailwind
app-empty/ # TypeScript + App Router + Empty
app-tw-empty/ # TypeScript + App Router + Tailwind + Empty
# ... Pages Router variants も同様に
テンプレートエンジンは使わない。各ディレクトリがそのまま動く完全なプロジェクトになっている。ジェネレーターは選択された組み合わせに対応するディレクトリを丸ごとコピーするだけ。
利点は、テンプレートがそのまま有効なソースコードであること。エディタでの補完も効くし、テストも容易。テンプレート自体をCIでビルドして動作確認できる。
欠点は組み合わせ爆発。create-next-appは現状4つの軸(App Router/Pages Router、TypeScript/JavaScript、Tailwind有無、Empty有無)で約16テンプレートを持つ。新しい軸を1つ追加すると、テンプレート数が倍になる。5つ目の軸を追加したら32テンプレートになる。すべてのテンプレートを個別にメンテナンスし続けなければならない。
create-next-appはオプショナル機能が4つだからこの方式で成り立っている。14個のオプショナル機能があるzudo-docでは、理論上16,384通りの組み合わせになるので現実的ではない。
パターン2: Layered Template Overlay(create-vue方式)
create-vueは、ベーステンプレートの上に機能別のフラグメントをオーバーレイする方式を取る。調査した中では最もエレガントなアプローチだった。
template/
base/ # always copied first
package.json
src/main.js
src/App.vue
config/
jsx/ # --jsx
package.json # merged into base package.json
router/ # --router
package.json
src/router/index.js
pinia/ # --pinia
package.json
src/stores/counter.js
typescript/ # --typescript
package.json
tsconfig.json
env.d.ts
処理の流れはこうなる。
base/を丸ごとターゲットにコピー- 選択された機能のディレクトリを順番にオーバーレイ
package.jsonはディープマージ
renderTemplate()関数がpackage.jsonやVS Code設定を自動でディープマージする。
function renderTemplate(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file));
}
return;
}
const filename = path.basename(src);
// package.json is deep-merged
if (filename === "package.json" && fs.existsSync(dest)) {
const existing = JSON.parse(fs.readFileSync(dest, "utf-8"));
const newPkg = JSON.parse(fs.readFileSync(src, "utf-8"));
const merged = deepMerge(existing, newPkg);
merged.dependencies = sortDependencies(merged.dependencies);
merged.devDependencies = sortDependencies(merged.devDependencies);
fs.writeFileSync(dest, JSON.stringify(merged, null, 2) + "\n");
return;
}
fs.copyFileSync(src, dest);
}
機能が交差するファイル(複数の機能が影響する共有ファイル)については、プリビルドされたバリアントを使う。create-vueではmain.jsに4つのバリアント(router有無 x pinia有無)がある。それ以外のテンプレート処理ではEJSはほとんど使わず、使う場合もデータサイドカーファイル(.data.mjs)経由で最小限にとどめている。
利点は、機能ごとに独立したディレクトリを持つのでスケーラブルなこと。新機能を追加するときは新しいディレクトリを追加するだけで、既存のテンプレートを変更する必要がない。package.jsonのマージロジックもエレガント。
欠点は、機能が交差するファイルにはバリアントが必要なこと。交差する機能が増えるとバリアント数が増える。ただし、create-next-appのようにプロジェクト全体をバリアントにするのではなく、交差するファイルだけをバリアントにするので、スケーリングの問題ははるかに小さい。
パターン3: Installer Functions + Pre-built Variants(create-t3-app方式)
create-t3-appは、ベーステンプレートに対して機能別の「インストーラー関数」を実行する方式を取る。
template/
base/ # always copied
extras/
src/pages/
_app/
with-auth.tsx
with-auth-trpc.tsx
with-trpc.tsx
with-auth-tw.tsx
with-auth-trpc-tw.tsx
with-trpc-tw.tsx
with-tw.tsx
# ... 12+ variants
各インストーラーは必要なパッケージとファイルをコピーする関数。
export const trpcInstaller: Installer = ({ projectDir, packages }) => {
addPackageDependency({
projectDir,
dependencies: trpcDependencies,
devMode: false,
});
// Copy tRPC-specific files
// Select the right _app.tsx variant
};
_app.tsxのバリアント選択がif-elseチェーンになる。
const appFileRouter = _dependencies.includes("trpc")
? _dependencies.includes("auth")
? _dependencies.includes("tailwind")
? "with-auth-trpc-tw.tsx"
: "with-auth-trpc.tsx"
: _dependencies.includes("tailwind")
? "with-trpc-tw.tsx"
: "with-trpc.tsx"
: _dependencies.includes("auth")
? _dependencies.includes("tailwind")
? "with-auth-tw.tsx"
: "with-auth.tsx"
: _dependencies.includes("tailwind")
? "with-tw.tsx"
: "base.tsx";
組み合わせ爆発問題が最も顕著に現れている実例。auth x tRPC x Tailwindの3機能で12以上のバリアントが必要になる。新しい機能を1つ追加すると、バリアント数が倍増する。
create-t3-appの場合はオプショナル機能が少ない(NextAuth、tRPC、Tailwind、Prisma、Drizzle程度)のでまだ管理可能な範囲に収まっている。ただ、14個の機能があるプロジェクトでこの方式を採用すると現実的ではない。
パターン4: Template Engine(Yeoman方式)
Yeomanはソースファイル内にEJSのコンディショナルを埋め込む古典的なアプローチ。
import express from 'express';
<% if (useTypeScript) { %>
import { Request, Response } from 'express';
<% } %>
<% if (useAuth) { %>
import passport from 'passport';
<% } %>
const app = express();
<% if (useAuth) { %>
app.use(passport.initialize());
<% } %>
<% if (useCors) { %>
app.use(cors());
<% } %>
composeWith()によるサブジェネレーターの合成も可能で、メモリファイルシステムとコンフリクト解決機能もある。
利点は最大限の柔軟性。どんな組み合わせでも1つのテンプレートで表現できる。
欠点は「テンプレートスープ」問題。EJSのコンディショナルが増えると、テンプレートファイルが有効なソースコードではなくなる。エディタの補完が効かない、構文ハイライトが壊れる、テンプレート自体をビルドして動作確認できない。機能が10個以上になると、テンプレートの可読性が著しく下がる。
<header>
<% if (useLogo) { %>
<img src="<%= logoPath %>" alt="Logo" />
<% } %>
<nav>
<% if (useAuth) { %>
<a href="/login">Login</a>
<% } %>
<% if (useSearch) { %>
<search-component />
<% } %>
<% if (useI18n) { %>
<language-switcher />
<% } %>
<% if (useTheme) { %>
<theme-toggle />
<% } %>
</nav>
</header>
これは5機能だけの例だが、14機能が1つのファイルに集中するとさらに複雑になる。
パターン5: Injection with Anchors(Hygen方式)
Hygenは、フロントマターでinjectionを宣言するアプローチを取る。調査した中で唯一、injectionをプリミティブとして持っているツール。
---
inject: true
to: src/components/header.astro
after: "<!-- @slot:header-actions -->"
skip_if: "ai-chat-trigger"
---
{
settings.aiAssistant && (
<button id="ai-chat-trigger" type="button">
<svg>...</svg>
</button>
),
}
ベーステンプレートの共有ファイルにアンカーコメントを配置し、各機能がそのアンカーの後にコードを注入する。
<!-- base/src/components/header.astro -->
<header>
<nav>
<!-- @slot:header-actions -->
</nav>
</header>
AI Assistant機能のinjection。
---
inject: true
to: src/components/header.astro
after: "<!-- @slot:header-actions -->"
skip_if: "ai-chat-trigger"
---
<button id="ai-chat-trigger">AI Chat</button>
検索機能のinjection。
---
inject: true
to: src/components/header.astro
after: "<!-- @slot:header-actions -->"
skip_if: "search-trigger"
---
<Search client:load />
この方式には2つの特徴がある。
1つ目。機能が増えても線形にスケールする。新機能を追加するときは、新しいinjectionファイルを追加するだけ。既存の機能のファイルを変更する必要がない。N個の機能でN個のinjectionファイル。指数的ではなく線形。
2つ目。skip_ifガードで冪等性が保証される。同じinjectionを2回実行しても、すでに注入済みであればスキップされる。
利点は、cross-cutting concern問題を自然に解決できること。共有ファイルにアンカーを置いて、各機能が独立してinjectionを宣言する。機能同士が互いを知る必要がない。
欠点は、アンカーポイントの安定性に依存すること。アンカーコメントを誰かが消したり移動したりすると、injectionが失敗する。また、injection後のファイルのフォーマッティングが崩れる可能性がある。
パターン6: AST-based Modification(astro add / magicast方式)
Astroのastro addコマンドは、JavaScript/TypeScriptファイルをASTとしてパースし、プログラム的に修正するアプローチを取る。内部ではmagicastライブラリを使っている。
import { loadConfigFile } from "magicast";
// astro.config.mjs をパースして修正
const config = await loadConfigFile("astro.config.mjs");
// import を追加
config.imports.$add({
from: "@astrojs/react",
imported: "react",
local: "react",
});
// integrations配列に追加
config.exports.default.integrations.$push("react()");
// ファイルに書き戻し(フォーマッティング保持)
await config.write();
ASTレベルで操作するので、正規表現のように「意図しないマッチ」が起きない。import文の追加、設定オブジェクトのプロパティ追加、配列への要素追加といった操作がAPI経由でできる。フォーマッティングもある程度保持される。
利点は最も堅牢であること。コードの構造を理解した上で修正するので、アンカーコメントも正規表現も不要。
欠点は、構造化されたコード(JavaScript/TypeScript設定ファイル)にしか使えないこと。Astroテンプレート(.astroファイルのHTML部分)やCSS、YAMLなどにはAST操作が使えない。zudo-docの場合、astro.config.tsやsettings.tsにはAST操作が使えるが、header.astroのHTML部分やglobal.cssのスタイルルールには使えない。
ASTベースの修正: markdownだけではない
パターン6で触れたASTベースの修正について、もう少し掘り下げる。
remarkやrehypeのプラグインを書いたことがある人なら、ASTという概念には馴染みがあるだろう。markdownをパースして見出しやリンクをツリー構造のノードとして操作する、あれ。実はASTはmarkdownだけのものではなく、JavaScript/TypeScriptのソースコード操作にも同じアプローチが使える。
remarkのASTとJavaScriptのAST
remarkの場合、markdownテキストをパースするとheading、paragraph、linkなどのノードからなるツリーが得られる。visit(tree, 'heading', ...) でノードを走査して、見出しのテキストを変更したり、ノードを追加・削除したりできる。
JavaScript/TypeScriptの場合も同じ構造がある。ソースコードをパースするとImportDeclaration、ObjectExpression、ArrayExpressionなどのノードからなるツリーが得られる。import文を追加したいならImportDeclarationノードを作ってツリーに挿入する。オブジェクトにプロパティを追加したいならObjectExpressionのpropertiesにPropertyノードを追加する。
magicastの仕組み
パターン6で紹介したmagicastは、JavaScript/TypeScriptのASTを操作するためのラッパーライブラリ。内部ではrecast(AST操作 + フォーマッティング保持)とBabel(JSパーサー)が使われている。
magicastが提供するのは、低レベルのAST操作を隠蔽した高レベルAPI。
import { loadFile, generateCode, builders } from "magicast";
const mod = await loadFile("astro.config.mjs");
// import文を追加
mod.imports.$append({
imported: "default",
local: "react",
from: "@astrojs/react",
});
// default exportの設定オブジェクトにアクセス
const config = mod.exports.default.$args[0];
// integrations配列にpush
config.integrations.push(builders.functionCall("react"));
// コードを再生成(フォーマッティング保持)
const { code } = generateCode(mod);
mod.imports.$append()やconfig.integrations.push()のように、JavaScriptオブジェクトを操作する感覚でASTを操作できる。内部ではASTノードの生成・挿入が行われているが、利用者はそれを意識する必要がない。
Astroのastro addコマンドがまさにこの方式で動いている。astro add reactを実行すると、magicastでastro.config.mjsをパースし、@astrojs/reactのimportを追加し、integrations配列にreact()を追加する。
正規表現との違い
正規表現でコードを修正する場合、コードを「文字列」として扱う。
// 正規表現: コードを文字列として扱う
const code = fs.readFileSync("astro.config.mjs", "utf-8");
const newCode = code.replace(/(integrations:\s*\[)/, "$1\n react(),");
この方式はフォーマッティングが異なると壊れる。integrations: [とintegrations:[とintegrations : [で正規表現が変わる。コメントがあるとマッチしない。import文の追加はさらに厄介で、既存のimportの後に追加するか先頭に追加するか、既存のimportがない場合にどうするかなど、考慮すべきケースが多い。
ASTベースの修正では、コードを「ツリー構造」として扱うので、フォーマッティングに関係なく構造的に正しい操作ができる。remarkでvisit(tree, 'heading', ...)と書くとき、markdownの空行やスペースの数を気にしないのと同じ。
適用範囲の制約
ASTベースの修正が使えるのは、パーサーが存在する構造化された言語だけ。
- JavaScript/TypeScript: magicast、recast、Babel、jscodeshift
- JSON: 標準
JSON.parseで十分 - CSS: postcssでAST操作が可能(ただしmagicastほど高レベルなAPIはない)
逆にASTベースの修正が使えないもの。
- Astroテンプレート(
.astroファイルのHTML部分) - YAML(パーサーはあるがフォーマッティング保持の操作ライブラリが少ない)
- 設定ファイル内のコメント(ASTは通常コメントを捨てる。recastはコメント保持するが完全ではない)
zudo-docの場合、astro.config.tsとcontent.config.tsは構造が明確に決まっている設定ファイルだった。そのため、ASTで既存のコードを修正するのではなく、プログラム的にゼロから生成する方式を選択した。settings-gen.tsと同じ方針で、機能フラグに基づいてファイル全体を文字列として組み立てる。ASTの修正も有効な選択肢だったが、生成対象の構造が固定されているなら、ゼロから組み立てるほうがシンプルになる。
比較分析
各パターンの特性を3つのケースで比較する。
「機能がエントリファイルにインポートを追加する」ケース
| パターン | 対応方法 |
|---|---|
| Full Pre-built | インポート済みのテンプレートを用意 |
| Layered Overlay | バリアントファイルで対応 |
| Installer + Variants | バリアントファイルで対応 |
| Template Engine | EJSコンディショナルでimport文を出し分け |
| Injection | アンカー後にimport文を注入 |
| AST Modification | magicastでimportをプログラム追加 |
「機能がpackage.jsonに依存を追加する」ケース
| パターン | 対応方法 |
|---|---|
| Full Pre-built | テンプレートのpackage.jsonに含める |
| Layered Overlay | 機能別package.jsonをディープマージ |
| Installer + Variants | インストーラー関数でaddPackageDependency |
| Template Engine | EJSで依存セクションを出し分け |
| Injection | package.jsonへの注入(JSONなので難しい) |
| AST Modification | JSONパース+マージ(AST不要) |
「機能がCSSをグローバルスタイルに追加する」ケース
| パターン | 対応方法 |
|---|---|
| Full Pre-built | テンプレートのCSSに含める |
| Layered Overlay | CSSファイルのバリアントまたは追加ファイル |
| Installer + Variants | CSSファイルのバリアント |
| Template Engine | EJSコンディショナルでCSSルールを出し分け |
| Injection | アンカーコメント後にCSSルールを注入 |
| AST Modification | CSSにはAST操作が使えない |
パターンのスペクトラムとしては、左から右に向かって「静的 → 動的」になる。
Full Pre-built → Overlay/Compose → Template Engine → AST Modification
(最も静的) (最も動的)
静的なアプローチはシンプルだが組み合わせ爆発に弱い。動的なアプローチは柔軟だがテンプレートの可読性が下がる。
zudo-docでの実践と結果
OSSの調査結果を踏まえて、create-vue式のFragment overlayとHygen式のInjection anchorsのハイブリッドで実装した。
合成エンジン: compose.ts
中核になったのは合成エンジンcompose.ts。ベーステンプレートを起点に、有効な機能のファイルをオーバーレイし、injection anchorsでコードを注入する。
処理の流れはこうなった。
base/を丸ごとコピー(73ファイルのベーステンプレート)- 有効な機能の
files/をコピー(create-vue式overlay) - 有効な機能の
inject/を実行(Hygen式injection、22箇所のアンカーポイント) package.jsonをディープマージastro.config.tsをプログラム生成content.config.tsをプログラム生成settings.tsのフラグを生成
ベーステンプレートの共有ファイルにはアンカーコメントが配置してある。
<!-- base/src/components/header.astro -->
<header>
<nav>
<!-- @slot:header-actions -->
</nav>
</header>
/* base/src/styles/global.css */
/* === Base styles === */
body {
/* ... */
}
/* @slot:feature-styles */
各機能はfiles/ディレクトリにファイルレベルで自己完結するファイルを、inject/ディレクトリに共有ファイルへのinjectionスニペットを持つ。
strip.tsの573行が不要に
旧方式のstrip.tsは573行あった。各機能ごとに「どのファイルを消すか」「どの正規表現でパッチするか」を列挙したファイルで、機能が増えるたびに肥大化していた。
新方式ではこのファイルを削除した。「何を削るか」ではなく「何を足すか」だけを記述するadditive-only方式なので、strip処理自体が不要になった。
10個の機能モジュール
以下の10機能がそれぞれ独立したモジュールとして実装された。
- AI Assistant
- Search
- i18n
- Color Customization
- Doc History
- LLMs.txt
- Footer
- Changelog
- Skill Symlinker
- Language: Japanese
各モジュールはfiles/とinject/のディレクトリを持ち、自分が追加するものだけを宣言する。他の機能を知る必要がない。
astro.config.tsとcontent.config.tsのプログラム生成
astro.config.tsは5機能がタッチする共有ファイルだったので、injection anchorsではなくプログラム生成を選択した。settings-gen.tsと同じ方針で、機能フラグに基づいてファイル全体を文字列として組み立てる。
content.config.tsも同様。i18n機能の有無でcontent collection定義が変わるので、プログラム生成にした。
injection anchorsとプログラム生成の使い分けは以下。
- HTML/CSS/テンプレートファイル: injection anchors(部分的な挿入で十分なので)
- JS/TS設定ファイル: プログラム生成(構造が固定されていて全体を制御したいので)
テスト結果
125個のユニットテストが通っている。加えて、5つ以上のパターンで統合テストを実施した。
- barebone(全機能オフ)
- all-features(全機能オン)
- search-only(検索だけ)
- i18n-only(i18nだけ)
- lang-ja(日本語のみ)
各パターンでpnpm create zudo-doc → pnpm buildが成功することを確認している。
まとめ
copy-then-stripは動くが脆い。機能が増えるとstrip漏れのリスクが増大する。strip.tsの正規表現はレビューが難しく、「この機能のコードは本当にすべて削除されているか?」を確認するには、その機能がどのファイルに影響しているかの完全な知識が必要になる。
OSSのジェネレーターでは「ベースから構築する」アプローチが主流。特にcreate-vueのLayered Template Overlayはエレガントで、ファイルレベルの機能合成とpackage.jsonのディープマージという2つのプリミティブだけで多くのケースをカバーしている。
cross-cutting concernは避けられないが、Hygen式のinjection anchorsで線形にスケール可能。共有ファイルにアンカーを置いて各機能が独立してinjectionを宣言する方式は、機能同士の結合を最小限にする。
完璧なパターンは存在しない。Full Pre-builtはシンプルだが組み合わせ爆発に弱い、Template Engineは柔軟だがテンプレートスープになる、AST Modificationは堅牢だがJS/TSにしか使えない。プロジェクトの特性に応じたハイブリッドが現実的な選択肢。
zudo-docでは、Fragment overlay + Injection anchors + プログラム生成のハイブリッドで移行を完了した。573行のstrip.tsが消えて、10個の機能モジュールと合成エンジンに置き換わった。additive-only方式なので、新しい機能を追加するときは既存のファイルに触れず、新しいモジュールディレクトリを追加するだけで済む。strip漏れは構造的に起きない。