Slide 1

Slide 1 text

ts-morph で プロジェクト固有の アーキテクチャガードレールを作る 須藤 路真 ( @michimasa_suto ) 株式会社 PKSHA Technology 01 / 28

Slide 2

Slide 2 text

こういう経験、ありませんか? AI が書いたコード ガイドライン(CLAUDE.mdなど) にルールを 書いても、意図に沿わないコードが返ってくる プロンプトで縛っても、 ⻑いコンテキストの中で次第に守られなくなる ⼈間が書いたコード Pull Request で、同じアーキテクチャ違反 が繰り返し指摘される レビュアーは毎回同じ指摘で疲弊 レビューされる側も申し訳ない気持ちに... 02 / 28

Slide 3

Slide 3 text

ドキュメントもプロンプトも、結局 “お願い”でしかない お願いベースのルールは、コードや組織の成⻑とともに形骸化する 03 / 28

Slide 4

Slide 4 text

「お願い」ではなく、⼈も AI も⾃然と準拠するための「ガードレール」を引こう お願いベースのルールは、コードや組織の成⻑とともに形骸化する 04 / 28 ドキュメントもプロンプトも、結局 “お願い”でしかない

Slide 5

Slide 5 text

フロントエンド構成 TECH STACK ARCHITECTURE ⼦孫‧兄弟への参照は可能 それ以外(親‧いとこ‧おじ/おば...)へ の参照は禁⽌ features/ ├── TaskList/ │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx featuresは再帰的にネスト可能 再帰的な features 構成 05 / 28 ⾔語‧ライブラリ AI ツール CONTEXT

Slide 6

Slide 6 text

再帰的な features 構成 の詳細は Zenn の記事を↓ 🔒 zenn.dev/pksha/articles/recursive-features-directory-structure 06 / 28 CONTEXT

Slide 7

Slide 7 text

TODAY'S TALK このルールを静的解析で検知できるようにしたい features/ ├── TaskList/ │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx 07 / 28

Slide 8

Slide 8 text

TOOLING 技術選定 08 / 28 モジュール間の import 制約を 宣⾔的に書ける 優れた Linter がある dependency-cruiser ESLint Biome ディレクトリの親⼦関係 でルール判定したい今回の構成において、 上記のツールでは⼗分に表現しきれなかった Oxlint

Slide 9

Slide 9 text

TOOLING 技術選定 モジュール間の import 制約を 宣⾔的に書ける 優れた Linter がある ディレクトリの親⼦関係 でルール判定したい今回の構成において、 上記のツールでは⼗分に表現しきれなかった 09 / 28 ts-morph を採⽤ dependency-cruiser ESLint Biome Oxlint

Slide 10

Slide 10 text

TOOLING ts-morph とは ‧AST(抽象構⽂⽊) を使って、コードを 構造として読み取れる ‧TypeScript Compiler API を扱いやすく包んだラッパー ‧Lint ツールよりも ⾃由度の⾼いコード解析‧変換 ができる 10 / 28

Slide 11

Slide 11 text

TOOLING ts-morph とは import { Project } from "ts-morph"; const project = new Project({ tsConfigFilePath: "./tsconfig.json" }); const sf = project.getSourceFile("src/Button.tsx")!; sf.getImportDeclarations(); // import 一覧を取得 sf.getExportedDeclarations(); // export 一覧を取得 sf.getFunctions(); // 関数の一覧を取得 11 / 28

Slide 12

Slide 12 text

GOAL これから作るもの ─ 違反 を CI で検知する features/ ├── TaskList/ │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx 12 / 28

Slide 13

Slide 13 text

APPROACH どう検知する? 1 ファイルパスと import パス のペアを取得する FILE features/TaskDetail/index.tsx IMPORT import { xxx } from "../../features/TaskList/features/TaskExport/index.tsx" ペアが違反していないかを「ホワイトリスト」で判定する isAllowedReference ( "features/TaskDetail/index.tsx" , "../../features/TaskList/features/TaskExport/index.tsx" ) 2 13 / 28

Slide 14

Slide 14 text

