Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-licenses.mjs
T

312 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* License-compliance gate.
*
* Enumerates production `dependencies` declared in the root + workspace
* package.json manifests, locates each dep's installed package.json under
* `node_modules`, and classifies its SPDX license expression against:
*
* - ALLOWED_LICENSES permissive / Apache-style licenses we accept.
* - BLOCKED_LICENSES copyleft / source-available families we refuse.
* - scripts/license-baseline.json reviewed exceptions
* (specific `name@version` pairs).
*
* Unknown SPDX strings are surfaced as warnings (manual review required)
* but do not block. Blocked-list matches always fail with exit code 1.
*
* Output (single line on success):
* PASS scanned=N allowed=M reviewed=K unknown=L
*
* Output on failure (one line per offender + summary):
* FAIL <name>@<version>: <license>
* FAIL scanned=N allowed=M reviewed=K unknown=L blocked=B
*/
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..");
const BASELINE_PATH = path.join(__dirname, "license-baseline.json");
export const ALLOWED_LICENSES = new Set([
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"0BSD",
"Unlicense",
"CC0-1.0",
"(MIT OR Apache-2.0)",
"(MIT OR CC0-1.0)",
"BlueOak-1.0.0",
"WTFPL",
]);
export const BLOCKED_LICENSE_PATTERNS = [
/^GPL-/i,
/^AGPL-/i,
/^SSPL-/i,
/Commons-Clause/i,
/^LGPL-/i,
];
/**
* Returns the workspace manifests (root + apps/* + packages/*).
*/
export function discoverWorkspaceManifests(repoRoot = REPO_ROOT) {
const roots = [path.join(repoRoot, "package.json")];
for (const group of ["apps", "packages"]) {
const dir = path.join(repoRoot, group);
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(dir, entry.name, "package.json");
if (fs.existsSync(candidate)) roots.push(candidate);
}
}
return roots;
}
/**
* Collects unique production deps from a manifest list.
* Workspace deps (`workspace:*`) are excluded — they're our own code.
*/
export function collectProductionDeps(manifestPaths) {
const seen = new Set();
for (const manifestPath of manifestPaths) {
let raw;
try {
raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch {
continue;
}
const deps = raw.dependencies ?? {};
for (const [name, version] of Object.entries(deps)) {
if (typeof version === "string" && version.startsWith("workspace:"))
continue;
seen.add(name);
}
}
return [...seen].sort();
}
/**
* Resolve a dep's installed package.json. Falls back to walking
* `node_modules` up the tree because pnpm can hoist or nest packages.
*/
export function resolveInstalledManifest(depName, repoRoot = REPO_ROOT) {
const candidate = path.join(repoRoot, "node_modules", depName, "package.json");
if (fs.existsSync(candidate)) return candidate;
// Fallback: shallow scan of .pnpm virtual store for the first match.
const pnpmDir = path.join(repoRoot, "node_modules", ".pnpm");
if (fs.existsSync(pnpmDir)) {
const scoped = depName.startsWith("@");
const escaped = depName.replace("/", "+");
for (const entry of fs.readdirSync(pnpmDir)) {
// pnpm names virtual store entries like `name@version` or
// `@scope+name@version`. We need an exact prefix match on the
// package identity so e.g. `react-router` doesn't match
// `react-router-dom`.
const head = scoped ? `${escaped}@` : `${depName}@`;
if (!entry.startsWith(head)) continue;
const nested = path.join(
pnpmDir,
entry,
"node_modules",
depName,
"package.json",
);
if (fs.existsSync(nested)) return nested;
}
}
return null;
}
/**
* Normalize an SPDX license field into a canonical string.
*
* Accepts:
* - plain string ("MIT")
* - object form ({ type: "MIT", url: "..." })
* - "licenses" array form ([{ type: "MIT" }, { type: "Apache-2.0" }])
*
* Returns null when no license is declared.
*/
export function parseLicenseField(pkg) {
if (!pkg || typeof pkg !== "object") return null;
const lic = pkg.license;
if (typeof lic === "string" && lic.trim() !== "") return lic.trim();
if (lic && typeof lic === "object" && typeof lic.type === "string") {
return lic.type.trim();
}
if (Array.isArray(pkg.licenses) && pkg.licenses.length > 0) {
const types = pkg.licenses
.map((entry) =>
entry && typeof entry === "object" && typeof entry.type === "string"
? entry.type.trim()
: null,
)
.filter(Boolean);
if (types.length === 1) return types[0];
if (types.length > 1) return `(${types.join(" OR ")})`;
}
return null;
}
/**
* Split a parenthesised SPDX expression like "(MIT OR Apache-2.0)" into
* its atoms. Leaves single-token expressions untouched. We only handle
* the OR conjunction here — AND would mean "all of these apply", which
* does not benefit from any allowlist atom.
*/
export function splitSpdxExpression(expr) {
if (typeof expr !== "string") return [];
const trimmed = expr.trim();
if (trimmed === "") return [];
// Strip a single matched pair of surrounding parens.
const unwrapped =
trimmed.startsWith("(") && trimmed.endsWith(")")
? trimmed.slice(1, -1).trim()
: trimmed;
if (!/\bOR\b/i.test(unwrapped)) return [trimmed];
return unwrapped
.split(/\s+OR\s+/i)
.map((part) => part.trim())
.filter(Boolean);
}
/**
* Classify a license string against the allow / block lists.
*
* Returns one of: "allowed" | "blocked" | "unknown".
*
* A composite OR expression is "allowed" as long as at least one of its
* atoms is on the allowlist. A blocked atom still wins, because we never
* want to import a project where the redistributor could pick GPL.
*/
export function classifyLicense(
license,
{
allowed = ALLOWED_LICENSES,
blockedPatterns = BLOCKED_LICENSE_PATTERNS,
} = {},
) {
if (license == null) return "unknown";
if (allowed.has(license)) return "allowed";
for (const pat of blockedPatterns) {
if (pat.test(license)) return "blocked";
}
const atoms = splitSpdxExpression(license);
if (atoms.length > 1) {
for (const atom of atoms) {
for (const pat of blockedPatterns) {
if (pat.test(atom)) return "blocked";
}
}
if (atoms.some((atom) => allowed.has(atom))) return "allowed";
}
return "unknown";
}
export function loadBaseline(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return {};
try {
const raw = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
} catch {
return {};
}
}
export function isReviewed(baseline, name, version) {
if (!baseline) return false;
const key = `${name}@${version}`;
return Object.prototype.hasOwnProperty.call(baseline, key);
}
/**
* Run the audit. Returns a result object — does not exit or print.
* The CLI shell at the bottom of this file handles I/O.
*/
export function auditLicenses({
repoRoot = REPO_ROOT,
baseline = loadBaseline(),
allowed = ALLOWED_LICENSES,
blockedPatterns = BLOCKED_LICENSE_PATTERNS,
} = {}) {
const manifests = discoverWorkspaceManifests(repoRoot);
const deps = collectProductionDeps(manifests);
const counts = { scanned: 0, allowed: 0, reviewed: 0, unknown: 0, blocked: 0 };
const blocked = [];
const unknown = [];
const missing = [];
for (const name of deps) {
counts.scanned += 1;
const manifestPath = resolveInstalledManifest(name, repoRoot);
if (!manifestPath) {
missing.push(name);
counts.unknown += 1;
unknown.push({ name, version: "(not installed)", license: null });
continue;
}
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch {
counts.unknown += 1;
unknown.push({ name, version: "(unreadable)", license: null });
continue;
}
const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
const license = parseLicenseField(pkg);
if (isReviewed(baseline, name, version)) {
counts.reviewed += 1;
continue;
}
const verdict = classifyLicense(license, { allowed, blockedPatterns });
if (verdict === "allowed") counts.allowed += 1;
else if (verdict === "blocked") {
counts.blocked += 1;
blocked.push({ name, version, license });
} else {
counts.unknown += 1;
unknown.push({ name, version, license });
}
}
return { counts, blocked, unknown, missing };
}
function formatLine(prefix, entry) {
return `${prefix} ${entry.name}@${entry.version}: ${entry.license ?? "(no license field)"}`;
}
function main() {
const result = auditLicenses();
const { counts, blocked, unknown } = result;
for (const entry of blocked) {
console.error(formatLine("FAIL", entry));
}
for (const entry of unknown) {
console.warn(formatLine("WARN", entry));
}
if (blocked.length > 0) {
console.error(
`FAIL scanned=${counts.scanned} allowed=${counts.allowed} reviewed=${counts.reviewed} unknown=${counts.unknown} blocked=${counts.blocked}`,
);
process.exit(1);
}
console.log(
`PASS scanned=${counts.scanned} allowed=${counts.allowed} reviewed=${counts.reviewed} unknown=${counts.unknown}`,
);
}
if (import.meta.url === url.pathToFileURL(process.argv[1] ?? "").href) {
main();
}