Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ts-morph でプロジェクト固有のアーキテクチャガードレールを作る

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

ts-morph でプロジェクト固有のアーキテクチャガードレールを作る

More Decks by PKSHA Technology(パークシャテクノロジー)

Transcript

  1. フロントエンド構成 TECH STACK ARCHITECTURE ⼦孫‧兄弟への参照は可能 それ以外(親‧いとこ‧おじ/おば...)へ の参照は禁⽌ features/ ├── TaskList/

    │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx featuresは再帰的にネスト可能 再帰的な features 構成 05 / 28 ⾔語‧ライブラリ AI ツール CONTEXT
  2. TODAY'S TALK このルールを静的解析で検知できるようにしたい features/ ├── TaskList/ │ ├── index.tsx │

    └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx 07 / 28
  3. TOOLING 技術選定 08 / 28 モジュール間の import 制約を 宣⾔的に書ける 優れた

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

    でルール判定したい今回の構成において、 上記のツールでは⼗分に表現しきれなかった 09 / 28 ts-morph を採⽤ dependency-cruiser ESLint Biome Oxlint
  5. 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
  6. GOAL これから作るもの ─ 違反 を CI で検知する features/ ├── TaskList/

    │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx 12 / 28
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. APPROACH ホワイトリスト? 20 / 28 ⼦孫‧兄弟 への参照は可能 それ以外(親‧いとこ‧おじ/おば...)への参照は禁⽌ features/ ├──

    TaskList/ │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx
  15. 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
  16. 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 外な らルール対象外として無視
  17. 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 を⾒てホワ イトリストで判定する
  18. 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 同士のみ参照できます
  19. ⾃⼰紹介 須藤 路真 @michimasa_suto 所属 株式会社 PKSHA Technology ( 25卒

    ) 担当 コンタクトセンター向け SaaS の開発‧運⽤ 今年の⽬標 毎⽉ Zenn を書く 27 / 28