blob: 173d9bf660cdc50267fcd511278cd5be1766bd0a [file]
#!/usr/bin/env node
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Render or validate license-checker-rseidelsohn JSON output.
//
// Usage:
// render-node-licenses.mjs <license-checker-json> render mode
// render-node-licenses.mjs --validate-only <json> validate mode
//
// Render mode emits a LICENSE-binary manifest on stdout, embedding the
// full LICENSE file text per package.
//
// Validate mode parses each package's SPDX expression, compares it
// against the allow-list below, and exits non-zero on disallowed
// licenses with a per-package report on stderr. Disjunctions (`OR`)
// pass if ANY operand is in the allow-list, matching cargo-about's
// permissive disjunction semantics. Conjunctions (`AND`) require ALL
// operands to be in the allow-list. Unknown / UNLICENSED licenses
// fail.
import { readFileSync, existsSync } from 'node:fs';
// Allow-list mirrors about.toml (Apache Iggy / ASF policy). Update both
// in lockstep when adding a new accepted license. SPDX identifiers
// only; expressions are tokenised before matching against this list.
const ALLOWED_LICENSES = new Set([
'Apache-2.0',
'Apache-2.0 WITH LLVM-exception',
'MIT',
'BSD-2-Clause',
'BSD-3-Clause',
'BSD-3-Clause-Clear',
'ISC',
'Zlib',
'Unicode-3.0',
'Unicode-DFS-2016',
'MPL-2.0',
'CC0-1.0',
'OpenSSL',
'BSL-1.0',
'NCSA',
// Common npm-only SPDX identifiers used by widely-bundled packages.
// CC-BY-4.0 is a Cat-A documentation license; some npm packages
// declare it for embedded asset metadata. Acceptable per ASF
// resolved.html.
'CC-BY-4.0',
'0BSD',
]);
const args = process.argv.slice(2);
const validateOnly = args[0] === '--validate-only';
const inputPath = validateOnly ? args[1] : args[0];
if (!inputPath) {
console.error('Usage: render-node-licenses.mjs [--validate-only] <license-checker-json>');
process.exit(1);
}
const data = JSON.parse(readFileSync(inputPath, 'utf8'));
if (validateOnly) {
validate(data);
} else {
render(data);
}
function normalizeIdent(ident) {
// license-checker-rseidelsohn appends `*` to identifiers it inferred
// heuristically (e.g. `MIT*` when the SPDX expression is missing but
// a LICENSE-MIT file was found). Treat them as the underlying SPDX
// for allow-list comparison.
return ident.trim().replace(/\*$/, '');
}
function tokenizeSpdx(expr) {
// Strip parens, split on AND/OR, drop empty tokens. Preserve
// operator structure by returning a flat list along with the
// outermost operator found. This is a best-effort parser; cargo-about
// does the same simplification (full SPDX evaluator is overkill here
// and the ASF only requires that AT LEAST one acceptable license
// applies).
const cleaned = String(expr).replace(/[()]/g, ' ').trim();
if (!cleaned) return { op: 'NONE', operands: [] };
const hasOr = /\bOR\b/i.test(cleaned);
const hasAnd = /\bAND\b/i.test(cleaned);
// If only one operator type appears, split on it.
if (hasOr && !hasAnd) {
return {
op: 'OR',
operands: cleaned.split(/\s+OR\s+/i).map(s => s.trim()).filter(Boolean),
};
}
if (hasAnd && !hasOr) {
return {
op: 'AND',
operands: cleaned.split(/\s+AND\s+/i).map(s => s.trim()).filter(Boolean),
};
}
if (hasOr && hasAnd) {
// Mixed expression. Treat conservatively: split on OR first, each
// operand may contain AND. The expression passes if any OR operand
// (a conjunction) is fully allowed.
return {
op: 'OR',
operands: cleaned.split(/\s+OR\s+/i).map(s => s.trim()).filter(Boolean),
};
}
return { op: 'SINGLE', operands: [cleaned] };
}
function isAllowed(licensesField) {
if (!licensesField) return false;
const value = Array.isArray(licensesField) ? licensesField.join(' OR ') : String(licensesField);
if (!value) return false;
if (/^UNKNOWN$|^UNLICENSED$/i.test(value.trim())) return false;
const { op, operands } = tokenizeSpdx(value);
if (operands.length === 0) return false;
switch (op) {
case 'OR':
return operands.some(operand => {
// Each operand may itself be an AND expression.
if (/\bAND\b/i.test(operand)) {
return operand.split(/\s+AND\s+/i).every(t => ALLOWED_LICENSES.has(normalizeIdent(t)));
}
return ALLOWED_LICENSES.has(normalizeIdent(operand));
});
case 'AND':
return operands.every(t => ALLOWED_LICENSES.has(normalizeIdent(t)));
case 'SINGLE':
return ALLOWED_LICENSES.has(normalizeIdent(operands[0]));
default:
return false;
}
}
function validate(pkgs) {
const violations = [];
for (const [pkg, info] of Object.entries(pkgs)) {
if (!isAllowed(info.licenses)) {
violations.push({ pkg, licenses: info.licenses ?? '(unknown)' });
}
}
if (violations.length > 0) {
console.error(`Disallowed npm license(s) detected (${violations.length} package(s)):`);
for (const v of violations) {
console.error(` - ${v.pkg}: ${v.licenses}`);
}
console.error('');
console.error('Allowed SPDX identifiers are listed at the top of');
console.error('scripts/ci/render-node-licenses.mjs (mirrors about.toml).');
process.exit(1);
}
console.log(`OK: ${Object.keys(pkgs).length} npm package(s) validated against the SPDX allow-list.`);
}
function render(pkgs) {
const out = [];
out.push('Apache Iggy (incubating) - Third-Party License Manifest (Node)');
out.push('================================================================');
out.push('');
out.push('The following npm production dependencies are bundled into this');
out.push('Apache Iggy (incubating) convenience binary artifact. Packages are');
out.push('grouped by license; the canonical license text appears once per');
out.push('group as required by the Apache Software Foundation release policy.');
out.push('');
out.push('Generated by license-checker-rseidelsohn from the package manifest');
out.push('at build time. Does not apply to the official Apache source release.');
out.push('');
// Group packages by license. Use the raw `licenses` field as the
// grouping key so that compound expressions ("(MIT OR Apache-2.0)")
// and Custom URLs each form their own bucket. Within a bucket the
// first package's license file text is treated as canonical; the
// others just contribute to the USED BY listing.
const groups = new Map();
for (const [pkg, info] of Object.entries(pkgs).sort()) {
const key = String(info.licenses ?? '(unknown)');
if (!groups.has(key)) {
groups.set(key, { canonicalText: null, members: [] });
}
const g = groups.get(key);
if (g.canonicalText === null && info.licenseFile && existsSync(info.licenseFile)) {
g.canonicalText = readFileSync(info.licenseFile, 'utf8').trimEnd();
}
g.members.push({ pkg, info });
}
// Emit groups in stable order (sorted by SPDX key).
for (const [licenseKey, group] of Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
out.push('================================================================================');
out.push(`LICENSE: ${licenseKey} - used by ${group.members.length} package(s)`);
out.push('USED BY:');
for (const { pkg, info } of group.members) {
let line = ` - ${pkg}`;
if (info.repository) line += ` (${info.repository})`;
out.push(line);
}
out.push('================================================================================');
out.push('');
out.push(group.canonicalText ?? '(no license text available; see SPDX identifier above)');
out.push('');
out.push('');
}
process.stdout.write(out.join('\n'));
}