APPROACH どう検知する? ファイルパスと import パス のペアを取得する FILE features/TaskDetail/index.tsx IMPORT import { xxx } from "../../features/TaskList/features/TaskExport/index.tsx" ペアが違反していないかを「ホワイトリスト」で判定する isAllowedReference ( "features/TaskDetail/index.tsx" , "../../features/TaskList/features/TaskExport/index.tsx" ) 2 14 / 28 1

Slide 15

Slide 15 text

CODE · 1 / 5 1. ファイルパスと import パス のペアを取得する const project = new Project({ tsConfigFilePath: "./tsconfig.json" }); for (const source of project.getSourceFiles()) { for (const decl of sf.getImportDeclarations()) { const target = decl.getModuleSpecifierSourceFile(); if (!target) continue; if (!isAllowedReference(source.getFilePath(), target.getFilePath())) { throw new Error(`${source.getFilePath()} → ${target.getFilePath()}`); } } } STEP 01 tsconfig を読み込んで Project を作る 15 / 28

Slide 16

Slide 16 text

CODE · 2 / 5 1. ファイルパスと import パス のペアを取得する const project = new Project({ tsConfigFilePath: "./tsconfig.json" }); for (const source of project.getSourceFiles()) { for (const decl of sf.getImportDeclarations()) { const target = decl.getModuleSpecifierSourceFile(); if (!target) continue; if (!isAllowedReference(source.getFilePath(), target.getFilePath())) { throw new Error(`${source.getFilePath()} → ${target.getFilePath()}`); } } } STEP 02 全ファイル内を探索 import ⽂をすべて確認 16 / 28

Slide 17

Slide 17 text

CODE · 3 / 5 1. ファイルパスと import パス のペアを取得する const project = new Project({ tsConfigFilePath: "./tsconfig.json" }); for (const source of project.getSourceFiles()) { for (const decl of sf.getImportDeclarations()) { const target = decl.getModuleSpecifierSourceFile(); if (!target) continue; if (!isAllowedReference(source.getFilePath(), target.getFilePath())) { throw new Error(`${source.getFilePath()} → ${target.getFilePath()}`); } } } STEP 03 import ⽂から対応するファ イルを取得する 17 / 28

Slide 18

Slide 18 text

CODE · 4 / 5 1. ファイルパスと import パス のペアを取得する const project = new Project({ tsConfigFilePath: "./tsconfig.json" }); for (const source of project.getSourceFiles()) { for (const decl of sf.getImportDeclarations()) { const target = decl.getModuleSpecifierSourceFile(); if (!target) continue; if (!isAllowedReference(source.getFilePath(), target.getFilePath())) { throw new Error(`${source.getFilePath()} → ${target.getFilePath()}`); } } } STEP 04 ファイルパスとimportパスの ペアを元にルールを判定 →違反ならerror をthrow > isAllowedReference の中身は 次のスライドで説明 18 / 28

Slide 19

Slide 19 text

APPROACH どう検知する? 1 ファイルパスと import パス のペアを取得する FILE features/TaskDetail/index.tsx IMPORT import { xxx } from "../../features/TaskList/features/TaskExport/index.tsx" ペアが 違反していないか を「ホワイトリスト」で判定する isAllowedReference ( "features/TaskDetail/index.tsx", "../../features/TaskList/features/TaskExport/index.tsx" ) 2 19 / 28

Slide 20

Slide 20 text

APPROACH ホワイトリスト? 20 / 28 ⼦孫‧兄弟 への参照は可能 それ以外(親‧いとこ‧おじ/おば...)への参照は禁⽌ features/ ├── TaskList/ │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx

Slide 21

Slide 21 text

2. ペアが違反していないかを「ホワイトリスト」で判定する STEP 01 最も深い feature まで のパス を抜き出すhelper関数 正規表現で /features/X を全マッチさ せ、 ⼀番深いものまでを採⽤。 TaskList/features/TaskExport/index.tsx → TaskList/features/TaskExport function getFeaturePath(filePath: string): string | null { const matches = [...filePath.matchAll(/\/features\/([^/]+)/g)]; if (matches.length ..= 0) return null; const last = matches[matches.length - 1]; return filePath.substring(0, last.index! + last[0].length); } function isAllowedReference(source: string, target: string): boolean { const sourceFeature = getFeaturePath(source); const targetFeature = getFeaturePath(target); if (!sourceFeature .| !targetFeature) return true; return( target.startsWith(`${sourceFeature}/`) .| sourceFeature.substring(0, sourceFeature.lastIndexOf("/")) ..= targetFeature.substring(0, targetFeature.lastIndexOf("/")); )} 21 / 28 CODE · 1 / 3

