3. 从 package.json 提取依赖许可证信息

从 package.json 提取依赖许可证信息 学习卡片

为什么不能只读 package.json 的 dependencies

Node.js 项目的 package.json 只声明直接依赖及其版本范围,而每个直接依赖又会引入自己的依赖,形成依赖树。许可证风险往往藏在间接依赖里,因此需要同时解析 dependencies、devDependencies、peerDependencies、optionalDependencies,再结合已安装的 node_modules/<pkg>/package.json 递归展开,才能得到完整的依赖许可证清单。

package.json 依赖树 → 许可证清单提取流程 项目根 package.json dependencies devDependencies 依赖树解析器 读取 node_modules 递归展开子依赖 license 字段提取 license / licenses SPDX 表达式 依赖树展开示意(示例) my-app (root) express@4.18.2 lodash@4.17.21 axios@1.6.0 body-parser@1.20 cookie@0.5.0 follow-redirects@1.15 form-data@4.0 直接依赖 间接依赖 根项目

提取脚本实现

以下脚本读取项目根 package.json,遍历所有依赖类型,再递归读取 node_modules 中每个包的 package.json,提取 license 或 licenses 字段,输出结构化清单。

// extract-licenses.js
const fs = require('fs');
const path = require('path');

const ROOT = process.cwd();
const DEP_TYPES = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];

function readJson(p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
  catch { return null; }
}

function normalizeLicense(pkg) {
  if (!pkg) return { license: 'UNKNOWN', source: 'missing-package' };
  if (typeof pkg.license === 'string') return { license: pkg.license, source: 'license' };
  if (pkg.license && typeof pkg.license === 'object') return { license: pkg.license.type || 'UNKNOWN', source: 'license.type' };
  if (Array.isArray(pkg.licenses)) {
    const types = pkg.licenses.map(l => (typeof l === 'string' ? l : l.type)).filter(Boolean);
    return { license: types.join(' OR ') || 'UNKNOWN', source: 'licenses[]' };
  }
  return { license: 'UNKNOWN', source: 'no-license-field' };
}

function collectDeps(pkgJson) {
  const set = new Set();
  for (const t of DEP_TYPES) {
    const d = pkgJson[t];
    if (d && typeof d === 'object') Object.keys(d).forEach(n => set.add(n));
  }
  return [...set];
}

function resolveInstalled(name) {
  const p = path.join(ROOT, 'node_modules', name, 'package.json');
  return readJson(p);
}

function walk(name, visited, results, depth) {
  if (visited.has(name)) return;
  visited.add(name);
  const pkg = resolveInstalled(name);
  const { license, source } = normalizeLicense(pkg);
  results.push({ name, version: pkg ? pkg.version : 'N/A', license, source, depth });
  if (pkg) {
    const children = collectDeps(pkg);
    for (const c of children) walk(c, visited, results, depth + 1);
  }
}

const rootPkg = readJson(path.join(ROOT, 'package.json'));
if (!rootPkg) { console.error('未找到 package.json'); process.exit(1); }

const directDeps = collectDeps(rootPkg);
const visited = new Set();
const results = [];

for (const d of directDeps) walk(d, visited, results, 1);

// 输出 CSV
const header = 'name,version,license,source,depth';
const rows = results.map(r => `${r.name},${r.version},${r.license},${r.source},${r.depth}`);
console.log([header, ...rows].join('\n'));
console.error(`\n共提取 ${results.length} 个依赖包`);

运行与结果判读

在项目根目录执行 node extract-licenses.js > licenses.csv,即可得到依赖许可证清单。判读时重点关注:

  • license 为 UNKNOWN:该包未声明许可证,需人工核查其仓库或联系维护者。
  • source 为 licenses[]:使用了旧版数组格式,需确认是否可标准化为 SPDX 表达式。
  • depth 大于 1:间接依赖,容易被忽略,但同样构成合规风险。
  • license 含 OR / AND:复合表达式,需按 SPDX 规则进一步解析选择条件。
若项目使用 pnpm 或 yarn PnP,node_modules 结构不同,需改用 pnpm list --json 或 yarn info 获取依赖树后再提取许可证。
本脚本仅读取已安装的 node_modules,若依赖未安装或版本范围未锁定,提取结果可能与实际部署不一致。建议在 npm ci 后执行。