newmo 技術ブログ

技術で地域をカラフルに

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する

newmoでは、フロントエンド、バックエンド、iOSやAndroidなどのモバイルアプリをすべて同じリポジトリで管理するmonorepoを採用しています。 monorepoを採用することで、アプリケーション間で共通のコードを共有することができたり、CIの管理が楽になったり、他のチームのコードを見るのにわざわざリポジトリをcloneする必要がなくなります。 また、monorepoを採用することで、アプリケーションが利用しているパッケージ(ライブラリやツール)のバージョンを1つだけにするOne Version Ruleが実装できます。

One Version Rule

One Version Ruleは、monorepo内のパッケージのパッケージのバージョンを1つだけにするルールです。

One Version Ruleでは、monorepo内のアプリケーションが依存するパッケージは1つのバージョンだけにします。 たとえば、アプリケーションAがpackageXのバージョン 1.0.0 を使っているとき、アプリケーションBもpackageXのバージョン 1.0.0 を使うように統一します。

これによって、あるパッケージが1つのバージョンだけに集約されるので、メンテナンス性がよくなったり、パッケージのアップデートに関するセキュリティ的な問題に対処しやすくなったり、Diamond dependencyのようなパッケージ同士の依存におけるバージョンのConflictが起きにくくなります。

また、副作用として新しいパッケージを入れることに慎重となるため、同じ機能を持つ異なるパッケージが入りにくい傾向があります。 雑にパッケージの依存を増やすコストが高くなるため、newmoではDesign Docを書いて議論してからパッケージを追加することが多いです。

一方で、One Version Ruleを実践するには、当たり前ですがパッケージのバージョンを1つに統一する必要があります。そのため、依存するパッケージのバージョンを1つに統一するための方法を考える必要があります。 このパッケージのバージョン管理方法は言語により異なるため、言語ごとに適切な方法を選択する必要があります。

この記事では、フロントエンドで利用するnpm Registryのパッケージのバージョンを1つに統一するOne Version Ruleを実装する方法について紹介します。

pnpm catalogを使ったOne Version Ruleの実装

newmoでは、npmのパッケージ管理ツールとしてpnpmを利用しています。 そして、pnpmのCatalogs機能を使ってOne Version Ruleを実装しています。

pnpm catalogとは

pnpm catalogは、pnpm 9.5で追加された機能で、依存する複数のパッケージに名前をつけて管理したり、パッケージの依存をカタログ的に一箇所で管理できる仕組みです。

pnpmのworkspace(複数のパッケージ = ここではアプリケーションを統合的に扱う仕組み)を定義するpnpm-workspace.yamlに、catalogを定義できるようになっています。 pnpm-workspace.yamlcatalog:には、パッケージ名とそのバージョンを定義できます。

pnpm-workspace.yaml:

