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

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

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

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

Transcript

  1. ⾃⼰紹介 須藤 路真 @michimasa_suto 所属 株式会社 PKSHA Technology ( 25卒

    ) 担当 コンタクトセンター向け SaaS の開発‧運⽤ 趣味 海外旅⾏、ポーカー 02 / 27
  2. フロントエンド構成 TECH STACK ARCHITECTURE ⼦孫‧兄弟への参照は可能 それ以外(親‧いとこ‧おじ/おば...)へ の参照は禁⽌ features/ ├── TaskList/

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

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

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

    ディレクトリの親⼦関係 で判定したい今回の構成において、 上記のツールでは⼗分に表現しきれなかった 10 / 27 ts-morph を採⽤
  6. 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(); // 関数の一覧を取得 12 / 27
  7. GOAL これから作るもの ─ 違反 を CI で検知する features/ ├── TaskList/

    │ ├── index.tsx │ └── features/ │ └── TaskExport/ │ └── index.tsx └── TaskDetail/ └── index.tsx 13 / 27
  8. 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 14 / 27
  9. 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 15 / 27 1
  10. 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 を作る 16 / 27
  11. 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 ⽂をすべて確認 17 / 27
  12. 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 ⽂から対応するファ イルを取得する 18 / 27
  13. 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 の中身は 次のスライドで説明 19 / 27
  14. 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 20 / 27
  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 / 27 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 / 27 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 / 27 2. ペアが違反していないかを「ホワイトリスト」で判定する STEP 03 source → target の依存が 「⼦孫」か「兄弟」ならOK ⼦孫か兄弟かを、 prefix を⾒てホワ イトリストで判定する
  18. 24 / 27 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 同士のみ参照できます