Slide 22

Slide 22 text

function getFeaturePath(filePath: string): string | null { const matches = [...filePath.matchAll(/\/features\/([^/]+)/g)]; if (matches.length ..= 0) return null; const last = matches[matches.length - 1]; return filePath.substring(0, last.index! + last[0].length); } function isAllowedReference(source: string, target: string): boolean { const sourceFeature = getFeaturePath(source); const targetFeature = getFeaturePath(target); if (!sourceFeature .| !targetFeature) return true; return( target.startsWith(`${sourceFeature}/`) .| sourceFeature.substring(0, sourceFeature.lastIndexOf("/")) ..= targetFeature.substring(0, targetFeature.lastIndexOf("/")); )} CODE · 2 / 3 22 / 28 2. ペアが違反していないかを「ホワイトリスト」で判定する STEP 02 source / target の Feature を 特定 import元‧import先がそれぞれどの Feature に属するかを特定。Feature 外な らルール対象外として無視

Slide 23

Slide 23 text

function getFeaturePath(filePath: string): string | null { const matches = [...filePath.matchAll(/\/features\/([^/]+)/g)]; if (matches.length ..= 0) return null; const last = matches[matches.length - 1]; return filePath.substring(0, last.index! + last[0].length); } function isAllowedReference(source: string, target: string): boolean { const sourceFeature = getFeaturePath(source); const targetFeature = getFeaturePath(target); if (!sourceFeature .| !targetFeature) return true; return( target.startsWith(`${sourceFeature}/`) .| sourceFeature.substring(0, sourceFeature.lastIndexOf("/")) ..= targetFeature.substring(0, targetFeature.lastIndexOf("/")); )} CODE · 2 / 3 23 / 28 2. ペアが違反していないかを「ホワイトリスト」で判定する STEP 03 source → target の依存が 「⼦孫」か「兄弟」ならOK ⼦孫か兄弟かを、 prefix を⾒てホワ イトリストで判定する

Slide 24

Slide 24 text

24 / 28 OUTPUT · 4 / 4 ツールの出⼒サンプル $ pnpm run feature-lint ✘ src/features/TaskList/index.tsx:12:1 rule: feature-reference 別ラインの feature を参照しています: src/features/TaskDetail/components/DetailHeader 10 | 11 | import { TaskTable } from "./components/TaskTable"; > 12 | import { DetailHeader } from "../TaskDetail/components/DetailHeader"; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 13 | hint: 直系(祖先と子孫の関係)の feature 同士のみ参照できます

Slide 25

Slide 25 text

‧AI エージェントが⽣成したコードも CI で検知できるようになった OUTCOME 実際に導⼊してみて ‧既存のコード内で ルール違反 が数⼗件⾒つかった ‧レビューで指摘していた内容が 事前に弾けるようになった 25 / 28

Slide 26

Slide 26 text

まとめ ‧AST解析でガードレールは⾃作できる  ‧プロジェクト固有のルールもコードを⾛査して静的に検出できる ‧ts-morph は直感的な関数操作ができて敷居が低い  ‧ぜひ⾃分のプロジェクトでも使ってみてください! ‧AI時代こそ、⾃動ガードレールが効く  ‧プロンプトやドキュメントでお願いするより、ガードレールで担保する 26 / 28 WRAP-UP

Slide 27

Slide 27 text

⾃⼰紹介 須藤 路真 @michimasa_suto 所属 株式会社 PKSHA Technology ( 25卒 ) 担当 コンタクトセンター向け SaaS の開発‧運⽤ 今年の⽬標 毎⽉ Zenn を書く 27 / 28

Slide 28

Slide 28 text

Thank you for listening! ご質問は Ask The Speaker か懇親会でぜひ 🙌 28 / 28