設定画面の一番下に「ライセンス」の項目を置くかどうかで、半日ほど手が止まりました。
置いたほうがよいことは分かっています。分からないのは、そこに何を並べるのかです。AI ビルダーが書き出したプロジェクトには、自分で選んだ覚えのないパッケージが数百件入っています。全部載せるのか、実際に配布されるものだけでよいのか。その線引きを決めないままテンプレートを貼っても、それは表示したふりにしかなりません。
そこで、運用している Next.js 16 の本番プロジェクトの lockfile を、実際に分類してみました。先に結論を書きます。最初に肝を冷やした LGPL の14件は、1件も配布物に入っていませんでした。そして本当に表示が要る依存は、lockfile の dev / prod フラグを見るだけでは確定しませんでした。
lockfile をそのまま数えるところから始める
package-lock.json の lockfileVersion: 3 は、パッケージごとに license フィールドを持っています。node_modules を展開しなくても、lockfile 単体で全依存のライセンスを集計できます。
実際に走らせたスクリプトがこれです。標準ライブラリだけで動きます。
# license_inventory.py — lockfile 単体で依存のライセンスを分類する
import json, collections, sys
path = sys.argv[ 1 ] if len (sys.argv) > 1 else "package-lock.json"
with open (path, encoding = "utf-8" ) as f:
data = json.load(f)
packages = data.get( "packages" , {})
if not packages:
# lockfileVersion 1 系は packages を持たないため、ここで気づけるようにする
raise SystemExit ( "packages がありません。lockfileVersion 2 以上で生成し直してください" )
def classify (meta):
# 判定の順番が重要。dev を先に見ないと devOptional が prod 側へ流れる
if meta.get( "dev" ):
return "dev"
if meta.get( "devOptional" ):
return "devOptional"
if meta.get( "optional" ):
return "optional"
return "prod"
counts = collections.Counter()
by_class = collections.defaultdict(collections.Counter)
unknown = []
for name, meta in packages.items():
if not name: # ルートプロジェクト自身は空文字キー。除外しないと1件ずれる
continue
kind = classify(meta)
counts[kind] += 1
lic = meta.get( "license" )
if isinstance (lic, list ): # 古いパッケージは配列で持つことがある
lic = " / " .join(lic)
if lic is None :
unknown.append(name)
lic = "(未記載)"
by_class[kind][lic] += 1
print ( "総数:" , sum (counts.values()), dict (counts))
for kind in ( "prod" , "optional" , "dev" , "devOptional" ):
if by_class[kind]:
print ( f "--- { kind } ---" )
for lic, n in by_class[kind].most_common():
print ( f " { n :4d } { lic } " )
print ( "ライセンス未記載:" , len (unknown))
for name in unknown[: 20 ]:
print ( " " , name)
出力はこうなりました。
分類 件数 意味
prod 430 本番依存として解決されたもの
dev 429 ビルド・テスト・リンタ側でしか使われないもの
optional 70 プラットフォーム別のバイナリなど、環境次第で入らないもの
devOptional 3 開発側かつ任意
総数932件。prod と dev がほぼ半々で、それぞれ全体の46%を占めています。copyleft を含む記載は27件、全体の2.9%でした。ライセンス未記載は0件です。
未記載が0件だったのは、正直なところ意外でした。数百件も抱えていれば数件は欠けているものと思っていたのですが、少なくともこのプロジェクトでは全件に記載がありました。個人開発の規模でも、依存の出どころ自体は思ったより整っているようです。
慌てた copyleft は、全部ビルド専用だった
分類の内訳を見て最初に目に入ったのが、LGPL-3.0-or-later の10件と Apache-2.0 AND LGPL-3.0-or-later の4件でした。合計14件。手が止まるには十分な数です。
中身を出してみると、全部が画像処理ライブラリ sharp のプラットフォーム別バイナリ(@img/sharp-libvips-*)でした。そして分類は14件とも dev です。
同じことが MPL-2.0 の13件にも起きていました。内訳は CSS 変換の lightningcss とそのプラットフォーム別バイナリ、それとアクセシビリティ検査の axe-core。こちらも全件 dev でした。
ライセンス 件数 分類 実体
LGPL-3.0-or-later(単独および複合) 14 すべて dev sharp の libvips バイナリ
MPL-2.0 13 すべて dev lightningcss / axe-core
CC-BY-4.0 1 prod caniuse-lite
つまり、名前で検索して身構えた14件は、ユーザーの端末に届くものではありませんでした。画像を変換したのはビルドマシンの上で、変換結果だけが配布されています。
ここで気づいたことがあります。ライセンスの棚卸しを「危険な名前を探す作業」として始めると、最初に見つかるのは高い確率でビルドツールです。ビルドツールは開発機の上で動く都合上、ネイティブバイナリを抱えやすく、ネイティブバイナリは copyleft を持ちやすいからです。探し方の順番が逆だと、いちばん関係のないところで時間を使います。
MPL-2.0 は実行形式で配布する場合に、対応するソースコード形式の入手方法を受領者へ知らせることを求めています(Mozilla Public License 2.0 )。裏を返せば、配布していないものについてこの条項を気にする必要はありません。まず「配布しているか」を確定させるほうが先でした。
dev / prod の分類は、配布物に入るかどうかとは別の話
では prod の430件をそのまま載せればよいのか。ここも違いました。
prod に分類されていた caniuse-lite は CC-BY-4.0 です。ブラウザ対応表のデータセットで、実行時に読み込まれるものではありません。ビルド時に参照されて、成果物には結果しか残りません。それでも npm の依存グラフ上は本番依存として解決されます。
prod フラグが意味するのは「本番用の依存グラフに含まれる」であって、「配布物のバイトに含まれる」ではありません。この2つを同じものとして扱うと、載せなくてよいものを載せ、載せるべきものを取りこぼします。
私は3層に分けて考えることにしました。
層 判断の材料 ライセンス表示の扱い
1. 依存グラフに存在する lockfile の分類 候補として拾うだけ。ここでは決めない
2. 成果物のバイトに入る バンドル出力・ネイティブの依存記述 表示の対象。ここが本体
3. 実行時に読み込まれる 実機での動作 層2に含まれるため、追加の判断は不要
層1から層2へ落とすところが、手を動かさないと埋まりません。ここを飛ばして「lockfile を全部載せる」で済ませているプロジェクトが多いのは、たぶんこの手間のせいです。全部載せても義務違反にはなりませんが、ビルドツールの著作権表示が延々と並ぶ画面は、読む人にとって意味がありません。
Expo で書き出したプロジェクトで層2を確定させる
Rork や Rork Max が書き出すプロジェクトも、npm の世界にいる点は同じです。違うのは、成果物が JS バンドルとネイティブバイナリの2つに分かれることです。
JS 側は、実際にバンドルを出力してから中身を見るのが確実です。
# 1) 本番と同じ設定でバンドルを書き出す
npx expo export --platform ios --output-dir ./dist-audit
# 2) どのモジュールがバンドルへ入ったかを source map から拾う
# 出力される .map の sources には、取り込まれたファイルのパスが並びます
node -e "
const fs=require('fs');
const path=process.argv[1];
const map=JSON.parse(fs.readFileSync(path,'utf8'));
const pkgs=new Set();
for (const s of map.sources) {
const m = s.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
if (m) pkgs.add(m[1]);
}
console.log([...pkgs].sort().join('\n'));
console.error('取り込まれたパッケージ数: ' + pkgs.size);
" ./dist-audit/_expo/static/js/ios/ * .map
ここで出てくる一覧が層2の JS 側です。lockfile の prod 430件とは一致しません。取り込まれていないパッケージは、依存として解決されていてもバンドルには入らないためです。
ネイティブ側は別経路です。iOS なら ios/Podfile.lock、Android なら Gradle の解決結果が対象になります。
# iOS: 実際にリンクされた Pod の一覧
grep -A200 '^PODS:' ios/Podfile.lock | sed -n 's/^ - \([^ (]*\).*/\1/p' | sort -u
# Android: 実行時 classpath に載る依存だけを出す
cd android && ./gradlew app:dependencies --configuration releaseRuntimeClasspath
App Store と Google Play の両方へ出す場合は、当然ながら両方のネイティブ依存が対象になります。片方だけ数えて済ませると、もう一方の配布物に入っている依存が表示から抜け落ちます。
出力は SDK のバージョンと導入済みモジュールで変わるため、数値は各自の環境で取ってください。私の環境の数字を写しても意味がない部分です。
手を動かして分かったつまずきどころ
3点ありました。
1つ目は、source map が出ていないと層2の JS 側が取れないことです。本番向けの書き出しで source map を無効にしている構成があり、その場合は監査のときだけ有効にして書き出す必要があります。監査用の成果物は配布用と別の場所に出して、確認後に破棄しています。
2つ目は、optional の依存が環境によって入れ替わることです。プラットフォーム別のバイナリは、手元の macOS と CI の Linux で解決結果が変わります。手元だけで数えると、CI が引き込んだ依存を取りこぼします。棚卸しは配布物を実際に作る場所、つまり本番と同じビルド環境で走らせるのが確実でした。
3つ目は、lockfileVersion が古い場合です。1系には packages が存在しないため、先のスクリプトはエラーを出して止まります。回避策として lockfile を作り直す方法はありますが、依存の解決結果そのものが変わる可能性があります。リリース直前に触る作業ではありません。私はこのときの監査を次のリリースサイクルへ回しました。
表示義務の読み方を、載せる前に決めておく
層2が確定したら、次はライセンスごとに何を書くかです。ここは条文を読むのが結局いちばん早いという結論になりました。
ライセンス 配布時に求められること 実務上の置き場所
MIT / ISC / BSD 系 著作権表示と許諾条文を残すこと ライセンス画面に全文を収める
Apache-2.0 条文に加え、NOTICE ファイルがある場合はその中の表示を読める形で含めること ライセンス画面に NOTICE の内容も併記
MPL-2.0 実行形式で配る場合、ソースコード形式の入手方法を知らせること 層2に入っていれば個別の対応が必要
CC-BY-4.0 クレジット表示 層2に入っていれば表示、入っていなければ不要
Apache-2.0 の NOTICE の扱いは条文の第4条(d)に書かれています。派生物を配布する場合、NOTICE に含まれる表示を読める形で含める場所として、NOTICE ファイル・ソースまたは文書・成果物が生成する表示のいずれかが挙げられています(Apache License, Version 2.0 )。「成果物が生成する表示」に、アプリのライセンス画面が該当します。設定画面にライセンス項目を置く実務的な根拠は、この一文です。
なお私は法律の専門家ではありません。ここに書いたのは条文と自分のプロジェクトを突き合わせた読み方であって、判断が必要な場面では専門家に確認してください。特に copyleft のライブラリが層2に入っている場合は、自己判断で進めないほうが安全だと考えています。
結局、設定画面には何を置いたか
私が決めた基準はこうです。
層2に入った依存だけを載せる。層1で止まったものは載せない
一覧は手で書かず、ビルドの成果物から毎回生成する
copyleft が層2に現れたら、リリース前に個別に確認する。ここだけは自己判断で流さないことをお勧めします
2番目が要点でした。手で書いた一覧は、依存を1つ足した翌週から嘘になります。生成の手間を惜しんだ結果として不正確な表示を出すくらいなら、表示しないほうがまだ誠実です。だから生成できる形にするまでは項目を置きませんでした。
生成は、層2の一覧を作る処理をビルドスクリプトに足して、JSON をアプリのアセットとして同梱する形に落ち着きました。画面側は JSON を読んで並べるだけです。設定画面そのものの組み立て方はRork で作ったアプリに「ちゃんとした設定画面」を実装する で扱っているので、置き場所に迷う場合はそちらを先に見ていただくとつながりが分かりやすいと思います。
3番目は、CI に検出だけ入れました。止める判断は人がします。層2に copyleft が入る変更は、そもそも依存を1つ足したという以上の意味を持つことが多いためです。
この先に手を動かすなら
まず自分のプロジェクトの package-lock.json に対して、この記事の集計スクリプトを1回走らせてみてください。分類の内訳を見るだけで、自分が何を抱えているのかの見当がつきます。層2の確定はそのあとで構いません。
私自身、ライセンスの扱いは「面倒な後回しの作業」だと長らく思っていました。実際に数えてみると、面倒だったのは作業ではなく、線引きを決めていなかったことのほうでした。同じところで止まっている方の参考になれば嬉しいです。