#!/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 @: * 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(); }