packages:
  - packages/*

# Define a catalog of version ranges.
catalog:
  react: ^18.3.1
  redux: ^5.0.1

catalog: 直下のパッケージはデフォルトのカタログとして扱われ、アプリケーションのpackage.jsonからは catalog:(名前がない場合はデフォルトカタログという意味となる)で参照できるようになります。

アプリケーションのpackage.json:

{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

さらにcatalogにはNamed Catalogsという機能もあり、複数のパッケージとバージョンをまとめたものに対して名前をつけて管理することができます。

次の例では、react17react18という名前のcatalogを定義しています。

catalogs:
  # Can be referenced through "catalog:react17"
  react17:
    react: ^17.0.2
    react-dom: ^17.0.2

  # Can be referenced through "catalog:react18"
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0

同じようにアプリケーションからは catalog:react17catalog:react18 で参照できます。

{
  "name": "@example/components",
  "dependencies": {
    "react": "catalog:react18",
  }
}

この機能を使うことで、monorepoにある全てのパッケージの名前とバージョンがpnpm-workspace.yamlという一つのファイルで管理できるようになります。

One Version Ruleでは、monorepoでは1つのバージョンを扱うので基本的にデフォルトカタログ(catalog:)にほとんどのパッケージを記述することになります。

パッケージをアップデートするときに、アプリケーションのコードも必要な場合があります。 複数のアプリケーションが依存しているパッケージの場合、同時にアプリケーションを修正することが難しいケースもあります。 その場合は、例外的にNamed Catalogsを使ってバージョンを分けることで、段階的にアップデートを進めることも可能です。

ここまでは、pnpmのcatalog機能の紹介でしたが、実際にOne Version Ruleをやるにはこのルールに強制力が必要です。 newmoのmonorepoでは、pnpmのHooks機能を使ってこのルールに強制力を持たせています。

pnpmのHooksを使ったOne Version Ruleの実装

pnpmのHooks機能を使うことで、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装することができます。

pnpmのHooks機能は、package.jsonの依存を解析したタイミング(readPackage)と依存関係が全て解決したタイミング(afterAllResolved)にフックする処理を.pnpmfile.cjsファイルに記述できます。

実現したいOne Version Ruleをpnpmのレベルまで落とすと、次のようなチェックをすれば良いことがわかります。

  1. アプリケーションが依存するパッケージのバージョンは workspace:* または catalog: で指定する
    • アプリケーション側には直接バージョンを指定できなくします
  2. { "<name>": "catalog:" }とアプリケーションで指定されたパッケージの実際のバージョンが pnpm-workspace.yaml に記載されている
    • pnpm-workspace.yamlで全てのパッケージのバージョンを管理するようにします
    • 基本的にはデフォルトカタログ(catalog:)にパッケージとバージョンを定義して、1つのバージョンを使うようにします
    • 例外としてnamed catalogを使うことで、複数のバージョンが存在することは許容します
  3. monorepo内のパッケージとnode_modulesのパッケージを区別できるような名前をつける
    • これはreadPackageがmonorepo内外の両方package.jsonの解析のタイミングで呼ばれるため、区別するのに必要です
    • newmoでは @newmo-app/ で始まるパッケージをmonorepo内のパッケージとして扱っています
  4. バージョンは必ずPinされたバージョンを使うようにする
    • lockファイルでバージョンは固定はされますが、pnpm-workspace.yamlを見たときにバージョンがわかるようにPinされたバージョンを使うようにしています
  5. このルールを満たさない時は、自動的に修正コマンドをエラーに表示する
    • このルールを満たさないときに手動で修正することは可能ですが、自動的に修正コマンドを表示することで、修正の手間を減らすことができます
    • 一般的とは言えないルールなので、普通のパッケージ管理ツールを使うのと同じぐらい簡単に扱えるようにする必要があります

このルールを実装した.pnpmfile.cjsは、次のようになっています。 MONOREPO_PREFIXを変更すれば、大体そのまま利用できるようになっています。

.pnpmfile.cjs (クリックで開く)

/**
 * # One Version Rule Implementation
 * **社内Notionへのリンク**
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo internal packages
 * monorpo内部のアプリケーションやmonorepo内のworkspaceのパッケージは、このprefixで始まるようにする
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const pnpmCatalogYamlFile = path.join(rootDir, "pnpm-workspace.yaml");
const pnpmCatalogYaml = fs.readFileSync(pnpmCatalogYamlFile, "utf-8");
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
const isPackageIncludedInCatalog = (pkgName) => {
  // "pkg": version
  // or
  // pkg: version
  const pkgAndVersionPattern = new RegExp(String.raw`"?${pkgName}"?: \d+\.\d+\.\d+`);
  if (pkgAndVersionPattern.test(pnpmCatalogYaml)) {
    return true;
  }
  // npm alias pattern
  // pkg: npm:...
  // e.g "@types/react": npm:types-react@19.0.0-rc.0
  const pkgAndVersionPatternWithNpm = new RegExp(String.raw`"?${pkgName}"?: npm:[\w-]{1,32}@`);
  return pkgAndVersionPatternWithNpm.test(pnpmCatalogYaml);
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map(pathFromRoot => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
            
Please make sure that all internal packages are prefixed with ${MONOREPO_PREFIX}

詳細は **社内Notionへのリンク** を参照してください。
`);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(`Invalid argument: ${pkg.name}. This function only accepts root package.json`);
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName, depVersion, depType, errorType: "invalid-semver-version"
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName, depVersion, depType: "pnpm.overrides", errorType: "invalid-semver-version"
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      if (errorPackage.errorType === "invalid-semver-version") {
        if (errorPackage.depType === "pnpm.overrides") {
          return `npm pkg set 'pnpm.overrides.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
        }
        return `npm pkg set '${errorPackage.depType}.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
      }
    }).join("\n");
    throw new Error(`Found invalid dependency versions in root package.json

This monorepo enforces all dependencies to be pinned version in root "package.json".
Please run the following commands to fix the issue:

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`
 
詳細は **社内Notionへのリンク** を参照してください。
`);
  }

};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-catalog" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in catalog
        if (!isPackageIncludedInCatalog(depName)) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "non-catalog"
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:") && !depVersion.startsWith("catalog:")) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "invalid-version"
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({ depName, depVersion, depType, packageName, errorType }) => {
      if (errorType === "non-catalog") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:") || depVersion.startsWith("catalog:")) {
          // use yq to append `depName: {latest}}` to "catalog" field
          return `yq -i '.catalog += {"${depName}": "$(npm info ${depName} version)"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
        }
        // If not `depName: version` to pnpm-workspace.yaml
        return `yq -i '.catalog += {"${depName}": "${pinVersion(depVersion)}"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='catalog:'`;
      }
    };
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      return fixCommand(errorPackage);
    }).join("\n");

    const isAddNewCatalog = Array.from(errorPackageSet).some((errorPackage) => {
      return errorPackage.errorType === "non-catalog";
    })

    throw new Error(`Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and pnpm catalogs.
Please run the following commands to fix the issue:

yq コマンドに依存しているので、 "brew install yq" で事前にインストールしてください

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`

${isAddNewCatalog ? "新しいパッケージがpnpm-workspace.yamlのcatalogに追加されます。コマンド実行後に、pnpm-workspace.yamlに追加された依存を適切なグループに移動してください" : ""}
詳細は **社内Notionへのリンク** を参照してください。

`);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved
  }
};

この.pnpmfile.cjsは、pnpm install時に実行され、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

たとえば、次のようにアプリケーションに直接バージョンを指定した状態で pnpm install を行うとエラーが発生します

  "devDependencies": {
    "@playwright/test": "catalog:",
    "@types/node": "catalog:",
    "typescript": "catalog:",
    "jquery": "^3.7.1"
  },

実行時のエラーメッセージには、このエラーを修正するコマンドも表示されるので、コマンドを実行することでエラーを修正することができます。

pnpm: Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and force the version via pnpm catalogs
Please run the following commands to fix the issue:

yq コマンドに依存しているので、"brew install yq" で事前にインストールしてください

```
cd /path/tp/newmohq/newmo-app
yq -i '.catalog += {"jquery": "3.7.1"}' "/path/tp/newmohq/newmo-app/pnpm-workspace.yaml" # @newmo-app/application-x
npm --prefix=$(find . -name package.json | xargs grep -l '"name": "@newmo-app/application-x' | xargs dirname) pkg set 'devDependencies.jquery'='catalog:'
pnpm install
```

詳細は **社内Notionへのリンク** を参照してください。

pnpm installでOne Version Ruleのチェックが行われるので、自動的にCIでも落ち、また開発者のローカルでもすぐエラーがわかるようになっています。

これによってnewmoでは、monorepo内の全てのパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

Note: Sherifのようなmonorepoに特化したLinterなどもありますが、.pnpmfile.cjsで実装するメリットは他のツールを増やす必要がない点にあります。

参考: newmoのpnpm catalog

参考までに、newmoのpnpm-workspace.yamlに記載されているパッケージのカタログを紹介します。 ここに書かれているパッケージが、現在時点(2024-08-30)でのフロントエンドで利用しているパッケージの一覧です。

packages:
  - ... 色々な内部パッケージ ...
catalog:
  # ------------------------------
  # Browser Runtimeで利用するその他の依存
  # カテゴライズが難しいが、ブラウザのRuntimeに含まれるような依存
  # ------------------------------
  # LIFFのSDK
  # Design Docs: **Design Docへのリンク**
  "@line/liff": 2.24.0
  # アニメーションを再生するプレイヤーのライブラリ
  # https://rive.app/
  "@rive-app/react-canvas": 4.12.0
  # ------------------------------
  # React関係
  # ------------------------------
  react: 19.0.0-rc-935180c7e0-20240524
  react-dom: 19.0.0-rc-935180c7e0-20240524
  "@types/react": npm:types-react@19.0.0-rc.0
  "@types/react-dom": npm:types-react-dom@19.0.0-rc.0
  # ReactのUIコンポーネントライブラリではReact Ariaを利用している
  # https://react-spectrum.adobe.com/react-aria/index.html
  # Design Doc: **Design Docへのリンク**
  react-aria-components: 1.2.1
  # ------------------------------
  # Next.js関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  next: 15.0.0-rc.0
  "@next/third-parties": 14.2.5
  # ------------------------------
  # Panda CSS関係
  # https://pandacss.com/
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  "@pandacss/dev": 0.44.0
  # 直接postcssは利用していない、Panda CSSをNext.jsと連携させるために利用している
  postcss: 8.4.41
  # ------------------------------
  # Storybook関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  storybook: 8.2.9
  "@storybook/addon-essentials": 8.2.9
  "@storybook/addon-interactions": 8.2.9
  "@storybook/addon-links": 8.2.9
  "@storybook/addon-storysource": 8.2.9
  "@storybook/blocks": 8.2.9
  "@storybook/react": 8.2.9
  # VRTには https://www.chromatic.com/ を利用している
  chromatic: 11.7.1
  "@chromatic-com/storybook": 1.6.1
  # StorybookにはViteを利用しているため、ViteのReactプラグインを入れてる
  "@storybook/react-vite": 8.2.9
  "@vitejs/plugin-react": 4.3.1
  # ------------------------------
  # Vite関係
  # https://vitejs.dev/
  # ------------------------------
  vite: 5.4.2
  # Unit TestはViteを利用している
  vitest: 2.0.3
  # ------------------------------
  # Playwright関係
  # https://playwright.dev/
  # ------------------------------
  # Integration TestはPlaywrightを利用している
  "@playwright/test": 1.46.0
  # 静的なHTMLをテストで利用する時に、ローカルサーバとしてserveを利用している
  serve: 14.2.3
  # ------------------------------
  # GraphQL関係
  # https://graphql-code-generator.com/
  # codegenのpluginやfake serverなど
  # GraphQLのruntimeでも必要な依存はここに記載する
  # ------------------------------
  graphql: 16.8.1
  "@graphql-typed-document-node/core": 3.2.0
  # GraphQLのクラインアントはApollo Clientを利用している
  "@apollo/client": 3.10.6
  # GraphQLのdevDependenciesはここに記載する
  "@graphql-codegen/cli": 5.0.2
  "@graphql-codegen/client-preset": 4.2.5
  "@newmo/graphql-codegen-plugin-typescript-react-apollo": 1.2.2
  # Fake Serverはローカル開発時に利用している
  # Design Doc: **Design Docへのリンク**
  "@newmo/graphql-fake-server": 0.11.0
  "@newmo/graphql-codegen-fake-server-client": 0.11.0
  # ------------------------------
  # ESLint関係
  # https://eslint.org/
  # ESLintの設定は/eslint.config.jsにまとめられている
  # ------------------------------
  eslint: 8.57.0
  typescript-eslint: 7.13.1
  eslint-config-next: 15.0.0-rc.0
  eslint-plugin-playwright: 1.6.2
  eslint-plugin-prettier: 5.1.3
  eslint-plugin-react: 7.34.1
  eslint-plugin-react-hooks: 4.6.2
  eslint-plugin-storybook: 0.8.0
  "@vitest/eslint-plugin": 1.0.4
  "@pandacss/eslint-plugin": 0.1.9
  "@next/eslint-plugin-next": 14.2.4
  "@graphql-eslint/eslint-plugin": 3.20.1
  # ------------------------------
  # secretlint関係
  # https://github.com/secretlint/secretlint
  # 機密情報がコミットされたらCIで落とすために入れている
  # ------------------------------
  secretlint: 8.2.4
  "@secretlint/secretlint-rule-preset-recommend": 8.2.4
  # ------------------------------
  # TypeScript関係
  # https://www.typescriptlang.org/
  # TypeScriptのcompiler pluginやtsconfigに関係するもの
  # ------------------------------
  typescript: 5.5.2
  # ------------------------------
  # Formatter関係
  # ------------------------------
  prettier: 3.2.5
  # ------------------------------
  # スクリプト/ツール関係
  # script/やcmd/などで利用する開発用のスクリプトやツールで利用するもの
  # ------------------------------
  glob: 10.3.12 # TODO: Node.js 22ではネイティブで利用できるので不要になる
  # ------------------------------
  # その他の開発系(devDependencies)の依存
  # driver-webでcloudflare pagesへデプロイするのに利用
  # ------------------------------
  wrangler: 3.57.1
  # ------------------------------
  # その他の@types
  # グルーピングされないtypesはここに記載する
  # ------------------------------
  "@types/node": 20.12.7
  # ------------------------------
  # ここから↓↓↓は、まだカテゴライズされていない依存が列挙されています
  # yqで追加した時はここに記載されるので、適切なカテゴライズをしてください。
  # ------------------------------

すでに複数のウェブアプリケーションが稼働していますが、依存はかなり最小限にしていて、また新しいパッケージを追加する際にはDesign Docを書いて議論することが多いです。 Design Docでは、導入する目的/目的外、他の選択肢との比較、メリット/デメリット、Tier(フロントエンドの移り変わりは早すぎるのかを参考に)などを議論しています。 いわゆるArchitecture Decision Record(ADR)のようなものですが、新しいパッケージを追加する際には、このような議論を行うことでなぜそのパッケージを導入するのかを明確にしています。 カタログにDesign Docへのリンクを入れることで、後から入った人もなぜそのパッケージを使っているかをわかるようにしています。

こうした議論をするのは、一度入れたパッケージを削除するのが難しいからです。 パッケージの扱い(Tier)について話すのも、どれぐらい該当のパッケージに依存してアプリケーションを作るかを関係者で認識を揃えるためです。

まとめ

newmoのフロントエンドでは、pnpmのcatalog機能を使ってOne Version Ruleを実装しています。

One Version Ruleは、monorepo内の全てのパッケージのバージョンを1つに統一するルールです。 これによって、パッケージの一覧性がよくなり、パッケージのアップデートといったメンテナンスがしやすくなります。

一方で、基本的には1つのバージョンだけを扱うことになるので、アップデート時には自動テストが重要になります。 newmoのフロントエンドでは、PlaywrightとFake Serverを使ったIntegration TestsやChromaticを使ったVRT(Visual Regression Test)など自動テストを充実させています。 ライブラリを扱うコードに対しては、Unit Testが書きにくいことも多いため、Integration Testsなどユーザーが見るものに対するテストに比重を置いています。 (主なロジックはGoで書かれたバックエンドにあるため、バックエンド側もE2Eテストなどを充実させてカバーしています)

副作用として、新しいパッケージを追加する際には、通常のバラバラのバージョンで管理するよりは心理的な障壁が高くなります。 良い面としてはちゃんと議論してからパッケージを追加することができるということですが、逆を言えばパッケージを簡単には追加できないということでもあります。 これは、newmoではちゃんと検討してからパッケージを追加するような意思決定をしたということなので、全ての開発でこの方法が適切というわけではありません。

One Version Ruleから複数バージョンへ移行するのは簡単ですが、その逆は難しいです。 このOne Version Ruleで破綻するところまでは、この方法でやってみようということで、newmoではフロントエンドを含め、Go言語のバックエンドやSwiftのiOSなども同様の方法でパッケージ管理を行っています。

PR: newmoではフロントエンドエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

Appendix: pnpm 9.5未満でのOne Version Ruleの実装

pnpm catalogはpnpm 9.5で追加された機能ですが、pnpm 9.5未満でも同様のOne Version Ruleを実装することができます。

pnpm.overridesworkspace:* を使うことで擬似的にpnpm catalogと同様の機能を実現することができます。 pnpm catalogが出る前にこの仕組みを実装して使っていて、pnpm catalogがリリースされたときには、pnpm catalogを使うように移行しました。

こちらの方式は pnpm overridesを使うのでrenovatebotなども対応しています。

pnpm catalogはまだリリースされたばかりなので、renomatebotやdependabotなどのツールはまだ対応していません。

pnpmfile.js (クリックで開く)

 /**
 * ## One Version Rule
 *
 * - Root package.json should define all versions of child packages via `pnpm.overrides`
 *  - e.g. `"pnpm": { "overrides": { "package-name": "1.0.0" } }`
 * - Each child package should use `workspaces:*` version instead of specific version
 *   - It uses the version defined in the root package.json's "pnpm.overrides"
 *   - e.g. `"dependencies": { "package-name": "workspace:*" }`
 *
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo packages
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map((pathFromRoot) => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
              
  Please make sure that all packages are prefixed with ${MONOREPO_PREFIX}
  `);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(
      `Invalid argument: ${pkg.name}. This function only accepts root package.json`
    );
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            errorType: "invalid-semver-version",
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName,
          depVersion,
          depType: "pnpm.overrides",
          errorType: "invalid-semver-version",
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        if (errorPackage.errorType === "invalid-semver-version") {
          if (errorPackage.depType === "pnpm.overrides") {
            return `npm pkg set 'pnpm.overrides.${
              errorPackage.depName
            }'='${pinVersion(errorPackage.depVersion)}'`;
          }
          return `npm pkg set '${errorPackage.depType}.${
            errorPackage.depName
          }'='${pinVersion(errorPackage.depVersion)}'`;
        }
      })
      .join("\n");
    throw new Error(`Found invalid dependency versions in root package.json
  
  This monorepo enforces all dependencies to be pinned version in root "package.json".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
    `);
  }
};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-overrides" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in root package.json
        if (!rootPkg.pnpm.overrides[depName]) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "non-overrides",
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:")) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "invalid-version",
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({
      depName,
      depVersion,
      depType,
      packageName,
      errorType,
    }) => {
      if (errorType === "non-overrides") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:")) {
          return `npm pkg set 'pnpm.overrides.${depName}'="$(npm info ${depName} version)"`;
        }
        return `npm pkg set 'pnpm.overrides.${depName}'='${pinVersion(
          depVersion
        )}'`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='workspace:*'`;
      }
    };
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        return fixCommand(errorPackage);
      })
      .join("\n");
    throw new Error(`Found packages that violate the rule
  
  This monorepo enforces all dependencies to be defined in root "package.json" and force the version via "pnpm.overrides".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
  
  詳細は **社内Notionへのリンク** を参照してください。
  
  `);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved,
  },
};

Reviewed by ito.