File size: 4,739 Bytes
7acc713
 
 
 
 
 
 
 
 
 
 
 
 
 
1a6d0d2
 
 
 
 
7acc713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
const fs = require("node:fs");
const path = require("node:path");

const ALLOWED = new Set([
  "MIT",
  "Apache-2.0",
  "BSD-2-Clause",
  "BSD-3-Clause",
  "ISC",
  "Unlicense",
  "CC0-1.0",
  "0BSD",
  "LGPL-2.1",
  "LGPL-3.0",
  // MPL-2.0 is file-level copyleft; combining it with Apache-2.0 in one
  // program is explicitly permitted by Mozilla's own FAQ (Q13,
  // https://www.mozilla.org/en-US/MPL/2.0/FAQ/). Ships via vite's
  // lightningcss binaries, unmodified.
  "MPL-2.0",
]);

// Each entry below was verified by hand against the LICENSE file shipped in
// that package's own node_modules directory. A new opaque-license package
// must be added here ONLY after a human has read its license file.
const OVERRIDE = {
  flatbuffers: "Apache-2.0",
  "keyword-extractor": "MIT",
};

const OPAQUE = "SEE LICENSE IN LICENSE.txt";

// OR = any side may be allowed; AND = every side must be allowed.
function isAllowed(license) {
  if (typeof license !== "string") return false;
  if (ALLOWED.has(license)) return true;
  if (license.includes(" OR ")) {
    return license
      .split(" OR ")
      .some((side) => ALLOWED.has(side.replace(/^\(|\)$/g, "").trim()));
  }
  if (license.includes(" AND ")) {
    return license
      .split(" AND ")
      .every((side) => ALLOWED.has(side.replace(/^\(|\)$/g, "").trim()));
  }
  return false;
}

// A missing license field and the opaque "SEE LICENSE IN LICENSE.txt" field
// are only allowed via the OVERRIDE map, which a human must populate after
// reading the package's own license. Any other package with such a field
// fails the gate (safety net).
function isAllowedLicense(name, license) {
  if (license === undefined || license === OPAQUE) {
    return isAllowed(OVERRIDE[name]);
  }
  return isAllowed(license);
}

function productionPackages(lock) {
  const packages = [];
  for (const [key, entry] of Object.entries(lock.packages ?? {})) {
    if (key === "") continue;
    if (entry.dev) continue;
    if (entry.link) continue;
    const name = key.split("node_modules/").pop();
    packages.push({
      name,
      version: entry.version ?? "unknown",
      license: entry.license,
    });
  }
  return packages;
}

function formatLicense(license) {
  return typeof license === "string" ? license : "no license field";
}

function writeReport(reportPath, packages) {
  const rows = [...packages].sort((a, b) => a.name.localeCompare(b.name));
  const lines = [
    "# Dependency Licenses",
    "",
    `Project license: **Apache-2.0**`,
    "",
    "| Package | License |",
    "| --- | --- |",
    ...rows.map((p) => `| ${p.name} | ${formatLicense(p.license)} |`),
    "",
  ];
  fs.mkdirSync(path.dirname(reportPath), { recursive: true });
  fs.writeFileSync(reportPath, lines.join("\n"), "utf8");
}

function run(lockPath, reportPath) {
  const lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
  const packages = productionPackages(lock);
  const violations = packages.filter(
    (p) => !isAllowedLicense(p.name, p.license),
  );

  if (violations.length > 0) {
    for (const v of violations) {
      console.log(
        `NOT ALLOWED: ${v.name}@${v.version} (${formatLicense(v.license)})`,
      );
    }
    console.log(
      `\n${violations.length} of ${packages.length} production packages have a license that is not compatible with Apache-2.0`,
    );
    if (reportPath) writeReport(reportPath, packages);
    return 1;
  }

  console.log(
    `All ${packages.length} production packages have a license compatible with Apache-2.0`,
  );
  if (reportPath) writeReport(reportPath, packages);
  return 0;
}

if (require.main === module) {
  const args = process.argv.slice(2);
  const usage =
    "Usage: node scripts/license-check.cjs [lockfilePath] [--report <path>]";

  let lockfilePath;
  let reportPath;

  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--report") {
      reportPath = args[++i];
      if (!reportPath) {
        console.error(`❌ --report requires a path\n   ${usage}`);
        process.exit(1);
      }
    } else if (args[i].startsWith("-")) {
      console.error(`❌ Unknown option: ${args[i]}\n   ${usage}`);
      process.exit(1);
    } else if (lockfilePath === undefined) {
      lockfilePath = args[i];
    } else {
      console.error(`❌ Too many arguments\n   ${usage}`);
      process.exit(1);
    }
  }

  const rootDir = path.resolve(__dirname, "..");
  lockfilePath = lockfilePath
    ? path.resolve(lockfilePath)
    : path.join(rootDir, "package-lock.json");

  if (!fs.existsSync(lockfilePath)) {
    console.error(`❌ Lockfile not found: ${lockfilePath}`);
    process.exit(1);
  }

  process.exit(run(lockfilePath, reportPath));
}

module.exports = { run, isAllowedLicense, ALLOWED, OVERRIDE };