OSSファンディングのために、会社で依存しているnpmライブラリを数えてみた

2023.06.23

クラスメソッドOSS支援開始のお知らせ

↑上記ブログのOSSサポートに際して、以下の目的でスクリプトを作成しました。

  • 以下のnpmライブラリを集計する
    • 社内のJS/TSプロジェクトが依存しているnpmライブラリ
    • fundを受け付けているnpmライブラリ

実際の支援OSSの選定には、このスクリプトの結果以外にも様々の観点を考慮しましたが、定量的な情報があると選定理由を示しやすくなります。

リポジトリはこちら => https://github.com/yamatatsu/fundable-oss-summarizer

まえがき

実は今回このブログを書くにあたって、作成当時のコードを見直していたのですが、「ちょっとかっこ悪いな」と思うコードが散見されたのでリファクタリングしました。。。

要件

要件は端的に言うと以下の2点です

  • package-lock.jsonをもとに、依存しているライブラリの支援用のURLを集計する
  • 依存元ライブラリが依存先ライブラリと同じ作者である場合は、カウントしない

それぞれ説明します。

package-lock.jsonをもとに、依存しているライブラリの支援用のURLを集計する

npmのlockファイルであるpackage-lock.jsonには、依存しているライブラリの支援用のURLが記載されています。

npm fund --jsonというコマンドを実行することで、支援を受け付けているライブラリの情報がJSON形式で取得できます。
これはTSの型で表現すると以下のような形です。

type FundData = {
  funding: Funding | Funding[];
  dependencies?: Record<string, FundData>;
};
type Funding = { url: string };

FundDataが再帰している点がポイントです。
このjsonを読んで、以下のように支援先URLごとの依存数を集計したいと思います。

output:

type Result = Record<string, number>

依存元ライブラリが依存先ライブラリと同じ作者である場合は、カウントしない

ライブラリ作者が自分自身のライブラリを利用している場合は、支援先URLごとの依存数にカウントしないようにします。

例えば以下のようなnpm fund --jsonの結果があると仮定します。

サンプルjson:

{
  "ownerA_lib4_parent": {
    "funding": { "url": "https://example.com/ownerA1" },
    "dependencies": {
      "ownerA_lib5_same_owner": {
        "funding": { "url": "https://example.com/ownerA1" }
      },
      "ownerB_lib1_other_owner": {
        "funding": { "url": "https://example.com/ownerB1" },
        "dependencies": {
          "ownerA_lib6_same_as_grandparent": {
            "funding": { "url": "https://example.com/ownerA1" }
          }
        }
      }
    }
  }
}

このとき、それぞれのライブラリについては以下のように考えます。

  • ownerA_lib4_parent: プロジェクトがトップレベルで依存しているライブラリ。カウントする。
  • ownerA_lib5_same_owner: 依存元ライブラリownerA_lib4_parentと同じ作者であるため、カウントしない。
  • ownerB_lib1_other_owner: 依存元ライブラリownerA_lib4_parentとは異なる作者であるため、カウントする。
  • ownerA_lib6_same_as_grandparent: 依存元を辿ると同一作者も現れるが、依存元ライブラリownerA_lib4_parentとは異なる作者であるため、カウントする。

以上、これらの要件をもとにスクリプトを作成しました。

スクリプトの説明

言語はTS、ランタイムはDenoを選択しました。

main.ts

import { walk } from "https://deno.land/std@0.149.0/fs/walk.ts";
import { countByFundingUrl, FundData, Result } from "./lib.ts";

type FundJSON = {
  dependencies: Record<string, FundData>;
};

let result: Result = {};

for await (const entry of walk("./jsons", { includeDirs: false })) {
  const json: FundJSON = JSON.parse(Deno.readTextFileSync(entry.path));
  const _result = countByFundingUrl(json.dependencies);
  result = merge(result, _result);
}

console.info(Object.entries(result).sort((a, b) => b[1] - a[1]));

Denoのwalkが便利です。このためにDenoを選択したまである。
(そこまでメモリを気にする必要もないけど)1ファイルずつ展開するのでメモリに優しいです。

main.ts#merge()

function merge(a: Result, b: Result): Result {
  return Object.entries(b).reduce(
    (acc, [key, value]) => ({ ...acc, [key]: (acc[key] ?? 0) + value }),
    a,
  );
}

このコードは github copilot が勝手に書きました。
main.tsの他の部分(merge()を使っている箇所も含む)を書いたあとでfunction merge()まで書けばあとは勝手に書いてくれます。いい時代になりましたね。

lib.ts#countByFundingUrl()

lib.tsのなかに入っていきます。

export type FundData = {
  funding: Funding | Funding[];
  dependencies?: Record<string, FundData>;
};
type Funding = { url: string };
export type Result = Record<string, number>;

export function countByFundingUrl(
  dependencies: Record<string, FundData>,
): Result {
  return count(flattenAndFilter(dependencies));
}

flattenAndFilter()してcount()してますね。へー。

lib.ts#flattenAndFilter()

これが大事なところですね。「親が同作者ならカウントしない」を達成するためにflatten処理とfilter処理を統合した関数にしました。

/**
 * flatten dependencies and filter it that is used by same maintainer
 * @param dependencies
 * @param parentUrl
 * @returns
 */
function flattenAndFilter(
  dependencies?: Record<string, FundData>,
  parentUrl?: string,
): string[] {
  if (!dependencies) return [];
  return Object.values(dependencies).flatMap(
    (fundData) => {
      const url = getFundingUrl(fundData);
      const flattened = flattenAndFilter(fundData.dependencies, url);
      return url === parentUrl ? flattened : [url, ...flattened];
    },
  );
}

これは流石に github copilot には書いてもらえませんでした。
でもflattenしつつfilterするのもArray.prototype.flatMap()があるから簡単にかけます。いい時代になりましたね。

lib.ts#getFundingUrl()

これも github copilot には書いてもらえませんでした。たぶん。(当時のままなのでよく覚えてない。。)
「同じ作者なのに funding url が表記ゆれする。。。」という課題を対応するためのコードに当時の試行錯誤が伺えますね。えもい。

function getFundingUrl(fundData: FundData): string {
  const url = Array.isArray(fundData.funding)
    ? fundData.funding[0].url
    : fundData.funding.url;

  // https://github.com/sponsors/xxxxx => https://github.com/xxxxx
  if (url.startsWith("https://github.com/sponsors")) {
    return url.replace(/\/sponsors/, "");
  }
  // https://github.com/xxxxx/yyyyy?sponsor=1 => https://github.com/chalk/xxxxx
  if (url.startsWith("https://github.com")) {
    return (url.match(/^https\:\/\/github\.com\/[^/]+/) ?? [""])[0];
  }
  return url;
}

lib.ts#count()

function count(arr: string[]): Result {
  return arr.reduce(
    (acc, str) => ({ ...acc, [str]: (acc[str] ?? 0) + 1 }),
    {} as Result,
  );
}

これもほとんど github copilot に書いてもらえました。いい時代になりましたね。

countBy,groupBy,chunk,mapValuesなど、よくある関数の名前を暗記してるとgithub copilotに手伝ってもらいやすいです。

おわりに

以上、fundを受け付けているnpmパッケージを集計するスクリプトを紹介しました。

ご参考までに。